From e43f3e7e04596b0bd13bb854445ed10db416bcb6 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:42:23 +0000 Subject: [PATCH] feat(opencode): open OpenCode 2 in the chat view and list 2.x models before the first message (#28424) --- .../components/mobile-agent-icon-assets.ts | 1 + src/main/acp/acp-conversation-copy.test.ts | 29 + src/main/acp/acp-conversation-copy.ts | 14 +- src/main/acp/acp-launch-specs.ts | 106 +++- src/main/acp/acp-model-catalog-probe.test.ts | 197 +++++- src/main/acp/acp-model-catalog-probe.ts | 123 +++- src/main/acp/acp-session-runtime.ts | 3 + .../acp/acp-structured-compaction.test.ts | 2 +- .../acp-structured-host-live-listing.test.ts | 4 +- ...uctured-launch-resolution-opencode.test.ts | 38 +- .../acp/acp-structured-launch-resolution.ts | 2 +- ...acp-structured-opencode-recordings.test.ts | 57 +- .../acp/acp-timeline-fixture.test-support.ts | 7 +- src/main/acp/acp-timeline-recordings.test.ts | 28 + src/main/acp/fixtures/opencode-v2-deny.jsonl | 13 + .../acp/fixtures/opencode-v2-reopen.jsonl | 72 +++ src/main/acp/fixtures/opencode-v2-stop.jsonl | 14 + src/main/acp/fixtures/opencode-v2-tool.jsonl | 16 + ...tured-session-ownership-acp-and-pi.test.ts | 36 +- .../ai-vault/structured-session-ownership.ts | 89 ++- src/main/ipc/notification-options.ts | 25 +- .../notifications-message-formatting.test.ts | 34 ++ .../agent-model-catalog-discovery.ts | 3 + .../agent-model-catalog-failures.ts | 3 +- .../agent-model-catalog-fingerprint.ts | 24 +- .../agent-model-catalog-prewarm.test.ts | 131 +++- .../agent-model-catalog-service.ts | 215 +++---- .../agent-model-catalog-store.ts | 7 +- .../agent-model-catalog-unavailable.test.ts | 2 + .../agent-model-catalog-workspace.ts | 99 +++ .../agent-project-model-override.test.ts | 9 + .../agent-project-model-override.ts | 32 +- ...ed-agent-session-launch-permission.test.ts | 2 +- .../structured-agent-session-options-read.ts | 2 +- ...tured-agent-session-outgrown-child.test.ts | 2 +- ...t-session-provider-started-catalog.test.ts | 2 +- ...red-agent-session-provider-started.test.ts | 2 +- ...agent-session-startup-attempt-host.test.ts | 2 +- ...ed-agent-cli-conversation-adoption.test.ts | 34 +- .../structured-agent-cli-source.ts | 6 +- ...red-agent-session-history-adoption.test.ts | 16 + ...ructured-agent-session-history-adoption.ts | 3 +- ...red-agent-session-source-admission.test.ts | 35 ++ .../opencode-model-catalog-listing.ts | 5 +- ...tructured-agent-launch-parity.test.ts.snap | 575 +++++++++++++++++- ...gent-session-reservation-admission.test.ts | 39 ++ .../agent-session-reservation-admission.ts | 8 +- .../structured-agent-launch-parity.test.ts | 11 +- ...ured-agent-model-catalog-discovery.test.ts | 9 + ...tructured-agent-model-catalog-discovery.ts | 3 +- .../structured-agent-model-catalog-wiring.ts | 7 +- .../structured-agent-runtime-metadata.test.ts | 1 + ...ed-agent-runtime-registrations-acp.test.ts | 12 +- ...ion-runtime-model-catalog-lifetime.test.ts | 2 +- src/main/skills/discovery.test.ts | 20 + .../skill-discovery-concurrency.test.ts | 2 +- src/main/skills/skill-discovery-sources.ts | 11 +- .../native-chat/native-chat-picker-items.ts | 32 + .../use-native-chat-composer-catalog.test.tsx | 86 ++- .../use-native-chat-composer-catalog.ts | 31 +- .../use-native-chat-picker-state.ts | 19 +- .../use-native-chat-skills.test.ts | 9 + .../native-chat/use-native-chat-skills.ts | 7 +- .../ai-vault-session-launch-actions.test.ts | 23 +- .../ai-vault-session-launch-actions.ts | 2 +- ...ault-session-resume-in-chat-launch.test.ts | 39 +- .../ai-vault-session-resume-in-chat-launch.ts | 37 +- ...-vault-session-resume-in-chat-workspace.ts | 18 +- .../tab-bar/tab-session-history-switch.ts | 24 +- .../lib/orchestration-skill-coverage.test.ts | 34 ++ .../src/lib/orchestration-skill-coverage.ts | 21 +- .../host-model-catalog-snapshots.test.ts | 16 + .../runtime/host-model-catalog-snapshots.ts | 7 +- src/shared/agent-chat-permission-mode.ts | 1 + src/shared/agent-session-sign-in.ts | 1 + src/shared/agent-session-wire.ts | 3 + src/shared/ai-vault-resume-startup.ts | 31 +- src/shared/native-chat-agent-profiles.test.ts | 5 + src/shared/native-chat-agent-profiles.ts | 36 +- ...uctured-agent-cli-conversation-adoption.ts | 50 +- 80 files changed, 2307 insertions(+), 471 deletions(-) create mode 100644 src/main/acp/fixtures/opencode-v2-deny.jsonl create mode 100644 src/main/acp/fixtures/opencode-v2-reopen.jsonl create mode 100644 src/main/acp/fixtures/opencode-v2-stop.jsonl create mode 100644 src/main/acp/fixtures/opencode-v2-tool.jsonl create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index fdab7f7959e..9c1ce5034a6 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -14,6 +14,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial { ).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() + } + ) }) diff --git a/src/main/acp/acp-conversation-copy.ts b/src/main/acp/acp-conversation-copy.ts index 2fc1199e63c..1edae4addc1 100644 --- a/src/main/acp/acp-conversation-copy.ts +++ b/src/main/acp/acp-conversation-copy.ts @@ -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) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 60c27cb9011..fb39656239f 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -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 { + 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 ] diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index 7afa57e763a..d7fab2068db 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { createAcpModelCatalogProbe, @@ -6,6 +6,7 @@ import { } from './acp-model-catalog-probe' import { AcpSessionRuntime } from './acp-session-runtime' import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { 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): { } } +function scriptedOpenCode2(canDelete: boolean): { + agent: AcpScriptedAgent + launches: ProviderProcessLaunch[] + closed: () => number + deps: AcpModelCatalogProbeDeps & { + runListing: Mock> + } +} { + const agent = new AcpScriptedAgent() + agents.push(agent) + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: canDelete ? { delete: {} } : {} + } + }) + ) + agent.on('session/new', (frame) => + agent.reply(frame, { + sessionId: 'probe-session', + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'opencode/exo-free', + options: [ + { value: 'opencode/big-pickle', name: 'Big Pickle' }, + { value: 'opencode/exo-free', name: 'Exo Free' } + ] + } + ] + }) + ) + agent.on('session/delete', (frame) => agent.reply(frame, {})) + const launches: ProviderProcessLaunch[] = [] + 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>(), + 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() + }) }) diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index e2dae860018..39996dbf31e 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -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 } +/** A connection that may open one session, to read what a new chat offers, and delete it. */ +export type AcpModelCatalogSessionConnection = Omit< + AcpModelCatalogConnection, + 'requestSessionFreeExtension' +> & { + start(options: AcpSessionStartOptions): Promise + deleteSession(sessionId: string): Promise +} + export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { /** Test seams; production spawns through the shared supervised runner and ACP connection. */ probeVersion?: typeof probeAgentCliVersion connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection + connectSession?: (launch: ProviderProcessLaunch) => AcpModelCatalogSessionConnection runListing?: typeof runAgentModelCatalogListing } /** - * Lists an ACP agent's models without a session, under the binary, account and scrubbed - * environment its chats launch with. A release that runs no structured chat lists nothing. + * Lists an ACP agent's models before a chat, under the binary, account and scrubbed environment its + * chats launch with. A release that runs no structured chat lists nothing, nor does one its + * discovery names no listing for. */ export function createAcpModelCatalogProbe( spec: AcpLaunchSpec, @@ -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 + +/** 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 { + 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 +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 56dce2b9364..266b9995a97 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -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 { if (this.started) { return Promise.resolve(this.started) diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index 4d333777286..ec56bb9104e 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -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) }) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 201ff58b85e..a519d9567d0 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -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') }), diff --git a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts index 61ad1b54ace..da7a3a5da84 100644 --- a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts +++ b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts @@ -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 probeVersion?: Parameters[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' } + } + }) + } + ) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 86ef432b377..d0ddf202dcd 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -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 }, wantVersion: boolean, diff --git a/src/main/acp/acp-structured-opencode-recordings.test.ts b/src/main/acp/acp-structured-opencode-recordings.test.ts index 6adf5d1d910..4e37cc896fb 100644 --- a/src/main/acp/acp-structured-opencode-recordings.test.ts +++ b/src/main/acp/acp-structured-opencode-recordings.test.ts @@ -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' }) + }) +}) diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 3f16ad4139c..7b39131192b 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -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. diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 017f53c3fec..4297da6fc80 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -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')) diff --git a/src/main/acp/fixtures/opencode-v2-deny.jsonl b/src/main/acp/fixtures/opencode-v2-deny.jsonl new file mode 100644 index 00000000000..dc1b4cc7cdb --- /dev/null +++ b/src/main/acp/fixtures/opencode-v2-deny.jsonl @@ -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":{}}}} diff --git a/src/main/acp/fixtures/opencode-v2-reopen.jsonl b/src/main/acp/fixtures/opencode-v2-reopen.jsonl new file mode 100644 index 00000000000..4504d5dfd30 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v2-reopen.jsonl @@ -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":{}}}} diff --git a/src/main/acp/fixtures/opencode-v2-stop.jsonl b/src/main/acp/fixtures/opencode-v2-stop.jsonl new file mode 100644 index 00000000000..2a3e1f33af4 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v2-stop.jsonl @@ -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":{}}}} diff --git a/src/main/acp/fixtures/opencode-v2-tool.jsonl b/src/main/acp/fixtures/opencode-v2-tool.jsonl new file mode 100644 index 00000000000..cb67e8d07a7 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v2-tool.jsonl @@ -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":{}}}} diff --git a/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts b/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts index 5538d237bd4..ae09ba04b1f 100644 --- a/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts +++ b/src/main/ai-vault/structured-session-ownership-acp-and-pi.test.ts @@ -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', () => { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 29ae8b37e18..e2551eab731 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -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() +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 ): 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() + // One binary can run the chats of several agents; a resume of it is checked against each. + const binaries = new Map() 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 diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 3f2c260dddf..39fb64a1cb9 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -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> = { - 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 { diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index bf96745fb4f..ed24a981812 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -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: () => ({ diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts index a2c2ff6b8e4..6cb8778dd31 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -13,5 +13,8 @@ export type AgentModelCatalogDiscovery = /** The listing marks the model the account is configured to run as its default, so a new * chat launches the listed default. False where the agent's own settings may pick another. */ listingNamesConfiguredModel: boolean + /** The listing 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 } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts index 7da3c68d680..25917669dd3 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts @@ -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 = { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index e15763eef43..27e8c072fa4 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -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 + record: Pick, + 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. diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts index b74a7f74823..b6387f20561 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -6,6 +6,7 @@ import { agentModelCatalogFingerprintForRecord, agentModelCatalogSessionAccess } from './agent-model-catalog-fingerprint' +import { 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(), deferred()] + 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() + 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' }, diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 275f3d43d4c..19f9765ac3b 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -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>> /** Agents whose listing marks the model the account is configured to run as its default. */ listingNamesConfiguredModel?: ReadonlySet - /** Where a session's provider runs, for deciding whether its own config scope is the account's. */ - recordWorkspacePath?: (record: AgentSessionRecord) => Promise /** False for an agent whose model no project config can pick; its default holds everywhere. */ 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 /** 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 /** 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 /** 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 - ) => 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 { - 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 { - 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 { - 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( 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>() const listWith = - (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome) => + (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome, workspacePath?: string) => (): Promise => - probe(home, { signal: lifetime.signal }).catch((error: unknown) => { + probe(home, { signal: lifetime.signal, workspacePath }).catch((error: unknown) => { throw lifetime.signal.aborted ? new AgentModelCatalogListingStoppedError(String(error)) : error }) + // A running chat's saves for agents kept per folder, which wait on a folder lookup. + const liveWrites = new Map>() + /** 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 => + 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] diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index fa412f2e299..53ec19ef3cf 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -48,11 +48,12 @@ export type { AgentModelCatalogFailure } export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 -/** Lists an agent's models without a session, under the account a launch would pin. `signal` - * stops the listing and its child once the host that asked is going away. */ +/** Lists an agent's models before a chat, under the account a launch would pin. `signal` stops + * the listing and its child once the host that asked is going away; `workspacePath` is the folder + * of the chat that asked, where a listing that opens a session runs. */ export type AgentModelCatalogProbe = ( accountHome: AgentSessionAccountHome, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; workspacePath?: string } ) => Promise /** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts index d22f18e0e4e..a9e85c07f64 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts @@ -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: { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts new file mode 100644 index 00000000000..1e29f22923d --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-workspace.ts @@ -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 + /** Where a session's provider runs, for deciding whether its own config scope is the account's. */ + recordWorkspacePath?: (record: AgentSessionRecord) => Promise + /** 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 +} + +/** 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts index 6669fff9407..1cbc87556bf 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts @@ -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' }], diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index ec08b2a3a21..a0e126f72a3 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -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> = { codex: { files: files(['.codex/config.toml'], codexConfigMayPickModel) }, @@ -82,20 +97,9 @@ const PROJECT_MODEL_LAYERS: Readonly> = { 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: [ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts index 48a64a13d9d..415cedef7a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-permission.test.ts @@ -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(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 7d351922809..fbc7cfb43fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index a141ee6f37f..6ab1111089b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -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(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index a3f54d8a058..07c5d80bc3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -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(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index c8de9f74b55..86163c25fe4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -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: () => {}, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index ae6c69fb113..5c45da77127 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -88,7 +88,7 @@ async function startHost(): Promise { }, 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(), diff --git a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts index 4edef884235..90a5218f221 100644 --- a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts +++ b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts @@ -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) + } + } }) }) diff --git a/src/main/native-chat/structured-agent-cli-source.ts b/src/main/native-chat/structured-agent-cli-source.ts index 244a84dd315..a0364d7c79d 100644 --- a/src/main/native-chat/structured-agent-cli-source.ts +++ b/src/main/native-chat/structured-agent-cli-source.ts @@ -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) { diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index e251752f8d1..718e7fa85b5 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -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({ diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 6c317de6e93..65a5e37c92c 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -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 ) diff --git a/src/main/native-chat/structured-agent-session-source-admission.test.ts b/src/main/native-chat/structured-agent-session-source-admission.test.ts index 3474e02b1fc..04fd387af20 100644 --- a/src/main/native-chat/structured-agent-session-source-admission.test.ts +++ b/src/main/native-chat/structured-agent-session-source-admission.test.ts @@ -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') + } +}) diff --git a/src/main/opencode/opencode-model-catalog-listing.ts b/src/main/opencode/opencode-model-catalog-listing.ts index 7f667cf3a49..452d23c3f0a 100644 --- a/src/main/opencode/opencode-model-catalog-listing.ts +++ b/src/main/opencode/opencode-model-catalog-listing.ts @@ -1,5 +1,6 @@ -// OpenCode's session-free model listing: `opencode models --verbose` prints each `provider/model` id -// on its own line, followed by that model's metadata as indented JSON closed by a bare `}` line. +// OpenCode 1.x's session-free model listing: `opencode models --verbose` prints each +// `provider/model` id on its own line, followed by that model's metadata as indented JSON closed by +// a bare `}` line. import { z } from 'zod' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' diff --git a/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap index fdd304fa4cc..819e1b55e84 100644 --- a/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap +++ b/src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap @@ -232,7 +232,7 @@ exports[`native-chat launch parity > antigravity worktree fresh configured 1`] = "HOME": "/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": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", @@ -444,7 +444,7 @@ exports[`native-chat launch parity > antigravity worktree resumed configured 1`] "HOME": "/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": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", @@ -777,10 +777,10 @@ exports[`native-chat launch parity > claude worktree fresh configured 1`] = ` "--add-dir", "/state/agent-session-attachments", "--add-dir", - "/state/chat-scratch/4513a9571ec9fe3b6c2cee8782d16d37", + "/state/chat-scratch/7d3f4165565b3c0388cb5e400cb94aeb", "--plugin-dir", "/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": "/state/chat-scratch/4513a9571ec9fe3b6c2cee8782d16d37", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/7d3f4165565b3c0388cb5e400cb94aeb", "ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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", "/state/agent-session-attachments", "--add-dir", - "/state/chat-scratch/083c5a4185dc6c2750d599f361c671d1", + "/state/chat-scratch/4a19eb9b3f23e5c387e9d8af286e4a7f", "--plugin-dir", "/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": "/state/chat-scratch/083c5a4185dc6c2750d599f361c671d1", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/4a19eb9b3f23e5c387e9d8af286e4a7f", "ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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": "/state/chat-scratch/9825b24d9014b859f02ff26f814792b0", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/6d760d82a1a8964efd96490eeb4e7d20", "ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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": "/state/chat-scratch/7eca093e243e678793121c8537d04cc0", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/3d2ec752c13cffa4ab07edb61d8803b5", "ORCA_CLAUDE_AGENT_STATUS_SETTINGS": "parent-orca_claude_agent_status_settings", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", @@ -1831,9 +1831,9 @@ exports[`native-chat launch parity > grok worktree fresh configured 1`] = ` "HOME": "/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": "/state/chat-scratch/3d37237436bb2343509e2f2d6240ec5c", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/fd01d42f6105793cffa86eca0504cdad", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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": "/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": "/state/chat-scratch/7d3f4165565b3c0388cb5e400cb94aeb", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/f9f96d433c3c4a517315697c40c1e1ff", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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": "/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": "/state/chat-scratch/3d2ec752c13cffa4ab07edb61d8803b5", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/ac3d7b3d7216f9d1740228556d8496f7", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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": "/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": "/state/chat-scratch/fd01d42f6105793cffa86eca0504cdad", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/035c996f8981604afe69d1c2d022552b", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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":["/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": "/state/chat-scratch/4a19eb9b3f23e5c387e9d8af286e4a7f", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/ed9363a1a427b8d0fec5f6779bd3488f", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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":["/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": "/state/chat-scratch/6d760d82a1a8964efd96490eeb4e7d20", + "ORCA_CHAT_SCRATCH_DIR": "/state/chat-scratch/3ca467fd6a25c17ed3f81d1555ba95d7", "ORCA_CLI_BIN_DIR": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/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": "/bin/opencode2", + "cwd": "/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": "/home", + "INHERITED_ONLY": "process", + "LANG": "en_US.UTF-8", + "OPENCODE_CLIENT": "acp", + "OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["/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": "/state/chat-scratch/9825b24d9014b859f02ff26f814792b0", + "ORCA_CLI_BIN_DIR": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2", + "PATH": "/orca-cli/bin:/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": "/floating", + "lifetime": "session", + "nodeEnv": { + "NODE_OPTIONS": "--max-old-space-size=4096", + }, + }, + }, + "launchPosture": { + "fullAccess": false, + }, + "pinnedLaunchDirectories": [ + "/floating", + ], +} +`; + +exports[`native-chat launch parity > opencode2 floating resumed 1`] = ` +{ + "child": { + "args": [ + "acp", + ], + "command": "/bin/opencode2", + "cwd": "/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": "/home", + "INHERITED_ONLY": "process", + "LANG": "en_US.UTF-8", + "OPENCODE_CLIENT": "acp", + "OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["/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": "/state/chat-scratch/7eca093e243e678793121c8537d04cc0", + "ORCA_CLI_BIN_DIR": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2", + "PATH": "/orca-cli/bin:/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": "/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": "/bin/opencode2", + "cwd": "/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": "/home", + "INHERITED_ONLY": "process", + "LANG": "en_US.UTF-8", + "OPENCODE_CLIENT": "acp", + "OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["/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": "/state/chat-scratch/4513a9571ec9fe3b6c2cee8782d16d37", + "ORCA_CLI_BIN_DIR": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2", + "PATH": "/orca-cli/bin:/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": "/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": "/custom-bin/opencode2-custom", + "cwd": "/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": "/home", + "INHERITED_ONLY": "overlay", + "LANG": "C", + "OPENCODE_CLIENT": "acp", + "OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["/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": "/state/chat-scratch/fd5ce56aaa624b617bf39dcc77e3d4b2", + "ORCA_CLI_BIN_DIR": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2-configured", + "PATH": "/orca-cli/bin:/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": "/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": "/bin/opencode2", + "cwd": "/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": "/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": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2", + "PATH": "/orca-cli/bin:/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": "/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": "/bin/opencode2", + "cwd": "/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": "/home", + "INHERITED_ONLY": "process", + "LANG": "en_US.UTF-8", + "OPENCODE_CLIENT": "acp", + "OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["/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": "/state/chat-scratch/083c5a4185dc6c2750d599f361c671d1", + "ORCA_CLI_BIN_DIR": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2", + "PATH": "/orca-cli/bin:/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": "/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": "/custom-bin/opencode2-custom", + "cwd": "/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": "/home", + "INHERITED_ONLY": "overlay", + "LANG": "C", + "OPENCODE_CLIENT": "acp", + "OPENCODE_CONFIG_CONTENT": "{"skills":{"paths":["/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": "/state/chat-scratch/368fd4cc5da6a076200cc5852a3d63f9", + "ORCA_CLI_BIN_DIR": "/orca-cli/bin", + "ORCA_CLI_COMMAND": "/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": "/user-data", + "PARITY_OVERLAY": "opencode2-configured", + "PATH": "/orca-cli/bin:/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": "/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": "/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": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", @@ -3506,7 +4023,7 @@ exports[`native-chat launch parity > pi worktree resumed configured 1`] = ` "--mode", "rpc", "--session", - "/pi-sessions/00000000-0000-4000-8000-000000000047.jsonl", + "/pi-sessions/00000000-0000-4000-8000-000000000052.jsonl", ], "command": "/custom-bin/pi-custom", "cwd": "/worktree", @@ -3518,7 +4035,7 @@ exports[`native-chat launch parity > pi worktree resumed configured 1`] = ` "HOME": "/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": "/orca-cli/bin", "ORCA_CLI_COMMAND": "/orca-cli/bin/orca", diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 3602aa29cd7..0858424559e 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -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', () => { diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 17d220f39aa..31a1ece91ff 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -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', { diff --git a/src/main/runtime/structured-agent-launch-parity.test.ts b/src/main/runtime/structured-agent-launch-parity.test.ts index f5b73a0cdd3..b128562d1d6 100644 --- a/src/main/runtime/structured-agent-launch-parity.test.ts +++ b/src/main/runtime/structured-agent-launch-parity.test.ts @@ -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 = { 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> = { antigravity: 'agy_acp_server.par' } @@ -280,7 +282,8 @@ function configuredSettings(): Partial { 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}` } diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index edfc5037762..f0015e2121d 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -82,6 +82,15 @@ describe('the model catalog contract on every registration', () => { expect([...listingNamesConfiguredModel].sort()).toEqual(['codex']) }) + // OpenCode 2.x lists in a session opened in the chat's folder, which a prewarm does not have. + it('marks the agents whose listing runs in a chat folder', () => { + const { listsInChatFolder } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + expect([...listsInChatFolder].sort()).toEqual(['opencode', 'opencode2']) + }) + it('maps an ACP agent with no session-free listing to an unavailable registration', () => { const spec = acpLaunchSpecFor('grok')! const discovery = acpModelCatalogDiscovery( diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts index ea0a87f3166..8e9be6d6cb4 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -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, diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index f72f5eb4b4f..83cb796ba48 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -47,9 +47,11 @@ export function registeredModelCatalogDiscovery( ): { probes: Record listingNamesConfiguredModel: Set + listsInChatFolder: Set } { const probes: Record = {} const listingNamesConfiguredModel = new Set() + const listsInChatFolder = new Set() for (const registration of registrations) { const { agent } = registration.definition const discovery = registration.modelCatalog(context) @@ -58,9 +60,12 @@ export function registeredModelCatalogDiscovery( if (discovery.listingNamesConfiguredModel) { listingNamesConfiguredModel.add(agent) } + if (discovery.listsInChatFolder) { + listsInChatFolder.add(agent) + } } } - return { probes, listingNamesConfiguredModel } + return { probes, listingNamesConfiguredModel, listsInChatFolder } } /** diff --git a/src/main/runtime/structured-agent-runtime-metadata.test.ts b/src/main/runtime/structured-agent-runtime-metadata.test.ts index 48f13270516..c61d163bcf4 100644 --- a/src/main/runtime/structured-agent-runtime-metadata.test.ts +++ b/src/main/runtime/structured-agent-runtime-metadata.test.ts @@ -28,6 +28,7 @@ describe('structured agent metadata without adapter implementations', () => { 'claude', 'grok', 'opencode', + 'opencode2', 'omp', 'antigravity' ]) diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index a0fa8bb42a2..fc747c7d3ed 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -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, diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts index be66bc127d7..56b7d6df6a8 100644 --- a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -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(), diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index bc3d5945a5e..4f259def709 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -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') diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index 2194eb01ba3..3213c919043 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -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()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 89881a310cc..f02e7e7de76 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -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' + ) ) } diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 4786fcbecb2..5c76e6e635d 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -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 = { repo: 0, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 1b89fb06bbe..b98df2e2a01 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -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) => + 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) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index d2e0b93ac40..dd933336f53 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -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 } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index af6b70494be..1bc06758be5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 1ab0286666b..91cc49b4add 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -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: [ diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index f045766178f..b59a2a823f9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -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) }) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 36b9ba0dc51..8e1ccb56f47 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -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() diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 292f405fddb..0f4bcae5b34 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -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) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index 9a90f4dd63d..8b53eeaa786 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -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([ diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index 6c3e06ccedf..0a12a02b1b0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -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 { + 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. */ diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index 4ea361da9b4..c07e1e88608 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -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 diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index d3e74a9af3e..f3cace93580 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -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 diff --git a/src/renderer/src/lib/orchestration-skill-coverage.test.ts b/src/renderer/src/lib/orchestration-skill-coverage.test.ts index 8fe696caab1..6840687e8c8 100644 --- a/src/renderer/src/lib/orchestration-skill-coverage.test.ts +++ b/src/renderer/src/lib/orchestration-skill-coverage.test.ts @@ -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({ diff --git a/src/renderer/src/lib/orchestration-skill-coverage.ts b/src/renderer/src/lib/orchestration-skill-coverage.ts index 4196a50dd18..b6f530e1e4a 100644 --- a/src/renderer/src/lib/orchestration-skill-coverage.ts +++ b/src/renderer/src/lib/orchestration-skill-coverage.ts @@ -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 { + 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) ) ) }) diff --git a/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts b/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts index 094c3da7f8f..34a2f1cdf5d 100644 --- a/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts +++ b/src/renderer/src/runtime/host-model-catalog-snapshots.test.ts @@ -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' }) diff --git a/src/renderer/src/runtime/host-model-catalog-snapshots.ts b/src/renderer/src/runtime/host-model-catalog-snapshots.ts index 928f440a31e..67cb661f905 100644 --- a/src/renderer/src/runtime/host-model-catalog-snapshots.ts +++ b/src/renderer/src/runtime/host-model-catalog-snapshots.ts @@ -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 diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 83e76254721..2a40715f2d0 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -52,6 +52,7 @@ const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map { }) }) + // 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() }) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index 521755aab56..1750092742f 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -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> = { codex: { skillPrefix: '$', @@ -35,18 +50,27 @@ const NATIVE_CHAT_AGENT_PROFILES: Partial, + source: Pick +): 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 { diff --git a/src/shared/structured-agent-cli-conversation-adoption.ts b/src/shared/structured-agent-cli-conversation-adoption.ts index 04877b6e588..1bc9d8e4365 100644 --- a/src/shared/structured-agent-cli-conversation-adoption.ts +++ b/src/shared/structured-agent-cli-conversation-adoption.ts @@ -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)) +}