feat(agent): pin managed runtimes and add update awareness (#2906)

This commit is contained in:
Carlos Florêncio
2026-08-22 22:08:45 +00:00
committed by GitHub
parent dd1b3d3d3b
commit 358a41a8a7
92 changed files with 5334 additions and 584 deletions
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Contract tests for the managed runtime pin update workflow."""
from pathlib import Path
import re
import unittest
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "update-agent-runtime-pins.yml"
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
SCRIPT = REPO_ROOT / "scripts" / "update-agent-runtime-pins.mjs"
class ManagedRuntimePinWorkflowContractTest(unittest.TestCase):
def setUp(self) -> None:
self.workflow = WORKFLOW.read_text(encoding="utf-8")
def test_workflow_has_weekly_and_manual_triggers(self) -> None:
self.assertIn("schedule:", self.workflow)
self.assertRegex(self.workflow, r"cron:\s*[\"']?[0-9]+ [0-9]+ \* \* [0-6][\"']?")
self.assertIn("workflow_dispatch:", self.workflow)
def test_workflow_uses_least_privilege_and_dedicated_app_token(self) -> None:
self.assertIn("permissions:\n contents: read", self.workflow)
self.assertIn("permission-contents: write", self.workflow)
self.assertIn("permission-pull-requests: write", self.workflow)
self.assertNotIn("permissions:\n contents: write", self.workflow)
self.assertNotIn("permissions:\n pull-requests: write", self.workflow)
self.assertIn("MANAGED_RUNTIME_PIN_APP_PRIVATE_KEY", self.workflow)
self.assertIn("MANAGED_RUNTIME_PIN_APP_CLIENT_ID", self.workflow)
self.assertNotIn("secrets.GITHUB_TOKEN", self.workflow)
self.assertNotIn("GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}", self.workflow)
def test_workflow_groups_one_conventional_commit_pr_on_stable_branch(self) -> None:
self.assertIn("update-managed-runtime-pins", self.workflow)
self.assertIn("chore: update managed runtime pins", self.workflow)
self.assertIn("gh pr create", self.workflow)
self.assertNotIn("gh pr merge", self.workflow)
self.assertNotIn("--auto", self.workflow)
self.assertIn("at most one", self.workflow)
def test_workflow_refreshes_existing_pr_by_number_and_authenticates_push(self) -> None:
self.assertIn('open_pr_number="$(gh pr list', self.workflow)
self.assertIn('gh pr edit "$open_pr_number"', self.workflow)
self.assertNotIn('gh pr edit \\\n --repo "$GITHUB_REPOSITORY" \\\n --head "$BOT_BRANCH"', self.workflow)
push_start = self.workflow.index("- name: Commit and push one grouped change")
pr_start = self.workflow.index("- name: Create or refresh the single grouped PR")
push_step = self.workflow[push_start:pr_start]
self.assertIn("GH_TOKEN: ${{ steps.app-token.outputs.token }}", push_step)
def test_workflow_validates_before_any_commit_or_push(self) -> None:
update_pos = self.workflow.index("node scripts/update-agent-runtime-pins.mjs")
validation_pos = self.workflow.index("go test ./internal/agent/agents")
commit_pos = self.workflow.index("git commit")
push_pos = self.workflow.index("git push")
self.assertLess(update_pos, validation_pos)
self.assertLess(validation_pos, commit_pos)
self.assertLess(commit_pos, push_pos)
self.assertIn("node --test scripts/update-agent-runtime-pins.test.mjs", self.workflow)
self.assertIn("--github-output", self.workflow)
self.assertIn("working-directory: apps/backend", self.workflow)
self.assertIn(
"go test ./internal/agent/agents ./internal/agent/managedruntime "
"./internal/agent/runtime/lifecycle ./internal/agent/hostutility "
"./internal/agent/settings/controller ./internal/agent/settings/handlers",
self.workflow,
)
self.assertIn("if: steps.update.outputs.changed == 'true'", self.workflow)
def test_all_actions_are_commit_pinned(self) -> None:
for line in self.workflow.splitlines():
if " uses: " not in line:
continue
self.assertRegex(line, r"uses:\s+[^@\s]+@[0-9a-f]{40}(?:\s+#.*)?$")
def test_lint_workflow_runs_this_contract(self) -> None:
lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8")
self.assertIn(
"python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py",
lint_workflow,
)
def test_updater_script_exists_and_has_no_network_in_fixture_tests(self) -> None:
self.assertTrue(SCRIPT.is_file())
script = SCRIPT.read_text(encoding="utf-8")
self.assertIn("registry.npmjs.org", script)
self.assertIn("fs.rename", script)
if __name__ == "__main__":
unittest.main()
@@ -71,6 +71,9 @@ jobs:
- name: Test E2E workflow contract
run: python3 .github/scripts/e2e-tests-workflow-contract_test.py
- name: Test managed runtime pin update workflow contract
run: python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@@ -0,0 +1,115 @@
name: Update managed runtime pins
on:
schedule:
# Refresh the reviewed runtime pins every Monday at 06:17 UTC, then open
# or refresh the single grouped updater PR when a stable version changes.
- cron: "17 6 * * 1"
workflow_dispatch:
# The job mints a narrower GitHub App token only after checkout. The default
# workflow token is never used to create the generated PR, so its events retain
# the repository's normal CI behavior.
permissions:
contents: read
concurrency:
group: managed-runtime-pin-update
cancel-in-progress: false
jobs:
update:
name: Open managed runtime pin PR
runs-on: ubuntu-latest
permissions:
contents: read
env:
BOT_BRANCH: automation/update-managed-runtime-pins
steps:
- name: Checkout main
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- name: Test pin updater
run: node --test scripts/update-agent-runtime-pins.test.mjs
- name: Create managed runtime pin App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.MANAGED_RUNTIME_PIN_APP_CLIENT_ID }}
private-key: ${{ secrets.MANAGED_RUNTIME_PIN_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Configure git authentication
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: gh auth setup-git
- name: Prepare stable bot branch
run: |
set -euo pipefail
git fetch origin main
git fetch origin "refs/heads/${BOT_BRANCH}:refs/remotes/origin/${BOT_BRANCH}" || true
git checkout -B "${BOT_BRANCH}" origin/main
- name: Update trusted catalogue
id: update
run: node scripts/update-agent-runtime-pins.mjs --github-output "$GITHUB_OUTPUT"
- name: Test managed runtime catalogue and commands
working-directory: apps/backend
run: go test ./internal/agent/agents ./internal/agent/managedruntime ./internal/agent/runtime/lifecycle ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers
- name: Commit and push one grouped change
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
git config user.name "kandev-runtime-updater[bot]"
git config user.email "kandev-runtime-updater[bot]@users.noreply.github.com"
git add apps/backend/internal/agent/agents/managed_npm_runtime_versions.json
git diff --cached --check
git commit -m "chore: update managed runtime pins"
git push --force-with-lease origin "${BOT_BRANCH}"
# The stable branch and head filter keep this workflow to at most one
# open grouped PR, even when the scheduler and manual trigger overlap.
- name: Create or refresh the single grouped PR
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
cat > /tmp/managed-runtime-pin-pr-body.md <<'EOF'
## Summary
Refresh the reviewed managed npm runtime defaults from stable `latest` metadata.
The catalogue is validated before this PR is created. This workflow does not install or activate a runtime.
EOF
open_pr_number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BOT_BRANCH" --base main --state open --json number --jq '.[0].number // ""')"
if [[ -z "$open_pr_number" ]]; then
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head "$BOT_BRANCH" \
--title "chore: update managed runtime pins" \
--body-file /tmp/managed-runtime-pin-pr-body.md
else
gh pr edit "$open_pr_number" \
--repo "$GITHUB_REPOSITORY" \
--title "chore: update managed runtime pins" \
--body-file /tmp/managed-runtime-pin-pr-body.md
fi
@@ -1,30 +1,38 @@
# Managed npm ACP runtimes
Kandev invokes these managed npm-provided ACP runtimes by unversioned package
name:
Kandev invokes these managed npm-provided ACP runtimes with the exact effective
version. The default values below are the reviewed pins in
`managed_npm_runtime_versions.json` at this commit. An operator selection
replaces the default for that installation until **Use Kandev default** clears
it.
| Agent | Package | ACP arguments |
| --- | --- | --- |
| Claude | `@agentclientprotocol/claude-agent-acp` | none |
| Codex | `@agentclientprotocol/codex-acp` | none |
| OpenCode | `opencode-ai` | `acp --print-logs --log-level ERROR` |
| Copilot | `@github/copilot` | `--acp` |
| Gemini | `@google/gemini-cli` | `--acp` |
| Agent | Package | Default version | ACP arguments |
| --- | --- | --- | --- |
| Claude | `@agentclientprotocol/claude-agent-acp` | `0.70.0` | none |
| Codex | `@agentclientprotocol/codex-acp` | `1.6.0` | none |
| OpenCode | `opencode-ai` | `1.18.18` | `acp --print-logs --log-level ERROR` |
| Copilot | `@github/copilot` | `1.0.75` | `--acp` |
| Gemini | `@google/gemini-cli` | `0.52.0` | `--acp` |
Normal capability probes, sessions, container commands, and one-shot
inference use `npx --yes --prefer-offline` with the package name and arguments
above. OpenCode's error-only log flags are part of its managed command so
agentctl can observe terminal provider diagnostics without reading OpenCode's
private log files. This lets npm reuse its execution cache without making the cache a
durable installed-version guarantee. Kandev records the version reported by
the ACP initialize response instead of inferring it from source.
Normal capability probes, sessions, container commands, and one-shot inference
use `npx --yes --prefer-offline package@effective-version` with the ACP
arguments above. For example, an unmodified Claude installation currently
launches `npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp@0.70.0`.
OpenCode's error-only log flags
are part of its managed command so agentctl can observe terminal provider
diagnostics without reading OpenCode's private log files. The exact top-level
package is pinned, but npm transitive ranges, its cache, and the registry still
affect reproducibility. Kandev records the version reported by the ACP
initialize response instead of inferring it from source.
The **Update agent** action in Settings is the explicit freshness boundary for
the Kandev host. It resolves the upstream npm version, refreshes the
unversioned execution-cache entry with online preference, and then launches a
fresh ACP capability probe. Successful probes replace the advertised version,
models, modes, commands, and configuration options used for later launches.
Already-running sessions continue with their existing process.
the Kandev host. Its candidate preparation resolves the requested trusted
`package@effective-version` with online preference, then launches a fresh ACP
capability probe. Successful probes replace the advertised version, models,
modes, commands, and configuration options used for later launches.
Already-running sessions continue with their existing process. The normal
launch path remains offline-preferred; the update path is online-preferred so
it can refresh stale npm metadata.
ACP protocol negotiation and advertised capabilities are the compatibility
boundary. Kandev does not maintain an exact package-version allowlist or
@@ -33,6 +41,8 @@ update commands come only from built-in agent metadata; callers cannot supply
package names, versions, registry URLs, or shell text.
Separately configured passthrough commands, native authentication helpers, and
native-only agents such as Cursor are outside this managed update path. Remote
executors and containers resolve their own unversioned runtime when they
launch; the host Settings action does not update those environments.
native-only agents such as Cursor are outside this managed update path. The
install-wide effective version is included in commands built for remote
executors and new containers, but the Settings action does not prepare their
package cache. Each remote environment must resolve the exact package when it
launches.
+1 -1
View File
@@ -215,7 +215,7 @@ type CommandOptions struct {
// the native binary (e.g. "copilot --acp") instead of "npx -y <pkg>".
PreferNativeBinary bool
// ManagedRuntimeVersion is an internal exact version override for trusted
// managed npm ACP runtimes. Empty preserves the legacy unversioned command.
// managed npm ACP runtimes. Empty uses the built-in exact version pin.
ManagedRuntimeVersion string
}
@@ -111,7 +111,7 @@ func (a *ClaudeACP) BuildCommand(opts CommandOptions) Command {
}
func (a *ClaudeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{Package: claudeACPPackage}
return newManagedNPMRuntimeSpec(claudeACPPackage)
}
func (a *ClaudeACP) Runtime() *RuntimeConfig {
@@ -8,7 +8,7 @@ import (
func TestClaudeACPUsesManagedRuntime(t *testing.T) {
a := NewClaudeACP()
want := []string{"npx", "--yes", "--prefer-offline", "@agentclientprotocol/claude-agent-acp"}
want := a.ManagedNPMRuntime().CachedACPCommand().Args()
if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) {
t.Fatalf("BuildCommand = %#v, want %#v", got, want)
@@ -100,7 +100,7 @@ func (a *CodexACP) BuildCommand(opts CommandOptions) Command {
}
func (a *CodexACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{Package: codexACPPackage}
return newManagedNPMRuntimeSpec(codexACPPackage)
}
func (a *CodexACP) Runtime() *RuntimeConfig {
@@ -25,8 +25,9 @@ func TestCodexACP_PermissionSettings_NoBridgeCLIFlags(t *testing.T) {
}
func TestCodexACP_BuildCommand_NoCodexCLIFlags(t *testing.T) {
want := []string{"npx", "--yes", "--prefer-offline", codexACPPackage}
cmd := NewCodexACP().BuildCommand(CommandOptions{
agent := NewCodexACP()
want := agent.ManagedNPMRuntime().CachedACPCommand().Args()
cmd := agent.BuildCommand(CommandOptions{
PermissionValues: map[string]bool{PermissionKeyAutoApprove: true},
})
if !slices.Equal(cmd.Args(), want) {
@@ -36,7 +37,7 @@ func TestCodexACP_BuildCommand_NoCodexCLIFlags(t *testing.T) {
func TestCodexACP_UsesAgentClientProtocolBridge(t *testing.T) {
a := NewCodexACP()
want := []string{"npx", "--yes", "--prefer-offline", "@agentclientprotocol/codex-acp"}
want := a.ManagedNPMRuntime().CachedACPCommand().Args()
if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) {
t.Fatalf("BuildCommand = %#v, want %#v", got, want)
@@ -95,7 +95,7 @@ func (a *CopilotACP) BuildCommand(opts CommandOptions) Command {
}
func (a *CopilotACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{Package: copilotACPPackage, ACPArgs: []string{"--acp"}}
return newManagedNPMRuntimeSpec(copilotACPPackage, "--acp")
}
func (a *CopilotACP) Runtime() *RuntimeConfig {
@@ -50,7 +50,7 @@ func TestCopilotACP_BuildCommand_NoCLIFlagSpecialCasing(t *testing.T) {
ag := NewCopilotACP()
cmd := ag.BuildCommand(CommandOptions{})
got := cmd.Args()
want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"}
want := ag.ManagedNPMRuntime().CachedACPCommand().Args()
if len(got) != len(want) {
t.Fatalf("argv length mismatch\n got: %#v\n want: %#v", got, want)
}
@@ -63,7 +63,7 @@ func TestCopilotACP_BuildCommand_NoCLIFlagSpecialCasing(t *testing.T) {
func TestCopilotACP_UsesManagedPackageOnEveryNPMCommandSurface(t *testing.T) {
ag := NewCopilotACP()
wantACP := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"}
wantACP := ag.ManagedNPMRuntime().CachedACPCommand().Args()
wantPassthrough := []string{"npx", "--yes", "--prefer-offline", "@github/copilot"}
if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, wantACP) {
@@ -91,7 +91,7 @@ func TestCopilotACP_BuildCommand_KeepsManagedPackageWhenNativeBinaryIsPresent(t
}
native := ag.BuildCommand(CommandOptions{PreferNativeBinary: true}).Args()
wantNative := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"}
wantNative := ag.ManagedNPMRuntime().CachedACPCommand().Args()
if len(native) != len(wantNative) {
t.Fatalf("native argv mismatch\n got: %#v\n want: %#v", native, wantNative)
}
+1 -1
View File
@@ -82,7 +82,7 @@ func (a *Gemini) BuildCommand(opts CommandOptions) Command {
}
func (a *Gemini) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{Package: geminiPackage, ACPArgs: []string{"--acp"}}
return newManagedNPMRuntimeSpec(geminiPackage, "--acp")
}
func (a *Gemini) Runtime() *RuntimeConfig {
@@ -7,7 +7,7 @@ import (
func TestGeminiUsesManagedPackageOnEveryNPMCommandSurface(t *testing.T) {
ag := NewGemini()
wantACP := []string{"npx", "--yes", "--prefer-offline", "@google/gemini-cli", "--acp"}
wantACP := ag.ManagedNPMRuntime().CachedACPCommand().Args()
wantPassthrough := []string{"npx", "--yes", "--prefer-offline", "@google/gemini-cli"}
if got := ag.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, wantACP) {
@@ -1,6 +1,9 @@
package agents
import (
"fmt"
"strings"
"github.com/kandev/kandev/internal/agent/managedruntime"
)
@@ -8,12 +11,46 @@ import (
// Package and ACPArgs must come from trusted agent metadata, never request
// input, because update jobs execute them directly.
type ManagedNPMRuntimeSpec struct {
Package string
ACPArgs []string
Package string
DefaultVersion string
ACPArgs []string
}
// DefaultVersionOrPinned returns the reviewed default version, including the
// catalogue fallback for specs that do not embed one. Custom specs without an
// explicit default remain empty rather than treating the bare package name as
// a version.
func (s ManagedNPMRuntimeSpec) DefaultVersionOrPinned() string {
if s.DefaultVersion != "" {
return s.DefaultVersion
}
packageSpec := s.PackageSpec("")
if strings.HasPrefix(packageSpec, s.Package+"@") {
return strings.TrimPrefix(packageSpec, s.Package+"@")
}
return ""
}
func newManagedNPMRuntimeSpec(packageName string, acpArgs ...string) ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{
Package: packageName,
DefaultVersion: MustDefaultManagedNPMRuntimeVersion(packageName),
ACPArgs: acpArgs,
}
}
// PackageSpec returns the trusted package name or exact package@version spec.
func (s ManagedNPMRuntimeSpec) PackageSpec(version string) string {
if version == "" {
version = s.DefaultVersion
if version == "" {
if reviewed, err := DefaultManagedNPMRuntimeVersion(s.Package); err == nil {
version = reviewed
} else if isBuiltInManagedNPMRuntimePackage(s.Package) {
panic(fmt.Sprintf("managed runtime default is unavailable for %q: %v", s.Package, err))
}
}
}
if version == "" {
return s.Package
}
@@ -23,13 +60,13 @@ func (s ManagedNPMRuntimeSpec) PackageSpec(version string) string {
// ExecutionCacheKey returns npm's deterministic _npx execution-tree key for
// this trusted package spec. npm derives it from the full package string using
// SHA-512 and the first 16 lowercase hexadecimal characters. The optional
// argument preserves the unversioned legacy key when omitted.
// argument uses the reviewed default when omitted.
func (s ManagedNPMRuntimeSpec) ExecutionCacheKey(versions ...string) string {
return managedruntime.NpxExecutionCacheKey(s.PackageSpec(firstVersion(versions)))
}
// ACPCommand returns the normal launch command for the exact version when one
// is supplied. An empty version keeps the legacy unversioned behavior.
// is supplied. An empty version uses the reviewed default.
func (s ManagedNPMRuntimeSpec) ACPCommand(version string) Command {
return s.ACPCommandWithNpmPreference(version, false)
}
@@ -47,7 +84,7 @@ func (s ManagedNPMRuntimeSpec) ACPCommandWithNpmPreference(version string, prefe
return NewCommand(args...)
}
// CachedACPCommand returns the legacy unversioned launch command.
// CachedACPCommand returns the default exact-version launch command.
func (s ManagedNPMRuntimeSpec) CachedACPCommand() Command {
return s.ACPCommand("")
}
@@ -75,3 +112,8 @@ func firstVersion(versions []string) string {
}
return versions[0]
}
func isBuiltInManagedNPMRuntimePackage(packageName string) bool {
_, err := DefaultManagedNPMRuntimeVersion(packageName)
return err == nil
}
@@ -24,24 +24,33 @@ func TestManagedNPMRuntimeContracts(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
spec := tt.agent.ManagedNPMRuntime()
wantDefault := spec.DefaultVersionOrPinned()
if wantDefault == "" {
t.Fatal("DefaultVersionOrPinned() is empty")
}
if got := spec.Package; got != tt.wantPackage {
t.Fatalf("Package = %q, want %q", got, tt.wantPackage)
}
if got := spec.DefaultVersion; got != wantDefault {
t.Fatalf("DefaultVersion = %q, want %q", got, wantDefault)
}
if got := spec.ACPArgs; !slices.Equal(got, tt.wantACPArgs) {
t.Fatalf("ACPArgs = %#v, want %#v", got, tt.wantACPArgs)
}
cached := spec.CachedACPCommand()
wantCached := append([]string{"npx", "--yes", "--prefer-offline", tt.wantPackage}, tt.wantACPArgs...)
wantCached := append([]string{"npx", "--yes", "--prefer-offline", tt.wantPackage + "@" + wantDefault}, tt.wantACPArgs...)
if !slices.Equal(cached.Args(), wantCached) {
t.Fatalf("CachedACPCommand = %#v, want %#v", cached.Args(), wantCached)
}
assertUnversionedPackage(t, cached.Args(), tt.wantPackage)
if got := spec.PackageSpec(""); got != tt.wantPackage+"@"+wantDefault {
t.Fatalf("empty PackageSpec = %q, want exact default", got)
}
update := spec.CacheUpdateCommand()
wantUpdate := []string{
"npm", "exec", "--yes", "--prefer-online",
"--package=" + tt.wantPackage, "--", "node", "-e", "",
"--package=" + tt.wantPackage + "@" + wantDefault, "--", "node", "-e", "",
}
if !slices.Equal(update.Args(), wantUpdate) {
t.Fatalf("CacheUpdateCommand = %#v, want %#v", update.Args(), wantUpdate)
@@ -55,8 +64,27 @@ func TestManagedNPMRuntimeContracts(t *testing.T) {
func TestManagedNPMRuntimeExecutionCacheKeyMatchesNPM(t *testing.T) {
spec := ManagedNPMRuntimeSpec{Package: "opencode-ai"}
if got := spec.ExecutionCacheKey(); got != "e2094862b59aac7b" {
t.Fatalf("ExecutionCacheKey = %q, want npm key e2094862b59aac7b", got)
if got := spec.ExecutionCacheKey(); got != "305cdb391114ad88" {
t.Fatalf("ExecutionCacheKey = %q, want npm key 305cdb391114ad88", got)
}
}
func TestManagedNPMRuntimeDefaultVersionOrPinned(t *testing.T) {
tests := []struct {
name string
spec ManagedNPMRuntimeSpec
want string
}{
{name: "explicit default", spec: ManagedNPMRuntimeSpec{Package: "@scope/managed", DefaultVersion: "1.2.3"}, want: "1.2.3"},
{name: "built-in catalogue fallback", spec: ManagedNPMRuntimeSpec{Package: "opencode-ai"}, want: MustDefaultManagedNPMRuntimeVersion("opencode-ai")},
{name: "custom package without default", spec: ManagedNPMRuntimeSpec{Package: "@scope/managed"}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.spec.DefaultVersionOrPinned(); got != tt.want {
t.Fatalf("DefaultVersionOrPinned() = %q, want %q", got, tt.want)
}
})
}
}
@@ -134,14 +162,3 @@ func TestManagedAgentsHonorExactVersionCommandOption(t *testing.T) {
})
}
}
func assertUnversionedPackage(t *testing.T, argv []string, wantPackage string) {
t.Helper()
packageArg := argv[3]
if packageArg != wantPackage {
t.Fatalf("package argv = %q, want unversioned %q", packageArg, wantPackage)
}
if packageArg == wantPackage+"@latest" {
t.Fatalf("package argv contains explicit latest: %q", packageArg)
}
}
@@ -0,0 +1,73 @@
package agents
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/kandev/kandev/internal/agent/managedruntime"
)
//go:embed managed_npm_runtime_versions.json
var managedNPMRuntimeVersionsJSON []byte
var (
managedNPMRuntimeVersionsOnce sync.Once
managedNPMRuntimeVersions map[string]string
managedNPMRuntimeVersionsErr error
)
// DefaultManagedNPMRuntimeVersion returns the reviewed exact default for a
// trusted managed npm package.
func DefaultManagedNPMRuntimeVersion(packageName string) (string, error) {
packageName = strings.TrimSpace(packageName)
if packageName == "" {
return "", fmt.Errorf("managed runtime package is empty")
}
managedNPMRuntimeVersionsOnce.Do(loadManagedNPMRuntimeVersions)
if managedNPMRuntimeVersionsErr != nil {
return "", managedNPMRuntimeVersionsErr
}
version, ok := managedNPMRuntimeVersions[packageName]
if !ok {
return "", fmt.Errorf("no reviewed default for managed runtime package %q", packageName)
}
return version, nil
}
// MustDefaultManagedNPMRuntimeVersion returns a reviewed default or panics.
// Built-in agents call this while the registry is loaded, so a malformed or
// incomplete catalogue fails startup instead of creating an unversioned
// command.
func MustDefaultManagedNPMRuntimeVersion(packageName string) string {
version, err := DefaultManagedNPMRuntimeVersion(packageName)
if err != nil {
panic(fmt.Sprintf("managed runtime defaults are invalid: %v", err))
}
return version
}
func loadManagedNPMRuntimeVersions() {
var versions map[string]string
if err := json.Unmarshal(managedNPMRuntimeVersionsJSON, &versions); err != nil {
managedNPMRuntimeVersionsErr = fmt.Errorf("decode managed runtime defaults: %w", err)
return
}
if len(versions) == 0 {
managedNPMRuntimeVersionsErr = fmt.Errorf("managed runtime defaults are empty")
return
}
for packageName, version := range versions {
if strings.TrimSpace(packageName) == "" {
managedNPMRuntimeVersionsErr = fmt.Errorf("managed runtime defaults contain an empty package")
return
}
if _, err := managedruntime.ParseStableVersion(version); err != nil {
managedNPMRuntimeVersionsErr = fmt.Errorf("managed runtime default %q for %q: %w", version, packageName, err)
return
}
}
managedNPMRuntimeVersions = versions
}
@@ -0,0 +1,7 @@
{
"@agentclientprotocol/claude-agent-acp": "0.70.0",
"@agentclientprotocol/codex-acp": "1.6.0",
"@github/copilot": "1.0.75",
"@google/gemini-cli": "0.52.0",
"opencode-ai": "1.18.18"
}
@@ -89,10 +89,10 @@ func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
}
func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{
Package: opencodeACPPackage,
ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"},
}
return newManagedNPMRuntimeSpec(
opencodeACPPackage,
"acp", "--print-logs", "--log-level", "ERROR",
)
}
func (a *OpenCodeACP) Runtime() *RuntimeConfig {
@@ -10,7 +10,7 @@ import (
func TestOpenCodeACPUsesManagedRuntime(t *testing.T) {
a := NewOpenCodeACP()
want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp", "--print-logs", "--log-level", "ERROR"}
want := a.ManagedNPMRuntime().CachedACPCommand().Args()
if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) {
t.Fatalf("BuildCommand = %#v, want %#v", got, want)
@@ -718,7 +718,7 @@ func (m *Manager) resolveInferenceCommand(
return agents.Command{}, fmt.Errorf("resolve active managed runtime version for %s: %w", agentType, err)
}
if !found || selection.Package != spec.Package {
return command, nil
return spec.ACPCommand(spec.DefaultVersion), nil
}
return spec.ACPCommand(selection.Version), nil
}
@@ -20,6 +20,7 @@ const selectionKeyPrefix = "managed_runtime.active."
type SettingsStore interface {
Get(context.Context, string) ([]byte, bool, error)
Save(context.Context, string, []byte) error
Delete(context.Context, string) error
}
// SelectionStore is the runtime-facing seam for reading and persisting an
@@ -27,6 +28,7 @@ type SettingsStore interface {
type SelectionStore interface {
Get(context.Context, string, string) (Selection, bool, error)
Save(context.Context, string, string, string) error
Delete(context.Context, string, string) error
}
// SelectionReader is the read-only seam used by runtime command builders.
@@ -97,6 +99,19 @@ func (s *Store) Save(ctx context.Context, agentID, packageName, version string)
return s.settings.Save(ctx, selectionKey(agentID), raw)
}
// Delete removes the operator selection for one trusted package. The built-in
// default remains outside the settings store and becomes effective after this
// operation succeeds.
func (s *Store) Delete(ctx context.Context, agentID, packageName string) error {
if s == nil || s.settings == nil {
return errSettingsMissing
}
if agentID == "" || packageName == "" {
return fmt.Errorf("%w: agent and package are required", ErrInvalidSelection)
}
return s.settings.Delete(ctx, selectionKey(agentID))
}
func selectionKey(agentID string) string {
return selectionKeyPrefix + strings.TrimSpace(agentID)
}
@@ -30,6 +30,14 @@ func (s *memorySettings) Save(_ context.Context, key string, value []byte) error
return nil
}
func (s *memorySettings) Delete(_ context.Context, key string) error {
if s.err != nil {
return s.err
}
delete(s.values, key)
return nil
}
func TestStoreRoundTripAndPerAgentIsolation(t *testing.T) {
settings := &memorySettings{}
store := NewStore(settings)
@@ -80,3 +88,18 @@ func TestStorePropagatesSettingsErrors(t *testing.T) {
t.Fatalf("save error = %v, want %v", err, wantErr)
}
}
func TestStoreDeleteRemovesSelection(t *testing.T) {
settings := &memorySettings{}
store := NewStore(settings)
ctx := context.Background()
if err := store.Save(ctx, "opencode-acp", "opencode-ai", "1.18.5"); err != nil {
t.Fatalf("save selection: %v", err)
}
if err := store.Delete(ctx, "opencode-acp", "opencode-ai"); err != nil {
t.Fatalf("delete selection: %v", err)
}
if _, found, err := store.Get(ctx, "opencode-acp", "opencode-ai"); err != nil || found {
t.Fatalf("selection after delete = found %v, err %v; want absent", found, err)
}
}
@@ -17,10 +17,11 @@ var ErrNoStableVersions = errors.New("no stable runtime versions available")
type Operation string
const (
OperationUpdate Operation = "update"
OperationRollback Operation = "rollback"
OperationRepair Operation = "repair"
OperationUpToDate Operation = "up_to_date"
OperationUpdate Operation = "update"
OperationRollback Operation = "rollback"
OperationRepair Operation = "repair"
OperationUpToDate Operation = "up_to_date"
OperationUseDefault Operation = "use_default"
)
// VersionOption is one selectable version in the bounded catalogue.
@@ -211,6 +212,48 @@ func ClassifyOperation(active, current, target string) (Operation, error) {
return OperationRepair, nil
}
// ClassifyEffectiveOperation derives the operator action from the effective
// version used by future commands. An active selection is distinct from the
// effective version because choosing the shipped default removes that
// selection only after the candidate has been validated.
func ClassifyEffectiveOperation(
useDefault bool,
active string,
effective string,
current string,
target string,
defaultVersion string,
) (Operation, error) {
targetParsed, err := ParseStableVersion(target)
if err != nil {
return "", err
}
if useDefault && active != "" && defaultVersion != "" {
if _, err := ParseStableVersion(defaultVersion); err != nil {
return "", err
}
if target == defaultVersion {
return OperationUseDefault, nil
}
}
effectiveParsed, effectiveOK := parseKnownVersion(effective)
_, currentOK := parseKnownVersion(current)
if !effectiveOK || !currentOK {
return OperationRepair, nil
}
if effective == target && current == target {
return OperationUpToDate, nil
}
compare := targetParsed.Compare(effectiveParsed)
if compare > 0 {
return OperationUpdate, nil
}
if compare < 0 {
return OperationRollback, nil
}
return OperationRepair, nil
}
func parseKnownVersion(value string) (*semver.Version, bool) {
if value == "" {
return nil, false
@@ -91,3 +91,64 @@ func TestClassifyOperation(t *testing.T) {
})
}
}
func TestClassifyEffectiveOperation(t *testing.T) {
tests := []struct {
name string
useDefault bool
active string
effective string
current string
target string
defaultVer string
want Operation
}{
{
name: "return to default",
useDefault: true,
active: "1.18.5",
effective: "1.18.5",
current: "1.18.5",
target: "1.18.18",
defaultVer: "1.18.18",
want: OperationUseDefault,
},
{
name: "default is up to date without active selection",
effective: "1.18.18",
current: "1.18.18",
target: "1.18.18",
defaultVer: "1.18.18",
want: OperationUpToDate,
},
{
name: "selected version updates",
active: "1.18.5",
effective: "1.18.5",
current: "1.18.5",
target: "1.18.10",
defaultVer: "1.18.18",
want: OperationUpdate,
},
{
name: "numeric default remains an active selection",
active: "1.18.5",
effective: "1.18.5",
current: "1.18.5",
target: "1.18.18",
defaultVer: "1.18.18",
want: OperationUpdate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ClassifyEffectiveOperation(tt.useDefault, tt.active, tt.effective, tt.current, tt.target, tt.defaultVer)
if err != nil {
t.Fatalf("ClassifyEffectiveOperation: %v", err)
}
if got != tt.want {
t.Fatalf("operation = %q, want %q", got, tt.want)
}
})
}
}
@@ -162,12 +162,15 @@ func (r *Registry) resolveManagedProviderVersion(
}
spec := managed.ManagedNPMRuntime()
selection, found, err := selectionStore.Get(ctx, providerID, spec.Package)
if err != nil || !found {
return "", err == nil
}
if selection.Package != spec.Package || selection.Version == "" {
if err != nil {
return "", false
}
if !found || selection.Package != spec.Package {
return spec.DefaultVersion, true
}
if selection.Version == "" {
return spec.DefaultVersion, true
}
return selection.Version, true
}
@@ -444,13 +444,17 @@ type ExecutorCreateRequest struct {
ComparisonTargets map[string]models.ComparisonTarget
McpServers []McpServerConfig
AgentConfig agents.Agent // Agent type info needed by runtimes
PreviousExecutionID string // Non-empty when reconnecting to a previous execution
McpMode string // MCP tool mode: "task" (default), "config", or "office"
McpProviders []string // Normalized provider capabilities attached to the task
McpProfile *mcpprofile.Context
AuthToken string // Previously handshaken agentctl token for reconnects
BootstrapNonce string // Stored nonce for re-handshake after container restart
AgentctlStartupConfig commonconfig.AgentctlStartupConfig
// ManagedRuntimeVersion is the effective exact version resolved for this
// launch. Remote executors use it during preflight before agentctl receives
// the final command.
ManagedRuntimeVersion string
PreviousExecutionID string // Non-empty when reconnecting to a previous execution
McpMode string // MCP tool mode: "task" (default), "config", or "office"
McpProviders []string // Normalized provider capabilities attached to the task
McpProfile *mcpprofile.Context
AuthToken string // Previously handshaken agentctl token for reconnects
BootstrapNonce string // Stored nonce for re-handshake after container restart
AgentctlStartupConfig commonconfig.AgentctlStartupConfig
// OnProgress is an optional callback for streaming preparation progress.
// Executors that perform multi-step setup (e.g. Sprites, remote Docker) can
@@ -925,7 +925,7 @@ func (r *SSHExecutor) preflightAgentBinary(
return nil
}
cmd := req.AgentConfig.BuildCommand(agents.CommandOptions{Runtime: agentruntime.RuntimeSSH})
cmd := buildRemotePreflightAgentCommand(req)
args := cmd.Args()
if len(args) == 0 {
return nil
@@ -945,6 +945,16 @@ func (r *SSHExecutor) preflightAgentBinary(
return nil
}
func buildRemotePreflightAgentCommand(req *ExecutorCreateRequest) agents.Command {
if req == nil || req.AgentConfig == nil {
return agents.Command{}
}
return req.AgentConfig.BuildCommand(agents.CommandOptions{
Runtime: agentruntime.RuntimeSSH,
ManagedRuntimeVersion: req.ManagedRuntimeVersion,
})
}
// probeNativeBinary probes the remote for the agent's standalone CLI (if it
// declares one via NativeBinaryAgent). On a hit it records the binary name in
// req.Metadata under MetadataKeyNativeBinary and returns true. It returns false
@@ -30,7 +30,11 @@ func (s managedRuntimeSelectionStore) Save(context.Context, string, string, stri
return nil
}
func TestBuildAgentCommandUsesExactVersionOnlyOnStandalone(t *testing.T) {
func (s managedRuntimeSelectionStore) Delete(context.Context, string, string) error {
return nil
}
func TestBuildAgentCommandUsesEffectiveVersionAcrossExecutors(t *testing.T) {
log := newTestLogger()
manager := &Manager{commandBuilder: NewCommandBuilder(), logger: log}
manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{
@@ -46,8 +50,8 @@ func TestBuildAgentCommandUsesExactVersionOnlyOnStandalone(t *testing.T) {
}{
{name: "standalone", executorType: models.ExecutorTypeLocal, wantVersion: true},
{name: "worktree", executorType: models.ExecutorTypeWorktree, wantVersion: true},
{name: "docker", executorType: models.ExecutorTypeLocalDocker},
{name: "ssh", executorType: models.ExecutorTypeSSH},
{name: "docker", executorType: models.ExecutorTypeLocalDocker, wantVersion: true},
{name: "ssh", executorType: models.ExecutorTypeSSH, wantVersion: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -121,7 +125,7 @@ func TestBuildFreshAgentCommandFailsWhenStandaloneSelectionCannotBeRead(t *testi
}
}
func TestBuildFreshAgentCommandLeavesRemoteCommandUnversioned(t *testing.T) {
func TestBuildFreshAgentCommandUsesEffectiveVersionForRemoteRestart(t *testing.T) {
manager := &Manager{commandBuilder: NewCommandBuilder(), logger: newTestLogger()}
manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{
selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"},
@@ -136,10 +140,19 @@ func TestBuildFreshAgentCommandLeavesRemoteCommandUnversioned(t *testing.T) {
if err != nil {
t.Fatalf("buildFreshAgentCommand: %v", err)
}
if strings.Contains(commands.initial, "opencode-ai@1.18.5") {
t.Fatalf("remote restart command = %q, must not use host selection", commands.initial)
}
if !strings.Contains(commands.initial, "opencode-ai") {
t.Fatalf("remote restart command = %q, want managed runtime command", commands.initial)
if !strings.Contains(commands.initial, "opencode-ai@1.18.5") {
t.Fatalf("remote restart command = %q, want exact selected version", commands.initial)
}
}
func TestRemotePreflightUsesResolvedManagedRuntimeVersion(t *testing.T) {
req := &ExecutorCreateRequest{
AgentConfig: agents.NewOpenCodeACP(),
ManagedRuntimeVersion: "1.18.5",
}
got := buildRemotePreflightAgentCommand(req).Args()
want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.18.5", "acp", "--print-logs", "--log-level", "ERROR"}
if !strings.EqualFold(strings.Join(got, " "), strings.Join(want, " ")) {
t.Fatalf("remote preflight command = %#v, want %#v", got, want)
}
}
@@ -682,6 +682,14 @@ func (m *Manager) prepareExecutionCreateRequest(
if !ok {
return nil, fmt.Errorf("agent type %q not found in registry", info.AgentID)
}
managedRuntimeVersion, err := m.resolveManagedRuntimeVersion(
ctx,
models.ExecutorType(info.ExecutorType).Runtime(),
agentConfig,
)
if err != nil {
return nil, err
}
executionProfileID := workspaceExecutionProfileID(info)
profileInfo := m.resolveWorkspaceExecutionProfile(ctx, executionProfileID)
@@ -747,6 +755,7 @@ func (m *Manager) prepareExecutionCreateRequest(
AgentctlStartupConfig: m.agentctlStartupConfig,
RemoteContributions: remoteContributions,
ContributionDestinations: contributionDestinations,
ManagedRuntimeVersion: managedRuntimeVersion,
ComparisonTargets: comparisonTargets,
},
profileInfo: profileInfo,
@@ -489,23 +489,32 @@ func (m *Manager) buildAgentCommandWithContext(
func (m *Manager) resolveManagedRuntimeVersion(
ctx context.Context,
runtime agentruntime.Runtime,
_ agentruntime.Runtime,
agentConfig agents.Agent,
) (string, error) {
if runtime != agentruntime.RuntimeStandalone || m.managedRuntimeSelections == nil {
return "", nil
}
managed, ok := agentConfig.(agents.ManagedNPMRuntimeAgent)
if !ok {
return "", nil
}
spec := managed.ManagedNPMRuntime()
effectiveVersion := spec.DefaultVersion
if effectiveVersion == "" {
// Test and embedded agents may construct a spec literal. PackageSpec
// still resolves a known built-in package's reviewed default.
packageSpec := spec.PackageSpec("")
if packageSpec != spec.Package {
effectiveVersion = strings.TrimPrefix(packageSpec, spec.Package+"@")
}
}
if m.managedRuntimeSelections == nil {
return effectiveVersion, nil
}
selection, found, err := m.managedRuntimeSelections.Get(ctx, agentConfig.ID(), spec.Package)
if err != nil {
return "", fmt.Errorf("resolve active managed runtime version for %s: %w", agentConfig.ID(), err)
}
if !found || selection.Package != spec.Package {
return "", nil
return effectiveVersion, nil
}
return selection.Version, nil
}
@@ -136,40 +136,35 @@ func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) {
tests := []struct {
name string
agent agents.Agent
want string
}{
{
name: "claude",
agent: agents.NewClaudeACP(),
want: "npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp",
},
{
name: "codex",
agent: agents.NewCodexACP(),
want: "npx --yes --prefer-offline @agentclientprotocol/codex-acp",
},
{
name: "opencode",
agent: agents.NewOpenCodeACP(),
want: "npx --yes --prefer-offline opencode-ai acp --print-logs --log-level ERROR",
},
{
name: "copilot",
agent: agents.NewCopilotACP(),
want: "npx --yes --prefer-offline @github/copilot --acp",
},
{
name: "gemini",
agent: agents.NewGemini(),
want: "npx --yes --prefer-offline @google/gemini-cli --acp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
want := strings.Join(tt.agent.(agents.ManagedNPMRuntimeAgent).ManagedNPMRuntime().CachedACPCommand().Args(), " ")
cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, tt.agent, true)
require.NoError(t, err)
require.Equal(t, tt.want, cmds.initial)
require.Equal(t, want, cmds.initial)
})
}
}
@@ -166,10 +166,18 @@ func (c *Controller) buildRuntimeUpdateDTO(ctx context.Context, ag agents.Agent,
if spec.Package == "" {
return nil
}
item := &dto.RuntimeUpdateDTO{Supported: true, Package: spec.Package}
defaultVersion := spec.DefaultVersionOrPinned()
item := &dto.RuntimeUpdateDTO{
Supported: true,
Package: spec.Package,
DefaultVersion: defaultVersion,
EffectiveVersion: defaultVersion,
}
if c.managedRuntimeSelections != nil {
if selection, found, err := c.managedRuntimeSelections.Get(ctx, ag.ID(), spec.Package); err == nil && found {
if selection, found, err := c.managedRuntimeSelections.Get(ctx, ag.ID(), spec.Package); err == nil && found &&
selection.Package == spec.Package {
item.ActiveVersion = selection.Version
item.EffectiveVersion = selection.Version
}
}
if c.runtimeUpdater != nil {
@@ -9,6 +9,7 @@ import (
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/managedruntime"
)
type managedTestAgent struct {
@@ -84,6 +85,39 @@ func TestUnavailableManagedAgentOmitsRuntimeMetadata(t *testing.T) {
}
}
func TestAvailableAgentIgnoresMismatchedManagedRuntimeSelection(t *testing.T) {
ag := &managedTestAgent{
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
spec: agents.ManagedNPMRuntimeSpec{
Package: "@example/managed-acp",
DefaultVersion: "1.2.3",
},
}
selectionStore := newRecoverySelectionStore()
selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{
Package: "@other/managed-acp",
Version: "9.9.9",
}
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
ctrl.SetManagedRuntimeSelectionStore(selectionStore)
item := ctrl.buildAvailableAgentDTO(
context.Background(),
ag,
discovery.Availability{Name: ag.ID(), Available: true},
time.Now(),
)
if item.RuntimeUpdate == nil {
t.Fatal("runtime_update missing")
}
if item.RuntimeUpdate.ActiveVersion != "" {
t.Fatalf("active_version = %q, want empty for mismatched selection", item.RuntimeUpdate.ActiveVersion)
}
if item.RuntimeUpdate.EffectiveVersion != "1.2.3" {
t.Fatalf("effective_version = %q, want default 1.2.3", item.RuntimeUpdate.EffectiveVersion)
}
}
func TestAvailableACPInferenceAgentIsDynamicBeforeProbeSnapshot(t *testing.T) {
ag := agents.NewMockAgent()
ag.SetEnabled(true)
@@ -33,6 +33,28 @@ func (c *Controller) PreviewAgentUpdate(
ctx context.Context,
name string,
targetVersions ...string,
) (*dto.AgentUpdatePreviewDTO, error) {
target := ""
if len(targetVersions) > 0 {
target = strings.TrimSpace(targetVersions[0])
}
return c.previewAgentUpdate(ctx, name, target, false)
}
// PreviewAgentUpdateUseDefault previews returning to the reviewed Kandev
// default without claiming maintenance state or running a candidate.
func (c *Controller) PreviewAgentUpdateUseDefault(
ctx context.Context,
name string,
) (*dto.AgentUpdatePreviewDTO, error) {
return c.previewAgentUpdate(ctx, name, "", true)
}
func (c *Controller) previewAgentUpdate(
ctx context.Context,
name string,
targetVersion string,
useDefault bool,
) (*dto.AgentUpdatePreviewDTO, error) {
if c.runtimeUpdater == nil {
return nil, ErrRuntimeUpdaterUnavailable
@@ -54,30 +76,34 @@ func (c *Controller) PreviewAgentUpdate(
if caps, found := c.runtimeUpdater.CurrentCapabilities(name); found {
current = caps.AgentVersion
}
active, err := c.activeRuntimeVersion(ctx, name, spec.Package)
active, effective, defaultVersion, err := c.runtimeVersions(ctx, name, spec)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err)
}
catalogue, exactCatalogue, err := c.resolveRuntimeCatalogue(ctx, spec.Package, active, current)
catalogue, exactCatalogue, err := c.resolveRuntimeCatalogue(
ctx, spec.Package, active, current, effective, defaultVersion,
)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err)
}
target := catalogue.Latest
if len(targetVersions) > 0 && strings.TrimSpace(targetVersions[0]) != "" {
target = strings.TrimSpace(targetVersions[0])
if _, err := managedruntime.ParseStableVersion(target); err != nil {
return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err)
}
if exactCatalogue && !catalogue.Has(target) {
return nil, fmt.Errorf("%w: %s", ErrRuntimeUpdateTargetMissing, target)
}
target, err := resolvePreviewTarget(
catalogue,
exactCatalogue,
targetVersion,
defaultVersion,
useDefault,
)
if err != nil {
return nil, err
}
operation, err := managedruntime.ClassifyOperation(active, current, target)
operation, err := managedruntime.ClassifyEffectiveOperation(
useDefault, active, effective, current, target, defaultVersion,
)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err)
}
command := spec.CacheUpdateCommand(target).Args()
if !exactCatalogue && (len(targetVersions) == 0 || strings.TrimSpace(targetVersions[0]) == "") {
if !exactCatalogue && targetVersion == "" && !useDefault {
// Keep the compatibility preview for embedders that provide only the
// legacy latest-version seam. Production uses the catalogue resolver and
// always previews an exact package@version command.
@@ -87,7 +113,9 @@ func (c *Controller) PreviewAgentUpdate(
AgentName: name,
Package: spec.Package,
CurrentVersion: current,
DefaultVersion: defaultVersion,
ActiveVersion: active,
EffectiveVersion: effective,
TargetVersion: target,
Operation: string(operation),
AvailableVersions: runtimeVersionDTOs(catalogue),
@@ -96,6 +124,31 @@ func (c *Controller) PreviewAgentUpdate(
}, nil
}
func resolvePreviewTarget(
catalogue managedruntime.Catalogue,
exactCatalogue bool,
targetVersion string,
defaultVersion string,
useDefault bool,
) (string, error) {
target := catalogue.Latest
switch {
case useDefault:
target = defaultVersion
case targetVersion != "":
target = targetVersion
default:
return target, nil
}
if _, err := managedruntime.ParseStableVersion(target); err != nil {
return "", fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err)
}
if exactCatalogue && !catalogue.Has(target) {
return "", fmt.Errorf("%w: %s", ErrRuntimeUpdateTargetMissing, target)
}
return target, nil
}
func (c *Controller) resolveRuntimeCatalogue(
ctx context.Context,
packageName string,
@@ -146,6 +199,23 @@ func (c *Controller) activeRuntimeVersion(
return selection.Version, nil
}
func (c *Controller) runtimeVersions(
ctx context.Context,
agentName string,
spec agents.ManagedNPMRuntimeSpec,
) (active, effective, defaultVersion string, err error) {
defaultVersion = spec.DefaultVersionOrPinned()
active, err = c.activeRuntimeVersion(ctx, agentName, spec.Package)
if err != nil {
return "", "", defaultVersion, err
}
effective = defaultVersion
if active != "" {
effective = active
}
return active, effective, defaultVersion, 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 {
@@ -167,6 +237,11 @@ type RuntimeVersionMetadata struct {
Latest string
}
type npmRuntimeMetadata struct {
Versions []string `json:"versions"`
DistTags map[string]string `json:"dist-tags"`
}
// RuntimeVersionResolver obtains package metadata without executing a package.
type RuntimeVersionResolver interface {
ResolveVersions(context.Context, string) (RuntimeVersionMetadata, error)
@@ -267,12 +342,9 @@ func (u *hostRuntimeUpdater) ResolveVersions(
if err != nil {
return RuntimeVersionMetadata{}, err
}
var metadata struct {
Versions []string `json:"versions"`
DistTags map[string]string `json:"dist-tags"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &metadata); err != nil {
return RuntimeVersionMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err)
metadata, err := parseNPMRuntimeMetadata(output)
if err != nil {
return RuntimeVersionMetadata{}, err
}
latest := ""
if metadata.DistTags != nil {
@@ -284,6 +356,26 @@ func (u *hostRuntimeUpdater) ResolveVersions(
return RuntimeVersionMetadata{Versions: metadata.Versions, Latest: latest}, nil
}
func parseNPMRuntimeMetadata(output string) (npmRuntimeMetadata, error) {
payload := []byte(strings.TrimSpace(output))
if len(payload) > 0 && payload[0] == '[' {
var records []npmRuntimeMetadata
if err := json.Unmarshal(payload, &records); err != nil {
return npmRuntimeMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err)
}
if len(records) != 1 {
return npmRuntimeMetadata{}, errors.New("parse npm runtime metadata: expected one record")
}
return records[0], nil
}
var metadata npmRuntimeMetadata
if err := json.Unmarshal(payload, &metadata); err != nil {
return npmRuntimeMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err)
}
return metadata, nil
}
func runDirectCommandOutput(ctx context.Context, command agents.Command) (string, error) {
argv := command.Args()
if len(argv) == 0 {
@@ -323,7 +415,11 @@ func (u *hostRuntimeUpdater) InvalidateExecutionCache(ctx context.Context, packa
if err != nil {
return err
}
return managedruntime.RemoveNpxExecutionTree(cacheRoot, packageName)
// Cache repair must target the same exact package spec used by the managed
// launch command. Known built-ins therefore invalidate their pinned tree,
// while unknown packages retain the unversioned compatibility path.
spec := agents.ManagedNPMRuntimeSpec{Package: packageName}
return managedruntime.RemoveNpxExecutionTree(cacheRoot, spec.PackageSpec(""))
}
func (u *hostRuntimeUpdater) InvalidateExecutionCacheVersion(
@@ -429,6 +525,25 @@ func (c *Controller) EnqueueAgentUpdate(
ctx context.Context,
name string,
targetVersion string,
) (*dto.AgentUpdateJobDTO, error) {
return c.enqueueAgentUpdate(ctx, name, strings.TrimSpace(targetVersion), false)
}
// EnqueueAgentUpdateUseDefault validates and activates the reviewed Kandev
// default, deleting an operator selection only after the candidate probe
// succeeds.
func (c *Controller) EnqueueAgentUpdateUseDefault(
ctx context.Context,
name string,
) (*dto.AgentUpdateJobDTO, error) {
return c.enqueueAgentUpdate(ctx, name, "", true)
}
func (c *Controller) enqueueAgentUpdate(
ctx context.Context,
name string,
targetVersion string,
useDefault bool,
) (*dto.AgentUpdateJobDTO, error) {
if c.updateJobStore == nil || c.runtimeUpdater == nil {
return nil, ErrRuntimeUpdaterUnavailable
@@ -448,6 +563,9 @@ func (c *Controller) EnqueueAgentUpdate(
if strings.TrimSpace(spec.Package) == "" {
return nil, ErrRuntimeUpdateUnsupported
}
if useDefault {
targetVersion = spec.DefaultVersionOrPinned()
}
targetVersion = strings.TrimSpace(targetVersion)
if targetVersion == "" {
return nil, ErrRuntimeUpdateTargetRequired
@@ -455,10 +573,18 @@ func (c *Controller) EnqueueAgentUpdate(
if err := c.validateAgentUpdateTarget(ctx, spec, targetVersion); err != nil {
return nil, err
}
if noOp := c.alreadyActiveHealthyUpdate(ctx, name, spec, targetVersion); noOp != nil {
return noOp, nil
if !useDefault {
if noOp := c.alreadyActiveHealthyUpdate(ctx, name, spec, targetVersion); noOp != nil {
return noOp, nil
}
}
var job *AgentUpdateJob
var err error
if useDefault {
job, err = c.updateJobStore.EnqueueDefault(name, spec)
} else {
job, err = c.updateJobStore.Enqueue(name, spec, targetVersion)
}
job, err := c.updateJobStore.Enqueue(name, spec, targetVersion)
if err != nil {
return nil, err
}
@@ -515,16 +641,19 @@ func (c *Controller) alreadyActiveHealthyUpdate(
return nil
}
now := time.Now().UTC()
defaultVersion := spec.DefaultVersionOrPinned()
return &dto.AgentUpdateJobDTO{
AgentName: agentName,
Status: dto.AgentUpdateJobStatusSucceeded,
Operation: string(managedruntime.OperationUpToDate),
CurrentVersion: caps.AgentVersion,
ActiveVersion: selection.Version,
TargetVersion: targetVersion,
Output: "Runtime already up to date.\n",
StartedAt: now,
FinishedAt: &now,
AgentName: agentName,
Status: dto.AgentUpdateJobStatusSucceeded,
Operation: string(managedruntime.OperationUpToDate),
CurrentVersion: caps.AgentVersion,
DefaultVersion: defaultVersion,
ActiveVersion: selection.Version,
EffectiveVersion: selection.Version,
TargetVersion: targetVersion,
Output: "Runtime already up to date.\n",
StartedAt: now,
FinishedAt: &now,
}
}
@@ -19,31 +19,36 @@ import (
)
type AgentUpdateJob struct {
ID string
AgentName string
Status dto.AgentUpdateJobStatus
Operation managedruntime.Operation
CurrentVersion string
ActiveVersion string
TargetVersion string
Output *ringBuffer
StartedAt time.Time
FinishedAt *time.Time
Error string
RefreshError string
ID string
AgentName string
Package string
Status dto.AgentUpdateJobStatus
Operation managedruntime.Operation
CurrentVersion string
DefaultVersion string
ActiveVersion string
EffectiveVersion string
TargetVersion string
UseDefault bool
Output *ringBuffer
StartedAt time.Time
FinishedAt *time.Time
Error string
RefreshError string
}
type AgentUpdateJobStore struct {
mu sync.Mutex
jobs map[string]*AgentUpdateJob
activeByAgt map[string]*AgentUpdateJob
semaphore chan struct{}
hub JobBroadcaster
log *zap.Logger
updater RuntimeUpdater
maintenance *maintenanceCoordinator
onRefresh func()
selectionStore managedruntime.SelectionStore
mu sync.Mutex
jobs map[string]*AgentUpdateJob
activeByAgt map[string]*AgentUpdateJob
semaphore chan struct{}
hub JobBroadcaster
log *zap.Logger
updater RuntimeUpdater
maintenance *maintenanceCoordinator
onRefresh func()
selectionStore managedruntime.SelectionStore
onStatusInvalidated func(string)
}
func NewAgentUpdateJobStore(
@@ -78,6 +83,22 @@ func (s *AgentUpdateJobStore) Enqueue(
agentName string,
spec agents.ManagedNPMRuntimeSpec,
targetVersions ...string,
) (*AgentUpdateJob, error) {
return s.enqueue(agentName, spec, false, targetVersions...)
}
func (s *AgentUpdateJobStore) EnqueueDefault(
agentName string,
spec agents.ManagedNPMRuntimeSpec,
) (*AgentUpdateJob, error) {
return s.enqueue(agentName, spec, true)
}
func (s *AgentUpdateJobStore) enqueue(
agentName string,
spec agents.ManagedNPMRuntimeSpec,
useDefault bool,
targetVersions ...string,
) (*AgentUpdateJob, error) {
s.mu.Lock()
if existing, ok := s.activeByAgt[agentName]; ok {
@@ -85,11 +106,13 @@ func (s *AgentUpdateJobStore) Enqueue(
return existing, nil
}
job := &AgentUpdateJob{
ID: uuid.NewString(),
AgentName: agentName,
Status: dto.AgentUpdateJobStatusQueued,
Output: newRingBuffer(jobOutputRingSize),
StartedAt: time.Now().UTC(),
ID: uuid.NewString(),
AgentName: agentName,
Package: spec.Package,
Status: dto.AgentUpdateJobStatusQueued,
Output: newRingBuffer(jobOutputRingSize),
StartedAt: time.Now().UTC(),
UseDefault: useDefault,
}
requestedTarget := ""
if len(targetVersions) > 0 {
@@ -115,6 +138,14 @@ func (s *AgentUpdateJobStore) Enqueue(
return job, nil
}
// SetStatusInvalidator wires the process-local status cache invalidation used
// after a successful runtime activation. It is optional for isolated stores.
func (s *AgentUpdateJobStore) SetStatusInvalidator(invalidator func(string)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onStatusInvalidated = invalidator
}
func (s *AgentUpdateJobStore) Get(jobID string) (*dto.AgentUpdateJobDTO, bool) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -161,7 +192,20 @@ func (s *AgentUpdateJobStore) run(
defer cancel()
s.setStatus(job, dto.AgentUpdateJobStatusResolving)
target, exactTarget, err := s.resolveTarget(ctx, spec.Package, requestedTarget)
var (
target string
exactTarget bool
err error
)
if job.UseDefault {
target = spec.DefaultVersionOrPinned()
if _, parseErr := managedruntime.ParseStableVersion(target); parseErr != nil {
err = fmt.Errorf("resolve default version: %w", parseErr)
}
exactTarget = true
} else {
target, exactTarget, err = s.resolveTarget(ctx, spec.Package, requestedTarget)
}
if err != nil {
s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref)
return
@@ -184,7 +228,14 @@ func (s *AgentUpdateJobStore) run(
activeVersion = selection.Version
}
}
operation, err := managedruntime.ClassifyOperation(activeVersion, currentVersion, target)
defaultVersion := spec.DefaultVersionOrPinned()
effectiveVersion := defaultVersion
if activeVersion != "" {
effectiveVersion = activeVersion
}
operation, err := managedruntime.ClassifyEffectiveOperation(
job.UseDefault, activeVersion, effectiveVersion, currentVersion, target, defaultVersion,
)
if err != nil {
s.finishFailed(job, ctx, fmt.Errorf("classify runtime operation: %w", err), ref)
return
@@ -192,7 +243,9 @@ func (s *AgentUpdateJobStore) run(
s.mu.Lock()
job.TargetVersion = target
job.Operation = operation
job.DefaultVersion = defaultVersion
job.ActiveVersion = activeVersion
job.EffectiveVersion = effectiveVersion
s.mu.Unlock()
if operation == managedruntime.OperationUpToDate ||
(s.selectionStore == nil && currentVersion != "" && currentVersion == target) {
@@ -329,10 +382,27 @@ func (s *AgentUpdateJobStore) runExactCandidate(
s.finishFailed(job, ctx, errors.New(capabilityRefreshError(caps)), ref)
return
}
if err := s.selectionStore.Save(ctx, job.AgentName, spec.Package, target); err != nil {
s.mu.Lock()
job.CurrentVersion = caps.AgentVersion
s.mu.Unlock()
if job.UseDefault {
if err := s.selectionStore.Delete(ctx, job.AgentName, spec.Package); err != nil {
s.finishFailed(job, ctx, fmt.Errorf("clear active runtime version: %w", err), ref)
return
}
} else if err := s.selectionStore.Save(ctx, job.AgentName, spec.Package, target); err != nil {
s.finishFailed(job, ctx, fmt.Errorf("persist active runtime version: %w", err), ref)
return
}
s.mu.Lock()
if job.UseDefault {
job.ActiveVersion = ""
job.EffectiveVersion = job.DefaultVersion
} else {
job.ActiveVersion = target
job.EffectiveVersion = target
}
s.mu.Unlock()
candidate.PublishCapabilities(job.AgentName, caps)
s.finishActivated(job, target, ref)
}
@@ -386,11 +456,22 @@ func (s *AgentUpdateJobStore) finishActivated(
) {
s.mu.Lock()
job.Status = dto.AgentUpdateJobStatusSucceeded
job.ActiveVersion = target
if !job.UseDefault {
job.ActiveVersion = target
job.EffectiveVersion = target
} else {
job.ActiveVersion = ""
job.EffectiveVersion = job.DefaultVersion
}
s.maintenance.release(job.AgentName, ref)
s.finishLocked(job)
snapshot := job.snapshot()
statusInvalidator := s.onStatusInvalidated
packageName := job.Package
s.mu.Unlock()
if statusInvalidator != nil && packageName != "" {
statusInvalidator(packageName)
}
if s.onRefresh != nil {
s.onRefresh()
}
@@ -413,6 +494,7 @@ func (s *AgentUpdateJobStore) finishRefresh(
job.Error = formatUpdateJobError(ctx, fmt.Errorf("refresh capabilities: %w", err))
case caps.Status == hostutility.StatusOK:
job.Status = dto.AgentUpdateJobStatusSucceeded
job.CurrentVersion = caps.AgentVersion
refreshed = true
case caps.Status == hostutility.StatusAuthRequired:
job.Status = dto.AgentUpdateJobStatusSucceeded
@@ -429,7 +511,12 @@ func (s *AgentUpdateJobStore) finishRefresh(
s.maintenance.release(job.AgentName, ref)
s.finishLocked(job)
snapshot := job.snapshot()
statusInvalidator := s.onStatusInvalidated
packageName := job.Package
s.mu.Unlock()
if refreshed && statusInvalidator != nil && packageName != "" {
statusInvalidator(packageName)
}
if refreshed && s.onRefresh != nil {
s.onRefresh()
}
@@ -476,17 +563,19 @@ func (s *AgentUpdateJobStore) broadcast(action string, payload dto.AgentUpdateJo
func (j *AgentUpdateJob) snapshot() dto.AgentUpdateJobDTO {
snapshot := dto.AgentUpdateJobDTO{
JobID: j.ID,
AgentName: j.AgentName,
Status: j.Status,
Operation: string(j.Operation),
CurrentVersion: j.CurrentVersion,
ActiveVersion: j.ActiveVersion,
TargetVersion: j.TargetVersion,
Output: j.Output.String(),
Error: j.Error,
RefreshError: j.RefreshError,
StartedAt: j.StartedAt,
JobID: j.ID,
AgentName: j.AgentName,
Status: j.Status,
Operation: string(j.Operation),
CurrentVersion: j.CurrentVersion,
DefaultVersion: j.DefaultVersion,
ActiveVersion: j.ActiveVersion,
EffectiveVersion: j.EffectiveVersion,
TargetVersion: j.TargetVersion,
Output: j.Output.String(),
Error: j.Error,
RefreshError: j.RefreshError,
StartedAt: j.StartedAt,
}
if j.FinishedAt != nil {
finished := *j.FinishedAt
@@ -0,0 +1,38 @@
package controller
import (
"context"
"testing"
)
func TestHostRuntimeUpdaterResolvesNPM12ArrayVersionCatalogue(t *testing.T) {
executor := &recordingCommandExecutor{
output: `[{"versions":["1.0.1","1.0.2"],"dist-tags":{"latest":"1.0.2"}}]`,
}
updater := &hostRuntimeUpdater{executor: executor}
metadata, err := updater.ResolveVersions(context.Background(), "@example/managed-acp")
if err != nil {
t.Fatalf("ResolveVersions: %v", err)
}
if metadata.Latest != "1.0.2" || len(metadata.Versions) != 2 {
t.Fatalf("metadata = %#v", metadata)
}
}
func TestHostRuntimeUpdaterRejectsAmbiguousNPMRuntimeMetadata(t *testing.T) {
tests := map[string]string{
"empty array": `[]`,
"multiple records": `[{"dist-tags":{"latest":"1.0.1"}},{"dist-tags":{"latest":"1.0.2"}}]`,
"malformed payload": `{not-json`,
}
for name, output := range tests {
t.Run(name, func(t *testing.T) {
updater := &hostRuntimeUpdater{executor: &recordingCommandExecutor{output: output}}
if _, err := updater.ResolveVersions(context.Background(), "@example/managed-acp"); err == nil {
t.Fatal("ResolveVersions succeeded, want metadata error")
}
})
}
}
@@ -61,10 +61,28 @@ func (s *recoverySelectionStore) Save(
return nil
}
func (s *recoverySelectionStore) Delete(
_ context.Context,
agentName string,
packageName string,
) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.saveErr != nil {
return s.saveErr
}
delete(s.values, agentName+"\x00"+packageName)
if s.events != nil {
*s.events = append(*s.events, "delete")
}
return nil
}
type recoveryRuntimeUpdater struct {
mu sync.Mutex
metadata RuntimeVersionMetadata
metadataCalls int
resolveErr error
current hostutility.AgentCapabilities
currentFound bool
probeCaps hostutility.AgentCapabilities
@@ -84,7 +102,7 @@ func (u *recoveryRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentC
}
func (u *recoveryRuntimeUpdater) ResolveTarget(context.Context, string) (string, error) {
return u.metadata.Latest, nil
return u.metadata.Latest, u.resolveErr
}
func (u *recoveryRuntimeUpdater) ResolveVersions(context.Context, string) (RuntimeVersionMetadata, error) {
@@ -192,6 +210,7 @@ func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) {
current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.3"},
currentFound: true,
probeCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.2"},
resolveErr: errors.New("registry unavailable"),
}
updater.events = &events
store, completed := newRecoveryStore(updater, selectionStore)
@@ -207,6 +226,9 @@ func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) {
if final.ActiveVersion != "1.0.2" {
t.Fatalf("active version = %q, want 1.0.2", final.ActiveVersion)
}
if final.CurrentVersion != "1.0.2" {
t.Fatalf("current version = %q, want probed 1.0.2", final.CurrentVersion)
}
if len(updater.prepare) != 1 || !strings.Contains(updater.prepare[0], "--package=@example/managed-acp@1.0.2") {
t.Fatalf("prepare commands = %#v", updater.prepare)
}
@@ -221,6 +243,50 @@ func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) {
}
}
func TestAgentUpdateUseDefaultDeletesSelectionAfterProbe(t *testing.T) {
selectionStore := newRecoverySelectionStore()
selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{
Package: "@example/managed-acp",
Version: "1.0.1",
}
events := make([]string, 0, 4)
selectionStore.events = &events
updater := &recoveryRuntimeUpdater{
metadata: RuntimeVersionMetadata{
Versions: []string{"1.0.1", "1.0.2"},
Latest: "1.0.2",
},
current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.1"},
currentFound: true,
probeCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.2"},
}
updater.events = &events
store, completed := newRecoveryStore(updater, selectionStore)
spec := managedRuntimeSpec()
spec.DefaultVersion = "1.0.2"
job, err := store.EnqueueDefault("managed-acp", spec)
if err != nil {
t.Fatalf("EnqueueDefault: %v", err)
}
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
if final.Operation != string(managedruntime.OperationUseDefault) {
t.Fatalf("operation = %q, want use_default", final.Operation)
}
if final.ActiveVersion != "" || final.EffectiveVersion != "1.0.2" {
t.Fatalf("final versions = active %q, effective %q", final.ActiveVersion, final.EffectiveVersion)
}
if final.CurrentVersion != "1.0.2" {
t.Fatalf("current version = %q, want probed default 1.0.2", final.CurrentVersion)
}
if _, found, err := selectionStore.Get(context.Background(), "managed-acp", "@example/managed-acp"); err != nil || found {
t.Fatalf("selection after reset = found %v, err %v; want absent", found, err)
}
if len(events) != 3 || events[0] != "probe" || events[1] != "delete" || events[2] != "publish" {
t.Fatalf("reset activation order = %#v", events)
}
}
func TestAgentUpdateExactTargetSkipsMetadataRefetch(t *testing.T) {
selectionStore := newRecoverySelectionStore()
updater := &recoveryRuntimeUpdater{
@@ -0,0 +1,288 @@
package controller
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agent/settings/dto"
)
const (
runtimeUpdateStatusSuccessTTL = 6 * time.Hour
runtimeUpdateStatusFailureTTL = 15 * time.Minute
runtimeUpdateStatusMaxConcurrent = 5
runtimeUpdateStatusLookupTimeout = 10 * time.Second
)
// RuntimeUpdateStatusResolver is the latest-version lookup seam used by the
// cached status endpoint. Production resolves through the configured runtime
// updater; tests and embedders can provide a deterministic implementation.
type RuntimeUpdateStatusResolver func(context.Context, string) (string, error)
type runtimeUpdateStatusCacheEntry struct {
latest string
checkedAt time.Time
expiresAt time.Time
ok bool
}
type runtimeUpdateStatusTarget struct {
agentName string
packageName string
defaultVersion string
activeVersion string
effectiveVersion string
selectionErr error
}
// SetRuntimeUpdateStatusClock injects the clock used for status cache TTLs.
// Passing nil restores the wall clock.
func (c *Controller) SetRuntimeUpdateStatusClock(now func() time.Time) {
c.runtimeUpdateStatusMu.Lock()
defer c.runtimeUpdateStatusMu.Unlock()
if now == nil {
c.runtimeUpdateStatusNow = time.Now
return
}
c.runtimeUpdateStatusNow = now
}
// SetRuntimeUpdateStatusResolver injects the read-only latest-version lookup
// used by the status endpoint and clears prior results from another resolver.
func (c *Controller) SetRuntimeUpdateStatusResolver(resolver RuntimeUpdateStatusResolver) {
c.runtimeUpdateStatusMu.Lock()
defer c.runtimeUpdateStatusMu.Unlock()
c.runtimeUpdateStatusResolver = resolver
c.runtimeUpdateStatusCache = make(map[string]runtimeUpdateStatusCacheEntry)
}
// InvalidateRuntimeUpdateStatus expires only the affected package. Successful
// activation and return-to-default jobs call this after persistence/probing.
func (c *Controller) InvalidateRuntimeUpdateStatus(packageName string) {
packageName = strings.TrimSpace(packageName)
if packageName == "" {
return
}
c.runtimeUpdateStatusMu.Lock()
defer c.runtimeUpdateStatusMu.Unlock()
delete(c.runtimeUpdateStatusCache, packageName)
}
// ListAgentUpdateStatuses returns one non-mutating status item for each
// available built-in managed runtime. Registry failures are represented as
// unknown entries rather than failing the whole batch.
func (c *Controller) ListAgentUpdateStatuses(ctx context.Context) (*dto.ListAgentUpdateStatusResponse, error) {
targets, err := c.runtimeUpdateStatusTargets(ctx)
if err != nil {
return nil, err
}
now := c.runtimeUpdateStatusTime()
entries := make(map[string]runtimeUpdateStatusCacheEntry, len(targets))
uniquePackages := make(map[string]struct{}, len(targets))
for _, target := range targets {
if _, exists := uniquePackages[target.packageName]; exists {
continue
}
uniquePackages[target.packageName] = struct{}{}
}
type result struct {
packageName string
entry runtimeUpdateStatusCacheEntry
}
results := make(chan result, len(uniquePackages))
for packageName := range uniquePackages {
go func(packageName string) {
entry := c.runtimeUpdateStatusEntry(ctx, packageName, now)
results <- result{packageName: packageName, entry: entry}
}(packageName)
}
for range uniquePackages {
item := <-results
entries[item.packageName] = item.entry
}
statuses := make([]dto.AgentUpdateStatusDTO, 0, len(targets))
for _, target := range targets {
entry := entries[target.packageName]
status := dto.AgentUpdateStatusDTO{
AgentName: target.agentName,
Package: target.packageName,
DefaultVersion: target.defaultVersion,
ActiveVersion: target.activeVersion,
EffectiveVersion: target.effectiveVersion,
CheckState: dto.AgentUpdateCheckStateUnknown,
}
if !entry.checkedAt.IsZero() {
checkedAt := entry.checkedAt
status.CheckedAt = &checkedAt
}
if entry.ok {
status.LatestVersion = entry.latest
}
if target.selectionErr == nil && entry.ok {
status.CheckState = compareRuntimeUpdateStatus(entry.latest, target.effectiveVersion)
}
statuses = append(statuses, status)
}
return &dto.ListAgentUpdateStatusResponse{Statuses: statuses}, nil
}
func (c *Controller) runtimeUpdateStatusTargets(ctx context.Context) ([]runtimeUpdateStatusTarget, error) {
if c.agentRegistry == nil {
return nil, nil
}
available := map[string]bool{}
if c.discovery != nil {
results, err := c.detectAgents(ctx)
if err != nil {
return nil, fmt.Errorf("detect available agents: %w", err)
}
for _, result := range results {
available[result.Name] = result.Available
}
}
targets := make([]runtimeUpdateStatusTarget, 0)
for _, ag := range c.agentRegistry.ListEnabled() {
if c.discovery != nil && !available[ag.ID()] {
continue
}
managed, ok := ag.(agents.ManagedNPMRuntimeAgent)
if !ok {
continue
}
spec := managed.ManagedNPMRuntime()
packageName := strings.TrimSpace(spec.Package)
if packageName == "" {
continue
}
defaultVersion := strings.TrimSpace(spec.DefaultVersionOrPinned())
if _, err := managedruntime.ParseStableVersion(defaultVersion); err != nil {
// Custom test/extension agents are not part of the built-in managed
// catalogue and must not create an unverifiable status item.
continue
}
activeVersion, effectiveVersion, _, selectionErr := c.runtimeVersions(ctx, ag.ID(), spec)
if selectionErr != nil {
effectiveVersion = defaultVersion
}
targets = append(targets, runtimeUpdateStatusTarget{
agentName: ag.ID(),
packageName: packageName,
defaultVersion: defaultVersion,
activeVersion: activeVersion,
effectiveVersion: effectiveVersion,
selectionErr: selectionErr,
})
}
return targets, nil
}
func (c *Controller) runtimeUpdateStatusEntry(
ctx context.Context,
packageName string,
now time.Time,
) runtimeUpdateStatusCacheEntry {
c.runtimeUpdateStatusMu.Lock()
if entry, ok := c.runtimeUpdateStatusCache[packageName]; ok && now.Before(entry.expiresAt) {
c.runtimeUpdateStatusMu.Unlock()
return entry
}
lookup := c.runtimeUpdateStatusLookup
if lookup == nil {
lookup = make(chan struct{}, runtimeUpdateStatusMaxConcurrent)
c.runtimeUpdateStatusLookup = lookup
}
c.runtimeUpdateStatusMu.Unlock()
lookup <- struct{}{}
defer func() { <-lookup }()
// Recheck after waiting for the bounded slot so concurrent requests do not
// repeat a lookup that another waiter already completed.
c.runtimeUpdateStatusMu.Lock()
if entry, ok := c.runtimeUpdateStatusCache[packageName]; ok && now.Before(entry.expiresAt) {
c.runtimeUpdateStatusMu.Unlock()
return entry
}
c.runtimeUpdateStatusMu.Unlock()
lookupCtx, cancel := context.WithTimeout(ctx, runtimeUpdateStatusLookupTimeout)
defer cancel()
latest, err := c.resolveRuntimeUpdateLatest(lookupCtx, packageName)
entry := runtimeUpdateStatusCacheEntry{}
if err == nil {
entry.latest = latest
entry.ok = true
entry.checkedAt = now
entry.expiresAt = now.Add(runtimeUpdateStatusSuccessTTL)
} else {
entry.expiresAt = now.Add(runtimeUpdateStatusFailureTTL)
}
c.runtimeUpdateStatusMu.Lock()
if c.runtimeUpdateStatusCache == nil {
c.runtimeUpdateStatusCache = make(map[string]runtimeUpdateStatusCacheEntry)
}
c.runtimeUpdateStatusCache[packageName] = entry
c.runtimeUpdateStatusMu.Unlock()
return entry
}
func (c *Controller) resolveRuntimeUpdateLatest(ctx context.Context, packageName string) (string, error) {
c.runtimeUpdateStatusMu.Lock()
resolver := c.runtimeUpdateStatusResolver
c.runtimeUpdateStatusMu.Unlock()
if resolver != nil {
return validateRuntimeUpdateLatest(resolver(ctx, packageName))
}
if c.runtimeUpdater == nil {
return "", errors.New("runtime updater unavailable")
}
if metadataResolver, ok := c.runtimeUpdater.(RuntimeVersionResolver); ok {
metadata, err := metadataResolver.ResolveVersions(ctx, packageName)
if err != nil {
return "", err
}
return validateRuntimeUpdateLatest(metadata.Latest, nil)
}
return validateRuntimeUpdateLatest(c.runtimeUpdater.ResolveTarget(ctx, packageName))
}
func validateRuntimeUpdateLatest(latest string, err error) (string, error) {
if err != nil {
return "", err
}
latest = strings.TrimSpace(latest)
if _, parseErr := managedruntime.ParseStableVersion(latest); parseErr != nil {
return "", parseErr
}
return latest, nil
}
func compareRuntimeUpdateStatus(latest, effective string) dto.AgentUpdateCheckState {
latestVersion, latestErr := managedruntime.ParseStableVersion(latest)
effectiveVersion, effectiveErr := managedruntime.ParseStableVersion(effective)
if latestErr != nil || effectiveErr != nil {
return dto.AgentUpdateCheckStateUnknown
}
if latestVersion.GreaterThan(effectiveVersion) {
return dto.AgentUpdateCheckStateUpdateAvailable
}
return dto.AgentUpdateCheckStateUpToDate
}
func (c *Controller) runtimeUpdateStatusTime() time.Time {
c.runtimeUpdateStatusMu.Lock()
now := c.runtimeUpdateStatusNow
c.runtimeUpdateStatusMu.Unlock()
if now == nil {
return time.Now().UTC()
}
return now().UTC()
}
@@ -0,0 +1,226 @@
package controller
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/managedruntime"
"github.com/kandev/kandev/internal/agent/settings/dto"
)
type statusSelectionStore struct {
selection map[string]managedruntime.Selection
}
func (s *statusSelectionStore) Get(_ context.Context, agentName, packageName string) (managedruntime.Selection, bool, error) {
selection, ok := s.selection[agentName+"\x00"+packageName]
return selection, ok, nil
}
func (s *statusSelectionStore) Save(context.Context, string, string, string) error {
return nil
}
func (s *statusSelectionStore) Delete(context.Context, string, string) error {
return nil
}
func TestListAgentUpdateStatusesComparesLatestAndCachesFailures(t *testing.T) {
controller := newTestController(map[string]agents.Agent{
"claude-acp": agents.NewClaudeACP(),
"codex-acp": agents.NewCodexACP(),
"copilot-acp": agents.NewCopilotACP(),
"gemini": agents.NewGemini(),
})
controller.SetManagedRuntimeSelectionStore(&statusSelectionStore{selection: map[string]managedruntime.Selection{
"claude-acp\x00@agentclientprotocol/claude-agent-acp": {
Package: "@agentclientprotocol/claude-agent-acp",
Version: "0.70.1",
},
}})
now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC)
controller.SetRuntimeUpdateStatusClock(func() time.Time { return now })
var mu sync.Mutex
calls := make(map[string]int)
controller.SetRuntimeUpdateStatusResolver(func(_ context.Context, packageName string) (string, error) {
mu.Lock()
calls[packageName]++
mu.Unlock()
switch packageName {
case "@agentclientprotocol/claude-agent-acp":
return "0.71.0", nil
case "@agentclientprotocol/codex-acp":
return agents.NewCodexACP().ManagedNPMRuntime().DefaultVersionOrPinned(), nil
case "@github/copilot":
return "1.0.74", nil
case "@google/gemini-cli":
return "", errors.New("registry unavailable")
default:
return "", errors.New("unexpected package")
}
})
statuses, err := controller.ListAgentUpdateStatuses(context.Background())
if err != nil {
t.Fatalf("ListAgentUpdateStatuses: %v", err)
}
if len(statuses.Statuses) != 4 {
t.Fatalf("status count = %d, want 4", len(statuses.Statuses))
}
byPackage := make(map[string]dto.AgentUpdateStatusDTO, len(statuses.Statuses))
for _, status := range statuses.Statuses {
byPackage[status.Package] = status
if status.Package != "@google/gemini-cli" && status.CheckedAt == nil {
t.Fatalf("%s has no checked_at", status.Package)
}
}
if got := byPackage["@agentclientprotocol/claude-agent-acp"].CheckState; got != dto.AgentUpdateCheckStateUpdateAvailable {
t.Fatalf("claude state = %q, want update_available", got)
}
if got := byPackage["@agentclientprotocol/claude-agent-acp"].EffectiveVersion; got != "0.70.1" {
t.Fatalf("claude effective = %q, want selected version", got)
}
if got := byPackage["@agentclientprotocol/codex-acp"].CheckState; got != dto.AgentUpdateCheckStateUpToDate {
t.Fatalf("codex state = %q, want up_to_date", got)
}
if got := byPackage["@github/copilot"].CheckState; got != dto.AgentUpdateCheckStateUpToDate {
t.Fatalf("copilot state = %q, want up_to_date for older latest", got)
}
if got := byPackage["@google/gemini-cli"].CheckState; got != dto.AgentUpdateCheckStateUnknown {
t.Fatalf("gemini state = %q, want unknown", got)
}
if status := byPackage["@google/gemini-cli"]; status.CheckedAt != nil || status.LatestVersion != "" {
t.Fatalf("gemini failure metadata = latest %q, checked_at %v; want both omitted", status.LatestVersion, status.CheckedAt)
}
if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil {
t.Fatalf("cached ListAgentUpdateStatuses: %v", err)
}
mu.Lock()
for packageName, count := range calls {
if count != 1 {
t.Errorf("resolver calls for %s = %d, want 1 within TTL", packageName, count)
}
}
mu.Unlock()
now = now.Add(15*time.Minute + time.Second)
if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil {
t.Fatalf("failed-cache refresh: %v", err)
}
mu.Lock()
if calls["@google/gemini-cli"] != 2 {
t.Errorf("failed resolver calls = %d, want 2 after failure TTL", calls["@google/gemini-cli"])
}
if calls["@agentclientprotocol/claude-agent-acp"] != 1 {
t.Errorf("successful resolver calls = %d, want 1 before success TTL", calls["@agentclientprotocol/claude-agent-acp"])
}
mu.Unlock()
now = now.Add(6*time.Hour + time.Second)
if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil {
t.Fatalf("successful-cache refresh: %v", err)
}
mu.Lock()
if calls["@agentclientprotocol/claude-agent-acp"] != 2 {
t.Errorf("successful resolver calls = %d, want 2 after success TTL", calls["@agentclientprotocol/claude-agent-acp"])
}
mu.Unlock()
if got := controller.ListAgentUpdateJobs(); len(got) != 0 {
t.Fatalf("status request created %d update jobs", len(got))
}
}
func TestListAgentUpdateStatusesInvalidatesOnePackage(t *testing.T) {
controller := newTestController(map[string]agents.Agent{
"claude-acp": agents.NewClaudeACP(),
})
var calls int
controller.SetRuntimeUpdateStatusResolver(func(context.Context, string) (string, error) {
calls++
return "0.71.0", nil
})
if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil {
t.Fatalf("initial status: %v", err)
}
controller.InvalidateRuntimeUpdateStatus("@agentclientprotocol/claude-agent-acp")
if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil {
t.Fatalf("status after invalidation: %v", err)
}
if calls != 2 {
t.Fatalf("resolver calls = %d, want 2 after invalidation", calls)
}
}
func TestListAgentUpdateStatusesInitializesCacheForBareController(t *testing.T) {
controller := newTestController(map[string]agents.Agent{
"claude-acp": agents.NewClaudeACP(),
})
statuses, err := controller.ListAgentUpdateStatuses(context.Background())
if err != nil {
t.Fatalf("ListAgentUpdateStatuses: %v", err)
}
if len(statuses.Statuses) != 1 || statuses.Statuses[0].CheckState != dto.AgentUpdateCheckStateUnknown {
t.Fatalf("statuses = %#v, want one unknown status", statuses.Statuses)
}
}
func TestListAgentUpdateStatusesBoundsRegistryLookupByContext(t *testing.T) {
controller := newTestController(map[string]agents.Agent{
"claude-acp": agents.NewClaudeACP(),
})
controller.SetRuntimeUpdateStatusResolver(func(ctx context.Context, _ string) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
statuses, err := controller.ListAgentUpdateStatuses(ctx)
if err != nil {
t.Fatalf("ListAgentUpdateStatuses: %v", err)
}
if len(statuses.Statuses) != 1 || statuses.Statuses[0].CheckState != dto.AgentUpdateCheckStateUnknown {
t.Fatalf("statuses = %#v, want one unknown status", statuses.Statuses)
}
}
func TestListAgentUpdateStatusesBoundsConcurrentLookups(t *testing.T) {
controller := newTestController(map[string]agents.Agent{
"claude-acp": agents.NewClaudeACP(),
"codex-acp": agents.NewCodexACP(),
"copilot-acp": agents.NewCopilotACP(),
"gemini": agents.NewGemini(),
"opencode-acp": agents.NewOpenCodeACP(),
})
var mu sync.Mutex
current, maximum := 0, 0
controller.SetRuntimeUpdateStatusResolver(func(context.Context, string) (string, error) {
mu.Lock()
current++
if current > maximum {
maximum = current
}
mu.Unlock()
time.Sleep(5 * time.Millisecond)
mu.Lock()
current--
mu.Unlock()
return "9.9.9", nil
})
if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil {
t.Fatalf("ListAgentUpdateStatuses: %v", err)
}
if maximum > 5 {
t.Fatalf("maximum concurrent lookups = %d, want at most 5", maximum)
}
}
@@ -316,6 +316,43 @@ func TestAgentUpdatePreviewResolvesTrustedCommandWithoutStartingAJob(t *testing.
}
}
func TestAgentUpdatePreviewUseDefaultClassifiesResetAndShowsEffectiveVersions(t *testing.T) {
updater := &recoveryRuntimeUpdater{
metadata: RuntimeVersionMetadata{Versions: []string{"1.0.0", "1.1.0"}, Latest: "1.1.0"},
current: hostutility.AgentCapabilities{
Status: hostutility.StatusOK,
AgentVersion: "1.0.0",
},
currentFound: true,
}
selectionStore := newRecoverySelectionStore()
selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{
Package: "@example/managed-acp",
Version: "1.0.0",
}
ag := &managedTestAgent{
testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true},
spec: agents.ManagedNPMRuntimeSpec{
Package: "@example/managed-acp",
DefaultVersion: "1.1.0",
},
}
ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag})
ctrl.SetRuntimeUpdater(updater)
ctrl.SetManagedRuntimeSelectionStore(selectionStore)
preview, err := ctrl.PreviewAgentUpdateUseDefault(context.Background(), ag.ID())
if err != nil {
t.Fatalf("PreviewAgentUpdateUseDefault: %v", err)
}
if preview.Operation != string(managedruntime.OperationUseDefault) {
t.Fatalf("operation = %q, want use_default", preview.Operation)
}
if preview.DefaultVersion != "1.1.0" || preview.EffectiveVersion != "1.0.0" || preview.TargetVersion != "1.1.0" {
t.Fatalf("versions = default %q, effective %q, target %q", preview.DefaultVersion, preview.EffectiveVersion, preview.TargetVersion)
}
}
func TestAgentUpdatePreviewRejectsUnsupportedAndResolutionFailure(t *testing.T) {
tests := []struct {
name string
@@ -495,8 +532,8 @@ func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) {
t.Fatalf("Enqueue: %v", err)
}
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
if final.CurrentVersion != "1.0.0" || final.TargetVersion != "1.1.0" {
t.Fatalf("versions = %q -> %q, want 1.0.0 -> 1.1.0", final.CurrentVersion, final.TargetVersion)
if final.CurrentVersion != "1.1.0" || final.TargetVersion != "1.1.0" {
t.Fatalf("versions = %q -> %q, want 1.1.0 -> 1.1.0", final.CurrentVersion, final.TargetVersion)
}
if final.Output != "npm prepared runtime\n" {
t.Fatalf("Output = %q", final.Output)
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/kandev/kandev/internal/agent/discovery"
@@ -61,26 +62,31 @@ var (
)
type Controller struct {
repo store.Repository
discovery *discovery.Registry
agentRegistry *registry.Registry
sessionChecker SessionChecker
watcherDeps WatcherDependencyChecker
routingTierDeps RoutingTierDependencyChecker
automationDeps AutomationDependencyChecker
utilityDeps UtilityDependencyChecker
mcpService *mcpconfig.Service
modelCache *modelfetcher.Cache
hostUtility hostUtilityProvider
jobStore *JobStore
updateJobStore *AgentUpdateJobStore
runtimeUpdater RuntimeUpdater
managedRuntimeSelections managedruntime.SelectionStore
maintenance *maintenanceCoordinator
hub JobBroadcaster
logger *logger.Logger
secretStore secrets.SecretStore
dynamicAgentRoutingEnabled bool
repo store.Repository
discovery *discovery.Registry
agentRegistry *registry.Registry
sessionChecker SessionChecker
watcherDeps WatcherDependencyChecker
routingTierDeps RoutingTierDependencyChecker
automationDeps AutomationDependencyChecker
utilityDeps UtilityDependencyChecker
mcpService *mcpconfig.Service
modelCache *modelfetcher.Cache
hostUtility hostUtilityProvider
jobStore *JobStore
updateJobStore *AgentUpdateJobStore
runtimeUpdater RuntimeUpdater
managedRuntimeSelections managedruntime.SelectionStore
maintenance *maintenanceCoordinator
hub JobBroadcaster
logger *logger.Logger
secretStore secrets.SecretStore
runtimeUpdateStatusMu sync.Mutex
runtimeUpdateStatusCache map[string]runtimeUpdateStatusCacheEntry
runtimeUpdateStatusNow func() time.Time
runtimeUpdateStatusResolver RuntimeUpdateStatusResolver
runtimeUpdateStatusLookup chan struct{}
dynamicAgentRoutingEnabled bool
}
// SetDynamicAgentRoutingEnabled applies the authoritative runtime flag to the
@@ -245,13 +251,16 @@ type hostUtilityProvider interface {
func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry *registry.Registry, sessionChecker SessionChecker, log *logger.Logger,
) *Controller {
return &Controller{
repo: repo,
discovery: discoveryRegistry,
agentRegistry: agentRegistry,
sessionChecker: sessionChecker,
mcpService: mcpconfig.NewService(repo),
modelCache: modelfetcher.NewCache(),
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
repo: repo,
discovery: discoveryRegistry,
agentRegistry: agentRegistry,
sessionChecker: sessionChecker,
mcpService: mcpconfig.NewService(repo),
modelCache: modelfetcher.NewCache(),
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
runtimeUpdateStatusCache: make(map[string]runtimeUpdateStatusCacheEntry),
runtimeUpdateStatusNow: time.Now,
runtimeUpdateStatusLookup: make(chan struct{}, runtimeUpdateStatusMaxConcurrent),
}
}
@@ -339,6 +348,7 @@ func (c *Controller) initializeUpdateJobStore() {
c.BroadcastAvailableAgents,
c.managedRuntimeSelections,
)
c.updateJobStore.SetStatusInvalidator(c.InvalidateRuntimeUpdateStatus)
}
// BroadcastAvailableAgents fetches the current available-agents snapshot and
@@ -681,7 +681,8 @@ func TestController_PreviewAgentCommand_CopilotKeepsManagedPackage(t *testing.T)
if err != nil {
t.Fatalf("PreviewAgentCommand() error = %v", err)
}
want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"}
managed := agents.NewCopilotACP()
want := []string{"npx", "--yes", "--prefer-offline", managed.ManagedNPMRuntime().PackageSpec(""), "--acp"}
if got := res.Command; !slices.Equal(got, want) {
t.Errorf("preview with copilot on PATH = %v, want %v", got, want)
}
+52 -16
View File
@@ -283,10 +283,41 @@ type AvailableAgentDTO struct {
// RuntimeUpdateDTO describes a Kandev-managed npm runtime. Package is
// informational; update requests select only the built-in agent name.
type RuntimeUpdateDTO struct {
Supported bool `json:"supported"`
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
ActiveVersion string `json:"active_version,omitempty"`
Supported bool `json:"supported"`
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
DefaultVersion string `json:"default_version"`
ActiveVersion string `json:"active_version,omitempty"`
EffectiveVersion string `json:"effective_version"`
}
// AgentUpdateCheckState describes the result of the cached npm latest-version
// lookup. Unknown is intentionally distinct from up_to_date so the UI can
// avoid suggesting that an unavailable registry check is current.
type AgentUpdateCheckState string
const (
AgentUpdateCheckStateUpdateAvailable AgentUpdateCheckState = "update_available"
AgentUpdateCheckStateUpToDate AgentUpdateCheckState = "up_to_date"
AgentUpdateCheckStateUnknown AgentUpdateCheckState = "unknown"
)
// AgentUpdateStatusDTO is the read-only update hint for one available managed
// runtime. ActiveVersion is the optional persisted operator selection; the
// default is never persisted and remains the fallback effective version.
type AgentUpdateStatusDTO struct {
AgentName string `json:"agent_name"`
Package string `json:"package"`
DefaultVersion string `json:"default_version"`
ActiveVersion string `json:"active_version,omitempty"`
EffectiveVersion string `json:"effective_version"`
LatestVersion string `json:"latest_version,omitempty"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
CheckState AgentUpdateCheckState `json:"check_state"`
}
type ListAgentUpdateStatusResponse struct {
Statuses []AgentUpdateStatusDTO `json:"statuses"`
}
type ListAvailableAgentsResponse struct {
@@ -344,18 +375,20 @@ const (
// AgentUpdateJobDTO is the retained HTTP and WebSocket update snapshot.
type AgentUpdateJobDTO struct {
JobID string `json:"job_id"`
AgentName string `json:"agent_name"`
Status AgentUpdateJobStatus `json:"status"`
Operation string `json:"operation"`
CurrentVersion string `json:"current_version,omitempty"`
ActiveVersion string `json:"active_version,omitempty"`
TargetVersion string `json:"target_version,omitempty"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
RefreshError string `json:"refresh_error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
JobID string `json:"job_id"`
AgentName string `json:"agent_name"`
Status AgentUpdateJobStatus `json:"status"`
Operation string `json:"operation"`
CurrentVersion string `json:"current_version,omitempty"`
DefaultVersion string `json:"default_version"`
ActiveVersion string `json:"active_version,omitempty"`
EffectiveVersion string `json:"effective_version"`
TargetVersion string `json:"target_version,omitempty"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
RefreshError string `json:"refresh_error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
// AgentUpdatePreviewDTO is a read-only representation of the next managed
@@ -364,7 +397,9 @@ type AgentUpdatePreviewDTO struct {
AgentName string `json:"agent_name"`
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
DefaultVersion string `json:"default_version"`
ActiveVersion string `json:"active_version,omitempty"`
EffectiveVersion string `json:"effective_version"`
TargetVersion string `json:"target_version"`
Operation string `json:"operation"`
AvailableVersions []AgentUpdateVersionDTO `json:"available_versions"`
@@ -383,6 +418,7 @@ type AgentUpdateVersionDTO struct {
// always resolved from trusted built-in agent metadata.
type AgentUpdateRequest struct {
TargetVersion string `json:"target_version"`
UseDefault bool `json:"use_default"`
}
type ListAgentUpdateJobsResponse struct {
@@ -45,6 +45,10 @@ func (handlerSelectionStore) Save(context.Context, string, string, string) error
return nil
}
func (handlerSelectionStore) Delete(context.Context, string, string) error {
return nil
}
func (u *handlerRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) {
return hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.0"}, true
}
@@ -181,7 +185,9 @@ func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) {
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 ""` {
commandArgs := agents.NewClaudeACP().ManagedNPMRuntime().CacheUpdateCommand().Args()
wantCommand := strings.Join(commandArgs[:len(commandArgs)-1], " ") + ` ""`
if preview["command_string"] != wantCommand {
t.Fatalf("command_string = %#v", preview["command_string"])
}
@@ -192,6 +198,28 @@ func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) {
}
}
func TestAgentUpdateStatusEndpointIsReadOnly(t *testing.T) {
t.Setenv("KANDEV_E2E_MOCK", "true")
router, ctrl, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{})
var statusResponse struct {
Statuses []dto.AgentUpdateStatusDTO `json:"statuses"`
}
response := httptest.NewRecorder()
router.ServeHTTP(response, updateRequest(http.MethodGet, "/api/v1/agent-update/status"))
if response.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", response.Code, response.Body.String())
}
if err := json.Unmarshal(response.Body.Bytes(), &statusResponse); err != nil {
t.Fatalf("decode status response: %v", err)
}
if len(statusResponse.Statuses) == 0 {
t.Fatal("status response is empty")
}
if jobs := ctrl.ListAgentUpdateJobs(); len(jobs) != 0 {
t.Fatalf("status request created %d update jobs", len(jobs))
}
}
func TestAgentUpdateEndpointsAcceptAndRetainJobs(t *testing.T) {
router, _, completed := newAgentUpdateRouter(t, &handlerRuntimeUpdater{})
response := httptest.NewRecorder()
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -69,6 +70,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
api.POST("/agent-models/:agentName/resolve", h.httpResolveAgentModelConfig)
api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand)
api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent)
api.GET("/agent-update/status", h.httpListAgentUpdateStatuses)
api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate)
api.GET("/agent-install/jobs", h.httpListInstallJobs)
api.GET("/agent-install/jobs/:id", h.httpGetInstallJob)
@@ -131,11 +133,18 @@ func (h *Handlers) httpUpdateAgentRuntime(c *gin.Context) {
return
}
request.TargetVersion = strings.TrimSpace(request.TargetVersion)
if request.TargetVersion == "" {
if request.UseDefault && request.TargetVersion != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target version and use_default cannot be combined"})
return
}
if !request.UseDefault && request.TargetVersion == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target version is required"})
return
}
h.enqueueMaintenance(c, name, "update", func() (any, error) {
if request.UseDefault {
return h.controller.EnqueueAgentUpdateUseDefault(c.Request.Context(), name)
}
return h.controller.EnqueueAgentUpdate(c.Request.Context(), name, request.TargetVersion)
}, classifyUpdateError)
}
@@ -145,11 +154,27 @@ func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) {
if !ok {
return
}
preview, err := h.controller.PreviewAgentUpdate(
c.Request.Context(),
name,
strings.TrimSpace(c.Query("target_version")),
)
targetVersion := strings.TrimSpace(c.Query("target_version"))
useDefault := false
if raw := strings.TrimSpace(c.Query("use_default")); raw != "" {
parsed, err := strconv.ParseBool(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "use_default must be a boolean"})
return
}
useDefault = parsed
}
if useDefault && targetVersion != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target version and use_default cannot be combined"})
return
}
var preview *dto.AgentUpdatePreviewDTO
var err error
if useDefault {
preview, err = h.controller.PreviewAgentUpdateUseDefault(c.Request.Context(), name)
} else {
preview, err = h.controller.PreviewAgentUpdate(c.Request.Context(), name, targetVersion)
}
if err == nil {
c.JSON(http.StatusOK, preview)
return
@@ -162,6 +187,16 @@ func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to preview agent update"})
}
func (h *Handlers) httpListAgentUpdateStatuses(c *gin.Context) {
resp, err := h.controller.ListAgentUpdateStatuses(c.Request.Context())
if err != nil {
h.logger.Error("failed to list agent runtime update statuses", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agent update statuses"})
return
}
c.JSON(http.StatusOK, resp)
}
func requireAgentName(c *gin.Context) (string, bool) {
name := strings.TrimSpace(c.Param("agentName"))
if name == "" {
@@ -80,3 +80,8 @@ func (s *Store) Save(ctx context.Context, key string, value []byte) error {
`), key, string(value), time.Now().UTC())
return err
}
func (s *Store) Delete(ctx context.Context, key string) error {
_, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM settings WHERE key = ?`), key)
return err
}
+23 -5
View File
@@ -22,9 +22,10 @@ import {
listAgents,
listAvailableAgents,
} from "@/lib/api";
import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api";
import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery";
import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates";
import { useAgentRuntimeUpdateStatuses } from "@/hooks/domains/settings/use-agent-runtime-update-statuses";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
import { AgentProfilesSubList } from "@/components/settings/agents/agent-profiles-section";
@@ -55,10 +56,19 @@ type InstalledAgentsSectionProps = {
resolveDisplayName: (name: string) => string;
resolveCapabilityStatus: (name: string) => string | undefined;
resolveRuntimeUpdate: (name: string) => RuntimeUpdate | undefined;
resolveRuntimeUpdateStatus: (name: string) => AgentUpdateStatus | undefined;
installJobs: Record<string, InstallJob>;
updateJobs: Record<string, AgentUpdateJob>;
previewUpdate: (name: string, targetVersion?: string) => Promise<AgentUpdatePreview>;
startUpdate: (name: string, targetVersion: string) => Promise<AgentUpdateJob>;
previewUpdate: (
name: string,
targetVersion?: string,
useDefault?: boolean,
) => Promise<AgentUpdatePreview>;
startUpdate: (
name: string,
targetVersion: string,
useDefault?: boolean,
) => Promise<AgentUpdateJob>;
setTuiDialogOpen: (open: boolean) => void;
handleRescan: () => Promise<void>;
};
@@ -172,6 +182,7 @@ function InstalledAgentsSection({
resolveDisplayName,
resolveCapabilityStatus,
resolveRuntimeUpdate,
resolveRuntimeUpdateStatus,
installJobs,
updateJobs,
previewUpdate,
@@ -239,6 +250,7 @@ function InstalledAgentsSection({
? {
capabilityStatus: resolveCapabilityStatus(agent.name),
runtimeUpdate: resolveRuntimeUpdate(agent.name),
runtimeUpdateStatus: resolveRuntimeUpdateStatus(agent.name),
installJob: installJobs[agent.name],
updateJob: updateJobs[agent.name],
onPreview: previewUpdate,
@@ -270,6 +282,9 @@ function useAgentPageState() {
const { items: availableAgents } = useAvailableAgents();
const [rescanning, setRescanning] = useState(false);
const [tuiDialogOpen, setTuiDialogOpen] = useState(false);
const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates();
const { refresh: refreshRuntimeUpdateStatuses, statusByAgent } =
useAgentRuntimeUpdateStatuses(updateJobs);
const installedAgents = useMemo(() => detectedAgents(discoveryAgents), [discoveryAgents]);
const savedAgentsByName = useMemo(
@@ -282,6 +297,7 @@ function useAgentPageState() {
availableAgents.find((item: AvailableAgent) => item.name === name)?.model_config?.status;
const resolveRuntimeUpdate = (name: string) =>
availableAgents.find((item: AvailableAgent) => item.name === name)?.runtime_update;
const resolveRuntimeUpdateStatus = (name: string) => statusByAgent[name];
const handleRescan = async () => {
if (rescanning) {
@@ -295,13 +311,12 @@ function useAgentPageState() {
]);
setAgentDiscovery(discoveryResp.agents);
setAvailableAgents(availableResp.agents, availableResp.tools ?? []);
await refreshRuntimeUpdateStatuses();
} finally {
setRescanning(false);
}
};
const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates();
const handleCreateCustomTUI = async (data: {
display_name: string;
model?: string;
@@ -336,6 +351,7 @@ function useAgentPageState() {
resolveDisplayName,
resolveCapabilityStatus,
resolveRuntimeUpdate,
resolveRuntimeUpdateStatus,
handleRescan,
handleCreateCustomTUI,
installJobs,
@@ -358,6 +374,7 @@ export default function AgentsSettingsPage() {
resolveDisplayName,
resolveCapabilityStatus,
resolveRuntimeUpdate,
resolveRuntimeUpdateStatus,
handleRescan,
handleCreateCustomTUI,
installJobs,
@@ -403,6 +420,7 @@ export default function AgentsSettingsPage() {
resolveDisplayName={resolveDisplayName}
resolveCapabilityStatus={resolveCapabilityStatus}
resolveRuntimeUpdate={resolveRuntimeUpdate}
resolveRuntimeUpdateStatus={resolveRuntimeUpdateStatus}
installJobs={installJobs}
updateJobs={updateJobs}
previewUpdate={previewUpdate}
@@ -0,0 +1,319 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState, type ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
import { AgentRuntimeUpdateControl } from "./agent-runtime-update-control";
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false }),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
vi.mock("@kandev/ui/dialog", () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
open ? <>{children}</> : null,
DialogContent: ({ children, ...props }: { children?: ReactNode } & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children?: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children?: ReactNode }) => <header>{children}</header>,
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@kandev/ui/drawer", () => ({
Drawer: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
open ? <>{children}</> : null,
DrawerContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DrawerDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
DrawerFooter: ({ children }: { children?: ReactNode }) => <footer>{children}</footer>,
DrawerHeader: ({ children }: { children?: ReactNode }) => <header>{children}</header>,
DrawerTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
}));
const AGENT_NAME = "claude-acp";
const PACKAGE_NAME = "@agentclientprotocol/claude-agent-acp";
const ACTIVE_VERSION = "0.70.0";
function preview(overrides: Partial<AgentUpdatePreview> = {}): AgentUpdatePreview {
return {
agent_name: AGENT_NAME,
package: PACKAGE_NAME,
current_version: ACTIVE_VERSION,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
target_version: "0.71.0",
operation: "update",
active_version: ACTIVE_VERSION,
available_versions: [
{ version: "0.71.0", latest: true },
{ version: ACTIVE_VERSION, latest: false },
],
command: ["npm", "exec"],
command_string: "npm exec",
...overrides,
};
}
function queuedJob(): AgentUpdateJob {
return {
job_id: "job-1",
agent_name: AGENT_NAME,
status: "queued",
started_at: "2026-01-01T00:00:00.000Z",
};
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("AgentRuntimeUpdateControl", () => {
it("keeps a long version history behind the browse action", async () => {
const onPreview = vi.fn().mockResolvedValue(
preview({
available_versions: [
{ version: "0.71.0", latest: true },
{ version: ACTIVE_VERSION, latest: false },
{ version: "0.69.0", latest: false },
{ version: "0.50.0", latest: false },
],
}),
);
render(
<AgentRuntimeUpdateControl
agentName={AGENT_NAME}
displayName="Claude Code"
runtimeUpdate={{
supported: true,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
}}
onPreview={onPreview}
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
/>,
);
fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Browse all versions" })).toBeTruthy();
});
expect(screen.queryByText("0.50.0")).toBeNull();
});
it("filters the full version history and previews the selected version", async () => {
const onPreview = vi.fn().mockResolvedValue(
preview({
available_versions: [
{ version: "0.71.0", latest: true },
{ version: ACTIVE_VERSION, latest: false },
{ version: "0.69.0", latest: false },
{ version: "0.50.0", latest: false },
],
}),
);
render(
<AgentRuntimeUpdateControl
agentName={AGENT_NAME}
displayName="Claude Code"
runtimeUpdate={{
supported: true,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
}}
onPreview={onPreview}
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
/>,
);
fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Browse all versions" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Browse all versions" }));
const search = screen.getByPlaceholderText("Search versions");
expect(screen.getByTestId(`agent-update-version-option-${AGENT_NAME}-0.50.0`)).toBeTruthy();
fireEvent.change(search, { target: { value: "0.50" } });
expect(screen.getByTestId(`agent-update-version-option-${AGENT_NAME}-0.50.0`)).toBeTruthy();
expect(screen.queryByTestId(`agent-update-version-option-${AGENT_NAME}-0.69.0`)).toBeNull();
fireEvent.click(screen.getByTestId(`agent-update-version-option-${AGENT_NAME}-0.50.0`));
await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith(AGENT_NAME, "0.50.0"));
});
});
describe("AgentRuntimeUpdateControl", () => {
it("shows the update dot and exposes effective and latest versions", () => {
render(
<AgentRuntimeUpdateControl
agentName={AGENT_NAME}
displayName="Claude Code"
runtimeUpdate={{
supported: true,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
}}
runtimeUpdateStatus={{
agent_name: AGENT_NAME,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
latest_version: "0.71.0",
check_state: "update_available",
}}
onPreview={vi.fn().mockResolvedValue(preview())}
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
/>,
);
expect(screen.getByTestId(`agent-update-available-dot-${AGENT_NAME}`)).toBeTruthy();
expect(
screen.getByRole("button", { name: `Update Claude Code from ${ACTIVE_VERSION} to 0.71.0` }),
).toBeTruthy();
});
it("keeps the trigger usable when status is unknown and offers the default action", async () => {
const onPreview = vi.fn().mockResolvedValue(preview());
const onUpdate = vi.fn().mockResolvedValue(queuedJob());
render(
<AgentRuntimeUpdateControl
agentName={AGENT_NAME}
displayName="Claude Code"
runtimeUpdate={{
supported: true,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
}}
runtimeUpdateStatus={{
agent_name: AGENT_NAME,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
check_state: "unknown",
}}
onPreview={onPreview}
onUpdate={onUpdate}
/>,
);
expect(screen.queryByTestId(`agent-update-available-dot-${AGENT_NAME}`)).toBeNull();
expect(
screen.getByRole("button", {
name: "Latest version for Claude Code is unavailable. The update control remains usable.",
}),
).toBeTruthy();
fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`));
await waitFor(() => expect(onPreview).toHaveBeenCalledWith(AGENT_NAME, undefined));
expect(
screen.getByRole("button", { name: `Use Kandev default (${ACTIVE_VERSION})` }),
).toBeTruthy();
});
it("offers the default action when the active version is unknown", async () => {
const onPreview = vi.fn().mockResolvedValue(preview({ active_version: undefined }));
render(
<AgentRuntimeUpdateControl
agentName={AGENT_NAME}
displayName="Claude Code"
runtimeUpdate={{
supported: true,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
}}
runtimeUpdateStatus={{
agent_name: AGENT_NAME,
package: PACKAGE_NAME,
default_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
check_state: "unknown",
}}
onPreview={onPreview}
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
/>,
);
fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`));
await waitFor(() => expect(onPreview).toHaveBeenCalledWith(AGENT_NAME, undefined));
expect(
screen.getByRole("button", { name: `Use Kandev default (${ACTIVE_VERSION})` }),
).toBeTruthy();
});
});
describe("AgentRuntimeUpdateControl reset state", () => {
it("shows the cleared active version and job effective version after a reset", async () => {
const defaultVersion = "0.71.0";
const onPreview = vi.fn().mockResolvedValue(
preview({
default_version: defaultVersion,
target_version: defaultVersion,
operation: "use_default",
}),
);
const resetJob: AgentUpdateJob = {
job_id: "job-reset",
agent_name: AGENT_NAME,
status: "succeeded",
operation: "use_default",
current_version: defaultVersion,
default_version: defaultVersion,
effective_version: defaultVersion,
target_version: defaultVersion,
started_at: "2026-01-01T00:00:00.000Z",
finished_at: "2026-01-01T00:01:00.000Z",
};
function StatefulControl() {
const [job, setJob] = useState<AgentUpdateJob>();
const update = vi.fn().mockImplementation(async () => {
setJob(resetJob);
return resetJob;
});
return (
<AgentRuntimeUpdateControl
agentName={AGENT_NAME}
displayName="Claude Code"
runtimeUpdate={{
supported: true,
package: PACKAGE_NAME,
default_version: defaultVersion,
active_version: ACTIVE_VERSION,
effective_version: ACTIVE_VERSION,
}}
onPreview={onPreview}
onUpdate={update}
job={job}
/>
);
}
render(<StatefulControl />);
fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`));
await waitFor(() =>
expect(
screen.getByRole("button", { name: `Use Kandev default (${defaultVersion})` }),
).toBeTruthy(),
);
fireEvent.click(screen.getByTestId(`agent-update-quick-default-${AGENT_NAME}`));
await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith(AGENT_NAME, undefined, true));
fireEvent.click(screen.getByTestId(`agent-update-confirm-${AGENT_NAME}`));
await waitFor(() => {
const dialog = screen.getByTestId(`agent-update-dialog-${AGENT_NAME}`);
const text = dialog.textContent ?? "";
expect(text).toContain(`Effective version: ${defaultVersion}`);
expect(text).not.toContain(`Active version: ${ACTIVE_VERSION}`);
});
});
});
@@ -2,36 +2,24 @@
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconAlertTriangle, IconChevronDown, IconLoader2, IconRefresh } from "@tabler/icons-react";
import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import { DialogFooter } from "@kandev/ui/dialog";
import { DrawerFooter } from "@kandev/ui/drawer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import {
canApproveAgentRuntimeUpdate,
resolveRuntimeActiveVersion,
resolveRuntimeEffectiveVersion,
resolveRuntimeOperation,
resolveRuntimeVersionPair,
latestRuntimeVersions,
runtimeOperationLabelKey,
} from "@/lib/agent-runtime-update";
import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api";
import type { RuntimeUpdate } from "@/lib/types/http";
import { AgentRuntimeUpdateSurface } from "./agent-runtime-update-surface";
import { RuntimeVersionPicker } from "./runtime-version-picker";
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
const UPDATE_AGENT_KEY = "agents:updateAgent";
@@ -107,6 +95,8 @@ type UpdateBodyProps = {
onRetryPreview: () => void;
selectedTarget: string;
onSelectTarget: (targetVersion: string) => void;
selectedUseDefault: boolean;
onSelectDefault: () => void;
starting: boolean;
};
@@ -124,95 +114,74 @@ function RuntimeVersionSummary({
const activeVersion = resolveRuntimeActiveVersion(preview, job);
const operation = resolveRuntimeOperation(preview, job);
const isUpToDate = operation === "up_to_date";
const effectiveVersion = resolveRuntimeEffectiveVersion(preview, job);
return (
<div className="space-y-1" data-testid={`agent-update-version-summary-${agentName}`}>
<p className="font-medium">{t(runtimeOperationLabelKey(operation))}</p>
<div className="space-y-0.5" data-testid={`agent-update-version-summary-${agentName}`}>
<p className="font-medium" role={isUpToDate ? "status" : undefined}>
{t(runtimeOperationLabelKey(operation))}
</p>
<p className="break-words font-mono text-sm">
{isUpToDate ? currentVersion : `${currentVersion} → ${targetVersion}`}
</p>
{activeVersion && (
<p className="text-sm text-muted-foreground">
{t("agents:activeRuntimeVersion", { version: activeVersion })}
</p>
)}
{isUpToDate && (
<p className="text-sm text-muted-foreground" role="status">
{t("agents:upToDate")}
</p>
)}
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs text-muted-foreground sm:grid-cols-3">
{activeVersion && <p>{t("agents:activeRuntimeVersion", { version: activeVersion })}</p>}
<p>{t("agents:effectiveRuntimeVersion", { version: effectiveVersion })}</p>
{preview.default_version && (
<p>{t("agents:kandevDefaultVersion", { version: preview.default_version })}</p>
)}
</div>
</div>
);
}
function RuntimeVersionSelector({
function RuntimeUpdatePreviewDetails({
agentName,
preview,
selectedTarget,
selectedUseDefault,
loading,
starting,
job,
onSelectTarget,
onSelectDefault,
}: {
agentName: string;
preview: AgentUpdatePreview;
selectedTarget: string;
selectedUseDefault: boolean;
loading: boolean;
starting: boolean;
job?: AgentUpdateJob;
onSelectTarget: (targetVersion: string) => void;
onSelectDefault: () => void;
}) {
const { t } = useTranslation();
const versions = latestRuntimeVersions(
preview.available_versions ?? [{ version: preview.target_version, latest: false }],
);
const activeVersion = resolveRuntimeActiveVersion(preview, job);
return (
<div className="space-y-1">
<label className="font-medium" htmlFor={`agent-update-version-${agentName}`}>
{t("agents:selectRuntimeVersion")}
</label>
<div className="relative">
<select
id={`agent-update-version-${agentName}`}
className="h-11 w-full min-w-0 appearance-none rounded-md border bg-background px-3 pr-10 text-sm sm:h-10"
value={selectedTarget || preview.target_version}
onChange={(event) => onSelectTarget(event.target.value)}
disabled={Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)) || starting || loading}
data-testid={`agent-update-version-${agentName}`}
>
{versions.map((version) => {
const markers = [
version.latest ? t("agents:latestRuntimeVersion") : "",
version.version === activeVersion ? t("agents:activeRuntimeVersionMarker") : "",
]
.filter(Boolean)
.join(", ");
const markerLabel = markers ? t("agents:runtimeVersionMarkerGroup", { markers }) : "";
return (
<option key={version.version} value={version.version}>
{t("agents:runtimeVersionOption", {
version: version.version,
markers: markerLabel,
})}
</option>
);
})}
</select>
<span
className="pointer-events-none absolute inset-y-0 right-3 flex items-center"
data-testid={loading ? `agent-update-version-loading-${agentName}` : undefined}
role={loading ? "status" : undefined}
>
{loading ? (
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<IconChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
)}
{loading && <span className="sr-only">{t("agents:checkingLatestRuntimeVersion")}</span>}
</span>
<>
<RuntimeVersionSummary agentName={agentName} preview={preview} job={job} />
<RuntimeVersionPicker
agentName={agentName}
preview={preview}
selectedTarget={selectedTarget}
selectedUseDefault={selectedUseDefault}
loading={loading}
starting={starting}
job={job}
onSelectTarget={onSelectTarget}
onSelectDefault={onSelectDefault}
/>
<div className="space-y-0.5 text-xs text-muted-foreground">
<p>{t("agents:runtimeUpdateExplainer")}</p>
<p>{t("agents:runtimeUpdateSessionsNote")}</p>
</div>
</div>
<div className="space-y-0.5">
<p className="font-medium">{t("agents:commandThatWillRun")}</p>
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{preview.command_string}
</pre>
</div>
</>
);
}
@@ -226,13 +195,15 @@ function UpdateBody({
onRetryPreview,
selectedTarget,
onSelectTarget,
selectedUseDefault,
onSelectDefault,
starting,
}: UpdateBodyProps) {
const { t } = useTranslation();
const phase = updatePhase(t, job?.status);
return (
<div
className="max-h-[calc(80dvh-10rem)] min-h-0 space-y-3 overflow-y-auto overscroll-contain px-4 py-3 text-xs/relaxed"
className="max-h-[calc(92dvh-10rem)] min-h-0 space-y-2 overflow-y-auto overscroll-contain px-4 py-2 text-xs/normal sm:max-h-[calc(92dvh-8rem)]"
data-testid={`agent-update-dialog-body-${agentName}`}
>
{loading && !preview && (
@@ -243,7 +214,7 @@ function UpdateBody({
)}
{previewError && (
<div
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3"
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-2"
role="alert"
>
<p className="flex items-start gap-2 text-destructive">
@@ -257,7 +228,7 @@ function UpdateBody({
)}
{approveError && (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3"
className="rounded-md border border-destructive/30 bg-destructive/5 p-2"
role="alert"
data-testid={`agent-update-approve-error-${agentName}`}
>
@@ -268,28 +239,17 @@ function UpdateBody({
</div>
)}
{preview && (
<>
<RuntimeVersionSummary agentName={agentName} preview={preview} job={job} />
<RuntimeVersionSelector
agentName={agentName}
preview={preview}
selectedTarget={selectedTarget}
loading={loading}
starting={starting}
job={job}
onSelectTarget={onSelectTarget}
/>
<div className="space-y-1 text-muted-foreground">
<p>{t("agents:runtimeUpdateExplainer")}</p>
<p>{t("agents:runtimeUpdateSessionsNote")}</p>
</div>
<div className="space-y-1">
<p className="font-medium">{t("agents:commandThatWillRun")}</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>
</>
<RuntimeUpdatePreviewDetails
agentName={agentName}
preview={preview}
selectedTarget={selectedTarget}
selectedUseDefault={selectedUseDefault}
loading={loading}
starting={starting}
job={job}
onSelectTarget={onSelectTarget}
onSelectDefault={onSelectDefault}
/>
)}
{phase && (
<p
@@ -304,7 +264,7 @@ function UpdateBody({
{job?.output && (
<pre
data-testid={`agent-update-log-${agentName}`}
className="whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground"
className="whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground"
>
{job.output}
</pre>
@@ -374,57 +334,84 @@ function UpdateFooter({
);
if (mobile) {
return (
<DrawerFooter className="border-t pb-[max(1rem,env(safe-area-inset-bottom))]">
<DrawerFooter className="border-t px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
{content}
</DrawerFooter>
);
}
return <DialogFooter className="border-t px-4 py-3">{content}</DialogFooter>;
return <DialogFooter className="border-t px-4 py-2">{content}</DialogFooter>;
}
function UpdateTrigger({
agentName,
displayName,
runtimeUpdateStatus,
installInFlight,
onOpen,
}: {
agentName: string;
displayName: string;
runtimeUpdateStatus?: AgentUpdateStatus;
installInFlight: boolean;
onOpen: () => void;
}) {
const { t } = useTranslation();
const statusLabel = runtimeUpdateStatusLabel(t, displayName, runtimeUpdateStatus);
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={installInFlight ? 0 : -1} className="inline-flex">
<span tabIndex={installInFlight ? 0 : -1} className="relative 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={t(UPDATE_AGENT_KEY, { name: displayName })}
aria-label={statusLabel}
disabled={installInFlight}
onClick={onOpen}
data-testid={`agent-update-trigger-${agentName}`}
>
<IconRefresh className="size-4" />
</Button>
{runtimeUpdateStatus?.check_state === "update_available" && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-0.5 top-0.5 size-2 rounded-full bg-sky-500 ring-2 ring-background"
data-testid={`agent-update-available-dot-${agentName}`}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent>
{installInFlight
? t("agents:agentInstallationInProgress")
: t(UPDATE_AGENT_KEY, { name: displayName })}
{installInFlight ? t("agents:agentInstallationInProgress") : statusLabel}
</TooltipContent>
</Tooltip>
);
}
function runtimeUpdateStatusLabel(
t: TFunction,
displayName: string,
status?: AgentUpdateStatus,
): string {
if (status?.check_state === "update_available") {
return t("agents:updateAvailableWithVersions", {
name: displayName,
current: status.effective_version,
latest: status.latest_version,
});
}
if (status?.check_state === "unknown") {
return t("agents:runtimeUpdateStatusUnknown", { name: displayName });
}
return t(UPDATE_AGENT_KEY, { name: displayName });
}
export function AgentRuntimeUpdateControl({
agentName,
displayName,
runtimeUpdate,
runtimeUpdateStatus,
job,
installJob,
onPreview,
@@ -433,12 +420,20 @@ export function AgentRuntimeUpdateControl({
agentName: string;
displayName: string;
runtimeUpdate: RuntimeUpdate;
runtimeUpdateStatus?: AgentUpdateStatus;
job?: AgentUpdateJob;
installJob?: InstallJob;
onPreview: (agentName: string, targetVersion?: string) => Promise<AgentUpdatePreview>;
onUpdate: (agentName: string, targetVersion: string) => Promise<AgentUpdateJob>;
onPreview: (
agentName: string,
targetVersion?: string,
useDefault?: boolean,
) => Promise<AgentUpdatePreview>;
onUpdate: (
agentName: string,
targetVersion: string,
useDefault?: boolean,
) => Promise<AgentUpdateJob>;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const {
activeJob,
@@ -451,7 +446,9 @@ export function AgentRuntimeUpdateControl({
preview,
previewError,
selectTarget,
selectDefault,
selectedTarget,
selectedUseDefault,
starting,
} = useAgentUpdateDialogState({ agentName, job, onPreview, onUpdate });
const installInFlight = installJob?.status === "queued" || installJob?.status === "running";
@@ -466,9 +463,13 @@ export function AgentRuntimeUpdateControl({
previewError={previewError}
approveError={approveError}
job={activeJob}
onRetryPreview={() => void loadPreview(selectedTarget || undefined)}
onRetryPreview={() =>
void loadPreview(selectedUseDefault ? undefined : selectedTarget, selectedUseDefault)
}
selectedTarget={selectedTarget}
onSelectTarget={selectTarget}
selectedUseDefault={selectedUseDefault}
onSelectDefault={selectDefault}
starting={starting}
/>
);
@@ -493,35 +494,19 @@ export function AgentRuntimeUpdateControl({
<UpdateTrigger
agentName={agentName}
displayName={displayName}
runtimeUpdateStatus={runtimeUpdateStatus}
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>{t(UPDATE_AGENT_KEY, { name: displayName })}</DrawerTitle>
<DrawerDescription>{t("agents:reviewUpdateBeforeApplying")}</DrawerDescription>
</DrawerHeader>
{body}
{footer(true)}
</DrawerContent>
</Drawer>
) : (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-h-[80dvh] gap-0 p-0 sm:max-w-2xl"
data-testid={`agent-update-dialog-${agentName}`}
>
<DialogHeader className="px-4 pt-4">
<DialogTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DialogTitle>
<DialogDescription>{t("agents:reviewUpdateBeforeApplying")}</DialogDescription>
</DialogHeader>
{body}
{footer()}
</DialogContent>
</Dialog>
)}
<AgentRuntimeUpdateSurface
agentName={agentName}
displayName={displayName}
isMobile={isMobile}
open={open}
onOpenChange={handleOpenChange}
body={body}
footer={footer}
/>
</>
);
}
@@ -0,0 +1,72 @@
"use client";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
const UPDATE_AGENT_KEY = "agents:updateAgent";
export function AgentRuntimeUpdateSurface({
agentName,
displayName,
isMobile,
open,
onOpenChange,
body,
footer,
}: {
agentName: string;
displayName: string;
isMobile: boolean;
open: boolean;
onOpenChange: (nextOpen: boolean) => void;
body: ReactNode;
footer: (mobile?: boolean) => ReactNode;
}) {
const { t } = useTranslation();
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
className="max-h-[92dvh] overflow-hidden data-[vaul-drawer-direction=bottom]:max-h-[92dvh]"
data-testid={`agent-update-drawer-${agentName}`}
>
<DrawerHeader className="shrink-0 px-4 py-3 text-left">
<DrawerTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DrawerTitle>
<DrawerDescription>{t("agents:reviewUpdateBeforeApplying")}</DrawerDescription>
</DrawerHeader>
{body}
{footer(true)}
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:min-w-[40rem] sm:max-w-2xl"
data-testid={`agent-update-dialog-${agentName}`}
>
<DialogHeader className="px-4 pb-1 pt-3">
<DialogTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DialogTitle>
<DialogDescription>{t("agents:reviewUpdateBeforeApplying")}</DialogDescription>
</DialogHeader>
{body}
{footer()}
</DialogContent>
</Dialog>
);
}
@@ -22,7 +22,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, AgentUpdatePreview, InstallJob } from "@/lib/api";
import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api";
import type { Agent, AgentDiscovery, RuntimeUpdate } from "@/lib/types/http";
type Props = {
@@ -32,10 +32,19 @@ type Props = {
/** Capability status from the host utility probe ("ok", "auth_required", etc.). */
capabilityStatus?: string;
runtimeUpdate?: RuntimeUpdate;
runtimeUpdateStatus?: AgentUpdateStatus;
updateJob?: AgentUpdateJob;
installJob?: InstallJob;
onPreview?: (agentName: string) => Promise<AgentUpdatePreview>;
onUpdate?: (agentName: string, targetVersion: string) => Promise<AgentUpdateJob>;
onPreview?: (
agentName: string,
targetVersion?: string,
useDefault?: boolean,
) => Promise<AgentUpdatePreview>;
onUpdate?: (
agentName: string,
targetVersion: string,
useDefault?: boolean,
) => Promise<AgentUpdateJob>;
/**
* Called when the auth/shell dialog closes so the page can refresh
* discovery + availability. Without this the yellow lock stays put even
@@ -240,6 +249,7 @@ export function InstalledAgentCard({
displayName,
capabilityStatus,
runtimeUpdate,
runtimeUpdateStatus,
updateJob,
installJob,
onPreview,
@@ -304,6 +314,7 @@ export function InstalledAgentCard({
agentName={agent.name}
displayName={displayName}
runtimeUpdate={runtimeUpdate}
runtimeUpdateStatus={runtimeUpdateStatus}
job={updateJob}
installJob={installJob}
onPreview={onPreview}
@@ -0,0 +1,266 @@
"use client";
import { useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { latestRuntimeVersions, resolveRuntimeActiveVersion } from "@/lib/agent-runtime-update";
import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateVersion } from "@/lib/api";
const ACTIVE_UPDATE_STATUSES = new Set<AgentUpdateJob["status"]>([
"queued",
"resolving",
"updating",
"refreshing",
]);
function versionLabel(
t: TFunction,
version: AgentUpdateVersion,
activeVersion?: string,
defaultVersion?: string,
): string {
const markers = [
version.latest ? t("agents:latestRuntimeVersion") : "",
version.version === activeVersion ? t("agents:activeRuntimeVersionMarker") : "",
version.version === defaultVersion ? t("agents:kandevDefaultRuntimeVersionMarker") : "",
]
.filter(Boolean)
.join(", ");
if (!markers) return version.version;
return t("agents:runtimeVersionOption", {
version: version.version,
markers: t("agents:runtimeVersionMarkerGroup", { markers }),
});
}
function RuntimeVersionBrowser({
agentName,
browserId,
versions,
activeVersion,
defaultVersion,
selectedVersion,
selectedUseDefault,
onSelectTarget,
}: {
agentName: string;
browserId: string;
versions: AgentUpdateVersion[];
activeVersion?: string;
defaultVersion?: string;
selectedVersion?: string;
selectedUseDefault: boolean;
onSelectTarget: (targetVersion: string) => void;
}) {
const { t } = useTranslation();
return (
<PopoverContent
id={browserId}
align="start"
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] p-0"
data-testid={browserId}
onWheel={(event) => event.stopPropagation()}
>
<Command className="h-auto min-h-0 w-full rounded-md bg-transparent" shouldFilter>
<CommandInput
autoFocus
placeholder={t("agents:searchRuntimeVersions")}
aria-label={t("agents:searchRuntimeVersions")}
/>
<CommandList className="max-h-[min(18rem,var(--radix-popover-content-available-height))]">
<CommandEmpty>{t("agents:noMatchingRuntimeVersions")}</CommandEmpty>
<CommandGroup>
{versions.map((version) => {
const isSelected = !selectedUseDefault && selectedVersion === version.version;
return (
<CommandItem
key={version.version}
value={version.version}
className="min-h-11 cursor-pointer px-3 py-1.5 sm:min-h-10"
aria-selected={isSelected}
data-checked={isSelected}
data-testid={`agent-update-version-option-${agentName}-${version.version}`}
onSelect={() => onSelectTarget(version.version)}
>
<span className="font-mono">{version.version}</span>
<span className="text-muted-foreground">
{versionLabel(t, version, activeVersion, defaultVersion).slice(
version.version.length,
)}
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
);
}
function RuntimeVersionQuickChoices({
agentName,
latestVersion,
activeVersion,
defaultVersion,
selectedVersion,
selectedUseDefault,
disabled,
onSelectTarget,
onSelectDefault,
}: {
agentName: string;
latestVersion: string;
activeVersion?: string;
defaultVersion?: string;
selectedVersion?: string;
selectedUseDefault: boolean;
disabled: boolean;
onSelectTarget: (targetVersion: string) => void;
onSelectDefault: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1">
<p className="font-medium">{t("agents:runtimeVersionQuickChoices")}</p>
<div className="grid grid-cols-2 gap-1.5">
<Button
type="button"
variant={selectedVersion === latestVersion ? "secondary" : "outline"}
className="h-11 min-h-11 min-w-0 cursor-pointer justify-start px-2 text-left text-xs sm:h-10 sm:min-h-10"
aria-pressed={selectedVersion === latestVersion}
disabled={disabled}
onClick={() => onSelectTarget(latestVersion)}
data-testid={`agent-update-quick-latest-${agentName}`}
>
{versionLabel(t, { version: latestVersion, latest: true }, activeVersion, defaultVersion)}
</Button>
{defaultVersion && (
<Button
type="button"
variant={selectedUseDefault ? "secondary" : "outline"}
className="h-11 min-h-11 min-w-0 cursor-pointer justify-start px-2 text-left text-xs sm:h-10 sm:min-h-10"
aria-pressed={selectedUseDefault}
disabled={disabled}
onClick={onSelectDefault}
data-testid={`agent-update-quick-default-${agentName}`}
>
{t("agents:useKandevDefaultVersion", { version: defaultVersion })}
</Button>
)}
</div>
</div>
);
}
export function RuntimeVersionPicker({
agentName,
preview,
selectedTarget,
selectedUseDefault,
loading,
starting,
job,
onSelectTarget,
onSelectDefault,
}: {
agentName: string;
preview: AgentUpdatePreview;
selectedTarget: string;
selectedUseDefault: boolean;
loading: boolean;
starting: boolean;
job?: AgentUpdateJob;
onSelectTarget: (targetVersion: string) => void;
onSelectDefault: () => void;
}) {
const { t } = useTranslation();
const [browseOpen, setBrowseOpen] = useState(false);
const versions = latestRuntimeVersions(
preview.available_versions ?? [{ version: preview.target_version, latest: false }],
);
const latestVersion =
versions.find((version) => version.latest)?.version ?? preview.target_version;
const activeVersion = resolveRuntimeActiveVersion(preview, job);
const selectedVersion = selectedUseDefault ? undefined : selectedTarget || preview.target_version;
const disabled = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)) || starting || loading;
const browserId = `agent-update-version-browser-${agentName}`;
const selectTarget = (targetVersion: string) => {
setBrowseOpen(false);
onSelectTarget(targetVersion);
};
const selectDefault = () => {
setBrowseOpen(false);
onSelectDefault();
};
return (
<div className="space-y-2" data-testid={`agent-update-version-picker-${agentName}`}>
<RuntimeVersionQuickChoices
agentName={agentName}
latestVersion={latestVersion}
activeVersion={activeVersion}
defaultVersion={preview.default_version}
selectedVersion={selectedVersion}
selectedUseDefault={selectedUseDefault}
disabled={disabled}
onSelectTarget={selectTarget}
onSelectDefault={selectDefault}
/>
<div className="space-y-1">
<p className="font-medium">{t("agents:selectRuntimeVersion")}</p>
<Popover modal={false} open={browseOpen} onOpenChange={setBrowseOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="h-12 w-full cursor-pointer justify-between px-3"
aria-expanded={browseOpen}
aria-controls={browserId}
disabled={disabled}
data-testid={`agent-update-version-${agentName}`}
>
<span>{t("agents:browseRuntimeVersions")}</span>
{loading ? (
<span
className="flex items-center"
data-testid={`agent-update-version-loading-${agentName}`}
role="status"
>
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
<span className="sr-only">{t("agents:checkingLatestRuntimeVersion")}</span>
</span>
) : (
<IconChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
)}
</Button>
</PopoverTrigger>
<RuntimeVersionBrowser
agentName={agentName}
browserId={browserId}
versions={versions}
activeVersion={activeVersion}
defaultVersion={preview.default_version}
selectedVersion={selectedVersion}
selectedUseDefault={selectedUseDefault}
onSelectTarget={selectTarget}
/>
</Popover>
</div>
</div>
);
}
@@ -193,7 +193,9 @@ describe("useAgentUpdateDialogState target selection", () => {
expect(result.current.selectedTarget).toBe("0.60.0");
expect(result.current.preview?.target_version).toBe("0.60.0");
});
});
describe("useAgentUpdateDialogState approval", () => {
it("approves the selected exact target", async () => {
const onUpdate = vi.fn().mockResolvedValue({
job_id: "job-1",
@@ -223,6 +225,42 @@ describe("useAgentUpdateDialogState target selection", () => {
});
expect(onUpdate).toHaveBeenLastCalledWith(AGENT_NAME, "0.61.0");
});
it("previews and approves the Kandev default through structural arguments", async () => {
const defaultPreview: AgentUpdatePreview = {
...FIRST_PREVIEW,
default_version: "0.61.0",
active_version: "0.62.0",
effective_version: "0.62.0",
target_version: "0.61.0",
operation: "use_default",
};
const onPreview = vi.fn().mockResolvedValue(defaultPreview);
const onUpdate = vi.fn().mockResolvedValue({
job_id: "job-2",
agent_name: AGENT_NAME,
status: "queued",
started_at: "2026-01-01T00:00:00.000Z",
} satisfies AgentUpdateJob);
const { result } = renderHook(() =>
useAgentUpdateDialogState({
agentName: AGENT_NAME,
onPreview,
onUpdate,
}),
);
await act(async () => {
await result.current.loadPreview();
});
act(() => result.current.selectDefault());
await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith(AGENT_NAME, undefined, true));
expect(result.current.selectedUseDefault).toBe(true);
await act(async () => {
await result.current.approve();
});
expect(onUpdate).toHaveBeenLastCalledWith(AGENT_NAME, "0.61.0", true);
});
});
describe("useAgentUpdateDialogState failed target selection", () => {
@@ -10,10 +10,129 @@ function errorMessage(error: unknown) {
type UseAgentUpdateDialogStateOptions = {
agentName: string;
job?: AgentUpdateJob;
onPreview: (agentName: string, targetVersion?: string) => Promise<AgentUpdatePreview>;
onUpdate: (agentName: string, targetVersion: string) => Promise<AgentUpdateJob>;
onPreview: (
agentName: string,
targetVersion?: string,
useDefault?: boolean,
) => Promise<AgentUpdatePreview>;
onUpdate: (
agentName: string,
targetVersion: string,
useDefault?: boolean,
) => Promise<AgentUpdateJob>;
};
// i18n-exempt: internal selector token, never rendered as user-facing copy.
export const DEFAULT_RUNTIME_TARGET = "__kandev_default__";
function resetDialogState({
previewRequestID,
setPreview,
setPreviewError,
setApproveError,
setLoading,
setStarting,
setSelectedTarget,
setSelectedUseDefault,
setActiveJobID,
}: {
previewRequestID: { current: number };
setPreview: (value: AgentUpdatePreview | null) => void;
setPreviewError: (value: string | null) => void;
setApproveError: (value: string | null) => void;
setLoading: (value: boolean) => void;
setStarting: (value: boolean) => void;
setSelectedTarget: (value: string) => void;
setSelectedUseDefault: (value: boolean) => void;
setActiveJobID: (value: string | null) => void;
}) {
previewRequestID.current += 1;
setPreview(null);
setPreviewError(null);
setApproveError(null);
setLoading(false);
setStarting(false);
setSelectedTarget("");
setSelectedUseDefault(false);
setActiveJobID(null);
}
async function approveRuntimeUpdate({
requestID,
previewRequestID,
agentName,
preview,
selectedTarget,
selectedUseDefault,
onUpdate,
setStarting,
setApproveError,
setActiveJobID,
}: {
requestID: number;
previewRequestID: { current: number };
agentName: string;
preview: AgentUpdatePreview | null;
selectedTarget: string;
selectedUseDefault: boolean;
onUpdate: UseAgentUpdateDialogStateOptions["onUpdate"];
setStarting: (value: boolean) => void;
setApproveError: (value: string | null) => void;
setActiveJobID: (value: string | null) => void;
}) {
const targetVersion = selectedUseDefault
? preview?.default_version || preview?.target_version || ""
: selectedTarget || preview?.target_version || "";
if (!targetVersion) return;
setStarting(true);
setApproveError(null);
try {
const nextJob = selectedUseDefault
? await onUpdate(agentName, targetVersion, true)
: await onUpdate(agentName, targetVersion);
if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id);
} catch (error) {
if (requestID === previewRequestID.current) setApproveError(errorMessage(error));
} finally {
if (requestID === previewRequestID.current) setStarting(false);
}
}
function handleDialogOpenChange(
nextOpen: boolean,
setOpen: (value: boolean) => void,
reset: () => void,
) {
setOpen(nextOpen);
if (!nextOpen) reset();
}
type PreviewLoader = (targetVersion?: string, useDefault?: boolean) => Promise<void>;
function useRuntimeTargetSelectors(
loadPreview: PreviewLoader,
setActiveJobID: (value: string | null) => void,
setSelectedTarget: (value: string) => void,
setSelectedUseDefault: (value: boolean) => void,
) {
const selectTarget = useCallback(
(targetVersion: string) => {
setActiveJobID(null);
setSelectedTarget(targetVersion);
setSelectedUseDefault(false);
void loadPreview(targetVersion);
},
[loadPreview],
);
const selectDefault = useCallback(() => {
setActiveJobID(null);
setSelectedTarget(DEFAULT_RUNTIME_TARGET);
setSelectedUseDefault(true);
void loadPreview(undefined, true);
}, [loadPreview]);
return { selectTarget, selectDefault };
}
export function useAgentUpdateDialogState({
agentName,
job,
@@ -27,32 +146,39 @@ export function useAgentUpdateDialogState({
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [selectedTarget, setSelectedTarget] = useState("");
const [selectedUseDefault, setSelectedUseDefault] = 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);
setSelectedTarget("");
setActiveJobID(null);
resetDialogState({
previewRequestID,
setPreview,
setPreviewError,
setApproveError,
setLoading,
setStarting,
setSelectedTarget,
setSelectedUseDefault,
setActiveJobID,
});
}, []);
const loadPreview = useCallback(
async (targetVersion?: string) => {
async (targetVersion?: string, useDefault = false) => {
const requestID = ++previewRequestID.current;
setLoading(true);
setPreviewError(null);
setApproveError(null);
try {
const nextPreview = await onPreview(agentName, targetVersion);
const nextPreview = useDefault
? await onPreview(agentName, undefined, true)
: await onPreview(agentName, targetVersion);
if (requestID === previewRequestID.current) {
setPreview(nextPreview);
setSelectedTarget(nextPreview.target_version);
setSelectedTarget(useDefault ? DEFAULT_RUNTIME_TARGET : nextPreview.target_version);
setSelectedUseDefault(useDefault);
}
} catch (error) {
if (requestID === previewRequestID.current) setPreviewError(errorMessage(error));
@@ -63,39 +189,35 @@ export function useAgentUpdateDialogState({
[agentName, onPreview],
);
const selectTarget = useCallback(
(targetVersion: string) => {
setActiveJobID(null);
setSelectedTarget(targetVersion);
void loadPreview(targetVersion);
},
[loadPreview],
const { selectTarget, selectDefault } = useRuntimeTargetSelectors(
loadPreview,
setActiveJobID,
setSelectedTarget,
setSelectedUseDefault,
);
useEffect(() => {
if (open) void loadPreview();
}, [loadPreview, open]);
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) reset();
};
const handleOpenChange = (nextOpen: boolean) => handleDialogOpenChange(nextOpen, setOpen, reset);
const approve = async () => {
const requestID = previewRequestID.current;
const targetVersion = selectedTarget || preview?.target_version || "";
if (!targetVersion) return;
setStarting(true);
setApproveError(null);
try {
const nextJob = await onUpdate(agentName, targetVersion);
if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id);
} catch (error) {
if (requestID === previewRequestID.current) setApproveError(errorMessage(error));
} finally {
if (requestID === previewRequestID.current) setStarting(false);
}
};
const approve = useCallback(
() =>
approveRuntimeUpdate({
requestID: previewRequestID.current,
previewRequestID,
agentName,
preview,
selectedTarget,
selectedUseDefault,
onUpdate,
setStarting,
setApproveError,
setActiveJobID,
}),
[agentName, onUpdate, preview, selectedTarget, selectedUseDefault],
);
return {
activeJob,
@@ -108,7 +230,9 @@ export function useAgentUpdateDialogState({
preview,
previewError,
selectTarget,
selectDefault,
selectedTarget,
selectedUseDefault,
starting,
};
}
@@ -1,11 +1,11 @@
import { expect, type Page, type WebSocketRoute } from "@playwright/test";
import { expect, type Page, type Route, type WebSocketRoute } from "@playwright/test";
import { injectLatency } from "../../helpers/causal-waits";
const AGENT_NAME = "claude-acp";
const NOW = "2026-07-26T12:00:00.000Z";
type UpdateStatus = "queued" | "resolving" | "updating" | "refreshing" | "succeeded" | "failed";
type UpdateOperation = "update" | "rollback" | "repair" | "up_to_date";
type UpdateOperation = "update" | "rollback" | "repair" | "up_to_date" | "use_default";
type UpdateJob = {
job_id: string;
@@ -14,6 +14,8 @@ type UpdateJob = {
operation?: UpdateOperation;
active_version?: string;
current_version?: string;
default_version?: string;
effective_version?: string;
target_version?: string;
output?: string;
error?: string;
@@ -26,7 +28,9 @@ type UpdatePreview = {
agent_name: string;
package: string;
current_version: string;
default_version?: string;
active_version?: string;
effective_version?: string;
target_version: string;
operation?: UpdateOperation;
available_versions?: Array<{ version: string; latest: boolean }>;
@@ -34,7 +38,22 @@ type UpdatePreview = {
command_string: string;
};
function catalogue(models: Array<{ id: string; name: string }>, displayName = "Claude") {
export type RuntimeUpdateStatus = {
agent_name: string;
package: string;
default_version: string;
active_version?: string;
effective_version: string;
latest_version?: string;
checked_at?: string;
check_state: "update_available" | "up_to_date" | "unknown";
};
function catalogue(
models: Array<{ id: string; name: string }>,
displayName = "Claude",
runtimeVersion = "0.62.0",
) {
return {
agents: [
{
@@ -65,8 +84,10 @@ function catalogue(models: Array<{ id: string; name: string }>, displayName = "C
runtime_update: {
supported: true,
package: "@agentclientprotocol/claude-agent-acp",
current_version: "0.62.0",
active_version: "0.62.0",
current_version: runtimeVersion,
default_version: "0.64.0",
active_version: runtimeVersion,
effective_version: runtimeVersion,
},
updated_at: NOW,
},
@@ -123,12 +144,72 @@ function operationForTarget(currentVersion: string, targetVersion: string): Upda
return comparison > 0 ? "update" : "rollback";
}
function previewForRequest(
previewResponse: UpdatePreview,
requestedTarget: string | null,
useDefault: boolean,
): UpdatePreview {
if (useDefault) {
const defaultVersion = previewResponse.default_version ?? "0.64.0";
return {
...previewResponse,
target_version: defaultVersion,
operation: "use_default",
};
}
if (!requestedTarget) return previewResponse;
return {
...previewResponse,
target_version: requestedTarget,
operation: operationForTarget(previewResponse.current_version, requestedTarget),
};
}
async function handlePreviewRoute({
route,
url,
previewResponse,
previewFailures,
previewDelayMs,
}: {
route: Route;
url: URL;
previewResponse: UpdatePreview;
previewFailures: string[];
previewDelayMs: number;
}): Promise<void> {
const requestedTarget = url.searchParams.get("target_version");
const useDefault = url.searchParams.get("use_default") === "true";
if (requestedTarget && previewFailures.includes(requestedTarget)) {
previewFailures.splice(previewFailures.indexOf(requestedTarget), 1);
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "preview temporarily unavailable" }),
});
return;
}
if (requestedTarget && previewDelayMs > 0) {
await injectLatency(
previewDelayMs,
"simulates a slow agent-update preview so the in-flight preview state stays observable",
);
}
const response = previewForRequest(previewResponse, requestedTarget, useDefault);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
});
}
export type RuntimeUpdateFixtureOptions = {
retainedJobs?: UpdateJob[];
postResponse?: UpdateJob;
previewResponse?: UpdatePreview;
previewFailures?: string[];
previewDelayMs?: number;
statusResponse?: RuntimeUpdateStatus[];
};
export async function installRuntimeUpdateFixture(
@@ -136,6 +217,7 @@ export async function installRuntimeUpdateFixture(
options: RuntimeUpdateFixtureOptions = {},
) {
let currentModels = [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }];
let persistedRuntimeVersion = "0.62.0";
let retainedJobs = options.retainedJobs ?? [];
let postResponse: UpdateJob =
options.postResponse ??
@@ -148,10 +230,24 @@ export async function installRuntimeUpdateFixture(
} satisfies UpdateJob);
let postCount = 0;
let previewCount = 0;
let previewDefaultCount = 0;
let statusRequestCount = 0;
const previewTargets: string[] = [];
const postTargets: string[] = [];
const previewFailures = [...(options.previewFailures ?? [])];
const previewDelayMs = options.previewDelayMs ?? 0;
let statusResponse = options.statusResponse ?? [
{
agent_name: AGENT_NAME,
package: "@agentclientprotocol/claude-agent-acp",
default_version: "0.64.0",
active_version: "0.62.0",
effective_version: "0.62.0",
latest_version: "0.64.0",
checked_at: NOW,
check_state: "update_available",
},
];
let previewResponse: UpdatePreview =
options.previewResponse ??
({
@@ -160,7 +256,9 @@ export async function installRuntimeUpdateFixture(
current_version: "0.62.0",
target_version: "0.63.0",
operation: "update",
default_version: "0.64.0",
active_version: "0.62.0",
effective_version: "0.62.0",
available_versions: [
{ version: "0.64.0", latest: true },
{ version: "0.63.0", latest: false },
@@ -195,7 +293,7 @@ export async function installRuntimeUpdateFixture(
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(catalogue(currentModels)),
body: JSON.stringify(catalogue(currentModels, "Claude", persistedRuntimeVersion)),
}),
);
await page.route("**/api/v1/agents", (route) =>
@@ -213,33 +311,23 @@ export async function installRuntimeUpdateFixture(
url.pathname.endsWith(`/agent-update/${AGENT_NAME}/preview`)
) {
previewCount += 1;
previewTargets.push(url.searchParams.get("target_version") ?? "");
const requestedTarget = url.searchParams.get("target_version");
if (requestedTarget && previewFailures.includes(requestedTarget)) {
previewFailures.splice(previewFailures.indexOf(requestedTarget), 1);
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "preview temporarily unavailable" }),
});
}
if (requestedTarget && previewDelayMs > 0) {
await injectLatency(
previewDelayMs,
"simulates a slow agent-update preview so the in-flight preview state stays observable",
);
}
const response = requestedTarget
? {
...previewResponse,
target_version: requestedTarget,
operation: operationForTarget(previewResponse.current_version, requestedTarget),
}
: previewResponse;
previewTargets.push(requestedTarget ?? "");
if (url.searchParams.get("use_default") === "true") previewDefaultCount += 1;
return handlePreviewRoute({
route,
url,
previewResponse,
previewFailures,
previewDelayMs,
});
}
if (request.method() === "GET" && url.pathname.endsWith("/agent-update/status")) {
statusRequestCount += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(response),
body: JSON.stringify({ statuses: statusResponse }),
});
}
if (request.method() === "GET" && url.pathname.endsWith("/agent-update/jobs")) {
@@ -251,8 +339,11 @@ export async function installRuntimeUpdateFixture(
}
if (request.method() === "POST" && url.pathname.endsWith(`/agent-update/${AGENT_NAME}`)) {
postCount += 1;
const body = request.postDataJSON() as { target_version?: string } | null;
postTargets.push(body?.target_version ?? "");
const body = request.postDataJSON() as {
target_version?: string;
use_default?: boolean;
} | null;
postTargets.push(body?.use_default ? "__kandev_default__" : (body?.target_version ?? ""));
return route.fulfill({
status: 202,
contentType: "application/json",
@@ -302,8 +393,34 @@ export async function installRuntimeUpdateFixture(
setPreviewResponse(preview: UpdatePreview) {
previewResponse = preview;
},
setPersistedRuntimeVersion(version: string) {
persistedRuntimeVersion = version;
previewResponse = {
...previewResponse,
current_version: version,
active_version: version,
effective_version: version,
target_version: version,
operation: "up_to_date",
};
statusResponse = statusResponse.map((status) =>
status.agent_name === AGENT_NAME
? {
...status,
active_version: version,
effective_version: version,
check_state: "up_to_date" as const,
}
: status,
);
},
setStatusResponse(statuses: RuntimeUpdateStatus[]) {
statusResponse = statuses;
},
postCount: () => postCount,
previewCount: () => previewCount,
previewDefaultCount: () => previewDefaultCount,
statusRequestCount: () => statusRequestCount,
previewTargets: () => [...previewTargets],
postTargets: () => [...postTargets],
async emit(action: string, payload: unknown) {
@@ -327,7 +444,10 @@ export async function installRuntimeUpdateFixture(
},
async emitCatalogue(models: Array<{ id: string; name: string }>) {
currentModels = models;
await this.emit("agent.available.updated", catalogue(models, "Claude refreshed"));
await this.emit(
"agent.available.updated",
catalogue(models, "Claude refreshed", persistedRuntimeVersion),
);
},
};
}
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { settledBoundingBox } from "../../helpers/settled-box";
import { installRuntimeUpdateFixture, updateJob } from "./agent-runtime-update-helpers";
test.describe("managed agent runtime updates", () => {
@@ -16,6 +17,10 @@ test.describe("managed agent runtime updates", () => {
const navigationsAfterLoad = documentNavigations;
const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`);
await expect(trigger).toBeVisible();
await expect(
testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`),
).toBeVisible();
await expect(trigger).toHaveAttribute("aria-label", /0\.62\.0.*0\.64\.0/);
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([
@@ -40,17 +45,21 @@ test.describe("managed agent runtime updates", () => {
.getByTestId(`agent-update-version-${runtime.agentName}`)
.boundingBox();
expect(selectorBox).not.toBeNull();
expect(selectorBox!.height).toBeLessThanOrEqual(40);
expect(selectorBox!.height).toBeGreaterThanOrEqual(44);
const body = dialog.getByTestId(`agent-update-dialog-body-${runtime.agentName}`);
await expect
.poll(() => body.evaluate((element) => element.scrollHeight <= element.clientHeight))
.toBe(true);
const quickLatestBox = await dialog
.getByTestId(`agent-update-quick-latest-${runtime.agentName}`)
.boundingBox();
expect(quickLatestBox).not.toBeNull();
expect(quickLatestBox!.height).toBeLessThan(44);
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", {
@@ -77,12 +86,28 @@ test.describe("managed agent runtime updates", () => {
finished_at: "2026-07-26T12:01:00.000Z",
}),
);
runtime.setStatusResponse([
{
agent_name: runtime.agentName,
package: "@agentclientprotocol/claude-agent-acp",
default_version: "0.64.0",
active_version: "0.63.0",
effective_version: "0.63.0",
latest_version: "0.63.0",
checked_at: "2026-07-26T12:01:00.000Z",
check_state: "up_to_date",
},
]);
runtime.setPersistedRuntimeVersion("0.63.0");
await runtime.emitCatalogue([
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-5", name: "Claude Opus 5" },
]);
await expect(testPage.getByText("Claude refreshed", { exact: true })).toBeVisible();
await expect(
testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`),
).toHaveCount(0);
await expect(dialog.getByTestId(`agent-update-result-${runtime.agentName}`)).toContainText(
"Runtime updated successfully",
);
@@ -91,6 +116,10 @@ test.describe("managed agent runtime updates", () => {
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);
await trigger.click();
await expect(testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`)).toContainText(
"Active version: 0.63.0",
);
});
test("requires a current runtime version before approval", async ({ testPage }) => {
@@ -114,6 +143,52 @@ test.describe("managed agent runtime updates", () => {
expect(runtime.postCount()).toBe(0);
});
test("keeps the control usable when the latest-version check is unknown", async ({
testPage,
}) => {
const runtime = await installRuntimeUpdateFixture(testPage, {
statusResponse: [
{
agent_name: "claude-acp",
package: "@agentclientprotocol/claude-agent-acp",
default_version: "0.64.0",
active_version: "0.62.0",
effective_version: "0.62.0",
check_state: "unknown",
},
],
});
await testPage.goto("/settings/agents");
const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`);
await expect(trigger).toBeVisible();
await expect(
testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`),
).toHaveCount(0);
await expect(trigger).toHaveAttribute("aria-label", /latest version.*unavailable/i);
await trigger.click();
await expect(testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`)).toBeVisible();
expect(runtime.postCount()).toBe(0);
});
test("uses a structural request to return to the Kandev default", async ({ testPage }) => {
const runtime = await installRuntimeUpdateFixture(testPage);
await testPage.goto("/settings/agents");
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click();
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
await dialog.getByTestId(`agent-update-quick-default-${runtime.agentName}`).click();
await expect(dialog).toContainText("0.62.0 → 0.64.0");
await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText(
"Use Kandev default",
);
expect(runtime.previewTargets()).toEqual(["", ""]);
expect(runtime.previewDefaultCount()).toBe(1);
await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click();
expect(runtime.postTargets()).toEqual(["__kandev_default__"]);
});
test("shows an up-to-date preview and disables approval when versions match", async ({
testPage,
prCapture,
@@ -192,7 +267,11 @@ test.describe("managed agent runtime updates", () => {
await testPage.goto("/settings/agents");
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click();
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0");
await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).click();
await testPage
.getByTestId(`agent-update-version-browser-${runtime.agentName}`)
.getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`)
.click();
await expect(dialog).toContainText("0.62.0 → 0.61.0");
await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText(
@@ -211,7 +290,11 @@ test.describe("managed agent runtime updates", () => {
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
const body = dialog.getByTestId(`agent-update-dialog-body-${runtime.agentName}`);
await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0");
await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).click();
await testPage
.getByTestId(`agent-update-version-browser-${runtime.agentName}`)
.getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`)
.click();
await expect(
dialog.getByTestId(`agent-update-version-loading-${runtime.agentName}`),
@@ -224,7 +307,7 @@ test.describe("managed agent runtime updates", () => {
).toBeHidden();
});
test("limits the version selector to the ten newest options", async ({ testPage }) => {
test("shows the complete backend version projection", async ({ testPage }) => {
const runtime = await installRuntimeUpdateFixture(testPage);
runtime.setPreviewResponse({
agent_name: "claude-acp",
@@ -243,14 +326,39 @@ test.describe("managed agent runtime updates", () => {
await testPage.goto("/settings/agents");
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click();
const options = testPage
.getByTestId(`agent-update-version-${runtime.agentName}`)
.locator("option");
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
const selector = testPage.getByTestId(`agent-update-version-${runtime.agentName}`);
await expect(
testPage.getByTestId(`agent-update-version-option-${runtime.agentName}-0.74.0`),
).toHaveCount(0);
const dialogBefore = await settledBoundingBox(dialog);
await selector.click();
const browser = testPage.getByTestId(`agent-update-version-browser-${runtime.agentName}`);
await expect(browser).toBeVisible();
await expect(browser).toHaveAttribute("data-slot", "popover-content");
const browserBox = await settledBoundingBox(browser);
const viewport = testPage.viewportSize();
expect(viewport).not.toBeNull();
expect(browserBox.x).toBeGreaterThanOrEqual(0);
expect(browserBox.y).toBeGreaterThanOrEqual(0);
expect(browserBox.x + browserBox.width).toBeLessThanOrEqual(viewport!.width);
expect(browserBox.y + browserBox.height).toBeLessThanOrEqual(viewport!.height);
const dialogAfter = await settledBoundingBox(dialog);
expect(Math.abs(dialogAfter.height - dialogBefore.height)).toBeLessThan(2);
expect(
await browser.evaluate(
(element) =>
element.closest('[data-slot="dialog-content"], [data-slot="drawer-content"]') === null,
),
).toBe(true);
const options = browser.getByRole("option");
await expect(options).toHaveCount(10);
await expect(options).toHaveCount(12);
expect(
await options.evaluateAll((elements) =>
elements.map((element) => (element as HTMLOptionElement).value),
elements.map(
(element) => element.getAttribute("data-value") ?? element.textContent?.trim(),
),
),
).toEqual([
"0.74.0",
@@ -263,6 +371,8 @@ test.describe("managed agent runtime updates", () => {
"0.67.0",
"0.66.0",
"0.65.0",
"0.64.0",
"0.63.0",
]);
});
@@ -274,7 +384,11 @@ test.describe("managed agent runtime updates", () => {
await testPage.goto("/settings/agents");
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click();
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0");
await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).click();
await testPage
.getByTestId(`agent-update-version-browser-${runtime.agentName}`)
.getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`)
.click();
await expect(dialog.getByRole("alert")).toContainText("preview temporarily unavailable");
await dialog.getByRole("button", { name: "Retry version check" }).click();
@@ -353,15 +467,39 @@ test.describe("managed agent runtime updates", () => {
);
await expect(dialog).toContainText("Active version: 0.63.0");
const successOptionText = await dialog
.getByTestId(`agent-update-version-${runtime.agentName}`)
.locator("option")
await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).click();
const successOptionText = await testPage
.getByTestId(`agent-update-version-browser-${runtime.agentName}`)
.getByRole("option")
.allTextContents();
expect(
successOptionText.some((text) => text.includes("0.63.0") && text.includes("active")),
).toBe(true);
});
test("clears the active version after a successful default reset", async ({ testPage }) => {
const runtime = await installRuntimeUpdateFixture(testPage, {
postResponse: updateJob({
status: "succeeded",
operation: "use_default",
current_version: "0.64.0",
default_version: "0.64.0",
effective_version: "0.64.0",
target_version: "0.64.0",
finished_at: "2026-07-26T12:01:00.000Z",
}),
});
await testPage.goto("/settings/agents");
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click();
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
await dialog.getByTestId(`agent-update-quick-default-${runtime.agentName}`).click();
await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click();
await expect(dialog).toContainText("Effective version: 0.64.0");
await expect(dialog).not.toContainText("Active version: 0.62.0");
});
test("keeps the previous active version after a failed activation", async ({ testPage }) => {
const runtime = await installRuntimeUpdateFixture(testPage);
@@ -379,9 +517,10 @@ test.describe("managed agent runtime updates", () => {
);
await expect(dialog).toContainText("Active version: 0.62.0");
const failedOptionText = await dialog
.getByTestId(`agent-update-version-${runtime.agentName}`)
.locator("option")
await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).click();
const failedOptionText = await testPage
.getByTestId(`agent-update-version-browser-${runtime.agentName}`)
.getByRole("option")
.allTextContents();
expect(
failedOptionText.some((text) => text.includes("0.62.0") && text.includes("active")),
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { settledBoundingBox } from "../../helpers/settled-box";
import { installRuntimeUpdateFixture, updateJob } from "./agent-runtime-update-helpers";
test.describe("managed agent runtime updates on mobile", () => {
@@ -10,6 +11,9 @@ test.describe("managed agent runtime updates on mobile", () => {
await testPage.goto("/settings/agents");
const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`);
await expect(
testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`),
).toBeVisible();
await trigger.scrollIntoViewIfNeeded();
const triggerBox = await trigger.boundingBox();
expect(triggerBox).not.toBeNull();
@@ -20,6 +24,10 @@ test.describe("managed agent runtime updates on mobile", () => {
const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`);
await expect(drawer).toBeVisible();
await expect(drawer).toContainText("0.62.0 → 0.63.0");
const body = drawer.getByTestId(`agent-update-dialog-body-${runtime.agentName}`);
await expect
.poll(() => body.evaluate((element) => element.scrollHeight <= element.clientHeight))
.toBe(true);
expect(runtime.postCount()).toBe(0);
await prCapture.screenshot("mobile-update-preview", {
caption: "Mobile update preview before approval",
@@ -40,7 +48,6 @@ test.describe("managed agent runtime updates on mobile", () => {
await expect(drawer.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText(
"Updating runtime",
);
const body = drawer.getByTestId(`agent-update-dialog-body-${runtime.agentName}`);
await body.scrollIntoViewIfNeeded();
await expect(
body.evaluate((element) => element.scrollHeight > element.clientHeight),
@@ -95,7 +102,31 @@ test.describe("managed agent runtime updates on mobile", () => {
const selectorBox = await selector.boundingBox();
expect(selectorBox).not.toBeNull();
expect(selectorBox!.height).toBeGreaterThanOrEqual(44);
await selector.selectOption("0.61.0");
const drawerBefore = await settledBoundingBox(drawer);
await selector.tap();
const browser = testPage.getByTestId(`agent-update-version-browser-${runtime.agentName}`);
await expect(browser).toBeVisible();
await expect(browser).toHaveAttribute("data-slot", "popover-content");
const browserBox = await settledBoundingBox(browser);
const viewport = testPage.viewportSize();
expect(viewport).not.toBeNull();
expect(browserBox.x).toBeGreaterThanOrEqual(0);
expect(browserBox.y).toBeGreaterThanOrEqual(0);
expect(browserBox.x + browserBox.width).toBeLessThanOrEqual(viewport!.width);
expect(browserBox.y + browserBox.height).toBeLessThanOrEqual(viewport!.height);
const drawerAfter = await settledBoundingBox(drawer);
expect(Math.abs(drawerAfter.height - drawerBefore.height)).toBeLessThan(2);
expect(
await browser.evaluate(
(element) =>
element.closest('[data-slot="dialog-content"], [data-slot="drawer-content"]') === null,
),
).toBe(true);
const option = browser.getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`);
const optionBox = await option.boundingBox();
expect(optionBox).not.toBeNull();
expect(optionBox!.height).toBeGreaterThanOrEqual(44);
await option.tap();
await expect(drawer).toContainText("0.62.0 → 0.61.0");
await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText(
@@ -106,6 +137,46 @@ test.describe("managed agent runtime updates on mobile", () => {
expect(runtime.previewTargets()).toEqual(["", "0.61.0"]);
});
test("searches the full version history in the drawer", async ({ testPage }) => {
const runtime = await installRuntimeUpdateFixture(testPage, {
previewResponse: {
agent_name: "claude-acp",
package: "@agentclientprotocol/claude-agent-acp",
current_version: "0.62.0",
default_version: "0.64.0",
active_version: "0.62.0",
effective_version: "0.62.0",
target_version: "0.63.0",
available_versions: Array.from({ length: 12 }, (_, index) => ({
version: `0.${74 - index}.0`,
latest: index === 0,
})),
command: ["npm", "exec"],
command_string: "npm exec",
},
});
await testPage.goto("/settings/agents");
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).tap();
const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`);
await drawer.getByTestId(`agent-update-version-${runtime.agentName}`).tap();
const browser = testPage.getByTestId(`agent-update-version-browser-${runtime.agentName}`);
await expect(browser).toBeVisible();
const search = browser.getByPlaceholder("Search versions");
await expect(search).toBeVisible();
await search.fill("0.70");
const option = browser.getByTestId(`agent-update-version-option-${runtime.agentName}-0.70.0`);
await expect(option).toBeVisible();
// The popover entrance animation scales the option row briefly. Poll until
// the touch target reaches its settled 44px height before tapping it.
await expect
.poll(async () => Math.round((await option.boundingBox())?.height ?? 0))
.toBeGreaterThanOrEqual(44);
await option.tap();
await expect(drawer).toContainText("0.62.0 → 0.70.0");
expect(runtime.previewTargets()).toEqual(["", "0.70.0"]);
});
test("keeps retry available after an update job fails", async ({ testPage }) => {
const runtime = await installRuntimeUpdateFixture(testPage);
@@ -0,0 +1,99 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentUpdateJob } from "@/lib/api";
const listAgentUpdateStatusesMock = vi.fn();
vi.mock("@/lib/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api")>()),
listAgentUpdateStatuses: (...args: unknown[]) => listAgentUpdateStatusesMock(...args),
}));
import { useAgentRuntimeUpdateStatuses } from "./use-agent-runtime-update-statuses";
function job(overrides: Partial<AgentUpdateJob> = {}): AgentUpdateJob {
return {
job_id: "job-1",
agent_name: "claude-acp",
status: "succeeded",
started_at: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
afterEach(() => vi.clearAllMocks());
describe("useAgentRuntimeUpdateStatuses", () => {
it("loads structural statuses into a page-local agent map", async () => {
listAgentUpdateStatusesMock.mockResolvedValueOnce({
statuses: [
{
agent_name: "claude-acp",
package: "@agentclientprotocol/claude-agent-acp",
default_version: "0.70.0",
effective_version: "0.70.0",
latest_version: "0.71.0",
check_state: "update_available",
},
{
agent_name: "codex-acp",
package: "@agentclientprotocol/codex-acp",
default_version: "1.6.0",
effective_version: "1.6.0",
check_state: "unknown",
},
],
});
const { result } = renderHook(() => useAgentRuntimeUpdateStatuses({}));
await waitFor(() => expect(result.current.statusByAgent["claude-acp"]).toBeDefined());
expect(result.current.statusByAgent["claude-acp"]?.check_state).toBe("update_available");
expect(result.current.statusByAgent["codex-acp"]?.check_state).toBe("unknown");
expect(listAgentUpdateStatusesMock).toHaveBeenCalledWith({ cache: "no-store" });
});
it("refreshes once after a successful update job", async () => {
listAgentUpdateStatusesMock
.mockResolvedValueOnce({ statuses: [] })
.mockResolvedValueOnce({ statuses: [] });
const { result, rerender } = renderHook(({ jobs }) => useAgentRuntimeUpdateStatuses(jobs), {
initialProps: { jobs: {} as Record<string, AgentUpdateJob> },
});
await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(1));
rerender({ jobs: { "claude-acp": job() } });
await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(2));
expect(result.current.statusByAgent).toEqual({});
});
it("does not erase a last good map when a refresh fails", async () => {
listAgentUpdateStatusesMock
.mockResolvedValueOnce({
statuses: [
{
agent_name: "claude-acp",
package: "@agentclientprotocol/claude-agent-acp",
default_version: "0.70.0",
effective_version: "0.70.0",
latest_version: "0.71.0",
check_state: "update_available",
},
],
})
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ statuses: [] });
const { result, rerender } = renderHook(({ jobs }) => useAgentRuntimeUpdateStatuses(jobs), {
initialProps: { jobs: {} as Record<string, AgentUpdateJob> },
});
await waitFor(() => expect(result.current.statusByAgent["claude-acp"]).toBeDefined());
rerender({ jobs: { "claude-acp": job({ job_id: "job-2" }) } });
await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(2));
expect(result.current.statusByAgent["claude-acp"]?.check_state).toBe("update_available");
rerender({ jobs: { "claude-acp": job({ job_id: "job-2" }) } });
await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(3));
});
});
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { listAgentUpdateStatuses, type AgentUpdateJob, type AgentUpdateStatus } from "@/lib/api";
type StatusByAgent = Record<string, AgentUpdateStatus>;
function indexStatuses(statuses: AgentUpdateStatus[]): StatusByAgent {
return Object.fromEntries(statuses.map((status) => [status.agent_name, status]));
}
export function useAgentRuntimeUpdateStatuses(updateJobs: Record<string, AgentUpdateJob>) {
const [statusByAgent, setStatusByAgent] = useState<StatusByAgent>({});
const observedSuccessfulJobs = useRef(new Set<string>());
const pendingSuccessfulJobs = useRef(new Set<string>());
const refresh = useCallback(async (): Promise<boolean> => {
try {
const response = await listAgentUpdateStatuses({ cache: "no-store" });
setStatusByAgent(indexStatuses(response.statuses));
return true;
} catch {
// A status hint is advisory. Keep the last good map so a transient
// registry failure does not remove an already visible hint or disable
// the authoritative update control.
return false;
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
for (const job of Object.values(updateJobs)) {
if (job.status !== "succeeded" || observedSuccessfulJobs.current.has(job.job_id)) {
continue;
}
if (pendingSuccessfulJobs.current.has(job.job_id)) {
continue;
}
pendingSuccessfulJobs.current.add(job.job_id);
void refresh().then((succeeded) => {
pendingSuccessfulJobs.current.delete(job.job_id);
if (succeeded) {
observedSuccessfulJobs.current.add(job.job_id);
}
});
}
}, [refresh, updateJobs]);
return { refresh, statusByAgent };
}
@@ -7,7 +7,9 @@ import {
getAgentUpdateJob,
getInstallJob,
previewAgentUpdate,
previewAgentUpdateUseDefault,
updateAgent,
updateAgentUseDefault,
type AgentUpdateJob,
} from "@/lib/api";
import { ApiError } from "@/lib/api/client";
@@ -52,9 +54,11 @@ export function useAgentRuntimeUpdates() {
);
const startUpdate = useCallback(
async (agentName: string, targetVersion: string) => {
async (agentName: string, targetVersion: string, useDefault = false) => {
try {
const job = await updateAgent(agentName, targetVersion);
const job = useDefault
? await updateAgentUseDefault(agentName)
: await updateAgent(agentName, targetVersion);
store.getState().upsertAgentUpdateJob(job);
return job;
} catch (error) {
@@ -69,8 +73,10 @@ export function useAgentRuntimeUpdates() {
);
const previewUpdate = useCallback(
(agentName: string, targetVersion?: string) =>
previewAgentUpdate(agentName, targetVersion, { cache: "no-store" }),
(agentName: string, targetVersion?: string, useDefault = false) =>
useDefault
? previewAgentUpdateUseDefault(agentName, { cache: "no-store" })
: previewAgentUpdate(agentName, targetVersion, { cache: "no-store" }),
[],
);
+27 -2
View File
@@ -5,19 +5,20 @@ import {
canApproveAgentRuntimeUpdate,
latestRuntimeVersions,
resolveRuntimeActiveVersion,
resolveRuntimeEffectiveVersion,
resolveRuntimeOperation,
resolveRuntimeVersionPair,
runtimeOperationLabelKey,
} from "./agent-runtime-update";
describe("latestRuntimeVersions", () => {
it("limits the selector to the ten newest options", () => {
it("preserves the complete backend projection", () => {
const versions = Array.from({ length: 12 }, (_, index) => ({
version: `1.0.${12 - index}`,
latest: index === 0,
}));
expect(latestRuntimeVersions(versions)).toEqual(versions.slice(0, 10));
expect(latestRuntimeVersions(versions)).toEqual(versions);
});
});
@@ -82,6 +83,30 @@ describe("resolveRuntimeActiveVersion", () => {
),
).toBe("0.62.0");
});
it("clears the preview selection after a successful default reset", () => {
expect(
resolveRuntimeActiveVersion(
preview({ active_version: "0.62.0" }),
job({
status: "succeeded",
operation: "use_default",
active_version: undefined,
}),
),
).toBeUndefined();
});
});
describe("resolveRuntimeEffectiveVersion", () => {
it("prefers the terminal job effective version", () => {
expect(
resolveRuntimeEffectiveVersion(
preview({ effective_version: "0.62.0" }),
job({ status: "succeeded", effective_version: "0.64.0" }),
),
).toBe("0.64.0");
});
});
describe("canApproveAgentRuntimeUpdate", () => {
+18 -3
View File
@@ -6,10 +6,8 @@ import type {
} from "@/lib/api";
import { t } from "@/lib/i18n";
const MAX_VISIBLE_RUNTIME_VERSIONS = 10;
export function latestRuntimeVersions(versions: AgentUpdateVersion[]): AgentUpdateVersion[] {
return versions.slice(0, MAX_VISIBLE_RUNTIME_VERSIONS);
return versions;
}
export type RuntimeVersionPair = {
@@ -43,9 +41,24 @@ export function resolveRuntimeActiveVersion(
preview: AgentUpdatePreview,
job?: AgentUpdateJob,
): string | undefined {
if (job?.status === "succeeded" && job.operation === "use_default" && !job.active_version) {
return undefined;
}
return job?.active_version ?? preview.active_version;
}
export function resolveRuntimeEffectiveVersion(
preview: AgentUpdatePreview,
job?: AgentUpdateJob,
): string {
return (
job?.effective_version ??
preview.effective_version ??
resolveRuntimeActiveVersion(preview, job) ??
resolveRuntimeVersionPair(preview, job).currentVersion
);
}
export function resolveRuntimeOperation(
preview: AgentUpdatePreview | null,
job?: AgentUpdateJob,
@@ -61,6 +74,8 @@ export function runtimeOperationLabelKey(operation: AgentUpdateOperation | undef
return "agents:repairRuntime";
case "up_to_date":
return "agents:upToDateRuntime";
case "use_default":
return "agents:useKandevDefault";
case "update":
default:
return "agents:updateRuntime";
@@ -4,18 +4,27 @@ vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import { previewAgentUpdate, updateAgent } from "./agent-update-api";
import {
listAgentUpdateStatuses,
previewAgentUpdate,
previewAgentUpdateUseDefault,
updateAgent,
updateAgentUseDefault,
} from "./agent-update-api";
const fetchSpy = vi.fn<typeof fetch>();
const API_BASE_URL = "http://api.test";
const AGENT_NAME = "opencode-acp";
const TARGET_VERSION = "1.18.5";
beforeEach(() => {
fetchSpy.mockReset();
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({
agent_name: "opencode-acp",
agent_name: AGENT_NAME,
package: "opencode-ai",
target_version: "1.18.5",
target_version: TARGET_VERSION,
operation: "rollback",
available_versions: [],
command: [],
@@ -31,20 +40,46 @@ afterEach(() => vi.unstubAllGlobals());
describe("managed runtime update API", () => {
it("encodes an optional exact target in the preview query", async () => {
await previewAgentUpdate("opencode-acp", "1.18.5", { cache: "no-store" });
await previewAgentUpdate(AGENT_NAME, TARGET_VERSION, { cache: "no-store" });
const [input, init] = fetchSpy.mock.calls[0] ?? [];
expect(String(input)).toBe(
"http://api.test/api/v1/agent-update/opencode-acp/preview?target_version=1.18.5",
`${API_BASE_URL}/api/v1/agent-update/${AGENT_NAME}/preview?target_version=${TARGET_VERSION}`,
);
expect(init?.cache).toBe("no-store");
});
it("sends only the exact target version in the update body", async () => {
await updateAgent("opencode-acp", "1.18.5");
await updateAgent(AGENT_NAME, TARGET_VERSION);
const [, init] = fetchSpy.mock.calls[0] ?? [];
expect(init?.method).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ target_version: "1.18.5" }));
expect(init?.body).toBe(JSON.stringify({ target_version: TARGET_VERSION }));
});
it("previews returning to the Kandev default with a structural query", async () => {
await previewAgentUpdateUseDefault(AGENT_NAME, { cache: "no-store" });
const [input, init] = fetchSpy.mock.calls[0] ?? [];
expect(String(input)).toBe(
`${API_BASE_URL}/api/v1/agent-update/${AGENT_NAME}/preview?use_default=true`,
);
expect(init?.cache).toBe("no-store");
});
it("sends a structural use_default update request", async () => {
await updateAgentUseDefault(AGENT_NAME);
const [, init] = fetchSpy.mock.calls[0] ?? [];
expect(init?.method).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ use_default: true }));
});
it("loads the read-only batch status endpoint without mutation", async () => {
await listAgentUpdateStatuses({ cache: "no-store" });
const [input, init] = fetchSpy.mock.calls[0] ?? [];
expect(String(input)).toBe(`${API_BASE_URL}/api/v1/agent-update/status`);
expect(init?.cache).toBe("no-store");
});
});
+48 -1
View File
@@ -8,7 +8,9 @@ export type AgentUpdateJobStatus =
| "succeeded"
| "failed";
export type AgentUpdateOperation = "update" | "rollback" | "repair" | "up_to_date";
export type AgentUpdateOperation = "update" | "rollback" | "repair" | "up_to_date" | "use_default";
export type AgentUpdateCheckState = "update_available" | "up_to_date" | "unknown";
export type AgentUpdateVersion = {
version: string;
@@ -21,7 +23,9 @@ export type AgentUpdateJob = {
status: AgentUpdateJobStatus;
operation?: AgentUpdateOperation;
current_version?: string;
default_version?: string;
active_version?: string;
effective_version?: string;
target_version?: string;
output?: string;
error?: string;
@@ -34,7 +38,9 @@ export type AgentUpdatePreview = {
agent_name: string;
package: string;
current_version?: string;
default_version?: string;
active_version?: string;
effective_version?: string;
target_version: string;
operation?: AgentUpdateOperation;
available_versions?: AgentUpdateVersion[];
@@ -54,6 +60,16 @@ export async function previewAgentUpdate(
);
}
export async function previewAgentUpdateUseDefault(
agentName: string,
options?: ApiRequestOptions,
): Promise<AgentUpdatePreview> {
return fetchJson<AgentUpdatePreview>(
`/api/v1/agent-update/${encodeURIComponent(agentName)}/preview?use_default=true`,
options,
);
}
export async function updateAgent(
agentName: string,
targetVersion: string,
@@ -69,6 +85,37 @@ export async function updateAgent(
});
}
export async function updateAgentUseDefault(
agentName: string,
options?: ApiRequestOptions,
): Promise<AgentUpdateJob> {
return fetchJson<AgentUpdateJob>(`/api/v1/agent-update/${encodeURIComponent(agentName)}`, {
...options,
init: {
method: "POST",
body: JSON.stringify({ use_default: true }),
...(options?.init ?? {}),
},
});
}
export type AgentUpdateStatus = {
agent_name: string;
package: string;
default_version: string;
active_version?: string;
effective_version: string;
latest_version?: string;
checked_at?: string;
check_state: AgentUpdateCheckState;
};
export async function listAgentUpdateStatuses(
options?: ApiRequestOptions,
): Promise<{ statuses: AgentUpdateStatus[] }> {
return fetchJson<{ statuses: AgentUpdateStatus[] }>("/api/v1/agent-update/status", options);
}
export async function listAgentUpdateJobs(
options?: ApiRequestOptions,
): Promise<{ jobs: AgentUpdateJob[] }> {
+2
View File
@@ -231,7 +231,9 @@ export type RuntimeUpdate = {
supported: boolean;
package: string;
current_version?: string;
default_version?: string;
active_version?: string;
effective_version?: string;
};
export type AvailableAgent = {
+11
View File
@@ -185,16 +185,27 @@
"runtimeUpdateSessionsNote": "Active sessions keep running; only later probes and launches use the refreshed runtime.",
"runtimeUpdatedRefreshFailed": "Runtime updated, but capabilities could not be refreshed: {{error}}",
"runtimeUpdatedSuccess": "Runtime updated successfully.",
"runtimeVersionQuickChoices": "Quick choices",
"browseRuntimeVersions": "Browse all versions",
"searchRuntimeVersions": "Search versions",
"noMatchingRuntimeVersions": "No versions match your search.",
"activeRuntimeVersion": "Active version: {{version}}",
"activeRuntimeVersionMarker": "active",
"effectiveRuntimeVersion": "Effective version: {{version}}",
"kandevDefaultVersion": "Kandev default: {{version}}",
"kandevDefaultRuntimeVersionMarker": "Kandev default",
"latestRuntimeVersion": "latest",
"runtimeUpdateStatusUnknown": "Latest version for {{name}} is unavailable. The update control remains usable.",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "Version to activate",
"updateAvailableWithVersions": "Update {{name}} from {{current}} to {{latest}}",
"updateRuntime": "Update runtime",
"rollBackRuntime": "Roll back runtime",
"repairRuntime": "Repair runtime",
"upToDateRuntime": "Up to date",
"useKandevDefault": "Use Kandev default",
"useKandevDefaultVersion": "Use Kandev default ({{version}})",
"scanningForInstalledAgents": "Scanning for installed agents...",
"searchModels": "Search models...",
"searchModes": "Search modes...",
+11
View File
@@ -185,16 +185,27 @@
"runtimeUpdateSessionsNote": "Àćţĩvē śēśśĩōńś ķēēƥ ŕũńńĩńĝ; ōńĺŷ ĺàţēŕ ƥŕōƀēś àńď ĺàũńćĥēś ũśē ţĥē ŕēƒŕēśĥēď ŕũńţĩḿē.",
"runtimeUpdatedRefreshFailed": "Ŕũńţĩḿē ũƥďàţēď, ƀũţ ćàƥàƀĩĺĩţĩēś ćōũĺď ńōţ ƀē ŕēƒŕēśĥēď: {{error}}",
"runtimeUpdatedSuccess": "Ŕũńţĩḿē ũƥďàţēď śũććēśśƒũĺĺŷ.",
"runtimeVersionQuickChoices": "Qũĩćķ ćĥōĩćēś",
"browseRuntimeVersions": "Ɓŕōŵśē àĺĺ vēŕśĩōńś",
"searchRuntimeVersions": "Śēàŕćĥ vēŕśĩōńś",
"noMatchingRuntimeVersions": "Ńō vēŕśĩōńś ḿàţćĥ ŷōũŕ śēàŕćĥ.",
"activeRuntimeVersion": "Àćţĩvē vēŕśĩōń: {{version}}",
"activeRuntimeVersionMarker": "àćţĩvē",
"effectiveRuntimeVersion": "Ēƒƒēćţĩvē vēŕśĩōń: {{version}}",
"kandevDefaultVersion": "Ķàńďēv ďēƒàũĺţ: {{version}}",
"kandevDefaultRuntimeVersionMarker": "Ķàńďēv ďēƒàũĺţ",
"latestRuntimeVersion": "ĺàţēśţ",
"runtimeUpdateStatusUnknown": "Ĺàţēśţ vēŕśĩōń ƒōŕ {{name}} ĩś ũńàvàĩĺàƀĺē. Ţĥē ũƥďàţē ćōńţŕōĺ ŕēḿàĩńś ũśàƀĺē.",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "Vēŕśĩōń ţō àćţĩvàţē",
"updateAvailableWithVersions": "Ũƥďàţē {{name}} ƒŕōḿ {{current}} ţō {{latest}}",
"updateRuntime": "Ũƥďàţē ŕũńţĩḿē",
"rollBackRuntime": "Ŕōĺĺ ƀàćķ ŕũńţĩḿē",
"repairRuntime": "Ŕēƥàĩŕ ŕũńţĩḿē",
"upToDateRuntime": "Ũƥ ţō ďàţē",
"useKandevDefault": "Ũśē Ķàńďēv ďēƒàũĺţ",
"useKandevDefaultVersion": "Ũśē Ķàńďēv ďēƒàũĺţ ({{version}})",
"scanningForInstalledAgents": "Śćàńńĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś...",
"searchModels": "Śēàŕćĥ ḿōďēĺś...",
"searchModes": "Śēàŕćĥ ḿōďēś...",
+11
View File
@@ -175,16 +175,27 @@
"runtimeUpdateSessionsNote": "As sessões ativas continuam a correr; só as sondagens e os arranques posteriores usam o runtime atualizado.",
"runtimeUpdatedRefreshFailed": "O runtime foi atualizado, mas não foi possível atualizar as capacidades: {{error}}",
"runtimeUpdatedSuccess": "Runtime atualizado com êxito.",
"runtimeVersionQuickChoices": "Escolhas rápidas",
"browseRuntimeVersions": "Procurar todas as versões",
"searchRuntimeVersions": "Pesquisar versões",
"noMatchingRuntimeVersions": "Nenhuma versão corresponde à pesquisa.",
"activeRuntimeVersion": "Versão ativa: {{version}}",
"activeRuntimeVersionMarker": "ativa",
"effectiveRuntimeVersion": "Versão efetiva: {{version}}",
"kandevDefaultVersion": "Predefinição do Kandev: {{version}}",
"kandevDefaultRuntimeVersionMarker": "predefinição do Kandev",
"latestRuntimeVersion": "mais recente",
"runtimeUpdateStatusUnknown": "A versão mais recente de {{name}} não está disponível. O controlo de atualização continua utilizável.",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "Versão a ativar",
"updateAvailableWithVersions": "Atualizar {{name}} de {{current}} para {{latest}}",
"updateRuntime": "Atualizar runtime",
"rollBackRuntime": "Reverter runtime",
"repairRuntime": "Reparar runtime",
"upToDateRuntime": "Atualizado",
"useKandevDefault": "Usar a predefinição do Kandev",
"useKandevDefaultVersion": "Usar a predefinição do Kandev ({{version}})",
"scanningForInstalledAgents": "À procura de agentes instalados...",
"searchModels": "Pesquisar modelos...",
"searchModes": "Pesquisar modos...",
+11
View File
@@ -182,16 +182,27 @@
"runtimeUpdateSessionsNote": "活动会话会继续运行;仅后续探测与启动会使用刷新后的运行时。",
"runtimeUpdatedRefreshFailed": "运行时已更新,但无法刷新能力:{{error}}",
"runtimeUpdatedSuccess": "运行时更新成功。",
"runtimeVersionQuickChoices": "快速选择",
"browseRuntimeVersions": "浏览所有版本",
"searchRuntimeVersions": "搜索版本",
"noMatchingRuntimeVersions": "没有符合搜索条件的版本。",
"activeRuntimeVersion": "活动版本:{{version}}",
"activeRuntimeVersionMarker": "活动",
"effectiveRuntimeVersion": "生效版本:{{version}}",
"kandevDefaultVersion": "Kandev 默认版本:{{version}}",
"kandevDefaultRuntimeVersionMarker": "Kandev 默认",
"latestRuntimeVersion": "最新",
"runtimeUpdateStatusUnknown": "无法获取 {{name}} 的最新版本。更新控件仍可使用。",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要激活的版本",
"updateAvailableWithVersions": "将 {{name}} 从 {{current}} 更新到 {{latest}}",
"updateRuntime": "更新运行时",
"rollBackRuntime": "回滚运行时",
"repairRuntime": "修复运行时",
"upToDateRuntime": "已是最新",
"useKandevDefault": "使用 Kandev 默认版本",
"useKandevDefaultVersion": "使用 Kandev 默认版本({{version}})",
"scanningForInstalledAgents": "正在扫描已安装的智能体…",
"searchModels": "搜索模型…",
"searchModes": "搜索模式…",
+12 -1
View File
@@ -182,16 +182,27 @@
"runtimeUpdateSessionsNote": "活動工作階段會繼續執行;僅後續探測與啓動會使用刷新後的執行時。",
"runtimeUpdatedRefreshFailed": "執行時已更新,但無法刷新能力:{{error}}",
"runtimeUpdatedSuccess": "執行時更新成功。",
"runtimeVersionQuickChoices": "快速選擇",
"browseRuntimeVersions": "瀏覽所有版本",
"searchRuntimeVersions": "搜尋版本",
"noMatchingRuntimeVersions": "沒有符合搜尋條件的版本。",
"activeRuntimeVersion": "活動版本:{{version}}",
"activeRuntimeVersionMarker": "活動",
"effectiveRuntimeVersion": "生效版本:{{version}}",
"kandevDefaultVersion": "Kandev 預設版本:{{version}}",
"kandevDefaultRuntimeVersionMarker": "Kandev 預設",
"latestRuntimeVersion": "最新",
"runtimeUpdateStatusUnknown": "無法擷取 {{name}} 的最新版本。更新控制項仍可使用。",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要激活的版本",
"selectRuntimeVersion": "要啟用的版本",
"updateAvailableWithVersions": "將 {{name}} 從 {{current}} 更新到 {{latest}}",
"updateRuntime": "更新執行時",
"rollBackRuntime": "回滾執行時",
"repairRuntime": "修復執行時",
"upToDateRuntime": "已是最新",
"useKandevDefault": "使用 Kandev 預設版本",
"useKandevDefaultVersion": "使用 Kandev 預設版本({{version}})",
"scanningForInstalledAgents": "正在掃描已安裝的代理程式…",
"searchModels": "搜尋模型…",
"searchModes": "搜尋模式…",
+11
View File
@@ -182,16 +182,27 @@
"runtimeUpdateSessionsNote": "活動工作階段會繼續執行;僅後續探測與啟動會使用重新整理後的執行時。",
"runtimeUpdatedRefreshFailed": "執行時已更新,但無法重新整理能力:{{error}}",
"runtimeUpdatedSuccess": "執行時更新成功。",
"runtimeVersionQuickChoices": "快速選擇",
"browseRuntimeVersions": "瀏覽所有版本",
"searchRuntimeVersions": "搜尋版本",
"noMatchingRuntimeVersions": "沒有符合搜尋條件的版本。",
"activeRuntimeVersion": "活動版本:{{version}}",
"activeRuntimeVersionMarker": "活動",
"effectiveRuntimeVersion": "生效版本:{{version}}",
"kandevDefaultVersion": "Kandev 預設版本:{{version}}",
"kandevDefaultRuntimeVersionMarker": "Kandev 預設",
"latestRuntimeVersion": "最新",
"runtimeUpdateStatusUnknown": "無法擷取 {{name}} 的最新版本。更新控制項仍可使用。",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要啟用的版本",
"updateAvailableWithVersions": "將 {{name}} 從 {{current}} 更新到 {{latest}}",
"updateRuntime": "更新執行時",
"rollBackRuntime": "回滾執行時",
"repairRuntime": "修復執行時",
"upToDateRuntime": "已是最新",
"useKandevDefault": "使用 Kandev 預設版本",
"useKandevDefaultVersion": "使用 Kandev 預設版本({{version}})",
"scanningForInstalledAgents": "正在掃描已安裝的代理程式…",
"searchModels": "搜尋模型…",
"searchModes": "搜尋模式…",
@@ -10,11 +10,12 @@ Kandev's `codex-acp` agent previously launched `@zed-industries/codex-acp`. That
## Decision
Kandev's `codex-acp` agent launches the unversioned
`@agentclientprotocol/codex-acp` package for ACP chat and one-shot inference
sessions. Normal launches prefer npm's execution cache. The install script
still installs `@openai/codex` for `codex login`; that native authentication
helper is separate from the managed ACP runtime.
Kandev's `codex-acp` agent launches the exact effective version of
`@agentclientprotocol/codex-acp` for ACP chat and one-shot inference sessions.
The reviewed Kandev default is currently `1.6.0`; an install-wide operator
selection can override it. Normal launches prefer npm's execution cache. The
install script still installs `@openai/codex` for `codex login`; that native
authentication helper is separate from the managed ACP runtime.
The host operator can deliberately refresh the ACP package through
**Settings > Agents > Update agent**. Kandev then re-probes the bridge and
@@ -29,10 +30,11 @@ Codex ACP sessions receive the model catalogue and config options from the Agent
Codex-specific ACP `cli_flags` are not advertised because the bridge entrypoint does not apply the native Codex `-c` config overrides to chat sessions. Kandev still exposes the universal agentctl auto-approve toggle for ACP permission requests and keeps native Codex flags scoped to passthrough mode.
Kandev source no longer determines the exact bridge release. Incident
diagnosis uses the version reported during ACP initialization. Compatibility
is enforced through ACP protocol negotiation and advertised capabilities, so
an upstream same-protocol regression can still require operator intervention.
Kandev source supplies a reviewed default and records any operator-selected
exact version. Incident diagnosis also uses the version reported during ACP
initialization. Compatibility is enforced through ACP protocol negotiation and
advertised capabilities, so an upstream same-protocol regression can still
require operator intervention.
## Alternatives Considered
@@ -1,8 +1,8 @@
# ADR-2026-08-12-validated-managed-runtime-version-selection: Validate and Persist Managed Runtime Version Selection
**Status:** accepted (amended 2026-08-16)
**Status:** accepted (amended 2026-08-21)
**Date:** 2026-08-12
**Area:** backend, frontend, protocol
**Area:** backend, frontend, protocol, workflow
**Supersedes:**
[ADR-2026-07-26-user-managed-agent-runtime-updates](2026-07-26-user-managed-agent-runtime-updates.md)
@@ -21,6 +21,14 @@ identity, registry location, and ACP arguments are trusted Kandev metadata.
Users need to choose among valid versions of that package, validate the
candidate, and keep a known-good version across Kandev restarts.
Persisting a selection only after a manual update still leaves every fresh
installation and every unselected agent on an unversioned `npx <package>`
command. Removing npm from the launch path would require Kandev-owned or
user-installed runtime artifacts and is not a requirement for this iteration.
The immediate invariant is narrower: every managed npm ACP command names an
exact top-level package version, while npm remains responsible for cache and
network behavior.
## Decision
Kandev lets an operator select an exact stable version for a built-in managed
@@ -38,12 +46,18 @@ the candidate capabilities.
This ordering makes the persisted active version the last known good version;
automatic silent rollback is unnecessary.
Future standalone host probes, utility calls, and local agent sessions resolve
the persisted exact version. Existing sessions keep their current process.
SSH, container, and other remote runtimes do not inherit the host selection and
continue to resolve their own runtime. When no selection record exists, Kandev
retains the legacy unversioned host behavior until the first candidate is
successfully activated.
Each trusted managed package has an exact default version shipped in Kandev.
Command resolution uses the persisted operator selection when its package
identity still matches; otherwise it uses the shipped default. Kandev does not
copy the default into the database. An installation with no explicit selection
therefore follows newer defaults when Kandev upgrades, while an operator
selection remains stable until the operator changes or clears it.
The resulting effective version applies to every Kandev-built ACP command for
that managed package, including capability probes, utility prompts, local
sessions, containers, and SSH executors. Existing sessions keep their current
process. Native binaries, passthrough commands, and separately distributed
authentication helpers remain outside this package selection.
The managed package and exact-version override belong only to an agent's ACP
command surfaces. They do not replace `PassthroughConfig.PassthroughCmd`, an
@@ -53,25 +67,52 @@ and a separately distributed native CLI for terminal passthrough.
The selection is install-wide and per built-in agent. A saved selection applies
only while its stored package identity matches the agent's current trusted
package metadata; a package change starts with no selection and requires fresh
validation. The selection survives backend and browser restarts independently
of npm's best-effort cache. If reading a saved
selection fails, Kandev fails the new host-managed launch or probe visibly
instead of bypassing the selection with an unversioned package. If npm evicts
the selected version's cache, the exact version is prepared again when needed.
package metadata; a package change falls back to the replacement package's
shipped default. The selection survives backend and browser restarts
independently of npm's best-effort cache. If reading a saved selection fails,
Kandev fails the new managed launch or probe visibly instead of bypassing the
selection. If npm evicts the effective version's cache, npm may prepare that
same exact version again when needed.
Settings checks the registry for each managed package when the Agents page asks
for update status. The backend caches successful results for a bounded period,
returns an explicit unknown state when the registry is unavailable, and never
updates a runtime from this read-only check. The UI marks an available newer
stable version on the existing update control. Opening the control performs the
authoritative preview and retains the validated prepare, probe, and activation
flow.
Normal launches retain `--prefer-offline` because an exact package version
removes version-tag drift while preserving npm cache reuse. Explicit previews,
manual updates, and the bounded stale-metadata retry use online-preferred
metadata. Kandev accepts that a cold or incomplete npm cache can still place the
registry on the launch path.
A weekly repository workflow compares the shipped defaults with each trusted
package's stable npm `latest` tag and opens a reviewable PR for changes. It does
not merge automatically. The same centralized version catalogue drives command
construction, tests, and the workflow so runtime pins cannot drift across
separate constants.
## Consequences
Operators can recover from a broken latest release in Settings without shell
access or a Kandev restart. Runtime changes become attributable: after the
first activation, host launches use the selected exact version until another
candidate passes validation.
access or a Kandev restart. Runtime changes are attributable: every managed npm
launch uses either a reviewed Kandev default or an explicit validated operator
selection.
The backend now owns a small durable selection record and must route it through
every host-local managed-runtime command path. Version catalogue lookup remains
dependent on registry availability, and cached versions are not a Kandev-owned
artifact inventory. A version that has disappeared from the registry can still
fail to reinstall after npm cache eviction.
every managed-runtime command path. It also owns a bounded, process-local update
status cache. Version catalogue lookup remains dependent on registry
availability, and cached versions are not a Kandev-owned artifact inventory. A
version that has disappeared from the registry can still fail to reinstall
after npm cache eviction.
An exact top-level package pin does not lock transitive dependency ranges. npm
process startup, cold-cache downloads, registry failures, and cache eviction
remain accepted costs. The weekly PR requires maintenance and compatibility
review, while the Settings override lets operators move ahead of a Kandev
release when necessary.
The ACP probe is the activation boundary, not an assertion that every provider,
model, or future prompt will succeed. Authentication-required or failed probes
@@ -86,11 +127,22 @@ capabilities.
Settings request execute an arbitrary npm package.
- Automatic fallback after a failed launch was rejected because it hides the
version actually used and can make separate launches behave differently.
- Persisting the shipped default as an operator selection was rejected because
it would prevent existing unmodified installations from inheriting a newer
reviewed default in a later Kandev release.
- Removing `--prefer-offline` was rejected because exact package specs already
prevent top-level version drift, while online-preferred metadata on every
launch adds registry checks and latency. Explicit update paths remain the
freshness boundary.
- Automatically installing npm `latest` after an update check was rejected.
The checker is informational, and every version change remains an explicit
operator action or a reviewed Kandev pin PR.
- A Kandev-owned package directory and lockfile were rejected for this
iteration. Exact npm package specs and version-specific execution keys provide
transactional selection without reimplementing package storage.
- Applying the host selection to remote executors was rejected because each
remote runtime has separate platform, cache, credentials, and ownership.
- User-installed bridge discovery was rejected for this iteration because it
would require path ownership, compatibility discovery, and a separate update
contract. npm remains the supported bridge distribution mechanism.
- Reusing the managed ACP package for terminal passthrough was rejected because
an ACP JSON-RPC stdio adapter is not an interactive PTY application, and some
integrations distribute those surfaces as different packages and binaries.
+1 -1
View File
@@ -84,7 +84,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-07-24-opt-in-authentication | [Opt-in Authentication and Per-User Workspace Scoping](2026-07-24-opt-in-authentication.md) | accepted | backend, frontend, protocol, security | 2026-07-24 |
| 2026-07-24-semantic-notification-events | [Semantic notification events come from domain occurrences](2026-07-24-semantic-notification-events.md) | accepted | backend, frontend, desktop | 2026-07-24 |
| 2026-07-26-user-managed-agent-runtime-updates | [User-Managed Agent Runtime Updates](2026-07-26-user-managed-agent-runtime-updates.md) | superseded by 2026-08-12-validated-managed-runtime-version-selection | backend, frontend, protocol | 2026-07-26 |
| 2026-08-12-validated-managed-runtime-version-selection | [Validate and Persist Managed Runtime Version Selection](2026-08-12-validated-managed-runtime-version-selection.md) | accepted | backend, frontend, protocol | 2026-08-12 |
| 2026-08-12-validated-managed-runtime-version-selection | [Validate and Persist Managed Runtime Version Selection](2026-08-12-validated-managed-runtime-version-selection.md) | accepted (amended 2026-08-21) | backend, frontend, protocol, workflow | 2026-08-12 |
| 2026-07-26-single-session-model-switching | [Single-Session Model Switching](2026-07-26-single-session-model-switching.md) | accepted (amended 2026-07-27) | workflow, infra | 2026-07-26 |
| 2026-07-27-task-git-credential-policy | [Separate GitHub Automation From Task Git Credential Policy](2026-07-27-task-git-credential-policy.md) | accepted | backend, frontend, security | 2026-07-27 |
| 2026-07-27-spa-failure-containment-and-deployment-recovery | [SPA Failure Containment and Deployment Recovery](2026-07-27-spa-failure-containment-and-deployment-recovery.md) | accepted | frontend | 2026-07-27 |
@@ -0,0 +1,61 @@
---
spec: docs/specs/agents/runtime-updates.md
created: 2026-08-22
status: complete
---
# Implementation Plan: Managed Runtime npm Metadata Compatibility
## Overview
Normalize the version metadata returned by supported npm CLI versions before
building the managed-runtime catalogue. npm 12 wraps the result of a
multi-field `npm view` query in a one-element array, while earlier npm versions
return the object directly. The backend must accept both valid forms and keep
rejecting malformed or ambiguous metadata.
## Backend
### Version metadata resolver
- Update `apps/backend/internal/agent/settings/controller/agent_update.go`
alongside `hostRuntimeUpdater.ResolveVersions` to accept the existing object
response and npm 12's one-element array response.
- Normalize both forms to the existing `RuntimeVersionMetadata` contract. An
empty or multi-entry array remains an error and cannot produce a catalogue.
- Preserve the existing exact npm argv, stable-version filtering, latest-tag
validation, and preview error behavior.
## Tests
- **What:** A valid npm 12-shaped one-element array resolves to the same
version catalogue and latest value as the object form.
**File:** `apps/backend/internal/agent/settings/controller/agent_update_test.go`.
**How:** Use the existing recording command executor and assert the returned
metadata plus the exact direct npm argv.
- **What:** Existing preview and malformed-metadata behavior remains covered.
**File:** `apps/backend/internal/agent/settings/controller/agent_update_test.go`
and `apps/backend/internal/agent/settings/handlers/discovery_handlers_test.go`.
**How:** Run the focused controller and handler packages after the regression
test passes.
## Verification Results
- `cd apps/backend && go test -run 'TestHostRuntimeUpdaterResolves|TestHostRuntimeUpdaterRejectsAmbiguousNPMRuntimeMetadata' ./internal/agent/settings/controller`: 7 tests passed.
- `cd apps/backend && go test ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed.
- `cd apps/backend && go test -race ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed.
- `cd apps/backend && golangci-lint run ./... --new-from-rev="032ea05bc8997028b1690f5f351939c83a6f77c2" --timeout=5m`: no issues.
- `git diff --check`: passed. No temporary diagnostic state was created.
## Implementation Waves And Parallel Candidates
Wave 1, sequential:
- [x] [Task 01: Normalize npm version metadata](task-01-normalize-npm-version-metadata.md)
## Risks
- npm output is external-process data. The parser must not accept multiple
metadata records or change the trusted package, registry, or command shape.
- This repair changes only catalogue resolution; it does not alter runtime
selection, cache invalidation, or update activation.
@@ -0,0 +1,72 @@
---
id: "01-normalize-npm-version-metadata"
title: "Normalize npm version metadata"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 01: Normalize npm version metadata
The preview currently fails on a Sprite because npm 12 returns a one-element
array for `npm view <package> versions dist-tags --json`, while the resolver
expects an object. Implement the smallest parser change after proving the
failure with a regression test.
## Acceptance
- Object-shaped npm metadata continues to resolve unchanged.
- npm 12's one-element array-shaped metadata resolves to a stable catalogue
and latest version.
- Empty, multi-entry, malformed, and missing-latest metadata still fails
closed through the existing preview error path.
## Verification
```bash
cd apps/backend && go test -run 'TestHostRuntimeUpdaterResolves|TestPreviewAgentUpdate' ./internal/agent/settings/controller ./internal/agent/settings/handlers
```
## Files likely touched
- `apps/backend/internal/agent/settings/controller/agent_update.go`
- `apps/backend/internal/agent/settings/controller/agent_update_test.go`
- `docs/specs/agents/runtime-updates.md`
- `docs/plans/managed-runtime-npm-metadata-compat/plan.md`
- `docs/plans/managed-runtime-npm-metadata-compat/task-01-normalize-npm-version-metadata.md`
## Dependencies
None.
## Parallelism
Sequential.
## Inputs
- The managed runtime preview contract and the npm metadata compatibility
scenario in `docs/specs/agents/runtime-updates.md`.
- The confirmed Sprite reproduction: npm `12.0.2` returns a top-level array
for the exact multi-field query.
## Output contract
Update the parser and regression coverage, record exact test results in this
task and `plan.md`, and report changed files, risks, and any remaining
environment-specific unknowns.
## Results
- Added a dedicated resolver regression test for npm 12's one-element array
response and rejection coverage for empty, multi-entry, and malformed
metadata.
- Updated the resolver to normalize object and one-element array payloads while
preserving the existing exact npm argv and latest-version validation.
- `cd apps/backend && go test -run 'TestHostRuntimeUpdaterResolves|TestHostRuntimeUpdaterRejectsAmbiguousNPMRuntimeMetadata' ./internal/agent/settings/controller`: 7 tests passed.
- `cd apps/backend && go test ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed.
- `cd apps/backend && go test -race ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed.
- `cd apps/backend && golangci-lint run ./... --new-from-rev="032ea05bc8997028b1690f5f351939c83a6f77c2" --timeout=5m`: no issues.
- `git diff --check`: passed. No temporary diagnostic state was created.
@@ -0,0 +1,284 @@
---
spec: docs/specs/agents/runtime-updates.md
decision: docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md
created: 2026-08-21
status: complete
---
# Implementation Plan: Managed Runtime Version Awareness
## Overview
Replace every unversioned managed npm ACP package invocation with an exact
effective version. The effective version is an install-wide validated operator
selection when present and a reviewed Kandev default otherwise. Build on the
existing version dialog and selection store, add a cached read-only update
status API and accessible blue-dot indicator, and add weekly automation that
opens reviewable pin-update PRs without changing runtimes automatically.
---
## Backend
### Central managed-runtime pins
- Add one machine-readable version catalogue at
`apps/backend/internal/agent/agents/managed_npm_runtime_versions.json` for
Claude, Codex, OpenCode, Copilot, and Gemini. Package identity remains in the
trusted agent metadata; the catalogue maps only those package names to exact
stable SemVer defaults.
- Embed and validate the catalogue from a focused Go helper beside
`managed_npm_runtime.go`. Startup must fail clearly if a trusted managed
package has no valid default; no command may fall back to an unversioned
package.
- Add `DefaultVersion` to `ManagedNPMRuntimeSpec`. `PackageSpec("")`,
`CachedACPCommand()`, runtime configs, inference configs, and cache keys use
the default. A non-empty validated override continues to produce
`package@override`.
- Keep launch commands on `npx --yes --prefer-offline`. Candidate preparation
and the existing bounded stale-metadata retry remain online-preferred.
### Effective version and operator selection
- Define one effective-version resolver: read
`managed_runtime.active.<agent-id>` from the install-wide settings store,
require the saved package to match trusted metadata, and otherwise return the
built-in default.
- Route the effective version through every centrally built managed ACP command,
including host utility probes, command previews, standalone sessions,
containers, and SSH executors. Native-binary preference, passthrough commands,
and separately distributed login helpers remain unchanged.
- Extend `managedruntime.SelectionStore` with deletion. The version job accepts
a trusted `use_default` request, stages and ACP-probes the built-in default,
then deletes the selection only after success. Candidate failure retains the
prior selection and capabilities.
- Extend runtime catalogue, preview, and job DTOs with `default_version` and
`effective_version`. Preserve `active_version` as the optional persisted
operator selection. Add structural `use_default` operation copy; the browser
does not infer operations from translated labels or version strings.
### Cached update status API
- Add `GET /api/v1/agent-update/status`. It returns one entry per available
managed runtime with package, default, optional active selection, effective
version, optional latest version, `checked_at`, and `check_state`.
- Implement a process-local, mutex-protected per-package cache in the agent
settings controller. Successful npm `latest` results live for six hours;
failed lookups produce `unknown` for fifteen minutes. Use injected clock and
resolver seams in tests and bound concurrent stale lookups to the five trusted
packages.
- Compare strict stable SemVer in the backend. Return `update_available` only
when latest is newer than effective, `up_to_date` otherwise, and `unknown`
when metadata is unavailable or invalid. One failed package does not fail the
batch.
- Invalidate the affected cache entry after a successful version activation or
return-to-default job. The dialog preview remains an uncached authoritative
catalogue lookup.
---
## Frontend
### API and page-local status hook
- Add typed batch-status contracts to
`apps/web/lib/api/domains/agent-update-api.ts` and the existing API barrels.
- Add a settings-domain hook that requests status when Settings > Agents mounts,
after Rescan, and after a successful version job. Keep status page-local; the
existing WebSocket-backed update-job store remains authoritative for active
mutations.
- Treat `unknown` as no indicator, not as up to date. Do not disable the update
trigger when the checker fails.
### Agent-card update indicator
- Pass the backend status for each agent through
`apps/web/app/settings/agents/page.tsx` and
`installed-agent-card.tsx` into the existing runtime update control.
- When `check_state` is `update_available`, overlay a blue dot on the existing
refresh trigger. Add effective and latest versions to its accessible name and
desktop tooltip; do not rely on color alone.
- Opening the trigger continues to fetch a live preview. The dot is a hint and
never a separate action.
- Extend the existing dialog/drawer version selector with a clearly labelled
Kandev-default option when an operator selection exists. The same state and
mutation handler serve desktop and mobile.
### Mobile design contract
- **Desktop outcome:** the agent-group card shows a compact update hint and
opens the existing centered version dialog.
- **Mobile entry point:** the same 44 px update trigger on the agent-group card.
- **Nearest exemplar:** the existing `AgentRuntimeUpdateControl`; retain its
inset bottom `Drawer`, fixed header/footer, safe-area padding, and single
scrolling body.
- **Hierarchy and action:** the dot signals availability; tapping the trigger
opens the authoritative version summary and update/default action. No hover
is required.
- **Geometry:** the dot does not change the trigger hitbox, add a scroll owner,
or introduce horizontal overflow. Shared status and selection logic remain
viewport-neutral.
### Localization
- Add copy for update availability, version interpolation, Kandev default, and
unknown checker behavior to all five required locale catalogues.
- Generate Traditional Chinese catalogues through `pnpm run i18n:zh-hant` and
run the focused i18n checks; do not hardcode labels in components.
---
## Weekly Pin Update Workflow
- Add `scripts/update-agent-runtime-pins.mjs` to read the central catalogue,
query each trusted package's npm `dist-tags.latest`, reject prerelease or
invalid SemVer values, and rewrite only changed pins. Keep parsing and rewrite
logic independently testable with fixture metadata and no network.
- Add `.github/workflows/update-agent-runtime-pins.yml` with weekly and manual
triggers, least-privilege permissions, pinned action SHAs, a stable bot
branch, and one grouped Conventional Commit PR. Use a repository-approved
bot/App token so the generated PR receives normal CI events. Do not auto-merge.
- Run the updater tests, targeted managed-runtime Go tests, action-pinning lint,
and workflow contract test before opening a PR. When no pin changes, exit
without a branch or PR mutation.
- Add a workflow contract test under `.github/scripts/` and wire it into
`lint-action-pinning.yml` so schedule, permissions, token boundary, validation,
and no-auto-merge behavior cannot silently drift.
---
## Tests
- **Exact defaults and overrides:** table-driven argv tests cover all five
packages, empty-selection fallback, explicit selection, native preference,
host utility, standalone, container, and SSH command construction.
- **Selection lifecycle:** real settings-store tests cover save, matching package,
default fallback, delete-on-success, and preservation on failed candidate
validation.
- **Update status:** controller and handler tests cover newer/equal/older latest,
partial registry failure, strict SemVer, cache TTLs, invalidation, and one
read-only batch response without starting a job.
- **Frontend status:** API and hook tests cover successful, partial-unknown, and
post-update refresh behavior. Component tests cover dot visibility,
accessible version copy, usable unknown state, and return-to-default action.
- **Workflow:** offline updater fixtures and workflow-contract tests cover
changed/no-change/prerelease/error cases and secure PR creation.
## E2E Tests
- **Desktop update hint:** given a newer registry version, opening Settings >
Agents shows the blue dot; opening it shows the authoritative current-to-latest
preview and starts no mutation until approval.
- **Unknown status:** given one failed package lookup, that agent has no dot but
its update dialog remains usable; another package can still show an update.
- **Post-update state:** after a successful update, the status refresh removes
the dot and the selected version survives page reload.
- **Mobile parity:** on Pixel 5, the dotted 44 px trigger opens the existing
drawer, exposes the version information without hover, keeps the action
reachable, and produces no document horizontal overflow.
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`
---
## Public Documentation
- Update `docs/public/agents-and-profiles.md` with shipped defaults, explicit
selections, the blue update hint, return-to-default behavior, and the accepted
npm/network/cache limitations.
- Update `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md` so every
documented command uses `package@effective-version` and distinguishes normal
offline-preferred launches from online-preferred updates.
- Search public and internal docs for claims that managed packages are
unversioned or host-only and reconcile only affected sources.
---
## Verification Results
Pre-remediation baseline. Backend focused tests passed 2,614 tests across seven
packages and `make -C apps/backend lint` reported zero issues. Frontend focused
tests passed 22/22, full web lint and typecheck passed, and the i18n gate
passed. Desktop and mobile E2E passed 14/14 and 4/4. Updater,
workflow-contract, action-pinning, and `zizmor` checks passed. Public-doc tests
passed 61/61 and the validator accepted 41 pages. `git diff --check` passed.
### Review remediation
The review pass corrected existing-PR selection and App-token propagation in
the weekly workflow, parsed GitHub output flags before catalogue selection,
preserved structural `use_default` intent, omitted failed lookup timestamps,
and retried failed page-local status refreshes. The second remediation pass
also applied least-privilege workflow permissions, package-safe selection
projection, bounded status lookups, shared default derivation, nil-cache
initialization, and default restoration when the active version is unknown.
The follow-up review pass updates terminal `current_version` from the
successful capability probe, makes the UI prefer terminal `effective_version`
and retain the complete backend version projection, derives managed-runtime
test expectations from the catalogue, and validates those commands before the
weekly workflow commits or pushes. It also keeps the App token on the push
step and refreshes existing updater PRs by number.
Follow-up verification passes 2,594 focused backend tests, 32 focused web
tests, full web lint/typecheck/i18n, 7 pin updater tests, 8 workflow-contract
tests, 9 action-pinning tests, the 19-workflow action linter, `zizmor`, and
desktop/mobile E2E at 15/15 and 4/4. The aggregate backend phase of `make
test` passed; its full web Vitest phase did not reach a terminal summary, so it
was interrupted after the isolated `http-git-server` suite passed 3/3.
---
## Implementation Waves And Parallel Candidates
Execution is sequential in the primary conversation unless the user explicitly
authorizes subagents after choosing the implementation model.
Wave 1:
- [x] [Task 01: Add reviewed default pins](task-01-default-pins.md)
Wave 2:
- [x] [Task 02: Resolve and persist effective versions](task-02-effective-version-selection.md)
- [x] [Task 04: Automate weekly pin PRs](task-04-weekly-pin-workflow.md)
Task 04 is parallel-safe after Task 01 because it owns workflow and script files;
Task 02 owns runtime selection and API mutation files.
Wave 3:
- [x] [Task 03: Add cached update status](task-03-update-status-api.md)
Wave 4:
- [x] [Task 05: Show update awareness in Settings](task-05-settings-update-indicator.md)
Wave 5:
- [x] [Task 06: Prove desktop and mobile flows](task-06-update-awareness-e2e.md)
- [x] [Task 07: Update runtime documentation](task-07-runtime-version-docs.md)
Task 07 is parallel-safe after Tasks 01-05 because it owns documentation only;
Task 06 owns E2E fixtures and specs.
---
## Risks
- An exact top-level npm package does not lock transitive ranges. The design
intentionally accepts npm/cache/network behavior rather than claiming full
artifact reproducibility.
- A user selection is install-wide and affects remote commands built by the
same backend, but candidate ACP validation runs on the Kandev host. A remote
platform can still reject a host-validated package and must surface its normal
launch error.
- Registry checks must not delay application boot or agent launches. They are
page-triggered, read-only, cached, and separate from the agent catalogue boot
payload.
- A bot-created PR must trigger the repository's normal required checks. The
workflow must not silently fall back to `GITHUB_TOKEN` behavior that suppresses
those PR events.
@@ -0,0 +1,75 @@
---
id: "01-default-pins"
title: "Add reviewed default pins"
status: complete
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 01: Add reviewed default pins
## Acceptance
- One validated machine-readable catalogue defines an exact stable default for
every built-in managed npm runtime.
- Every command built without an operator selection uses
`package@default_version`; no supported managed runtime can fall back to an
unversioned package.
- Normal launches retain `--prefer-offline`, while explicit preparation and the
bounded stale-metadata retry retain online-preferred behavior.
## Verification
```bash
cd apps/backend && go test ./internal/agent/agents ./internal/agent/registry ./internal/agent/runtime/lifecycle
```
## Files likely touched
- `apps/backend/internal/agent/agents/managed_npm_runtime_versions.json`
- `apps/backend/internal/agent/agents/managed_npm_runtime_versions.go`
- `apps/backend/internal/agent/agents/managed_npm_runtime.go`
- `apps/backend/internal/agent/agents/agent.go`
- `apps/backend/internal/agent/agents/{claude_acp,codex_acp,opencode_acp,copilot_acp,gemini}.go`
- Focused `*_test.go` files beside the managed agents and lifecycle command builder
## Dependencies
None.
## Parallelism
Sequential. This establishes the shared catalogue and command invariant used by
all later tasks.
## Inputs
- Spec: managed package set, effective version semantics, command routing
- Plan: Central managed-runtime pins
- Existing pattern: `ManagedNPMRuntimeSpec.PackageSpec` and command tests
## Output contract
Report the selected initial pins, files changed, exact test result, absence of
unversioned managed commands, risks, and synchronized task/plan status.
## Results
Complete. The catalogue pins are Claude `0.70.0`, Codex `1.6.0`, OpenCode
`1.18.18`, Copilot `1.0.75`, and Gemini `0.52.0`. The embedded Go validator
rejects missing, malformed, prerelease, and non-stable defaults. Empty-version
commands now use `package@default_version`; explicit validated selections use
`package@selection`, while normal launches remain `--prefer-offline` and
preparation/recovery remain online-preferred.
Verification: `go test ./internal/agent/agents ./internal/agent/registry
./internal/agent/runtime/lifecycle -count=1` passed as part of the current
2,646-test post-remediation scoped backend run.
Follow-up review verification removed duplicated current-pin snapshots from
the managed-agent and lifecycle tests. Their command expectations now derive
from the embedded catalogue, so the weekly updater can change a pin without
making the generated PR fail on stale test literals. The focused catalogue and
command suite passed again as part of the 2,594-test follow-up backend run.
@@ -0,0 +1,79 @@
---
id: "02-effective-version-selection"
title: "Resolve and persist effective versions"
status: complete
wave: 2
depends_on: ["01-default-pins"]
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 02: Resolve and persist effective versions
## Acceptance
- Effective version resolution uses a matching validated DB selection first and
the reviewed default otherwise across host, container, and SSH managed ACP
commands.
- Runtime preview and job contracts expose default, optional active selection,
and effective version without changing trusted package identity.
- **Use Kandev default** validates the exact default and deletes the selection
only after a successful candidate probe; failures retain the prior selection.
## Verification
```bash
cd apps/backend && go test ./internal/agent/managedruntime ./internal/agent/hostutility ./internal/agent/runtime/lifecycle ./internal/agent/settings/controller ./internal/agent/settings/handlers
```
## Files likely touched
- `apps/backend/internal/agent/managedruntime/selection.go`
- `apps/backend/internal/agent/managedruntime/selection_test.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go`
- `apps/backend/internal/agent/hostutility/manager.go`
- `apps/backend/internal/agent/settings/dto/dto.go`
- `apps/backend/internal/agent/settings/controller/agent_update.go`
- `apps/backend/internal/agent/settings/controller/agent_update_job.go`
- `apps/backend/internal/agent/settings/handlers/handlers.go`
- Focused controller, handler, host utility, and lifecycle tests
## Dependencies
Task 01.
## Parallelism
Parallel-safe with Task 04 after Task 01. This task owns runtime selection and
mutation contracts; Task 04 owns automation files.
## Inputs
- Spec: version semantics, activation lifecycle, command routing, persistence
- ADR: install-wide selection precedence and accepted npm boundary
- Existing patterns: `managedruntime.Store`, `PreviewAgentUpdate`, update jobs
## Output contract
Report API changes, persistence behavior, execution surfaces covered, exact
tests and results, blockers/risks, and synchronized task/plan status.
## Results
Complete. Matching install-wide selections now take precedence over reviewed
defaults across host utility, standalone, container, and SSH command builders.
Runtime catalogue, preview, job, and executor contracts expose default,
active, and effective versions. Structural `use_default` previews and jobs
validate and probe the exact default before deleting the stored selection;
failed candidates retain the previous selection.
Verification: the post-remediation command `go test ./internal/agent/agents
./internal/agent/registry ./internal/agent/runtime/lifecycle
./internal/agent/managedruntime ./internal/agent/hostutility
./internal/agent/settings/controller ./internal/agent/settings/handlers -count=1`
passed 2,646 tests.
Follow-up review verification added terminal assertions for normal update,
rollback, and return-to-default jobs. Successful capability probes now update
the terminal `current_version` before publication; the focused follow-up
backend command passed 2,594 tests.
@@ -0,0 +1,70 @@
---
id: "03-update-status-api"
title: "Add cached update status"
status: complete
wave: 3
depends_on: ["02-effective-version-selection"]
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 03: Add cached update status
## Acceptance
- One read-only batch endpoint returns structural update status for every
available managed runtime and tolerates per-package registry failures.
- The backend performs strict stable SemVer comparison, caches successful
checks for six hours and failures for fifteen minutes, and never starts an
update job from a status request.
- Successful activation or return-to-default invalidates the affected cache;
tests use injected clock/resolver seams with no real registry dependency.
## Verification
```bash
cd apps/backend && go test ./internal/agent/settings/controller ./internal/agent/settings/handlers
```
## Files likely touched
- `apps/backend/internal/agent/settings/dto/dto.go`
- `apps/backend/internal/agent/settings/controller/agent_update_status.go`
- `apps/backend/internal/agent/settings/controller/controller.go`
- `apps/backend/internal/agent/settings/controller/agent_update_job.go`
- `apps/backend/internal/agent/settings/handlers/handlers.go`
- `apps/backend/internal/agent/settings/controller/agent_update_status_test.go`
- `apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go`
## Dependencies
Task 02.
## Parallelism
Sequential. The checker depends on final effective-version and job-invalidation
contracts.
## Inputs
- Spec: Update status API, failure behavior, cache lifetimes
- Plan: Cached update status API
- Existing pattern: `RuntimeUpdater.ResolveTarget` and handler error contracts
## Output contract
Report the endpoint shape, cache and concurrency behavior, partial-failure
evidence, exact tests/results, risks, and synchronized task/plan status.
## Results
Complete. `GET /api/v1/agent-update/status` returns one structural status per
available managed runtime, compares strict stable SemVer, tolerates individual
registry failures as `unknown`, bounds stale lookups to five concurrent
packages, and does not create update jobs. Successful checks cache for six
hours, failures for fifteen minutes, and successful activation/default reset
invalidates the affected package.
Verification: controller and handler tests passed in the post-remediation
2,646-test scoped backend run, including cache TTL, invalidation, bounded
lookups, partial failure, and read-only endpoint coverage.
@@ -0,0 +1,74 @@
---
id: "04-weekly-pin-workflow"
title: "Automate weekly pin PRs"
status: complete
wave: 2
depends_on: ["01-default-pins"]
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 04: Automate weekly pin PRs
## Acceptance
- A fixture-tested script updates only trusted catalogue entries to valid stable
npm `latest` versions and handles no-change, prerelease, malformed, and lookup
failure cases without partial writes.
- A weekly/manual least-privilege workflow runs validation and opens at most one
grouped Conventional Commit PR through a token that permits normal PR CI.
- The workflow uses pinned actions, never auto-merges, and has a contract test
wired into the repository's action-pinning workflow.
## Verification
```bash
node --test scripts/update-agent-runtime-pins.test.mjs && python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py && python3 .github/scripts/lint-action-pinning_test.py && python3 .github/scripts/lint-action-pinning.py && zizmor .github/workflows/update-agent-runtime-pins.yml && git diff --check
```
## Files likely touched
- `scripts/update-agent-runtime-pins.mjs`
- `scripts/update-agent-runtime-pins.test.mjs`
- `.github/workflows/update-agent-runtime-pins.yml`
- `.github/scripts/update-agent-runtime-pins-workflow-contract_test.py`
- `.github/workflows/lint-action-pinning.yml`
## Dependencies
Task 01.
## Parallelism
Parallel-safe with Task 02 after Task 01. Ownership is restricted to scripts and
`.github/**`; do not edit runtime selection files or the pin catalogue format.
## Inputs
- ADR: reviewed weekly PR, no automatic merge or runtime mutation
- Plan: Weekly Pin Update Workflow
- Scoped rules: `.github/AGENTS.md`
## Output contract
Report the required token boundary, workflow/branch behavior, files changed,
all security and contract checks, external side effects (expected: none during
local tests), risks, and synchronized task/plan status.
## Results
Complete. The updater changes only existing trusted catalogue entries, rejects
missing, malformed, and prerelease values before its atomic write, and the
weekly/manual workflow uses a pinned GitHub App token, a stable bot branch, one
grouped PR, and no auto-merge or `GITHUB_TOKEN` fallback.
Verification: updater tests passed 7/7; workflow contract tests passed 8/8;
action-pinning tests passed 9/9 and the linter accepted all 19 workflow files;
`zizmor .github/workflows/update-agent-runtime-pins.yml` reported no findings.
Local checks caused no external workflow or PR side effects.
Follow-up review verification keeps the App token in the push step, edits an
existing updater PR by its number, and runs the focused managed-runtime Go
suite after the catalogue update and before any commit or push. The workflow
contract now asserts that validation boundary; the contract passed 8/8 and
the action-pinning checks remained green.
@@ -0,0 +1,77 @@
---
id: "05-settings-update-indicator"
title: "Show update awareness in Settings"
status: complete
wave: 4
depends_on: ["03-update-status-api"]
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 05: Show update awareness in Settings
## Acceptance
- Settings > Agents loads page-local batch status and shows a blue dot only for
structural `update_available`; unknown status leaves the update control usable.
- The trigger exposes effective and latest versions in accessible copy, and
opening it still fetches the authoritative live preview before mutation.
- Desktop keeps the existing dialog, mobile keeps the existing 44 px trigger
and inset drawer, and **Use Kandev default** shares the same state/action logic.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- lib/api/domains/agent-update-api.test.ts hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx components/settings/agent-runtime-update-control.test.tsx && pnpm --filter @kandev/web exec eslint app/settings/agents/page.tsx components/settings/installed-agent-card.tsx components/settings/agent-runtime-update-control.tsx hooks/domains/settings/use-agent-runtime-update-statuses.ts lib/api/domains/agent-update-api.ts && pnpm --filter @kandev/web i18n:check && cd web && pnpm run typecheck
```
## Files likely touched
- `apps/web/lib/api/domains/agent-update-api.ts`
- `apps/web/lib/api/domains/agent-update-api.test.ts`
- `apps/web/lib/types/http-agents.ts`
- `apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.ts`
- `apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx`
- `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/agent-runtime-update-control.test.tsx`
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/agents.json`
## Dependencies
Task 03.
## Parallelism
Sequential. It consumes the final backend status and reset contracts.
## Inputs
- Spec: Desktop and mobile behavior, update status, failure modes
- Plan: Frontend and Mobile design contract
- Mobile exemplar: existing `AgentRuntimeUpdateControl`
## Output contract
Report rendered states, accessibility/mobile behavior, locale changes, exact
unit/lint/i18n/typecheck results, risks, and synchronized task/plan status.
## Results
Complete. Settings now loads page-local cached status, shows the blue dot only
for structural `update_available`, keeps unknown controls usable, refreshes
after Rescan and successful jobs, and shares the exact preview/mutation logic
between the desktop dialog and mobile drawer. Accessible labels include the
effective/latest versions, all new copy is localized in five catalogues, and
the 44 px mobile trigger remains intact.
Verification: focused frontend unit tests passed 49/49; full web lint passed;
`pnpm run typecheck` passed; `pnpm run i18n:check` passed with pseudo and all
four translated catalogues complete.
Follow-up review verification keeps the complete backend version projection in
the selector, prefers terminal effective versions, and clears a stale active
selection after a successful default reset. The focused frontend tests passed
32/32, the changed component lint passed, full web lint/typecheck/i18n passed,
and desktop/mobile E2E passed 15/15 and 4/4.
@@ -0,0 +1,72 @@
---
id: "06-update-awareness-e2e"
title: "Prove desktop and mobile flows"
status: complete
wave: 5
depends_on: ["05-settings-update-indicator"]
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 06: Prove desktop and mobile flows
## Acceptance
- Desktop E2E proves the update dot, authoritative preview, no pre-approval
mutation, successful dot removal, persisted selection, and partial-unknown
behavior through the UI.
- Mobile E2E taps the dotted 44 px trigger, completes the same update flow in
the existing drawer, and proves containment and no document horizontal
overflow.
- Tests use deterministic status/preview fixtures, stable test IDs, causal
waits, and no raw sleeps.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run tests/settings/agent-runtime-update.spec.ts && pnpm e2e:run --project mobile-chrome tests/settings/mobile-agent-runtime-update.spec.ts
```
## Files likely touched
- `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`
## Dependencies
Task 05.
## Parallelism
Parallel-safe with Task 07 after Tasks 01-05. This task owns only E2E files.
## Inputs
- Spec scenarios for update, unknown status, persistence, and mobile parity
- `/e2e` fixture and cleanup rules
- Existing runtime-update desktop/mobile specs and helper routes
## Output contract
Report RED/GREEN evidence, exact discovered test counts and commands, rendered
artifact paths if produced, cleanup evidence, risks, and synchronized task/plan
status.
## Results
Complete. Desktop coverage proves the blue dot, authoritative preview, no
pre-approval POST, successful dot removal, persisted active selection after
reload, unknown-status usability, and structural return-to-default behavior.
Mobile coverage proves the dotted 44 px trigger, drawer update flow, contained
scrolling, and no document horizontal overflow.
Verification: the production-build desktop run
`pnpm e2e:run --project chromium tests/settings/agent-runtime-update.spec.ts`
passed 15/15, and the production-build mobile run
`pnpm e2e:run --project mobile-chrome
tests/settings/mobile-agent-runtime-update.spec.ts` passed 4/4. No raw sleeps
were added and the fixture uses causal waits.
Follow-up review coverage adds the complete backend projection and successful
default-reset state, including removal of the stale active-version display.
@@ -0,0 +1,64 @@
---
id: "07-runtime-version-docs"
title: "Update runtime version documentation"
status: complete
wave: 5
depends_on: ["01-default-pins", "02-effective-version-selection", "03-update-status-api", "05-settings-update-indicator"]
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 07: Update runtime version documentation
## Acceptance
- Public agent documentation explains Kandev defaults, operator selections,
the blue update hint, return-to-default behavior, and active-session effects.
- Internal bridge documentation shows exact effective-version commands and
distinguishes offline-preferred launch from online-preferred update behavior.
- A focused search finds no live documentation claiming managed packages are
unversioned or that selections are host-only.
## Verification
```bash
node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs && git diff --check
```
## Files likely touched
- `docs/public/agents-and-profiles.md`
- `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md`
- Other directly affected public/spec/decision references found by focused `rg`
## Dependencies
Tasks 01, 02, 03, and 05.
## Parallelism
Parallel-safe with Task 06 after Tasks 01-05. This task owns documentation only.
## Inputs
- Updated runtime-updates spec and ADR
- Final API labels and command behavior from Tasks 01-05
- `/docs-maintainer` public documentation boundary
## Output contract
Report public/internal docs changed, content type, focused stale-claim search,
exact validation results, risks, and synchronized task/plan status.
## Results
Complete. Public and internal runtime documentation now explains reviewed
defaults, install-wide selections, return-to-default, blue update hints,
unknown checks, active-session behavior, exact effective-version commands, and
offline-preferred launches versus online-preferred updates. The current Codex
decision was reconciled, and the superseded unversioned/host-only claims remain
only as historical context in superseded records.
Verification: public-doc tests passed 61/61 and the validator accepted 41
published pages; `git diff --check` passed; focused stale-claim search found no
current public or bridge documentation making the old claim.
@@ -0,0 +1,100 @@
---
spec: docs/specs/agents/runtime-updates.md
created: 2026-08-22
status: complete
---
# Implementation Plan: Managed Runtime Version Picker
## Overview
Improve the existing managed-runtime update dialog without changing its API or
selection semantics. The dialog will show a compact status summary and quick
latest/default choices, while a searchable browser exposes the complete stable
catalogue on demand in both the desktop dialog and mobile drawer.
## Frontend
### Compact update dialog
- Refine `apps/web/components/settings/agent-runtime-update-control.tsx` so
the status summary does not repeat the up-to-date label and the full version
list is not rendered in the initial view.
- Keep current/effective/default values visible as compact metadata and retain
the existing approval, preview, and job state wiring.
- Keep the initial preview free of body overflow on desktop and mobile. Allow
the shared body to scroll only for long version lists or streamed output,
while preserving 44px mobile actions and a reachable footer.
### Version browser
- Add a focused picker component beside the existing control for quick latest
and Kandev-default actions plus an explicit browse trigger.
- Use the shared command/search primitives for version filtering and 44px
selectable rows. Preserve latest, active, and default markers and call the
existing target/default callbacks.
- Keep the browser inside the existing dialog body/drawer so desktop and mobile
share selection state and one internal scroll owner.
### Localization
- Add the browse, search, quick-choice, and empty-search copy to all required
agent locale catalogues. Generate Traditional Chinese updates with the
repository i18n command.
## Tests
- **What:** The initial dialog exposes compact quick choices and does not render
all version rows.
**File:** `apps/web/components/settings/agent-runtime-update-control.test.tsx`.
**How:** Render a long preview and assert the browse trigger plus quick
actions; assert a distant version is absent before browsing.
- **What:** Searching the browser filters the catalogue and selecting a result
uses the existing target-preview callback.
**File:** `apps/web/components/settings/agent-runtime-update-control.test.tsx`.
**How:** Open the browser, fill the search field, assert matching/nonmatching
rows, then select the matching row and assert the callback.
- **What:** The browser works in the mobile drawer with contained long content.
**File:** `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`.
**How:** Use the mobile fixture to open the update drawer, browse/search, and
select a version while asserting no document horizontal overflow.
## E2E Tests
- **Scenario:** A long catalogue opens as a compact dialog with quick choices,
and browsing exposes searchable versions.
**File:** `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`.
**What to verify:** The summary and browse trigger are visible, the full list
appears only after browsing, and a search result can be selected.
- **Scenario:** The same browse/search/selection flow works on Pixel 5.
**File:** `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`.
**What to verify:** The drawer contains the browser, touch rows are reachable,
and document horizontal overflow remains zero.
## Verification Results
- `cd apps && pnpm --filter @kandev/web test -- --run components/settings/agent-runtime-update-control.test.tsx` passed (6 tests).
- `cd apps/web && pnpm run typecheck` passed.
- `cd apps/web && pnpm run i18n:check` passed; all five locale catalogues are complete.
- Changed-file ESLint passed with zero warnings or errors.
- `cd apps/web && pnpm e2e:run --project chromium e2e/tests/settings/agent-runtime-update.spec.ts` passed (15 tests).
- `cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts` passed (5 tests).
- `git diff --check` passed.
- The compactness refinement passed the rendered no-initial-overflow checks on
desktop and mobile; streamed output still produced a scrollable body.
- Fresh desktop and mobile preview screenshots were validated and compressed
for PR publication.
- The Traditional Chinese generator was attempted but refused to write because of the existing `dynamicProfileSettings` residual warning in both Traditional Chinese catalogues. The five new values were added in the generator's OpenCC-equivalent form, and the full i18n check passed afterward.
## Implementation Waves And Parallel Candidates
Wave 1, sequential:
- [x] [Task 01: Implement the compact searchable picker](task-01-compact-searchable-picker.md)
## Risks
- The picker must reuse the existing callbacks and not change backend target
validation or preview semantics.
- A mobile browser must not create a second page-level scroll container or
hide the primary update action behind an unreachable surface.
@@ -0,0 +1,84 @@
---
id: "01-compact-searchable-picker"
title: "Implement the compact searchable picker"
status: complete
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 01: Implement the compact searchable picker
Replace the always-visible native 50-option selector with compact latest and
Kandev-default quick choices plus an explicit searchable browser. Keep the
existing target preview, default reset, approval, and job lifecycle callbacks.
## Acceptance
- Opening the dialog with a long catalogue shows the status summary and quick
choices, not the full version history.
- Browsing exposes a searchable list with latest, active, and default markers;
selecting a result previews that exact target.
- Desktop and mobile preserve the same selection behavior, with one contained
scroll owner and touch-sized rows on mobile.
- Existing locales remain complete and the focused desktop/mobile tests pass.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/settings/agent-runtime-update-control.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:check
cd apps/web && pnpm e2e:run --project chromium e2e/tests/settings/agent-runtime-update.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts
```
## Files likely touched
- `apps/web/components/settings/agent-runtime-update-control.tsx`
- `apps/web/components/settings/runtime-version-picker.tsx`
- `apps/web/components/settings/agent-runtime-update-control.test.tsx`
- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`
- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`
- `apps/web/src/locales/en/agents.json`
- `apps/web/src/locales/pt-pt/agents.json`
- `apps/web/src/locales/zh-cn/agents.json`
- `apps/web/src/locales/zh-hk/agents.json`
- `apps/web/src/locales/zh-tw/agents.json`
- `docs/specs/agents/runtime-updates.md`
- `docs/plans/managed-runtime-version-picker/plan.md`
- `docs/plans/managed-runtime-version-picker/task-01-compact-searchable-picker.md`
## Dependencies
None.
## Parallelism
Sequential.
## Inputs
- Option 1 selected by the user: compact summary, quick latest/default
choices, explicit browse action, searchable complete catalogue.
- Existing `AgentRuntimeUpdateControl` callbacks and desktop/mobile surface.
- Mobile parity contract: one drawer body scroll owner and 44px touch rows.
## Output contract
Update the picker, localized copy, focused component/E2E coverage, and these
plan results. Report exact commands, changed files, and any remaining browser
verification blockers.
## Results
- Implemented the compact quick-choice picker and on-demand searchable version browser.
- Preserved preview/default callbacks and updated desktop/mobile selectors, rollback, reset, and status assertions.
- Added localized copy and regenerated the pseudo catalogue.
- Component test, typecheck, i18n check, changed-file lint, desktop E2E (15), mobile E2E (5), and `git diff --check` all pass.
- Traditional Chinese generation was blocked by the pre-existing `dynamicProfileSettings` residual warning; equivalent generated values were added manually and the catalog checks pass.
- Follow-up compactness refinement keeps the initial desktop dialog and mobile
drawer free of body overflow, while long streamed output remains scrollable.
- Fresh desktop and mobile screenshots were captured, inspected, mapped in the
PR asset manifest, and compressed before publication.
@@ -0,0 +1,64 @@
---
spec: docs/specs/agents/runtime-updates.md
created: 2026-08-22
status: completed
---
# Fix Plan: Anchored Managed Runtime Version Popover
## Root cause
`RuntimeVersionPicker` renders `RuntimeVersionBrowser` as a conditional block
inside the update body. The browser's command list therefore participates in
normal layout flow and expands the dialog or mobile drawer instead of behaving
like an anchored selector.
## Repair
Use the existing `@kandev/ui/popover` primitive around the version trigger and
render the searchable `Command` content in `PopoverContent`. Keep the existing
shared selection callbacks and close the popover after target selection. The
desktop dialog and mobile drawer must use the same popover state and catalogue;
only their trigger surface and available viewport geometry differ.
## Regression coverage
- Desktop: opening a long catalogue renders a `PopoverContent` outside the
dialog's layout subtree and leaves the dialog height unchanged.
- Mobile: opening the same catalogue renders the popover outside the drawer's
layout subtree, leaves the drawer height unchanged, keeps options at least
44px high, and preserves selection.
- Existing searchable selection and preview callback coverage remains green.
## Files
- `apps/web/components/settings/runtime-version-picker.tsx`
- `apps/web/components/settings/agent-runtime-update-control.test.tsx`
- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`
- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`
- `docs/specs/agents/runtime-updates.md`
## Verification
Completed on 2026-08-22:
- Component tests: 6 passed.
- Desktop managed-runtime E2E: 15 passed.
- Mobile managed-runtime E2E: 5 passed.
- Web typecheck passed.
- Web i18n check passed.
- Changed-file ESLint passed.
- `git diff --check` passed.
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/settings/agent-runtime-update-control.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:check
cd apps/web && pnpm e2e:run --project chromium e2e/tests/settings/agent-runtime-update.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts
```
## Out of scope
- Changing version resolution, selection semantics, or update job behavior.
- Replacing the shared dialog, drawer, command, or popover primitives.
@@ -0,0 +1,32 @@
---
id: "01-anchored-popover"
title: "Render the managed runtime catalogue in an anchored popover"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agents/runtime-updates.md"
---
# Task 01: Render the managed runtime catalogue in an anchored popover
## Acceptance
- The version trigger opens the searchable catalogue as a popover instead of
inserting the catalogue into dialog/drawer layout flow.
- Opening the catalogue does not increase the desktop dialog or mobile drawer
height.
- Desktop and mobile preserve searchable selection, preview callbacks, and
44px touch-safe option rows.
- Selecting a version closes the popover.
## TDD sequence
1. [x] Add desktop and mobile geometry assertions and confirm they fail against
the current inline browser.
2. [x] Wrap the trigger and browser with `@kandev/ui/popover`.
3. [x] Rerun the focused tests and refactor only after the behavior is green.
## Validation
Run the commands in `plan.md`, plus `git diff --check` and changed-file ESLint.
+18 -1
View File
@@ -49,6 +49,20 @@ The status shown on this page is authoritative for the current host. A CLI that
The update icon is available on managed Claude, Codex, OpenCode, Copilot, and
Gemini agent cards. It updates the runtime on the Kandev host.
Each managed runtime has a reviewed Kandev default. If you have not selected a
version, Kandev uses that exact default for probes, sessions, standalone
inference, containers, and SSH commands. A successful version update stores
your exact selection for this Kandev installation. The selection takes
precedence over the default until you choose **Use Kandev default**. Kandev
does not store the default as a user selection, so later Kandev releases can
move unmodified installations to their reviewed defaults.
When the cached npm check finds a newer stable release, the update control has
a blue dot and its accessible label includes the effective and latest
versions. The dot is only a hint. Opening the control performs a fresh,
authoritative preview before any update starts. If the check is unavailable,
the control has no dot but remains usable.
1. Select the update icon.
2. Review the current version, active version, available stable versions, and command.
3. Keep the latest version selected for a normal update, or select an older stable version to roll back.
@@ -65,7 +79,10 @@ configuration options, commands, and runtime version without a page reload.
- The active exact version survives Kandev and browser restarts.
- Later host-local probes, utility calls, and standalone sessions use the active exact version.
- Active sessions keep running. They are not restarted or hot-swapped.
- Passthrough agents, authentication helpers, remote executors, and running containers are unchanged.
- Passthrough agents and authentication helpers remain unchanged. The Settings
update job prepares the host runtime only; remote executor and new-container
commands still use the effective exact version and must resolve that package
in their own environment. Running sessions and containers are unchanged.
- If preparation, ACP validation, authentication, or persistence fails, Kandev keeps the previous active version and capability catalogue. Select another stable version or retry the same target.
- Kandev may prepare the exact version again if npm removes its cache entry. Kandev does not own an offline package inventory, and global npm cache cleanup is not required.
+204 -56
View File
@@ -1,11 +1,11 @@
---
status: approved
created: 2026-07-26
updated: 2026-08-16
updated: 2026-08-22
owner: Kandev
---
# Managed Agent Runtime Version Recovery
# Managed Agent Runtime Versions and Updates
## Why
@@ -15,6 +15,11 @@ partly published, incompatible with ACP, or otherwise cannot start. Rebuilding
an npm cache is not sufficient when an unversioned command selects the same
broken release again.
Operators also need to know that a newer managed agent bridge exists before
they open its version dialog. Kandev must provide a stable reviewed default for
unmodified installations without preventing an operator from selecting a newer
validated version between Kandev releases.
Managed runtimes can also fail when npm has stale package metadata. In that
case, npm can report `ETARGET` and `No matching version found` for a dependency
that the configured registry now contains. Kandev currently reports the later
@@ -35,13 +40,18 @@ action unless they inspect backend logs.
- Kandev stages the exact trusted `package@version`, ACP-probes that candidate,
and activates it only after a successful probe. Candidate failure preserves
the prior active version and capability catalogue.
- A successful activation persists the exact version for this Kandev install.
Later standalone host probes, utility calls, and local sessions use that
exact package spec. Active sessions continue unchanged.
- When no active selection exists, legacy host launches remain unversioned.
The first successful update, rollback, or repair establishes the durable
active version.
- SSH, containers, and other remote executors do not use the host selection.
- Every managed npm runtime has an exact Kandev default version. A successful
activation persists an operator-selected exact version for this Kandev
install. The effective version is the selected version when present and the
Kandev default otherwise.
- Kandev does not persist the default as a selection. An installation without
an operator selection follows default-pin changes delivered by later Kandev
releases.
- Every Kandev-built ACP command for the managed package uses the effective
exact version, including probes, utility calls, standalone sessions,
containers, and SSH executors. Active sessions continue unchanged.
- Settings lets the operator clear the selected version and return to the
Kandev default after that default passes the normal candidate validation.
- Package name, ACP arguments, and command shape remain trusted built-in
metadata. A caller can submit only a version returned by the trusted
package's npm metadata. Tags, prereleases, package specs, registry URLs, and
@@ -57,6 +67,29 @@ action unless they inspect backend logs.
and retries the same trusted package and version once.
- If the retry also fails, Kandev reports an npm runtime preparation error and
offers one runtime retry action. The user does not need to understand ACP.
- When the Agents settings page loads, Kandev checks each available managed
package against npm's stable `latest` version through one batch status
request. A newer version marks the existing update control with a blue dot
and accessible update information.
- An update-status check is read-only. It never prepares, probes, selects, or
starts a runtime. Registry failure leaves the status unknown, keeps the update
control usable, and does not show a false up-to-date result.
- Kandev caches successful registry status checks for six hours per package and
failed checks for fifteen minutes. Opening the version dialog still performs
an authoritative live preview.
- The authoritative preview normalizes valid stable package metadata returned
by supported npm CLI versions, including npm versions that wrap a
multi-field `npm view` response in a one-element array. A valid response
always produces the same stable version catalogue for the operator.
- The version dialog presents a compact status summary and quick choices for
the latest and Kandev default versions. The complete stable catalogue stays
behind an explicit browse action and can be searched by version.
- The initial preview fits within the desktop dialog and phone drawer without
body overflow. The shared body becomes scrollable only when the version
browser or streamed job output needs more space.
- Opening the version browser uses an anchored popover. The popover must not
increase the dialog or drawer height, must remain viewport-contained, and
must close after a version is selected.
The managed package set is:
@@ -76,23 +109,33 @@ Decision:
## Version and operation semantics
Kandev distinguishes three version values:
Kandev distinguishes six version values:
- `current_version` is the version reported by the last successful host ACP
probe. It can be absent after a failed probe.
- `active_version` is the exact version persisted for future host-local
managed-runtime commands. It can be absent before the first activation.
- `default_version` is the exact reviewed version shipped with Kandev.
- `active_version` is the exact operator-selected version persisted for future
managed-runtime commands. It is absent when the operator follows the Kandev
default.
- `effective_version` is `active_version` when present and `default_version`
otherwise. It is never empty for a supported managed runtime.
- `latest_version` is npm's stable `latest` version from the most recent update
status check or authoritative preview. It is absent when that check fails.
- `target_version` is the stable version selected by the operator.
The backend derives the operation from `active_version` when present and
otherwise from `current_version`:
The backend derives the operation from `effective_version` and
`current_version`. When an active operator selection exists and the operator
chooses `default_version`, `use_default` takes precedence over version
comparison. Without that reset condition, the backend classifies the request as
`update`, `rollback`, `repair`, or `up_to_date`:
| Condition | Operation | Approval |
| --- | --- | --- |
| Target is newer than the effective version. | `update` | **Update runtime** |
| Target is older than the effective version. | `rollback` | **Roll back runtime** |
| Current is unknown, versions cannot be compared, or the observed version matches but has not been activated exactly. | `repair` | **Repair runtime** |
| Active, current, and target versions match. | `up_to_date` | Disabled as **Up to date** |
| Current is unknown or versions cannot be compared. | `repair` | **Repair runtime** |
| Effective, current, and target versions match. | `up_to_date` | Disabled as **Up to date** |
| An active operator selection exists and the operator chooses the shipped default. | `use_default` | **Use Kandev default** |
Only strict stable SemVer values from npm's published version list are
selectable. Kandev does not offer prereleases. Version ordering and operation
@@ -110,13 +153,45 @@ Installed-agent catalogue entries expose optional runtime-management metadata:
"supported": true,
"package": "opencode-ai",
"current_version": "1.18.5",
"active_version": "1.18.5"
"default_version": "1.18.5",
"effective_version": "1.18.5"
}
}
```
`current_version` and `active_version` are independently omitted when unknown
or not yet established. Unmanaged agents omit `runtime_update`.
or not selected. `default_version` and `effective_version` are required when
`supported` is true. Unmanaged agents omit `runtime_update`.
### Update status
- `GET /api/v1/agent-update/status` returns one status for every available
built-in managed runtime.
- The backend queries stale package entries concurrently with a fixed bound and
reuses fresh per-package cache entries.
- One package lookup failure does not fail the full response.
```json
{
"statuses": [
{
"agent_name": "opencode-acp",
"package": "opencode-ai",
"default_version": "1.18.5",
"effective_version": "1.18.5",
"latest_version": "1.18.16",
"check_state": "update_available",
"checked_at": "timestamp"
}
]
}
```
`check_state` is `update_available`, `up_to_date`, or `unknown`. The backend
sets `update_available` only when strict stable SemVer ordering proves that
`latest_version` is newer than `effective_version`. It does not flag an older
upstream version as an update. An unknown entry omits `latest_version` and
`checked_at` when no successful cached value exists.
### Preview and jobs
@@ -124,8 +199,12 @@ or not yet established. Unmanaged agents omit `runtime_update`.
and previews the upstream latest stable target.
- `GET /api/v1/agent-update/:agentName/preview?target_version=1.18.5`
validates and previews one selected version without mutation.
- `GET /api/v1/agent-update/:agentName/preview?use_default=true` previews
returning to the trusted shipped default.
- `POST /api/v1/agent-update/:agentName` accepts JSON
`{ "target_version": "1.18.5" }` and starts or returns the active job.
`{ "target_version": "1.18.5" }` or `{ "use_default": true }` and starts or
returns the active job. `use_default` resolves the target from trusted agent
metadata; callers do not supply both fields.
- `GET /api/v1/agent-update/jobs` and
`GET /api/v1/agent-update/jobs/:id` retain their current polling behavior.
- State-changing requests use the Settings mutation interlock.
@@ -137,7 +216,8 @@ A preview contains:
"agent_name": "opencode-acp",
"package": "opencode-ai",
"current_version": "1.18.5",
"active_version": "1.18.5",
"default_version": "1.18.5",
"effective_version": "1.18.5",
"target_version": "1.18.16",
"operation": "update",
"available_versions": [
@@ -174,6 +254,8 @@ authoritative operation and active version:
"operation": "rollback",
"current_version": "1.18.16",
"active_version": "1.18.16",
"default_version": "1.18.5",
"effective_version": "1.18.16",
"target_version": "1.18.5",
"output": "",
"error": "",
@@ -183,8 +265,11 @@ authoritative operation and active version:
}
```
`active_version` reflects the persisted selection at that job snapshot. It
changes to the target only after successful validation and persistence. The
`active_version` reflects the persisted operator selection at that job
snapshot. `effective_version` reflects the version future managed commands use.
For a selected target, both become the target only after successful validation
and persistence. For `use_default`, `active_version` is removed and
`effective_version` becomes `default_version` only after successful validation. The
existing `agent.update.started`, `agent.update.output`, and
`agent.update.finished` WebSocket notifications carry the same job fields.
@@ -193,37 +278,38 @@ existing `agent.update.started`, `agent.update.output`, and
| State | Backend behavior | Observable behavior |
| --- | --- | --- |
| `queued` | Accept the selected version and maintenance claim. | The version picker and action are disabled. |
| `resolving` | Re-read npm versions, validate the exact target, and classify the operation. | The UI shows the selected target and resolving progress. |
| `resolving` | Re-read npm versions, resolve the requested exact target or trusted default, and classify the operation. | The UI shows the selected target and resolving progress. |
| `updating` | Prepare `package@target` in its version-specific npm execution tree. On first failure, invalidate only that exact tree and retry once. | Bounded stdout and stderr stream into the dialog. |
| `refreshing` | Probe the candidate command without replacing cached capabilities. On success, persist the target and then publish the candidate capabilities. | The UI explains that Kandev is validating before activation. |
| `refreshing` | Probe the candidate command without replacing cached capabilities. On success, save the selected target or delete the selection for `use_default`, then publish the candidate capabilities. | The UI explains that Kandev is validating before activation. |
| `succeeded` | The exact target is active and its capabilities are published. | The catalogue and models refresh without a page reload. |
| `failed` | Resolution, preparation, probe, or persistence failed. The previous active version and capabilities remain unchanged. | The UI shows the captured error and permits a new selection or retry. |
| `failed` | Resolution, preparation, probe, or persistence failed. The previous effective version and capabilities remain unchanged. | The UI shows the captured error and permits a new selection or retry. |
Jobs are terminal after `succeeded` or `failed`. Selecting the active, healthy
version produces `up_to_date` in preview and starts no job. Selecting the active
version while its current probe is unknown produces a repair job.
Jobs are terminal after `succeeded` or `failed`. Selecting the effective,
healthy version produces `up_to_date` in preview and starts no job. Returning
to the default still validates that default before deleting an existing
selection. Selecting the effective version while its current probe is unknown
produces a repair job.
## Host command routing
## Command routing
- A standalone managed-agent launch reads the active selection immediately
before building its command and passes the exact version through trusted
command options.
- Boot probes, manual capability refreshes, model-configuration resolution, and
sessionless utility prompts use the same active-version resolver.
- Every managed-agent command resolves the effective version immediately before
building its command and emits the trusted `package@effective_version` spec.
- Boot probes, manual capability refreshes, model-configuration resolution,
sessionless utility prompts, standalone sessions, containers, and SSH
executors use the same effective-version resolver.
- Native-binary preference continues to win when an agent deliberately selects
its supported native binary path.
- Passthrough command construction does not receive or apply the active managed
ACP version. It continues to use the agent's declared interactive CLI.
- SSH and container command builders receive no host version override.
- A saved selection read error fails the new host command with an actionable
error. It does not fall back to an unversioned package.
- A saved selection read error fails the new managed command with an actionable
error. It does not fall back to the default or an unversioned package.
- Candidate validation bypasses the active selection only for the trusted exact
candidate command created by the version job.
- No supported managed-runtime command emits an unversioned package spec.
## Launch-time stale metadata recovery
- Normal host-local managed runtime commands continue to use
`--prefer-offline`.
- Normal managed runtime commands continue to use `--prefer-offline`.
- Kandev inspects bounded process stderr when ACP initialization ends before a
response. Automatic recovery requires both npm `ETARGET` and a matching
`No matching version found for package@version` message.
@@ -232,7 +318,7 @@ version while its current probe is unknown produces a repair job.
- Recovery removes only the deterministic `_npx` execution tree for the
trusted built-in package specification. It never clears npm's full cache.
- Kandev starts the same managed runtime once with `--prefer-online`. The
command keeps the same trusted package, exact active version when one exists,
command keeps the same trusted package, exact effective version,
ACP arguments, configured npm registry, command prefix, permissions, model,
and session identity.
- Recovery is limited to one retry for each launch attempt. A delayed event
@@ -250,6 +336,9 @@ version while its current probe is unknown produces a repair job.
## Failure and recovery behavior
- Registry failure during preview keeps approval disabled and runs no command.
- A valid package metadata response in a supported npm CLI output shape is not
treated as a registry failure. Malformed, empty, or ambiguous metadata still
fails closed and keeps approval disabled.
- Registry failure or target disappearance after approval fails before staging.
- Preparation failure invalidates only the deterministic `_npx` tree for the
exact `package@version` and retries once. Kandev never runs a global npm cache
@@ -264,20 +353,32 @@ version while its current probe is unknown produces a repair job.
- Active sessions are never restarted, replaced, or hot-swapped.
- A launch-time stale metadata retry is not a version rollback. It prepares the
same package selection again and does not change the active version.
- Registry failure during the batch update-status check returns `unknown` for
only the affected package. It does not disable the update control, show an
error badge, or claim that the package is up to date.
- A stale update-available dot can remain until the successful six-hour cache
entry expires. Opening the dialog always replaces that hint with a live
authoritative preview. A successful update or reset invalidates the affected
status cache and refreshes the page state.
## Persistence guarantees
- The trusted package identity and active version are stored install-wide per
built-in agent in the system settings store and survive backend and browser
- The trusted package identity and operator-selected version are stored
install-wide per built-in agent in the system settings store and survive backend and browser
restarts. A record whose package no longer matches the agent's built-in
metadata is treated as having no active selection and is not applied to the
replacement package.
- The active version is written only after successful candidate validation, so
it is also the last known good selection.
metadata is treated as having no active selection; the replacement package's
reviewed default becomes effective.
- The Kandev default is compiled into the managed runtime catalogue and is not
copied into the settings database. Clearing a selection deletes its settings
record only after the default candidate passes validation.
- An operator-selected version is written only after successful candidate
validation, so it is also the last known good selection.
- Jobs, process output, and capability cache remain process-local and do not
survive a backend restart.
- Update-status results and timestamps are process-local cache entries. They do
not survive a backend restart and do not require a database migration.
- npm's execution cache is best-effort and is not Kandev-owned inventory. If an
exact selected cache entry disappears, npm may prepare that same exact
exact effective cache entry disappears, npm may prepare that same exact
version again; it must not advance to another version.
- Dialog selection, output, and result remain page-local after a browser page
restart.
@@ -289,16 +390,27 @@ version while its current probe is unknown produces a repair job.
- The existing agent-card update icon remains the entry point on desktop and
mobile and keeps a minimum 44 px touch target.
- When `check_state` is `update_available`, the update icon shows a blue dot.
The button's accessible name and desktop tooltip state the current effective
and latest versions, so color is not the only signal. The dot is absent for
`up_to_date` and `unknown`.
- Desktop uses the existing dialog. Phone layouts use the existing bottom
drawer; no nested drawer is introduced.
- The version selector is inside the shared body, is keyboard and touch
accessible, and shows latest and active markers without encoding state in
color alone.
- The compact dialog does not render the complete catalogue by default. The
browse surface exposes the complete list only after the operator requests it,
provides a search field, and keeps the selected, latest, active, and default
markers available.
- The body is the single internal scroll owner. The safe-area-aware footer
keeps the operation action reachable while long version lists and process
output remain viewport-contained.
- Selection state, preview loading, operation labels, request payloads, and
terminal results are shared across desktop and mobile presentations.
- Tapping the dotted control on a phone opens the same version drawer and shows
the authoritative version summary. The dot itself is not a separate touch
target and does not depend on hover.
- A failed automatic launch retry uses the existing inline recovery card in
Kanban chat and Office chat. It does not open a dialog or drawer.
- The card states that npm could not prepare the agent runtime. It states that
@@ -317,26 +429,60 @@ version while its current probe is unknown produces a repair job.
**Roll back runtime**, **THEN** Kandev prepares and probes that exact version,
persists it only after success, and restores its model list without restart.
- **GIVEN** a healthy exact active version, **WHEN** Kandev restarts, **THEN**
boot probes and later standalone sessions use the same exact version.
boot probes and later managed commands use the same exact version.
- **GIVEN** an agent has no operator selection, **WHEN** Kandev builds any of
its managed npm ACP commands, **THEN** the command uses the exact reviewed
Kandev default and never an unversioned package spec.
- **GIVEN** an agent has a validated operator selection, **WHEN** Kandev builds
a local, container, or SSH managed npm ACP command, **THEN** the command uses
that exact selection instead of the Kandev default.
- **GIVEN** an operator selection exists, **WHEN** the operator chooses **Use
Kandev default**, **THEN** Kandev validates the exact default, deletes the
selection only after success, and future commands follow shipped defaults.
- **GIVEN** a candidate fails ACP initialization, **WHEN** the job ends,
**THEN** the previous active version and capabilities remain authoritative.
- **GIVEN** the current version is unknown and there is no active selection,
- **GIVEN** the current version is unknown and the effective version is known,
**WHEN** the operator selects a published target, **THEN** the UI offers
**Repair runtime** and validation establishes the first exact active version.
- **GIVEN** active, current, and target versions match, **WHEN** the dialog
**Repair runtime** and validation establishes the selected exact version.
- **GIVEN** effective, current, and target versions match, **WHEN** the dialog
opens, **THEN** it shows **Up to date** and starts no job.
- **GIVEN** a different target is submitted while a job is active for the same
agent, **WHEN** the backend receives it, **THEN** it returns the existing job
and does not run a second candidate concurrently.
- **GIVEN** an SSH or container session, **WHEN** the host active version
changes, **THEN** that executor's command remains unchanged.
- **GIVEN** an agent whose interactive passthrough CLI is separate from its
managed ACP adapter, **WHEN** the host active ACP version changes, **THEN**
managed ACP adapter, **WHEN** the effective ACP version changes, **THEN**
later passthrough sessions still launch the declared interactive CLI and do
not launch the ACP package under a PTY.
- **GIVEN** a phone viewport and a long version catalogue or process log,
**WHEN** the operator selects and activates a version, **THEN** the drawer
remains contained and the primary action remains touch-reachable.
- **GIVEN** npm reports a newer stable version than the effective version,
**WHEN** the Agents settings page loads, **THEN** the existing update control
shows a blue dot and exposes both versions in accessible update information.
- **GIVEN** the update-status lookup fails for one managed package, **WHEN** the
Agents settings page loads, **THEN** that package shows no blue dot, its
update control remains usable, and other package statuses still render.
- **GIVEN** npm returns valid stable package metadata in either its object form
or a supported one-element collection form, **WHEN** the operator opens a
managed runtime update dialog, **THEN** the dialog lists the stable versions,
selects npm's stable latest version, and does not show a resolution error.
- **GIVEN** a managed runtime has a long stable version catalogue, **WHEN** the
operator opens its update dialog, **THEN** the dialog shows the status summary
and quick choices without rendering the full version history.
- **GIVEN** the operator opens the full version browser and enters a version
fragment, **WHEN** matching versions exist, **THEN** only matching stable
versions remain selectable and selecting one previews that exact target.
- **GIVEN** the operator opens the full version browser on a phone, **WHEN** the
operator searches or selects a version, **THEN** the existing update drawer
keeps one contained scroll owner, exposes 44px touch rows, and preserves the
same target selection behavior as desktop.
- **GIVEN** the operator opens a long version catalogue, **WHEN** the operator
opens the version selector, **THEN** the catalogue appears in an anchored
popover without increasing the dialog or drawer height, and selecting a
version closes the popover.
- **GIVEN** a dotted update control on a phone, **WHEN** the operator taps it,
**THEN** the existing update drawer opens and shows a live authoritative
preview without requiring hover.
- **GIVEN** a host-local managed runtime exits before ACP initialization with
npm `ETARGET` and a matching missing dependency version, **WHEN** Kandev
reads the captured stderr, **THEN** it removes only the trusted package's
@@ -355,12 +501,14 @@ version while its current probe is unknown produces a repair job.
## Out of scope
- Scheduled or automatic updates and automatic rollback after launch failure.
- Automatic runtime installation, automatic operator-selection changes, and
automatic rollback after launch failure.
- Global npm cache cleanup, registry replacement, dependency substitution, or
automatic selection of another package version.
- Prerelease, tag, arbitrary package-spec, registry, or shell-command input.
- Kandev-owned npm artifact retention or a package lockfile.
- Applying host selections to SSH, container, or other remote runtimes.
- Removing npm or network access from the launch path, or locking transitive
dependency ranges inside upstream packages.
- Restarting or hot-swapping active sessions.
- Native-only update channels and separately distributed passthrough or
authentication packages.
+162
View File
@@ -0,0 +1,162 @@
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const DEFAULT_CATALOGUE_PATH = path.resolve(
SCRIPT_DIR,
"../apps/backend/internal/agent/agents/managed_npm_runtime_versions.json",
);
const STABLE_SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
export function parseStableVersion(value) {
if (typeof value !== "string" || !STABLE_SEMVER.test(value)) {
throw new Error(`value is not a stable SemVer: ${String(value)}`);
}
return value;
}
function assertCatalogue(catalogue) {
if (!catalogue || typeof catalogue !== "object" || Array.isArray(catalogue)) {
throw new Error("runtime pin catalogue must be a JSON object");
}
const packages = Object.keys(catalogue);
if (packages.length === 0) {
throw new Error("runtime pin catalogue is empty");
}
for (const packageName of packages) {
if (!packageName.trim()) {
throw new Error("runtime pin catalogue contains an empty package name");
}
parseStableVersion(catalogue[packageName]);
}
}
export function updatePins(catalogue, latestByPackage) {
assertCatalogue(catalogue);
if (!latestByPackage || typeof latestByPackage !== "object") {
throw new Error("latest version lookup did not return an object");
}
const next = {};
let changed = false;
for (const packageName of Object.keys(catalogue)) {
const latest = latestByPackage[packageName];
if (latest === undefined || latest === null || latest === "") {
throw new Error(`latest version is missing for ${packageName}`);
}
parseStableVersion(latest);
next[packageName] = latest;
changed ||= latest !== catalogue[packageName];
}
return { catalogue: next, changed };
}
export function formatCatalogue(catalogue) {
assertCatalogue(catalogue);
return `${JSON.stringify(catalogue, Object.keys(catalogue).sort(), 2)}\n`;
}
async function fetchLatest(packageName, fetchImpl) {
const response = await fetchImpl(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
{ headers: { accept: "application/json" } },
);
if (!response.ok) {
throw new Error(`npm lookup for ${packageName} returned HTTP ${response.status}`);
}
const metadata = await response.json();
const latest = metadata?.["dist-tags"]?.latest;
parseStableVersion(latest);
return latest;
}
export async function fetchLatestVersions(packages, fetchImpl = globalThis.fetch) {
if (typeof fetchImpl !== "function") {
throw new Error("fetch is unavailable");
}
const pairs = await Promise.all(
packages.map(async (packageName) => [packageName, await fetchLatest(packageName, fetchImpl)]),
);
return Object.fromEntries(pairs);
}
export async function readCatalogue(cataloguePath = DEFAULT_CATALOGUE_PATH) {
const raw = await fs.readFile(cataloguePath, "utf8");
const catalogue = JSON.parse(raw);
assertCatalogue(catalogue);
return catalogue;
}
export async function writeCatalogueAtomically(cataloguePath, catalogue) {
const directory = path.dirname(cataloguePath);
const temporaryPath = path.join(
directory,
`.${path.basename(cataloguePath)}.${process.pid}.tmp`,
);
await fs.writeFile(temporaryPath, formatCatalogue(catalogue), "utf8");
await fs.rename(temporaryPath, cataloguePath);
}
export async function updateCatalogue({
cataloguePath = DEFAULT_CATALOGUE_PATH,
fetchImpl = globalThis.fetch,
} = {}) {
const catalogue = await readCatalogue(cataloguePath);
const latestByPackage = await fetchLatestVersions(Object.keys(catalogue), fetchImpl);
const result = updatePins(catalogue, latestByPackage);
if (result.changed) {
await writeCatalogueAtomically(cataloguePath, result.catalogue);
}
return result;
}
function outputChanged(outputPath, changed) {
if (!outputPath) {
return;
}
return fs.appendFile(outputPath, `changed=${changed ? "true" : "false"}\n`);
}
export function parseArguments(argv) {
let cataloguePath = DEFAULT_CATALOGUE_PATH;
let outputPath = "";
let positionalPathSeen = false;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--github-output") {
outputPath = argv[index + 1] ?? "";
if (!outputPath || outputPath.startsWith("--")) {
throw new Error("--github-output requires a path");
}
index += 1;
continue;
}
if (argument.startsWith("--")) {
throw new Error(`unknown option: ${argument}`);
}
if (positionalPathSeen) {
throw new Error("only one catalogue path is supported");
}
cataloguePath = argument;
positionalPathSeen = true;
}
return { cataloguePath, outputPath };
}
async function main(argv = process.argv.slice(2)) {
const { cataloguePath, outputPath } = parseArguments(argv);
const result = await updateCatalogue({ cataloguePath });
await outputChanged(outputPath, result.changed);
process.stdout.write(result.changed ? "Managed runtime pins changed.\n" : "Managed runtime pins are current.\n");
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(`Managed runtime pin update failed: ${error.message}`);
process.exitCode = 1;
});
}
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_CATALOGUE_PATH,
parseStableVersion,
parseArguments,
updatePins,
formatCatalogue,
} from "./update-agent-runtime-pins.mjs";
const catalogue = {
"@example/claude": "1.0.0",
"opencode-ai": "2.0.0",
};
test("updates only existing trusted catalogue entries", () => {
const result = updatePins(catalogue, {
"@example/claude": "1.1.0",
"opencode-ai": "2.0.0",
"@example/untrusted": "9.9.9",
});
assert.equal(result.changed, true);
assert.deepEqual(result.catalogue, {
"@example/claude": "1.1.0",
"opencode-ai": "2.0.0",
});
});
test("does not report a change when every latest value matches", () => {
const result = updatePins(catalogue, {
"@example/claude": "1.0.0",
"opencode-ai": "2.0.0",
});
assert.equal(result.changed, false);
assert.deepEqual(result.catalogue, catalogue);
});
test("rejects prerelease, malformed, and missing latest values before rewriting", () => {
for (const latest of ["1.1.0-beta.1", "latest", "", undefined]) {
assert.throws(
() => updatePins(catalogue, { "@example/claude": latest, "opencode-ai": "2.0.0" }),
/stable SemVer|latest version/i,
);
}
});
test("rejects an invalid current catalogue pin", () => {
assert.throws(
() => updatePins({ "@example/claude": "1.0.0-beta.1" }, { "@example/claude": "1.1.0" }),
/stable SemVer/i,
);
});
test("accepts build metadata but not prerelease syntax", () => {
assert.equal(parseStableVersion("1.2.3+build.7"), "1.2.3+build.7");
assert.throws(() => parseStableVersion("1.2.3-rc.1"), /stable SemVer/i);
});
test("parses the workflow output flag before the default catalogue path", () => {
assert.deepEqual(parseArguments(["--github-output", "/tmp/github-output"]), {
cataloguePath: DEFAULT_CATALOGUE_PATH,
outputPath: "/tmp/github-output",
});
});
test("formats the catalogue as one complete atomic-write payload", () => {
assert.equal(
formatCatalogue({ "@example/claude": "1.1.0", "opencode-ai": "2.0.0" }),
'{\n "@example/claude": "1.1.0",\n "opencode-ai": "2.0.0"\n}\n',
);
});