mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(opencode): open OpenCode 2 in the chat view and list 2.x models before the first message (#28424)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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: () => {},
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
+33
-6
@@ -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([
|
||||
|
||||
+34
-3
@@ -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. */
|
||||
|
||||
+10
-8
@@ -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
|
||||
|
||||
@@ -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]
|
||||
])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user