From f44d35dd9c2cefb287c432f64623b69286b5f855 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:36:57 +0000 Subject: [PATCH 1/4] fix(opencode): stop asking OpenCode 2.x for a model list it does not have Every 2.x release rejects `opencode models --verbose`, and its own listings are empty before 2.0.27 and can omit providers that report after startup. The early listing now applies to the 1.x line only; on 2.x the chat's own list fills the picker. --- src/main/acp/acp-launch-specs.ts | 22 ++++++++++-------- src/main/acp/acp-model-catalog-probe.test.ts | 18 ++++++++++++++- src/main/acp/acp-model-catalog-probe.ts | 23 +++++++++++-------- .../acp/acp-structured-launch-resolution.ts | 2 +- .../opencode-model-catalog-listing.ts | 5 ++-- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 7083bef1115b..b12bfadf309d 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -58,6 +58,9 @@ export type AcpModelDiscovery = args: readonly string[] parse(stdout: string): AgentSessionModelOption[] listingNamesConfiguredModel: boolean + /** The releases whose listing matches what their chats offer; any other runs none, and its + * chats' own lists fill the catalog. Absent lists on every release. */ + supportsVersion?(version: string): boolean } | { kind: 'unavailable'; reason: string } @@ -142,15 +145,13 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { sessionHistory: GROK_SESSION_HISTORY } +// The release the recorded sessions capture. +const OPENCODE_1X_LINE = { major: 1, floor: '1.18.31' } as const +// The 2.x release verified to start the private child below. +const OPENCODE_2X_LINE = { major: 2, floor: '2.0.14' } as const + // `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child // with this environment and ends it with stdin, so both lines run the chat's own account. -const OPENCODE_ACP_RELEASE_LINES = [ - // The release the recorded sessions capture. - { major: 1, floor: '1.18.31' }, - // The 2.x release verified to start that private child. - { major: 2, floor: '2.0.14' } -] as const - const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'opencode', command: 'opencode', @@ -165,7 +166,7 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { account: openCodeAcpAccountBinding(), installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')], supportsVersion: (version) => - OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)), + [OPENCODE_1X_LINE, OPENCODE_2X_LINE].some((line) => isStableCliVersionOnLine(version, line)), imagePrompts: true, readStoredUserMessages: openCodeStoredUserMessagesReader(), // Its `initialize` names no models and `session/new` stores a session; the listing does neither. @@ -174,7 +175,10 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { args: OPENCODE_MODEL_LISTING_ARGS, parse: parseOpenCodeModelListing, // The listing marks no default; a chat started with no pick names it. - listingNamesConfiguredModel: false + listingNamesConfiguredModel: false, + // 2.x rejects `--verbose`; its own listings are empty before 2.0.27 and then omit providers + // that report after startup. + supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE) }, visualsSkill: loadOpenCodeVisualsSkill, compaction: true, diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index 7afa57e763ac..b3b313f06837 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -158,7 +158,7 @@ describe('ACP model catalog probes', () => { resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), dataDirectory: '/orca-data', resolveCommand: () => '/opt/opencode/bin/opencode', - probeVersion: async () => true, + probeVersion: async (_launch, supports) => supports('1.18.35'), homePath: '/home/user', runListing, connect @@ -178,6 +178,22 @@ describe('ACP model catalog probes', () => { ]) }) + // Every 2.x release rejects `--verbose`, and its other listings miss what its chats offer. + it('runs no listing command on OpenCode 2.x, whose chats list its models', async () => { + const runListing = vi.fn(async () => '') + const probe = createAcpModelCatalogProbe(OPENCODE, { + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveCommand: () => '/opt/opencode/bin/opencode', + probeVersion: async (_launch, supports) => supports('2.0.28'), + homePath: '/home/user', + runListing + }) + await expect(probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })).rejects.toThrow( + /lists its models only through its chats/ + ) + expect(runListing).not.toHaveBeenCalled() + }) + it('lists nothing for a release that runs no structured chat', async () => { const runListing = vi.fn() const probe = createAcpModelCatalogProbe(OPENCODE, { diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index e2dae8600188..be214c9e85bc 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -13,7 +13,7 @@ import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { createAcpAgentConnection } from './acp-agent-connection' import type { AcpLaunchSpec } from './acp-launch-specs' import { - acpLaunchVersionSupported, + probeAcpLaunchVersion, resolveAcpLaunchInvocation, type AcpLaunchInvocationDeps } from './acp-structured-launch-resolution' @@ -35,7 +35,8 @@ export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { /** * Lists an ACP agent's models without a session, under the binary, account and scrubbed - * environment its chats launch with. A release that runs no structured chat lists nothing. + * environment its chats launch with. A release that runs no structured chat lists nothing, nor does + * one whose listing its chats' own lists do not match. */ export function createAcpModelCatalogProbe( spec: AcpLaunchSpec, @@ -53,15 +54,19 @@ export function createAcpModelCatalogProbe( ...deps, resolveWorkingDirectory: () => cwd }) - if ( - !(await acpLaunchVersionSupported( - spec, - { command, cwd, env }, - deps.probeVersion ?? probeAgentCliVersion - )) - ) { + const listsVersion = discovery.kind === 'command' ? discovery.supportsVersion : undefined + const release = await probeAcpLaunchVersion( + spec, + { command, cwd, env }, + listsVersion !== undefined, + deps.probeVersion ?? probeAgentCliVersion + ) + if (!release.supported) { throw new Error(`${spec.agent} at ${command} does not run structured chats`) } + if (listsVersion && (release.version === null || !listsVersion(release.version))) { + throw new Error(`${spec.agent} at ${command} lists its models only through its chats`) + } let models: AgentSessionModelOption[] if (discovery.kind === 'command') { const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 4330c67c81ef..96044c762c3f 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -145,7 +145,7 @@ export function resolveAcpLaunchCommand( /** Whether `command` is a release the spec runs a structured chat on (true when it names none), * and the version it printed. Asked only when the spec names releases or `wantVersion`. */ -async function probeAcpLaunchVersion( +export async function probeAcpLaunchVersion( spec: AcpLaunchSpec, launch: { command: string; cwd: string; env: Record }, wantVersion: boolean, diff --git a/src/main/opencode/opencode-model-catalog-listing.ts b/src/main/opencode/opencode-model-catalog-listing.ts index 7f667cf3a498..452d23c3f0a6 100644 --- a/src/main/opencode/opencode-model-catalog-listing.ts +++ b/src/main/opencode/opencode-model-catalog-listing.ts @@ -1,5 +1,6 @@ -// OpenCode's session-free model listing: `opencode models --verbose` prints each `provider/model` id -// on its own line, followed by that model's metadata as indented JSON closed by a bare `}` line. +// OpenCode 1.x's session-free model listing: `opencode models --verbose` prints each +// `provider/model` id on its own line, followed by that model's metadata as indented JSON closed by +// a bare `}` line. import { z } from 'zod' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' From eb897069a894bb07fb56f880e89fa1e6b4850ef6 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:40:54 +0000 Subject: [PATCH 2/4] feat(opencode): list OpenCode 2.x models through a session it opens and deletes 2.x has no listing command that matches its chats, but a new session reports exactly what a chat offers. The catalog probe now opens one, reads its models and deletes it with session/delete. A release that cannot delete a session is not asked, so no empty conversation is left; 1.x keeps its models --verbose listing. --- src/main/acp/acp-launch-specs.ts | 25 +++-- src/main/acp/acp-model-catalog-probe.test.ts | 109 ++++++++++++++++--- src/main/acp/acp-model-catalog-probe.ts | 87 ++++++++++++--- src/main/acp/acp-session-runtime.ts | 3 + 4 files changed, 185 insertions(+), 39 deletions(-) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index b12bfadf309d..4f16159bf106 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -39,8 +39,9 @@ import { OPENCODE_TRANSCRIPT_IMPORT } from '../opencode/opencode-transcript-impo import { OMP_SESSION_HISTORY } from '../omp/omp-session-history' import { OMP_TRANSCRIPT_IMPORT } from '../omp/omp-transcript-import' -/** How an agent lists its models without a session: from its `initialize` answer (plus extension - * requests), from a listing command, or not at all. Never `authenticate` or `session/new`. */ +/** How an agent lists its models before a chat: from its `initialize` answer (plus extension + * requests), from a listing command, from a session it opens and deletes, or not at all. Never + * `authenticate`. */ export type AcpModelDiscovery = | { kind: 'initialize' @@ -58,9 +59,17 @@ export type AcpModelDiscovery = args: readonly string[] parse(stdout: string): AgentSessionModelOption[] listingNamesConfiguredModel: boolean - /** The releases whose listing matches what their chats offer; any other runs none, and its - * chats' own lists fill the catalog. Absent lists on every release. */ + /** The releases whose listing matches what their chats offer; any other lists the way + * `otherReleases` names, else not at all and its chats' own lists fill the catalog. Absent + * lists on every release. */ supportsVersion?(version: string): boolean + otherReleases?: 'session' + } + | { + /** What a new chat offers, read from a session the listing then deletes. An agent that + * cannot delete one is not asked, so no empty conversation is left in its history. */ + kind: 'session' + listingNamesConfiguredModel: boolean } | { kind: 'unavailable'; reason: string } @@ -169,16 +178,16 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { [OPENCODE_1X_LINE, OPENCODE_2X_LINE].some((line) => isStableCliVersionOnLine(version, line)), imagePrompts: true, readStoredUserMessages: openCodeStoredUserMessagesReader(), - // Its `initialize` names no models and `session/new` stores a session; the listing does neither. + // Its `initialize` names no models; 1.x lists them with a command, 2.x in a session it deletes. modelDiscovery: { kind: 'command', args: OPENCODE_MODEL_LISTING_ARGS, parse: parseOpenCodeModelListing, // The listing marks no default; a chat started with no pick names it. listingNamesConfiguredModel: false, - // 2.x rejects `--verbose`; its own listings are empty before 2.0.27 and then omit providers - // that report after startup. - supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE) + // 2.x rejects `--verbose`, and its own listings omit providers that report after startup. + supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE), + otherReleases: 'session' }, visualsSkill: loadOpenCodeVisualsSkill, compaction: true, diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index b3b313f06837..10a21ab3366c 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { createAcpModelCatalogProbe, @@ -90,6 +90,67 @@ function scriptedGrok(initializeMeta: Record): { } } +function scriptedOpenCode2(canDelete: boolean): { + agent: AcpScriptedAgent + launches: ProviderProcessLaunch[] + deps: AcpModelCatalogProbeDeps & { + runListing: Mock> + } +} { + const agent = new AcpScriptedAgent() + agents.push(agent) + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: canDelete ? { delete: {} } : {} + } + }) + ) + agent.on('session/new', (frame) => + agent.reply(frame, { + sessionId: 'probe-session', + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'opencode/exo-free', + options: [ + { value: 'opencode/big-pickle', name: 'Big Pickle' }, + { value: 'opencode/exo-free', name: 'Exo Free' } + ] + } + ] + }) + ) + agent.on('session/delete', (frame) => agent.reply(frame, {})) + const launches: ProviderProcessLaunch[] = [] + return { + agent, + launches, + deps: { + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveCommand: () => '/opt/opencode/bin/opencode', + probeVersion: async (_launch, supports) => supports('2.0.29'), + homePath: '/home/user', + dataDirectory: '/orca-data', + runListing: vi.fn>(), + connectSession: (launch) => { + launches.push(launch) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin) + return { + initialize: () => runtime.initialize(), + start: (options) => runtime.start(options), + deleteSession: (sessionId) => runtime.deleteSession(sessionId), + close: async () => runtime.close() + } + } + } + } +} + describe('ACP model catalog probes', () => { it('lists Grok from initialize alone, under its pinned account, and closes the child', async () => { const { agent, deps, launches, closed } = scriptedGrok({ modelState: MODEL_STATE }) @@ -178,20 +239,40 @@ describe('ACP model catalog probes', () => { ]) }) - // Every 2.x release rejects `--verbose`, and its other listings miss what its chats offer. - it('runs no listing command on OpenCode 2.x, whose chats list its models', async () => { - const runListing = vi.fn(async () => '') - const probe = createAcpModelCatalogProbe(OPENCODE, { - resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), - resolveCommand: () => '/opt/opencode/bin/opencode', - probeVersion: async (_launch, supports) => supports('2.0.28'), - homePath: '/home/user', - runListing + // Every 2.x release rejects `--verbose`, and its own listings omit providers that report late. + it('lists OpenCode 2.x from a session it opens and deletes, with no listing command', async () => { + const { agent, deps, launches } = scriptedOpenCode2(true) + const success = await createAcpModelCatalogProbe( + OPENCODE, + deps + )({ + kind: 'opencode', + locator: { kind: 'unmanaged' } }) - await expect(probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })).rejects.toThrow( - /lists its models only through its chats/ - ) - expect(runListing).not.toHaveBeenCalled() + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'session/delete' + ]) + expect(agent.frames[1]!.params).toMatchObject({ cwd: '/home/user' }) + expect(agent.frames[2]!.params).toEqual({ sessionId: 'probe-session' }) + expect(launches).toEqual([expect.objectContaining({ args: ['acp'] })]) + expect(deps.runListing).not.toHaveBeenCalled() + expect(success.models.map((model) => [model.id, model.label])).toEqual([ + ['opencode/big-pickle', 'Big Pickle'], + ['opencode/exo-free', 'Exo Free'] + ]) + }) + + it('opens no session on an OpenCode 2.x that cannot delete one', async () => { + const { agent, deps } = scriptedOpenCode2(false) + await expect( + createAcpModelCatalogProbe( + OPENCODE, + deps + )({ kind: 'opencode', locator: { kind: 'unmanaged' } }) + ).rejects.toThrow(/cannot delete a session/) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) }) it('lists nothing for a release that runs no structured chat', async () => { diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index be214c9e85bc..8cdd0e06567b 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -11,12 +11,14 @@ import { import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { createAcpAgentConnection } from './acp-agent-connection' -import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpLaunchSpec, AcpModelDiscovery } from './acp-launch-specs' +import type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' import { probeAcpLaunchVersion, resolveAcpLaunchInvocation, type AcpLaunchInvocationDeps } from './acp-structured-launch-resolution' +import { AcpStructuredOptions } from './acp-structured-options' import type { InitializeResponse } from './generated/acp-protocol.generated' /** The part of an ACP connection a session-free listing may use: no session method is reachable. */ @@ -26,17 +28,27 @@ export type AcpModelCatalogConnection = { close(): Promise } +/** A connection that may open one session, to read what a new chat offers, and delete it. */ +export type AcpModelCatalogSessionConnection = Omit< + AcpModelCatalogConnection, + 'requestSessionFreeExtension' +> & { + start(options: AcpSessionStartOptions): Promise + deleteSession(sessionId: string): Promise +} + export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { /** Test seams; production spawns through the shared supervised runner and ACP connection. */ probeVersion?: typeof probeAgentCliVersion connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection + connectSession?: (launch: ProviderProcessLaunch) => AcpModelCatalogSessionConnection runListing?: typeof runAgentModelCatalogListing } /** - * Lists an ACP agent's models without a session, under the binary, account and scrubbed - * environment its chats launch with. A release that runs no structured chat lists nothing, nor does - * one whose listing its chats' own lists do not match. + * Lists an ACP agent's models before a chat, under the binary, account and scrubbed environment its + * chats launch with. A release that runs no structured chat lists nothing, nor does one its + * discovery names no listing for. */ export function createAcpModelCatalogProbe( spec: AcpLaunchSpec, @@ -64,28 +76,36 @@ export function createAcpModelCatalogProbe( if (!release.supported) { throw new Error(`${spec.agent} at ${command} does not run structured chats`) } - if (listsVersion && (release.version === null || !listsVersion(release.version))) { + const listing = listingFor(discovery, release.version) + if (listing === null) { throw new Error(`${spec.agent} at ${command} lists its models only through its chats`) } + const launch = { + command, + args: spec.args({ fullAccess: false, pluginDir: null }), + cwd, + env, + envToDelete + } + const run = { label: spec.agent, signal: options?.signal } let models: AgentSessionModelOption[] - if (discovery.kind === 'command') { + if (listing.kind === 'command') { const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( - { command, args: [...discovery.args], cwd, env, envToDelete }, + { command, args: [...listing.args], cwd, env, envToDelete }, { site: `${spec.agent}-model-catalog-probe`, signal: options?.signal } ) - models = discovery.parse(stdout) + models = listing.parse(stdout) + } else if (listing.kind === 'session') { + models = await runAgentModelCatalogSession( + () => (deps.connectSession ?? createAcpAgentConnection)(launch), + (connection) => readSessionModels(spec, connection, cwd), + run + ) } else { - const launch = { - command, - args: spec.args({ fullAccess: false, pluginDir: null }), - cwd, - env, - envToDelete - } models = await runAgentModelCatalogSession( () => (deps.connect ?? createAcpAgentConnection)(launch), - async (connection) => discovery.read(await connection.initialize(), connection), - { label: spec.agent, signal: options?.signal } + async (connection) => listing.read(await connection.initialize(), connection), + run ) } if (models.length === 0) { @@ -94,3 +114,36 @@ export function createAcpModelCatalogProbe( return { models, origin: 'probe' } } } + +type AcpModelListing = Exclude + +/** How `version` lists: its discovery, unless that is a command this release has no listing for. */ +function listingFor(discovery: AcpModelListing, version: string | null): AcpModelListing | null { + if (discovery.kind !== 'command' || !discovery.supportsVersion) { + return discovery + } + if (version !== null && discovery.supportsVersion(version)) { + return discovery + } + return discovery.otherReleases === 'session' + ? { kind: 'session', listingNamesConfiguredModel: discovery.listingNamesConfiguredModel } + : null +} + +/** What a new chat in `cwd` offers, read from a session opened only to be deleted. */ +async function readSessionModels( + spec: AcpLaunchSpec, + connection: AcpModelCatalogSessionConnection, + cwd: string +): Promise { + const initialized = await connection.initialize() + if (!initialized.agentCapabilities?.sessionCapabilities?.delete) { + throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`) + } + const started = await connection.start({ cwd, mcpServers: [] }) + // A failed delete leaves one empty conversation behind; the models it listed are still right. + await connection.deleteSession(started.sessionId).catch(() => undefined) + const options = new AcpStructuredOptions(spec.dialect) + options.adoptSession(started.response, 'new') + return options.read().models +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 8cfd4bbaf425..2f58f6fae860 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -152,6 +152,9 @@ export class AcpSessionRuntime { return (await this.call('session/fork', params, ForkSessionResponseSchema)).sessionId } + /** Deletes saved session `sessionId` (`session/delete`). Ask only an agent that advertises it. */ + deleteSession = (sessionId: string) => this.call('session/delete', { sessionId }, z.unknown()) + start(options: AcpSessionStartOptions): Promise { if (this.started) { return Promise.resolve(this.started) From 1b15064733c8298fd2c7c48485872728a86ed28f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:39:18 +0000 Subject: [PATCH 3/4] fix(opencode): open the model-list session in the asking chat's folder OpenCode 2 records a project for every folder a session opens in, so the throwaway session in the home folder left a home-folder project in OpenCode's list. The session now opens in the folder of the chat that asked, as bb does; a startup or settings refresh has no chat, so it skips the listing without counting as a failure. --- src/main/acp/acp-launch-specs.ts | 22 +++++------ src/main/acp/acp-model-catalog-probe.test.ts | 31 ++++++++++----- src/main/acp/acp-model-catalog-probe.ts | 17 +++++---- .../agent-model-catalog-failures.ts | 4 ++ .../agent-model-catalog-prewarm.test.ts | 38 +++++++++++++++++++ .../agent-model-catalog-service.ts | 13 ++++--- .../agent-model-catalog-store.ts | 7 ++-- 7 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 4f16159bf106..3db522eb0133 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -59,20 +59,20 @@ export type AcpModelDiscovery = args: readonly string[] parse(stdout: string): AgentSessionModelOption[] listingNamesConfiguredModel: boolean - /** The releases whose listing matches what their chats offer; any other lists the way - * `otherReleases` names, else not at all and its chats' own lists fill the catalog. Absent - * lists on every release. */ + /** The releases whose listing matches what their chats offer; any other lists through + * `otherReleases`, else not at all and its chats' own lists fill the catalog. Absent lists + * on every release. */ supportsVersion?(version: string): boolean - otherReleases?: 'session' - } - | { - /** What a new chat offers, read from a session the listing then deletes. An agent that - * cannot delete one is not asked, so no empty conversation is left in its history. */ - kind: 'session' - listingNamesConfiguredModel: boolean + otherReleases?: AcpSessionModelDiscovery } + | AcpSessionModelDiscovery | { kind: 'unavailable'; reason: string } +/** What a new chat offers, read from a session opened in the asking chat's folder and then + * deleted, as bb does. An agent that cannot delete one is not asked, and with no chat folder (a + * prewarm) none is opened, so no conversation or new project is left in its history. */ +export type AcpSessionModelDiscovery = { kind: 'session'; listingNamesConfiguredModel: boolean } + export type AcpLaunchSpec = { /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ agent: TuiAgent @@ -187,7 +187,7 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { listingNamesConfiguredModel: false, // 2.x rejects `--verbose`, and its own listings omit providers that report after startup. supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE), - otherReleases: 'session' + otherReleases: { kind: 'session', listingNamesConfiguredModel: false } }, visualsSkill: loadOpenCodeVisualsSkill, compaction: true, diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index 10a21ab3366c..ee33e01a5b8b 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -6,6 +6,7 @@ import { } from './acp-model-catalog-probe' import { AcpSessionRuntime } from './acp-session-runtime' import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { AgentModelCatalogListingDeferredError } from '../native-chat/agent-model-catalog/agent-model-catalog-failures' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' @@ -242,19 +243,17 @@ describe('ACP model catalog probes', () => { // Every 2.x release rejects `--verbose`, and its own listings omit providers that report late. it('lists OpenCode 2.x from a session it opens and deletes, with no listing command', async () => { const { agent, deps, launches } = scriptedOpenCode2(true) - const success = await createAcpModelCatalogProbe( - OPENCODE, - deps - )({ - kind: 'opencode', - locator: { kind: 'unmanaged' } - }) + const success = await createAcpModelCatalogProbe(OPENCODE, deps)( + { kind: 'opencode', locator: { kind: 'unmanaged' } }, + { workspacePath: '/work/project' } + ) expect(agent.frames.map((frame) => frame.method)).toEqual([ 'initialize', 'session/new', 'session/delete' ]) - expect(agent.frames[1]!.params).toMatchObject({ cwd: '/home/user' }) + // In the asking chat's folder, so OpenCode records no project the person never opened. + expect(agent.frames[1]!.params).toMatchObject({ cwd: '/work/project' }) expect(agent.frames[2]!.params).toEqual({ sessionId: 'probe-session' }) expect(launches).toEqual([expect.objectContaining({ args: ['acp'] })]) expect(deps.runListing).not.toHaveBeenCalled() @@ -266,13 +265,25 @@ describe('ACP model catalog probes', () => { it('opens no session on an OpenCode 2.x that cannot delete one', async () => { const { agent, deps } = scriptedOpenCode2(false) + await expect( + createAcpModelCatalogProbe(OPENCODE, deps)( + { kind: 'opencode', locator: { kind: 'unmanaged' } }, + { workspacePath: '/work/project' } + ) + ).rejects.toThrow(/cannot delete a session/) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + }) + + it('opens no session on OpenCode 2.x without a chat folder, as at a prewarm', async () => { + const { agent, deps, launches } = scriptedOpenCode2(true) await expect( createAcpModelCatalogProbe( OPENCODE, deps )({ kind: 'opencode', locator: { kind: 'unmanaged' } }) - ).rejects.toThrow(/cannot delete a session/) - expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + ).rejects.toBeInstanceOf(AgentModelCatalogListingDeferredError) + expect(launches).toEqual([]) + expect(agent.frames).toEqual([]) }) it('lists nothing for a release that runs no structured chat', async () => { diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index 8cdd0e06567b..6a94e82748e6 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -8,6 +8,7 @@ import { runAgentModelCatalogListing, runAgentModelCatalogSession } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import { AgentModelCatalogListingDeferredError } from '../native-chat/agent-model-catalog/agent-model-catalog-failures' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { createAcpAgentConnection } from './acp-agent-connection' @@ -96,9 +97,13 @@ export function createAcpModelCatalogProbe( ) models = listing.parse(stdout) } else if (listing.kind === 'session') { + const workspace = options?.workspacePath + if (!workspace) { + throw new AgentModelCatalogListingDeferredError(`${spec.agent} lists in a chat's folder`) + } models = await runAgentModelCatalogSession( () => (deps.connectSession ?? createAcpAgentConnection)(launch), - (connection) => readSessionModels(spec, connection, cwd), + (connection) => readSessionModels(spec, connection, workspace), run ) } else { @@ -125,22 +130,20 @@ function listingFor(discovery: AcpModelListing, version: string | null): AcpMode if (version !== null && discovery.supportsVersion(version)) { return discovery } - return discovery.otherReleases === 'session' - ? { kind: 'session', listingNamesConfiguredModel: discovery.listingNamesConfiguredModel } - : null + return discovery.otherReleases ?? null } -/** What a new chat in `cwd` offers, read from a session opened only to be deleted. */ +/** What a new chat in `workspace` offers, read from a session opened only to be deleted. */ async function readSessionModels( spec: AcpLaunchSpec, connection: AcpModelCatalogSessionConnection, - cwd: string + workspace: string ): Promise { const initialized = await connection.initialize() if (!initialized.agentCapabilities?.sessionCapabilities?.delete) { throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`) } - const started = await connection.start({ cwd, mcpServers: [] }) + const started = await connection.start({ cwd: workspace, mcpServers: [] }) // A failed delete leaves one empty conversation behind; the models it listed are still right. await connection.deleteSession(started.sessionId).catch(() => undefined) const options = new AcpStructuredOptions(spec.dialect) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts index 7da3c68d6800..c711a870d267 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts @@ -10,6 +10,10 @@ export const AGENT_MODEL_CATALOG_FAILURE_TTL_MS = 30_000 /** A probe the host stopped (it is going away): says nothing about the account, so it is not held. */ export class AgentModelCatalogListingStoppedError extends Error {} +/** A listing that runs only in a chat's folder, asked with none (a prewarm): like a stopped probe, + * it says nothing about the account. */ +export class AgentModelCatalogListingDeferredError extends AgentModelCatalogListingStoppedError {} + export type AgentModelCatalogFailure = { /** Set by the store's own refreshes; the fingerprint is a hash, so expiry by agent reads it. */ agent?: string diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index 290313f8f658..f9943e6cdd6a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -6,6 +6,7 @@ import { agentModelCatalogFingerprintForRecord, agentModelCatalogSessionAccess } from './agent-model-catalog-fingerprint' +import { AgentModelCatalogListingDeferredError } from './agent-model-catalog-failures' import { createAgentModelCatalogService } from './agent-model-catalog-service' import { AGENT_MODEL_CATALOG_FRESH_MS, @@ -96,6 +97,43 @@ describe('model catalog prewarm', () => { expect(grok).toHaveBeenCalledTimes(2) }) + // OpenCode 2.x lists in a session opened in the chat's folder; a prewarm has no chat. + it('hands a read its chat folder, and lets a listing that needs one skip the prewarm', async () => { + const store = new AgentModelCatalogStore() + const probe = vi.fn( + async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) => { + if (!options?.workspacePath) { + throw new AgentModelCatalogListingDeferredError('lists in a chat folder') + } + return listing('big-pickle') + } + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'), + probes: { opencode2: probe } + }) + + await service.prewarm() + expect(probe).toHaveBeenCalledWith(HOME('OPENCODE_HOME', '/homes/a'), { + signal: expect.any(AbortSignal) + }) + // Not held as a failure: the first chat lists at once, in its own folder. + const read = await service.read({ + agent: 'opencode2', + workspacePath: '/work/project', + waitForListing: true + }) + expect(probe).toHaveBeenLastCalledWith(HOME('OPENCODE_HOME', '/homes/a'), { + signal: expect.any(AbortSignal), + workspacePath: '/work/project' + }) + expect(read.origin).toBe('probe') + }) + it('lists the new account after a switch and leaves the old account’s catalog alone', async () => { const store = new AgentModelCatalogStore() let selected = '/homes/a' diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index de5a5c26b60c..4424a405cbfb 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -210,9 +210,9 @@ export function createAgentModelCatalogService( // answers after it, so the chat that reported it is followed by an answer naming it. const recordingDefaults = new Map>() const listWith = - (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome) => + (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome, workspacePath?: string) => (): Promise => - probe(home, { signal: lifetime.signal }).catch((error: unknown) => { + probe(home, { signal: lifetime.signal, workspacePath }).catch((error: unknown) => { throw lifetime.signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error @@ -247,6 +247,9 @@ export function createAgentModelCatalogService( ? await sessionWorkspacePath(deps, record) : null : params.workspacePath + // A listing that opens a session opens it in this chat's folder. + const list = (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome) => + listWith(probe, home, workspacePath ?? undefined) await recordingDefaults.get(fingerprint) let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] @@ -280,7 +283,7 @@ export function createAgentModelCatalogService( // Past its TTL, only the probe re-derives a held reason. The reason is served meanwhile; // only a read that asks waits for the probe's answer. if (probe && home && !lifetime.signal.aborted && deps.store.heldReasonDue(fingerprint)) { - const probing = deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) + const probing = deps.store.refresh(fingerprint, params.agent, probe, list(probe, home)) if (!params.waitForListing) { return answer(entry, { listingInProgress: true }) } @@ -291,9 +294,9 @@ export function createAgentModelCatalogService( let listing = !entry && home ? deps.store.pendingListing(fingerprint) : null if (probe && home && !lifetime.signal.aborted) { if (entry && deps.store.shouldRefresh(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) + void deps.store.refresh(fingerprint, params.agent, probe, list(probe, home)) } else if (!entry && !listing && !deps.store.hasActiveFailure(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) + void deps.store.refresh(fingerprint, params.agent, probe, list(probe, home)) listing = deps.store.pendingListing(fingerprint) } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index 6a2a6e58192b..d9d4cddac167 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -48,11 +48,12 @@ export type { AgentModelCatalogFailure } export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 -/** Lists an agent's models without a session, under the account a launch would pin. `signal` - * stops the listing and its child once the host that asked is going away. */ +/** Lists an agent's models before a chat, under the account a launch would pin. `signal` stops + * the listing and its child once the host that asked is going away; `workspacePath` is the folder + * of the chat that asked, where a listing that opens a session runs. */ export type AgentModelCatalogProbe = ( accountHome: AgentSessionAccountHome, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; workspacePath?: string } ) => Promise /** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ From 706c31591839cc336c7babef3e936053d2d1ed91 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:53 +0000 Subject: [PATCH 4/4] fix(opencode): never prewarm a model list that needs a chat's folder A picker opened during a startup refresh joined a listing that had no chat folder to open its session in, so it was answered with no models. Registrations now mark a listing that runs in the asking chat's folder, and the prewarm skips it, so the picker's own read lists in its folder. bb likewise keeps its folder-less prewarm apart from folder reads. --- .../agent-model-catalog-discovery.ts | 3 ++ .../agent-model-catalog-prewarm.test.ts | 41 +++++++++++++++++++ .../agent-model-catalog-service.ts | 6 ++- ...ured-agent-model-catalog-discovery.test.ts | 9 ++++ ...tructured-agent-model-catalog-discovery.ts | 3 ++ .../structured-agent-model-catalog-wiring.ts | 7 +++- 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts index a2c2ff6b8e42..7cf639474972 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -13,5 +13,8 @@ export type AgentModelCatalogDiscovery = /** The listing marks the model the account is configured to run as its default, so a new * chat launches the listed default. False where the agent's own settings may pick another. */ listingNamesConfiguredModel: boolean + /** The listing opens a session in the asking chat's folder, so a prewarm, which has no chat, + * never runs it and a picker never waits on one that cannot list. */ + listsInChatFolder?: true } | { kind: 'unavailable'; reason: string } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index f9943e6cdd6a..3ef2a33b563f 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -134,6 +134,47 @@ describe('model catalog prewarm', () => { expect(read.origin).toBe('probe') }) + // A picker that joined a prewarm listing with no chat folder was answered nothing at all. + it('never prewarms a listing that runs in a chat folder, so a picker lists in its own', async () => { + const store = new AgentModelCatalogStore() + const versionChecked = deferred() + const probe = vi.fn( + async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) => { + await versionChecked.promise + if (!options?.workspacePath) { + throw new AgentModelCatalogListingDeferredError('lists in a chat folder') + } + return listing('big-pickle') + } + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'), + probes: { opencode2: probe }, + listsInChatFolder: new Set(['opencode2']) + }) + + const prewarmed = service.prewarm() + const opened = await service.read({ agent: 'opencode2', workspacePath: '/work/project' }) + const waited = service.read({ + agent: 'opencode2', + workspacePath: '/work/project', + waitForListing: true + }) + versionChecked.resolve() + await prewarmed + expect(opened).toEqual({ origin: 'unknown', listingInProgress: true }) + expect((await waited).origin).toBe('probe') + expect(probe).toHaveBeenCalledTimes(1) + expect(probe).toHaveBeenCalledWith(HOME('OPENCODE_HOME', '/homes/a'), { + signal: expect.any(AbortSignal), + workspacePath: '/work/project' + }) + }) + it('lists the new account after a switch and leaves the old account’s catalog alone', async () => { const store = new AgentModelCatalogStore() let selected = '/homes/a' diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 4424a405cbfb..e40c97d1aeb0 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -33,6 +33,8 @@ export type AgentModelCatalogServiceDeps = { probes?: Readonly>> /** Agents whose listing marks the model the account is configured to run as its default. */ listingNamesConfiguredModel?: ReadonlySet + /** Agents whose listing runs in the asking chat's folder; a prewarm skips them. */ + listsInChatFolder?: ReadonlySet /** Where a session's provider runs, for deciding whether its own config scope is the account's. */ recordWorkspacePath?: (record: AgentSessionRecord) => Promise /** False for an agent whose model no project config can pick; its default holds everywhere. */ @@ -346,7 +348,9 @@ export function createAgentModelCatalogService( const probes = deps.probes ?? {} // An agent never used here waits for its first chat, which shows the quiet placeholder. const used = Object.keys(probes).filter( - (agent) => deps.store.hasEntryForAgent(agent) || deps.hasChatRecords?.(agent) === true + (agent) => + !deps.listsInChatFolder?.has(agent) && + (deps.store.hasEntryForAgent(agent) || deps.hasChatRecords?.(agent) === true) ) await runBounded(used, PREWARM_CONCURRENCY, lifetime.signal, async (agent) => { const probe = probes[agent] diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index 31608a4ff3db..b4f8dab3b0f0 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -82,6 +82,15 @@ describe('the model catalog contract on every registration', () => { expect([...listingNamesConfiguredModel].sort()).toEqual(['codex']) }) + // OpenCode 2.x lists in a session opened in the chat's folder, which a prewarm does not have. + it('marks the agents whose listing runs in a chat folder', () => { + const { listsInChatFolder } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + expect([...listsInChatFolder].sort()).toEqual(['opencode']) + }) + it('maps an ACP agent with no session-free listing to an unavailable registration', () => { const spec = acpLaunchSpecFor('grok')! const discovery = acpModelCatalogDiscovery( diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts index 632339b57f88..ac9ecab6f3e9 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -64,9 +64,12 @@ export function acpModelCatalogDiscovery( if (discovery.kind === 'unavailable') { return { kind: 'unavailable', reason: discovery.reason } } + const listsInSession = + discovery.kind === 'session' || (discovery.kind === 'command' && discovery.otherReleases) return { kind: 'probe', listingNamesConfiguredModel: discovery.listingNamesConfiguredModel, + ...(listsInSession ? { listsInChatFolder: true as const } : {}), probe: createAcpModelCatalogProbe(spec, { resolveEnvironment: environment.resolveBaseEnvironment, refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index f72f5eb4b4f8..83cb796ba488 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -47,9 +47,11 @@ export function registeredModelCatalogDiscovery( ): { probes: Record listingNamesConfiguredModel: Set + listsInChatFolder: Set } { const probes: Record = {} const listingNamesConfiguredModel = new Set() + const listsInChatFolder = new Set() for (const registration of registrations) { const { agent } = registration.definition const discovery = registration.modelCatalog(context) @@ -58,9 +60,12 @@ export function registeredModelCatalogDiscovery( if (discovery.listingNamesConfiguredModel) { listingNamesConfiguredModel.add(agent) } + if (discovery.listsInChatFolder) { + listsInChatFolder.add(agent) + } } } - return { probes, listingNamesConfiguredModel } + return { probes, listingNamesConfiguredModel, listsInChatFolder } } /**