diff --git a/apps/backend/cmd/preview/sprite_ops.go b/apps/backend/cmd/preview/sprite_ops.go index d75de62447..787360f7c4 100644 --- a/apps/backend/cmd/preview/sprite_ops.go +++ b/apps/backend/cmd/preview/sprite_ops.go @@ -127,8 +127,9 @@ export KANDEV_HOME_DIR=/data export KANDEV_DOCKER_ENABLED=false export KANDEV_LOG_LEVEL=info export KANDEV_WEB_DIST_DIR=/app/apps/web/dist -# Preview mode: only register the mock agent, suppress real agent discovery. -export KANDEV_MOCK_AGENT=only +# Preview mode: keep the mock agent while loading the built-in agent catalogue +# so Settings can show agents that are available to install. +export KANDEV_MOCK_AGENT=true # The preview service runs from /app, so the backend's relative dist probes do # not reach the packaged Vite build under /app/apps/web/dist. export KANDEV_WEB_DIST_DIR=/app/apps/web/dist diff --git a/apps/backend/cmd/preview/sprite_ops_test.go b/apps/backend/cmd/preview/sprite_ops_test.go index 52dc9f4f82..8b2b30f32b 100644 --- a/apps/backend/cmd/preview/sprite_ops_test.go +++ b/apps/backend/cmd/preview/sprite_ops_test.go @@ -14,8 +14,11 @@ func TestBuildExtractScript(t *testing.T) { if !strings.Contains(script, "rm -rf /data") { t.Errorf("expected rm -rf /data in script") } - if !strings.Contains(script, "KANDEV_MOCK_AGENT=only") { - t.Errorf("expected KANDEV_MOCK_AGENT=only in script") + if !strings.Contains(script, "KANDEV_MOCK_AGENT=true") { + t.Errorf("expected KANDEV_MOCK_AGENT=true in script") + } + if strings.Contains(script, "KANDEV_MOCK_AGENT=only") { + t.Errorf("preview must retain the built-in agent catalogue") } if !strings.Contains(script, "KANDEV_WEB_DIST_DIR=/app/apps/web/dist") { t.Errorf("expected KANDEV_WEB_DIST_DIR to point at packaged Vite dist") diff --git a/apps/backend/internal/agent/settings/controller/agent_update.go b/apps/backend/internal/agent/settings/controller/agent_update.go index 1c5491f4ba..62d64c5071 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update.go +++ b/apps/backend/internal/agent/settings/controller/agent_update.go @@ -17,10 +17,52 @@ import ( ) var ( - ErrRuntimeUpdateUnsupported = errors.New("agent runtime update unsupported") - ErrRuntimeUpdaterUnavailable = errors.New("agent runtime updater unavailable") + ErrRuntimeUpdateUnsupported = errors.New("agent runtime update unsupported") + ErrRuntimeUpdaterUnavailable = errors.New("agent runtime updater unavailable") + ErrRuntimeUpdatePreviewFailed = errors.New("agent runtime update preview failed") ) +// PreviewAgentUpdate resolves the trusted built-in update recipe without +// claiming maintenance state, creating a job, or running the command. +func (c *Controller) PreviewAgentUpdate( + ctx context.Context, + name string, +) (*dto.AgentUpdatePreviewDTO, error) { + if c.runtimeUpdater == nil { + return nil, ErrRuntimeUpdaterUnavailable + } + ag, ok := c.agentRegistry.Get(name) + if !ok { + return nil, ErrAgentNotFound + } + managed, ok := ag.(agents.ManagedNPMRuntimeAgent) + if !ok { + return nil, ErrRuntimeUpdateUnsupported + } + spec := managed.ManagedNPMRuntime() + if strings.TrimSpace(spec.Package) == "" { + return nil, ErrRuntimeUpdateUnsupported + } + + current := "" + if caps, found := c.runtimeUpdater.CurrentCapabilities(name); found { + current = caps.AgentVersion + } + target, err := c.runtimeUpdater.ResolveTarget(ctx, spec.Package) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) + } + command := spec.CacheUpdateCommand().Args() + return &dto.AgentUpdatePreviewDTO{ + AgentName: name, + Package: spec.Package, + CurrentVersion: current, + TargetVersion: target, + Command: command, + CommandString: buildCommandString(command), + }, nil +} + // RuntimeUpdater is the external-process and host-probe boundary used by // managed runtime jobs. Implementations must execute commands as direct argv. type RuntimeUpdater interface { diff --git a/apps/backend/internal/agent/settings/controller/agent_update_test.go b/apps/backend/internal/agent/settings/controller/agent_update_test.go index 44f451ea88..a5e80b1ee3 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_test.go @@ -190,6 +190,86 @@ func managedRuntimeSpec() agents.ManagedNPMRuntimeSpec { } } +func TestAgentUpdatePreviewResolvesTrustedCommandWithoutStartingAJob(t *testing.T) { + updater := &fakeRuntimeUpdater{ + current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, + currentFound: true, + target: "1.1.0", + } + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: managedRuntimeSpec(), + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetRuntimeUpdater(updater) + + preview, err := ctrl.PreviewAgentUpdate(context.Background(), ag.ID()) + if err != nil { + t.Fatalf("PreviewAgentUpdate: %v", err) + } + if preview.CurrentVersion != "1.0.0" || preview.TargetVersion != "1.1.0" { + t.Fatalf("versions = %q -> %q", preview.CurrentVersion, preview.TargetVersion) + } + wantCommand := []string{ + "npm", "exec", "--yes", "--prefer-online", "--package=@example/managed-acp", "--", "node", "-e", "", + } + if got := strings.Join(preview.Command, "\x00"); got != strings.Join(wantCommand, "\x00") { + t.Fatalf("command = %q, want %q", got, strings.Join(wantCommand, "\x00")) + } + if preview.CommandString != `npm exec --yes --prefer-online --package=@example/managed-acp -- node -e ""` { + t.Fatalf("command string = %q", preview.CommandString) + } + + updater.mu.Lock() + defer updater.mu.Unlock() + if updater.resolvedPackage != "@example/managed-acp" { + t.Fatalf("resolved package = %q", updater.resolvedPackage) + } + if updater.runCalls != 0 || updater.refreshCalls != 0 { + t.Fatalf("preview mutated runtime: update=%d refresh=%d", updater.runCalls, updater.refreshCalls) + } +} + +func TestAgentUpdatePreviewRejectsUnsupportedAndResolutionFailure(t *testing.T) { + tests := []struct { + name string + agent agents.Agent + updater *fakeRuntimeUpdater + wantErr error + }{ + { + name: "unmanaged", + agent: &testAgent{id: "native", name: "Native", enabled: true}, + updater: &fakeRuntimeUpdater{}, + wantErr: ErrRuntimeUpdateUnsupported, + }, + { + name: "registry failure", + agent: &managedTestAgent{ + testAgent: testAgent{id: "managed", name: "Managed", enabled: true}, + spec: managedRuntimeSpec(), + }, + updater: &fakeRuntimeUpdater{resolveErr: errors.New("registry unavailable")}, + wantErr: ErrRuntimeUpdatePreviewFailed, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{test.agent.ID(): test.agent}) + ctrl.SetRuntimeUpdater(test.updater) + _, err := ctrl.PreviewAgentUpdate(context.Background(), test.agent.ID()) + if !errors.Is(err, test.wantErr) { + t.Fatalf("PreviewAgentUpdate error = %v, want %v", err, test.wantErr) + } + test.updater.mu.Lock() + defer test.updater.mu.Unlock() + if test.updater.runCalls != 0 || test.updater.refreshCalls != 0 { + t.Fatalf("failed preview mutated runtime: update=%d refresh=%d", test.updater.runCalls, test.updater.refreshCalls) + } + }) + } +} + func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) { updater := &fakeRuntimeUpdater{ current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 2517ca3618..7434148404 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -23,10 +23,13 @@ import ( func buildCommandString(cmd []string) string { var parts []string for _, arg := range cmd { - if strings.ContainsAny(arg, " \t\n\"'`$\\") { + switch { + case arg == "": + parts = append(parts, `""`) + case strings.ContainsAny(arg, " \t\n\"'`$\\"): escaped := strings.ReplaceAll(arg, "\"", "\\\"") parts = append(parts, "\""+escaped+"\"") - } else { + default: parts = append(parts, arg) } } diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index fe02476a5a..51193572cb 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -459,6 +459,11 @@ func TestBuildCommandString(t *testing.T) { cmd: []string{}, expected: "", }, + { + name: "empty argument", + cmd: []string{"node", "-e", ""}, + expected: `node -e ""`, + }, } for _, tt := range tests { diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 13c25fe8f7..b1b0223487 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -284,6 +284,17 @@ type AgentUpdateJobDTO struct { FinishedAt *time.Time `json:"finished_at,omitempty"` } +// AgentUpdatePreviewDTO is a read-only representation of the next managed +// runtime update. The command is derived from trusted built-in agent metadata. +type AgentUpdatePreviewDTO struct { + AgentName string `json:"agent_name"` + Package string `json:"package"` + CurrentVersion string `json:"current_version,omitempty"` + TargetVersion string `json:"target_version"` + Command []string `json:"command"` + CommandString string `json:"command_string"` +} + type ListAgentUpdateJobsResponse struct { Jobs []AgentUpdateJobDTO `json:"jobs"` } diff --git a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go index 1736a73597..e47255b1f8 100644 --- a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go @@ -136,6 +136,34 @@ func updateRequest(method, path string) *http.Request { return request } +func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) { + router, _, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) + response := httptest.NewRecorder() + router.ServeHTTP(response, updateRequest( + http.MethodGet, + "/api/v1/agent-update/claude-acp/preview", + )) + if response.Code != http.StatusOK { + t.Fatalf("GET preview status = %d, body = %s", response.Code, response.Body.String()) + } + var preview map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &preview); err != nil { + t.Fatalf("decode preview: %v", err) + } + if preview["current_version"] != "1.0.0" || preview["target_version"] != "1.1.0" { + t.Fatalf("versions = %#v", preview) + } + if preview["command_string"] != `npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e ""` { + t.Fatalf("command_string = %#v", preview["command_string"]) + } + + jobs := httptest.NewRecorder() + router.ServeHTTP(jobs, updateRequest(http.MethodGet, "/api/v1/agent-update/jobs")) + if jobs.Code != http.StatusOK || !strings.Contains(jobs.Body.String(), `"jobs":[]`) { + t.Fatalf("preview must not create a job: %d %s", jobs.Code, jobs.Body.String()) + } +} + func TestAgentUpdateEndpointsAcceptAndRetainJobs(t *testing.T) { router, _, completed := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) response := httptest.NewRecorder() @@ -235,6 +263,15 @@ func TestAgentUpdateEndpointRejectsUnmanagedAgentAndMissingJob(t *testing.T) { t.Fatalf("unsupported status = %d, body = %s", unsupported.Code, unsupported.Body.String()) } + unsupportedPreview := httptest.NewRecorder() + router.ServeHTTP(unsupportedPreview, updateRequest( + http.MethodGet, + "/api/v1/agent-update/cursor-acp/preview", + )) + if unsupportedPreview.Code != http.StatusBadRequest { + t.Fatalf("unsupported preview status = %d, body = %s", unsupportedPreview.Code, unsupportedPreview.Body.String()) + } + missing := httptest.NewRecorder() router.ServeHTTP(missing, updateRequest( http.MethodGet, diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 9199d6821f..fd4f0b62ce 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -64,6 +64,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.GET("/agent-models/:agentName", h.httpGetAgentModels) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent) + api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate) api.GET("/agent-install/jobs", h.httpListInstallJobs) api.GET("/agent-install/jobs/:id", h.httpGetInstallJob) api.POST("/agent-update/:agentName", h.interlock, h.httpUpdateAgentRuntime) @@ -123,6 +124,24 @@ func (h *Handlers) httpUpdateAgentRuntime(c *gin.Context) { }, classifyUpdateError) } +func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) { + name, ok := requireAgentName(c) + if !ok { + return + } + preview, err := h.controller.PreviewAgentUpdate(c.Request.Context(), name) + if err == nil { + c.JSON(http.StatusOK, preview) + return + } + if status, message, matched := classifyUpdatePreviewError(err); matched { + c.JSON(status, gin.H{"error": message}) + return + } + h.logger.Error("failed to preview agent runtime update", zap.String("agent", name), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to preview agent update"}) +} + func requireAgentName(c *gin.Context) (string, bool) { name := strings.TrimSpace(c.Param("agentName")) if name == "" { @@ -193,6 +212,16 @@ func classifyUpdateError(err error) (int, string, bool) { } } +func classifyUpdatePreviewError(err error) (int, string, bool) { + if status, message, matched := classifyUpdateError(err); matched { + return status, message, true + } + if errors.Is(err, controller.ErrRuntimeUpdatePreviewFailed) { + return http.StatusBadGateway, "unable to resolve latest runtime version", true + } + return 0, "", false +} + func (h *Handlers) httpListAgentUpdateJobs(c *gin.Context) { c.JSON(http.StatusOK, dto.ListAgentUpdateJobsResponse{Jobs: h.controller.ListAgentUpdateJobs()}) } diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index c596229155..236284dffe 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -26,7 +26,7 @@ import { listAvailableAgents, listInstallJobs, } from "@/lib/api"; -import type { AgentUpdateJob, InstallJob } from "@/lib/api"; +import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; @@ -204,7 +204,8 @@ type InstalledAgentsSectionProps = { resolveRuntimeUpdate: (name: string) => RuntimeUpdate | undefined; installJobs: Record; updateJobs: Record; - handleUpdate: (name: string) => Promise; + previewUpdate: (name: string) => Promise; + startUpdate: (name: string) => Promise; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; }; @@ -272,7 +273,8 @@ function InstalledAgentsSection({ resolveRuntimeUpdate, installJobs, updateJobs, - handleUpdate, + previewUpdate, + startUpdate, setTuiDialogOpen, handleRescan, }: InstalledAgentsSectionProps) { @@ -324,7 +326,8 @@ function InstalledAgentsSection({ runtimeUpdate={resolveRuntimeUpdate(agent.name)} installJob={installJobs[agent.name]} updateJob={updateJobs[agent.name]} - onUpdate={(name) => void handleUpdate(name)} + onPreview={previewUpdate} + onUpdate={startUpdate} onAuthComplete={() => void handleRescan()} /> ))} @@ -524,7 +527,7 @@ function useAgentPageState() { }; const { installJobs, handleInstall } = useInstallAgent(handleRescan); - const { updateJobs, handleUpdate } = useAgentRuntimeUpdates(); + const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates(); const handleCreateCustomTUI = async (data: { display_name: string; @@ -565,7 +568,8 @@ function useAgentPageState() { installJobs, handleInstall, updateJobs, - handleUpdate, + previewUpdate, + startUpdate, }; } @@ -588,7 +592,8 @@ export default function AgentsSettingsPage() { installJobs, handleInstall, updateJobs, - handleUpdate, + previewUpdate, + startUpdate, } = useAgentPageState(); const { copiedValue, copy } = useCopyCommand(); @@ -613,7 +618,8 @@ export default function AgentsSettingsPage() { resolveRuntimeUpdate={resolveRuntimeUpdate} installJobs={installJobs} updateJobs={updateJobs} - handleUpdate={handleUpdate} + previewUpdate={previewUpdate} + startUpdate={startUpdate} setTuiDialogOpen={setTuiDialogOpen} handleRescan={handleRescan} /> diff --git a/apps/web/components/settings/agent-runtime-update-control.test.tsx b/apps/web/components/settings/agent-runtime-update-control.test.tsx deleted file mode 100644 index 14734d9c62..0000000000 --- a/apps/web/components/settings/agent-runtime-update-control.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { ComponentType } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { InstalledAgentCard } from "./installed-agent-card"; - -const ManagedCard = InstalledAgentCard as ComponentType>; -const AGENT_NAME = "claude-acp"; - -const discoveryAgent = { - name: AGENT_NAME, - supports_mcp: true, - installation_paths: [], - available: true, - matched_path: "/usr/bin/npx", -}; - -const runtimeUpdate = { - supported: true, - package: "@agentclientprotocol/claude-agent-acp", - current_version: "1.0.0", -}; - -function renderCard(overrides: Record = {}) { - const onUpdate = vi.fn(); - render( - , - ); - return onUpdate; -} - -afterEach(cleanup); - -describe("AgentRuntimeUpdateControl", () => { - it("shows a labeled touch action only for managed installed agents", () => { - const onUpdate = renderCard(); - - const button = screen.getByRole("button", { name: "Update agent" }); - expect(button.className).toContain("min-h-11"); - fireEvent.click(button); - expect(onUpdate).toHaveBeenCalledWith(AGENT_NAME); - - cleanup(); - renderCard({ runtimeUpdate: undefined }); - expect(screen.queryByRole("button", { name: "Update agent" })).toBeNull(); - }); - - it("shows current to target progress and a bounded wrapping log", () => { - renderCard({ - updateJob: { - job_id: "update-1", - agent_name: AGENT_NAME, - status: "updating", - current_version: "1.0.0", - target_version: "1.1.0", - output: "fetching package\n", - started_at: "2026-07-26T10:00:00Z", - }, - }); - - expect(screen.getByText("1.0.0 → 1.1.0")).not.toBeNull(); - expect(screen.getByText("Updating runtime…")).not.toBeNull(); - const log = screen.getByTestId("agent-update-log-claude-acp"); - expect(log.className).toContain("max-h-40"); - expect(log.className).toContain("overflow-y-auto"); - expect(log.className).toContain("break-words"); - expect(screen.getByRole("button", { name: "Updating agent" }).hasAttribute("disabled")).toBe( - true, - ); - }); - - it("distinguishes refresh warnings from retryable update failures", () => { - const { rerender } = render( - , - ); - - expect( - screen.getByText(/runtime updated, but capabilities could not be refreshed/i), - ).not.toBeNull(); - expect(screen.getByText(/authentication required/i)).not.toBeNull(); - - rerender( - , - ); - - expect(screen.getByText("Registry unavailable")).not.toBeNull(); - expect(screen.getByRole("button", { name: "Retry update" }).hasAttribute("disabled")).toBe( - false, - ); - }); - - it("disables updates while the same agent is installing", () => { - renderCard({ - installJob: { - job_id: "install-1", - agent_name: AGENT_NAME, - status: "running", - started_at: "2026-07-26T10:00:00Z", - }, - }); - - expect(screen.getByRole("button", { name: "Update agent" }).hasAttribute("disabled")).toBe( - true, - ); - expect(screen.getByText("Agent installation is in progress.")).not.toBeNull(); - }); -}); diff --git a/apps/web/components/settings/agent-runtime-update-control.tsx b/apps/web/components/settings/agent-runtime-update-control.tsx index fc23d95652..fdd2c472e9 100644 --- a/apps/web/components/settings/agent-runtime-update-control.tsx +++ b/apps/web/components/settings/agent-runtime-update-control.tsx @@ -1,10 +1,28 @@ "use client"; -import { IconLoader2, IconRefresh } from "@tabler/icons-react"; +import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; - -import type { AgentUpdateJob, InstallJob } from "@/lib/api"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; import type { RuntimeUpdate } from "@/lib/types/http"; +import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state"; const ACTIVE_UPDATE_STATUSES = new Set([ "queued", @@ -22,52 +40,18 @@ function updatePhase(status: AgentUpdateJob["status"] | undefined): string | nul case "updating": return "Updating runtime…"; case "refreshing": - return "Refreshing models…"; + return "Refreshing agent capabilities…"; default: return null; } } -function updateButtonLabel(status: AgentUpdateJob["status"] | undefined) { - switch (status) { - case "queued": - return "Update queued"; - case "resolving": - return "Resolving update"; - case "updating": - return "Updating agent"; - case "refreshing": - return "Refreshing agent capabilities"; - case "failed": - return "Retry update"; - default: - return "Update agent"; - } -} - -function VersionSummary({ - runtimeUpdate, - job, -}: { - runtimeUpdate: RuntimeUpdate; - job?: AgentUpdateJob; -}) { - const current = job?.current_version || runtimeUpdate.current_version || "Unknown"; - if (job?.target_version) { - return

{`${current} → ${job.target_version}`}

; - } - if (job?.status === "resolving") { - return

{`${current} → Checking latest…`}

; - } - return

Current version: {current}

; -} - function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) { if (!job) return null; if (job.status === "succeeded" && job.refresh_error) { return (

@@ -78,7 +62,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate if (job.status === "succeeded") { return (

@@ -89,7 +73,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate if (job.status === "failed") { return (

@@ -100,71 +84,338 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate return null; } -export function AgentRuntimeUpdateControl({ - agentName, - runtimeUpdate, - job, - installJob, - onUpdate, -}: { +type UpdateBodyProps = { agentName: string; - runtimeUpdate: RuntimeUpdate; + preview: AgentUpdatePreview | null; + loading: boolean; + previewError: string | null; + approveError: string | null; job?: AgentUpdateJob; - installJob?: InstallJob; - onUpdate: (agentName: string) => void; -}) { - if (!runtimeUpdate.supported) return null; + onRetryPreview: () => void; +}; - const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)); - const installInFlight = installJob?.status === "queued" || installJob?.status === "running"; - const disabled = updateInFlight || installInFlight; +function UpdateBody({ + agentName, + preview, + loading, + previewError, + approveError, + job, + onRetryPreview, +}: UpdateBodyProps) { const phase = updatePhase(job?.status); - const buttonLabel = updateButtonLabel(job?.status); - return ( -

- +
+ {loading && ( +

+ + Checking the latest runtime version… +

+ )} + {previewError && ( +
+

+ + {previewError} +

+ +
+ )} + {approveError && ( +
+

+ + Unable to start update: {approveError} +

+
+ )} + {preview && ( + <> +
+

Runtime version

+

+ {(job?.current_version || preview.current_version || "Unknown") + + " → " + + (job?.target_version || preview.target_version)} +

+
+
+

+ This updates the managed runtime on this Kandev host, then refreshes its models and + capabilities. +

+

+ Active sessions keep running; only later probes and launches use the refreshed + runtime. +

+
+
+

Command that will run

+
+              {preview.command_string}
+            
+
+ + )} {phase && (

- - {phase} -

- )} - {installInFlight && ( -

- Agent installation is in progress. + + {phase}

)} {job?.output && (
           {job.output}
         
)} -
); } + +type UpdateFooterProps = { + agentName: string; + preview: AgentUpdatePreview | null; + previewError: string | null; + job?: AgentUpdateJob; + loading: boolean; + starting: boolean; + installInFlight: boolean; + onApprove: () => void; + onClose: () => void; + mobile?: boolean; +}; + +function canApproveUpdate({ + preview, + previewError, + loading, + updateInFlight, + starting, + installInFlight, +}: { + preview: AgentUpdatePreview | null; + previewError: string | null; + loading: boolean; + updateInFlight: boolean; + starting: boolean; + installInFlight: boolean; +}) { + return ( + Boolean(preview?.current_version) && + !previewError && + !loading && + !updateInFlight && + !starting && + !installInFlight + ); +} + +function UpdateFooter({ + agentName, + preview, + previewError, + job, + loading, + starting, + installInFlight, + onApprove, + onClose, + mobile, +}: UpdateFooterProps) { + const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)); + const canRetry = job?.status === "failed"; + const canApprove = canApproveUpdate({ + preview, + previewError, + loading, + updateInFlight, + starting, + installInFlight, + }); + const showApprove = !job || canRetry; + const content = ( + <> + + {showApprove && ( + + )} + + ); + if (mobile) { + return ( + + {content} + + ); + } + return {content}; +} + +function UpdateTrigger({ + agentName, + displayName, + installInFlight, + onOpen, +}: { + agentName: string; + displayName: string; + installInFlight: boolean; + onOpen: () => void; +}) { + return ( + + + + + + + + {installInFlight ? "Agent installation is in progress" : `Update ${displayName}`} + + + ); +} + +export function AgentRuntimeUpdateControl({ + agentName, + displayName, + runtimeUpdate, + job, + installJob, + onPreview, + onUpdate, +}: { + agentName: string; + displayName: string; + runtimeUpdate: RuntimeUpdate; + job?: AgentUpdateJob; + installJob?: InstallJob; + onPreview: (agentName: string) => Promise; + onUpdate: (agentName: string) => Promise; +}) { + const { isMobile } = useResponsiveBreakpoint(); + const { + activeJob, + approve, + approveError, + handleOpenChange, + loading, + loadPreview, + open, + preview, + previewError, + starting, + } = useAgentUpdateDialogState({ agentName, job, onPreview, onUpdate }); + const installInFlight = installJob?.status === "queued" || installJob?.status === "running"; + + if (!runtimeUpdate.supported) return null; + + const body = ( + void loadPreview()} + /> + ); + + const footer = (mobile = false) => ( + void approve()} + onClose={() => handleOpenChange(false)} + mobile={mobile} + /> + ); + + return ( + <> + handleOpenChange(true)} + /> + {isMobile ? ( + + + + Update {displayName} + + Review the update before Kandev changes this host runtime. + + + {body} + {footer(true)} + + + ) : ( + + + + Update {displayName} + + Review the update before Kandev changes this host runtime. + + + {body} + {footer()} + + + )} + + ); +} diff --git a/apps/web/components/settings/installed-agent-card.tsx b/apps/web/components/settings/installed-agent-card.tsx index f9402d3f5f..515a5916e7 100644 --- a/apps/web/components/settings/installed-agent-card.tsx +++ b/apps/web/components/settings/installed-agent-card.tsx @@ -11,7 +11,7 @@ import { AgentLogo } from "@/components/agent-logo"; import { AgentLoginDialog } from "@/components/settings/agent-login-dialog"; import { AgentRuntimeUpdateControl } from "@/components/settings/agent-runtime-update-control"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; -import type { AgentUpdateJob, InstallJob } from "@/lib/api"; +import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; import type { Agent, AgentDiscovery, RuntimeUpdate } from "@/lib/types/http"; type Props = { @@ -23,7 +23,8 @@ type Props = { runtimeUpdate?: RuntimeUpdate; updateJob?: AgentUpdateJob; installJob?: InstallJob; - onUpdate?: (agentName: string) => void; + onPreview?: (agentName: string) => Promise; + onUpdate?: (agentName: string) => Promise; /** * Called when the auth/shell dialog closes so the page can refresh * discovery + availability. Without this the yellow lock stays put even @@ -56,40 +57,42 @@ function InstalledAgentIdentity({

{displayName}

{agent.supports_mcp && MCP} {configured && Configured} - {probing && ( - - - - - - - Checking agent capabilities and authentication... - - )} - {authRequired && ( - - - - - - {loginAvailable - ? "Authentication required - click to open login terminal" - : "Authentication required - click to open a shell and sign in"} - - - )} +
+ {probing && ( + + + + + + + Checking agent capabilities and authentication... + + )} + {authRequired && ( + + + + + + {loginAvailable + ? "Authentication required - click to open login terminal" + : "Authentication required - click to open a shell and sign in"} + + + )} +

- {runtimeUpdate?.supported && onUpdate && ( - - )} - + + + {hasAgentRecord ? "Create new profile" : "Setup Profile"} + + + {runtimeUpdate?.supported && onPreview && onUpdate && ( + + )} + () { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((complete, fail) => { + resolve = complete; + reject = fail; + }); + return { promise, reject, resolve }; +} + +const FIRST_PREVIEW: AgentUpdatePreview = { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + current_version: "0.62.0", + target_version: "0.63.0", + command: ["npm", "exec"], + command_string: "npm exec", +}; +const AGENT_NAME = FIRST_PREVIEW.agent_name; +const UPDATE_ALREADY_RUNNING = "Update is already running"; + +describe("useAgentUpdateDialogState", () => { + it("ignores a preview that resolves after close and reopen", async () => { + const firstRequest = deferred(); + const secondRequest = deferred(); + const onPreview = vi + .fn<(agentName: string) => Promise>() + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(secondRequest.promise); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview, + onUpdate: vi.fn(), + }), + ); + + act(() => result.current.handleOpenChange(true)); + await waitFor(() => expect(onPreview).toHaveBeenCalledTimes(1)); + act(() => result.current.handleOpenChange(false)); + act(() => result.current.handleOpenChange(true)); + await waitFor(() => expect(onPreview).toHaveBeenCalledTimes(2)); + + await act(async () => { + firstRequest.resolve(FIRST_PREVIEW); + await firstRequest.promise; + }); + + expect(result.current.preview).toBeNull(); + expect(result.current.loading).toBe(true); + + await act(async () => { + secondRequest.resolve({ ...FIRST_PREVIEW, target_version: "0.64.0" }); + await secondRequest.promise; + }); + + await waitFor(() => expect(result.current.preview?.target_version).toBe("0.64.0")); + }); + + it("ignores an approval failure after the dialog closes", async () => { + const updateRequest = deferred(); + const onUpdate = vi + .fn<(agentName: string) => Promise>() + .mockReturnValue(updateRequest.promise); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview: vi.fn().mockResolvedValue(FIRST_PREVIEW), + onUpdate, + }), + ); + + act(() => { + void result.current.approve(); + }); + await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)); + act(() => result.current.handleOpenChange(false)); + + await act(async () => { + updateRequest.reject(new Error(UPDATE_ALREADY_RUNNING)); + await updateRequest.promise.catch(() => undefined); + }); + + expect(result.current.previewError).toBeNull(); + expect(result.current.starting).toBe(false); + }); + + it("keeps a start failure separate from the update preview", async () => { + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview: vi.fn().mockResolvedValue(FIRST_PREVIEW), + onUpdate: vi.fn().mockRejectedValue(new Error(UPDATE_ALREADY_RUNNING)), + }), + ); + + await act(async () => { + await result.current.approve(); + }); + + expect(result.current.previewError).toBeNull(); + expect(result.current.approveError).toBe(UPDATE_ALREADY_RUNNING); + }); +}); diff --git a/apps/web/components/settings/use-agent-update-dialog-state.ts b/apps/web/components/settings/use-agent-update-dialog-state.ts new file mode 100644 index 0000000000..f132ed866b --- /dev/null +++ b/apps/web/components/settings/use-agent-update-dialog-state.ts @@ -0,0 +1,94 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api"; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +type UseAgentUpdateDialogStateOptions = { + agentName: string; + job?: AgentUpdateJob; + onPreview: (agentName: string) => Promise; + onUpdate: (agentName: string) => Promise; +}; + +export function useAgentUpdateDialogState({ + agentName, + job, + onPreview, + onUpdate, +}: UseAgentUpdateDialogStateOptions) { + const [open, setOpen] = useState(false); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [approveError, setApproveError] = useState(null); + const [loading, setLoading] = useState(false); + const [starting, setStarting] = useState(false); + const [activeJobID, setActiveJobID] = useState(null); + const previewRequestID = useRef(0); + const activeJob = activeJobID === job?.job_id ? job : undefined; + + const reset = useCallback(() => { + previewRequestID.current += 1; + setPreview(null); + setPreviewError(null); + setApproveError(null); + setLoading(false); + setStarting(false); + setActiveJobID(null); + }, []); + + const loadPreview = useCallback(async () => { + const requestID = ++previewRequestID.current; + setLoading(true); + setPreview(null); + setPreviewError(null); + setApproveError(null); + try { + const nextPreview = await onPreview(agentName); + if (requestID === previewRequestID.current) setPreview(nextPreview); + } catch (error) { + if (requestID === previewRequestID.current) setPreviewError(errorMessage(error)); + } finally { + if (requestID === previewRequestID.current) setLoading(false); + } + }, [agentName, onPreview]); + + useEffect(() => { + if (open) void loadPreview(); + }, [loadPreview, open]); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) reset(); + }; + + const approve = async () => { + const requestID = previewRequestID.current; + setStarting(true); + setApproveError(null); + try { + const nextJob = await onUpdate(agentName); + if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id); + } catch (error) { + if (requestID === previewRequestID.current) setApproveError(errorMessage(error)); + } finally { + if (requestID === previewRequestID.current) setStarting(false); + } + }; + + return { + activeJob, + approve, + approveError, + handleOpenChange, + loading, + loadPreview, + open, + preview, + previewError, + starting, + }; +} diff --git a/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts b/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts index 16026242ad..eda8da6aba 100644 --- a/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts +++ b/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts @@ -18,6 +18,15 @@ type UpdateJob = { finished_at?: string; }; +type UpdatePreview = { + agent_name: string; + package: string; + current_version: string; + target_version: string; + command: string[]; + command_string: string; +}; + function catalogue(models: Array<{ id: string; name: string }>, displayName = "Claude") { return { agents: [ @@ -93,6 +102,7 @@ function event(action: string, payload: unknown) { export type RuntimeUpdateFixtureOptions = { retainedJobs?: UpdateJob[]; postResponse?: UpdateJob; + previewResponse?: UpdatePreview; }; export async function installRuntimeUpdateFixture( @@ -111,6 +121,28 @@ export async function installRuntimeUpdateFixture( started_at: NOW, } satisfies UpdateJob); let postCount = 0; + let previewCount = 0; + let previewResponse: UpdatePreview = + options.previewResponse ?? + ({ + agent_name: AGENT_NAME, + package: "@agentclientprotocol/claude-agent-acp", + current_version: "0.62.0", + target_version: "0.63.0", + command: [ + "npm", + "exec", + "--yes", + "--prefer-online", + "--package=@agentclientprotocol/claude-agent-acp", + "--", + "node", + "-e", + "", + ], + command_string: + 'npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e ""', + } satisfies UpdatePreview); let socket: WebSocketRoute | undefined; let clientReady = false; @@ -138,6 +170,17 @@ export async function installRuntimeUpdateFixture( await page.route("**/api/v1/agent-update/**", (route) => { const request = route.request(); const url = new URL(request.url()); + if ( + request.method() === "GET" && + url.pathname.endsWith(`/agent-update/${AGENT_NAME}/preview`) + ) { + previewCount += 1; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(previewResponse), + }); + } if (request.method() === "GET" && url.pathname.endsWith("/agent-update/jobs")) { return route.fulfill({ status: 200, @@ -193,7 +236,11 @@ export async function installRuntimeUpdateFixture( setPostResponse(job: UpdateJob) { postResponse = job; }, + setPreviewResponse(preview: UpdatePreview) { + previewResponse = preview; + }, postCount: () => postCount, + previewCount: () => previewCount, async emit(action: string, payload: unknown) { await expect.poll(() => Boolean(socket)).toBe(true); await expect.poll(() => clientReady).toBe(true); diff --git a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts index 99d6ac403f..02bb2bd1b4 100644 --- a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts @@ -2,8 +2,9 @@ import { test, expect } from "../../fixtures/test-base"; import { installRuntimeUpdateFixture, updateJob } from "./agent-runtime-update-helpers"; test.describe("managed agent runtime updates", () => { - test("streams a successful update and renders refreshed models without a document reload", async ({ + test("previews, approves, and streams an update without putting details on the card", async ({ testPage, + prCapture, }) => { const runtime = await installRuntimeUpdateFixture(testPage); let documentNavigations = 0; @@ -13,25 +14,51 @@ test.describe("managed agent runtime updates", () => { await testPage.goto("/settings/agents"); const navigationsAfterLoad = documentNavigations; - const control = testPage.getByTestId(`agent-update-control-${runtime.agentName}`); - await expect(control).toBeVisible(); - await expect(control).toContainText("Current version: 0.62.0"); + const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`); + await expect(trigger).toBeVisible(); + await expect(testPage.getByTestId(`agent-update-control-${runtime.agentName}`)).toHaveCount(0); + const profileAction = testPage.getByRole("link", { name: "Setup Profile" }); + const [profileActionBox, triggerBox] = await Promise.all([ + profileAction.boundingBox(), + trigger.boundingBox(), + ]); + expect(profileActionBox).not.toBeNull(); + expect(triggerBox).not.toBeNull(); + expect(Math.abs(profileActionBox!.y - triggerBox!.y)).toBeLessThanOrEqual(8); + expect(triggerBox!.x).toBeGreaterThan(profileActionBox!.x); + expect(profileActionBox!.height).toBe(28); + expect(triggerBox!.height).toBe(profileActionBox!.height); - await testPage.getByTestId(`agent-update-button-${runtime.agentName}`).click(); - expect(runtime.postCount()).toBe(1); - await expect(testPage.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( - "Checking latest version", + await trigger.click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("0.62.0 → 0.63.0"); + await expect(dialog).toContainText( + 'npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e ""', ); + await expect(dialog).toContainText("Active sessions keep running"); + const viewport = testPage.viewportSize(); + expect(viewport).not.toBeNull(); + await expect + .poll(() => dialog.evaluate((element) => element.getBoundingClientRect().height)) + .toBeLessThan(viewport!.height * 0.6); + expect(runtime.previewCount()).toBe(1); + expect(runtime.postCount()).toBe(0); + await prCapture.screenshot("desktop-update-preview", { + caption: "Desktop update preview before approval", + }); + + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); + expect(runtime.postCount()).toBe(1); await runtime.emitUpdate(updateJob()); await runtime.emitOutput("Installed @agentclientprotocol/claude-agent-acp@0.63.0\n"); - await expect(testPage.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( + await expect(dialog.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( "Updating runtime", ); - await expect(testPage.getByTestId(`agent-update-log-${runtime.agentName}`)).toContainText( + await expect(dialog.getByTestId(`agent-update-log-${runtime.agentName}`)).toContainText( "Installed @agentclientprotocol/claude-agent-acp@0.63.0", ); - await expect(control).toContainText("0.62.0 → 0.63.0"); await runtime.emitUpdate( updateJob({ @@ -46,46 +73,57 @@ test.describe("managed agent runtime updates", () => { ]); await expect(testPage.getByText("Claude refreshed", { exact: true })).toBeVisible(); - await expect(testPage.getByTestId(`agent-update-result-${runtime.agentName}`)).toContainText( + await expect(dialog.getByTestId(`agent-update-result-${runtime.agentName}`)).toContainText( "Runtime updated successfully", ); expect(documentNavigations).toBe(navigationsAfterLoad); - await testPage.getByRole("link", { name: "Setup Profile" }).click(); - await expect(testPage).toHaveURL(/\/settings\/agents\/claude-acp$/); - await expect(testPage.getByRole("heading", { name: "Claude" })).toBeVisible(); - const modelPicker = testPage.getByRole("button", { name: "Profile start model settings" }); - await expect(modelPicker).toBeVisible(); - await modelPicker.click(); - await expect(testPage.getByText("Claude Opus 5", { exact: true })).toBeVisible(); + await testPage.reload(); + await expect(trigger).toBeVisible(); + await expect(testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`)).toHaveCount(0); + await expect(testPage.getByTestId(`agent-update-result-${runtime.agentName}`)).toHaveCount(0); }); - test("rehydrates a retained failure and lets the operator retry it", async ({ testPage }) => { + test("requires a current runtime version before approval", async ({ testPage }) => { const runtime = await installRuntimeUpdateFixture(testPage, { - retainedJobs: [ - updateJob({ - status: "failed", - error: "Registry lookup failed", - finished_at: "2026-07-26T12:01:00.000Z", - }), - ], - postResponse: updateJob({ - job_id: "runtime-update-job-2", - status: "resolving", - target_version: undefined, - started_at: "2026-07-26T12:02:00.000Z", - }), + previewResponse: { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + current_version: "", + target_version: "0.63.0", + command: ["npm", "exec"], + command_string: "npm exec", + }, }); await testPage.goto("/settings/agents"); - await expect(testPage.getByTestId(`agent-update-result-${runtime.agentName}`)).toContainText( - "Registry lookup failed", - ); - const retry = testPage.getByTestId(`agent-update-button-${runtime.agentName}`); - await expect(retry).toHaveText("Retry update"); - await retry.click(); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await expect(dialog).toContainText("Unknown → 0.63.0"); + await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toBeDisabled(); + expect(runtime.postCount()).toBe(0); + }); + + test("retries an update after its job fails", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const retryUpdate = testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`); + await retryUpdate.click(); expect(runtime.postCount()).toBe(1); - await expect(testPage.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( - "Checking latest version", + + await runtime.emitUpdate( + updateJob({ + status: "failed", + error: "The package registry is unavailable", + finished_at: "2026-07-26T12:01:00.000Z", + }), ); + + await expect(retryUpdate).toHaveText("Retry update"); + await expect(retryUpdate).toBeEnabled(); + await retryUpdate.click(); + expect(runtime.postCount()).toBe(2); }); }); diff --git a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts index 1da03c2159..0df32e5495 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts @@ -2,55 +2,73 @@ import { test, expect } from "../../fixtures/test-base"; import { installRuntimeUpdateFixture, updateJob } from "./agent-runtime-update-helpers"; test.describe("managed agent runtime updates on mobile", () => { - test("keeps progress, bounded output, and retry touch-reachable without horizontal overflow", async ({ + test("uses a touch-safe drawer to preview and stream an update without horizontal overflow", async ({ testPage, + prCapture, }) => { - const runtime = await installRuntimeUpdateFixture(testPage, { - retainedJobs: [ - updateJob({ - output: Array.from( - { length: 60 }, - (_, index) => `Downloading runtime chunk ${index + 1} for the latest agent package`, - ).join("\n"), - }), - ], - }); + const runtime = await installRuntimeUpdateFixture(testPage); await testPage.goto("/settings/agents"); - const control = testPage.getByTestId(`agent-update-control-${runtime.agentName}`); - const log = testPage.getByTestId(`agent-update-log-${runtime.agentName}`); - const button = testPage.getByTestId(`agent-update-button-${runtime.agentName}`); + const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`); + await trigger.scrollIntoViewIfNeeded(); + const triggerBox = await trigger.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(triggerBox!.height).toBeGreaterThanOrEqual(44); + expect(triggerBox!.width).toBeGreaterThanOrEqual(44); + await trigger.tap(); - await control.scrollIntoViewIfNeeded(); - await expect(control).toContainText("0.62.0 → 0.63.0"); - await expect(testPage.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( + const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`); + await expect(drawer).toBeVisible(); + await expect(drawer).toContainText("0.62.0 → 0.63.0"); + expect(runtime.postCount()).toBe(0); + await prCapture.screenshot("mobile-update-preview", { + caption: "Mobile update preview before approval", + }); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).tap(); + expect(runtime.postCount()).toBe(1); + + await runtime.emitUpdate( + updateJob({ + output: Array.from( + { length: 60 }, + (_, index) => `Downloading runtime chunk ${index + 1} for the latest agent package`, + ).join("\n"), + }), + ); + + await expect(drawer.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( "Updating runtime", ); - await expect(log).toBeInViewport({ ratio: 1 }); + const body = drawer.getByTestId(`agent-update-dialog-body-${runtime.agentName}`); + await body.scrollIntoViewIfNeeded(); await expect( - log.evaluate((element) => element.scrollHeight > element.clientHeight), + body.evaluate((element) => element.scrollHeight > element.clientHeight), ).resolves.toBe(true); - await expect(button).toBeInViewport({ ratio: 1 }); - const runningButtonBox = await button.boundingBox(); - expect(runningButtonBox).not.toBeNull(); - expect(runningButtonBox!.height).toBeGreaterThanOrEqual(44); await expect(testPage.locator("html")).toHaveJSProperty( "scrollWidth", await testPage.locator("html").evaluate((element) => element.clientWidth), ); + }); + + test("keeps retry available after an update job fails", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).tap(); + const retryUpdate = testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`); + await retryUpdate.tap(); + expect(runtime.postCount()).toBe(1); await runtime.emitUpdate( updateJob({ status: "failed", - error: "Registry lookup failed", + error: "The package registry is unavailable", finished_at: "2026-07-26T12:01:00.000Z", }), ); - const result = testPage.getByTestId(`agent-update-result-${runtime.agentName}`); - await result.scrollIntoViewIfNeeded(); - await expect(result).toContainText("Registry lookup failed"); - await expect(button).toHaveText("Retry update"); - await button.tap(); - expect(runtime.postCount()).toBe(1); + + await expect(retryUpdate).toHaveText("Retry update"); + await retryUpdate.tap(); + expect(runtime.postCount()).toBe(2); }); }); diff --git a/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts b/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts index 4b4ffbe8b2..4d83b08b96 100644 --- a/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts +++ b/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts @@ -1,9 +1,15 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback } from "react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { getAgentUpdateJob, getInstallJob, listAgentUpdateJobs, updateAgent } from "@/lib/api"; +import { + getAgentUpdateJob, + getInstallJob, + previewAgentUpdate, + updateAgent, + type AgentUpdateJob, +} from "@/lib/api"; import { ApiError } from "@/lib/api/client"; type MaintenanceConflict = { @@ -27,64 +33,41 @@ export function useAgentRuntimeUpdates() { const store = useAppStoreApi(); const updateJobs = useAppStore((state) => state.updateJobs.byAgent); - useEffect(() => { - let cancelled = false; - listAgentUpdateJobs({ cache: "no-store" }) - .then((response) => { - if (cancelled) return; - for (const job of response.jobs) { - store.getState().upsertAgentUpdateJob(job); - } - }) - .catch(() => { - // Retained jobs are an enhancement; live WebSocket events still populate state. - }); - return () => { - cancelled = true; - }; - }, [store]); - const hydrateConflict = useCallback( - async (agentName: string, conflict: MaintenanceConflict) => { + async (agentName: string, conflict: MaintenanceConflict): Promise => { if (conflict.active_kind === "update") { const job = await getAgentUpdateJob(conflict.active_job_id, { cache: "no-store" }); store.getState().upsertAgentUpdateJob(job); - return; + return job; } const job = await getInstallJob(conflict.active_job_id, { cache: "no-store" }); store.getState().upsertInstallJob(job.agent_name ? job : { ...job, agent_name: agentName }); + throw new Error("Agent installation is already in progress."); }, [store], ); - const handleUpdate = useCallback( + const startUpdate = useCallback( async (agentName: string) => { try { const job = await updateAgent(agentName); store.getState().upsertAgentUpdateJob(job); + return job; } catch (error) { const conflict = maintenanceConflict(error); if (conflict) { - try { - await hydrateConflict(agentName, conflict); - return; - } catch { - // Surface a retryable error below if the retained conflict job vanished. - } + return hydrateConflict(agentName, conflict); } - const current = store.getState().updateJobs.byAgent[agentName]; - if (current && !["succeeded", "failed"].includes(current.status)) return; - store.getState().upsertAgentUpdateJob({ - job_id: `local-error-${agentName}-${Date.now()}`, - agent_name: agentName, - status: "failed", - error: error instanceof Error ? error.message : String(error), - started_at: new Date().toISOString(), - }); + throw error; } }, [hydrateConflict, store], ); - return { updateJobs, handleUpdate }; + const previewUpdate = useCallback( + (agentName: string) => previewAgentUpdate(agentName, { cache: "no-store" }), + [], + ); + + return { updateJobs, previewUpdate, startUpdate }; } diff --git a/apps/web/lib/api/domains/agent-update-api.ts b/apps/web/lib/api/domains/agent-update-api.ts index edf8542c64..24dd2df3c8 100644 --- a/apps/web/lib/api/domains/agent-update-api.ts +++ b/apps/web/lib/api/domains/agent-update-api.ts @@ -21,6 +21,22 @@ export type AgentUpdateJob = { finished_at?: string; }; +export type AgentUpdatePreview = { + agent_name: string; + package: string; + current_version?: string; + target_version: string; + command: string[]; + command_string: string; +}; + +export async function previewAgentUpdate( + agentName: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`/api/v1/agent-update/${agentName}/preview`, options); +} + export async function updateAgent( agentName: string, options?: ApiRequestOptions, diff --git a/docs/plans/agent-runtime-update-dialog/plan.md b/docs/plans/agent-runtime-update-dialog/plan.md new file mode 100644 index 0000000000..aa1fbe340b --- /dev/null +++ b/docs/plans/agent-runtime-update-dialog/plan.md @@ -0,0 +1,173 @@ +--- +spec: docs/specs/agents/runtime-updates.md +created: 2026-07-27 +status: complete +--- + +# Implementation Plan: Agent Runtime Update Dialog + +## Overview + +PR #1950 placed retained update state, versions, output, and results directly +inside installed-agent cards. It also configured PR previews with +`KANDEV_MOCK_AGENT=only`, which intentionally removes every real agent from the +registry and therefore leaves the install catalogue empty. This repair restores +the preview catalogue, adds a read-only update-preview contract, and moves the +approval and ephemeral streaming lifecycle into a responsive dialog. + +## Confirmed root causes + +- `apps/backend/cmd/preview/sprite_ops.go` exports + `KANDEV_MOCK_AGENT=only`. `registry.Provide` treats `only` as an isolated E2E + mode and registers only `mock-agent`; `ListAvailableAgents` can only return + registry entries, so Settings receives no real install candidates. +- `AgentRuntimeUpdateControl` is rendered inside `InstalledAgentCard`, so all + versions, output, and terminal state expand the card. +- `useAgentRuntimeUpdates` calls `listAgentUpdateJobs` on mount and upserts + retained backend jobs into the global Settings store, explicitly restoring + output and terminal state after a page restart. +- The only preparation path is `POST /agent-update/:agentName`; target-version + resolution happens after enqueue, so the UI cannot show the next version and + exact command before mutation. + +## Backend + +### Preview environment catalogue + +Change `buildExtractScript` in +`apps/backend/cmd/preview/sprite_ops.go` to use +`KANDEV_MOCK_AGENT=true`. That mode retains the enabled mock agent while loading +the built-in real-agent catalogue, allowing unavailable agents with install +scripts to appear in Settings. Update the focused script assertion in +`apps/backend/cmd/preview/sprite_ops_test.go`; keep the registry mode test as +supporting evidence that `true` loads defaults and `only` does not. + +### Read-only update preview + +Add `AgentUpdatePreviewDTO` in +`apps/backend/internal/agent/settings/dto/dto.go` with agent, package, current +version, target version, argv, and display command fields. + +Add `Controller.PreviewAgentUpdate(ctx, name)` in +`apps/backend/internal/agent/settings/controller/agent_update.go`. It validates +the same built-in `ManagedNPMRuntimeAgent` boundary as enqueue, reads the current +capability version, resolves the upstream target through `RuntimeUpdater`, and +serializes `CacheUpdateCommand()` with the existing `buildCommandString` +helper. Extend that helper to preserve empty argv entries as `""`, so the +displayed recipe remains an exact representation of the direct argv command. +It never claims maintenance state, creates a job, or executes the update command. + +Register `GET /api/v1/agent-update/:agentName/preview` and a focused handler in +`apps/backend/internal/agent/settings/handlers/handlers.go`. Reuse the existing +update error classification for not-found, unsupported, unavailable, and +version-resolution failures. + +## Frontend + +### API and page-local update lifecycle + +Add `AgentUpdatePreview` and `previewAgentUpdate` to +`apps/web/lib/api/domains/agent-update-api.ts`. + +Refactor `useAgentRuntimeUpdates` so it does not list or hydrate retained update +jobs on mount. Starting an update returns or binds the accepted job identity for +the currently open dialog; WebSocket snapshots and chunks may continue using +the existing bounded Settings store, but the dialog displays only the job +started or recovered during its current page-local interaction. + +### Installed-agent card and responsive update surface + +Replace the inline `AgentRuntimeUpdateControl` composition with a compact +icon-only trigger in `apps/web/components/settings/installed-agent-card.tsx`. +The icon remains visually small while its standalone hitbox is at least 44px +and has an agent-specific accessible label and tooltip. Unmanaged agents omit +the trigger. + +Replace `agent-runtime-update-control.tsx` with a responsive update surface: + +- Desktop uses a centered `Dialog`. +- Phone uses an inset bottom `Drawer`, following + `components/kanban/mobile-menu-sheet.tsx`, because this is a temporary, + single-purpose approval flow rather than a primary route. +- Header and approval/dismiss actions remain fixed; one `min-h-0 flex-1 + overflow-y-auto` body owns scrolling and clears the bottom safe area. +- Opening fetches the read-only preview and shows current/target versions, + host-only scope, automatic capability refresh, unchanged active sessions, + and the exact wrapping command. +- Approval is disabled until preview succeeds and is the only action that + sends the update POST. +- After approval, the same surface shows phase, bounded stdout/stderr, terminal + result, and retry. Closing or restarting the page discards the UI selection + and presentation state. +- No update information is rendered in the card. + +### Public documentation + +Update `docs/public/agents-and-profiles.md` to describe the icon-triggered +preview and approval flow, the exact-command disclosure, dialog-only streaming, +and the intentionally ephemeral browser presentation. The preview launcher +environment change is internal and does not need public environment-variable +documentation. + +## Tests + +- **Preview catalogue mode:** change `TestBuildExtractScript` first and confirm + it fails while the script still exports `only`, then make it pass with + `true`. File: `apps/backend/cmd/preview/sprite_ops_test.go`. +- **Read-only preview:** controller tests prove current/target/command output, + empty-argument display, unmanaged rejection, registry failure, and absence + of update execution. + File: + `apps/backend/internal/agent/settings/controller/agent_update_test.go`. +- **Exact display command:** extend the existing table test for + `buildCommandString` with an empty argument. File: + `apps/backend/internal/agent/settings/controller/controller_test.go`. +- **Preview handler:** handler tests prove the GET response and errors while + preserving POST enqueue behavior. File: + `apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go`. +- **Ephemeral state helper behavior:** retain focused Settings slice/WS tests + for bounded same-job output merging, and remove the retained-job hydration + expectation from the hook path. Files: + `apps/web/lib/state/slices/settings/settings-slice.test.ts` and + `apps/web/lib/ws/handlers/agents.test.ts`. +- **Public docs:** run `node --test scripts/validate-public-docs.test.mjs` and + `node scripts/validate-public-docs.mjs`. + +## E2E Tests + +- **Desktop approval:** opening the card icon shows versions, explanation, and + exact command while POST count remains zero; approving starts one job and + streams output/result inside the dialog. +- **Restart reset:** after progress or a terminal result, restarting the page + leaves only the icon on the card; a newly opened dialog contains a fresh + preview and no prior output/result. +- **Mobile parity:** the Pixel 5 flow taps the 44px icon, uses the bottom + drawer, reads the preview before approval, streams a long output without + document horizontal overflow, and reaches the fixed final actions. + +Files: + +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts` + +## Implementation Waves And Parallel Candidates + +Execution is sequential in the primary conversation. No subagents are +authorized. + +Wave 1: + +- [x] [Task 01: Restore the preview install catalogue](task-01-preview-catalogue.md) +- [x] [Task 02: Add the read-only update preview API](task-02-update-preview-api.md) + +Wave 2: + +- [x] [Task 03: Move updates into an ephemeral responsive dialog](task-03-update-dialog-ui.md) + +Wave 3: + +- [x] [Task 04: Verify desktop and mobile update flows](task-04-update-dialog-e2e.md) + +Tasks 01 and 02 touch disjoint files, but remain sequential unless the user +explicitly authorizes subagents after the model checkpoint. diff --git a/docs/plans/agent-runtime-update-dialog/task-01-preview-catalogue.md b/docs/plans/agent-runtime-update-dialog/task-01-preview-catalogue.md new file mode 100644 index 0000000000..1e053859d4 --- /dev/null +++ b/docs/plans/agent-runtime-update-dialog/task-01-preview-catalogue.md @@ -0,0 +1,51 @@ +--- +id: "01-preview-catalogue" +title: "Restore the preview install catalogue" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 01: Restore the preview install catalogue + +## Acceptance + +- PR previews enable `mock-agent` while retaining the built-in real-agent + catalogue. +- Settings can derive unavailable built-in agents and their install scripts + from the preview registry. +- E2E-only `KANDEV_MOCK_AGENT=only` semantics remain unchanged elsewhere. + +## Verification + +- RED/GREEN: + `cd apps/backend && go test -run TestBuildExtractScript ./cmd/preview` +- Supporting: + `cd apps/backend && go test -run TestProvide_MockAgentModes ./internal/agent/registry` + +## Files likely touched + +- `apps/backend/cmd/preview/sprite_ops.go` +- `apps/backend/cmd/preview/sprite_ops_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential by default. It is file-disjoint from Task 02 and may run in +parallel only with explicit user authorization. + +## Inputs + +- Spec agent-catalogue and Settings scenarios +- `registry.Provide` mock-mode contract +- Confirmed preview script root cause in `plan.md` + +## Output contract + +Report RED and GREEN results, changed files, preview-mode risk, and update this +task plus `plan.md` status in the primary conversation. diff --git a/docs/plans/agent-runtime-update-dialog/task-02-update-preview-api.md b/docs/plans/agent-runtime-update-dialog/task-02-update-preview-api.md new file mode 100644 index 0000000000..347cd4f2bb --- /dev/null +++ b/docs/plans/agent-runtime-update-dialog/task-02-update-preview-api.md @@ -0,0 +1,60 @@ +--- +id: "02-update-preview-api" +title: "Add the read-only update preview API" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 02: Add the read-only update preview API + +## Acceptance + +- A managed installed agent exposes current version, upstream target version, + trusted update argv, and a display command through a read-only endpoint. +- The display command quotes the update recipe's empty final argv entry as + `""`, preserving the exact direct-execution command. +- Preview never creates a job, claims maintenance state, or executes the + update command. +- Unmanaged, missing, unavailable-updater, and registry-resolution failures + return explicit errors and do not mutate runtime state. + +## Verification + +- RED/GREEN controller: + `cd apps/backend && go test -run 'TestAgentUpdatePreview' ./internal/agent/settings/controller` +- RED/GREEN handler: + `cd apps/backend && go test -run 'TestAgentUpdatePreviewEndpoint' ./internal/agent/settings/handlers` + +## Files likely touched + +- `apps/backend/internal/agent/settings/dto/dto.go` +- `apps/backend/internal/agent/settings/controller/controller.go` +- `apps/backend/internal/agent/settings/controller/controller_test.go` +- `apps/backend/internal/agent/settings/controller/agent_update.go` +- `apps/backend/internal/agent/settings/controller/agent_update_test.go` +- `apps/backend/internal/agent/settings/handlers/handlers.go` +- `apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential by default. It is file-disjoint from Task 01 and may run in +parallel only with explicit user authorization. + +## Inputs + +- Spec update-preview API and approval scenarios +- Existing `RuntimeUpdater`, `ManagedNPMRuntimeSpec.CacheUpdateCommand`, and + `buildCommandString` +- Existing update handler error mapping and direct-argv security boundary + +## Output contract + +Report RED and GREEN results, response/error contracts, changed files, command +trust-boundary risk, and update this task plus `plan.md` status. diff --git a/docs/plans/agent-runtime-update-dialog/task-03-update-dialog-ui.md b/docs/plans/agent-runtime-update-dialog/task-03-update-dialog-ui.md new file mode 100644 index 0000000000..84dc4244aa --- /dev/null +++ b/docs/plans/agent-runtime-update-dialog/task-03-update-dialog-ui.md @@ -0,0 +1,70 @@ +--- +id: "03-update-dialog-ui" +title: "Move updates into an ephemeral responsive dialog" +status: done +wave: 2 +depends_on: ["02-update-preview-api"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 03: Move updates into an ephemeral responsive dialog + +## Acceptance + +- Managed cards show only a compact, accessible update icon; versions, command, + output, progress, and results appear only in the opened surface. +- Opening is read-only and approval is unavailable until current version, + target version, explanation, and exact command are visible. +- The approved job streams stdout/stderr and terminal state in the current + surface, while closing or restarting the page discards its presentation. +- Desktop uses a centered dialog; phone uses an inset bottom drawer with a + 44px trigger, fixed header/footer, one safe-area-aware scrolling body, and no + document horizontal overflow. +- Public Agents documentation describes the preview/approval dialog and notes + that its visible progress and results reset on page restart. + +## Verification + +- Targeted state/WS tests: + `cd apps && pnpm --filter @kandev/web test -- --run lib/state/slices/settings/settings-slice.test.ts lib/ws/handlers/agents.test.ts` +- Typecheck: + `cd apps/web && pnpm run typecheck` +- Lint: + `cd apps && pnpm --filter @kandev/web lint` +- Public docs: + `node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs` + +## Files likely touched + +- `apps/web/lib/api/domains/agent-update-api.ts` +- `apps/web/lib/api/index.ts` +- `apps/web/hooks/domains/settings/use-agent-runtime-updates.ts` +- `apps/web/app/settings/agents/page.tsx` +- `apps/web/components/settings/installed-agent-card.tsx` +- `apps/web/components/settings/agent-runtime-update-control.tsx` +- `apps/web/components/settings/use-agent-update-dialog-state.ts` +- `apps/web/lib/state/slices/settings/settings-slice.test.ts` +- `docs/public/agents-and-profiles.md` + +## Dependencies + +Task 02 defines the read-only preview contract. + +## Parallelism + +Sequential. + +## Inputs + +- Spec approval, non-persistence, failure, and mobile scenarios +- `components/kanban/mobile-menu-sheet.tsx` mobile surface exemplar +- Existing Settings WS job merging and install/update conflict handling +- Mobile contract in `plan.md` +- `/docs-maintainer` task-oriented documentation requirements + +## Output contract + +Report RED/GREEN evidence where non-visual logic changes, rendered desktop and +phone evidence, changed files, accessibility/mobile/state risks, and update +this task plus `plan.md` status. diff --git a/docs/plans/agent-runtime-update-dialog/task-04-update-dialog-e2e.md b/docs/plans/agent-runtime-update-dialog/task-04-update-dialog-e2e.md new file mode 100644 index 0000000000..b4a9bf3867 --- /dev/null +++ b/docs/plans/agent-runtime-update-dialog/task-04-update-dialog-e2e.md @@ -0,0 +1,55 @@ +--- +id: "04-update-dialog-e2e" +title: "Verify desktop and mobile update dialog flows" +status: done +wave: 3 +depends_on: ["01-preview-catalogue", "02-update-preview-api", "03-update-dialog-ui"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 04: Verify desktop and mobile update dialog flows + +## Acceptance + +- Desktop Playwright proves the icon opens a read-only preview, approval starts + one job, and stdout/stderr plus terminal state remain inside the dialog. +- Restart coverage proves prior progress/output/result is absent from the card + and a newly opened dialog. +- Mobile Chrome proves the 44px icon and bottom drawer deliver the same value, + keep long content internally reachable, clear safe areas, and do not create + document horizontal overflow. + +## Verification + +- Desktop: + `cd apps/web && pnpm e2e:run tests/settings/agent-runtime-update.spec.ts` +- Mobile: + `cd apps/web && pnpm e2e:run tests/settings/mobile-agent-runtime-update.spec.ts -- --project=mobile-chrome` + +## Files likely touched + +- `apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts` +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` +- Runtime-update UI files only for test IDs or regressions exposed by RED + +## Dependencies + +Tasks 01-03. + +## Parallelism + +Sequential. + +## Inputs + +- Every revised user-visible spec scenario +- `/e2e` production-build and mobile input-modality requirements +- Mobile geometry contract in `plan.md` + +## Output contract + +Report exact RED and GREEN commands/results, rendered artifacts inspected, +desktop/mobile geometry evidence, uncertainties, and update this task plus +`plan.md` status. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index b8bd7c6398..0590ff27f1 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -24,19 +24,22 @@ The status shown on this page is authoritative for the current host. A CLI that ### Update a managed agent runtime -Installed Claude, Codex, OpenCode, Copilot, and Gemini cards provide an -**Update agent** action. Kandev invokes these managed npm runtimes without an +Installed Claude, Codex, OpenCode, Copilot, and Gemini cards provide an update +icon. Kandev invokes these managed npm runtimes without an exact version or `latest` tag during ordinary launches, so npm can reuse its best-effort execution cache. The version reported by the agent is the authoritative current version; Kandev does not infer it from the application release. -Select **Update agent** to deliberately check npm and refresh the managed -runtime on the Kandev host. The card shows the current and upstream target -versions, streams progress, and remains disabled until the update finishes. -After the package update, Kandev automatically starts a fresh ACP capability -probe. A successful probe replaces the advertised models, modes, configuration -options, commands, and runtime version without a page reload. +Select the update icon to deliberately check npm and refresh the managed +runtime on the Kandev host. Before anything changes, the update dialog shows +the current and upstream target versions, the exact command Kandev will run, +and how the update affects sessions. Select **Approve update** to start it. +The dialog streams progress and stdout/stderr until the update finishes; those +details do not appear on the agent card and are cleared when you restart the +page. After the package update, Kandev automatically starts a fresh ACP +capability probe. A successful probe replaces the advertised models, modes, +configuration options, commands, and runtime version without a page reload. The action changes later host probes and sessions only. It does not restart an active session, update a separately configured passthrough or authentication @@ -44,11 +47,11 @@ helper, or update remote executors and running containers. Those environments resolve their own unversioned runtime when they launch. If registry lookup or package execution fails, Kandev keeps the previous -capability catalogue and shows the captured failure. If the package updates but -the follow-up probe requires authentication, the card reports the update plus -the refresh error; authenticate the host agent and refresh again. Update job -history is short-lived and does not survive a backend restart, and npm cache -contents are not a durable Kandev installation record. +capability catalogue and shows the captured failure in the dialog. If the +package updates but the follow-up probe requires authentication, the dialog +reports the update plus the refresh error; authenticate the host agent and +refresh again. Update job history is short-lived and does not survive a backend +restart, and npm cache contents are not a durable Kandev installation record. ### Add a custom terminal agent diff --git a/docs/specs/agents/runtime-updates.md b/docs/specs/agents/runtime-updates.md index bf4c0904fa..941c043c1a 100644 --- a/docs/specs/agents/runtime-updates.md +++ b/docs/specs/agents/runtime-updates.md @@ -20,10 +20,16 @@ cache is already present. tag. - Normal launches may reuse npm's execution cache. Cache reuse is best-effort; Kandev does not present it as a durable installed-version guarantee. -- Each supported installed agent has a visible **Update agent** action on the - Settings > Agents page on desktop and mobile. -- Starting an update shows the current runtime version, the upstream target - version, live progress, and the terminal success or failure state. +- Each supported installed agent has a compact, accessible update icon action + on its Settings > Agents card on desktop and mobile. Update versions, + command details, progress, output, and results are not rendered in the card. +- Opening the update action is read-only. It opens an update dialog that + resolves and shows the current runtime version, upstream target version, + exact built-in command, host-only scope, capability refresh, and active + session behavior before an update can start. +- The update starts only after the operator approves it in the dialog. The + dialog then shows live stdout/stderr, progress, and the terminal success or + failure state. - A successful package update automatically starts a fresh host capability probe. The returned runtime version, models, modes, configuration options, commands, and capability status replace the previous cached values. @@ -74,6 +80,9 @@ runtime version. Unmanaged agents omit `runtime_update`. ### Update jobs +- `GET /api/v1/agent-update/:agentName/preview` resolves the current and + upstream target versions and returns the exact trusted built-in update + command without starting an update. - `POST /api/v1/agent-update/:agentName` starts or returns the active update job for a built-in managed agent. - `GET /api/v1/agent-update/jobs` returns active and recently completed update @@ -105,6 +114,32 @@ An update job contains: notifications with the same job identity and state. Output notifications carry only the appended output chunk. +An update preview contains: + +```json +{ + "agent_name": "claude-acp", + "package": "@agentclientprotocol/claude-agent-acp", + "current_version": "0.62.0", + "target_version": "0.63.0", + "command": [ + "npm", + "exec", + "--yes", + "--prefer-online", + "--package=@agentclientprotocol/claude-agent-acp", + "--", + "node", + "-e", + "" + ], + "command_string": "npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e \"\"" +} +``` + +The preview endpoint accepts only the built-in agent name. Package names, +versions, registry URLs, and command arguments are not caller-controlled. + ## State machine | State | Trigger | Observable behavior | @@ -122,6 +157,8 @@ Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job. - If npm registry metadata cannot be resolved, the job fails before changing the runtime and retains the prior capability data. +- If preview version resolution fails, the dialog shows the error and keeps + approval disabled. No update job or package command starts. - If the package update command fails or times out, the job fails and retains the prior capability data. - If the package update succeeds but the capability probe fails because @@ -142,6 +179,9 @@ Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job. - Update jobs and capability data are process-local and do not survive a Kandev backend restart. - Completed jobs remain queryable for the existing short job-retention window. +- Settings does not rehydrate retained update jobs after a browser page + restart. Update dialog state, output, and results are intentionally + page-local and disappear when the page is restarted. - npm's host cache may survive Kandev restarts, but Kandev does not own or guarantee that cache. - After a backend restart, normal host capability probing reports whichever @@ -153,8 +193,12 @@ Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job. without an explicit update, **THEN** Kandev invokes the unversioned package spec and does not require a repository-maintained version pin. - **GIVEN** Claude reports runtime version `0.62.0` and npm reports `0.63.0`, - **WHEN** the operator selects **Update agent**, **THEN** the card shows - `0.62.0 → 0.63.0` and streams progress until the job is terminal. + **WHEN** the operator opens its update action, **THEN** the dialog shows + `0.62.0 → 0.63.0`, the exact built-in command, and how the host update + affects capabilities and active sessions without starting the command. +- **GIVEN** the update dialog has a resolved preview, **WHEN** the operator + approves the update, **THEN** Kandev starts exactly one update and the dialog + streams stdout/stderr until the job is terminal. - **GIVEN** an update succeeds and the new runtime advertises an additional model, **WHEN** the automatic capability probe completes, **THEN** the new model appears without a page reload or manual Rescan. @@ -164,16 +208,25 @@ Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job. - **GIVEN** an install is active for an agent, **WHEN** an update is requested for that agent, **THEN** Kandev returns the active maintenance job rather than running install and update concurrently. -- **GIVEN** npm registry lookup fails, **WHEN** an update is requested, - **THEN** the card shows a retryable failure and retains the previous models. +- **GIVEN** npm registry lookup fails while preparing the dialog, **WHEN** the + update action is opened, **THEN** the dialog shows a retryable preview failure, + keeps approval disabled, and starts no update job. +- **GIVEN** npm registry lookup succeeds for the preview but fails after + approval because the registry changed, **WHEN** the update job resolves its + target again, **THEN** the dialog shows a retryable update failure and retains + the previous models. - **GIVEN** the package update succeeds but the fresh probe requires - authentication, **WHEN** the job finishes, **THEN** the card reports the new - package version, shows the refresh error, retains the previous models, and - keeps the existing authentication recovery action available. + authentication, **WHEN** the job finishes, **THEN** the dialog reports the new + package version and refresh error, the previous models remain available, and + the card keeps its existing authentication recovery action. - **GIVEN** an agent is unmanaged or native-only, **WHEN** Settings renders its installed card, **THEN** no update action is shown. +- **GIVEN** an update dialog has shown progress or a terminal result, **WHEN** + the operator restarts the page, **THEN** the dialog is closed and no prior + update details, output, or result appear on the agent card or in a newly + opened dialog. - **GIVEN** an update is running on a phone viewport, **WHEN** the operator - views the installed-agent card, **THEN** current and target versions, + views the update surface, **THEN** current and target versions, the command, progress, output, and retry state are reachable by touch without horizontal page scrolling. - **GIVEN** an agent session is already running, **WHEN** its host runtime is @@ -191,3 +244,4 @@ Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job. - Managing native-only update channels such as Cursor. - Updating separately distributed passthrough or authentication helper packages when they are not the managed ACP runtime. +- Resuming or recovering update dialog state after a browser page restart.