diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 7083bef1115b..3db522eb0133 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,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 through + * `otherReleases`, else not at all and its chats' own lists fill the catalog. Absent lists + * on every release. */ + supportsVersion?(version: string): 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 @@ -142,15 +154,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,16 +175,19 @@ 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. + // 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 + listingNamesConfiguredModel: false, + // 2.x rejects `--verbose`, and its own listings omit providers that report after startup. + supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE), + 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 7afa57e763ac..ee33e01a5b8b 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, @@ -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' @@ -90,6 +91,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 }) @@ -158,7 +220,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 +240,52 @@ 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' } }, + { workspacePath: '/work/project' } + ) + expect(agent.frames.map((frame) => frame.method)).toEqual([ + 'initialize', + 'session/new', + 'session/delete' + ]) + // 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() + 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' } }, + { 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.toBeInstanceOf(AgentModelCatalogListingDeferredError) + expect(launches).toEqual([]) + expect(agent.frames).toEqual([]) + }) + 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..6a94e82748e6 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -8,15 +8,18 @@ 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' -import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpLaunchSpec, AcpModelDiscovery } from './acp-launch-specs' +import type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' import { - acpLaunchVersionSupported, + 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,16 +29,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. + * 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, @@ -53,34 +67,50 @@ 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`) } + 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) - } else { - const launch = { - command, - args: spec.args({ fullAccess: false, pluginDir: null }), - cwd, - env, - envToDelete + 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, workspace), + run + ) + } else { 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) { @@ -89,3 +119,34 @@ 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 ?? null +} + +/** What a new chat in `workspace` offers, read from a session opened only to be deleted. */ +async function readSessionModels( + spec: AcpLaunchSpec, + connection: AcpModelCatalogSessionConnection, + 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: 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) + 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) 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/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-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..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 @@ -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,84 @@ 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') + }) + + // 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 de5a5c26b60c..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. */ @@ -210,9 +212,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 +249,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 +285,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 +296,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) } } @@ -343,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/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. */ 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' 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 } } /**