Skip to content
Closed
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
37 changes: 25 additions & 12 deletions src/main/acp/acp-launch-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down
112 changes: 110 additions & 2 deletions src/main/acp/acp-model-catalog-probe.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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,
type AcpModelCatalogProbeDeps
} 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'

Expand Down Expand Up @@ -90,6 +91,67 @@ function scriptedGrok(initializeMeta: Record<string, unknown>): {
}
}

function scriptedOpenCode2(canDelete: boolean): {
agent: AcpScriptedAgent
launches: ProviderProcessLaunch[]
deps: AcpModelCatalogProbeDeps & {
runListing: Mock<NonNullable<AcpModelCatalogProbeDeps['runListing']>>
}
} {
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<NonNullable<AcpModelCatalogProbeDeps['runListing']>>(),
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 })
Expand Down Expand Up @@ -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
Expand All @@ -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, {
Expand Down
107 changes: 84 additions & 23 deletions src/main/acp/acp-model-catalog-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -26,16 +29,27 @@ export type AcpModelCatalogConnection = {
close(): Promise<unknown>
}

/** 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<AcpSessionStarted>
deleteSession(sessionId: string): Promise<unknown>
}

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,
Expand All @@ -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) {
Expand All @@ -89,3 +119,34 @@ export function createAcpModelCatalogProbe(
return { models, origin: 'probe' }
}
}

type AcpModelListing = Exclude<AcpModelDiscovery, { kind: 'unavailable' }>

/** 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<AgentSessionModelOption[]> {
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
}
3 changes: 3 additions & 0 deletions src/main/acp/acp-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcpSessionStarted> {
if (this.started) {
return Promise.resolve(this.started)
Expand Down
2 changes: 1 addition & 1 deletion src/main/acp/acp-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> },
wantVersion: boolean,
Expand Down
Loading
Loading