Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -1153,7 +1155,7 @@
}
},
"counts": {
"overlay_total": 1082,
"overlay_total": 1084,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>((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,/)
})
})
Original file line number Diff line number Diff line change
@@ -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<M, A> = {
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<M, A>(input: {
read: () => { model: M | undefined; agent: A | undefined }
reload: () => Promise<unknown>
timeoutMs?: number
}): Promise<CatalogResolution<M, A>> {
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<typeof setTimeout> | undefined
await Promise.race([
Promise.resolve()
.then(input.reload)
.catch(() => undefined),
new Promise<void>((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<K>(run: (key: K) => Promise<unknown>) {
const pending = new Map<K, Promise<unknown>>()
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -919,6 +937,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
refreshProviders,
reloadCatalog,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
Expand Down
6 changes: 6 additions & 0 deletions packages/app-bundle/overlay/packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading