From 1b0b34fad8433d2de5051bac609dcedf7c50cdce Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 25 Aug 2026 22:34:58 +0200 Subject: [PATCH] fix(quick-pane): restore model catalog controls --- src/bridge/agent-registry.js | 59 ++++++--- test/agent-registry.test.js | 54 ++++++++ test/multi-session-registry.test.js | 24 ++-- test/quick-pane-ui.test.js | 54 +++++--- ui/src/App.test.tsx | 82 ++++++++++++- ui/src/App.tsx | 134 ++++++++++++++------ ui/src/model-catalog.ts | 56 +++++++++ ui/src/quick-pane/QuickPane.test.tsx | 118 ++++++++++++++++++ ui/src/quick-pane/QuickPane.tsx | 176 +++++++++++++++++---------- ui/src/quick-pane/quick-pane.css | 119 ++++++++++++++---- 10 files changed, 706 insertions(+), 170 deletions(-) create mode 100644 ui/src/model-catalog.ts create mode 100644 ui/src/quick-pane/QuickPane.test.tsx diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index b6b314d..9659a64 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -273,6 +273,7 @@ export function createAgentRegistry({ let workspaceSequence = 0; let manifestIssue; let canonicalHomeWorktree; + let lastSuccessfulModelCatalog; let stopping = false; function publishWorkspace(type, runtime, data = {}) { @@ -307,13 +308,13 @@ export function createAgentRegistry({ runtimes: [...runtimesByRuntimeId.values()] .filter((runtime) => !runtime.ephemeral) .map((runtime) => ({ - runtimeId: runtime.runtimeId, - worktreePath: runtime.worktreePath, - ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}), - ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}), - ...(runtime.legacyDefault ? { legacyDefault: true } : {}), - openedAt: runtime.openedAt, - })), + runtimeId: runtime.runtimeId, + worktreePath: runtime.worktreePath, + ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}), + ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}), + ...(runtime.legacyDefault ? { legacyDefault: true } : {}), + openedAt: runtime.openedAt, + })), }; } @@ -986,7 +987,9 @@ export function createAgentRegistry({ } if (stopConfirmed) releaseClosedSessionLease(runtime); if (runtime.ephemeral && stopConfirmed) - await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {}); + await rm(runtime.sessionDir, { recursive: true, force: true }).catch( + () => {}, + ); runtime.state = "stopped"; publishWorkspace("runtime_closed", runtime, { runtimeId: runtime.runtimeId, @@ -1160,6 +1163,15 @@ export function createAgentRegistry({ }); await restoreRecords(records); } + if ( + !loaded.issue && + ![...runtimesByAgentId.values()].some((runtime) => runtime.adapter) + ) { + await openRuntime({ + worktreePath: canonicalHomeWorktree, + legacyDefault: true, + }); + } return this.listAgents()[0]; }, async selectWorktree(worktreePath) { @@ -1206,14 +1218,31 @@ export function createAgentRegistry({ return closeRuntime(runtimeId); }, async getModelCatalog() { - const runtime = [...runtimesByAgentId.values()].find( - (candidate) => candidate.adapter, + const candidates = [...runtimesByAgentId.values()].filter( + (runtime) => + runtime.adapter && + !runtime.stopped && + !["closing", "stopped", "failed"].includes(runtime.state), ); - if (!runtime?.adapter) return []; - const response = await enqueue(runtime, () => - runtime.adapter.send({ type: "get_available_models" }), - ); - return response?.data?.models ?? []; + let lastError; + for (const runtime of candidates) { + const adapter = runtime.adapter; + try { + const response = await enqueue(runtime, () => + adapter.send({ type: "get_available_models" }), + ); + if (response?.success !== true || !Array.isArray(response?.data?.models)) { + continue; + } + lastSuccessfulModelCatalog = response.data.models; + return response.data.models; + } catch (error) { + lastError = error; + } + } + if (lastSuccessfulModelCatalog) return lastSuccessfulModelCatalog; + if (lastError) throw lastError; + return []; }, listAgents() { return [...runtimesByAgentId.values()] diff --git a/test/agent-registry.test.js b/test/agent-registry.test.js index f9c5245..9279598 100644 --- a/test/agent-registry.test.js +++ b/test/agent-registry.test.js @@ -78,6 +78,60 @@ test("starts only the home agent and creates other worktree agents on explicit s assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped)); }); +test("model catalog retries unsuccessful responses and falls back to last success", async () => { + const worktrees = await createWorktrees(); + const fixture = createAdapterFactory(); + const registry = createAgentRegistry({ + homeWorktree: worktrees.home, + sessionRoot: worktrees.sessionRoot, + startAdapter: fixture.startAdapter, + }); + await registry.start(); + await registry.selectWorktree(worktrees.feature); + const expected = [{ provider: "provider", id: "family/model" }]; + const rejected = [{ provider: "wrong", id: "must-not-be-cached" }]; + const sends = fixture.calls.map(({ adapter }) => adapter.send.bind(adapter)); + let homeCatalogCalls = 0; + let featureCatalogCalls = 0; + fixture.calls[0].adapter.send = async (command, options) => { + if (command.type === "get_available_models") { + homeCatalogCalls += 1; + return { + type: "response", + success: false, + data: { models: rejected }, + }; + } + return sends[0](command, options); + }; + fixture.calls[1].adapter.send = async (command, options) => { + if (command.type === "get_available_models") { + featureCatalogCalls += 1; + return { type: "response", success: true, data: { models: expected } }; + } + return sends[1](command, options); + }; + + assert.deepEqual(await registry.getModelCatalog(), expected); + assert.equal(homeCatalogCalls, 1); + assert.equal(featureCatalogCalls, 1); + fixture.calls[1].adapter.send = async (command, options) => { + if (command.type === "get_available_models") { + featureCatalogCalls += 1; + return { + type: "response", + success: true, + data: { models: "malformed" }, + }; + } + return sends[1](command, options); + }; + assert.deepEqual(await registry.getModelCatalog(), expected); + assert.equal(homeCatalogCalls, 2); + assert.equal(featureCatalogCalls, 2); + await registry.stop(); +}); + test("sends abort without waiting behind a blocked prompt", async () => { const worktrees = await createWorktrees(); const fixture = createAdapterFactory(); diff --git a/test/multi-session-registry.test.js b/test/multi-session-registry.test.js index 33d542f..3de2843 100644 --- a/test/multi-session-registry.test.js +++ b/test/multi-session-registry.test.js @@ -987,7 +987,7 @@ test("closing retains a saved-session lease until the child stops", async () => await registry.stop(); }); -test("first migration retains a missing legacy session as failed dormant intent", async () => { +test("first migration retains missing intent and starts an idle home runtime", async () => { const paths = await fixture(); const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home); const missing = join(sessionDir, "missing.jsonl"); @@ -1003,14 +1003,18 @@ test("first migration retains a missing legacy session as failed dormant intent" startAdapter: adapters.startAdapter, }); await registry.start(); - const runtime = registry.getWorkspace().directories[0].runtimes[0]; - assert.equal(runtime.state, "failed"); - assert.equal(runtime.sessionPath, missing); - assert.equal(adapters.calls.length, 0); + const runtimes = registry.getWorkspace().directories[0].runtimes; + const failed = runtimes.find((runtime) => runtime.state === "failed"); + const live = runtimes.find((runtime) => runtime.state === "idle"); + assert.equal(failed.sessionPath, missing); + assert.equal(live.worktreePath, paths.home); + assert.equal(adapters.calls.length, 1); const manifest = await readJson( join(paths.sessionRoot, "bridge-workspace-v2.json"), ); - assert.equal(manifest.runtimes[0].sessionPath, missing); + assert.ok( + manifest.runtimes.some((runtime) => runtime.sessionPath === missing), + ); await registry.stop(); }); @@ -1198,7 +1202,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async await registry.stop(); }); -test("closing the last runtime persists a zero-runtime restart", async () => { +test("restoration with no live adapter starts one idle home runtime", async () => { const paths = await fixture(); const firstAdapters = adapterFactory(); const first = createAgentRegistry({ @@ -1216,8 +1220,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => { startAdapter: restoredAdapters.startAdapter, }); await restored.start(); - assert.equal(restored.getWorkspaceSummary().openCount, 0); - assert.equal(restoredAdapters.calls.length, 0); + assert.equal(restored.getWorkspaceSummary().openCount, 1); + assert.equal(restoredAdapters.calls.length, 1); + assert.equal(restored.listAgents()[0].worktreePath, paths.home); + assert.equal(restored.listAgents()[0].state, "idle"); await restored.stop(); }); diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index d4c6ae3..8c50884 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -9,32 +9,34 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async () ); assert.match(source, /get_session_runtime_snapshot/); assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/); - assert.match( - source, - /
\{answer\}<\/pre>/,
-  );
-  assert.match(
-    source,
-    /\{\(escalation \|\| error\) && \(\s*