feat: improve agent runtime update flow (#1982)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user