feat(opencode): open OpenCode 2 in the chat view and list 2.x models before the first message (#28424)

This commit is contained in:
Kelvin Amoaba
2026-10-18 18:42:23 +00:00
committed by GitHub
parent b6325ef951
commit e43f3e7e04
80 changed files with 2307 additions and 471 deletions
@@ -14,6 +14,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
grok: require('../../../src/shared/agent-icons/grok.png'),
copilot: require('../../../src/shared/agent-icons/copilot.png'),
opencode: require('../../../src/shared/agent-icons/opencode.png'),
opencode2: require('../../../src/shared/agent-icons/opencode.png'),
'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
ante: require('../../../src/shared/agent-icons/ante.png'),
trae: require('../../../src/shared/agent-icons/trae.png'),
@@ -135,4 +135,33 @@ describe('copying a chat conversation for the CLI', () => {
).rejects.toThrow('OMP could not copy this chat')
expect(children.map((child) => child.closes)).toEqual([1])
})
// A copy lands in this host's OpenCode store; a CLI sent to a server would not find it.
it.each(['opencode', 'opencode2'] as const)(
'refuses to copy a %s chat whose arguments route to a server, before starting OpenCode',
async (agent) => {
const spec = acpLaunchSpecFor(agent)!
const connect = vi.fn()
await expect(
copyAcpConversation(
{
spec,
dataDirectory: '/orca-data',
resolveLaunchArgs: () => ['--server', 'https://other-host:4096'],
readLaunch: async () => {
throw new Error('not reached')
},
connect
},
{
...input,
identity: { ...input.identity, agent },
accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } }
},
{ track: () => {} }
)
).rejects.toThrow('--server')
expect(connect).not.toHaveBeenCalled()
}
)
})
+5 -9
View File
@@ -1,8 +1,5 @@
import { normalizeAgentProviderSession } from '../../shared/agent-session-resume'
import {
assertOpenCodeCopyUsesLocalStore,
OpenCodeCopyServerRouteError
} from '../../shared/opencode-launch-command'
import { OpenCodeCopyServerRouteError } from '../../shared/opencode-launch-command'
// The chat protocol saves an independent conversation; its ordinary CLI opens that copy.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import {
@@ -48,12 +45,11 @@ export async function copyAcpConversation(
let connection: AcpStructuredConnection | null = null
try {
signal.throwIfAborted()
if (deps.spec.agent === 'opencode') {
const args = await waitForPromiseWithSignal(
Promise.resolve(deps.resolveLaunchArgs?.() ?? []),
signal
const assertCopyLaunchArgs = deps.spec.assertCopyLaunchArgs
if (assertCopyLaunchArgs) {
assertCopyLaunchArgs(
await waitForPromiseWithSignal(Promise.resolve(deps.resolveLaunchArgs?.() ?? []), signal)
)
assertOpenCodeCopyUsesLocalStore(args)
}
signal.throwIfAborted()
const launch = await waitForPromiseWithSignal(deps.readLaunch(input.identity), signal)
+76 -30
View File
@@ -38,6 +38,7 @@ import { GROK_SESSION_HISTORY } from '../grok/grok-session-history'
import { GROK_TRANSCRIPT_IMPORT } from '../grok/grok-transcript-import'
import { OPENCODE_SESSION_HISTORY } from '../opencode/opencode-session-history'
import { OPENCODE_TRANSCRIPT_IMPORT } from '../opencode/opencode-transcript-import'
import { assertOpenCodeCopyUsesLocalStore } from '../../shared/opencode-launch-command'
import { OMP_SESSION_HISTORY } from '../omp/omp-session-history'
import { OMP_TRANSCRIPT_IMPORT } from '../omp/omp-transcript-import'
import {
@@ -57,8 +58,9 @@ import { readAntigravitySignInLine } from '../antigravity/antigravity-sign-in-ur
import type { AcpSignInWatch } from './acp-agent-connection'
import type { AcpLaunchGate } from './acp-launch-gate'
/** 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'
@@ -76,9 +78,29 @@ export type AcpModelDiscovery =
args: readonly string[]
parse(stdout: string): AgentSessionModelOption[]
listingNamesConfiguredModel: boolean
/** The releases whose listing matches what their chats offer; any other lists in a session
* when `otherReleasesListInSession`, else not at all and its chats' own lists fill the
* catalog. Absent lists on every release. */
supportsVersion?(version: string): boolean
otherReleasesListInSession?: true
}
| AcpSessionModelDiscovery
| { kind: 'unavailable'; reason: string }
/** What a new chat offers, read from a session opened in the asking chat's folder and then
* deleted. 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 }
/** Whether some release lists in a session in the asking chat's folder, so the listing needs one
* and reads that folder's project config. */
export function acpListingNeedsChatFolder(discovery: AcpModelDiscovery): boolean {
return (
discovery.kind === 'session' ||
(discovery.kind === 'command' && discovery.otherReleasesListInSession === true)
)
}
export type AcpLaunchSpec = {
/** The Orca agent id, which names the agent's records, its catalog label and its settings. */
agent: TuiAgent
@@ -124,6 +146,9 @@ export type AcpLaunchSpec = {
imagePrompts?: true
/** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */
compaction?: true
/** Throws when the chat's own arguments would send a copied conversation's CLI elsewhere than
* the store the copy is written to. */
assertCopyLaunchArgs?: (args: readonly string[]) => void
/** The agent's own store of a session's user messages, read for restart recovery only. */
readStoredUserMessages?: AcpStoredUserMessagesReader
modelDiscovery: AcpModelDiscovery
@@ -176,45 +201,65 @@ 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
function openCodeLaunchSpec(
row: Pick<AcpLaunchSpec, 'agent' | 'command' | 'supportsVersion' | 'modelDiscovery'>
): AcpLaunchSpec {
return {
...row,
// OpenCode has no bypass flag: full access answers each permission request yes.
args: () => ['acp'],
// Applied last: the client name ACP sessions report, and no question tool, since Orca shows no
// question form yet (1.x would wait on one forever; 2.x drops it).
env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' },
scrubEnvironment: scrubOpenCodeAcpEnvironment,
dialect: OPENCODE_ACP_DIALECT,
loginCommand: agentSessionSignInFor(row.agent)?.loginCommand ?? [],
account: openCodeAcpAccountBinding(),
installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')],
imagePrompts: true,
readStoredUserMessages: openCodeStoredUserMessagesReader(),
visualsSkill: loadOpenCodeVisualsSkill,
compaction: true,
assertCopyLaunchArgs: assertOpenCodeCopyUsesLocalStore,
// Finds a conversation of either line in the one database both lines write.
transcriptImport: OPENCODE_TRANSCRIPT_IMPORT,
sessionHistory: OPENCODE_SESSION_HISTORY
}
}
const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = {
const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({
agent: 'opencode',
command: 'opencode',
// OpenCode has no bypass flag: full access answers each permission request yes.
args: () => ['acp'],
// Applied last: the client name ACP sessions report, and no question tool, which ACP cannot
// answer (it would wait forever).
env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' },
scrubEnvironment: scrubOpenCodeAcpEnvironment,
dialect: OPENCODE_ACP_DIALECT,
loginCommand: agentSessionSignInFor('opencode')?.loginCommand ?? [],
account: openCodeAcpAccountBinding(),
installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')],
supportsVersion: (version) =>
OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)),
imagePrompts: true,
readStoredUserMessages: openCodeStoredUserMessagesReader(),
// Its `initialize` names no models and `session/new` stores a session; the listing does neither.
[OPENCODE_1X_LINE, OPENCODE_2X_LINE].some((line) => isStableCliVersionOnLine(version, line)),
// 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
},
visualsSkill: loadOpenCodeVisualsSkill,
compaction: true,
transcriptImport: OPENCODE_TRANSCRIPT_IMPORT,
sessionHistory: OPENCODE_SESSION_HISTORY
}
listingNamesConfiguredModel: false,
// 2.x rejects `--verbose`, and its own listings omit providers that report after startup.
supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE),
otherReleasesListInSession: true
}
})
// Since 2.0.0 `opencode2` is an alias OpenCode's installer writes beside `opencode`, and the only
// name for 2.x where `opencode` is still 1.x. Its chats share OpenCode's account and history rows.
const OPENCODE2_LAUNCH_SPEC = openCodeLaunchSpec({
agent: 'opencode2',
command: 'opencode2',
supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_2X_LINE),
modelDiscovery: { kind: 'session', listingNamesConfiguredModel: false }
})
// OMP serves ACP through `omp acp`; its environment reaches it as the user set it.
const OMP_LAUNCH_SPEC: AcpLaunchSpec = {
@@ -295,6 +340,7 @@ export const ANTIGRAVITY_LAUNCH_SPEC = {
export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [
GROK_LAUNCH_SPEC,
OPENCODE_LAUNCH_SPEC,
OPENCODE2_LAUNCH_SPEC,
OMP_LAUNCH_SPEC,
ANTIGRAVITY_LAUNCH_SPEC
]
+191 -6
View File
@@ -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 { AgentModelCatalogListingStoppedError } 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,73 @@ function scriptedGrok(initializeMeta: Record<string, unknown>): {
}
}
function scriptedOpenCode2(canDelete: boolean): {
agent: AcpScriptedAgent
launches: ProviderProcessLaunch[]
closed: () => number
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[] = []
let closes = 0
return {
agent,
launches,
closed: () => closes,
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 () => {
closes += 1
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,17 +226,22 @@ 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
})
const success = await probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })
const success = await probe(
{ kind: 'opencode', locator: { kind: 'unmanaged' } },
{ workspacePath: '/work/project' }
)
expect(connect).not.toHaveBeenCalled()
expect(runListing).toHaveBeenCalledWith(
expect.objectContaining({
command: '/opt/opencode/bin/opencode',
args: ['models', '--verbose'],
// Where the project's opencode.json is read, so it lists what a chat there offers.
cwd: '/work/project',
env: expect.objectContaining({ OPENCODE_CLIENT: 'acp' })
}),
expect.objectContaining({ site: 'opencode-model-catalog-probe' })
@@ -178,6 +251,100 @@ 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(AgentModelCatalogListingStoppedError)
expect(launches).toEqual([])
expect(agent.frames).toEqual([])
})
it('keeps the models, and says so, when OpenCode 2.x refuses to delete the listing session', async () => {
const { agent, deps } = scriptedOpenCode2(true)
agent.on('session/delete', (frame) => agent.fail(frame, -32603, 'locked'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const success = await createAcpModelCatalogProbe(OPENCODE, deps)(
{ kind: 'opencode', locator: { kind: 'unmanaged' } },
{ workspacePath: '/work/project' }
)
expect(success.models).toHaveLength(2)
expect(warn).toHaveBeenCalledWith(
'[acp-model-catalog] opencode kept its listing session:',
expect.anything()
)
warn.mockRestore()
})
// OpenCode may already have saved the session it never named; nothing is left to delete it by.
it('stops a listing cut off while OpenCode 2.x creates its session, without deleting', async () => {
const { agent, deps, closed } = scriptedOpenCode2(true)
agent.on('session/new', () => {})
const stop = new AbortController()
const listing = createAcpModelCatalogProbe(OPENCODE, deps)(
{ kind: 'opencode', locator: { kind: 'unmanaged' } },
{ workspacePath: '/work/project', signal: stop.signal }
)
await vi.waitFor(() =>
expect(agent.frames.map((frame) => frame.method)).toContain('session/new')
)
stop.abort()
await expect(listing).rejects.toThrow(/listing stopped/)
expect(closed()).toBe(1)
expect(agent.frames.map((frame) => frame.method)).not.toContain('session/delete')
})
it('asks an OpenCode 2 chat nothing, not even its version, without a chat folder', async () => {
const { deps, launches } = scriptedOpenCode2(true)
const probeVersion = vi.fn(deps.probeVersion)
const spec = { ...acpLaunchSpecFor('opencode2')!, account: OPENCODE.account }
await expect(
createAcpModelCatalogProbe(spec, { ...deps, probeVersion })({
kind: 'opencode',
locator: { kind: 'unmanaged' }
})
).rejects.toBeInstanceOf(AgentModelCatalogListingStoppedError)
expect(probeVersion).not.toHaveBeenCalled()
expect(launches).toEqual([])
})
it('lists nothing for a release that runs no structured chat', async () => {
const runListing = vi.fn()
const probe = createAcpModelCatalogProbe(OPENCODE, {
@@ -187,9 +354,27 @@ describe('ACP model catalog probes', () => {
probeVersion: async () => false,
runListing
})
await expect(probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })).rejects.toThrow(
/does not run structured chats/
)
await expect(
probe(
{ kind: 'opencode', locator: { kind: 'unmanaged' } },
{ workspacePath: '/work/project' }
)
).rejects.toThrow(/does not run structured chats/)
expect(runListing).not.toHaveBeenCalled()
})
// The binary may be 2.x, which lists only in a chat's folder; nothing is spawned to find out.
it('asks `opencode` nothing, not even its version, without a chat folder', async () => {
const probeVersion = vi.fn(async () => true)
const probe = createAcpModelCatalogProbe(OPENCODE, {
resolveEnvironment: async () => ({ PATH: '/usr/bin' }),
dataDirectory: '/orca-data',
resolveCommand: () => '/opt/opencode/bin/opencode',
probeVersion
})
await expect(
probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })
).rejects.toBeInstanceOf(AgentModelCatalogListingStoppedError)
expect(probeVersion).not.toHaveBeenCalled()
})
})
+99 -24
View File
@@ -8,15 +8,22 @@ import {
runAgentModelCatalogListing,
runAgentModelCatalogSession
} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner'
import { AgentModelCatalogListingStoppedError } 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 {
acpLaunchVersionSupported,
acpListingNeedsChatFolder,
type AcpLaunchSpec,
type 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,16 +33,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,
@@ -47,40 +65,59 @@ export function createAcpModelCatalogProbe(
if (discovery.kind === 'unavailable') {
throw new Error(discovery.reason)
}
// A listing reads no workspace: the catalog is the account's, whichever chat asks.
const workspace = options?.workspacePath
// Known without spawning anything: a listing that may open a session needs a chat's folder.
if (!workspace && acpListingNeedsChatFolder(discovery)) {
throw folderlessListing(spec)
}
// Spawned from home; a listing reads a project's config only in the chat folder it is asked for.
const cwd = deps.homePath ?? homedir()
const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, {
...deps,
resolveWorkingDirectory: () => cwd
})
if (
!(await acpLaunchVersionSupported(
spec,
{ command, cwd, env },
deps.probeVersion ?? probeAgentCliVersion
))
) {
const release = await probeAcpLaunchVersion(
spec,
{ command, cwd, env },
discovery.kind === 'command' && discovery.supportsVersion !== 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: workspace ?? 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') {
if (!workspace) {
throw folderlessListing(spec)
}
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 +126,41 @@ export function createAcpModelCatalogProbe(
return { models, origin: 'probe' }
}
}
const folderlessListing = (spec: AcpLaunchSpec) =>
new AgentModelCatalogListingStoppedError(`${spec.agent} lists in a chat's folder`)
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.otherReleasesListInSession
? { kind: 'session', listingNamesConfiguredModel: discovery.listingNamesConfiguredModel }
: 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: [] })
// Best effort: a failed delete, or a listing stopped mid-create, leaves one empty conversation.
await connection.deleteSession(started.sessionId).catch((error: unknown) => {
console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error)
})
const options = new AcpStructuredOptions(spec.dialect)
options.adoptSession(started.response, 'new')
return options.read().models
}
+3
View File
@@ -150,6 +150,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)
@@ -93,7 +93,7 @@ describe('ACP compaction capability', () => {
acpStructuredAgentDefinition(spec).capabilities.compact
])
)
).toEqual({ grok: true, opencode: true, omp: true, antigravity: false })
).toEqual({ grok: true, opencode: true, opencode2: true, omp: true, antigravity: false })
expect(acpStructuredAgentDefinition(withoutCompaction(GROK)).capabilities.compact).toBe(false)
})
@@ -15,7 +15,7 @@ describe('a live Grok listing', () => {
it('is saved through the host catalog when the chat reads its options', async () => {
const modelCatalog: AgentModelCatalogService = {
read: vi.fn(async () => ({ origin: 'unknown' as const })),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => {}),
stop: vi.fn(),
@@ -39,7 +39,7 @@ describe('a live Grok listing', () => {
it('starts the chat even when saving its start listing throws', async () => {
const modelCatalog: AgentModelCatalogService = {
read: vi.fn(async () => ({ origin: 'unknown' as const })),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn().mockImplementationOnce(() => {
throw new Error('catalog unavailable')
}),
@@ -7,7 +7,7 @@ import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-
import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor'
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'
import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment'
import { acpLaunchSpecFor } from './acp-launch-specs'
import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
const PROFILE = '123e4567-e89b-42d3-a456-426614174000'
@@ -60,9 +60,10 @@ function resolver(
inheritedEnv?: NodeJS.ProcessEnv
base?: Record<string, string>
probeVersion?: Parameters<typeof createAcpStructuredLaunchResolver>[1]['probeVersion']
spec?: AcpLaunchSpec
} = {}
) {
return createAcpStructuredLaunchResolver(OPENCODE, {
return createAcpStructuredLaunchResolver(options.spec ?? OPENCODE, {
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
readJournal: () => null,
resolveWorkspacePath: async () => '/repo/worktree',
@@ -252,3 +253,36 @@ describe('OpenCode ACP launch resolution', () => {
expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }])
})
})
// Since OpenCode 2.0.0 `opencode2` is an alias the 2.x installer writes beside `opencode`.
describe('OpenCode 2 ACP launch resolution', () => {
const OPENCODE2 = {
...acpLaunchSpecFor('opencode2')!,
account: openCodeAcpAccountBinding(() => managedAccounts)
}
const launchOn = (version: string) =>
resolver(
{ ...openCodeRecord(UNMANAGED), provider: 'opencode2' },
{ spec: OPENCODE2, probeVersion: async (_input, supports) => supports(version) }
)({ identity: { ...identity, agent: 'opencode2' } })
it('runs `opencode2 acp` as an ACP client, and names its own sign-in command', async () => {
const launch = await launchOn('2.0.28')
expect(launch).toMatchObject({ command: '/resolved/opencode2', args: ['acp'] })
expect(launch.env).toMatchObject({ OPENCODE_CLIENT: 'acp' })
expect(OPENCODE2.loginCommand).toEqual(['opencode2', 'auth', 'login'])
})
// 1.x, below the 2.x floor, and a pre-release: none is the OpenCode 2 this row runs.
it.each(['1.18.35', '2.0.13', '2.1.0-beta.1'])(
'refuses %s before spawning it',
async (version) => {
await expect(launchOn(version)).rejects.toMatchObject({
refusal: {
code: 'structured_agent_session_unsupported',
details: { reason: 'hostUnsupported' }
}
})
}
)
})
@@ -143,7 +143,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,
@@ -1,5 +1,6 @@
// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31): each
// recording plays back as the agent, and the assertions read the journal a client would see.
// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31, and 2.0.29 for the
// `opencode-v2-*` ones): each recording plays back as the agent, and the assertions read the
// journal a client would see.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
@@ -27,10 +28,10 @@ const ask: AgentJournalMessageItem = {
blocks: [{ type: 'text', text: 'Fixture prompt' }]
}
async function replaying(name: string) {
async function replaying(name: string, agent: 'opencode' | 'opencode2' = 'opencode') {
const replay = new GrokFixtureReplay(await readAcpFixture(name))
const rig = await openAcpAdapterRig({
spec: acpLaunchSpecFor('opencode')!,
spec: acpLaunchSpecFor(agent)!,
script: (scripted) => replay.attach(scripted)
})
await rig.acquire()
@@ -127,3 +128,51 @@ describe('OpenCode recordings through the adapter', () => {
expect(texts).toContain('CAPTURE_PROVIDER_400')
})
})
describe('OpenCode 2 recordings through the adapter', () => {
it('an approved shell command completes with its output and exit code', async () => {
const { rig, replay } = await replaying('opencode-v2-tool', 'opencode2')
const approval = await answer(rig, replay, 'once')
expect(approval.body).toMatchObject({
kind: 'approval',
options: [
{ id: 'once', label: 'Allow once' },
{ id: 'always', label: 'Always allow' },
{ id: 'reject', label: 'Reject' }
]
})
await waitFor(() => expect(replay.awaiting).toBeNull())
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }])
)
expect((await rowsOf(rig, 'tool-call')).at(-1)?.body).toMatchObject({
state: 'completed',
exitCode: 0
})
})
// Unlike 1.x, 2.x ends the turn when a permission is rejected: it answers `cancelled`.
it('a rejected permission fails its tool and OpenCode ends the turn as cancelled', async () => {
const { rig, replay } = await replaying('opencode-v2-deny', 'opencode2')
await answer(rig, replay, 'reject')
await waitFor(() => expect(replay.awaiting).toBeNull())
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }])
)
expect((await rowsOf(rig, 'tool-call')).at(-1)?.body).toMatchObject({ state: 'failed' })
})
it('Stop during an approved command cancels it and ends the turn', async () => {
const { rig, replay } = await replaying('opencode-v2-stop', 'opencode2')
await answer(rig, replay, 'once')
await waitFor(() => expect(replay.awaiting).toBe('session/cancel'))
await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({
cancelled: true
})
await waitFor(() => expect(replay.awaiting).toBeNull())
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }])
)
expect((await rowsOf(rig, 'tool-call')).at(-1)?.body).toMatchObject({ state: 'failed' })
})
})
@@ -9,7 +9,7 @@ import {
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import type { AcpRequestPresentation } from './acp-dialects/acp-dialect'
import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect'
import { AcpTimelineTranslator } from './acp-timeline-translator'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view'
@@ -70,11 +70,10 @@ function fixtureAnswer(
}
/** Captured frames go through translation, assembly and the real serialized journal. */
export async function openAcpFixtureRig() {
export async function openAcpFixtureRig(dialect: AcpDialect = GROK_ACP_DIALECT) {
const rig = await openProviderTimelineRig()
let providerSessionId = 'session-1'
const translator = () =>
new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT })
const translator = () => new AcpTimelineTranslator({ sessionId: providerSessionId, dialect })
let lane = translator()
const records = acpSubagentRecords()
// What each subagent's saved row last said: its host record, kept past the record's retirement.
@@ -5,6 +5,7 @@ import {
messageText
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support'
import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect'
afterEach(closeProviderTimelineRigs)
@@ -106,6 +107,33 @@ describe('recorded ACP traffic through the journal', () => {
expect(await fixture.rig.turns()).toHaveLength(2)
})
// OpenCode 2.0.29: three turns, then a restart whose `session/load` replays them before a follow-up.
it("keeps an OpenCode 2 chat's history once across a reopen, and adds the follow-up turn", async () => {
const fixture = await openAcpFixtureRig(OPENCODE_ACP_DIALECT)
const frames = await readAcpFixture('opencode-v2-reopen')
await fixture.feed(frames.filter((frame) => frame.process === 'A'))
const before = await fixture.rig.rows()
expect((await fixture.rig.turns()).map((turn) => turn.outcome)).toEqual([
'success',
'cancellation',
'cancellation'
])
await fixture.restart()
const loading = frames.filter((frame) => frame.process === 'B')
const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt')
await fixture.feed(loading.slice(0, nextPrompt))
fixture.finishLoad()
const items = (rows: typeof before) => rows.filter((row) => row.body.kind !== 'turn')
expect(items(await fixture.rig.rows())).toEqual(items(before))
await fixture.feed(loading.slice(nextPrompt))
expect((await fixture.rig.turns()).map((turn) => turn.outcome)).toEqual([
'success',
'cancellation',
'cancellation',
'success'
])
})
it('answers questions with the recorded reply shape and shows the plan as a plan, not a gate', async () => {
const fixture = await openAcpFixtureRig()
const rows = await fixture.feed(await readAcpFixture('s5-plan-approved'))
@@ -0,0 +1,13 @@
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Run the shell tool with exactly: printf \"DENY-ME\\n\". If the command is refused, reply with exactly the word REFUSED and do nothing else."}]}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":"The user wants me"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":" to run `printf \"DENY-ME\\n\""}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":"`. This is a harmless"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":" command. Let me run"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":" it."}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_5c544d5c1c284ed0918121d5","name":"shell","title":"shell","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5c544d5c1c284ed0918121d5","status":"in_progress","kind":"execute","title":"printf \"DENY-ME\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_5c544d5c1c284ed0918121d5","name":"shell","title":"printf \"DENY-ME\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}}
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5c544d5c1c284ed0918121d5","status":"failed","kind":"execute","title":"printf \"DENY-ME\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"},"content":[{"type":"content","content":{"type":"text","text":"The user declined this tool call"}}],"rawOutput":{"metadata":{},"error":"The user declined this tool call"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":12034,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","usage":{"inputTokens":77,"outputTokens":25,"totalTokens":12034,"thoughtTokens":28,"cachedReadTokens":11904},"_meta":{}}}}
@@ -0,0 +1,72 @@
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Run the shell tool with exactly: printf \"TOOL-OK\\n\". Then reply briefly with the marker it printed. Do not run anything else."}]}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opencode/big-pickle","options":[{"value":"opencode/beam-free","name":"opencode/Beam Free"},{"value":"opencode/big-pickle","name":"opencode/Big Pickle"},{"value":"opencode/deepseek-v4.1-flash-free","name":"opencode/DeepSeek V4.1 Flash Free"},{"value":"opencode/glm-5.3-flash-free","name":"opencode/GLM-5.3-Flash Free"},{"value":"opencode/longcat-2.5-preview-free","name":"opencode/LongCat 2.5 Preview Free"},{"value":"opencode/mimo-v2.6-flash-free","name":"opencode/MiMo-V2.6-Flash Free"},{"value":"opencode/nemotron-3-ultra-free","name":"opencode/Nemotron 3 Ultra Free"},{"value":"opencode/nemotron-3.5-lightning-free","name":"opencode/Nemotron 3.5 Lightning Free"},{"value":"opencode/nemotron-3.5-super-vl-free","name":"opencode/Nemotron 3.5 Super VL Free"},{"value":"opencode/space-bunny-free","name":"opencode/Space Bunny Free"}]},{"id":"mode","name":"Session Mode","category":"mode","type":"select","currentValue":"build","options":[{"value":"build","name":"Build","description":"The default agent. Executes tools based on configured permissions."},{"value":"plan","name":"Plan","description":"Read-only agent for exploring the codebase and planning work before implementation."}]}]}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":"The user wants"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":" me to run the shell tool with exactly"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":" `printf \"TOOL-OK\\n\"`."}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_54ffbe02d04b4842a2494b02","name":"shell","title":"shell","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_54ffbe02d04b4842a2494b02","name":"shell","title":"printf \"TOOL-OK\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}}
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"completed","locations":[{"path":"/private/tmp/oc2-proj-a"}],"content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}],"rawOutput":{"metadata":{"status":"completed","truncated":false,"exit":0}}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym:reasoning:0","content":{"type":"text","text":"The tool printed TOOL"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym:reasoning:0","content":{"type":"text","text":"-OK."}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym","content":{"type":"text","text":"TOOL-OK"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":11941,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","usage":{"inputTokens":7667,"outputTokens":31,"totalTokens":23856,"thoughtTokens":30,"cachedReadTokens":16128},"_meta":{}}}}
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Run the shell tool with exactly: printf \"DENY-ME\\n\". If the command is refused, reply with exactly the word REFUSED and do nothing else."}]}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":"The user wants me"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":" to run `printf \"DENY-ME\\n\""}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":"`. This is a harmless"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":" command. Let me run"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":" it."}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_5c544d5c1c284ed0918121d5","name":"shell","title":"shell","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5c544d5c1c284ed0918121d5","status":"in_progress","kind":"execute","title":"printf \"DENY-ME\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_5c544d5c1c284ed0918121d5","name":"shell","title":"printf \"DENY-ME\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}}
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5c544d5c1c284ed0918121d5","status":"failed","kind":"execute","title":"printf \"DENY-ME\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"},"content":[{"type":"content","content":{"type":"text","text":"The user declined this tool call"}}],"rawOutput":{"metadata":{},"error":"The user declined this tool call"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":12034,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","usage":{"inputTokens":77,"outputTokens":25,"totalTokens":12034,"thoughtTokens":28,"cachedReadTokens":11904},"_meta":{}}}}
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":6,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Run the shell tool with exactly: sleep 40 && printf \"LATE\\n\". Then reply with what it printed."}]}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"The user wants me to run `sleep 40 && printf \"LATE\\n\"`. This is harmless but will take 40 seconds. The shell tool has a default timeout of 120000ms ("}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"120 seconds"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"), so 40 seconds should be"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":" fine."}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_20278364c2e54d46a7e91573","name":"shell","title":"shell","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"in_progress","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":2,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_20278364c2e54d46a7e91573","name":"shell","title":"sleep 40 && printf \"LATE\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}}
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"in_progress","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"out","process":"A","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"failed","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"},"content":[{"type":"content","content":{"type":"text","text":"Tool execution interrupted"}}],"rawOutput":{"metadata":{"shellID":"sh_14af92184001zf8A7kQPaFQ0pc"},"error":"Tool execution interrupted"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":12207,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":6,"result":{"stopReason":"cancelled","usage":{"inputTokens":149,"outputTokens":28,"totalTokens":12207,"thoughtTokens":62,"cachedReadTokens":11968},"_meta":{}}}}
{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"cwd":"/private/tmp/oc2-proj-a","mcpServers":[],"sessionId":"session-1"}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","messageId":"msg_14af709310014Kqf2zAlQQFqbz","content":{"type":"text","text":"Run the shell tool with exactly: printf \"TOOL-OK\\n\". Then reply briefly with the marker it printed. Do not run anything else."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":"The user wants me to run the shell tool with exactly `printf \"TOOL-OK\\n\"`."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_54ffbe02d04b4842a2494b02","name":"shell","title":"printf \"TOOL-OK\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"completed","locations":[{"path":"/private/tmp/oc2-proj-a"}],"content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}],"rawOutput":{"metadata":{"status":"completed","truncated":false,"exit":0}}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym:reasoning:0","content":{"type":"text","text":"The tool printed TOOL-OK."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym","content":{"type":"text","text":"TOOL-OK"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","messageId":"msg_14af7e9b8001DmWN7bvq1JTM91","content":{"type":"text","text":"Run the shell tool with exactly: printf \"DENY-ME\\n\". If the command is refused, reply with exactly the word REFUSED and do nothing else."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af7e9c3001DgImkqCnr5kLkr:reasoning:0","content":{"type":"text","text":"The user wants me to run `printf \"DENY-ME\\n\"`. This is a harmless command. Let me run it."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_5c544d5c1c284ed0918121d5","name":"shell","title":"printf \"DENY-ME\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5c544d5c1c284ed0918121d5","status":"failed","kind":"execute","title":"printf \"DENY-ME\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"DENY-ME\\n\"","cwd":"/private/tmp/oc2-proj-a"},"content":[{"type":"content","content":{"type":"text","text":"The user declined this tool call"}}],"rawOutput":{"error":"The user declined this tool call"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","messageId":"msg_14af8d244001YVqsACeF4BJCuH","content":{"type":"text","text":"Run the shell tool with exactly: sleep 40 && printf \"LATE\\n\". Then reply with what it printed."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"The user wants me to run `sleep 40 && printf \"LATE\\n\"`. This is harmless but will take 40 seconds. The shell tool has a default timeout of 120000ms (120 seconds), so 40 seconds should be fine."}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_20278364c2e54d46a7e91573","name":"shell","title":"sleep 40 && printf \"LATE\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"failed","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"},"content":[{"type":"content","content":{"type":"text","text":"Tool execution interrupted"}}],"rawOutput":{"metadata":{"shellID":"sh_14af92184001zf8A7kQPaFQ0pc"},"error":"Tool execution interrupted"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":2,"result":{"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opencode/big-pickle","options":[{"value":"opencode/beam-free","name":"opencode/Beam Free"},{"value":"opencode/big-pickle","name":"opencode/Big Pickle"},{"value":"opencode/deepseek-v4.1-flash-free","name":"opencode/DeepSeek V4.1 Flash Free"},{"value":"opencode/glm-5.3-flash-free","name":"opencode/GLM-5.3-Flash Free"},{"value":"opencode/longcat-2.5-preview-free","name":"opencode/LongCat 2.5 Preview Free"},{"value":"opencode/mimo-v2.6-flash-free","name":"opencode/MiMo-V2.6-Flash Free"},{"value":"opencode/nemotron-3-ultra-free","name":"opencode/Nemotron 3 Ultra Free"},{"value":"opencode/nemotron-3.5-lightning-free","name":"opencode/Nemotron 3.5 Lightning Free"},{"value":"opencode/nemotron-3.5-super-vl-free","name":"opencode/Nemotron 3.5 Super VL Free"},{"value":"opencode/space-bunny-free","name":"opencode/Space Bunny Free"}]},{"id":"mode","name":"Session Mode","category":"mode","type":"select","currentValue":"build","options":[{"value":"build","name":"Build","description":"The default agent. Executes tools based on configured permissions."},{"value":"plan","name":"Plan","description":"Read-only agent for exploring the codebase and planning work before implementation."}]}]}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"compact","description":"Compact the session"}]}}}}
{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Without running anything, what marker did the first command in this conversation print? Answer with just the marker."}]}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":"The user is"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":" asking a simple factual question about the conversation history. They explicitly"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":" say \"Without running anything\" \u2014"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":" so I should just answer from memory of the conversation.\n\nThe first command in this conversation was `printf"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":" \"TOOL-OK\\n\"` which"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":" printed `TOOL"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":"-OK`.\n\nAnswer with just the marker: TOOL"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I:reasoning:0","content":{"type":"text","text":"-OK"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_14afa6c9c0015BkgmdemrpwT2I","content":{"type":"text","text":"TOOL-OK"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":12390,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":221,"outputTokens":6,"totalTokens":12390,"thoughtTokens":67,"cachedReadTokens":12096},"_meta":{}}}}
@@ -0,0 +1,14 @@
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":6,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Run the shell tool with exactly: sleep 40 && printf \"LATE\\n\". Then reply with what it printed."}]}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"The user wants me to run `sleep 40 && printf \"LATE\\n\"`. This is harmless but will take 40 seconds. The shell tool has a default timeout of 120000ms ("}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"120 seconds"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":"), so 40 seconds should be"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af8d24d0015lA6ZvBNX646j0:reasoning:0","content":{"type":"text","text":" fine."}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_20278364c2e54d46a7e91573","name":"shell","title":"shell","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"in_progress","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_20278364c2e54d46a7e91573","name":"shell","title":"sleep 40 && printf \"LATE\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}}
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"in_progress","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_20278364c2e54d46a7e91573","status":"failed","kind":"execute","title":"sleep 40 && printf \"LATE\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"sleep 40 && printf \"LATE\\n\"","cwd":"/private/tmp/oc2-proj-a"},"content":[{"type":"content","content":{"type":"text","text":"Tool execution interrupted"}}],"rawOutput":{"metadata":{"shellID":"sh_14af92184001zf8A7kQPaFQ0pc"},"error":"Tool execution interrupted"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":12207,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":6,"result":{"stopReason":"cancelled","usage":{"inputTokens":149,"outputTokens":28,"totalTokens":12207,"thoughtTokens":62,"cachedReadTokens":11968},"_meta":{}}}}
@@ -0,0 +1,16 @@
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Run the shell tool with exactly: printf \"TOOL-OK\\n\". Then reply briefly with the marker it printed. Do not run anything else."}]}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"opencode/big-pickle","options":[{"value":"opencode/beam-free","name":"opencode/Beam Free"},{"value":"opencode/big-pickle","name":"opencode/Big Pickle"},{"value":"opencode/deepseek-v4.1-flash-free","name":"opencode/DeepSeek V4.1 Flash Free"},{"value":"opencode/glm-5.3-flash-free","name":"opencode/GLM-5.3-Flash Free"},{"value":"opencode/longcat-2.5-preview-free","name":"opencode/LongCat 2.5 Preview Free"},{"value":"opencode/mimo-v2.6-flash-free","name":"opencode/MiMo-V2.6-Flash Free"},{"value":"opencode/nemotron-3-ultra-free","name":"opencode/Nemotron 3 Ultra Free"},{"value":"opencode/nemotron-3.5-lightning-free","name":"opencode/Nemotron 3.5 Lightning Free"},{"value":"opencode/nemotron-3.5-super-vl-free","name":"opencode/Nemotron 3.5 Super VL Free"},{"value":"opencode/space-bunny-free","name":"opencode/Space Bunny Free"}]},{"id":"mode","name":"Session Mode","category":"mode","type":"select","currentValue":"build","options":[{"value":"build","name":"Build","description":"The default agent. Executes tools based on configured permissions."},{"value":"plan","name":"Plan","description":"Read-only agent for exploring the codebase and planning work before implementation."}]}]}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":"The user wants"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":" me to run the shell tool with exactly"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af70943001bXc5YZJ5hGemWS:reasoning:0","content":{"type":"text","text":" `printf \"TOOL-OK\\n\"`."}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_54ffbe02d04b4842a2494b02","name":"shell","title":"shell","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_54ffbe02d04b4842a2494b02","name":"shell","title":"printf \"TOOL-OK\\n\"","kind":"execute","status":"pending","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}}
{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/private/tmp/oc2-proj-a"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/private/tmp/oc2-proj-a"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_54ffbe02d04b4842a2494b02","status":"completed","locations":[{"path":"/private/tmp/oc2-proj-a"}],"content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}],"rawOutput":{"metadata":{"status":"completed","truncated":false,"exit":0}}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym:reasoning:0","content":{"type":"text","text":"The tool printed TOOL"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym:reasoning:0","content":{"type":"text","text":"-OK."}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_14af77369001wSHXSP8Cuz3Hym","content":{"type":"text","text":"TOOL-OK"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":11941,"size":200000,"cost":{"amount":0,"currency":"USD"}}}}}
{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","usage":{"inputTokens":7667,"outputTokens":31,"totalTokens":23856,"thoughtTokens":30,"cachedReadTokens":16128},"_meta":{}}}}
@@ -19,7 +19,10 @@ const OTHER_UUID = '019fd633-4d5e-7f60-b172-8d9e0f1a2b3c'
const OMP_FILE = `/Users/me/.omp/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${OMP_ID}.jsonl`
const PI_FILE = `/Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${PI_ID}.jsonl`
const acp = (agent: 'opencode' | 'grok' | 'omp', nativeId: string): AgentSessionProviderHandle => ({
const acp = (
agent: 'opencode' | 'opencode2' | 'grok' | 'omp',
nativeId: string
): AgentSessionProviderHandle => ({
transport: 'acp',
agent,
nativeId
@@ -69,6 +72,20 @@ describe('Session History rows owned by OpenCode, Grok, OMP and Pi chats', () =>
sessionId: OPENCODE_ID,
filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}`
},
// One 2.x conversation lists as an `opencode2` row whichever command its chat runs, and an
// upgraded database keeps a 1.x copy under the same id.
{
handle: acp('opencode2', OPENCODE_ID),
rowAgent: 'opencode2' as const,
sessionId: OPENCODE_ID,
filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}`
},
{
handle: acp('opencode2', OPENCODE_ID),
rowAgent: 'opencode' as const,
sessionId: OPENCODE_ID,
filePath: `/Users/me/.local/share/opencode/opencode.db#${OPENCODE_ID}`
},
{
handle: acp('grok', GROK_ID),
rowAgent: 'grok' as const,
@@ -117,14 +134,6 @@ describe('Session History rows owned by OpenCode, Grok, OMP and Pi chats', () =>
installOwnership({ handle: acp('omp', OMP_ID) })
expect(ownedRow('pi', OMP_ID, OMP_FILE)).not.toHaveProperty('structuredSession')
})
// A row agent's owner is the first registration claiming it, so a second claim would be ignored.
it('gives each history row agent one owning registration', () => {
const rowAgents = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.flatMap(
({ sessionHistory }) => sessionHistory.rowAgents
)
expect(new Set(rowAgents).size).toBe(rowAgents.length)
})
})
describe('terminal resumes of a conversation an OpenCode chat owns', () => {
@@ -158,6 +167,15 @@ describe('terminal resumes of a conversation an OpenCode chat owns', () => {
installOwnership({ handle: acp('opencode', OPENCODE_ID) })
await allowed(command)
})
// Either command name can run the program whose chat owns the conversation.
it.each([
`opencode --session ${OPENCODE_ID}`,
`opencode2 --standalone --session '${OPENCODE_ID}'`
])('refuses %s while an OpenCode 2 chat owns it', async (command) => {
installOwnership({ handle: acp('opencode2', OPENCODE_ID) })
await refused(command)
})
})
describe('terminal resumes of a conversation a Grok chat owns', () => {
@@ -130,18 +130,29 @@ function ownershipLookup():
}
}
return (row) => {
const owner = historyRowOwner(row.agent)
return owner ? (byProviderSession.get(`${owner}\0${row.sessionId}`) ?? null) : null
for (const owner of historyRowOwners(row.agent)) {
const ownership = byProviderSession.get(`${owner}\0${row.sessionId}`)
if (ownership) {
return ownership
}
}
return null
}
}
/** The agent whose chats own history rows listed under `rowAgent`; null when none does. */
function historyRowOwner(rowAgent: string): StructuredAgentId | null {
return (
STRUCTURED_AGENT_RUNTIME_METADATA.find(({ sessionHistory }) =>
sessionHistory.rowAgents.some((agent) => agent === rowAgent)
)?.definition.agent ?? null
)
/** The agents whose chats own history rows listed under each row agent: one CLI can run as several. */
const HISTORY_ROW_OWNERS = new Map<string, StructuredAgentId[]>()
for (const { definition, sessionHistory } of STRUCTURED_AGENT_RUNTIME_METADATA) {
for (const rowAgent of sessionHistory.rowAgents) {
HISTORY_ROW_OWNERS.set(rowAgent, [
...(HISTORY_ROW_OWNERS.get(rowAgent) ?? []),
definition.agent
])
}
}
function historyRowOwners(rowAgent: string): readonly StructuredAgentId[] {
return HISTORY_ROW_OWNERS.get(rowAgent) ?? []
}
export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void {
@@ -177,35 +188,18 @@ export async function assertLegacyAiVaultResumeCommandAllowed(
export function findResumeOwnership(
args: Pick<AiVaultPrepareSessionResumeArgs, 'agent' | 'sessionId' | 'filePath'>
): StructuredProviderSessionOwnership | null {
const owner = historyRowOwner(args.agent)
if (!owner) {
const owners = new Set(historyRowOwners(args.agent))
if (owners.size === 0) {
return null
}
const host = getStructuredAgentSessionHost()
if (!host) {
return null
}
const exact = args.sessionId ? findOwnership(owner, args.sessionId) : null
if (exact) {
return exact
}
const owned = listOwnership().filter((ownership) => owners.has(ownership.provider))
const fileName = args.filePath.split(/[\\/]/).at(-1) ?? ''
return (
listOwnership().find(
(ownership) => ownership.provider === owner && fileName.includes(ownership.providerSessionId)
) ?? null
)
}
function findOwnership(
provider: StructuredAgentId,
providerSessionId: string
): StructuredProviderSessionOwnership | null {
return (
listOwnership().find(
(ownership) =>
ownership.provider === provider && ownership.providerSessionId === providerSessionId
) ?? null
(args.sessionId
? owned.find((ownership) => ownership.providerSessionId === args.sessionId)
: undefined) ??
owned.find((ownership) => fileName.includes(ownership.providerSessionId)) ??
null
)
}
@@ -241,10 +235,11 @@ type ResumeInvocation = StructuredAgentResumeInvocation & {
}
function parseResumeInvocations(command: string): ResumeInvocation[] {
const binaries = new Map<string, StructuredAgentRuntimeMetadata>()
// One binary can run the chats of several agents; a resume of it is checked against each.
const binaries = new Map<string, StructuredAgentRuntimeMetadata[]>()
for (const registration of STRUCTURED_AGENT_RUNTIME_METADATA) {
for (const name of sessionHistoryBinaryNames(registration.sessionHistory)) {
binaries.set(name, registration)
binaries.set(name, [...(binaries.get(name) ?? []), registration])
}
}
// Only a command's program can resume: a name elsewhere is an argument or prompt text
@@ -252,18 +247,16 @@ function parseResumeInvocations(command: string): ResumeInvocation[] {
const invocations: ResumeInvocation[] = []
for (const words of shellCommands(command)) {
const program = commandProgramIndex(words)
const registration = binaries.get(commandName(words[program] ?? ''))
if (!registration) {
continue
}
const history = registration.sessionHistory
const invocation = history.parseResumeArgs(words.slice(program + 1))
if (invocation) {
invocations.push({
...invocation,
provider: registration.definition.agent,
history
})
for (const registration of binaries.get(commandName(words[program] ?? '')) ?? []) {
const history = registration.sessionHistory
const invocation = history.parseResumeArgs(words.slice(program + 1))
if (invocation) {
invocations.push({
...invocation,
provider: registration.definition.agent,
history
})
}
}
}
return invocations
+4 -21
View File
@@ -1,3 +1,4 @@
import { formatAgentTypeLabel } from '../../shared/agent-type-label'
import type { NotificationDispatchRequest } from '../../shared/notification-settings-types'
type NotificationStatusTranslator = (key: string, fallback: string) => string
@@ -8,22 +9,6 @@ const NOTIFICATION_AGENT_LABEL_MAX_LENGTH = 40
const NOTIFICATION_TITLE_CONTEXT_MAX_LENGTH = 80
const NOTIFICATION_BODY_PREVIEW_MAX_LENGTH = 180
const AGENT_TYPE_LABELS: Readonly<Record<string, string>> = {
claude: 'Claude',
openclaude: 'OpenClaude',
codex: 'Codex',
gemini: 'Gemini',
antigravity: 'Antigravity',
opencode: 'OpenCode',
cursor: 'Cursor',
aider: 'Aider',
pi: 'Pi',
omp: 'OMP',
droid: 'Droid',
grok: 'Grok',
hermes: 'Hermes'
}
export function buildNotificationOptions(
args: NotificationDispatchRequest,
translate: NotificationStatusTranslator = englishNotificationStatus
@@ -177,11 +162,9 @@ function buildAgentTaskCompleteFallbackBody(args: NotificationDispatchRequest):
}
function formatNotificationAgentLabel(agentType: string | null | undefined): string {
const normalized = normalizeNotificationText(agentType, NOTIFICATION_AGENT_LABEL_MAX_LENGTH)
if (!normalized || normalized === 'unknown') {
return 'Agent'
}
return AGENT_TYPE_LABELS[normalized] ?? normalized
return formatAgentTypeLabel(
normalizeNotificationText(agentType, NOTIFICATION_AGENT_LABEL_MAX_LENGTH)
)
}
function normalizeNotificationText(value: string | null | undefined, maxLength: number): string {
@@ -181,6 +181,40 @@ describe('registerNotificationHandlers', () => {
)
})
it.each([
{ agentType: 'opencode2', label: 'OpenCode 2' },
{ agentType: 'copilot', label: 'GitHub Copilot' }
])('names $agentType the way the app does', async ({ agentType, label }) => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true
}
})
} as never)
await getDispatchHandler()(
{},
{
source: 'agent-task-complete',
worktreeId: 'repo::wt1',
worktreeLabel: 'feat/notis',
agentType,
agentState: 'done'
}
)
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
title: `feat/notis - ${label} finished`,
body: `${label} finished.`
})
)
})
it('formats blocked and interrupted agent snapshots distinctly', async () => {
registerNotificationHandlers({
getSettings: () => ({
@@ -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 runs in the asking chat's folder and reads its project config: each folder
* keeps its own catalog, and a prewarm, which has no chat, never runs it. */
listsInChatFolder?: true
}
| { kind: 'unavailable'; reason: string }
@@ -7,7 +7,8 @@ import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavail
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. */
/** A probe that did not run to an answer: the host stopped it (it is going away), or it lists only
* in a chat's folder and was asked with none. Says nothing about the account, so it is not held. */
export class AgentModelCatalogListingStoppedError extends Error {}
export type AgentModelCatalogFailure = {
@@ -11,14 +11,17 @@ import {
/**
* Everything that changes which models a listing can answer with: the agent,
* the account home the CLI reads credentials/config from, and the execution
* host that runs the binary. Login-state or CLI-version drift under the same
* key is corrected by later listings, never by the fingerprint.
* the account home the CLI reads credentials/config from, the execution host
* that runs the binary, and for an agent whose project config adds or removes
* models, the folder it listed in. Login-state or CLI-version drift under the
* same key is corrected by later listings, never by the fingerprint.
*/
export type AgentModelCatalogIdentity = {
agent: string
/** Null on the native host; WSL distros each carry their own CLI. */
wslDistro: string | null
/** Only for an agent whose models differ by folder; absent keys the account's catalog. */
folder?: string
} & (
| { accountHomeVariable: string; accountHomePath: string; accountHome?: never }
| { accountHome: AgentSessionAccountHome; accountHomeVariable?: never; accountHomePath?: never }
@@ -32,7 +35,14 @@ export function agentModelCatalogFingerprint(identity: AgentModelCatalogIdentity
: [account.kind, account.locator]
: [identity.accountHomeVariable, identity.accountHomePath]
return createHash('sha256')
.update(JSON.stringify([identity.agent, ...parts, identity.wslDistro ?? '']))
.update(
JSON.stringify([
identity.agent,
...parts,
identity.wslDistro ?? '',
...(identity.folder ? [identity.folder] : [])
])
)
.digest('hex')
}
@@ -48,10 +58,12 @@ export function agentModelCatalogIdentityForRecord(
}
}
/** `folder` only for an agent whose catalog is kept per folder. */
export function agentModelCatalogFingerprintForRecord(
record: Pick<AgentSessionRecord, 'provider' | 'accountHome' | 'location'>
record: Pick<AgentSessionRecord, 'provider' | 'accountHome' | 'location'>,
folder?: string
): string {
return agentModelCatalogFingerprint(agentModelCatalogIdentityForRecord(record))
return agentModelCatalogFingerprint({ ...agentModelCatalogIdentityForRecord(record), folder })
}
/** A live session's store handle, pinned to the account home it spawned under.
@@ -6,6 +6,7 @@ import {
agentModelCatalogFingerprintForRecord,
agentModelCatalogSessionAccess
} from './agent-model-catalog-fingerprint'
import { AgentModelCatalogListingStoppedError } from './agent-model-catalog-failures'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import {
AGENT_MODEL_CATALOG_FRESH_MS,
@@ -96,6 +97,132 @@ describe('model catalog prewarm', () => {
expect(grok).toHaveBeenCalledTimes(2)
})
// A project's opencode.json can add or remove models, so its folder keeps its own list.
// The folder lookup can answer the earlier report last; the later report must still win.
it("saves a running chat's reports in the order it sent them, per folder", async () => {
const store = new AgentModelCatalogStore()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome, location and launchDirectory.
const chat = {
sessionId: 'chat-b',
provider: 'opencode2',
accountHome: HOME('OPENCODE_HOME', '/homes/a'),
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'ws-b',
workspaceKind: 'folder'
}
} as AgentSessionRecord
const lookups = [deferred<string | null>(), deferred<string | null>()]
let lookup = 0
const service = createAgentModelCatalogService({
store,
getRecord: () => chat,
drivesRecord: () => true,
resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'),
listsInChatFolder: new Set(['opencode2']),
recordWorkspacePath: () => lookups[lookup++]?.promise ?? Promise.resolve('/work/b')
})
service.recordLiveListing('chat-b', { models: listing('older').models })
service.recordLiveListing('chat-b', { models: listing('newer').models })
lookups[1]!.resolve('/work/b')
lookups[0]!.resolve('/work/b')
const read = await service.read({ agent: 'opencode2', sessionId: 'chat-b' })
expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['newer'])
})
it('keeps one catalog per folder for an agent whose project config changes its models', async () => {
const store = new AgentModelCatalogStore()
const probe = vi.fn(
async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) =>
listing(options?.workspacePath === '/work/b' ? 'only-in-b' : 'big-pickle')
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome, location and launchDirectory.
const chatInB = {
sessionId: 'chat-b',
provider: 'opencode2',
accountHome: HOME('OPENCODE_HOME', '/homes/a'),
launchDirectory: '/work/b',
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'ws-b',
workspaceKind: 'folder'
}
} as AgentSessionRecord
const service = createAgentModelCatalogService({
store,
getRecord: (sessionId) => (sessionId === 'chat-b' ? chatInB : undefined),
drivesRecord: () => true,
resolveAccountHome: async () => HOME('OPENCODE_HOME', '/homes/a'),
probes: { opencode2: probe },
listsInChatFolder: new Set(['opencode2']),
recordWorkspacePath: async (record) => record.launchDirectory ?? null
})
const ids = async (workspacePath: string) => {
const read = await service.read({ agent: 'opencode2', workspacePath, waitForListing: true })
return read.origin === 'unknown' ? [] : read.models.map((model) => model.id)
}
// Asked at once, neither folder joins the other's listing.
expect(await Promise.all([ids('/work/a'), ids('/work/b')])).toEqual([
['big-pickle'],
['only-in-b']
])
expect(probe.mock.calls.map(([, options]) => options?.workspacePath).sort()).toEqual([
'/work/a',
'/work/b'
])
// A chat running in B saves what it lists for B only.
service.recordLiveListing('chat-b', { models: listing('live-b').models })
await vi.waitFor(async () => expect(await ids('/work/b')).toEqual(['live-b']))
expect(await ids('/work/a')).toEqual(['big-pickle'])
expect(probe).toHaveBeenCalledTimes(2)
const answer = await service.read({ agent: 'opencode2', workspacePath: '/work/a' })
expect(answer).toMatchObject({ modelsDifferByWorkspace: true })
})
// 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<void>()
const probe = vi.fn(
async (_home: AgentSessionAccountHome, options?: { workspacePath?: string }) => {
await versionChecked.promise
if (!options?.workspacePath) {
throw new AgentModelCatalogListingStoppedError('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'
@@ -291,8 +418,8 @@ describe('one account, one catalog key', () => {
const incoming = { models: [...listing('gpt-live').models, ...listing('gpt-new').models] }
const retained = (launchOnlyModelId: string) =>
service.retainedLiveModelIds('session-1', { ...incoming, launchOnlyModelId })
expect(retained('gpt-live')).toEqual(['gpt-live', 'gpt-new'])
expect(retained('gpt-new')).toEqual(['gpt-live'])
expect(await retained('gpt-live')).toEqual(['gpt-live', 'gpt-new'])
expect(await retained('gpt-new')).toEqual(['gpt-live'])
const adapterAccess = agentModelCatalogSessionAccess(
store,
{ agent: 'codex', accountHomeVariable: 'CODEX_HOME' },
@@ -4,27 +4,30 @@ import type {
AgentSessionRecord
} from '../../../shared/agent-session-record'
import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { isNativeLocalLocation } from '../../../shared/execution-host'
import {
agentModelCatalogFingerprint,
agentModelCatalogFingerprintForRecord
} from './agent-model-catalog-fingerprint'
import {
agentModelCatalogResultFromEntry,
retainedAgentModelCatalogModels,
type AgentModelCatalogConfiguredChoice
retainedAgentModelCatalogModels
} from './agent-model-catalog-entry'
import type {
AgentModelCatalogEntry,
AgentModelCatalogLiveListing,
AgentModelCatalogProbe,
AgentModelCatalogStore,
AgentModelCatalogSuccess
} from './agent-model-catalog-store'
import { AgentModelCatalogListingStoppedError } from './agent-model-catalog-failures'
import {
catalogFolder,
recordConfiguredDefault,
sessionWorkspacePath,
workspaceKeepsListedDefault,
type AgentModelCatalogWorkspaceDeps
} from './agent-model-catalog-workspace'
export type AgentModelCatalogServiceDeps = {
store: AgentModelCatalogStore
export type AgentModelCatalogServiceDeps = AgentModelCatalogWorkspaceDeps & {
getRecord: (sessionId: string) => AgentSessionRecord | undefined
/** Whether this build can start the record's agent as the record pins it; a record it cannot
* names no account a probe may start that agent's CLI under. */
@@ -37,17 +40,8 @@ export type AgentModelCatalogServiceDeps = {
probes?: Readonly<Partial<Record<string, AgentModelCatalogProbe>>>
/** Agents whose listing marks the model the account is configured to run as its default. */
listingNamesConfiguredModel?: ReadonlySet<string>
/** Where a session's provider runs, for deciding whether its own config scope is the account's. */
recordWorkspacePath?: (record: AgentSessionRecord) => Promise<string | null>
/** False for an agent whose model no project config can pick; its default holds everywhere. */
agentReadsProjectModelConfig?: (agent: string) => boolean
/** Whether the workspace's own config could pick a model other than the listed default;
* `accountHomePath` is the agent's own home folder, which is account config, not a layer. */
workspaceMayOverrideDefaultModel?: (input: {
agent: string
workspacePath: string
accountHomePath: string | null
}) => Promise<boolean>
/** Whether this host keeps any chat of the agent; with a saved catalog, what marks it in use. */
hasChatRecords?: (agent: string) => boolean
}
@@ -67,7 +61,10 @@ export type AgentModelCatalogService = {
savedOnly?: boolean
}) => Promise<AgentSessionModelCatalogResult>
/** Incoming listing ids, checking only a launch-added row against the pinned account. */
retainedLiveModelIds: (sessionId: string, listing: AgentModelCatalogLiveListing) => string[]
retainedLiveModelIds: (
sessionId: string,
listing: AgentModelCatalogLiveListing
) => Promise<string[]>
/** Saves a session's listing and newly learned menus so the next chat starts warm. */
recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void
/** Lists, in the background, every agent the user has used here (a saved catalog or a chat)
@@ -77,38 +74,17 @@ export type AgentModelCatalogService = {
/** The host is going away: start no listing, and stop the ones running. */
stop: () => void
/** A chat under this record's account proved its start: a held reason is re-checked sooner. */
providerStarted: (
record: Pick<AgentSessionRecord, 'provider' | 'accountHome' | 'location'>
) => void
providerStarted: (record: AgentSessionRecord) => void
}
// At most this many agents list at once: each listing spawns that agent's CLI.
const PREWARM_CONCURRENCY = 2
/** A named workspace keeps the listed default only when none of its own config can replace it. */
async function workspaceKeepsListedDefault(
deps: AgentModelCatalogServiceDeps,
agent: string,
workspacePath: string | null | undefined,
accountHomePath: string | null
): Promise<boolean> {
if (workspacePath === undefined) {
return true
}
if (workspacePath === null || !deps.workspaceMayOverrideDefaultModel) {
return false
}
try {
return !(await deps.workspaceMayOverrideDefaultModel({ agent, workspacePath, accountHomePath }))
} catch {
return false
}
}
/** The catalog a new chat of `agent` would read: the account a launch would pin right now. */
async function newChatCatalogKey(
deps: AgentModelCatalogServiceDeps,
agent: string
agent: string,
folder?: string
): Promise<{ fingerprint: string; accountHome: AgentSessionAccountHome } | null> {
let accountHome: AgentSessionAccountHome
try {
@@ -117,50 +93,11 @@ async function newChatCatalogKey(
return null
}
return {
fingerprint: agentModelCatalogFingerprint({ agent, accountHome, wslDistro: null }),
fingerprint: agentModelCatalogFingerprint({ agent, accountHome, wslDistro: null, folder }),
accountHome
}
}
/** The local folder a session's agent runs in; null where this host cannot look into it, as a new
* chat's own read answers. */
async function sessionWorkspacePath(
deps: AgentModelCatalogServiceDeps,
record: AgentSessionRecord
): Promise<string | null> {
if (!isNativeLocalLocation(record.location) || !deps.recordWorkspacePath) {
return null
}
return deps.recordWorkspacePath(record).catch(() => null)
}
/** A session launched with no model pick resolved its config scope's default model and effort;
* when that scope is the account's (a native workspace with no config of its own), they are the
* account's default, and a resolution naming no listed model retires the saved one. */
async function recordConfiguredDefault(
deps: AgentModelCatalogServiceDeps,
record: AgentSessionRecord,
fingerprint: string,
choice: AgentModelCatalogConfiguredChoice | null
): Promise<void> {
const accountHome = record.accountHome
if (!deps.workspaceMayOverrideDefaultModel) {
return
}
const workspacePath = await sessionWorkspacePath(deps, record)
if (
!workspacePath ||
(await deps.workspaceMayOverrideDefaultModel({
agent: record.provider,
workspacePath,
accountHomePath: isLegacyAgentSessionAccountHome(accountHome) ? accountHome.path : null
}))
) {
return
}
deps.store.recordConfiguredDefault(fingerprint, choice)
}
async function runBounded<T>(
items: readonly T[],
limit: number,
@@ -197,30 +134,76 @@ export function createAgentModelCatalogService(
// answers after it, so the chat that reported it is followed by an answer naming it.
const recordingDefaults = new Map<string, Promise<void>>()
const listWith =
(probe: AgentModelCatalogProbe, home: AgentSessionAccountHome) =>
(probe: AgentModelCatalogProbe, home: AgentSessionAccountHome, workspacePath?: string) =>
(): Promise<AgentModelCatalogSuccess> =>
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
})
// A running chat's saves for agents kept per folder, which wait on a folder lookup.
const liveWrites = new Map<string, Promise<void>>()
/** The catalog a session's own listings belong to: its pinned account and host, and its folder
* for an agent kept per folder. */
const sessionCatalogKey = async (record: AgentSessionRecord): Promise<string> =>
agentModelCatalogFingerprintForRecord(
record,
await catalogFolder(deps, record.provider, undefined, record)
)
const saveLiveListing = (
record: AgentSessionRecord,
listing: AgentModelCatalogLiveListing,
fingerprint: string
): void => {
const { configuredDefault, ...listed } = listing
const saved = deps.store.recordSuccess(
fingerprint,
record.provider,
{ ...listed, origin: 'live-session' },
'live'
)
if (saved && configuredDefault !== undefined) {
// In report order, so the latest chat's resolution is the one kept.
const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve())
.then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault))
.catch(() => {})
.then(() => {
if (recordingDefaults.get(fingerprint) === recording) {
recordingDefaults.delete(fingerprint)
}
})
recordingDefaults.set(fingerprint, recording)
}
}
return {
providerStarted(record) {
deps.store.expireFailure(agentModelCatalogFingerprintForRecord(record))
if (!deps.listsInChatFolder?.has(record.provider)) {
deps.store.expireFailure(agentModelCatalogFingerprintForRecord(record))
return
}
void sessionCatalogKey(record)
.then((fingerprint) => deps.store.expireFailure(fingerprint))
.catch(() => {})
},
async read(params) {
const record = params.sessionId ? deps.getRecord(params.sessionId) : undefined
const scoped =
record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined
const workspacePath = params.inSessionWorkspace
? record
? await sessionWorkspacePath(deps, record)
: null
: params.workspacePath
const folder = await catalogFolder(deps, params.agent, workspacePath, scoped)
let fingerprint: string
// The account a probe lists under; null where this host cannot spawn one natively.
let probeHome: AgentSessionAccountHome | null
if (scoped) {
fingerprint = agentModelCatalogFingerprintForRecord(scoped)
fingerprint = agentModelCatalogFingerprintForRecord(scoped, folder)
// Probes spawn natively; a WSL-pinned record has no host-side lister.
probeHome = scoped.location.wslDistro === null ? scoped.accountHome : null
} else {
const key = await newChatCatalogKey(deps, params.agent)
const key = await newChatCatalogKey(deps, params.agent, folder)
if (!key) {
return { origin: 'unknown' }
}
@@ -229,11 +212,8 @@ export function createAgentModelCatalogService(
}
const accountHomePath =
probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null
const workspacePath = params.inSessionWorkspace
? record
? await sessionWorkspacePath(deps, record)
: null
: params.workspacePath
// A chat's report still finding its folder lands first, as it would have before the read.
await Promise.all(liveWrites.values())
await recordingDefaults.get(fingerprint)
let entry = deps.store.get(fingerprint)
const probe = deps.probes?.[params.agent]
@@ -257,6 +237,7 @@ export function createAgentModelCatalogService(
return {
...(result ?? { origin: 'unknown' }),
...(holdsEverywhere ? { defaultHoldsInEveryWorkspace: true as const } : {}),
...(result && folder !== undefined ? { modelsDifferByWorkspace: true as const } : {}),
...extra,
...(unavailable ? { unavailable } : {})
}
@@ -272,7 +253,12 @@ 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,
listWith(probe, home, folder)
)
if (!params.waitForListing) {
return answer(entry, { listingInProgress: true })
}
@@ -283,9 +269,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, listWith(probe, home, folder))
} 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, listWith(probe, home, folder))
listing = deps.store.pendingListing(fingerprint)
}
}
@@ -301,11 +287,11 @@ export function createAgentModelCatalogService(
}
return answer(entry)
},
retainedLiveModelIds(sessionId, listing) {
async retainedLiveModelIds(sessionId, listing) {
const record = deps.getRecord(sessionId)
const entry =
record && deps.drivesRecord(record)
? deps.store.get(agentModelCatalogFingerprintForRecord(record))
? deps.store.get(await sessionCatalogKey(record))
: undefined
return retainedAgentModelCatalogModels(entry, listing).map((model) => model.id)
},
@@ -314,27 +300,22 @@ export function createAgentModelCatalogService(
if (!record || !deps.drivesRecord(record)) {
return
}
// The record's pinned account and host: the account this child listed under.
const fingerprint = agentModelCatalogFingerprintForRecord(record)
const { configuredDefault, ...listed } = listing
const saved = deps.store.recordSuccess(
fingerprint,
record.provider,
{ ...listed, origin: 'live-session' },
'live'
)
if (saved && configuredDefault !== undefined) {
// In report order, so the latest chat's resolution is the one kept.
const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve())
.then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault))
.catch(() => {})
.then(() => {
if (recordingDefaults.get(fingerprint) === recording) {
recordingDefaults.delete(fingerprint)
}
})
recordingDefaults.set(fingerprint, recording)
if (!deps.listsInChatFolder?.has(record.provider)) {
saveLiveListing(record, listing, agentModelCatalogFingerprintForRecord(record))
return
}
// In report order: a folder lookup that resolves late must not let an older listing land last.
const writing = (liveWrites.get(sessionId) ?? Promise.resolve())
.then(async () => saveLiveListing(record, listing, await sessionCatalogKey(record)))
.catch((error: unknown) => {
console.warn('[agent-model-catalog] could not save a chat model list:', error)
})
.finally(() => {
if (liveWrites.get(sessionId) === writing) {
liveWrites.delete(sessionId)
}
})
liveWrites.set(sessionId, writing)
},
async prewarm() {
if (lifetime.signal.aborted) {
@@ -343,7 +324,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]
@@ -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<AgentModelCatalogSuccess>
/** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */
@@ -15,6 +15,7 @@ import {
} from './agent-model-catalog-store'
import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home'
// Why a chat cannot start, as the probe found it: the reason rides every catalog answer until a
@@ -150,6 +151,7 @@ describe('a chat that starts under the account', () => {
const { store, probe, probed, answerWith, service } = rig()
await probed()
service.providerStarted({
...agentSessionRecordFixture(),
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: HOME },
location: {
@@ -0,0 +1,99 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home'
import { isNativeLocalLocation } from '../../../shared/execution-host'
import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry'
import type { AgentModelCatalogStore } from './agent-model-catalog-store'
/** What a workspace changes about a catalog read: its folder's own catalog, and its default. */
export type AgentModelCatalogWorkspaceDeps = {
store: AgentModelCatalogStore
/** Agents that list in the asking chat's folder, whose project config can change their models:
* each folder keeps its own catalog, and a prewarm, which names none, skips them. */
listsInChatFolder?: ReadonlySet<string>
/** Where a session's provider runs, for deciding whether its own config scope is the account's. */
recordWorkspacePath?: (record: AgentSessionRecord) => Promise<string | null>
/** Whether the workspace's own config could pick a model other than the listed default;
* `accountHomePath` is the agent's own home folder, which is account config, not a layer. */
workspaceMayOverrideDefaultModel?: (input: {
agent: string
workspacePath: string
accountHomePath: string | null
}) => Promise<boolean>
}
/** A named workspace keeps the listed default only when none of its own config can replace it. */
export async function workspaceKeepsListedDefault(
deps: AgentModelCatalogWorkspaceDeps,
agent: string,
workspacePath: string | null | undefined,
accountHomePath: string | null
): Promise<boolean> {
if (workspacePath === undefined) {
return true
}
if (workspacePath === null || !deps.workspaceMayOverrideDefaultModel) {
return false
}
try {
return !(await deps.workspaceMayOverrideDefaultModel({ agent, workspacePath, accountHomePath }))
} catch {
return false
}
}
/** The local folder a session's agent runs in; null where this host cannot look into it, as a new
* chat's own read answers. */
export async function sessionWorkspacePath(
deps: AgentModelCatalogWorkspaceDeps,
record: AgentSessionRecord
): Promise<string | null> {
if (!isNativeLocalLocation(record.location) || !deps.recordWorkspacePath) {
return null
}
return deps.recordWorkspacePath(record).catch(() => null)
}
/** The folder whose catalog a read of `agent` answers from, for an agent whose models differ by
* folder: the workspace the read names, else its session's. Undefined keys the account's. */
export async function catalogFolder(
deps: AgentModelCatalogWorkspaceDeps,
agent: string,
workspacePath: string | null | undefined,
session: AgentSessionRecord | undefined
): Promise<string | undefined> {
if (!deps.listsInChatFolder?.has(agent)) {
return undefined
}
const folder =
workspacePath === undefined && session
? await sessionWorkspacePath(deps, session)
: workspacePath
return folder ?? undefined
}
/** A session launched with no model pick resolved its config scope's default model and effort;
* when that scope is the account's (a native workspace with no config of its own), they are the
* account's default, and a resolution naming no listed model retires the saved one. */
export async function recordConfiguredDefault(
deps: AgentModelCatalogWorkspaceDeps,
record: AgentSessionRecord,
fingerprint: string,
choice: AgentModelCatalogConfiguredChoice | null
): Promise<void> {
const accountHome = record.accountHome
if (!deps.workspaceMayOverrideDefaultModel) {
return
}
const workspacePath = await sessionWorkspacePath(deps, record)
if (
!workspacePath ||
(await deps.workspaceMayOverrideDefaultModel({
agent: record.provider,
workspacePath,
accountHomePath: isLegacyAgentSessionAccountHome(accountHome) ? accountHome.path : null
}))
) {
return
}
deps.store.recordConfiguredDefault(fingerprint, choice)
}
@@ -147,6 +147,15 @@ describe('workspaceMayOverrideDefaultModel', () => {
expect(await mayOverride('opencode', worktree, null)).toBe(false)
})
// `opencode2` is the same program under its other name, so it reads the same project files.
it('reads OpenCode 2 project config through the same layers as OpenCode', async () => {
const worktree = join(root, 'oc2-plain')
write(join(worktree, '.git'), 'gitdir: /elsewhere')
expect(await mayOverride('opencode2', worktree, null)).toBe(false)
write(join(worktree, 'opencode.json'), '{"model":"x/y"}')
expect(await mayOverride('opencode2', worktree, null)).toBe(true)
})
it.each([
['a model', 'opencode.json', { model: 'anthropic/claude-sonnet-5' }],
['a default agent', 'opencode.json', { default_agent: 'plan' }],
@@ -73,6 +73,21 @@ const files = (
mayPickModel: (text: string) => boolean
): ProjectModelFile[] => paths.map((path) => ({ path, mayPickModel }))
// OpenCode 2.x discovers `opencode.json(c)` and `.opencode/` in every ancestor of the workspace.
const OPENCODE_PROJECT_MODEL_LAYERS: ProjectModelLayers = {
wholeAncestry: true,
files: files(
['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'],
openCodeConfigMayPickModel
),
agentFolders: [
{ path: '.opencode/agent', recursive: true },
{ path: '.opencode/agents', recursive: true },
{ path: '.opencode/mode', recursive: false },
{ path: '.opencode/modes', recursive: false }
]
}
// The project files each agent's CLI reads a model from.
const PROJECT_MODEL_LAYERS: Readonly<Record<string, ProjectModelLayers>> = {
codex: { files: files(['.codex/config.toml'], codexConfigMayPickModel) },
@@ -82,20 +97,9 @@ const PROJECT_MODEL_LAYERS: Readonly<Record<string, ProjectModelLayers>> = {
claudeSettingsMayPickModel
)
},
// OpenCode 2.x discovers `opencode.json(c)` and `.opencode/` in every ancestor of the workspace.
opencode: {
wholeAncestry: true,
files: files(
['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'],
openCodeConfigMayPickModel
),
agentFolders: [
{ path: '.opencode/agent', recursive: true },
{ path: '.opencode/agents', recursive: true },
{ path: '.opencode/mode', recursive: false },
{ path: '.opencode/modes', recursive: false }
]
},
opencode: OPENCODE_PROJECT_MODEL_LAYERS,
// The same program under its other command name.
opencode2: OPENCODE_PROJECT_MODEL_LAYERS,
// OMP merges its own `.omp` files and these other agents' project settings into its settings.
omp: {
files: [
@@ -39,7 +39,7 @@ function launchPermission(
})
const modelCatalog = {
read,
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
@@ -240,7 +240,7 @@ export async function readStructuredAgentSessionOptions(
if (catalogListing) {
// Check prior membership before saving: a launch-added row cannot verify itself.
const listedModelIds = context.deps.modelCatalog
? context.deps.modelCatalog.retainedLiveModelIds(sessionId, catalogListing)
? await context.deps.modelCatalog.retainedLiveModelIds(sessionId, catalogListing)
: retainedAgentModelCatalogModels(undefined, catalogListing).map((model) => model.id)
saveStructuredAgentSessionListing(context, sessionId, catalogListing)
options = { ...answer, listedModelIds }
@@ -319,7 +319,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
fetchedAt: 1,
models: rows.map((row) => ({ ...row, label: row.id, efforts: [] }))
})),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
@@ -56,7 +56,7 @@ function startHost(): void {
},
modelCatalog: {
read: vi.fn(),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => {}),
stop: vi.fn(),
@@ -103,7 +103,7 @@ beforeEach(async () => {
mintSpawnToken: () => 'spawn-a',
modelCatalog: {
read: async () => ({ origin: 'unknown' }),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }),
prewarm: async () => {},
stop: () => {},
@@ -88,7 +88,7 @@ async function startHost(): Promise<void> {
},
modelCatalog: {
read: vi.fn(async () => ({ origin: 'unknown' as const })),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => {}),
stop: vi.fn(),
@@ -2,7 +2,10 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../shared/structured-agent-cli-conversation-adoption'
import {
adoptsCliConversations,
CLI_CONVERSATION_ADOPTING_AGENTS
} from '../../shared/structured-agent-cli-conversation-adoption'
import {
clearGrokSessionPathLookupCacheForTests,
findGrokChatHistoryBySessionId,
@@ -157,16 +160,25 @@ function adopt(
}
describe('which chats can take over a CLI conversation', () => {
it('maps exactly the row agents of the agents the host registers a transcript import for', () => {
expect(
Object.fromEntries(
STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter(
(registration) => registration.transcriptImport
).flatMap((registration) =>
registration.sessionHistory.rowAgents.map((row) => [row, registration.definition.agent])
)
)
).toEqual(CLI_CONVERSATION_ADOPTING_AGENTS)
it('names, for every row an importing chat lists, only chats that import and list it', () => {
const importing = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter(
(registration) => registration.transcriptImport
)
const rows = new Set(importing.flatMap((registration) => registration.sessionHistory.rowAgents))
expect(new Set(Object.keys(CLI_CONVERSATION_ADOPTING_AGENTS))).toEqual(rows)
for (const [row, agents] of Object.entries(CLI_CONVERSATION_ADOPTING_AGENTS)) {
for (const agent of agents) {
const registration = importing.find((entry) => entry.definition.agent === agent)
expect(registration?.sessionHistory.rowAgents, `${agent} takes over ${row}`).toContain(row)
}
}
// Each importing chat takes over the rows of its own name first.
for (const registration of importing) {
const { agent } = registration.definition
if (adoptsCliConversations(agent)) {
expect(CLI_CONVERSATION_ADOPTING_AGENTS[agent][0]).toBe(agent)
}
}
})
})
@@ -4,6 +4,7 @@ import type { StructuredAgentId } from '../../shared/agent-session-provider-hand
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import {
adoptsCliConversations,
chatAgentsShareConversations,
CLI_CONVERSATION_ADOPTING_AGENTS
} from '../../shared/structured-agent-cli-conversation-adoption'
import { isWslUncPath } from '../../shared/wsl-paths'
@@ -69,7 +70,10 @@ export function structuredAgentCliSourcePath(input: {
!candidate.connectionId &&
!candidate.structuredHost &&
adoptsCliConversations(candidate.agentType) &&
CLI_CONVERSATION_ADOPTING_AGENTS[candidate.agentType] === input.agent &&
// A 2.x pane started as `opencode` reports `opencode`; the OpenCode 2 chat opens its conversation.
CLI_CONVERSATION_ADOPTING_AGENTS[candidate.agentType].some((agent) =>
chatAgentsShareConversations(agent, input.agent)
) &&
candidate.providerSession?.id === input.resumeFrom.providerSessionId
)
if (!row) {
@@ -104,6 +104,22 @@ describe('findConflictingStructuredAdoption', () => {
).toBeNull()
})
// `opencode` and `opencode2` run one OpenCode under two names, so they open the same ids.
it.each([
['opencode', 'opencode2'],
['opencode2', 'opencode']
] as const)('names an %s chat holding the conversation an %s chat would adopt', (held, agent) => {
const owner = ownership({ provider: held, providerSessionId: 'ses_same' })
expect(
findConflictingStructuredAdoption({
agent,
providerSessionId: 'ses_same',
selfSessionId: 'new',
ownership: [owner]
})
).toBe(owner)
})
it('finds nothing when no session holds the conversation', () => {
expect(
findConflictingStructuredAdoption({
@@ -2,6 +2,7 @@
//
// Credential homes and transcripts must come from runtime-owned facts, never client-supplied paths.
import { chatAgentsShareConversations } from '../../shared/structured-agent-cli-conversation-adoption'
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger'
import type {
@@ -93,7 +94,7 @@ export function findConflictingStructuredAdoption(input: {
input.ownership.find(
(owner) =>
owner.sessionId !== input.selfSessionId &&
owner.provider === input.agent &&
chatAgentsShareConversations(owner.provider, input.agent) &&
owner.providerSessionId === input.providerSessionId
) ?? null
)
@@ -199,3 +199,38 @@ it('materializes legacy Codex into the system home and pins a verified selected
await expect(prepareCodexAdoption(source, id, async () => { throw new Error('EACCES selected account') },
[source.accountHome])).rejects.toThrow('EACCES selected account')
})
// A 2.x terminal started as `opencode` reports `opencode`; either OpenCode chat opens its conversation.
it.each([
['opencode', true],
['opencode2', true],
['claude', false]
] as const)('lets a %s chat take over an `opencode` pane: %s', (agent, allowed) => {
const sourcePath = () =>
structuredAgentCliSourcePath({
agent,
workspaceId: 'folder',
resumeFrom: {
providerSessionId: 'ses_1',
cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' }
},
rows: [
{
paneKey: 'tab:leaf',
worktreeId: 'folder',
agentType: 'opencode',
state: 'waiting',
prompt: '',
connectionId: null,
receivedAt: 1,
stateStartedAt: 1,
providerSession: { key: 'session_id', id: 'ses_1', transcriptPath: '/data/opencode.db' }
}
]
})
if (allowed) {
expect(sourcePath()).toBe('/data/opencode.db')
} else {
expect(sourcePath).toThrow('agent_session_identity_required')
}
})
@@ -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'
@@ -232,7 +232,7 @@ exports[`native-chat launch parity > antigravity worktree fresh configured 1`] =
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000048",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000053",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -444,7 +444,7 @@ exports[`native-chat launch parity > antigravity worktree resumed configured 1`]
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000049",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000054",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -777,10 +777,10 @@ exports[`native-chat launch parity > claude worktree fresh configured 1`] = `
"--add-dir",
"<root>/state/agent-session-attachments",
"--add-dir",
"<root>/state/chat-scratch/4513a9571ec9fe3b6c2cee8782d16d37",
"<root>/state/chat-scratch/7d3f4165565b3c0388cb5e400cb94aeb",
"--plugin-dir",
"<repo>/resources/native-chat-visuals",
"--session-id=23212b43-90a6-4936-a4c0-dae4b27f63cb",
"--session-id=549765eb-29e2-4b8e-a209-47773e58bade",
"--replay-user-messages",
"--dangerously-skip-permissions",
"--thinking-display",
@@ -808,9 +808,9 @@ exports[`native-chat launch parity > claude worktree fresh configured 1`] = `
"ORCA_AGENT_LAUNCH": "parent-scrub-safe-launch",
"ORCA_AGENT_LAUNCH_TOKEN": "parent-launch-token",
"ORCA_AGENT_PANE": "parent-scrub-safe-pane",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000036",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000041",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/4513a9571ec9fe3b6c2cee8782d16d37",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/7d3f4165565b3c0388cb5e400cb94aeb",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -1020,7 +1020,7 @@ exports[`native-chat launch parity > claude worktree resumed configured 1`] = `
"stream-json",
"--permission-prompt-tool",
"stdio",
"--resume=00000000-0000-4000-8000-000000000037",
"--resume=00000000-0000-4000-8000-000000000042",
"--setting-sources=user,project,local",
"--permission-mode",
"default",
@@ -1030,7 +1030,7 @@ exports[`native-chat launch parity > claude worktree resumed configured 1`] = `
"--add-dir",
"<root>/state/agent-session-attachments",
"--add-dir",
"<root>/state/chat-scratch/083c5a4185dc6c2750d599f361c671d1",
"<root>/state/chat-scratch/4a19eb9b3f23e5c387e9d8af286e4a7f",
"--plugin-dir",
"<repo>/resources/native-chat-visuals",
"--replay-user-messages",
@@ -1060,9 +1060,9 @@ exports[`native-chat launch parity > claude worktree resumed configured 1`] = `
"ORCA_AGENT_LAUNCH": "parent-scrub-safe-launch",
"ORCA_AGENT_LAUNCH_TOKEN": "parent-launch-token",
"ORCA_AGENT_PANE": "parent-scrub-safe-pane",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000037",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000042",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/083c5a4185dc6c2750d599f361c671d1",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/4a19eb9b3f23e5c387e9d8af286e4a7f",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -1339,9 +1339,9 @@ exports[`native-chat launch parity > codex worktree fresh configured 1`] = `
"ORCA_AGENT_LAUNCH": "parent-scrub-safe-launch",
"ORCA_AGENT_LAUNCH_TOKEN": "parent-launch-token",
"ORCA_AGENT_PANE": "parent-scrub-safe-pane",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000038",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000043",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/9825b24d9014b859f02ff26f814792b0",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/6d760d82a1a8964efd96490eeb4e7d20",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -1554,9 +1554,9 @@ exports[`native-chat launch parity > codex worktree resumed configured 1`] = `
"ORCA_AGENT_LAUNCH": "parent-scrub-safe-launch",
"ORCA_AGENT_LAUNCH_TOKEN": "parent-launch-token",
"ORCA_AGENT_PANE": "parent-scrub-safe-pane",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000039",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000044",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/7eca093e243e678793121c8537d04cc0",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/3d2ec752c13cffa4ab07edb61d8803b5",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -1831,9 +1831,9 @@ exports[`native-chat launch parity > grok worktree fresh configured 1`] = `
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000040",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000045",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/3d37237436bb2343509e2f2d6240ec5c",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/fd01d42f6105793cffa86eca0504cdad",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_DATA_ACCOUNT_PROVIDER": "parent-account",
@@ -2039,9 +2039,9 @@ exports[`native-chat launch parity > grok worktree resumed configured 1`] = `
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000041",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000046",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/7d3f4165565b3c0388cb5e400cb94aeb",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/f9f96d433c3c4a517315697c40c1e1ff",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_DATA_ACCOUNT_PROVIDER": "parent-account",
@@ -2315,9 +2315,9 @@ exports[`native-chat launch parity > omp worktree fresh configured 1`] = `
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000044",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000049",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/3d2ec752c13cffa4ab07edb61d8803b5",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/ac3d7b3d7216f9d1740228556d8496f7",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_DATA_ACCOUNT_PROVIDER": "parent-account",
@@ -2519,9 +2519,9 @@ exports[`native-chat launch parity > omp worktree resumed configured 1`] = `
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000045",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000050",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/fd01d42f6105793cffa86eca0504cdad",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/035c996f8981604afe69d1c2d022552b",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_DATA_ACCOUNT_PROVIDER": "parent-account",
@@ -2812,9 +2812,9 @@ exports[`native-chat launch parity > opencode worktree fresh configured 1`] = `
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000042",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000047",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/4a19eb9b3f23e5c387e9d8af286e4a7f",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/ed9363a1a427b8d0fec5f6779bd3488f",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
@@ -3032,9 +3032,9 @@ exports[`native-chat launch parity > opencode worktree resumed configured 1`] =
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000043",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000048",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/6d760d82a1a8964efd96490eeb4e7d20",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/3ca467fd6a25c17ed3f81d1555ba95d7",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
@@ -3086,6 +3086,523 @@ exports[`native-chat launch parity > opencode worktree resumed configured 1`] =
}
`;
exports[`native-chat launch parity > opencode2 floating fresh 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/bin/opencode2",
"cwd": "<root>/floating",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "process",
"LANG": "en_US.UTF-8",
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000038",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/9825b24d9014b859f02ff26f814792b0",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "term_parent",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/floating",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": false,
},
"pinnedLaunchDirectories": [
"<root>/floating",
],
}
`;
exports[`native-chat launch parity > opencode2 floating resumed 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/bin/opencode2",
"cwd": "<root>/floating-pinned",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "process",
"LANG": "en_US.UTF-8",
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000039",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/7eca093e243e678793121c8537d04cc0",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "term_parent",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/floating-pinned",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": false,
},
"pinnedLaunchDirectories": [],
}
`;
exports[`native-chat launch parity > opencode2 worktree fresh 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/bin/opencode2",
"cwd": "<root>/worktree",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "process",
"LANG": "en_US.UTF-8",
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000036",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/4513a9571ec9fe3b6c2cee8782d16d37",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "term_parent",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/worktree",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": false,
},
"pinnedLaunchDirectories": [],
}
`;
exports[`native-chat launch parity > opencode2 worktree fresh configured 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/custom-bin/opencode2-custom",
"cwd": "<root>/worktree",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000055",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/fd5ce56aaa624b617bf39dcc77e3d4b2",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "term_parent",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2-configured",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/worktree",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": true,
},
"pinnedLaunchDirectories": [],
}
`;
exports[`native-chat launch parity > opencode2 worktree fresh worker 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/bin/opencode2",
"cwd": "<root>/worktree",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "process",
"LANG": "en_US.UTF-8",
"OPENCODE_CLIENT": "acp",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000040",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "structworker_00000000-0000-4000-8000-000000000040",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_SCRATCH_DIR",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/worktree",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": false,
},
"pinnedLaunchDirectories": [],
}
`;
exports[`native-chat launch parity > opencode2 worktree resumed 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/bin/opencode2",
"cwd": "<root>/worktree",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "process",
"LANG": "en_US.UTF-8",
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000037",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/083c5a4185dc6c2750d599f361c671d1",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "term_parent",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/worktree",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": false,
},
"pinnedLaunchDirectories": [],
}
`;
exports[`native-chat launch parity > opencode2 worktree resumed configured 1`] = `
{
"child": {
"args": [
"acp",
],
"command": "<root>/custom-bin/opencode2-custom",
"cwd": "<root>/worktree",
"env": {
"ANTHROPIC_API_KEY": "parent-api-key",
"CLAUDE_CODE_SESSION_ID": "parent-claude-session",
"CLAUDE_CONFIG_DIR": "/parent/claude",
"CODEX_HOME": "/parent/codex",
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"OPENCODE_CLIENT": "acp",
"OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["<repo>/resources/native-chat-visuals/skills"]}}",
"OPENCODE_ENABLE_QUESTION_TOOL": "false",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000056",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CHAT_SCRATCH_DIR": "<root>/state/chat-scratch/368fd4cc5da6a076200cc5852a3d63f9",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
"ORCA_ENVIRONMENT": "",
"ORCA_PAIRING_CODE": "",
"ORCA_REMOTE_PAIRING": "",
"ORCA_STRUCTURED_SESSION": "1",
"ORCA_TERMINAL_HANDLE": "term_parent",
"ORCA_USER_DATA_PATH": "<root>/user-data",
"PARITY_OVERLAY": "opencode2-configured",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
},
"envToDelete": [
"OPENCODE_AUTH_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"ORCA_AGENT_HOOK_ENDPOINT",
"ORCA_AGENT_HOOK_ENV",
"ORCA_AGENT_HOOK_PORT",
"ORCA_AGENT_HOOK_TOKEN",
"ORCA_AGENT_HOOK_TRANSPORT",
"ORCA_AGENT_HOOK_VERSION",
"ORCA_AGENT_LAUNCH",
"ORCA_AGENT_LAUNCH_TOKEN",
"ORCA_AGENT_PANE",
"ORCA_CHAT_VISUALS_DIR",
"ORCA_CLAUDE_AGENT_STATUS_SETTINGS",
"ORCA_DATA_ACCOUNT_PROVIDER",
"ORCA_OPENCODE_CONFIG_DIR",
"ORCA_PANE_KEY",
"ORCA_TAB_ID",
"ORCA_WORKTREE_ID",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
],
"supervisor": {
"closeRequest": "stdin-end",
"cwd": "<root>/worktree",
"lifetime": "session",
"nodeEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
},
},
},
"launchPosture": {
"fullAccess": true,
},
"pinnedLaunchDirectories": [],
}
`;
exports[`native-chat launch parity > pi floating fresh 1`] = `
{
"child": {
@@ -3311,7 +3828,7 @@ exports[`native-chat launch parity > pi worktree fresh configured 1`] = `
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000046",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000051",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -3506,7 +4023,7 @@ exports[`native-chat launch parity > pi worktree resumed configured 1`] = `
"--mode",
"rpc",
"--session",
"<root>/pi-sessions/00000000-0000-4000-8000-000000000047.jsonl",
"<root>/pi-sessions/00000000-0000-4000-8000-000000000052.jsonl",
],
"command": "<root>/custom-bin/pi-custom",
"cwd": "<root>/worktree",
@@ -3518,7 +4035,7 @@ exports[`native-chat launch parity > pi worktree resumed configured 1`] = `
"HOME": "<root>/home",
"INHERITED_ONLY": "overlay",
"LANG": "C",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000047",
"ORCA_AGENT_SESSION_ID": "00000000-0000-4000-8000-000000000052",
"ORCA_AGENT_SESSION_SPAWN_TOKEN": "parity-spawn-token",
"ORCA_CLI_BIN_DIR": "<root>/orca-cli/bin",
"ORCA_CLI_COMMAND": "<root>/orca-cli/bin/orca",
@@ -214,6 +214,45 @@ describe('adopted conversation ownership', () => {
)
).toThrow('agent_session_conflict')
})
// `opencode` and `opencode2` run one OpenCode under two names, so an id either mints is one
// conversation; two adoptions racing are serialized by the store, so the second sees the first.
it.each([
['opencode', 'opencode2', true],
['opencode2', 'opencode', true],
['grok', 'omp', false]
] as const)(
'an %s adoption, then an %s one of the same id: refused %s',
(first, second, refused) => {
const OPENCODE_HOME = { kind: 'opencode', locator: { kind: 'unmanaged' } } as const
const adoptAs = (agent: typeof first | typeof second, sessionId: string) =>
reserveRequest({
sessionId,
provider: agent,
accountHome: OPENCODE_HOME,
adoptedHandleLink: adoptedLink({
linkId: `${agent}-1-ses_same`,
handle: { transport: 'acp', agent, nativeId: 'ses_same' }
})
})
const { record } = applyAgentSessionReservation(
storeState(),
adoptAs(first, 'session-first'),
LEASE_TTL_MS
)
const adoptSecond = () =>
applyAgentSessionReservation(
storeState([record]),
adoptAs(second, 'session-second'),
LEASE_TTL_MS
)
if (refused) {
expect(adoptSecond).toThrow('agent_session_conflict')
} else {
expect(adoptSecond).not.toThrow()
}
}
)
})
describe('re-create over a failed create', () => {
@@ -10,6 +10,7 @@
*/
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import { chatAgentsShareConversations } from '../../shared/structured-agent-cli-conversation-adoption'
import {
agentSessionOperationKey,
evaluateAgentSessionOperation,
@@ -246,7 +247,12 @@ export function assertAdoptedConversationUnowned(
continue
}
const holdsSameConversation = record.providerHandleChain.some(
(link) => agentSessionProviderHandleRoot(link.handle) === root
(link) =>
agentSessionProviderHandleRoot(link.handle) === root ||
// One id from two chat agents that open the same conversations (`opencode`, `opencode2`).
(link.handle.transport === adopted.handle.transport &&
link.handle.nativeId === adopted.handle.nativeId &&
chatAgentsShareConversations(link.handle.agent, adopted.handle.agent))
)
if (holdsSameConversation) {
throw agentSessionRefusalError('agent_session_conflict', {
@@ -154,7 +154,8 @@ import {
structuredWorkerProcessIncarnation
} from './structured-worker-identity'
const AGENTS = ['claude', 'codex', 'grok', 'opencode', 'omp', 'pi', 'antigravity'] as const
// A new agent goes last: its cases then shift only the configured ones' session ids.
const AGENTS = ['claude', 'codex', 'grok', 'opencode', 'omp', 'pi', 'antigravity', 'opencode2'] as const
type Agent = (typeof AGENTS)[number]
/** What each fake binary prints for `--version`: a release every launch gate accepts. */
@@ -165,7 +166,8 @@ const VERSIONS: Record<Agent, string> = {
opencode: '2.0.20',
omp: '17.1.0',
pi: '1.1.0',
antigravity: '1.3.0'
antigravity: '1.3.0',
opencode2: '2.0.20'
}
/** An agent whose binary is not named after it. */
const BINARIES: Partial<Record<Agent, string>> = { antigravity: 'agy_acp_server.par' }
@@ -280,7 +282,8 @@ function configuredSettings(): Partial<GlobalSettings> {
opencode: '',
omp: '',
pi: '',
antigravity: ''
antigravity: '',
opencode2: ''
},
agentDefaultEnv: Object.fromEntries(
AGENTS.map((agent) => [
@@ -346,6 +349,7 @@ function accountHomeFor(agent: Agent): AgentSessionAccountHome {
case 'grok':
return { variable: 'GROK_HOME', path: directory('grok') }
case 'opencode':
case 'opencode2':
return { kind: 'opencode', locator: { kind: 'unmanaged' } }
case 'omp':
return { variable: 'PI_CODING_AGENT_DIR', path: directory('omp') }
@@ -374,6 +378,7 @@ async function resumedHandleFor(
}
case 'grok':
case 'opencode':
case 'opencode2':
case 'omp':
case 'antigravity':
return { transport: 'acp', agent, nativeId: `acp-session-${sessionId}` }
@@ -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', 'opencode2'])
})
it('maps an ACP agent with no session-free listing to an unavailable registration', () => {
const spec = acpLaunchSpecFor('grok')!
const discovery = acpModelCatalogDiscovery(
@@ -3,7 +3,7 @@
// or env could list models the user's sessions cannot see.
import { createAcpModelCatalogProbe } from '../acp/acp-model-catalog-probe'
import type { AcpLaunchSpec } from '../acp/acp-launch-specs'
import { acpListingNeedsChatFolder, type AcpLaunchSpec } from '../acp/acp-launch-specs'
import {
createClaudeModelCatalogProbe,
type ClaudeModelCatalogProbeDeps
@@ -77,6 +77,7 @@ export function acpModelCatalogDiscovery(
return {
kind: 'probe',
listingNamesConfiguredModel: discovery.listingNamesConfiguredModel,
...(acpListingNeedsChatFolder(discovery) ? { listsInChatFolder: true as const } : {}),
probe: createAcpModelCatalogProbe(spec, {
resolveEnvironment: environment.resolveBaseEnvironment,
refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram,
@@ -47,9 +47,11 @@ export function registeredModelCatalogDiscovery(
): {
probes: Record<string, AgentModelCatalogProbe>
listingNamesConfiguredModel: Set<string>
listsInChatFolder: Set<string>
} {
const probes: Record<string, AgentModelCatalogProbe> = {}
const listingNamesConfiguredModel = new Set<string>()
const listsInChatFolder = new Set<string>()
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 }
}
/**
@@ -28,6 +28,7 @@ describe('structured agent metadata without adapter implementations', () => {
'claude',
'grok',
'opencode',
'opencode2',
'omp',
'antigravity'
])
@@ -10,7 +10,7 @@ describe('ACP agents in the runtime registrations', () => {
it('registers Grok beside Claude and Codex with its declared capabilities', () => {
expect(
STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent)
).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'omp', 'antigravity'])
).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'opencode2', 'omp', 'antigravity'])
expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({
handleTransport: 'acp',
accountHomeVariable: 'GROK_HOME',
@@ -37,9 +37,9 @@ describe('ACP agents in the runtime registrations', () => {
expect(definition?.accountHomeVariable).toBeUndefined()
})
it('leaves the `opencode2` agent on its terminal-backed chat, and asks `opencode` its version', () => {
expect(structuredAgentRuntimeRegistration('opencode2')).toBeNull()
it('asks `opencode` and `opencode2` their version at create', () => {
expect(structuredAgentRuntimeRegistration('opencode')?.launchCheck).toBeDefined()
expect(structuredAgentRuntimeRegistration('opencode2')?.launchCheck).toBeDefined()
// Grok runs whatever is installed: its create asks nothing of the binary.
expect(structuredAgentRuntimeRegistration('grok')?.launchCheck).toBeUndefined()
})
@@ -55,13 +55,13 @@ describe('ACP agents in the runtime registrations', () => {
expect(registration?.launchCheck).toBeDefined()
})
it('opens OMP as a structured chat with Chat UI on', () => {
it.each(['omp', 'opencode2'])('opens %s as a structured chat with Chat UI on', (agent) => {
const settings = {
experimentalNativeChat: true
}
const placement = { agent: 'omp', workspaceKind: 'git-worktree' } as const
const placement = { agent, workspaceKind: 'git-worktree' } as const
expect(decideAgentLaunchMode({ placement, settings }).mode).toBe('structured')
// Off: new OMP launches use the terminal.
// Off: new launches use the terminal.
expect(
decideAgentLaunchMode({
placement,
@@ -36,7 +36,7 @@ describe('the host catalog lifetime', () => {
it('prewarms once the host is installed and stops its listings at teardown', async () => {
const service = {
read: vi.fn(async () => ({ origin: 'unknown' as const })),
retainedLiveModelIds: vi.fn(() => []),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => {}),
stop: vi.fn(),
+20
View File
@@ -212,6 +212,26 @@ describe('skill discovery', () => {
])
})
it("discovers a project's OpenCode skills as OpenCode's", async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const repo = join(root, 'repo')
const opencodeSkill = join(repo, '.opencode', 'skills', 'release-notes')
await mkdir(opencodeSkill, { recursive: true })
await writeFile(join(opencodeSkill, 'SKILL.md'), '# Release notes\n\nWrite release notes.')
const result = await discoverSkills({
homeDir: join(root, 'home'),
cwd: join(root, 'missing-cwd'),
repos: [makeRepo(repo)]
})
const skill = result.skills.find((candidate) => candidate.name === 'Release notes')
expect(result.sources.find((source) => source.path === skill?.rootPath)).toMatchObject({
sourceKind: 'repo',
owner: 'opencode'
})
})
it('filters discovery by requested directory name and source kind', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
@@ -185,7 +185,7 @@ describe('bounded concurrent skill discovery', () => {
const line = String(info.mock.calls.at(0)?.at(0))
// `present` is the signal that separates "big tree" from "big root set", and
// is not derivable from the other counts.
expect(line).toContain('[skills] scan roots=32 present=3 walked=32 skills=3')
expect(line).toContain('[skills] scan roots=33 present=3 walked=33 skills=3')
expect(line).toContain('home-claude')
expect(line).not.toContain(home)
expect(line).not.toContain(tmpdir())
+10 -1
View File
@@ -209,7 +209,16 @@ export function buildSkillDiscoverySources(
['agent-skills'],
null
),
...providerRepoSources(repoPath, label, pathApi)
...providerRepoSources(repoPath, label, pathApi),
// OpenCode also reads its own project folder; it installs into the canonical root above.
source(
`repo-opencode-${stablePathId(repoPath)}`,
`${label} .opencode`,
pathApi.join(repoPath, '.opencode', 'skills'),
'repo',
['agent-skills'],
'opencode'
)
)
}
@@ -40,6 +40,38 @@ export type NativeChatSkillDiscoverySnapshot = {
errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown'
}
/**
* Which commands a scanned skill of the same name may stand in for, by marking them unclassified.
* Never one the chat's own send handles (`/compact`), which runs that whatever the row says. Where
* the agent's report lists skills among its commands (OpenCode 1.x), only a name reported under
* that skill's own description; any other is the agent's command, which OpenCode runs first.
*/
export function classifyPickerCommands(
commands: readonly SlashCommandSuggestion[],
skills: readonly DiscoveredSkill[],
options: { reportMayListSkills: boolean; isSendCommand: (name: string) => boolean }
): readonly SlashCommandSuggestion[] {
const skillDescriptions = options.reportMayListSkills
? new Set(skills.map((skill) => `${skill.name}\0${normalizedDescription(skill.description)}`))
: null
return commands.map((command) => {
const unclassified =
(command.kindUnspecified === true ||
skillDescriptions?.has(`${command.name}\0${normalizedDescription(command.description)}`) ===
true) &&
!options.isSendCommand(command.name)
if (unclassified === (command.kindUnspecified === true)) {
return command
}
const { kindUnspecified: _dropped, ...classified } = command
return unclassified ? { ...command, kindUnspecified: true } : classified
})
}
function normalizedDescription(description: string | null | undefined): string {
return description?.replace(/\s+/g, ' ').trim() ?? ''
}
const PICKER_RESULT_LIMIT = 50
const SCOPE_PRIORITY: Record<SkillSourceKind, number> = {
repo: 0,
@@ -1,13 +1,17 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildNativeChatPickerItems } from './native-chat-picker-items'
import { buildNativeChatPickerItems, classifyPickerCommands } from './native-chat-picker-items'
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands'
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
import {
isStructuredAgentSessionComposerCommand,
structuredSlashCommands
} from '../../../../shared/structured-agent-session-composer'
import type { DiscoveredSkill } from '../../../../shared/skills'
function transport(sessionCommands?: NativeChatStructuredComposerTransport['sessionCommands']) {
return { sessionCommands } as NativeChatStructuredComposerTransport
@@ -157,3 +161,81 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co
result.current(enter)
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ name: 'clear', kind: 'command' }))
})
describe('OpenCode chat skills', () => {
function skill(name: string, description: string): DiscoveredSkill {
return {
id: name,
name,
description,
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'OpenCode home',
rootPath: '/Users/test/.config/opencode/skills',
directoryPath: `/Users/test/.config/opencode/skills/${name}`,
skillFilePath: `/Users/test/.config/opencode/skills/${name}/SKILL.md`,
installed: true,
updatedAt: null
}
}
const scanned = [skill('release-notes', 'Write release notes')]
// What the composer's picker builds: the reported catalog, classified against the scan.
function items(
agent: 'opencode' | 'opencode2' | 'claude',
reported: { name: string; description?: string; kindUnspecified?: true }[],
skills: DiscoveredSkill[] = scanned
) {
const report = reported.map((command) => ({ ...command, kind: 'command' as const }))
const { result } = renderHook(() => useNativeChatComposerCatalog(agent, transport(report)))
const commands = classifyPickerCommands(result.current.agentCommands, skills, {
reportMayListSkills: agent !== 'claude',
isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent)
})
return buildNativeChatPickerItems(commands, skills, '', '/', result.current.sessionSkillNames)
}
const rows = (...args: Parameters<typeof items>) =>
items(...args).map((item) => `${item.kind}:${item.name}`)
// 2.x reports only these commands, so its running chats listed no skills at all.
it('keeps the scanned skills once an OpenCode 2.x chat reports its commands', () => {
expect(rows('opencode2', [{ name: 'init' }, { name: 'review' }])).toContain(
'skill:release-notes'
)
})
// 1.x reports each skill as a command too, under the skill's own description.
it('shows a skill OpenCode 1.x also reports as a command once, as the skill', () => {
expect(
rows('opencode', [
{ name: 'init' },
{ name: 'release-notes', description: 'Write release notes' }
]).filter((row) => row.endsWith(':release-notes'))
).toEqual(['skill:release-notes'])
})
// OpenCode runs its own command before a skill of the same name, on either line.
it.each(['opencode', 'opencode2'] as const)(
'keeps %s’s own command when a skill shares its name, and marks the collision',
(agent) => {
const picked = items(
agent,
[{ name: 'review', description: 'review changes [commit|branch|pr]' }],
[skill('review', 'My review checklist')]
)
expect(picked).toEqual([
expect.objectContaining({ kind: 'command', name: 'review', skillCollision: true })
])
}
)
// The chat's send runs `/compact` as compaction whichever row inserted it.
it.each([
['opencode2', { name: 'compact', description: 'compact the session' }],
['opencode', { name: 'compact', description: 'Squash my notes' }],
['claude', { name: 'compact', kindUnspecified: true as const }]
] as const)('never lets a skill stand in for %s’s /compact', (agent, reported) => {
const picked = items(agent, [reported], [skill('compact', 'Squash my notes')])
expect(picked.map((item) => `${item.kind}:${item.name}`)).toEqual(['command:compact'])
expect(isStructuredAgentSessionComposerCommand(picked[0]!.token, agent)).toBe(true)
})
})
@@ -1,6 +1,9 @@
import { useMemo } from 'react'
import type { AgentType } from '../../../../shared/agent-status-types'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import {
getNativeChatAgentProfile,
getVerifiedNativeChatCommands
} from '../../../../shared/native-chat-agent-profiles'
import {
sessionReportedSkillNames,
sessionSlashCommandSuggestions,
@@ -28,18 +31,22 @@ export function useNativeChatComposerCatalog(
const structured = Boolean(structuredTransport)
const reported = structuredTransport?.sessionCommands
const conversationCommands = structuredTransport?.conversationCommands
const agentCommands = useMemo(
() =>
!structured
? getVerifiedNativeChatCommands(agent)
: reported !== undefined
? sessionSlashCommandSuggestions(agent, reported)
: structuredSlashCommands(conversationCommands, agent),
[agent, conversationCommands, reported, structured]
)
const keepsScannedSkills = getNativeChatAgentProfile(agent)?.keepsScannedSkills === true
const agentCommands = useMemo(() => {
if (!structured) {
return getVerifiedNativeChatCommands(agent)
}
if (reported === undefined) {
return structuredSlashCommands(conversationCommands, agent)
}
return sessionSlashCommandSuggestions(agent, reported)
}, [agent, conversationCommands, reported, structured])
const sessionSkillNames = useMemo(
() => (reported !== undefined ? sessionReportedSkillNames(reported) : undefined),
[reported]
() =>
reported !== undefined && !keepsScannedSkills
? sessionReportedSkillNames(reported)
: undefined,
[keepsScannedSkills, reported]
)
return { agentCommands, sessionSkillNames }
}
@@ -14,6 +14,7 @@ import {
import type { AgentType } from '../../../../shared/agent-status-types'
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands'
import { isStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import {
applyMentionSuggestion,
applyPickerSuggestion,
@@ -25,6 +26,7 @@ import {
type NativeChatPickerItem,
type NativeChatSendClassification
} from './native-chat-composer-state'
import { classifyPickerCommands } from './native-chat-picker-items'
import { useNativeChatSkills } from './use-native-chat-skills'
import {
emitNativeChatPickerItemAccepted,
@@ -83,6 +85,15 @@ export function useNativeChatPickerState(args: {
}, [disabled])
const skillPickerTriggered = !disabled && isSkillPickerTriggered(draft.slice(0, caret), profile)
const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered)
const pickerCommands = useMemo(
() =>
classifyPickerCommands(agentCommands, discovery.skills, {
// A chat that keeps scanned skills (OpenCode) may report each as a command too.
reportMayListSkills: profile?.keepsScannedSkills === true,
isSendCommand: (name) => isStructuredAgentSessionComposerCommand(`/${name}`, agent)
}),
[agent, agentCommands, discovery.skills, profile]
)
const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}`
const dismissalContext = `${draftScopeKey}:${agent}`
const [dismissed, setDismissed] = useState<{ context: string; triggerKey: string } | null>(null)
@@ -95,7 +106,7 @@ export function useNativeChatPickerState(args: {
: deriveComposerAutocomplete(
draft,
caret,
agentCommands,
pickerCommands,
discovery.skills,
profile,
discovery,
@@ -103,13 +114,13 @@ export function useNativeChatPickerState(args: {
sessionSkillNames
),
[
agentCommands,
caret,
dismissalContext,
dismissed,
discovery,
disabled,
draft,
pickerCommands,
profile,
recalledFromHistory,
sessionSkillNames
@@ -195,7 +206,7 @@ export function useNativeChatPickerState(args: {
const next = deriveComposerAutocomplete(
value,
nextCaret,
agentCommands,
pickerCommands,
discovery.skills,
profile,
discovery,
@@ -206,7 +217,7 @@ export function useNativeChatPickerState(args: {
setDismissed(null)
}
},
[agentCommands, dismissalContext, dismissed, discovery, draft, profile, sessionSkillNames]
[dismissalContext, dismissed, discovery, draft, pickerCommands, profile, sessionSkillNames]
)
const classifySend = useCallback(
@@ -75,6 +75,15 @@ describe('isNativeChatSkillForAgent', () => {
expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false)
})
// Both OpenCode lines read `.claude/skills` in the home folder and the project, but not plugins.
it('shows OpenCode the Claude skill folders it reads, and not Claude plugins', () => {
const claude = discovery('claude')
expect(isNativeChatSkillForAgent('opencode', skill({}), claude)).toBe(true)
expect(isNativeChatSkillForAgent('opencode2', skill({}), claude)).toBe(true)
const plugin = { sources: [{ ...claude.sources[0], sourceKind: 'plugin' as const }] }
expect(isNativeChatSkillForAgent('opencode2', skill({}), plugin)).toBe(false)
})
it('grants visibility through any contributing root, not just the dedup survivor', () => {
const result = {
sources: [
@@ -3,7 +3,10 @@ import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles'
import {
getNativeChatAgentProfile,
readsSkillSource
} from '../../../../shared/native-chat-agent-profiles'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry'
import {
@@ -81,7 +84,7 @@ export function isNativeChatSkillForAgent(
const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath]
return rootPaths.some((rootPath) => {
const source = result.sources.find((entry) => entry.path === rootPath)
return source?.owner === null || source?.owner === profile.skillSourceOwner
return source !== undefined && readsSkillSource(profile, source)
})
}
@@ -111,22 +111,27 @@ describe('Resume in New CLI launch', () => {
mocks.launchAiVaultSessionInNewTab.mockReturnValue({ tabId: 'tab-1' })
})
it.each(['command', 'arguments'] as const)(
'refuses effective OpenCode %s server routing before asking for any copy',
async (location) => {
// Either OpenCode chat may own a 2.x conversation, so the copy may open in either CLI.
it.each([
['command', 'opencode'],
['arguments', 'opencode'],
['command', 'opencode2'],
['arguments', 'opencode2']
] as const)(
'refuses %s server routing on %s before asking for any copy',
async (location, cli) => {
const chat = { ...owned, agent: 'opencode2' as const }
const agentCmdOverrides =
location === 'command' ? { [cli]: `${cli} --server http://synthetic-server` } : {}
mocks.getState.mockReturnValue({
activeWorktreeId: 'worktree-1',
settings: {
agentCmdOverrides,
agentDefaultArgs:
location === 'arguments' ? { opencode: '--server=http://synthetic-server' } : {}
location === 'arguments' ? { [cli]: '--server=http://synthetic-server' } : {}
}
})
renderActions(
location === 'command'
? { opencode: 'opencode --server http://synthetic-server' }
: undefined
).handleResumeInNewCli(chat, 'worktree-2')
renderActions(agentCmdOverrides).handleResumeInNewCli(chat, 'worktree-2')
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('Remove --server'))
expect(mocks.prepareAiVaultSessionForFork).not.toHaveBeenCalled()
@@ -152,7 +152,7 @@ export function useAiVaultSessionLaunchActions({
state: useAppStore.getState(),
worktreeId,
session,
commandOverride: agentCmdOverrides?.opencode
commandOverride: agentCmdOverrides?.[session.agent]
})
const prepared = await prepareAiVaultSessionForFork(session)
const target = await dropDeletedSshResumeCwd(prepared.session)
@@ -14,11 +14,17 @@ const mocks = vi.hoisted(() => ({
prepareAiVaultSessionForResume: vi.fn<() => Promise<{ sessionId: string }>>(),
activateAndRevealWorkspace: vi.fn<(workspaceId: string) => unknown>(),
toastError: vi.fn<(message: string) => void>(),
admitStructuredLaunchOnHost:
vi.fn<(target: unknown, worktree: string, agent: string) => Promise<{ kind: string }>>(),
activeWorktreeId: 'other-worktree'
}))
vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({
beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch
beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch,
structuredLaunchOwner: () => ({ executionHostId: 'local', target: { kind: 'local' } })
}))
vi.mock('@/lib/structured-agent-session-host-admission', () => ({
admitStructuredLaunchOnHost: mocks.admitStructuredLaunchOnHost
}))
vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({
prepareAiVaultSessionForResume: mocks.prepareAiVaultSessionForResume
@@ -94,12 +100,33 @@ describe('resumeAiVaultSessionInNewChat', () => {
settle({ kind: 'structured', sessionId: 'session-1' })
})
it('opens an OpenCode 2 row in an OpenCode chat', async () => {
await resumeAiVaultSessionInNewChat(session, 'opencode2', 'worktree-1', 'resume-click')
// Where `opencode` is 1.x, only the OpenCode 2 chat can load a 2.x conversation.
it.each([
['admitted', 'opencode2'],
['declined', 'opencode'],
// Not a refusal: switching would start `opencode`, which may be 1.x.
['unreachable', 'opencode2'],
['workspace-unresolved', 'opencode2']
])(
'opens an OpenCode 2 row in the OpenCode 2 chat, else an OpenCode chat (%s)',
async (kind, agent) => {
mocks.admitStructuredLaunchOnHost.mockResolvedValue({ kind })
await resumeAiVaultSessionInNewChat(session, 'opencode2', 'worktree-1', 'resume-click')
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith(
expect.objectContaining({ plan: expect.objectContaining({ agent: 'opencode' }) })
)
expect(mocks.admitStructuredLaunchOnHost).toHaveBeenCalledExactlyOnceWith(
{ kind: 'local' },
'id:worktree-1',
'opencode2'
)
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith(
expect.objectContaining({ plan: expect.objectContaining({ agent }) })
)
}
)
it('asks the host nothing for a row only one chat takes over', async () => {
await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click')
expect(mocks.admitStructuredLaunchOnHost).not.toHaveBeenCalled()
})
it.each([
@@ -12,8 +12,14 @@ import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-pr
import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan'
import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
import {
beginStructuredAgentSessionProvisionalLaunch,
structuredLaunchOwner
} from '@/lib/structured-agent-session-provisional-tab'
import { admitStructuredLaunchOnHost } from '@/lib/structured-agent-session-host-admission'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure'
import type { TuiAgent } from '../../../../shared/tui-agent'
export function activateAiVaultResumeWorkspace(workspaceId: string): boolean {
return activateAndRevealWorkspace(workspaceId) !== false
@@ -32,11 +38,14 @@ export async function resumeAiVaultSessionInNewChat(
try {
// Codex rows can live under a shared legacy home; the same preparation the terminal resume
// runs re-pins them, and its result is what names the conversation the host will look for.
const preparedSession = await prepareAiVaultSessionForResume(session)
const [preparedSession, agent] = await Promise.all([
prepareAiVaultSessionForResume(session),
adoptingAgentOnHost(rowAgent, worktreeId)
])
const plan = adoptAgentSessionLaunchVerdict({
route: 'structured-native-chat',
requestId,
agent: CLI_CONVERSATION_ADOPTING_AGENTS[rowAgent],
agent,
worktreeId,
resumeFrom: { providerSessionId: preparedSession.sessionId }
})
@@ -62,6 +71,28 @@ export async function resumeAiVaultSessionInNewChat(
}
}
/** The first chat the worktree's host does not refuse to take the row over, trying them in order.
* A host that cannot answer yet keeps the chat it was asked about, whose own launch reports it. */
async function adoptingAgentOnHost(
rowAgent: CliConversationRowAgent,
worktreeId: string
): Promise<TuiAgent> {
const [first, ...rest] = CLI_CONVERSATION_ADOPTING_AGENTS[rowAgent]
const owner = structuredLaunchOwner({ route: 'structured-native-chat' }, worktreeId)
const selector = toRuntimeWorktreeSelector(worktreeId)
let agent: TuiAgent = first
for (const next of rest) {
if (
!owner ||
(await admitStructuredLaunchOnHost(owner.target, selector, agent)).kind !== 'declined'
) {
return agent
}
agent = next
}
return agent
}
/** The host refuses an adoption whose conversation another chat already holds, and refuses one it
* cannot find under any account home it recognises. Both are actionable, and neither is the
* generic "could not prepare" the terminal resume reports. */
@@ -51,14 +51,16 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: {
targetWorkspaceId !== null &&
targetOwner !== null &&
targetOwner === normalizeExecutionHostId(args.session.executionHostId) &&
structuredAgentSessionLaunchFeasible(state, {
agent: CLI_CONVERSATION_ADOPTING_AGENTS[args.session.agent],
workspace: {
kind: workspaceKindForWorktreeId(targetWorkspaceId),
worktreeId: targetWorkspaceId
},
settings: args.settings
}) &&
CLI_CONVERSATION_ADOPTING_AGENTS[args.session.agent].some((agent) =>
structuredAgentSessionLaunchFeasible(state, {
agent,
workspace: {
kind: workspaceKindForWorktreeId(targetWorkspaceId),
worktreeId: targetWorkspaceId
},
settings: args.settings
})
) &&
resolveHostCapabilities(state, targetOwner)?.includes(
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY
) === true
@@ -21,6 +21,7 @@ import {
import {
adoptsCliConversations,
CLI_CONVERSATION_ADOPTING_AGENTS,
cliConversationRowsShareAgent,
type CliConversationRowAgent
} from '../../../../shared/structured-agent-cli-conversation-adoption'
import { agentForksConversations } from '../../../../shared/agent-resume-argv'
@@ -94,14 +95,16 @@ export function canPaneOfferResumeInNewChat(
// The row gate's launch-route half, so a project the chat route refuses (WSL, repair) hides it.
return (
canHostOfferTabSessionMove('cli', hostScope) &&
structuredAgentSessionLaunchFeasible(state, {
agent: CLI_CONVERSATION_ADOPTING_AGENTS[conversation.agent],
workspace: {
kind: workspaceKindForWorktreeId(conversation.worktreeId),
worktreeId: conversation.worktreeId
},
settings: state.settings
})
CLI_CONVERSATION_ADOPTING_AGENTS[conversation.agent].some((agent) =>
structuredAgentSessionLaunchFeasible(state, {
agent,
workspace: {
kind: workspaceKindForWorktreeId(conversation.worktreeId),
worktreeId: conversation.worktreeId
},
settings: state.settings
})
)
)
}
@@ -174,11 +177,10 @@ export function findTabSessionHistoryRow(
return session.structuredSession?.sessionId === subject.sessionId
}
// A chat-owned row still matches: the move's gate refuses it, and the caller can open that chat.
// By the chat agent that takes it over: an OpenCode 2.x pane reports `opencode`, its row `opencode2`.
// By the chats that take it over: an OpenCode 2.x pane reports `opencode`, its row `opencode2`.
return (
adoptsCliConversations(session.agent) &&
CLI_CONVERSATION_ADOPTING_AGENTS[session.agent] ===
CLI_CONVERSATION_ADOPTING_AGENTS[subject.agent] &&
cliConversationRowsShareAgent(session.agent, subject.agent) &&
session.sessionId === subject.providerSessionId
)
}) ?? null
@@ -306,6 +306,40 @@ describe('orchestration skill agent coverage', () => {
).toBe(true)
})
it('marks OpenCode 2 from ~/.config/opencode/skills like OpenCode', () => {
const skills = [
skill({
providers: ['agent-skills'],
sourceKind: 'home',
rootPath: '/Users/test/.config/opencode/skills',
directoryPath: '/Users/test/.config/opencode/skills/orchestration'
})
]
expect(
agentHasOrchestrationSkill('opencode2', skills, [
source('/Users/test/.config/opencode/skills', 'opencode')
])
).toBe(true)
})
it('marks OpenCode 2 from ~/.claude/skills, which OpenCode also reads', () => {
const skills = [
skill({
providers: ['claude'],
sourceKind: 'home',
rootPath: '/Users/test/.claude/skills',
directoryPath: '/Users/test/.claude/skills/orchestration'
})
]
expect(
agentHasOrchestrationSkill('opencode2', skills, [
source('/Users/test/.claude/skills', 'claude')
])
).toBe(true)
})
it('marks Claude Agent Teams from ~/.claude/skills like Claude Code', () => {
const skills = [
skill({
@@ -1,6 +1,11 @@
import type { AgentType } from '../../../shared/agent-status-types'
import type { DiscoveredSkill, SkillDiscoverySource } from '../../../shared/skills'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
getNativeChatAgentProfile,
readsSkillSource,
type NativeChatAgentProfile
} from '../../../shared/native-chat-agent-profiles'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { getAgentLabel } from '@/lib/agent-catalog'
import { TUI_AGENT_AUTO_PICK_ORDER } from '../../../shared/tui-agent-selection'
@@ -30,11 +35,13 @@ function isOrchestrationSkill(skill: DiscoveredSkill): boolean {
)
}
// Why: `native-chat-agent-profiles.ts` carries the same rule as `skillSourceOwner`;
// keep the two in step when an agent starts reading another agent's roots.
function getSkillSourceOwnerForAgent(agent: TuiAgent): AgentType {
// Why: both launch Claude Code and therefore consume Claude-owned skill roots.
return agent === 'claude-agent-teams' || agent === 'openclaude' ? 'claude' : agent
// Why: an agent with a chat profile reads the roots its chats do; without one it reads its own,
// and Claude Agent Teams launches Claude Code, so it reads Claude's.
function skillReaderForAgent(
agent: TuiAgent
): Pick<NativeChatAgentProfile, 'skillSourceOwner' | 'readsSkillFoldersOf'> {
const owner: AgentType = agent === 'claude-agent-teams' ? 'claude' : agent
return getNativeChatAgentProfile(agent) ?? { skillSourceOwner: owner }
}
/** `skills` and `sources` must come from the same discovery scan. */
@@ -43,7 +50,7 @@ export function agentHasOrchestrationSkill(
skills: readonly DiscoveredSkill[],
sources: readonly SkillDiscoverySource[]
): boolean {
const owner = getSkillSourceOwnerForAgent(agent)
const reader = skillReaderForAgent(agent)
return skills.some((skill) => {
if (!isOrchestrationSkill(skill)) {
return false
@@ -58,7 +65,7 @@ export function agentHasOrchestrationSkill(
(source) =>
source.path === rootPath &&
source.sourceKind !== 'repo' &&
(source.owner === null || source.owner === owner)
readsSkillSource(reader, source)
)
)
})
@@ -77,6 +77,22 @@ describe('host model catalog snapshots', () => {
expect(readHostModelCatalogSnapshot(PAIRED, 'codex', NEW_CHAT)).toBeUndefined()
})
it('serves a list the host says is a workspace’s own to that workspace only', () => {
const ownList = { ...list(false), modelsDifferByWorkspace: true as const }
recordHostModelCatalogSnapshot(LOCAL, 'opencode', 'wt-1', ownList)
expect(readHostModelCatalogSnapshot(LOCAL, 'opencode', NEW_CHAT)).toEqual(ownList)
expect(
readHostModelCatalogSnapshot(LOCAL, 'opencode', { ...NEW_CHAT, worktree: 'wt-2' })
).toBeUndefined()
expect(
readHostModelCatalogSnapshot(LOCAL, 'opencode', {
...NEW_CHAT,
newLaunch: false,
worktree: 'wt-2'
})
).toBeUndefined()
})
it('drops an agent’s answers when the host says the account has no list', () => {
recordHostModelCatalogSnapshot(LOCAL, 'codex', 'wt-1', list(true))
recordHostModelCatalogSnapshot(LOCAL, 'codex', 'wt-1', { origin: 'unknown' })
@@ -40,7 +40,8 @@ function withoutNamedDefault(catalog: ListedCatalog): ListedCatalog {
* What a chat's first frame may render from: undefined until the host has answered what that
* frame would show. Whether a new chat runs the listed default depends on its workspace's own
* config, so another workspace's answer serves only a chat that names its model, a list that
* names no default, or a default the host says no workspace can replace.
* names no default, or a default the host says no workspace can replace; never a list the host
* says is that workspace's own.
*/
export function readHostModelCatalogSnapshot(
target: RuntimeClientTarget,
@@ -56,8 +57,10 @@ export function readHostModelCatalogSnapshot(
return exact
}
const listed = entry.account ?? entry.byWorktree.values().next().value
if (!listed || listed.modelsDifferByWorkspace) {
return undefined
}
if (
!listed ||
!launch.newLaunch ||
listed.listingNamesConfiguredModel === false ||
listed.defaultHoldsInEveryWorkspace === true
+1
View File
@@ -52,6 +52,7 @@ const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map<string, AgentChatPermission
['codex', CODEX_CHAT_PERMISSION_MODES],
['grok', ACP_CHAT_PERMISSION_MODES],
['opencode', ACP_CHAT_PERMISSION_MODES],
['opencode2', ACP_CHAT_PERMISSION_MODES],
['omp', ACP_CHAT_PERMISSION_MODES],
['antigravity', ANTIGRAVITY_CHAT_PERMISSION_MODES]
])
+1
View File
@@ -5,6 +5,7 @@ export const AGENT_SESSION_SIGN_IN = [
{ agent: 'codex', loginCommand: ['codex', 'login'] },
{ agent: 'grok', loginCommand: ['grok', 'login'] },
{ agent: 'opencode', loginCommand: ['opencode', 'auth', 'login'] },
{ agent: 'opencode2', loginCommand: ['opencode2', 'auth', 'login'] },
{ agent: 'pi', loginCommand: ['pi'], slashCommand: '/login' },
{ agent: 'omp', loginCommand: [] },
// An `agy` login does not sign its chats in: they keep their own sign-in.
+3
View File
@@ -435,6 +435,9 @@ export type AgentSessionModelCatalogResult = {
/** The named default holds in every workspace: the agent reads no project config for its
* model, so an answer naming no workspace serves any new chat. Absent from an older host. */
defaultHoldsInEveryWorkspace?: true
/** The models are this workspace's: its own config adds or removes some, so the answer serves
* no other workspace. Absent from an older host. */
modelsDifferByWorkspace?: true
}
)
+18 -13
View File
@@ -25,6 +25,7 @@ import {
import { getTuiAgentLaunchCommand, isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
import { findOptionOccurrence } from './command-option-occurrence'
import { assertOpenCodeCopyUsesLocalStore } from './opencode-launch-command'
import { CLI_CONVERSATION_ADOPTING_AGENTS } from './structured-agent-cli-conversation-adoption'
import type { GlobalSettings } from './global-settings-types'
import { parseWslUncPath } from './wsl-paths'
@@ -283,22 +284,26 @@ export function resolveAiVaultCopiedSessionAgentArgs(
.join(' ')
}
/** OpenCode copies are written to the local store, so the launch must not point elsewhere. */
/** OpenCode copies are written to the local store, so the launch must not point elsewhere. Checked
* before the copy exists, so every CLI whose chat could own the row's conversation is checked. */
export function assertAiVaultCopiedSessionStoreAllowed(args: AiVaultResumeStartupArgs): void {
if (args.session.agent !== 'opencode' && args.session.agent !== 'opencode2') {
const row = args.session.agent
if (row !== 'opencode' && row !== 'opencode2') {
return
}
const { platform, liveShell } = args.host
const command =
args.commandOverride?.trim() ||
args.settings?.agentCmdOverrides?.opencode ||
getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, platform)
const parsed = tokenizeStartupCommand(
`${command} ${resolveTuiAgentLaunchArgs('opencode', args.settings?.agentDefaultArgs)}`,
resolveStartupShell(platform, liveShell)
)
if (!parsed.ok) {
throw new Error('This session cannot be opened in the CLI.')
for (const agent of CLI_CONVERSATION_ADOPTING_AGENTS[row]) {
const command =
(agent === row ? args.commandOverride?.trim() : undefined) ||
args.settings?.agentCmdOverrides?.[agent] ||
getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], platform)
const parsed = tokenizeStartupCommand(
`${command} ${resolveTuiAgentLaunchArgs(agent, args.settings?.agentDefaultArgs)}`,
resolveStartupShell(platform, liveShell)
)
if (!parsed.ok) {
throw new Error('This session cannot be opened in the CLI.')
}
assertOpenCodeCopyUsesLocalStore(parsed.tokens.slice(1))
}
assertOpenCodeCopyUsesLocalStore(parsed.tokens.slice(1))
}
@@ -29,6 +29,11 @@ describe('native chat agent picker profiles', () => {
})
})
// OpenCode 2.x reads the same skill folders as 1.x.
it("lists OpenCode's skills for OpenCode 2", () => {
expect(getNativeChatAgentProfile('opencode2')).toMatchObject({ skillSourceOwner: 'opencode' })
})
it('does not grant custom or unverified agents a skill grammar', () => {
expect(getNativeChatAgentProfile('custom-agent')).toBeNull()
})
+30 -6
View File
@@ -1,4 +1,5 @@
import type { AgentType } from './agent-status-types'
import type { SkillDiscoverySource } from './skills'
import {
getAgentSlashCommands,
type NativeChatCommandReply,
@@ -9,6 +10,11 @@ export type NativeChatAgentProfile = {
skillPrefix: '$' | '/'
/** OpenClaude reads Claude-owned roots, so this can differ from the agent. */
skillSourceOwner: AgentType
/** Another agent whose own skill folders, though not its plugins, this agent also reads. */
readsSkillFoldersOf?: AgentType
/** The agent's command report may list some skills among its commands (OpenCode 1.x) or none
* (2.x), so a running chat keeps the skill folders' list. */
keepsScannedSkills?: true
/** The agent's own harness expands a slash command out of the message text, so
* the chat host claims only the commands it implements itself. */
expandsSlashCommandsFromText?: true
@@ -17,6 +23,15 @@ export type NativeChatAgentProfile = {
textDrivenCommands?: readonly string[]
}
// Both lines read `.claude/skills` beside their own folders, and 2.x reports no skill as a command.
const OPENCODE_PROFILE: NativeChatAgentProfile = {
skillPrefix: '/',
skillSourceOwner: 'opencode',
readsSkillFoldersOf: 'claude',
keepsScannedSkills: true,
expandsSlashCommandsFromText: true
}
const NATIVE_CHAT_AGENT_PROFILES: Partial<Record<AgentType, NativeChatAgentProfile>> = {
codex: {
skillPrefix: '$',
@@ -35,18 +50,27 @@ const NATIVE_CHAT_AGENT_PROFILES: Partial<Record<AgentType, NativeChatAgentProfi
skillSourceOwner: 'claude',
expandsSlashCommandsFromText: true
},
opencode: { skillPrefix: '/', skillSourceOwner: 'opencode', expandsSlashCommandsFromText: true },
opencode2: {
skillPrefix: '/',
skillSourceOwner: 'opencode2',
expandsSlashCommandsFromText: true
},
opencode: OPENCODE_PROFILE,
opencode2: OPENCODE_PROFILE,
grok: {
skillPrefix: '/',
skillSourceOwner: 'grok'
}
}
/** Whether skills under `source` reach `profile`'s agent: shared roots, its own, and the skill
* folders of an agent it also reads. */
export function readsSkillSource(
profile: Pick<NativeChatAgentProfile, 'skillSourceOwner' | 'readsSkillFoldersOf'>,
source: Pick<SkillDiscoverySource, 'owner' | 'sourceKind'>
): boolean {
return (
source.owner === null ||
source.owner === profile.skillSourceOwner ||
(source.owner === profile.readsSkillFoldersOf && source.sourceKind !== 'plugin')
)
}
export function getNativeChatAgentProfile(
agent: AgentType | null | undefined
): NativeChatAgentProfile | null {
@@ -1,28 +1,44 @@
/**
* The agents whose chats can take over a conversation their own CLI recorded, by the agent its
* Session History row lists under: those the host registers a transcript import for, whose row
* agents a registration test keeps equal to this map. Only a conversation recorded on this machine
* is offered, and this machine's host is this build, so the map cannot disagree with the host
* that adopts.
* The chats that can take over a conversation their own CLI recorded, by the agent its Session
* History row lists under, in the order a resume tries them: each one the host registers a
* transcript import for, which a registration test checks. Only a conversation recorded on this
* machine is offered, and this machine's host is this build, so the map cannot disagree with the
* host that adopts.
*/
export const CLI_CONVERSATION_ADOPTING_AGENTS = {
claude: 'claude',
codex: 'codex',
grok: 'grok',
omp: 'omp',
opencode: 'opencode',
// OpenCode 2.x lists its conversations as `opencode2` rows; an OpenCode chat runs either line.
opencode2: 'opencode',
pi: 'pi'
claude: ['claude'],
codex: ['codex'],
grok: ['grok'],
omp: ['omp'],
opencode: ['opencode'],
// An OpenCode 2.x conversation: the OpenCode 2 chat runs only 2.x, an OpenCode chat runs 2.x
// where `opencode` is 2.x.
opencode2: ['opencode2', 'opencode'],
pi: ['pi']
} as const
/** A Session History row agent whose conversations a chat can take over. */
export type CliConversationRowAgent = keyof typeof CLI_CONVERSATION_ADOPTING_AGENTS
/** The chat agent that takes a row agent's conversations over. */
export type CliConversationAdoptingAgent =
(typeof CLI_CONVERSATION_ADOPTING_AGENTS)[CliConversationRowAgent]
export function adoptsCliConversations(agent: unknown): agent is CliConversationRowAgent {
return typeof agent === 'string' && Object.hasOwn(CLI_CONVERSATION_ADOPTING_AGENTS, agent)
}
/** Whether chats of the two agents can hold one conversation: both take over one row's. */
export function chatAgentsShareConversations(left: string, right: string): boolean {
return (
left === right ||
Object.values(CLI_CONVERSATION_ADOPTING_AGENTS).some(
(agents: readonly string[]) => agents.includes(left) && agents.includes(right)
)
)
}
/** Whether two rows' conversations can be one: a chat that takes over either takes over both. */
export function cliConversationRowsShareAgent(
left: CliConversationRowAgent,
right: CliConversationRowAgent
): boolean {
const rightAgents: readonly string[] = CLI_CONVERSATION_ADOPTING_AGENTS[right]
return CLI_CONVERSATION_ADOPTING_AGENTS[left].some((agent) => rightAgents.includes(agent))
}