feat: improve agent runtime update flow (#1982)

This commit is contained in:
Carlos Florêncio
2026-07-27 19:34:13 +01:00
committed by GitHub
parent b118a4fe98
commit 2bd2bae2ea
27 changed files with 1543 additions and 435 deletions
+3 -2
View File
@@ -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
+5 -2
View File
@@ -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")
@@ -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 {
@@ -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"},
@@ -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)
}
}
@@ -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 {
@@ -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"`
}
@@ -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,
@@ -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()})
}
+14 -8
View File
@@ -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<string, InstallJob>;
updateJobs: Record<string, AgentUpdateJob>;
handleUpdate: (name: string) => Promise<void>;
previewUpdate: (name: string) => Promise<AgentUpdatePreview>;
startUpdate: (name: string) => Promise<AgentUpdateJob>;
setTuiDialogOpen: (open: boolean) => void;
handleRescan: () => Promise<void>;
};
@@ -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}
/>
@@ -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<Record<string, unknown>>;
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<string, unknown> = {}) {
const onUpdate = vi.fn();
render(
<ManagedCard
agent={discoveryAgent}
savedAgent={undefined}
displayName="Claude"
runtimeUpdate={runtimeUpdate}
onUpdate={onUpdate}
{...overrides}
/>,
);
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(
<ManagedCard
agent={discoveryAgent}
savedAgent={undefined}
displayName="Claude"
runtimeUpdate={runtimeUpdate}
onUpdate={vi.fn()}
updateJob={{
job_id: "update-1",
agent_name: AGENT_NAME,
status: "succeeded",
current_version: "1.0.0",
target_version: "1.1.0",
refresh_error: "Authentication required",
started_at: "2026-07-26T10:00:00Z",
}}
/>,
);
expect(
screen.getByText(/runtime updated, but capabilities could not be refreshed/i),
).not.toBeNull();
expect(screen.getByText(/authentication required/i)).not.toBeNull();
rerender(
<ManagedCard
agent={discoveryAgent}
savedAgent={undefined}
displayName="Claude"
runtimeUpdate={runtimeUpdate}
onUpdate={vi.fn()}
updateJob={{
job_id: "update-2",
agent_name: AGENT_NAME,
status: "failed",
current_version: "1.0.0",
error: "Registry unavailable",
started_at: "2026-07-26T10:01:00Z",
}}
/>,
);
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();
});
});
@@ -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<AgentUpdateJob["status"]>([
"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 <p className="break-words font-mono text-xs">{`${current} → ${job.target_version}`}</p>;
}
if (job?.status === "resolving") {
return <p className="break-words font-mono text-xs">{`${current} → Checking latest…`}</p>;
}
return <p className="break-words text-xs text-muted-foreground">Current version: {current}</p>;
}
function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) {
if (!job) return null;
if (job.status === "succeeded" && job.refresh_error) {
return (
<p
className="break-words text-xs text-amber-600 dark:text-amber-400"
className="break-words text-amber-600 dark:text-amber-400"
role="alert"
data-testid={`agent-update-result-${agentName}`}
>
@@ -78,7 +62,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
if (job.status === "succeeded") {
return (
<p
className="break-words text-xs text-green-600 dark:text-green-400"
className="break-words text-green-600 dark:text-green-400"
role="status"
data-testid={`agent-update-result-${agentName}`}
>
@@ -89,7 +73,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
if (job.status === "failed") {
return (
<p
className="break-words text-xs text-destructive"
className="break-words text-destructive"
role="alert"
data-testid={`agent-update-result-${agentName}`}
>
@@ -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 (
<div className="min-w-0 space-y-2" data-testid={`agent-update-control-${agentName}`}>
<VersionSummary runtimeUpdate={runtimeUpdate} job={job} />
<div
className="max-h-[calc(80dvh-10rem)] min-h-0 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 text-xs/relaxed"
data-testid={`agent-update-dialog-body-${agentName}`}
>
{loading && (
<p className="flex items-center gap-2 text-muted-foreground" role="status">
<IconLoader2 className="size-4 animate-spin" />
Checking the latest runtime version…
</p>
)}
{previewError && (
<div
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3"
role="alert"
>
<p className="flex items-start gap-2 text-destructive">
<IconAlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{previewError}</span>
</p>
<Button type="button" variant="outline" size="sm" onClick={onRetryPreview}>
Retry version check
</Button>
</div>
)}
{approveError && (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3"
role="alert"
data-testid={`agent-update-approve-error-${agentName}`}
>
<p className="flex items-start gap-2 text-destructive">
<IconAlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>Unable to start update: {approveError}</span>
</p>
</div>
)}
{preview && (
<>
<div className="space-y-1">
<p className="font-medium">Runtime version</p>
<p className="break-words font-mono text-sm">
{(job?.current_version || preview.current_version || "Unknown") +
" → " +
(job?.target_version || preview.target_version)}
</p>
</div>
<div className="space-y-1 text-muted-foreground">
<p>
This updates the managed runtime on this Kandev host, then refreshes its models and
capabilities.
</p>
<p>
Active sessions keep running; only later probes and launches use the refreshed
runtime.
</p>
</div>
<div className="space-y-1">
<p className="font-medium">Command that will run</p>
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
{preview.command_string}
</pre>
</div>
</>
)}
{phase && (
<p
className="flex items-center gap-1.5 text-xs text-muted-foreground"
className="flex items-center gap-1.5 text-muted-foreground"
role="status"
data-testid={`agent-update-phase-${agentName}`}
>
<IconLoader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
<span className="min-w-0 break-words">{phase}</span>
</p>
)}
{installInFlight && (
<p className="break-words text-xs text-muted-foreground">
Agent installation is in progress.
<IconLoader2 className="size-3.5 shrink-0 animate-spin" />
{phase}
</p>
)}
{job?.output && (
<pre
data-testid={`agent-update-log-${agentName}`}
className="max-h-40 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words rounded-md bg-muted px-2 py-1.5 font-mono text-xs text-muted-foreground"
className="whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground"
>
{job.output}
</pre>
)}
<UpdateResult agentName={agentName} job={job} />
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 w-full cursor-pointer whitespace-normal"
disabled={disabled}
aria-label={buttonLabel}
onClick={() => onUpdate(agentName)}
data-testid={`agent-update-button-${agentName}`}
>
{updateInFlight ? (
<IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconRefresh className="mr-2 h-4 w-4" />
)}
{buttonLabel}
</Button>
</div>
);
}
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 = (
<>
<Button type="button" variant="outline" onClick={onClose}>
{job?.status === "succeeded" ? "Done" : "Cancel"}
</Button>
{showApprove && (
<Button
type="button"
disabled={!canApprove}
onClick={onApprove}
data-testid={`agent-update-confirm-${agentName}`}
>
{starting && <IconLoader2 className="mr-2 size-4 animate-spin" />}
{canRetry ? "Retry update" : "Approve update"}
</Button>
)}
</>
);
if (mobile) {
return (
<DrawerFooter className="border-t pb-[max(1rem,env(safe-area-inset-bottom))]">
{content}
</DrawerFooter>
);
}
return <DialogFooter className="border-t px-4 py-3">{content}</DialogFooter>;
}
function UpdateTrigger({
agentName,
displayName,
installInFlight,
onOpen,
}: {
agentName: string;
displayName: string;
installInFlight: boolean;
onOpen: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={installInFlight ? 0 : -1} className="inline-flex">
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 cursor-pointer active:scale-95 sm:h-7 sm:w-7"
aria-label={`Update ${displayName}`}
disabled={installInFlight}
onClick={onOpen}
data-testid={`agent-update-trigger-${agentName}`}
>
<IconRefresh className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{installInFlight ? "Agent installation is in progress" : `Update ${displayName}`}
</TooltipContent>
</Tooltip>
);
}
export function AgentRuntimeUpdateControl({
agentName,
displayName,
runtimeUpdate,
job,
installJob,
onPreview,
onUpdate,
}: {
agentName: string;
displayName: string;
runtimeUpdate: RuntimeUpdate;
job?: AgentUpdateJob;
installJob?: InstallJob;
onPreview: (agentName: string) => Promise<AgentUpdatePreview>;
onUpdate: (agentName: string) => Promise<AgentUpdateJob>;
}) {
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 = (
<UpdateBody
agentName={agentName}
preview={preview}
loading={loading}
previewError={previewError}
approveError={approveError}
job={activeJob}
onRetryPreview={() => void loadPreview()}
/>
);
const footer = (mobile = false) => (
<UpdateFooter
agentName={agentName}
preview={preview}
previewError={previewError}
job={activeJob}
loading={loading}
starting={starting}
installInFlight={installInFlight}
onApprove={() => void approve()}
onClose={() => handleOpenChange(false)}
mobile={mobile}
/>
);
return (
<>
<UpdateTrigger
agentName={agentName}
displayName={displayName}
installInFlight={installInFlight}
onOpen={() => handleOpenChange(true)}
/>
{isMobile ? (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent className="max-h-[80dvh]" data-testid={`agent-update-drawer-${agentName}`}>
<DrawerHeader className="shrink-0 text-left">
<DrawerTitle>Update {displayName}</DrawerTitle>
<DrawerDescription>
Review the update before Kandev changes this host runtime.
</DrawerDescription>
</DrawerHeader>
{body}
{footer(true)}
</DrawerContent>
</Drawer>
) : (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[80dvh] gap-0 p-0 sm:max-w-xl"
data-testid={`agent-update-dialog-${agentName}`}
>
<DialogHeader className="px-4 pt-4">
<DialogTitle>Update {displayName}</DialogTitle>
<DialogDescription>
Review the update before Kandev changes this host runtime.
</DialogDescription>
</DialogHeader>
{body}
{footer()}
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -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<AgentUpdatePreview>;
onUpdate?: (agentName: string) => Promise<AgentUpdateJob>;
/**
* 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({
<h4 className="min-w-0 truncate font-medium">{displayName}</h4>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
{configured && <Badge variant="outline">Configured</Badge>}
{probing && (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid={`probing-icon-${agent.name}`}
className="ml-auto flex items-center text-muted-foreground cursor-help"
aria-label="Checking authentication"
>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
</span>
</TooltipTrigger>
<TooltipContent>Checking agent capabilities and authentication...</TooltipContent>
</Tooltip>
)}
{authRequired && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onAuthClick}
data-testid={`auth-icon-${agent.name}`}
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-amber-500 cursor-pointer hover:bg-amber-500/10"
aria-label="Authentication required"
>
<IconLock className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
{loginAvailable
? "Authentication required - click to open login terminal"
: "Authentication required - click to open a shell and sign in"}
</TooltipContent>
</Tooltip>
)}
<div className="ml-auto flex shrink-0 items-center gap-1">
{probing && (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid={`probing-icon-${agent.name}`}
className="flex items-center text-muted-foreground cursor-help"
aria-label="Checking authentication"
>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
</span>
</TooltipTrigger>
<TooltipContent>Checking agent capabilities and authentication...</TooltipContent>
</Tooltip>
)}
{authRequired && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onAuthClick}
data-testid={`auth-icon-${agent.name}`}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-amber-500 cursor-pointer hover:bg-amber-500/10"
aria-label="Authentication required"
>
<IconLock className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
{loginAvailable
? "Authentication required - click to open login terminal"
: "Authentication required - click to open a shell and sign in"}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<p
className="text-xs text-muted-foreground line-clamp-2 min-h-[2rem]"
@@ -115,6 +118,7 @@ export function InstalledAgentCard({
runtimeUpdate,
updateJob,
installJob,
onPreview,
onUpdate,
onAuthComplete,
}: Props) {
@@ -146,27 +150,35 @@ export function InstalledAgentCard({
loginAvailable={loginAvailable}
onAuthClick={handleAuthClick}
/>
{runtimeUpdate?.supported && onUpdate && (
<AgentRuntimeUpdateControl
agentName={agent.name}
runtimeUpdate={runtimeUpdate}
job={updateJob}
installJob={installJob}
onUpdate={onUpdate}
/>
)}
<Button size="sm" className="cursor-pointer mt-auto" asChild>
<Link
href={
hasAgentRecord
? `/settings/agents/${encodeURIComponent(agent.name)}?mode=create`
: `/settings/agents/${encodeURIComponent(agent.name)}`
}
<div className="mt-auto flex items-center gap-2">
<Button
size="sm"
className="h-11 min-h-11 flex-1 cursor-pointer sm:h-7 sm:min-h-7"
asChild
>
<IconSettings className="h-4 w-4 mr-2" />
{hasAgentRecord ? "Create new profile" : "Setup Profile"}
</Link>
</Button>
<Link
href={
hasAgentRecord
? `/settings/agents/${encodeURIComponent(agent.name)}?mode=create`
: `/settings/agents/${encodeURIComponent(agent.name)}`
}
>
<IconSettings className="mr-2 h-4 w-4" />
{hasAgentRecord ? "Create new profile" : "Setup Profile"}
</Link>
</Button>
{runtimeUpdate?.supported && onPreview && onUpdate && (
<AgentRuntimeUpdateControl
agentName={agent.name}
displayName={displayName}
runtimeUpdate={runtimeUpdate}
job={updateJob}
installJob={installJob}
onPreview={onPreview}
onUpdate={onUpdate}
/>
)}
</div>
<AuthDialogs
agent={agent}
loginOpen={loginOpen}
@@ -0,0 +1,109 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<AgentUpdatePreview>();
const secondRequest = deferred<AgentUpdatePreview>();
const onPreview = vi
.fn<(agentName: string) => Promise<AgentUpdatePreview>>()
.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<AgentUpdateJob>();
const onUpdate = vi
.fn<(agentName: string) => Promise<AgentUpdateJob>>()
.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);
});
});
@@ -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<AgentUpdatePreview>;
onUpdate: (agentName: string) => Promise<AgentUpdateJob>;
};
export function useAgentUpdateDialogState({
agentName,
job,
onPreview,
onUpdate,
}: UseAgentUpdateDialogStateOptions) {
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState<AgentUpdatePreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [approveError, setApproveError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [activeJobID, setActiveJobID] = useState<string | null>(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,
};
}
@@ -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);
@@ -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);
});
});
@@ -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);
});
});
@@ -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<AgentUpdateJob> => {
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 };
}
@@ -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<AgentUpdatePreview> {
return fetchJson<AgentUpdatePreview>(`/api/v1/agent-update/${agentName}/preview`, options);
}
export async function updateAgent(
agentName: string,
options?: ApiRequestOptions,
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+16 -13
View File
@@ -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
+66 -12
View File
@@ -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.