From a162573891671f0f67855ab0f0ac6613e496a1ad Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Sun, 8 Nov 2026 12:11:54 -0500 Subject: [PATCH] fix(app): composer reloads a stale-empty agent/provider catalog before refusing a send The directory bootstrap loads the agent list once; if that request failed (engine mid re-init, e.g. while a rebuild re-stages its config) the list stayed empty for the window's lifetime and every send was refused with "Select an agent and model" even though a model was picked. The send guard now reloads the catalog once (agents fresh + all providers queries, single-flight, 8 s bound) and re-reads before refusing; the refusal names what is missing (agents didn't load / selected model's provider isn't connected). The agent is never defaulted to build. --- packages/app-bundle/manifest.json | 16 +- .../prompt-input/catalog-recovery.test.ts | 158 ++++++++++++++++++ .../prompt-input/catalog-recovery.ts | 67 ++++++++ .../app/src/components/prompt-input/submit.ts | 19 ++- .../packages/app/src/context/server-sync.tsx | 19 +++ .../overlay/packages/app/src/i18n/en.ts | 6 + 6 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index f61f9eff2..aa2f89cbd 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -234,7 +234,7 @@ "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea", "packages/app/src/context/server-session.test.ts": "b332098635885211bccaec0d7da8ca6b60696b04bd3f89ba3fcd97cd4c881856", "packages/app/src/context/server-session.ts": "ad486fc5d36f93f5f53d51b70fdf191a3008b67f2b280dfc58b64e6d8d20982d", - "packages/app/src/context/server-sync.tsx": "a8dad0dad76a402e6c4f912bba1d0a2018bf8885f30ac8fcf03b840f3bebc29e", + "packages/app/src/context/server-sync.tsx": "fdd4775a71348af39ac274dd1bf6a86477b151d35132bce3db8fe9e403ed3712", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/session-mirror.test.ts": "65e6d6474e4e67f2da194047140c8c8aac04fe0cc9926441468e53fabc48db96", "packages/app/src/context/session-mirror.ts": "b98de060f2b06e6f9a48b1f69ed408f12086c626d90c7dd22029a4ee732260e2", @@ -268,7 +268,7 @@ "packages/app/src/i18n/compaction-failure.test.ts": "a4a3765beb238a2c157c07c9c8ede8418b9febb814d8d1e3a5b2d8868cbd80d9", "packages/app/src/i18n/da.ts": "db685cb94f22ace194850efe9bdc02c530777a4583dc9eb9d00b97002b0a0968", "packages/app/src/i18n/de.ts": "c440ad7543321cae1425e0c2fad92ff73963f21672fbf31176831933573bf103", - "packages/app/src/i18n/en.ts": "50c3be0346b9d989f43de76e39319ab8192bc91e065e2af40cbaf3b0ffc27474", + "packages/app/src/i18n/en.ts": "81f395edb26e83bcc84b9178b6227d29dd4db2b20d371578c0549cc41d8c4370", "packages/app/src/i18n/es.ts": "8cdec3592cddf0b91d7fd59002c81c4e8973c5bf6573e065df64d48c42e1802f", "packages/app/src/i18n/fr.ts": "b9f646352189054b6918bfbac670579e76418c3148eb7e5afca9d71d97e7f2b7", "packages/app/src/i18n/ja.ts": "27ffc41a0fd8dc4b2e814a45c55fad4726ff1c7a85c884cf6af95f8397f17cbf", @@ -770,6 +770,8 @@ "packages/app/src/amicode/whats-new/whats-new-view.tsx": "90b31bc029bafb088f3e1f0669e0a484fd90b6d314f70d2f77a4613121877c23", "packages/app/src/amicode/whats-new/whats-new.tsx": "1b996377d7b67a8b1954f4b0711cf52619eb7f2552b175f2f76ed1e7b938a68d", "packages/app/src/components/prompt-input/attachments.ts": "c3c28df0d2f18378d39e17147a4c64e8ea01d63b97c39e1874ed9445ca671bb1", + "packages/app/src/components/prompt-input/catalog-recovery.test.ts": "ffc1a19840279860437802b992da8398836679f9d7d67fa7bb68d1c3b28839e0", + "packages/app/src/components/prompt-input/catalog-recovery.ts": "01db339b2222083001cc3b9620a375504e01d9c993a4e0727cbd7aec2b20ce47", "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "15f76dca1a25d024128718e4099220b79ca029263410aab55502cf9caf9b2914", "packages/app/src/components/prompt-input/clipboard-bridge.ts": "4b5a1edf59d4c85268d6ff72aa9f9d66f82d4fdb887121fb978c09caf4c5be65", "packages/app/src/components/prompt-input/context-items.tsx": "027b02e394ccd14849052bf3fcacdae1e090c71d164fa8955addfe3f7abf1758", @@ -785,7 +787,7 @@ "packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d", "packages/app/src/components/prompt-input/submit-concise.test.ts": "5d76bad06ced24bda9e5be84f6a01577145bcf2ad6dfa55d2941344da2a3d935", "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "351f0f5a8cf7aff55c859cb82fbca388ef848831256b80f74ee52ab9cf5d07c6", - "packages/app/src/components/prompt-input/submit.ts": "abae05ffc6793faf2edc0e21d9e9f20e8c97edc332027426833216206c5ca564", + "packages/app/src/components/prompt-input/submit.ts": "3940378c5cc0832fed56310c19df10066700bb9034da3ab9c17eb562cc228438", "packages/app/src/components/server/server-row.tsx": "caf3179cf0b34b00fcab6ed57a324ff1f90f571f604216c49164dc54bd9f12ab", "packages/app/src/components/session/context-warning-banner.tsx": "c4ceb3f9589ebd21cc66e21cef209552fc5fcff6cd825da5069c2259c8d33e50", "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8", @@ -1104,10 +1106,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-11-08T15:21:50.674Z", + "extracted_at": "2026-11-08T17:11:11.473Z", "per_package": { "packages/app": { - "A": 314, + "A": 316, "M": 166, "D": 0 }, @@ -1153,7 +1155,7 @@ } }, "counts": { - "overlay_total": 1082, + "overlay_total": 1084, "deletions": 7, "server_coupled": 0 }, @@ -1920,6 +1922,8 @@ "packages/app/src/amicode/whats-new/whats-new-view.tsx": "A", "packages/app/src/amicode/whats-new/whats-new.tsx": "A", "packages/app/src/components/prompt-input/attachments.ts": "M", + "packages/app/src/components/prompt-input/catalog-recovery.test.ts": "A", + "packages/app/src/components/prompt-input/catalog-recovery.ts": "A", "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "A", "packages/app/src/components/prompt-input/clipboard-bridge.ts": "A", "packages/app/src/components/prompt-input/context-items.tsx": "M", diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.test.ts new file mode 100644 index 000000000..f483ac7e2 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { resolveWithCatalogRecovery, singleFlight } from "./catalog-recovery" + +// The bootstrap loads the agent list once; a failed load (engine mid re-init) +// left it empty for the window's lifetime and every send was refused with +// "Select an agent and model" even though a model was picked. The guard now +// reloads the catalog once before refusing, names what is missing, and never +// invents an agent. + +const MODEL = { id: "anthropic.claude-opus-5-5" } +const PLAN = { name: "plan" } + +describe("resolveWithCatalogRecovery", () => { + test("a resolved selection sends without reloading", async () => { + let reloads = 0 + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: MODEL, agent: PLAN }), + reload: async () => void reloads++, + }) + expect(result).toEqual({ model: MODEL, agent: PLAN, missing: "none", reloaded: false }) + expect(reloads).toBe(0) + }) + + test("an empty agent list is reloaded once and the send goes through", async () => { + let agents: { name: string }[] = [] + let reloads = 0 + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: MODEL, agent: agents[0] }), + reload: async () => { + reloads++ + agents = [PLAN] + }, + }) + expect(result.missing).toBe("none") + expect(result.agent).toBe(PLAN) + expect(result.reloaded).toBe(true) + expect(reloads).toBe(1) + }) + + test("a disconnected provider recovers when the providers reload", async () => { + let connected = false + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: connected ? MODEL : undefined, agent: PLAN }), + reload: async () => void (connected = true), + }) + expect(result.missing).toBe("none") + expect(result.model).toBe(MODEL) + }) + + test("agents still missing after the reload refuses as 'agent' — never a defaulted agent", async () => { + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: MODEL, agent: undefined as { name: string } | undefined }), + reload: async () => {}, + }) + expect(result.missing).toBe("agent") + expect(result.agent).toBeUndefined() + }) + + test("agents present but model unusable refuses as 'model'", async () => { + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: undefined, agent: PLAN }), + reload: async () => {}, + }) + expect(result.missing).toBe("model") + }) + + test("both missing reports 'agent' (the catalog itself didn't load)", async () => { + const result = await resolveWithCatalogRecovery({ read: () => ({ model: undefined, agent: undefined }), reload: async () => {} }) + expect(result.missing).toBe("agent") + }) + + test("a reload that throws is swallowed and the guard still answers", async () => { + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: MODEL, agent: undefined }), + reload: async () => { + throw new Error("engine down") + }, + }) + expect(result.missing).toBe("agent") + expect(result.reloaded).toBe(true) + }) + + test("a reload that hangs is bounded by the timeout", async () => { + const start = Date.now() + const result = await resolveWithCatalogRecovery({ + read: () => ({ model: MODEL, agent: undefined }), + reload: () => new Promise(() => {}), + timeoutMs: 30, + }) + expect(result.missing).toBe("agent") + expect(Date.now() - start).toBeLessThan(1_000) + }) +}) + +describe("singleFlight", () => { + test("concurrent calls for one key share one run; a later call runs again", async () => { + let runs = 0 + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const reload = singleFlight(async (_dir: string) => { + runs++ + await gate + }) + const a = reload("/repo") + const b = reload("/repo") + expect(a).toBe(b) + release() + await a + await reload("/repo") + expect(runs).toBe(2) + }) + + test("different keys run independently, and a rejection clears the slot", async () => { + let runs = 0 + const reload = singleFlight(async (dir: string) => { + runs++ + if (dir === "/bad") throw new Error("nope") + }) + await reload("/a") + await expect(reload("/bad")).rejects.toThrow("nope") + await expect(reload("/bad")).rejects.toThrow("nope") + expect(runs).toBe(3) + }) +}) + +// Wiring pin (createPromptSubmit needs the whole provider tree, so it is pinned +// by source, like submit-stream-gap.test.ts). +describe("submit guard wiring", () => { + const submit = readFileSync(join(import.meta.dir, "submit.ts"), "utf8") + const serverSync = readFileSync(join(import.meta.dir, "..", "..", "context", "server-sync.tsx"), "utf8") + + test("the guard recovers through the server-sync catalog reload before refusing", () => { + expect(submit).toContain("await resolveWithCatalogRecovery({") + expect(submit).toContain("reload: () => serverSync().reloadCatalog(sdk().directory)") + expect(submit.indexOf("resolveWithCatalogRecovery({")).toBeLessThan(submit.indexOf("input.addToHistory")) + }) + + test("the refusal names what is missing; the old catch-all toast is gone", () => { + expect(submit).toContain("prompt.toast.agentsUnavailable.title") + expect(submit).toContain("prompt.toast.modelUnavailable.title") + expect(submit).not.toContain("prompt.toast.modelAgentRequired") + }) + + test("the agent is never defaulted in the guard", () => { + expect(submit).not.toMatch(/currentAgent\?\.name \?\? "build"/) + expect(submit).toContain("agent: local.agent.current()") + }) + + test("server-sync reloads agents fresh (not the cached failure) and the providers", () => { + expect(serverSync).toContain("const reloadCatalog = singleFlight(") + expect(serverSync).toContain("fetchQuery({ ...queryOptionsApi.agents(key), staleTime: 0 })") + expect(serverSync).toContain('setStore("agent", agents)') + expect(serverSync).toContain("refreshProviders()") + expect(serverSync).toMatch(/refreshProviders,\s*reloadCatalog,/) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.ts new file mode 100644 index 000000000..beaf25ce5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/catalog-recovery.ts @@ -0,0 +1,67 @@ +// The composer's send guard needs an agent and a usable model, both read from +// the engine's per-directory catalog (agent list + connected providers). The +// directory bootstrap loads the agent list ONCE; if that request fails — e.g. +// while the engine re-initialises after a rebuild re-stages its config — the +// list stays empty for the window's lifetime and every send was refused with +// "Select an agent and model", even though a model was picked. The guard now +// reloads the catalog once and re-reads before refusing, and the refusal names +// what is actually missing. It never invents an agent: an unresolved agent is +// refused, not defaulted to `build` (that would silently escalate a plan-mode +// chat to write access). + +export type CatalogMissing = "none" | "agent" | "model" + +export type CatalogResolution = { + model: M | undefined + agent: A | undefined + /** What is still unresolved after the (at most one) reload. An unresolved + * agent wins over a model — without agents the catalog itself didn't load. */ + missing: CatalogMissing + reloaded: boolean +} + +const classify = (model: unknown, agent: unknown): CatalogMissing => { + if (!agent) return "agent" + if (!model) return "model" + return "none" +} + +/** Read the selection; when anything is missing, reload the catalog once + * (bounded by `timeoutMs`, failures swallowed) and read again. */ +export async function resolveWithCatalogRecovery(input: { + read: () => { model: M | undefined; agent: A | undefined } + reload: () => Promise + timeoutMs?: number +}): Promise> { + const first = input.read() + if (classify(first.model, first.agent) === "none") { + return { ...first, missing: "none", reloaded: false } + } + + const timeoutMs = input.timeoutMs ?? 8_000 + let timer: ReturnType | undefined + await Promise.race([ + Promise.resolve() + .then(input.reload) + .catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }), + ]) + if (timer !== undefined) clearTimeout(timer) + + const second = input.read() + return { ...second, missing: classify(second.model, second.agent), reloaded: true } +} + +/** Single-flight wrapper: concurrent reloads for the same key share one request. */ +export function singleFlight(run: (key: K) => Promise) { + const pending = new Map>() + return (key: K) => { + const existing = pending.get(key) + if (existing) return existing + const promise = run(key).finally(() => pending.delete(key)) + pending.set(key, promise) + return promise + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts index 130b41cd9..447e001cf 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts @@ -5,6 +5,7 @@ import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { batch, startTransition, type Accessor } from "solid-js" import { withNewSessionGate } from "./new-session-gate" +import { resolveWithCatalogRecovery } from "./catalog-recovery" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServer } from "@/context/server" @@ -418,14 +419,24 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } + // The engine catalog (agents + connected providers) can be stale-empty if + // its one bootstrap load failed; reload it once before refusing, and name + // what is missing. Never default the agent — see catalog-recovery.ts. const modelSelection = input.model ?? local.model - const currentModel = modelSelection.current() - const currentAgent = local.agent.current() + const resolved = await resolveWithCatalogRecovery({ + read: () => ({ model: modelSelection.current(), agent: local.agent.current() }), + reload: () => serverSync().reloadCatalog(sdk().directory), + }) + const currentModel = resolved.model + const currentAgent = resolved.agent const variant = modelSelection.variant.current() if (!currentModel || !currentAgent) { + const agentMissing = resolved.missing === "agent" showToast({ - title: language.t("prompt.toast.modelAgentRequired.title"), - description: language.t("prompt.toast.modelAgentRequired.description"), + title: language.t(agentMissing ? "prompt.toast.agentsUnavailable.title" : "prompt.toast.modelUnavailable.title"), + description: language.t( + agentMissing ? "prompt.toast.agentsUnavailable.description" : "prompt.toast.modelUnavailable.description", + ), }) return } diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx index cfd9e3fa7..6f4f51a9e 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx @@ -36,6 +36,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" +import { singleFlight } from "@/components/prompt-input/catalog-recovery" import { PathKey } from "@/utils/path-key" import { createDirSyncContext } from "./directory-sync" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -545,6 +546,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return promise } + // The composer's send-guard recovery: the directory bootstrap writes the + // agent list into the child store ONCE, so a single failed request (engine + // mid re-init) left it empty for the window's lifetime. Re-fetch the agent + // list (fresh, not the cached failure) and every providers query, and write + // the agents back. Concurrent calls for one directory share a request. + const reloadCatalog = singleFlight(async (directory: string) => { + const key = directoryKey(directory) + if (!key) return + const [, setStore] = children.child(directory, { bootstrap: false }) + await Promise.allSettled([ + queryClient + .fetchQuery({ ...queryOptionsApi.agents(key), staleTime: 0 }) + .then((agents) => setStore("agent", agents)), + refreshProviders(), + ]) + }) + async function bootstrapInstance(directory: string) { const key = directoryKey(directory) if (!key) return @@ -919,6 +937,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { disableMcp: children.disableMcp, queryOptions: queryOptionsApi, refreshProviders, + reloadCatalog, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts index 6dcf64b32..2bac785be 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts @@ -367,6 +367,12 @@ export const dict = { "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded", "prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", + "prompt.toast.agentsUnavailable.title": "Agents didn't load", + "prompt.toast.agentsUnavailable.description": + "Couldn't load the agent list from the engine, even after retrying. Nothing was sent. Reload the window and try again.", + "prompt.toast.modelUnavailable.title": "Selected model isn't available", + "prompt.toast.modelUnavailable.description": + "The selected model's provider isn't connected. Nothing was sent. Pick another model, or reconnect the provider in Settings.", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", "prompt.toast.sessionCreateFailed.title": "Failed to create session", "prompt.toast.shellSendFailed.title": "Failed to send shell command",