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 dcf91c1d4f8d..958d787406d2 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -9,13 +9,14 @@ import { import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' 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' 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 +93,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: 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. + 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/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}}}} 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])