From fd59b324085778d8b2b2c7d39482ff3f761490e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:20:41 -0700 Subject: [PATCH 1/2] feat(omp): open OMP in the structured chat over ACP OMP joins Grok and OpenCode on the ACP lane: `omp acp`, with the user's environment passed through and the chat pinned to PI_CODING_AGENT_DIR (default ~/.omp/agent), the directory OMP's terminal chats read. A create and every launch ask `omp --version` of the binary the Command setting resolves to; stable releases from 17.0.5 run the chat, older ones keep the terminal-backed chat. Like every ACP agent it runs only with the updated structured native chat setting on. --- src/main/acp/acp-launch-specs.ts | 30 +++- ...p-structured-launch-resolution-omp.test.ts | 132 ++++++++++++++++++ src/main/agent-cli-version-probe.test.ts | 26 +++- src/main/agent-cli-version-probe.ts | 11 +- ...red-agent-launch-support-probe-omp.test.ts | 123 ++++++++++++++++ ...ed-agent-runtime-registrations-acp.test.ts | 55 +++++++- 6 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 src/main/acp/acp-structured-launch-resolution-omp.test.ts create mode 100644 src/main/runtime/structured-agent-launch-support-probe-omp.test.ts diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index dcf91c1d4f8d..ba688ec9b94b 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -7,7 +7,7 @@ import { ORCA_SCRUB_SAFE_PANE_ENV } from '../../shared/agent-hook-scrub-safe-env' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' -import type { AcpDialect } from './acp-dialects/acp-dialect' +import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' import { directoryAccountBinding, type AcpAccountBinding } from './acp-account-binding' @@ -15,7 +15,7 @@ import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-accou import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-stored-messages' import type { AcpStoredUserMessagesReader } from './acp-recovery-history' -import { isStableCliVersionOnLine } from '../agent-cli-version-probe' +import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' import type { TuiAgent } from '../../shared/tui-agent' export type AcpLaunchSpec = { @@ -92,7 +92,31 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { readStoredUserMessages: openCodeStoredUserMessagesReader() } -export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC, OPENCODE_LAUNCH_SPEC] +// OMP serves ACP through `omp acp`; its environment reaches it as the user set it. +const OMP_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'omp', + command: 'omp', + // `omp acp` takes no flags: full access answers each permission request yes. + args: () => ['acp'], + env: {}, + dialect: GENERIC_ACP_DIALECT, + // OMP signs in from its own `/login`. + loginCommand: ['omp'], + // The directory OMP's terminal chats read too; its default is OMP's own. + account: directoryAccountBinding('PI_CODING_AGENT_DIR', (homePath) => + join(homePath, '.omp', 'agent') + ), + // OMP's installers use directories the shared resolver already searches after PATH. + installDirectories: () => [], + // Stable releases from 17.0.5, the release verified to serve `omp acp`. + supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5') +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [ + GROK_LAUNCH_SPEC, + OPENCODE_LAUNCH_SPEC, + OMP_LAUNCH_SPEC +] export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null diff --git a/src/main/acp/acp-structured-launch-resolution-omp.test.ts b/src/main/acp/acp-structured-launch-resolution-omp.test.ts new file mode 100644 index 000000000000..8e34dee2e9d7 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution-omp.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { + createAcpStructuredLaunchResolver, + type AcpStructuredLaunchResolverDeps +} from './acp-structured-launch-resolution' + +const OMP = acpLaunchSpecFor('omp')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'omp', + providerHandle: null +} + +function ompRecord( + accountHome: AgentSessionRecord['accountHome'] = { + variable: 'PI_CODING_AGENT_DIR', + path: '/home/user/.omp/agent' + } +): AgentSessionRecord { + return { ...agentSessionRecordFixture(), provider: 'omp', providerHandleChain: [], accountHome } +} + +function resolver( + record: AgentSessionRecord, + options: { + launchEnv?: Record + fullAccess?: boolean + probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion'] + } = {} +) { + return createAcpStructuredLaunchResolver(OMP, { + store: { getRecord: () => record }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => options.launchEnv ?? {}, + resolveFullAccess: () => options.fullAccess ?? false, + resolveCommand: (command) => `/resolved/${command}`, + inheritedEnv: {}, + probeVersion: options.probeVersion ?? (async (_input, supports) => supports('17.0.5')) + }) +} + +describe('OMP ACP launch resolution', () => { + it('runs `omp acp` pointed at the agent directory the chat pinned, with or without full access', async () => { + for (const fullAccess of [false, true]) { + const launch = await resolver(ompRecord(), { fullAccess })({ identity }) + expect(launch).toMatchObject({ command: '/resolved/omp', args: ['acp'], fullAccess }) + expect(launch.env.PI_CODING_AGENT_DIR).toBe('/home/user/.omp/agent') + } + }) + + it("passes the user's own OMP environment through unchanged", async () => { + const userEnv = { + OMP_PROFILE: 'work', + PI_CONFIG_DIR: 'relative/config', + PI_SMOL_MODEL: 'fast', + ANTHROPIC_API_KEY: 'sk-test' + } + const launch = await resolver(ompRecord(), { launchEnv: userEnv })({ identity }) + expect(launch.envToDelete).toEqual([]) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + { ORCA_PANE_KEY: 'tab-1:pane-1' }, + 'darwin' + ).env + expect(child).toMatchObject(userEnv) + // A pane's identity would let OMP's Orca status extension report for this chat too. + expect(child.ORCA_PANE_KEY).toBeUndefined() + }) + + it('keeps a chat on the directory it pinned when the launch env names another', async () => { + const launch = await resolver(ompRecord(), { + launchEnv: { PI_CODING_AGENT_DIR: '/data/other-agent' } + })({ identity }) + expect(launch.env.PI_CODING_AGENT_DIR).toBe('/home/user/.omp/agent') + }) + + it("refuses a record that pins another agent's directory", async () => { + await expect( + resolver(ompRecord({ variable: 'GROK_HOME', path: '/home/user/.grok' }))({ identity }) + ).rejects.toThrow(/pin PI_CODING_AGENT_DIR/) + }) + + it.each(['17.0.5', '17.2.12', '18.4.5'])('admits OMP %s', async (version) => { + const launch = resolver(ompRecord(), { + probeVersion: async (_input, supports) => supports(version) + }) + await expect(launch({ identity })).resolves.toMatchObject({ args: ['acp'] }) + }) + + it.each(['17.0.4', '16.9.0', '18.0.0-beta.1'])( + 'refuses OMP %s before spawning it, as a host that cannot run the chat', + async (version) => { + const launch = resolver(ompRecord(), { + probeVersion: async (_input, supports) => supports(version) + }) + await expect(launch({ identity })).rejects.toMatchObject({ + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) + } + ) + + it('refuses when the version cannot be read', async () => { + const launch = resolver(ompRecord(), { probeVersion: async () => false }) + await expect(launch({ identity })).rejects.toMatchObject({ + refusal: { code: 'structured_agent_session_unsupported' } + }) + }) + + it('asks the version of exactly the binary, folder and environment it spawns', async () => { + const asked: Parameters>[0][] = [] + const launch = await resolver(ompRecord(), { + probeVersion: async (input, supports) => (asked.push(input), supports('17.0.5')) + })({ identity }) + expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) + }) +}) diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts index 33d9537dd4f0..d04480f71dce 100644 --- a/src/main/agent-cli-version-probe.test.ts +++ b/src/main/agent-cli-version-probe.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { isStableCliVersionOnLine, probeAgentCliVersion } from './agent-cli-version-probe' +import { + isStableCliVersionFrom, + isStableCliVersionOnLine, + probeAgentCliVersion +} from './agent-cli-version-probe' const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) vi.mock('../shared/child-process/run-process', () => ({ runProcess })) @@ -42,6 +46,13 @@ describe('probeAgentCliVersion', () => { expect(seen).toEqual(['2.0.21']) }) + it('reads a version printed after the program name and a slash', async () => { + prints('omp/17.0.5\n') + const seen: string[] = [] + await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) + expect(seen).toEqual(['17.0.5']) + }) + it.each([ ['a non-zero exit', { code: 1 }], ['a timeout', { timedOut: true }], @@ -92,3 +103,16 @@ describe('isStableCliVersionOnLine', () => { expect(isStableCliVersionOnLine(version, line)).toBe(supported) }) }) + +describe('isStableCliVersionFrom', () => { + it.each([ + ['17.0.5', true], + ['17.2.12', true], + ['18.4.5', true], + ['17.0.4', false], + ['16.9.0', false], + ['18.0.0-beta.1', false] + ])('%s -> %s', (version, supported) => { + expect(isStableCliVersionFrom(version, '17.0.5')).toBe(supported) + }) +}) diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts index b5f2a312a780..4cf45ce0e4d2 100644 --- a/src/main/agent-cli-version-probe.ts +++ b/src/main/agent-cli-version-probe.ts @@ -53,14 +53,15 @@ export async function probeAgentCliVersion( return true } +/** A stable release at or after `floor`, on any later major line too. */ +export function isStableCliVersionFrom(version: string, floor: string): boolean { + return !isPrereleaseAppVersion(version) && hasReachedAppVersion(version, floor) +} + /** A stable release on `major`'s line, at or after `floor`. */ export function isStableCliVersionOnLine( version: string, line: { major: number; floor: string } ): boolean { - return ( - version.startsWith(`${line.major}.`) && - !isPrereleaseAppVersion(version) && - hasReachedAppVersion(version, line.floor) - ) + return version.startsWith(`${line.major}.`) && isStableCliVersionFrom(version, line.floor) } diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts new file mode 100644 index 000000000000..393f0158594e --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -0,0 +1,123 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +type Settings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +let root: string +let shellBin: string +let privateBin: string + +/** A stand-in `omp` that answers `--version` as OMP does; never the real agent CLI. */ +async function fakeOmp(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'omp') + await writeFile(file, `#!/bin/sh\necho omp/${version}\n`) + await chmod(file, 0o755) + return dir +} + +function runtimeWith(settings: Settings) { + return { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: root } }) + } +} + +function launchWith(settings: Settings) { + return createAcpStructuredLaunchResolver(acpLaunchSpecFor('omp')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'omp', + providerHandleChain: [], + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: join(root, '.omp', 'agent') } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv?.omp ?? {}, + resolveCommandSettings: () => settings, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'omp', + providerHandle: null + } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-omp-probe-')) + shellBin = await fakeOmp(join(root, 'shell-bin'), '16.2.0') + privateBin = await fakeOmp(join(root, 'omp-prefix', 'bin'), '17.0.5') + // Orca's resolved login-shell PATH, with an OMP older than ACP support first. + loginShell.env = { PATH: `${shellBin}:/usr/bin:/bin`, HOME: root } +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const base = { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [] } + +// Real `--version` runs against stand-in scripts: the whole create check, not a mocked probe. +describe.skipIf(process.platform === 'win32')('OMP create check and launch, real probe', () => { + it('checks and launches the binary the Command setting names', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { omp: join(privateBin, 'omp') } + } + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + const launch = await launchWith(settings) + expect(launch.command).toBe(join(privateBin, 'omp')) + expect(launch.args).toEqual(['acp']) + }) + + it('asks the OMP the per-agent PATH names, not the one first on the shell PATH', async () => { + const settings = { ...base, agentDefaultEnv: { omp: { PATH: `${privateBin}:/usr/bin:/bin` } } } + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'omp')) + }) + + it('keeps an OMP older than 17.0.5 on its terminal-backed chat', async () => { + const settings = { ...base, agentDefaultEnv: {} } + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(false) + }) + + it('refuses the launch with the setting named when the Command is not runnable', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { omp: join(root, 'missing', 'omp') } + } + // Admitted, so the chat states why rather than silently opening a terminal. + expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + await expect(launchWith(settings)).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' + }) + }) +}) 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 ffc1920eba07..51efbacb1247 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -4,12 +4,13 @@ import { structuredAgentRuntimeRegistration, type StructuredAgentAccountHomeServices } from './structured-agent-runtime-registrations' +import { decideAgentLaunchMode } from '../agent-launch/agent-launch-mode' 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']) + ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'omp']) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', @@ -43,6 +44,58 @@ describe('ACP agents in the runtime registrations', () => { expect(structuredAgentRuntimeRegistration('grok')?.supportsLaunch).toBeUndefined() }) + it('registers OMP on the ACP lane pinning its agent directory, asking its version at create', () => { + const registration = structuredAgentRuntimeRegistration('omp') + expect(registration?.definition).toMatchObject({ + agent: 'omp', + handleTransport: 'acp', + accountHomeVariable: 'PI_CODING_AGENT_DIR', + capabilities: { imagePrompts: false, steering: 'queue', approvalEnforcement: 'orca' } + }) + expect(registration?.supportsLaunch).toBeTypeOf('function') + }) + + it('opens OMP as a structured chat only with the updated structured chat setting on', () => { + const settings = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true + } + const placement = { agent: 'omp', workspaceKind: 'git-worktree' } as const + expect(decideAgentLaunchMode({ placement, settings }).mode).toBe('structured') + // Off: OMP keeps the terminal-backed chat it has always had. + expect( + decideAgentLaunchMode({ + placement, + settings: { ...settings, experimentalStructuredNativeChat: false } + }) + ).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + }) + + it("finds OMP's agent directory from the launch env, else OMP's default", async () => { + const { resolveAccountHome } = structuredAgentRuntimeRegistration('omp')! + const unused = (): never => { + throw new Error('OMP resolves its account home without the runtime') + } + const resolve = async (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHome( + { launchEnv, location: null, purpose: 'read', workspacePath: null }, + { + getClaudeConfigDirectory: unused, + prepareCodexLaunchHome: unused, + readCodexLaunchHome: unused, + workspaceTrustSettings: unused + } + ) + await expect(resolve({ PI_CODING_AGENT_DIR: '/data/omp-agent' })).resolves.toEqual({ + variable: 'PI_CODING_AGENT_DIR', + path: '/data/omp-agent' + }) + await expect(resolve({ PI_CODING_AGENT_DIR: '' })).resolves.toMatchObject({ + path: expect.stringMatching(/[\\/]\.omp[\\/]agent$/) + }) + }) + it('takes model and effort picks at rest, and keeps no model list of its own', () => { const resting = structuredAgentRuntimeRegistration('grok')!.definition.restingOptions expect(['model', 'effort'].map(resting.acceptsKey)).toEqual([true, true]) From d3075eccf68dae77e768a754bf56f2920d131a2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:45:31 -0700 Subject: [PATCH 2/2] fix(omp): show a shell command's output and exit code in its row OMP's ACP server sends a command's result as rawOutput {content, details} with a non-zero exit at details.exitCode, and repeats it as content behind a "$ " echo. The shared reader takes content text first, so the row's output began with the command and carried no exit code. The OMP dialect drops the echo, moves the result text to stdout without OMP's "Wall time" and "Command exited with code N" notice lines, and maps details.exitCode. Recordings of omp acp 17.0.5 (exit 3, exit 0, Stop mid-command) replay through the adapter. --- src/main/acp/acp-dialects/omp-dialect.test.ts | 48 +++++++ src/main/acp/acp-dialects/omp-dialect.ts | 66 ++++++++++ src/main/acp/acp-launch-specs.ts | 5 +- .../acp/acp-structured-omp-recordings.test.ts | 117 ++++++++++++++++++ .../acp/fixtures/omp-v17-shell-exit-3.jsonl | 23 ++++ src/main/acp/fixtures/omp-v17-shell-ok.jsonl | 23 ++++ .../acp/fixtures/omp-v17-stop-mid-tool.jsonl | 8 ++ 7 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 src/main/acp/acp-dialects/omp-dialect.test.ts create mode 100644 src/main/acp/acp-dialects/omp-dialect.ts create mode 100644 src/main/acp/acp-structured-omp-recordings.test.ts create mode 100644 src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl create mode 100644 src/main/acp/fixtures/omp-v17-shell-ok.jsonl create mode 100644 src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl diff --git a/src/main/acp/acp-dialects/omp-dialect.test.ts b/src/main/acp/acp-dialects/omp-dialect.test.ts new file mode 100644 index 000000000000..5c6b3cb059b7 --- /dev/null +++ b/src/main/acp/acp-dialects/omp-dialect.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import { OMP_ACP_DIALECT } from './omp-dialect' + +const text = (value: string) => ({ + type: 'content' as const, + content: { type: 'text' as const, text: value } +}) +const normalize = (update: ToolCallUpdate) => OMP_ACP_DIALECT.normalizeToolUpdate!(update) + +describe('OMP tool updates', () => { + it('leaves a tool without a command echo as it came', () => { + const update: ToolCallUpdate = { + toolCallId: 'call-1', + status: 'completed', + rawOutput: { content: [{ type: 'text', text: 'file body' }], details: {} }, + content: [text('file body')] + } + expect(normalize(update)).toBe(update) + }) + + it('keeps a result that itself starts with "$ "', () => { + const update: ToolCallUpdate = { + toolCallId: 'call-1', + status: 'completed', + rawOutput: { content: [{ type: 'text', text: '$ 5.00' }], details: {} }, + content: [text('$ 5.00')] + } + expect(normalize(update)).toBe(update) + }) + + it('keeps output that only looks like a notice when OMP sent no matching detail', () => { + const update = normalize({ + toolCallId: 'call-1', + status: 'completed', + rawOutput: { + content: [{ type: 'text', text: 'done\n\nCommand exited with code 3' }], + details: {} + }, + content: [text('$ ./run'), text('done\n\nCommand exited with code 3')] + }) + expect(update).toMatchObject({ + content: [], + rawOutput: { stdout: 'done\n\nCommand exited with code 3' } + }) + expect(update.rawOutput).not.toHaveProperty('exitCode') + }) +}) diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts new file mode 100644 index 000000000000..cb7e321d8228 --- /dev/null +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import type { ToolCallContent, ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpDialect } from './acp-dialect' + +// OMP sends a tool's result as `rawOutput: {content: [{type: 'text', text}], details}`, with a +// command's non-zero exit at `details.exitCode`, and repeats the text as content behind a +// `$ ` echo. The shared reader takes content text first, so that echo became the output. +const textBlockSchema = z.looseObject({ type: z.literal('text'), text: z.string() }) +const toolResultSchema = z.looseObject({ + content: z.array(z.unknown()), + details: z + .looseObject({ + exitCode: z.number().int().safe().optional(), + wallTimeMs: z.number().optional() + }) + .optional() +}) + +function contentText(block: ToolCallContent | undefined): string | undefined { + return block?.type === 'content' && block.content.type === 'text' ? block.content.text : undefined +} + +/** OMP appends these notice lines to a command's output; the row shows exit and timing itself. */ +function commandOutput(text: string, exitCode: number | undefined, timed: boolean): string { + let output = text + const exitNotice = `\n\nCommand exited with code ${exitCode}` + if (exitCode !== undefined && output.endsWith(exitNotice)) { + output = output.slice(0, -exitNotice.length) + } + return timed ? output.replace(/\n\nWall time: \d+(?:\.\d+)? seconds$/u, '') : output +} + +function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { + const echo = contentText(update.content?.[0]) + if (!echo?.startsWith('$ ')) { + return update + } + const parsed = toolResultSchema.safeParse(update.rawOutput) + const texts = parsed.success + ? parsed.data.content.flatMap((block) => textBlockSchema.safeParse(block).data?.text ?? []) + : [] + if (texts.includes(echo)) { + return update + } + // The result's own text moves to `stdout`; content keeps anything else it carried. + const content = (update.content ?? []).slice(1).filter((block) => { + const text = contentText(block) + return text === undefined || !texts.includes(text) + }) + if (!parsed.success) { + return { ...update, content } + } + const exitCode = parsed.data.details?.exitCode + const stdout = commandOutput( + texts.join('\n'), + exitCode, + parsed.data.details?.wallTimeMs !== undefined + ) + return { + ...update, + content, + rawOutput: { ...parsed.data, stdout, ...(exitCode === undefined ? {} : { exitCode }) } + } +} + +export const OMP_ACP_DIALECT: AcpDialect = { normalizeToolUpdate } diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index ba688ec9b94b..958d787406d2 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -7,8 +7,9 @@ import { ORCA_SCRUB_SAFE_PANE_ENV } from '../../shared/agent-hook-scrub-safe-env' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' -import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { OMP_ACP_DIALECT } from './acp-dialects/omp-dialect' import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' import { directoryAccountBinding, type AcpAccountBinding } from './acp-account-binding' import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' @@ -99,7 +100,7 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { // `omp acp` takes no flags: full access answers each permission request yes. args: () => ['acp'], env: {}, - dialect: GENERIC_ACP_DIALECT, + dialect: OMP_ACP_DIALECT, // OMP signs in from its own `/login`. loginCommand: ['omp'], // The directory OMP's terminal chats read too; its default is OMP's own. diff --git a/src/main/acp/acp-structured-omp-recordings.test.ts b/src/main/acp/acp-structured-omp-recordings.test.ts new file mode 100644 index 000000000000..4c0dc40c03d6 --- /dev/null +++ b/src/main/acp/acp-structured-omp-recordings.test.ts @@ -0,0 +1,117 @@ +// The adapter against OMP's recorded `omp acp` traffic (17.0.5): 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' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string) { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('omp')!, + script: (scripted) => replay.attach(scripted) + }) + await rig.acquire() + await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', body: ask, fence: 1 }) + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function toolRow(rig: AcpAdapterRig) { + const rows = (await rig.rig.rows()).filter((row) => row.body.kind === 'tool-call') + expect(rows).toHaveLength(1) + return rows[0]!.body +} + +async function allowOnce(rig: AcpAdapterRig, replay: GrokFixtureReplay) { + const approval = await waitFor(async () => { + const found = (await rig.rig.rows()).find((row) => row.body.kind === 'approval') + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow_once' }, + fence: 1, + commit: async () => {} + }) +} + +describe('OMP recordings through the adapter', () => { + it("a failed command's row shows its output and exit code, not the command again", async () => { + const { rig, replay } = await replaying('omp-v17-shell-exit-3') + await allowOnce(rig, replay) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + expect(await toolRow(rig)).toMatchObject({ + state: 'failed', + input: { command: 'printf hi; exit 3' }, + exitCode: 3, + output: { head: 'hi', truncated: false } + }) + }) + + it("a successful command's row shows its output", async () => { + const { rig, replay } = await replaying('omp-v17-shell-ok') + await allowOnce(rig, replay) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const tool = await toolRow(rig) + expect(tool).toMatchObject({ state: 'completed', output: { head: 'ok', truncated: false } }) + // OMP reports an exit code only when it is not zero. + expect(tool).not.toHaveProperty('exitCode') + }) + + it('Stop during a running command ends the turn and leaves the row without the command as output', async () => { + const { rig, replay } = await replaying('omp-v17-stop-mid-tool') + await allowOnce(rig, replay) + 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' }]) + ) + // OMP sends nothing more for the stopped command; Orca settles its row with the turn. + const tool = await toolRow(rig) + expect(tool).toMatchObject({ + endedAs: 'interrupted', + input: { command: 'sleep 30', timeout: 60 } + }) + expect(tool).not.toHaveProperty('output') + }) +}) diff --git a/src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl b/src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl new file mode 100644 index 000000000000..7bbad57dbe8a --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-shell-exit-3.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the bash tool exactly once to run this exact command and nothing else: printf hi; exit 3\nDo not run any other tool. Then reply in one short sentence with what it printed and its exit code."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"model","description":"Show current model selection"},{"name":"fast","description":"Toggle fast mode","input":{"hint":"[on|off|status]"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:13.994Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","title":"printf hi; exit 3","kind":"execute","status":"pending","rawInput":{"command":"printf hi; exit 3","timeout":10},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}}],"locations":[]},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Always allow","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"},{"optionId":"reject_always","name":"Always reject","kind":"reject_always"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"$ printf hi; exit 3","kind":"execute","status":"pending","rawInput":{"command":"printf hi; exit 3","timeout":10},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","rawOutput":{"content":[{"type":"text","text":"hi"}],"details":{}},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}},{"type":"content","content":{"type":"text","text":"hi"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"failed","rawOutput":{"content":[{"type":"text","text":"hi\n\nWall time: 0.09 seconds\n\nCommand exited with code 3"}],"details":{"timeoutSeconds":10,"wallTimeMs":87.65562499999942,"exitCode":3},"isError":true},"content":[{"type":"content","content":{"type":"text","text":"$ printf hi; exit 3"}},{"type":"content","content":{"type":"text","text":"hi\n\nWall time: 0.09 seconds\n\nCommand exited with code 3"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"It"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" printed"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" exited"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"3"},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."},"messageId":"94959649-0666-4fe3-b0f8-5f4254d56d7e"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","size":272000,"used":18220,"cost":{"amount":0.10272500000000001,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:19.387Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":18459,"outputTokens":49,"totalTokens":36428,"cachedReadTokens":17920}}}} diff --git a/src/main/acp/fixtures/omp-v17-shell-ok.jsonl b/src/main/acp/fixtures/omp-v17-shell-ok.jsonl new file mode 100644 index 000000000000..0c96d48be96e --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-shell-ok.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the bash tool exactly once to run this exact command and nothing else: printf ok\nDo not run any other tool. Then reply in one short sentence with what it printed and its exit code."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"model","description":"Show current model selection"},{"name":"fast","description":"Toggle fast mode","input":{"hint":"[on|off|status]"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:00.014Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","title":"printf ok","kind":"execute","status":"pending","rawInput":{"command":"printf ok"},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}}],"locations":[]},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Always allow","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"},{"optionId":"reject_always","name":"Always reject","kind":"reject_always"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"$ printf ok","kind":"execute","status":"pending","rawInput":{"command":"printf ok"},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","rawOutput":{"content":[{"type":"text","text":"ok"}],"details":{}},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}},{"type":"content","content":{"type":"text","text":"ok"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"content":[{"type":"text","text":"ok\n\nWall time: 0.41 seconds"}],"details":{"timeoutSeconds":300,"wallTimeMs":406.50874999999996}},"content":[{"type":"content","content":{"type":"text","text":"$ printf ok"}},{"type":"content","content":{"type":"text","text":"ok\n\nWall time: 0.41 seconds"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"It"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" printed"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" exited"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"0"},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."},"messageId":"62df7016-80a7-41a9-8467-43c45abf950b"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","size":272000,"used":18198,"cost":{"amount":0.10234000000000001,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:24:06.553Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":18430,"outputTokens":41,"totalTokens":36391,"cachedReadTokens":17920}}}} diff --git a/src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl b/src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl new file mode 100644 index 000000000000..660483571a23 --- /dev/null +++ b/src/main/acp/fixtures/omp-v17-stop-mid-tool.jsonl @@ -0,0 +1,8 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the bash tool exactly once to run this exact command and nothing else: sleep 30\nDo not run any other tool. Then reply in one short sentence with what it printed and its exit code."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"model","description":"Show current model selection"},{"name":"fast","description":"Toggle fast mode","input":{"hint":"[on|off|status]"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","updatedAt":"2026-10-08T03:32:42.139Z"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","title":"sleep 30","kind":"execute","status":"pending","rawInput":{"command":"sleep 30","timeout":60},"content":[{"type":"content","content":{"type":"text","text":"$ sleep 30"}}],"locations":[]},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Always allow","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"},{"optionId":"reject_always","name":"Always reject","kind":"reject_always"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"$ sleep 30","kind":"execute","status":"pending","rawInput":{"command":"sleep 30","timeout":60},"content":[{"type":"content","content":{"type":"text","text":"$ sleep 30"}}]}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","usage":{"inputTokens":234,"outputTokens":63,"totalTokens":18217,"cachedReadTokens":17920}}}}