diff --git a/src/main/acp/acp-account-binding.ts b/src/main/acp/acp-account-binding.ts new file mode 100644 index 000000000000..4adddaa48822 --- /dev/null +++ b/src/main/acp/acp-account-binding.ts @@ -0,0 +1,40 @@ +// How an ACP agent's chats pin their account: what a new chat records, and how a launch points the +// agent at the account its record pinned. Both run on the machine that runs the agent. + +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { resolveStructuredEnvAccountHomePath } from '../runtime/structured-agent-account-home' + +type AccountPin = { accountHomeVariable: string } | { accountLocatorKind: 'opencode' } + +export type AcpAccountBinding = { + /** What the agent's definition says its records pin. */ + pin: AccountPin + /** The account a new chat pins: the current selection, read without side effects. */ + resolve(input: { launchEnv: NodeJS.ProcessEnv }): Promise + /** `env` pointed at the pinned account; throws for an account of another kind. */ + environment(home: AgentSessionAccountHome, env: Record): Record +} + +/** An agent whose account is one config directory named by `variable`, with a default under the + * user's home. */ +export function directoryAccountBinding( + variable: string, + defaultHome: (homePath: string) => string +): AcpAccountBinding { + return { + pin: { accountHomeVariable: variable }, + resolve: async ({ launchEnv }) => ({ + variable, + path: resolveStructuredEnvAccountHomePath({ launchEnv, variable, defaultPath: defaultHome }) + }), + environment: (home, env) => { + if (!isLegacyAgentSessionAccountHome(home) || home.variable !== variable) { + throw new Error(`sessions of this agent pin ${variable}`) + } + return { ...env, [variable]: home.path } + } + } +} diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 596ddcb4049a..c554e9b6115d 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -4,7 +4,7 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpAgentError } from '../acp-errors' -import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { PermissionOption, ToolCallUpdate } from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { body: ProviderTimelineRequestBody @@ -41,6 +41,10 @@ export type AcpDialectNotification = /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { injectedPromptIdentity?: true + /** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */ + normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate + /** The words for a permission option whose own name misstates what it grants. */ + permissionOptionLabel?(option: PermissionOption): string | undefined toolName?(update: ToolCallUpdate): string | undefined toolBackgroundTasks?( update: ToolCallUpdate, diff --git a/src/main/acp/acp-dialects/opencode-dialect.ts b/src/main/acp/acp-dialects/opencode-dialect.ts new file mode 100644 index 000000000000..e2904d618702 --- /dev/null +++ b/src/main/acp/acp-dialects/opencode-dialect.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpDialect } from './acp-dialect' + +// OpenCode reports a command's output and exit code under `rawOutput.metadata`; the shared shape +// is `stdout` and `exitCode`. +const commandRawOutputSchema = z.looseObject({ + output: z.unknown().optional(), + metadata: z + .looseObject({ + exit: z.number().int().nullable().optional(), + output: z.string().optional() + }) + .optional() +}) + +function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { + const parsed = commandRawOutputSchema.safeParse(update.rawOutput) + if (!parsed.success) { + return update + } + const rawOutput = parsed.data + const output = + typeof rawOutput.output === 'string' ? rawOutput.output : rawOutput.metadata?.output + const hasSharedOutput = ['stdout', 'stderr', 'output_for_prompt'].some( + (key) => rawOutput[key] !== undefined + ) + const hasSharedExitCode = rawOutput.exitCode !== undefined || rawOutput.exit_code !== undefined + const exitCode = rawOutput.metadata?.exit ?? undefined + if ((output === undefined || hasSharedOutput) && (exitCode === undefined || hasSharedExitCode)) { + return update + } + return { + ...update, + rawOutput: { + ...rawOutput, + ...(output === undefined || hasSharedOutput ? {} : { stdout: output }), + ...(exitCode === undefined || hasSharedExitCode ? {} : { exitCode }) + } + } +} + +export const OPENCODE_ACP_DIALECT: AcpDialect = { + normalizeToolUpdate, + // OpenCode 1.x keeps an "always" grant in its own process, which is this chat's alone and ends + // whenever Orca ends it (a Stop, quitting, or stopping an idle chat's agent): not "always". + permissionOptionLabel: (option) => + option.kind === 'allow_always' ? 'Allow until OpenCode restarts' : undefined +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 2b705aa0dd7f..dcf91c1d4f8d 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -9,15 +9,26 @@ 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 { 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 type { TuiAgent } from '../../shared/tui-agent' export type AcpLaunchSpec = { - /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ - agent: string + /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ + agent: TuiAgent command: string /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ args(input: { fullAccess: boolean }): string[] - /** Overlaid on the child's environment. */ + /** Laid over the child's environment last, after the account and the user's own variables. */ env: Readonly> + /** Rewrites what the child would inherit from Orca's own plumbing; returns the keys it must not + * inherit at all. `inherited` is the environment the child process starts from. */ + scrubEnvironment?(env: Record, inherited: NodeJS.ProcessEnv): string[] dialect: AcpDialect /** The agent's own sign-in command, for a person to run when it reports auth required. */ loginCommand: readonly string[] @@ -27,12 +38,17 @@ export type AcpLaunchSpec = { advertised: readonly string[] env: Readonly> }): string | undefined - /** Variable naming the agent's config directory, pinned as each record's account home. */ - accountHomeVariable: string - /** The config directory the agent uses when the variable is unset, under the user's home. */ - defaultAccountHome(homePath: string): string + /** The account each chat pins, and how a launch points the agent at it. */ + account: AcpAccountBinding + /** The `--version` releases a structured chat runs on, asked before a create and again at every + * launch; any other release keeps the terminal chat. Absent runs whatever is installed. */ + supportsVersion?(version: string): boolean /** Where the agent installs its own binary, searched after PATH. */ - installDirectories(accountHomePath: string): string[] + installDirectories(input: { env: Readonly>; homePath: string }): string[] + /** Images go to the agent when it also advertises them; off sends text prompts only. */ + imagePrompts?: true + /** The agent's own store of a session's user messages, read for restart recovery only. */ + readStoredUserMessages?: AcpStoredUserMessagesReader } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { @@ -50,12 +66,33 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { : advertised.includes('cached_token') ? 'cached_token' : undefined, - accountHomeVariable: 'GROK_HOME', - defaultAccountHome: (homePath) => join(homePath, '.grok'), - installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] + account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), + installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []) } -export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] +// OpenCode 1.x serves ACP in-process through `opencode acp`. OpenCode 2 (`opencode2`, and any +// `opencode` that is 2.x) is not: its `acp` runs inside the user's own background service, which a +// chat's environment and account pin do not reach, so it keeps its terminal-backed chat. +const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'opencode', + command: 'opencode', + // OpenCode has no bypass flag: full access answers each permission request yes. + args: () => ['acp'], + // Applied last: the client name ACP sessions report, and no question tool, which ACP cannot + // answer (it would wait forever). + env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' }, + scrubEnvironment: scrubOpenCodeAcpEnvironment, + dialect: OPENCODE_ACP_DIALECT, + loginCommand: ['opencode', 'auth', 'login'], + account: openCodeAcpAccountBinding(), + installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')], + // Stable 1.x from 1.18.31, the release the recorded sessions capture. + supportsVersion: (version) => isStableCliVersionOnLine(version, { major: 1, floor: '1.18.31' }), + imagePrompts: true, + readStoredUserMessages: openCodeStoredUserMessagesReader() +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC, OPENCODE_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-prompt-content.test.ts b/src/main/acp/acp-prompt-content.test.ts new file mode 100644 index 000000000000..63b7363ca5f9 --- /dev/null +++ b/src/main/acp/acp-prompt-content.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + ACP_PROMPT_IMAGE_MAX_BYTES, + AcpPromptContentError, + acpPromptBlocks +} from './acp-prompt-content' + +const PNG = Buffer.from('89504e470d0a1a0a', 'hex') +const dir = mkdtempSync(join(tmpdir(), 'acp-prompt-content-')) +const pngPath = join(dir, 'shot.png') +writeFileSync(pngPath, PNG) + +function message(...blocks: NativeChatBlock[]): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +async function refusal(body: AgentJournalMessageItem, images = true) { + const error = await acpPromptBlocks(body, images).catch((caught: unknown) => caught) + if (!(error instanceof AcpPromptContentError)) { + throw new Error(`expected a refusal, got ${String(error)}`) + } + return error.failure +} + +describe('ACP prompt content', () => { + it('sends text as text blocks', async () => { + await expect(acpPromptBlocks(message({ type: 'text', text: 'hi' }), false)).resolves.toEqual([ + { type: 'text', text: 'hi' } + ]) + }) + + it('sends a file or data image as an ACP image block, in order', async () => { + const data = PNG.toString('base64') + await expect( + acpPromptBlocks( + message( + { type: 'image-ref', path: pngPath }, + { type: 'text', text: 'what is this?' }, + { type: 'image-ref', url: pathToFileURL(pngPath).href }, + { type: 'image-ref', url: `data:image/png;base64,${data}` } + ), + true + ) + ).resolves.toEqual([ + { type: 'image', mimeType: 'image/png', data }, + { type: 'text', text: 'what is this?' }, + { type: 'image', mimeType: 'image/png', data }, + { type: 'image', mimeType: 'image/png', data } + ]) + }) + + it('refuses an image when the agent takes none', async () => { + expect(await refusal(message({ type: 'image-ref', path: pngPath }), false)).toMatchObject({ + kind: 'attachmentInvalid', + attachment: { reason: 'unsupportedType' } + }) + }) + + it('refuses what it cannot read or send before anything leaves Orca', async () => { + expect(await refusal(message({ type: 'image-ref', path: 'relative.png' }))).toMatchObject({ + attachment: { reason: 'noSource' } + }) + expect(await refusal(message({ type: 'image-ref', path: join(dir, 'a.bmp') }))).toMatchObject({ + attachment: { reason: 'unsupportedType' } + }) + expect(await refusal(message({ type: 'image-ref', path: `${dir}.png` }))).toMatchObject({ + kind: 'attachmentUnreadable' + }) + expect( + await refusal(message({ type: 'image-ref', url: 'data:image/svg+xml;base64,PHN2Zz4=' })) + ).toMatchObject({ attachment: { reason: 'unsupportedType' } }) + }) + + it('keeps a message with images within one protocol line', async () => { + // The images fit their raw limit, but with this much text the prompt would pass the line. + const text = 'x'.repeat(8 * 1024 * 1024) + const image = { + type: 'image-ref' as const, + url: `data:image/png;base64,${PNG.toString('base64')}` + } + expect( + await refusal(message({ type: 'text', text }, { type: 'text', text }, image)) + ).toMatchObject({ + attachment: { reason: 'totalTooLarge' } + }) + expect(ACP_PROMPT_IMAGE_MAX_BYTES * (4 / 3)).toBeLessThan(16 * 1024 * 1024) + }) + + it('leaves a text-only message to the agent, however long', async () => { + const text = 'x'.repeat(9 * 1024 * 1024) + await expect( + acpPromptBlocks(message({ type: 'text', text }, { type: 'text', text }), true) + ).resolves.toHaveLength(2) + }) + + it("bounds one message's images together", async () => { + const big = Buffer.alloc(ACP_PROMPT_IMAGE_MAX_BYTES / 2 + 1).toString('base64') + const image = { type: 'image-ref' as const, url: `data:image/png;base64,${big}` } + expect(await refusal(message(image, image))).toMatchObject({ + attachment: { reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES } + }) + }) +}) diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts new file mode 100644 index 000000000000..2d1c6e336b1d --- /dev/null +++ b/src/main/acp/acp-prompt-content.ts @@ -0,0 +1,161 @@ +// A person's message as an ACP prompt. Images go as the protocol's image blocks, and only to an +// agent that advertised them; each is checked and read before anything reaches the agent. + +import { extname, isAbsolute } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { IMAGE_FILE_MIME_TYPES } from '../../shared/image-file-extensions' +import type { NativeChatImageRefBlock } from '../../shared/native-chat-types' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/node-bounded-file-reader' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { resolveAcpPeerOptions } from './acp-peer-limits' +import { acpAgentName } from './acp-structured-acquire' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { ContentBlock } from './generated/acp-protocol.generated' + +const ACP_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) +/** A prompt is one JSON-RPC line; this leaves room for the request around its blocks. */ +const ACP_PROMPT_LINE_BUDGET = resolveAcpPeerOptions().maxLineBytes - 64 * 1024 +/** One message's images together, raw: base64 makes each 4 bytes of them 3 more. */ +export const ACP_PROMPT_IMAGE_MAX_BYTES = Math.floor((ACP_PROMPT_LINE_BUDGET * 3) / 4) - 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const READ_TIMEOUT_MS = 15_000 + +/** Why Orca did not send a message; never the agent's refusal. */ +export class AcpPromptContentError extends Error { + constructor(readonly failure: SubmissionRejectionFact) { + super(`ACP prompt refused: ${failure.kind}`) + this.name = 'AcpPromptContentError' + } +} + +function attachmentProblem(attachment: AgentSessionAttachmentProblem): AcpPromptContentError { + return new AcpPromptContentError(agentSessionFailureFact('attachmentInvalid', { attachment })) +} + +async function imageBlock( + block: NativeChatImageRefBlock, + remainingBytes: number, + signal: AbortSignal +): Promise<{ block: ContentBlock; bytes: number }> { + const tooLarge = (): AcpPromptContentError => + attachmentProblem({ + reason: remainingBytes < ACP_PROMPT_IMAGE_MAX_BYTES ? 'totalTooLarge' : 'tooLarge', + limit: ACP_PROMPT_IMAGE_MAX_BYTES + }) + if (block.url?.startsWith('data:')) { + const match = /^data:(image\/[a-z]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec(block.url) + if (!match || !ACP_IMAGE_MIME_TYPES.has(match[1]) || match[2].length % 4 !== 0) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + const bytes = Buffer.byteLength(match[2], 'base64') + if (bytes === 0) { + throw attachmentProblem({ reason: 'empty' }) + } + if (bytes > remainingBytes) { + throw tooLarge() + } + return { block: { type: 'image', mimeType: match[1], data: match[2] }, bytes } + } + let path = block.path + if (!path && block.url?.startsWith('file:')) { + try { + path = fileURLToPath(block.url) + } catch { + throw attachmentProblem({ reason: 'noSource' }) + } + } + if (!path || !isAbsolute(path)) { + throw attachmentProblem({ reason: 'noSource' }) + } + const mimeType = IMAGE_FILE_MIME_TYPES[extname(path).toLowerCase()] + if (!mimeType || !ACP_IMAGE_MIME_TYPES.has(mimeType)) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + let data: Buffer + try { + data = (await readNodeFileWithinLimit(path, remainingBytes, { regularFileOnly: true, signal })) + .buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw tooLarge() + } + if (error instanceof Error && error.message === 'Expected a regular file') { + throw attachmentProblem({ reason: 'notAFile' }) + } + throw new AcpPromptContentError(agentSessionFailureFact('attachmentUnreadable')) + } + if (data.byteLength === 0) { + throw attachmentProblem({ reason: 'empty' }) + } + return { block: { type: 'image', mimeType, data: data.toString('base64') }, bytes: data.length } +} + +/** The prompt for `body`; throws `AcpPromptContentError` for what Orca will not send. */ +export async function acpPromptBlocks( + body: AgentJournalMessageItem, + acceptsImages: boolean +): Promise { + const blocks: ContentBlock[] = [] + const signal = AbortSignal.timeout(READ_TIMEOUT_MS) + let imageCount = 0 + let imageBytes = 0 + for (const block of body.blocks) { + if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }) + continue + } + if (block.type !== 'image-ref' || !acceptsImages) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + imageCount += 1 + if (imageCount > MAX_IMAGE_COUNT) { + throw attachmentProblem({ reason: 'tooMany', limit: MAX_IMAGE_COUNT }) + } + const image = await imageBlock(block, ACP_PROMPT_IMAGE_MAX_BYTES - imageBytes, signal) + imageBytes += image.bytes + blocks.push(image.block) + } + // Only images can carry a message past one line here; their words are the refusal's. + if (imageCount > 0 && Buffer.byteLength(JSON.stringify(blocks)) > ACP_PROMPT_LINE_BUDGET) { + throw attachmentProblem({ reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES }) + } + return blocks +} + +/** The prompt for `body` to `session`'s agent, or Orca's refusal of it in words that name the + * agent. Images go only where the agent's row offers them and its start advertised them. */ +export async function acpDispatchPrompt( + body: AgentJournalMessageItem, + session: { + spec: Pick + connection: Pick + } +): Promise { + try { + const advertised = + session.spec.imagePrompts === true && + (await session.connection.initialize()).agentCapabilities?.promptCapabilities?.image === true + return await acpPromptBlocks(body, advertised) + } catch (error) { + if (error instanceof AcpPromptContentError) { + return agentSessionFailureWords(error.failure, { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + throw error + } +} diff --git a/src/main/acp/acp-recovery-history.test.ts b/src/main/acp/acp-recovery-history.test.ts new file mode 100644 index 000000000000..402b9e2f76d1 --- /dev/null +++ b/src/main/acp/acp-recovery-history.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { readAcpRecoveryHistory, type AcpStoredUserMessage } from './acp-recovery-history' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +const SESSION = 'session-alpha-1' +const identity = { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} + +function fingerprint(text: string): string { + return computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } + }) +} + +function submission( + clientMessageId: string, + text: string, + dispatchState: AgentJournalSubmission['dispatchState'], + submittedAt: number, + resolvedAt: number | null = null +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: fingerprint(text), + dispatchState, + providerItemId: null, + reason: null, + submittedAt, + resolvedAt + } +} + +function journal(...submissions: AgentJournalSubmission[]): JournalLoad { + const state = createJournalReducerState(SESSION, 'epoch-1') + for (const entry of submissions) { + state.submissions.set(entry.clientMessageId, entry) + } + return { state, newer: null, damage: null } +} + +const launch: AcpStructuredLaunch = { + spec: acpLaunchSpecFor('opencode')!, + command: '/resolved/opencode', + args: ['acp'], + cwd: '/repo', + env: { XDG_DATA_HOME: '/data' }, + envToDelete: [], + fullAccess: false, + resume: { + sessionId: 'ses_1', + key: 'acp:opencode:ses_1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } +} + +function stored(id: string, text: string, createdAt: number): AcpStoredUserMessage { + return { id, blocks: [{ type: 'text', text }], createdAt } +} + +async function windowFor(load: JournalLoad | null, messages: AcpStoredUserMessage[] | null) { + const readStoredUserMessages = vi.fn(async () => messages) + const window = await readAcpRecoveryHistory( + { + spec: { readStoredUserMessages }, + resolveLaunch: async () => launch, + readJournal: () => load + }, + identity + ) + return { window, readStoredUserMessages } +} + +describe('ACP restart recovery from the agent store', () => { + it('settles a message the agent recorded after the crash as sent, under its own row', async () => { + const load = journal( + submission('first', 'hello', 'accepted', 100, 110), + submission('held', 'run the tests', 'unknown', 200) + ) + const { window, readStoredUserMessages } = await windowFor(load, [ + stored('msg_1', 'hello', 105), + stored('msg_2', 'run the tests', 205) + ]) + expect(readStoredUserMessages).toHaveBeenCalledWith( + expect.objectContaining({ env: launch.env, providerSessionId: 'ses_1' }) + ) + expect(window).toMatchObject({ boundaryConsistent: false, turnInFlight: true }) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toEqual([ + { + clientMessageId: 'held', + outcome: 'accepted', + providerItemId: 'msg_2', + identity: { provider: 'orca', clientMessageId: 'held' } + } + ]) + }) + + it('never offers an older identical message as the one in doubt', async () => { + const load = journal( + submission('first', 'continue', 'accepted', 100, 110), + submission('held', 'continue', 'unknown', 200) + ) + const { window } = await windowFor(load, [stored('msg_1', 'continue', 105)]) + expect(window?.items).toEqual([]) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toMatchObject([{ clientMessageId: 'held', outcome: 'unknown' }]) + }) + + it('lets an accepted send claim its own copy even when the agent stored it after the next send', async () => { + // Accepted at dispatch (r=500) and stored at 505; the identical send behind it was lost. + const load = journal( + submission('first', 'continue', 'accepted', 100, 500), + submission('held', 'continue', 'unknown', 110) + ) + const { window } = await windowFor(load, [stored('msg_1', 'continue', 505)]) + expect(window?.items).toEqual([]) + }) + + it('keeps an older unconfirmed send from widening the window over a delivered copy', async () => { + const load = journal( + submission('old', 'yes', 'unknown', 50), + submission('first', 'yes', 'accepted', 300, 300), + submission('held', 'yes', 'unknown', 400) + ) + const { window } = await windowFor(load, [stored('msg_1', 'yes', 303)]) + expect(window?.items).toEqual([]) + }) + + it('confirms the doubted send when both identical copies are stored', async () => { + const load = journal( + submission('first', 'yes', 'accepted', 300, 300), + submission('held', 'yes', 'unknown', 400) + ) + const { window } = await windowFor(load, [ + stored('msg_1', 'yes', 303), + stored('msg_2', 'yes', 405) + ]) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toMatchObject([{ clientMessageId: 'held', outcome: 'accepted', providerItemId: 'msg_2' }]) + }) + + it('reads nothing for a queued card the agent was never handed', async () => { + const queued = { ...submission('queued', 'x', 'pending', 1), handoverRecorded: true as const } + const { window, readStoredUserMessages } = await windowFor(journal(queued), []) + expect(window).toBeNull() + expect(readStoredUserMessages).not.toHaveBeenCalled() + }) + + it('reads nothing with no message in doubt, a damaged journal, or no resumable session', async () => { + expect((await windowFor(journal(submission('a', 'x', 'accepted', 1, 2)), [])).window).toBeNull() + const damaged = journal(submission('held', 'x', 'unknown', 1)) + expect( + (await windowFor({ ...damaged, damage: { sequence: 3, cause: 'sequence-gap' } }, [])).window + ).toBeNull() + const readStoredUserMessages = vi.fn(async () => []) + await expect( + readAcpRecoveryHistory( + { + spec: { readStoredUserMessages }, + resolveLaunch: async () => ({ ...launch, resume: null }), + readJournal: () => journal(submission('held', 'x', 'unknown', 1)) + }, + identity + ) + ).resolves.toBeNull() + expect(readStoredUserMessages).not.toHaveBeenCalled() + }) + + it('reads nothing for an agent whose store Orca does not read', async () => { + await expect( + readAcpRecoveryHistory( + { + spec: {}, + resolveLaunch: async () => launch, + readJournal: () => journal(submission('held', 'x', 'unknown', 1)) + }, + identity + ) + ).resolves.toBeNull() + }) + + it('answers null, never throws, when the store cannot be read', async () => { + const warn = vi.fn() + await expect( + readAcpRecoveryHistory( + { + spec: { + readStoredUserMessages: async () => { + throw new Error('database is locked') + } + }, + resolveLaunch: async () => launch, + readJournal: () => journal(submission('held', 'x', 'unknown', 1)), + logger: { warn, error: vi.fn() } + }, + identity + ) + ).resolves.toBeNull() + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-recovery-history.ts b/src/main/acp/acp-recovery-history.ts new file mode 100644 index 000000000000..d00a21a6d863 --- /dev/null +++ b/src/main/acp/acp-recovery-history.ts @@ -0,0 +1,145 @@ +// Restart recovery for an ACP agent that keeps its own store: whether a message a crash left +// unconfirmed reached the agent. Presence only: a match settles it as sent, while absence proves +// nothing, so nothing is ever called undelivered. Bounded, and null on any failure; the user can +// always resend or discard, so this never stands between them and the chat. + +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation' +import type { + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import type { + ProviderHistoryItem, + ProviderHistoryWindow +} from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { isQueuedAgentJournalSubmission } from '../../shared/agent-session-queued-submission' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +const RECOVERY_READ_MS = 5_000 + +/** A person's message as the agent's own store holds it. */ +export type AcpStoredUserMessage = { + /** The store's id for it, claimed at most once. */ + id: string + blocks: NativeChatBlock[] + /** Epoch ms the agent recorded it, on this machine's clock. */ + createdAt: number +} + +/** Reads the user messages of one provider session from the agent's own store; null when it cannot. */ +export type AcpStoredUserMessagesReader = (input: { + env: Record + providerSessionId: string + signal: AbortSignal +}) => Promise + +/** Sends a crash may have left unconfirmed; a queued card was never handed over, so the agent + * cannot hold it. */ +function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] { + return [...load.state.submissions.values()].filter( + (submission) => + (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') && + !isQueuedAgentJournalSubmission(submission) + ) +} + +function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string { + return agentSessionSendBodyFingerprint(sessionId, { + kind: 'message', + role: 'user', + blocks: message.blocks + }) +} + +/** + * The agent's store keeps no id of Orca's, so a stored message can only match a send by content. + * Each send the journal already accepted first claims the earliest stored copy of its text written + * no earlier than it was sent, so an identical message that did reach the agent is never offered as + * the one in doubt; what is left, from the first send in doubt on, is the window. Claiming more than + * a send's own copy only leaves a send in doubt unconfirmed, never wrongly confirmed. + */ +function recoveryWindow( + load: JournalLoad, + inDoubt: readonly AgentJournalSubmission[], + messages: readonly AcpStoredUserMessage[] +): ProviderHistoryItem[] { + const submissions = [...load.state.submissions.values()] + const from = Math.min(...inDoubt.map((submission) => submission.submittedAt)) + const stored = messages + .map((message) => ({ message, fingerprint: fingerprintOf(load.state.sessionId, message) })) + .toSorted((left, right) => left.message.createdAt - right.message.createdAt) + const claimed = new Set() + const accepted = submissions + .filter((submission) => submission.dispatchState === 'accepted') + .toSorted((left, right) => left.submittedAt - right.submittedAt) + for (const submission of accepted) { + const index = stored.findIndex( + ({ message, fingerprint }, at) => + !claimed.has(at) && + fingerprint === submission.payloadFingerprint && + message.createdAt >= submission.submittedAt + ) + if (index !== -1) { + claimed.add(index) + } + } + return stored.flatMap(({ message, fingerprint }, at) => + claimed.has(at) || message.createdAt < from + ? [] + : [{ providerItemId: message.id, clientMessageId: null, payloadFingerprint: fingerprint }] + ) +} + +/** The window for one chat; null for an agent whose store Orca does not read. */ +export async function readAcpRecoveryHistory( + input: Pick & { + spec: Pick + }, + identity: AgentSessionJournalIdentity +): Promise { + const { readStoredUserMessages } = input.spec + if (!readStoredUserMessages || !input.readJournal) { + return null + } + const sessionId = identity.sessionId + const signal = AbortSignal.timeout(RECOVERY_READ_MS) + try { + const load = input.readJournal(sessionId) + const inDoubt = load && !load.damage && !load.newer ? sendsInDoubt(load) : [] + if (!load || inDoubt.length === 0) { + return null + } + const launch = await waitForPromiseWithSignal(input.resolveLaunch({ identity }), signal) + if (!launch.resume) { + return null + } + const messages = await waitForPromiseWithSignal( + readStoredUserMessages({ + env: launch.env, + providerSessionId: launch.resume.sessionId, + signal + }), + signal + ) + if (!messages) { + return null + } + return { + // Absence proves nothing: neither the start of the read nor the end of the agent's work is known. + boundaryConsistent: false, + turnInFlight: true, + items: recoveryWindow(load, inDoubt, messages) + } + } catch (error) { + input.logger?.warn('ACP recovery history could not be read', { + scope: 'acp-recovery-history', + sessionId, + error + }) + return null + } +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index cf7466f27d8f..b9c8b41d209f 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -124,7 +124,7 @@ export async function acquireAcpStructuredSession(input: { ...structuredSessionChildIdentityEnv(sessionId, launch.env), [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken }, - envToDelete: ACP_CHILD_ENV_TO_DELETE + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] }, { clientInfo: { name: 'orca', version: '1' }, diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index e39ade6c7b97..a81fb2c8198e 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -11,7 +11,7 @@ import { SESSION, type ProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { acpLaunchSpecFor } from './acp-launch-specs' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' import type { AcpAgentConnectionOptions } from './acp-agent-connection' import { AcpConnectionClosedError } from './acp-errors' @@ -156,6 +156,8 @@ export type AcpAdapterRig = { export async function openAcpAdapterRig( options: { + /** The agent's row; Grok's unless a test drives another agent. */ + spec?: AcpLaunchSpec launch?: Partial initialize?: Record script?: (agent: AcpScriptedAgent) => void @@ -167,14 +169,16 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + const spec = options.spec ?? GROK const adapter = new AcpStructuredSessionAdapter({ - spec: GROK, + spec, resolveLaunch: async () => ({ - spec: GROK, - command: '/opt/grok/bin/grok', - args: GROK.args({ fullAccess: false }), + spec, + command: `/opt/bin/${spec.command}`, + args: spec.args({ fullAccess: false }), cwd: '/workspace/project', env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + envToDelete: [], fullAccess: false, resume: null, ...options.launch @@ -226,7 +230,7 @@ export async function openAcpAdapterRig( sessionId: SESSION, workspaceId: 'workspace-1', hostId: 'local', - agent: 'grok', + agent: spec.agent, providerHandle: null }, fence: acquireOptions.fence ?? 1, diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts index d51816db450e..c82d5483ad88 100644 --- a/src/main/acp/acp-structured-agent-definitions.ts +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -16,15 +16,15 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge return { agent: spec.agent, handleTransport: ACP_HANDLE_TRANSPORT, - accountHomeVariable: spec.accountHomeVariable, + ...spec.account.pin, capabilities: { // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. rewind: false, compact: false, threadGoal: false, contextUsage: true, - // Off: Orca sends text prompts only until ACP image prompts have a path. - imagePrompts: false, + // An agent that does not also advertise images at its start has a message with one refused. + imagePrompts: spec.imagePrompts === true, steering: 'queue', // Orca answers every permission request the agent sends; the agent decides when it asks. approvalEnforcement: 'orca' diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts index 0a63542a1997..dfc3ceb45eb4 100644 --- a/src/main/acp/acp-structured-electron-free.test.ts +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -6,12 +6,13 @@ import { build } from 'esbuild' import { describe, expect, it } from 'vitest' describe('ACP structured lane', () => { - it('reaches no Electron import from the adapter or its launch resolution', async () => { + it('reaches no Electron import from the adapter, its launch resolution or any agent row', async () => { const result = await build({ entryPoints: [ resolve(__dirname, 'acp-structured-session-adapter.ts'), resolve(__dirname, 'acp-structured-launch-resolution.ts'), - resolve(__dirname, 'acp-agent-connection.ts') + resolve(__dirname, 'acp-agent-connection.ts'), + resolve(__dirname, 'acp-launch-specs.ts') ], bundle: true, write: false, diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts index c4cf1605c797..51555fe9a73b 100644 --- a/src/main/acp/acp-structured-fixture-replay.test-support.ts +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -16,7 +16,10 @@ const messageSchema = z.object({ id: z.union([z.string(), z.number()]).optional(), method: z.string().optional(), params: z.unknown().optional(), - result: z.unknown().optional() + result: z.unknown().optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional() }) function promptIdOf(params: unknown): string | undefined { @@ -83,13 +86,13 @@ export class GrokFixtureReplay { agent.send({ jsonrpc: '2.0', id: this.requestIds.get(message.id ?? '') ?? message.id, - result: message.result + ...(message.error === undefined ? { result: message.result } : { error: message.error }) }) } } } - private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + private rewrite(message: AcpFixtureFrame['message']): z.infer { let text = JSON.stringify(message).replaceAll( RECORDED_SESSION, JSON.stringify(PROVIDER_SESSION) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index afc1c1c42401..cc6dc7244913 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -61,6 +61,7 @@ export function launch(resume: () => boolean): () => Promise { + it('sends an image to an agent whose row offers images and whose start advertised them', async () => { + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('opencode')!, + initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toEqual({ state: 'admitted' }) + expect((await rig.frame('session/prompt')).params).toMatchObject({ + prompt: [ + { type: 'image', mimeType: 'image/png', data: DATA }, + { type: 'text', text: 'what is this?' } + ] + }) + }) + + it('refuses the message, sending nothing, when the agent did not advertise images', async () => { + const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor('opencode')! }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'attachmentInvalid' } }) + expect(rig.sent('session/prompt')).toEqual([]) + }) + + it('refuses an image for an agent whose row offers none, whatever it advertises', async () => { + const rig = await openAcpAdapterRig({ + initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toMatchObject({ state: 'rejected' }) + expect(rig.sent('session/prompt')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts new file mode 100644 index 000000000000..db5c10a551d7 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { + createAcpStructuredLaunchResolver, + type AcpStructuredLaunchResolverDeps +} from './acp-structured-launch-resolution' + +const PROFILE = '123e4567-e89b-42d3-a456-426614174000' + +const managedAccounts = { + list: (): ManagedDataAccountsState => ({ + accounts: [{ id: PROFILE, label: 'work', integrations: [], createdAt: 0 }], + activeAccountId: PROFILE + }), + restoreOriginalEnvironment: (environment: Record) => + restoreManagedDataAccountEnvironment(environment), + environmentForAccount: (_provider: 'opencode' | 'devin', id: string) => ({ + XDG_DATA_HOME: `/profiles/${id}/data`, + XDG_STATE_HOME: `/profiles/${id}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + }) +} + +const OPENCODE = { + ...acpLaunchSpecFor('opencode')!, + account: openCodeAcpAccountBinding(() => managedAccounts) +} +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} + +function openCodeRecord(accountHome: AgentSessionRecord['accountHome']): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome + } +} + +const UNMANAGED: AgentSessionRecord['accountHome'] = { + kind: 'opencode', + locator: { kind: 'unmanaged' } +} + +function resolver( + record: AgentSessionRecord, + options: { + launchEnv?: Record + inheritedEnv?: NodeJS.ProcessEnv + base?: Record + probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion'] + } = {} +) { + return createAcpStructuredLaunchResolver(OPENCODE, { + store: { getRecord: () => record }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user', ...options.base }), + resolveLaunchEnv: () => options.launchEnv ?? {}, + resolveCommand: (command) => `/resolved/${command}`, + inheritedEnv: options.inheritedEnv ?? {}, + probeVersion: options.probeVersion ?? (async (_input, supports) => supports('1.18.31')) + }) +} + +describe('OpenCode ACP launch resolution', () => { + it('runs `opencode acp` as an ACP client with no question tool, whatever the user set', async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + launchEnv: { OPENCODE_CLIENT: 'tui', OPENCODE_ENABLE_QUESTION_TOOL: 'true' } + })({ identity }) + expect(launch).toMatchObject({ command: '/resolved/opencode', args: ['acp'] }) + expect(launch.env).toMatchObject({ + OPENCODE_CLIENT: 'acp', + OPENCODE_ENABLE_QUESTION_TOOL: 'false' + }) + }) + + it("starts OpenCode with the user's own folders, database and inline credentials", async () => { + const userEnv = { + HOME: 'relative/home', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: '/data/opencode-state', + OPENCODE_DB: 'work.db', + OPENCODE_AUTH_CONTENT: '{"secret":1}' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { + base: { XDG_DATA_HOME: '/elsewhere' }, + launchEnv: userEnv + })({ identity }) + expect(launch.env).toMatchObject(userEnv) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + {}, + 'darwin' + ).env + expect(child).toMatchObject(userEnv) + }) + + it('leaves unset what the user left unset, whatever Orca itself inherited', async () => { + const inheritedEnv = { OPENCODE_DB: '/elsewhere/other.db', OPENCODE_AUTH_CONTENT: '' } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + expect(launch.env.XDG_DATA_HOME).toBeUndefined() + expect(launch.env.OPENCODE_DB).toBeUndefined() + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: launch.envToDelete + }, + inheritedEnv, + 'darwin' + ).env + expect(child.OPENCODE_DB).toBeUndefined() + expect(child.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('points a managed profile at its own directories', async () => { + const launch = await resolver( + openCodeRecord({ kind: 'opencode', locator: { kind: 'managed', managedProfileId: PROFILE } }) + )({ identity }) + expect(launch.env).toMatchObject({ + XDG_DATA_HOME: `/profiles/${PROFILE}/data`, + XDG_STATE_HOME: `/profiles/${PROFILE}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + }) + }) + + it("restores the user's own config directory over Orca's status overlay", async () => { + const inheritedEnv = { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/home/user/.config/opencode-mine', + ORCA_OPENCODE_AGENT: 'opencode' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + expect(launch.env.OPENCODE_CONFIG_DIR).toBe('/home/user/.config/opencode-mine') + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + inheritedEnv, + 'darwin' + ).env + expect(Object.keys(child).filter((key) => key.startsWith('ORCA_OPENCODE_'))).toEqual([]) + expect(child.OPENCODE_CONFIG_DIR).toBe('/home/user/.config/opencode-mine') + }) + + it('drops an inherited overlay with no recorded source, so the default config is read', async () => { + const inheritedEnv = { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + inheritedEnv, + 'darwin' + ).env + expect(child.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(child.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it("drops Orca's retired shared plugin directory an old shell still exports", () => { + const env: Record = { + OPENCODE_CONFIG_DIR: '/orca-data/opencode-hooks/shared', + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } + const removed = scrubOpenCodeAcpEnvironment(env, {}, '/orca-data') + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(removed).toEqual( + expect.arrayContaining(['OPENCODE_CONFIG_DIR', 'ORCA_DATA_ACCOUNT_PROVIDER']) + ) + }) + + it("keeps a config directory the user set for OpenCode's launches", async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + launchEnv: { OPENCODE_CONFIG_DIR: '/home/user/team-config' }, + inheritedEnv: { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay' + } + })({ identity }) + expect(launch.env.OPENCODE_CONFIG_DIR).toBe('/home/user/team-config') + expect(launch.envToDelete).not.toContain('OPENCODE_CONFIG_DIR') + }) + + it('refuses a record that pins a single directory instead of an OpenCode account', async () => { + await expect( + resolver(openCodeRecord({ variable: 'XDG_DATA_HOME', path: '/data' }))({ identity }) + ).rejects.toThrow(/pinned data account/) + }) + + it('refuses an `opencode` that is 2.x before spawning it, as a host that cannot run the chat', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { + probeVersion: async (_input, supports) => supports('2.0.21') + })({ identity }) + await expect(launch).rejects.toMatchObject({ + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) + }) + + it('refuses when the version cannot be read', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { 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(openCodeRecord(UNMANAGED), { + probeVersion: async (input, supports) => (asked.push(input), supports('1.18.31')) + })({ identity }) + expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index dbe372495a0a..8c13e2f3e620 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -14,7 +14,13 @@ import { type AgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + resolveStructuredAgentCommand, + type StructuredAgentCommandSettings +} from '../native-chat/structured-agent-command-resolution' +import { probeAgentCliVersion } from '../agent-cli-version-probe' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import { @@ -32,6 +38,8 @@ export type AcpStructuredLaunch = { args: string[] cwd: string env: Record + /** Keys the child must not inherit from this process's own environment. */ + envToDelete: string[] fullAccess: boolean /** The provider session to load; null starts a new one. */ resume: { @@ -58,8 +66,52 @@ export type AcpStructuredLaunchResolverDeps = { resolveLaunchEnv?: (agent: string) => Record /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ resolveFullAccess?: (agent: string) => boolean + /** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */ + resolveCommandSettings?: () => StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand homePath?: string + /** The environment the child process inherits at spawn; this process's own by default. */ + inheritedEnv?: NodeJS.ProcessEnv + probeVersion?: typeof probeAgentCliVersion +} + +/** The binary a launch with `env` spawns: the user's Command setting when set, else the stock + * binary from PATH, then the agent's own install directories. A create's version check resolves + * through here too, so it asks about the same file. */ +export function resolveAcpLaunchCommand( + spec: AcpLaunchSpec, + env: Readonly>, + options: { + commandSettings?: StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + homePath?: string + } = {} +): string { + const homePath = env.HOME ?? env.USERPROFILE ?? options.homePath ?? homedir() + const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories({ env, homePath })] + .filter((entry): entry is string => Boolean(entry)) + .join(delimiter) + return resolveStructuredAgentCommand( + spec.agent, + options.commandSettings ?? {}, + { pathEnv, homePath }, + { + command: spec.command, + ...(options.resolveCommand ? { resolve: options.resolveCommand } : {}) + } + ) +} + +/** Whether `command` is a release the spec runs a structured chat on; true when it names none. */ +export async function acpLaunchVersionSupported( + spec: AcpLaunchSpec, + launch: { command: string; cwd: string; env: Record }, + probe: typeof probeAgentCliVersion = probeAgentCliVersion +): Promise { + return ( + !spec.supportsVersion || + probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, spec.supportsVersion) + ) } export function createAcpStructuredLaunchResolver( @@ -82,33 +134,33 @@ export function createAcpStructuredLaunchResolver( `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` ) } - if (accountHome.variable !== spec.accountHomeVariable) { - throw new Error( - `${spec.agent} sessions pin ${spec.accountHomeVariable}, not ${accountHome.variable}` - ) - } const base = await deps.resolveEnvironment() - const env: Record = { + const env: Record = spec.account.environment(accountHome, { ...base, - ...deps.resolveLaunchEnv?.(spec.agent), - ...spec.env, - [spec.accountHomeVariable]: accountHome.path - } - const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] - .filter((entry): entry is string => Boolean(entry)) - .join(delimiter) - const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { - pathEnv, - homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() + ...deps.resolveLaunchEnv?.(spec.agent) }) + const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] + Object.assign(env, spec.env) + const command = resolveAcpLaunchCommand(spec, env, { + ...deps, + ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) + }) + const cwd = await deps.resolveWorkspacePath(location.workspaceId) + // Again at every launch: the binary on PATH may have changed since the chat was created. + if (!(await acpLaunchVersionSupported(spec, { command, cwd, env }, deps.probeVersion))) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false const head = agentSessionProviderHandleChainHead(record.providerHandleChain) return { spec, command, args: spec.args({ fullAccess }), - cwd: await deps.resolveWorkspacePath(location.workspaceId), + cwd, env, + envToDelete, fullAccess, resume: head ? { diff --git a/src/main/acp/acp-structured-opencode-recordings.test.ts b/src/main/acp/acp-structured-opencode-recordings.test.ts new file mode 100644 index 000000000000..36a1105697cc --- /dev/null +++ b/src/main/acp/acp-structured-opencode-recordings.test.ts @@ -0,0 +1,129 @@ +// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31): each +// recording plays back as the agent, and the assertions read the journal a client would see. + +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('opencode')!, + 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 rowsOf(rig: AcpAdapterRig, kind: string) { + return (await rig.rig.rows()).filter((row) => row.body.kind === kind) +} + +async function answer(rig: AcpAdapterRig, replay: GrokFixtureReplay, optionId: string, index = 0) { + const approval = await waitFor(async () => { + const found = (await rowsOf(rig, 'approval'))[index] + 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 }, + fence: 1, + commit: async () => {} + }) + return approval +} + +describe('OpenCode recordings through the adapter', () => { + it('an approved shell command lands with its exit code, and "always" says it lasts until OpenCode restarts', async () => { + const { rig, replay } = await replaying('opencode-v1-tool') + const approval = await answer(rig, replay, 'once') + expect(approval.body).toMatchObject({ + kind: 'approval', + options: [ + { id: 'once', label: 'Allow once' }, + { id: 'always', label: 'Allow until OpenCode restarts' }, + { id: 'reject', label: 'Reject' } + ] + }) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const shell = (await rowsOf(rig, 'tool-call')).find( + (row) => row.body.kind === 'tool-call' && row.body.exitCode !== undefined + ) + expect(shell?.body).toMatchObject({ state: 'completed', exitCode: 0 }) + }) + + it('a rejected permission fails its tool and the turn still ends', async () => { + const { rig, replay } = await replaying('opencode-v1-deny') + await answer(rig, replay, 'reject') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) + expect((await rowsOf(rig, 'tool-call')).at(-1)?.body).toMatchObject({ state: 'failed' }) + }) + + it("a subagent's approval never arrives (a known OpenCode bug); Stop still ends the turn", async () => { + const { rig, replay } = await replaying('opencode-v1-subagent-hang') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + expect(await rowsOf(rig, 'approval')).toEqual([]) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + }) + + it("OpenCode's stray file write after an approved edit is refused without breaking the turn", async () => { + const { rig, replay } = await replaying('opencode-v1-stray-write') + await answer(rig, replay, 'once', 0) + await answer(rig, replay, 'reject', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) + // The write OpenCode repeats to a client that took no file system leaves no row. + expect(JSON.stringify((await rig.rig.rows()).map((row) => row.body))).not.toContain( + 'fs/write_text_file' + ) + }) + + it("a provider error fails the turn in OpenCode's own words", async () => { + const { rig, replay } = await replaying('opencode-v1-provider-error') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ outcome: 'failure' }])) + const texts = JSON.stringify((await rig.rig.rows()).map((row) => row.body)) + expect(texts).toContain('CAPTURE_PROVIDER_400') + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 3f59f4db3e4a..f8605b12daf4 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -5,6 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import type { AcpLaunchSpec } from './acp-launch-specs' import type { ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' @@ -17,6 +18,8 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal?: (sessionId: string) => JournalLoad | null /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ connect: ConnectAcpAgent readProcessStartTime?: (pid: number) => Promise diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index bb845eb429fb..e20058bfd726 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -421,6 +421,7 @@ describe('ACP startup that never answers', () => { args: [], cwd: '/workspace/project', env: {}, + envToDelete: [], fullAccess: false, resume: null } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index b0dd9804b5ea..e1fd1f317151 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -30,14 +30,15 @@ import { import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' import { waitForAcpExit } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' -import { acpPromptBlocks } from './acp-structured-turns' import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, ACP_STOP_GRACE_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { writeAcpSessionOption } from './acp-structured-options' +import { readAcpRecoveryHistory } from './acp-recovery-history' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ @@ -46,6 +47,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ + providerHistoryWindow: NonNullable = ({ + identity + }) => readAcpRecoveryHistory(this.deps, identity) + // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) @@ -142,9 +148,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return this.rejected(lost, 'providerExited') } const session = this.live(input.sessionId) - const prompt = acpPromptBlocks(input.body) - if (!prompt) { - return this.rejected(session, 'attachmentInvalid') + const prompt = await acpDispatchPrompt(input.body, session) + if (!Array.isArray(prompt)) { + return { state: 'rejected', ...prompt } + } + if (this.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { + // The child ended while its attachments were read: nothing left Orca. + return this.rejected(session, 'providerExited') } await input.beforeDispatch?.() session.turns.dispatch({ @@ -312,7 +322,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private rejected( session: AcpStructuredSession, - kind: 'providerExited' | 'attachmentInvalid' + kind: 'providerExited' ): AgentSessionDispatchOutcome { return { state: 'rejected', diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index df5495f65edc..4bbf773ea51f 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -17,10 +17,7 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' @@ -32,19 +29,6 @@ export type AcpDispatchSettlement = { clientMessageId: string } & ( | { state: 'unknown'; reason: string } ) -/** A person's message as an ACP prompt; null when it carries what the agent cannot take. */ -export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { - const blocks: ContentBlock[] = [] - for (const block of body.blocks) { - if (block.type !== 'text') { - // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. - return null - } - blocks.push({ type: 'text', text: block.text }) - } - return blocks -} - type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index ae9eac456e47..c611cfabbb99 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -20,7 +20,8 @@ const frameSchema = z.object({ id: z.union([z.string(), z.number()]).optional(), method: z.string().optional(), params: z.unknown().optional(), - result: z.unknown().optional() + result: z.unknown().optional(), + error: z.unknown().optional() }) }) export type AcpFixtureFrame = z.infer diff --git a/src/main/acp/acp-timeline-opencode-dialect.test.ts b/src/main/acp/acp-timeline-opencode-dialect.test.ts new file mode 100644 index 000000000000..ac1b8313f3f9 --- /dev/null +++ b/src/main/acp/acp-timeline-opencode-dialect.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function openCodeRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + dialect: OPENCODE_ACP_DIALECT + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (body: Record, at: number) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update: body }, at)) + apply(translator.openPrompt('send-1', 1000).events) + return { rig, translator, apply, update } +} + +// The shapes OpenCode's ACP server writes for a shell tool: its output and exit code ride under +// `rawOutput.metadata`, beside the text content. +const bashStart = { + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'List files', + kind: 'execute', + status: 'pending', + rawInput: { command: 'ls missing', description: 'List files' } +} + +describe('OpenCode over ACP', () => { + it("reads a shell tool's exit code and output from OpenCode's metadata", async () => { + const { rig, update } = await openCodeRig() + update(bashStart, 1001) + update( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + rawOutput: { + output: 'ls: missing: No such file or directory\n', + metadata: { output: 'ls: missing: No such file or directory\n', exit: 1 } + } + }, + 1002 + ) + const tool = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + expect(tool).toMatchObject({ + kind: 'tool-call', + state: 'completed', + exitCode: 1, + output: { head: 'ls: missing: No such file or directory\n' } + }) + }) + + it('keeps a shared exit code the agent already reported', () => { + const normalized = OPENCODE_ACP_DIALECT.normalizeToolUpdate!({ + toolCallId: 'call-1', + rawOutput: { exitCode: 0, metadata: { exit: 3 } } + }) + expect(normalized.rawOutput).toEqual({ exitCode: 0, metadata: { exit: 3 } }) + }) + + it('leaves a tool with no command metadata as it came', () => { + const plain = { toolCallId: 'call-2', rawOutput: 'done' } + expect(OPENCODE_ACP_DIALECT.normalizeToolUpdate!(plain)).toBe(plain) + }) + + it('says that "always" lasts only until OpenCode restarts', async () => { + const { translator, apply, update } = await openCodeRig() + update(bashStart, 1001) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'ls missing', kind: 'execute' }, + options: [ + { optionId: 'once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'always', kind: 'allow_always', name: 'Always allow' }, + { optionId: 'reject', kind: 'reject_once', name: 'Reject' } + ] + }, + 0 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + options: [ + { id: 'once', label: 'Allow once' }, + { id: 'always', label: 'Allow until OpenCode restarts' }, + { id: 'reject', label: 'Reject' } + ] + }) + expect(request.presentation?.reply({ kind: 'option', optionId: 'always' })).toEqual({ + outcome: { outcome: 'selected', optionId: 'always' } + }) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 176967409c9e..4a3cecdf0103 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -16,7 +16,10 @@ export const pendingAcpResolution = { resolvedAt: null } as const -export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { +export function acpPermissionPresentation( + params: unknown, + dialect: AcpDialect +): AcpRequestPresentation { // The runtime already read this request and reported any field it dropped. const request = readAcpPermissionRequest(params, () => {}) if (!request) { @@ -28,7 +31,10 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati kind: 'approval', title: toolCall.title ?? 'Permission requested', detail: null, - options: options.map((option) => ({ id: option.optionId, label: option.name })), + options: options.map((option) => ({ + id: option.optionId, + label: dialect.permissionOptionLabel?.(option) ?? option.name + })), resolution: pendingAcpResolution }, reply: (response) => { @@ -47,6 +53,7 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati } const requestSessionSchema = z.object({ sessionId: z.string() }) +const UNADVERTISED_CLIENT_METHOD = /^(?:fs|terminal)\// const requestToolSchema = z.object({ toolCallId: z.string().optional(), toolCall: z.object({ toolCallId: z.string() }).optional() @@ -102,9 +109,14 @@ export function translateAcpRequest( } const presentation = method === 'session/request_permission' - ? acpPermissionPresentation(params) + ? acpPermissionPresentation(params, options.dialect) : options.dialect.request?.(method, params) if (!presentation) { + // A file system or terminal call Orca never advertised is the agent's own misstep (OpenCode 1.x + // repeats each approved edit as a file write it already made): refused, with no row. + if (UNADVERTISED_CLIENT_METHOD.test(method)) { + return { events: [] } + } return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 28762bc4646d..f6b4a22df2f7 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -19,6 +19,7 @@ import type { AcpDialect } from './acp-dialects/acp-dialect' import type { ToolCallUpdate } from './generated/acp-protocol.generated' const rawStreamsSchema = z.object({ stdout: z.string().optional(), stderr: z.string().optional() }) +const rawExitCodeSchema = z.looseObject({ exitCode: z.number().int().safe() }) function outputText(update: ToolCallUpdate): string | undefined { const text = update.content?.flatMap((block) => @@ -89,12 +90,14 @@ export class AcpToolTimeline { }) translate( - update: ToolCallUpdate, + raw: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { + const update = dialect.normalizeToolUpdate?.(raw) ?? raw const previous = this.tools.get(update.toolCallId) const output = outputText(update) + const exitCode = rawExitCodeSchema.safeParse(update.rawOutput).data?.exitCode const state = update.status === 'completed' ? 'completed' @@ -118,6 +121,7 @@ export class AcpToolTimeline { ? (previous?.body.input ?? null) : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), state, + ...(exitCode === undefined ? {} : { exitCode }), ...(output === undefined ? previous?.body.output ? { output: previous.body.output } diff --git a/src/main/acp/fixtures/opencode-v1-deny.jsonl b/src/main/acp/fixtures/opencode-v1-deny.jsonl new file mode 100644 index 000000000000..11fd57bd2117 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-deny.jsonl @@ -0,0 +1,21 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the write or edit tool to create denied.txt containing exactly DENY-ME. If permission is denied, stop and report it; do not retry via another tool."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":"The"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" user wants me to"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" create denied.txt"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" with content"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" DENY-ME. Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" me just do"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" it.\n\nBut wait -"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" there's a permission"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" system."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" Let me try the write"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" tool. If denied, stop"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" and report."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","title":"write","kind":"edit","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","status":"in_progress","kind":"edit","title":"write","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filePath":"/workspace/project/denied.txt","content":"DENY-ME"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","title":"/workspace/project/denied.txt","kind":"edit","status":"pending","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filepath":"/workspace/project/denied.txt","diff":"Index: /workspace/project/denied.txt\n===================================================================\n--- /workspace/project/denied.txt\n+++ /workspace/project/denied.txt\n@@ -0,0 +1,1 @@\n+DENY-ME\n\\ No newline at end of file\n"},"content":[{"type":"diff","path":"/workspace/project/denied.txt","oldText":"","newText":"DENY-ME"}]},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","status":"failed","kind":"edit","title":"write","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filePath":"/workspace/project/denied.txt","content":"DENY-ME"},"content":[{"type":"content","content":{"type":"text","text":"The user rejected permission to use this specific tool call."}}],"rawOutput":{"error":"The user rejected permission to use this specific tool call."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10390,"size":1000000,"cost":{"amount":0.00013182,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":150,"outputTokens":87,"totalTokens":10521,"thoughtTokens":44,"cachedReadTokens":10240},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-provider-error.jsonl b/src/main/acp/fixtures/opencode-v1-provider-error.jsonl new file mode 100644 index 000000000000..612772542c55 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-provider-error.jsonl @@ -0,0 +1,2 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Reply OK."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"error":{"code":-32603,"message":"Internal error: CAPTURE_PROVIDER_400","data":{"service":"session","errorName":"APIError"}}}} diff --git a/src/main/acp/fixtures/opencode-v1-stray-write.jsonl b/src/main/acp/fixtures/opencode-v1-stray-write.jsonl new file mode 100644 index 000000000000..8249b591c0ed --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-stray-write.jsonl @@ -0,0 +1,30 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"First use the edit tool to change edit.txt from before to approved. After that succeeds, run bash with exactly: printf \"STRAY\\n\" > stray.txt. Do not combine these actions. If the shell write permission is denied, stop and report it."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":"Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" me first"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" read edit.txt. Actually"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" the edit"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" tool requires reading first. Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" me read it."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","title":"read","kind":"read","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","status":"in_progress","kind":"read","title":"read","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","status":"completed","title":"/workspace/project/edit.txt","content":[{"type":"content","content":{"type":"text","text":"before"}}],"rawOutput":{"output":"/workspace/project/edit.txt\nfile\n\n1: before\n\n(End of file - total 1 lines)\n","metadata":{"preview":"before","truncated":false,"loaded":[],"display":{"type":"file","path":"/workspace/project/edit.txt","text":"before","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":"The"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" instructions say change"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" \"before\""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" to \"approved\"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","title":"edit","kind":"edit","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"in_progress","kind":"edit","title":"edit","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt","oldString":"before","newString":"approved"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","title":"/workspace/project/edit.txt","kind":"edit","status":"pending","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filepath":"/workspace/project/edit.txt","diff":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n"},"content":[{"type":"diff","path":"/workspace/project/edit.txt","oldText":"before\n","newText":"approved\n"}]},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"fs/write_text_file","params":{"sessionId":"session-1","path":"/workspace/project/edit.txt","content":"approved\n"}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: fs/write_text_file"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"in_progress","kind":"edit","title":"edit","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt","oldString":"before","newString":"approved"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"completed","title":"/workspace/project/edit.txt","content":[{"type":"content","content":{"type":"text","text":"Edit applied successfully."}},{"type":"diff","path":"/workspace/project/edit.txt","oldText":"before","newText":"approved"}],"rawOutput":{"output":"Edit applied successfully.","metadata":{"diagnostics":{},"diff":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n","filediff":{"file":"/workspace/project/edit.txt","patch":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n","additions":1,"deletions":1},"truncated":false}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/workspace/project"}],"rawInput":{"cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","status":"in_progress","kind":"execute","title":"printf \"STRAY\\n\" > stray.txt","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","title":"printf \"STRAY\\n\" > stray.txt","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","status":"failed","kind":"execute","title":"printf \"STRAY\\n\" > stray.txt","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt","cwd":"/workspace/project"},"content":[{"type":"content","content":{"type":"text","text":"The user rejected permission to use this specific tool call."}}],"rawOutput":{"error":"The user rejected permission to use this specific tool call."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10690,"size":1000000,"cost":{"amount":0.000322062,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":194,"outputTokens":45,"totalTokens":10735,"cachedReadTokens":10496},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl b/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl new file mode 100644 index 000000000000..1c176531db2e --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl @@ -0,0 +1,13 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the Task tool to launch one general subagent. The subagent must create child.txt in this project containing CHILD-OK, using a write or edit tool. Do not perform the edit yourself. Let the subagent finish, then reply briefly. Do not launch other subagents."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":"I'll launch"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" one"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" general subagent to create the"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" file."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","title":"task","kind":"think","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"in_progress","kind":"think","title":"Create child.txt","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"in_progress","kind":"think","title":"Create child.txt","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"failed","kind":"think","title":"task","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"},"content":[{"type":"content","content":{"type":"text","text":"Task cancelled"}}],"rawOutput":{"error":"Task cancelled","metadata":{"parentSessionId":"session-1","sessionId":"session-2","model":{"modelID":"deepseek-v4.1-flash","providerID":"opencode-go"}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":0,"size":1000000,"cost":{"amount":0.00016167,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","usage":{"inputTokens":0,"outputTokens":0,"totalTokens":0},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-tool.jsonl b/src/main/acp/fixtures/opencode-v1-tool.jsonl new file mode 100644 index 000000000000..4fec143af031 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-tool.jsonl @@ -0,0 +1,40 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Read README.md using the read tool. Then run the bash tool with exactly: printf \"TOOL-OK\\n\". Reply briefly with the two markers. Do not inspect any other paths."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" me read README.md and"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" then"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" run the bash command. Per"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" instructions, read first"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":", then bash."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" Can I batch"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"? The bash"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" says"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" run with exactly"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" printf \""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"TOOL-OK\\n\"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" me do the"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" read first, then bash"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":".\n\nActually I can do both in"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" parallel since independent"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":". But the instruction says \""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"Then run"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"\". Let me just"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" do them in"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" one"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" message"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","title":"read","kind":"read","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","status":"in_progress","kind":"read","title":"read","locations":[{"path":"/workspace/project/README.md"}],"rawInput":{"filePath":"/workspace/project/README.md"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/workspace/project"}],"rawInput":{"cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","status":"completed","title":"/workspace/project/README.md","content":[{"type":"content","content":{"type":"text","text":"Protocol capture scratch project. Read marker: CAPTURE-OK."}}],"rawOutput":{"output":"/workspace/project/README.md\nfile\n\n1: Protocol capture scratch project. Read marker: CAPTURE-OK.\n\n(End of file - total 1 lines)\n","metadata":{"preview":"Protocol capture scratch project. Read marker: CAPTURE-OK.","truncated":false,"loaded":[],"display":{"type":"file","path":"/workspace/project/README.md","text":"Protocol capture scratch project. Read marker: CAPTURE-OK.","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","title":"printf \"TOOL-OK\\n\"","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"printf \"TOOL-OK\\n\""}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"},"content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"completed","title":"printf \"TOOL-OK\\n\"","content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}],"rawOutput":{"output":"TOOL-OK\n","metadata":{"output":"TOOL-OK\n","exit":0,"truncated":false}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125cb49c001tmofdzWc1JiS6i","content":{"type":"text","text":"CAPTURE-OK"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125cb49c001tmofdzWc1JiS6i","content":{"type":"text","text":"\nTOOL-OK"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10669,"size":1000000,"cost":{"amount":0.00148533,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":301,"outputTokens":11,"totalTokens":10680,"cachedReadTokens":10368},"_meta":{}}}} diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts new file mode 100644 index 000000000000..33d9537dd4f0 --- /dev/null +++ b/src/main/agent-cli-version-probe.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { isStableCliVersionOnLine, probeAgentCliVersion } from './agent-cli-version-probe' + +const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess })) + +const INPUT = { program: '/opt/bin/agent', cwd: '/repo', env: { PATH: '/opt/bin' } } +const anyVersion = (): boolean => true + +function prints(stdout: string, result: Record = {}): void { + runProcess.mockResolvedValue({ + code: 0, + signal: null, + stdout, + stderr: '', + timedOut: false, + ...result + }) +} + +describe('probeAgentCliVersion', () => { + beforeEach(() => runProcess.mockReset()) + + it('asks the program for its version, bounded, where and as the launch would run it', async () => { + prints('1.18.31\n') + const seen: string[] = [] + expect(await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true))).toBe(true) + expect(seen).toEqual(['1.18.31']) + expect(runProcess).toHaveBeenCalledWith({ + ...INPUT, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + }) + + it('reads a version printed with a name or a v prefix', async () => { + prints('opencode v2.0.21\n') + const seen: string[] = [] + await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) + expect(seen).toEqual(['2.0.21']) + }) + + it.each([ + ['a non-zero exit', { code: 1 }], + ['a timeout', { timedOut: true }], + ['oversized output', { outputTruncated: true }] + ])('cannot tell after %s', async (_label, result) => { + prints('1.18.31\n', result) + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + }) + + it('cannot tell when the output names no version, or the program does not start', async () => { + prints('usage: agent [options]\n') + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + runProcess.mockImplementationOnce(() => { + throw new Error('spawn ENOENT') + }) + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + }) +}) + +describe('probeAgentCliVersion refusal', () => { + beforeEach(() => runProcess.mockReset()) + + it('says why it refused, with what the binary printed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + prints('', { code: 1, stderr: 'Error: EACCES mkdir /x\n' }) + await probeAgentCliVersion(INPUT, anyVersion) + prints('2.0.21\n') + await probeAgentCliVersion(INPUT, () => false) + expect(warn.mock.calls.map(([line]) => line)).toEqual([ + '[agent-cli-version] /opt/bin/agent --version: exited 1: Error: EACCES mkdir /x', + '[agent-cli-version] /opt/bin/agent --version: 2.0.21 is not a supported release' + ]) + warn.mockRestore() + }) +}) + +describe('isStableCliVersionOnLine', () => { + const line = { major: 1, floor: '1.18.31' } + + it.each([ + ['1.18.31', true], + ['1.19.2', true], + ['1.18.30', false], + ['2.0.21', false], + ['1.18.31-beta.1', false], + ['11.0.0', false] + ])('%s -> %s', (version, supported) => { + expect(isStableCliVersionOnLine(version, line)).toBe(supported) + }) +}) diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts new file mode 100644 index 000000000000..b5f2a312a780 --- /dev/null +++ b/src/main/agent-cli-version-probe.ts @@ -0,0 +1,66 @@ +import { + hasReachedAppVersion, + isPrereleaseAppVersion, + parseCliVersion +} from '../shared/app-version' +import { runProcess, type ProcessSpec } from '../shared/child-process/run-process' + +/** One line naming why a probe said no; the binary's own words, bounded. */ +function warnRefused(program: string, why: string, output = ''): void { + const said = output.trim().replace(/\s+/g, ' ').slice(0, 200) + console.warn(`[agent-cli-version] ${program} --version: ${why}${said ? `: ${said}` : ''}`) +} + +/** Runs ` --version` on this host and asks `supports` about the version it prints. False + * when it cannot tell: a failed spawn, a non-zero exit, a timeout, or output with no version. */ +export async function probeAgentCliVersion( + input: Pick, + supports: (version: string) => boolean +): Promise { + let result + try { + result = await runProcess({ + ...input, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + } catch (error) { + warnRefused(input.program, `did not start (${String(error)})`) + return false + } + const output = `${result.stdout}\n${result.stderr}` + if (result.timedOut || result.outputTruncated || result.code !== 0) { + const why = result.timedOut + ? 'timed out' + : result.outputTruncated + ? 'printed too much' + : `exited ${result.code ?? result.signal}` + warnRefused(input.program, why, output) + return false + } + const version = parseCliVersion(result.stdout) + if (version === null) { + warnRefused(input.program, 'printed no version', output) + return false + } + if (!supports(version)) { + warnRefused(input.program, `${version} is not a supported release`) + return false + } + console.info(`[agent-cli-version] ${input.program} --version: ${version} is supported`) + return true +} + +/** 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) + ) +} diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 3b5794a0ba74..642e59914148 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -55,7 +55,8 @@ import { resolveAgentLaunchModeOnHost, type AgentLaunchModeReceipt, type AgentLaunchModeVocabulary, - DEFAULT_LAUNCH_VOCABULARY + DEFAULT_LAUNCH_VOCABULARY, + warnStructuredLaunchDowngrade } from './agent-launch-mode' import { AgentLaunchStructuredSessionRefusedError, @@ -212,6 +213,7 @@ function published( execution: AgentLaunchExecution, surface: AgentLaunchPublishedSurface ): AgentLaunchPublishedSurface { + warnStructuredLaunchDowngrade(execution.intent.agent, surface.receipt) execution.onSurfacePublished?.(surface) return surface } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 2a96c4f58088..be3837f97b6b 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -256,6 +256,16 @@ function downgraded( } } +/** One main-log line when a launch the user's default asked to be a chat opened a terminal. */ +export function warnStructuredLaunchDowngrade( + agent: string, + receipt: AgentLaunchModeReceipt +): void { + if (receipt.mode === 'terminal' && receipt.preferred === 'structured') { + console.warn(`[agent-launch] ${agent} opened a terminal: ${receipt.reason}`) + } +} + /** The store can be missing on a runtime that never opened one; that reads as no preference. */ export function readAgentLaunchModeSettings( runtime: Pick diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts index 02bf2fc17a05..47d4198a4fac 100644 --- a/src/main/claude/claude-at-rest-commands.ts +++ b/src/main/claude/claude-at-rest-commands.ts @@ -1,5 +1,9 @@ import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + isLegacyAgentSessionAccountHome, + requireLegacyAgentSessionAccountHome +} from '../../shared/agent-session-account-home' import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' import { structuredSlashCommands } from '../../shared/structured-agent-session-composer' import { discoverSkills } from '../skills/discovery' @@ -45,7 +49,11 @@ export class ClaudeAtRestCommandCatalog { read = (record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined => { // Another host's folders are only readable there, so this host never answers for them. - if (record.provider !== 'claude' || !supportsClaudeStructuredLocation(record.location)) { + if ( + record.provider !== 'claude' || + !supportsClaudeStructuredLocation(record.location) || + !isLegacyAgentSessionAccountHome(record.accountHome) + ) { return undefined } const key = JSON.stringify([record.location.workspaceId, record.accountHome.path]) @@ -115,7 +123,7 @@ export class ClaudeAtRestCommandCatalog { const cwd = agentSessionPinnedLaunchDirectory(record) ?? (await this.deps.resolveWorkspacePath(record.location.workspaceId)) - const account = record.accountHome.path + const account = requireLegacyAgentSessionAccountHome(record.accountHome).path const [custom, discovered] = await Promise.all([ scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), (this.deps.discover ?? discoverSkills)({ diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index 991d681c988c..447ecb79a3be 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -1,9 +1,9 @@ -import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' -export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { +export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'claude', handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, accountHomeVariable: 'CLAUDE_CONFIG_DIR', diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 508d698beeb3..85bdc54a2c20 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,4 +1,5 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -231,9 +232,10 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } + const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) const pinned = CLAUDE_STRUCTURED_AGENT.accountHomeVariable - if (record.accountHome.variable !== pinned) { - throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) + if (accountHome.variable !== pinned) { + throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`) } // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. @@ -289,7 +291,7 @@ export function createClaudeStructuredLaunchResolver( hasTranscript: deps.hasTranscript })) // Why: without a router the home is fixed, so check it before the recheck that must stay last. - const resumedWithoutRouter = router ? undefined : await resumes(record.accountHome.path) + const resumedWithoutRouter = router ? undefined : await resumes(accountHome.path) // Last: it rechecks the account switch, which may have begun during any await above. const { command, env } = await resolveClaudeStructuredInvocation( deps, @@ -308,7 +310,7 @@ export function createClaudeStructuredLaunchResolver( ), sources ) - const launchHome = await resolveClaudeStructuredLaunchHome(router, env, record.accountHome.path) + const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path) const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e79f3cab5d25..6f8bc901989e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -131,7 +132,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) => resolveClaudeProviderHistoryWindow({ identity: input.identity, - accountHomePath: input.accountHome.path, + accountHomePath: requireLegacyAgentSessionAccountHome(input.accountHome).path, hasLiveSession: this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) }) diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 317acdbd3a35..1f06c603000b 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,8 +1,8 @@ -import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' -export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { +export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'codex', handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, accountHomeVariable: 'CODEX_HOME', diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 3cc28a0a4aec..8016c938ae83 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -7,6 +7,7 @@ // for, which is how a resume becomes a fork wearing a resume's name. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' @@ -70,7 +71,8 @@ export function createCodexStructuredLaunchResolver( if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) } - const { location, accountHome } = record + const { location } = record + const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) if (record.provider !== 'codex') { throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) } diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index bc96800a5aa6..dc8394e4e014 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -52,6 +52,27 @@ function openCodeSource(sessionTable = 'session'): void { } describe('managed data accounts', () => { + it('resolves a pinned OpenCode profile by ID after selection changes', async () => { + openCodeSource() + const first = (await service.add('opencode', source, 'First')).activeAccountId + if (!first) { + throw new Error('Expected selected first profile') + } + const original = service.environmentForAccount('opencode', first) + const second = (await service.add('opencode', source, 'Second')).activeAccountId + if (!second) { + throw new Error('Expected selected second profile') + } + expect(service.launchEnvironment('opencode')).toEqual( + service.environmentForAccount('opencode', second) + ) + expect(service.environmentForAccount('opencode', first)).toEqual(original) + await service.remove('opencode', first) + expect(() => service.environmentForAccount('opencode', first)).toThrow( + 'Managed account not found.' + ) + }) + it.each(['before write', 'after write', 'unrestricted'])( 'preserves credentials and original metadata when removal persistence fails %s', async (failure) => { diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 749fb023876e..7335a72f2696 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -152,14 +152,16 @@ export class ManagedDataAccountService { if (!state.activeAccountId) { return {} } - return this.profileEnvironment(provider, state.activeAccountId) + return this.environmentForAccount(provider, state.activeAccountId) } transcriptEnvironments(provider: ManagedDataAccountProvider): Record[] { const state = this.list(provider) const selected = state.accounts.filter((account) => account.id === state.activeAccountId) const others = state.accounts.filter((account) => account.id !== state.activeAccountId) - return [...selected, ...others].map((account) => this.profileEnvironment(provider, account.id)) + return [...selected, ...others].map((account) => + this.environmentForAccount(provider, account.id) + ) } captureOriginalEnvironment( @@ -217,7 +219,7 @@ export class ManagedDataAccountService { this.inlineAuthBaselines.clear() } - private profileEnvironment( + environmentForAccount( provider: ManagedDataAccountProvider, accountId: string ): Record { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index d59f0d124c33..fcd20b489312 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,7 +4,10 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' -import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../../shared/agent-session-account-home' /** * Everything that changes which models a listing can answer with: the agent, @@ -14,22 +17,22 @@ import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stor */ export type AgentModelCatalogIdentity = { agent: string - accountHomeVariable: string - accountHomePath: string /** Null on the native host; WSL distros each carry their own CLI. */ wslDistro: string | null -} +} & ( + | { accountHomeVariable: string; accountHomePath: string; accountHome?: never } + | { accountHome: AgentSessionAccountHome; accountHomeVariable?: never; accountHomePath?: never } +) export function agentModelCatalogFingerprint(identity: AgentModelCatalogIdentity): string { + const account = identity.accountHome + const parts = account + ? isLegacyAgentSessionAccountHome(account) + ? [account.variable, account.path] + : [account.kind, account.locator] + : [identity.accountHomeVariable, identity.accountHomePath] return createHash('sha256') - .update( - JSON.stringify([ - identity.agent, - identity.accountHomeVariable, - identity.accountHomePath, - identity.wslDistro ?? '' - ]) - ) + .update(JSON.stringify([identity.agent, ...parts, identity.wslDistro ?? ''])) .digest('hex') } @@ -40,8 +43,7 @@ export function agentModelCatalogIdentityForRecord( ): AgentModelCatalogIdentity { return { agent: record.provider, - accountHomeVariable: record.accountHome.variable, - accountHomePath: record.accountHome.path, + accountHome: record.accountHome, wslDistro: record.location.wslDistro } } @@ -56,7 +58,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: Pick, + agent: { agent: string; accountHomeVariable: string }, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 3cba2c9e6a64..b56dc291d442 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -3,6 +3,7 @@ import type { AgentSessionAccountHome, AgentSessionRecord } from '../../../shared/agent-session-record' +import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -102,7 +103,10 @@ export function createAgentModelCatalogService( if (scoped) { fingerprint = agentModelCatalogFingerprintForRecord(scoped) // Probes spawn natively; a WSL-pinned record has no host-side lister. - accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null + accountHomePath = + scoped.location.wslDistro === null && isLegacyAgentSessionAccountHome(scoped.accountHome) + ? scoped.accountHome.path + : null } else { let resolved: AgentSessionAccountHome try { @@ -112,11 +116,10 @@ export function createAgentModelCatalogService( } fingerprint = agentModelCatalogFingerprint({ agent: params.agent, - accountHomeVariable: resolved.variable, - accountHomePath: resolved.path, + accountHome: resolved, wslDistro: null }) - accountHomePath = resolved.path + accountHomePath = isLegacyAgentSessionAccountHome(resolved) ? resolved.path : null } let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index f1484bf5da8a..d4f7e543df53 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -77,7 +77,11 @@ function unseenHistory( } return { ...history, - items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) + // An item with no identity of its own cannot be one the journal committed under it; the + // adapter bounds its window to after the journal's last accepted send instead. + items: history.items.filter( + (item) => item.identity === undefined || !committed.has(agentJournalItemKey(item.identity)) + ) } } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 4647ba1e0e37..91522f76efef 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -30,7 +30,9 @@ export type ProviderHistoryItem = { /** Fingerprint of the submitted payload, when the caller can compute one from * provider content. Used only to break an otherwise unique tie. */ payloadFingerprint: string | null - identity: AgentJournalItemIdentity + /** Absent when the provider keeps no identity of its own for the item: a submission it matches + * keeps Orca's (`orca:`), which is the identity its row already has. */ + identity?: AgentJournalItemIdentity } export type ProviderHistoryWindow = { @@ -90,6 +92,7 @@ export function reconcileSubmissions(input: { // A submission that already adopted a key re-matches on that key, not on the // provider's raw id — the raw id renumbers, the identity-derived key does not. Boolean(submission.providerItemId) && + item.identity !== undefined && submission.providerItemId === agentJournalItemKey(item.identity) ) claimBy( @@ -169,7 +172,7 @@ function resolveOne( clientMessageId: submission.clientMessageId, outcome: 'accepted', providerItemId: item.providerItemId, - identity: item.identity + identity: item.identity ?? { provider: 'orca', clientMessageId: submission.clientMessageId } } } if (ambiguous.has(submission.clientMessageId)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index da783ac0749e..5a1e43ce2223 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -21,3 +21,8 @@ export type StructuredAgentDefinition = AgentSessionStoredAgent & { effortDefaultsToModel: boolean } } + +/** An agent whose account is one config directory, as Claude's and Codex's are. */ +export type DirectoryAccountAgentDefinition = StructuredAgentDefinition & { + accountHomeVariable: string +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts index 15f4e6523f0b..58510a7231b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts @@ -1,5 +1,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' type LaunchEnvResolver = ( provider: AgentSessionRecord['provider'] @@ -19,7 +20,9 @@ export async function pinnedAgentSessionLaunchEnv( return { launchEnv: { ...(await resolver(params.provider)), - [params.accountHome.variable]: params.accountHome.path + ...(isLegacyAgentSessionAccountHome(params.accountHome) + ? { [params.accountHome.variable]: params.accountHome.path } + : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index a726affd84dc..9215c4908445 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' +import { agentPinsAccountHome } from '../../../shared/agent-session-account-home' export function adapterSupportsCreate( adapter: StructuredAgentSessionAdapter, @@ -31,7 +32,7 @@ export function adapterSupportsCreateIfDeclared( } /** Whether this build can start the agent of `session`: the agent is registered here, its account - * variable is that agent's own (it becomes the child's environment), and every handle it holds is + * is the kind that agent pins (it becomes the child's environment), and every handle it holds is * in the transport the agent's adapter speaks. A record failing this stays readable (tab, history); * only starting its agent is refused, by the one launch admission every agent passes through. */ export function agentDrivesSession( @@ -41,7 +42,7 @@ export function agentDrivesSession( const definition = agents.definition(session.provider) return ( definition !== null && - session.accountHome.variable === definition.accountHomeVariable && + agentPinsAccountHome(definition, session.accountHome) && session.providerHandleChain.every( ({ handle }) => handle.transport === definition.handleTransport && handle.agent === definition.agent diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index a2fc166a1a33..c8be0c644fca 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -36,6 +36,21 @@ describe('structured agent executable resolution', () => { } ) + it('reads the Command setting of any agent, and its stock binary by the name it is given', () => { + const { command } = executable() + expect( + resolveStructuredAgentCommand('opencode', { agentCmdOverrides: { opencode: `"${command}"` } }) + ).toBe(command) + expect( + resolveStructuredAgentCommand( + 'opencode', + { agentCmdOverrides: { claude: `"${command}"` } }, + { pathEnv: '/nowhere' }, + { command: 'opencode', resolve: (name) => `/stock/${name}` } + ) + ).toBe('/stock/opencode') + }) + it('resolves a basename through the configured PATH and a home-relative path through the host home', () => { const { directory, command } = executable() const basenameCommand = join(directory, 'agent-custom') diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 9cc1f28bd010..828fd031760c 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -10,7 +10,10 @@ import { } from '../../shared/tui-agent-launch-command-override' import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' -type CommandSettings = Partial> +export type StructuredAgentCommandSettings = Partial< + Pick +> +type CommandSettings = StructuredAgentCommandSettings type CommandOptions = NonNullable[1]> function resolveOverride( @@ -35,14 +38,16 @@ function spawnableOn(platform: NodeJS.Platform, command: string): boolean { } /** Re-read the existing setting for every session acquisition and catalog probe. A set Command - * that names no runnable program refuses the start rather than quietly running the stock CLI. */ + * that names no runnable program refuses the start rather than quietly running the stock CLI. + * Without one, `stock` names the agent's own binary (the agent id by default). */ export function resolveStructuredAgentCommand( - agent: 'claude' | 'codex', + agent: TuiAgent, settings: CommandSettings, - options: CommandOptions = {} + options: CommandOptions = {}, + stock: { command?: string; resolve?: typeof resolveCliCommand } = {} ): string { if (!hasExplicitTuiLaunchCommand(settings, agent)) { - return resolveCliCommand(agent, options) + return (stock.resolve ?? resolveCliCommand)(stock.command ?? agent, options) } const command = resolveOverride(agent, settings, options) if (!command || !spawnableOn(options.platform ?? process.platform, command)) { diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index e15e03a1135f..dc2b40ac1e65 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -47,3 +47,24 @@ export function resolveStructuredAgentSessionCreateSupport(input: { } return { supported: true } } + +/** Which create-support check said no: where the chat would run, the installed agent (its binary + * and version on this host), or Claude's managed-account binding. */ +export type StructuredAgentSessionCreateSupportCheck = + | 'location' + | 'installed-agent' + | 'managed-account' + +/** One main-log line per create-support verdict that sends a launch to the terminal; names the + * check and never a path or environment value. */ +export function warnStructuredAgentSessionCreateUnsupported( + agent: StructuredAgentId, + support: StructuredAgentSessionCreateSupport, + check: StructuredAgentSessionCreateSupportCheck +): void { + if (!support.supported) { + console.warn( + `[structured-create-support] ${agent} unsupported: ${check} check refused (reason ${support.reason ?? 'none'})` + ) + } +} diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index 5f7ca25415db..25fadd541e67 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -1,8 +1,11 @@ import { stat } from 'node:fs/promises' -import { basename, isAbsolute, join } from 'node:path' +import { basename, join } from 'node:path' import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' -import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' +import { + resolveOpenCodeDataDirectory, + resolveOpenCodeDatabasePath +} from '../opencode/opencode-data-directory' import type { OpenCodeUsageProcessedDatabase } from './types' type OpenCodeDatabaseOverride = { @@ -10,20 +13,14 @@ type OpenCodeDatabaseOverride = { path: string | null } -function getOpenCodeDatabaseOverride( - dataDirectory: string, - environment: NodeJS.ProcessEnv -): OpenCodeDatabaseOverride { +function getOpenCodeDatabaseOverride(environment: NodeJS.ProcessEnv): OpenCodeDatabaseOverride { const raw = environment.OPENCODE_DB?.trim() if (!raw) { return { isConfigured: false, path: null } } - if (raw === ':memory:') { - return { isConfigured: true, path: null } - } return { isConfigured: true, - path: isAbsolute(raw) ? raw : join(dataDirectory, raw) + path: resolveOpenCodeDatabasePath(environment) } } @@ -40,7 +37,7 @@ export async function listOpenCodeDatabases( environment: NodeJS.ProcessEnv = process.env ): Promise { const dataDirectory = resolveOpenCodeDataDirectory(environment) - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment) + const databaseOverride = getOpenCodeDatabaseOverride(environment) if (databaseOverride.isConfigured) { if (!databaseOverride.path) { return [] diff --git a/src/main/opencode-usage/scanner-wsl-gate.test.ts b/src/main/opencode-usage/scanner-wsl-gate.test.ts index c8bb123c4763..b280a8638b2b 100644 --- a/src/main/opencode-usage/scanner-wsl-gate.test.ts +++ b/src/main/opencode-usage/scanner-wsl-gate.test.ts @@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../opencode/opencode-data-directory', () => ({ - resolveOpenCodeDataDirectory: mocks.resolveDataDirectory + resolveOpenCodeDataDirectory: mocks.resolveDataDirectory, + resolveOpenCodeDatabasePath: () => UNC_DATABASE })) vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/main/opencode/opencode-acp-environment.ts b/src/main/opencode/opencode-acp-environment.ts new file mode 100644 index 000000000000..2970ce317aee --- /dev/null +++ b/src/main/opencode/opencode-acp-environment.ts @@ -0,0 +1,69 @@ +import { restoreOrStripOverlayEnv } from '../../shared/agent-overlay-env' +import { getAppEnvironment } from '../../shared/app-environment' +import { isOpenCodeLegacySharedConfigDir } from './legacy-shared-config-dir' + +const ORCA_ENV_PREFIXES = ['ORCA_OPENCODE_', 'ORCA_DATA_ACCOUNT_'] +const ACCOUNT_ENV_KEYS = ['XDG_DATA_HOME', 'XDG_STATE_HOME', 'OPENCODE_DB', 'OPENCODE_AUTH_CONTENT'] + +/** + * A structured chat's OpenCode reports through the chat, never through Orca's terminal status + * plugin. Restores the user's own `OPENCODE_CONFIG_DIR` over the overlay a terminal pane would have + * (dropping it when none was recorded, or when it is the retired shared plugin directory), and drops + * every Orca OpenCode and data-account variable. Runs after the account is applied. Returns + * the keys the child must not inherit. + */ +export function scrubOpenCodeAcpEnvironment( + env: Record, + inherited: NodeJS.ProcessEnv, + userDataPath: string | null = orcaUserDataPath() +): string[] { + const view: Record = {} + for (const [key, value] of Object.entries(inherited)) { + if (value !== undefined) { + view[key] = value + } + } + Object.assign(view, env) + restoreOrStripOverlayEnv( + view, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + {} + ) + const removed: string[] = [] + if ( + view.OPENCODE_CONFIG_DIR === undefined || + (userDataPath !== null && + isOpenCodeLegacySharedConfigDir(view.OPENCODE_CONFIG_DIR, userDataPath)) + ) { + delete env.OPENCODE_CONFIG_DIR + removed.push('OPENCODE_CONFIG_DIR') + } else { + env.OPENCODE_CONFIG_DIR = view.OPENCODE_CONFIG_DIR + } + // The launch environment decides these; one it leaves unset must not come from Orca's overlay. + for (const key of ACCOUNT_ENV_KEYS) { + if (env[key] === undefined) { + removed.push(key) + } + } + for (const key of new Set([...Object.keys(env), ...Object.keys(inherited)])) { + if (ORCA_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) { + delete env[key] + removed.push(key) + } + } + return removed +} + +function orcaUserDataPath(): string | null { + try { + return getAppEnvironment().getPath('userData') + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-acp-stored-messages.test.ts b/src/main/opencode/opencode-acp-stored-messages.test.ts new file mode 100644 index 000000000000..ab7f66416b66 --- /dev/null +++ b/src/main/opencode/opencode-acp-stored-messages.test.ts @@ -0,0 +1,49 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + openCodeStoredUserMessagesReader, + type OpenCodeTranscriptPageReader +} from './opencode-acp-stored-messages' + +describe('OpenCode stored user messages', () => { + it("reads the database under the chat's own home, and only its user messages", async () => { + const readPage = vi.fn(async () => ({ + items: [ + { + rowid: 1, + fingerprint: 'a', + message: { id: 'msg_1', role: 'user', blocks: [], timestamp: 5, source: 'transcript' } + }, + { + rowid: 2, + fingerprint: 'b', + message: { + id: 'msg_2', + role: 'assistant', + blocks: [], + timestamp: 6, + source: 'transcript' + } + } + ], + hasMore: false, + beforeMessageRowId: 1 + })) + const home = join('/', 'child-home') + const read = openCodeStoredUserMessagesReader(readPage) + await expect( + read({ + env: { HOME: home, USERPROFILE: home }, + providerSessionId: 'ses_1', + signal: new AbortController().signal + }) + ).resolves.toEqual([{ id: 'msg_1', blocks: [], createdAt: 5 }]) + expect(readPage).toHaveBeenCalledWith( + expect.objectContaining({ + dbPath: join(home, '.local', 'share', 'opencode', 'opencode.db'), + sessionId: 'ses_1' + }), + expect.anything() + ) + }) +}) diff --git a/src/main/opencode/opencode-acp-stored-messages.ts b/src/main/opencode/opencode-acp-stored-messages.ts new file mode 100644 index 000000000000..57e16219442f --- /dev/null +++ b/src/main/opencode/opencode-acp-stored-messages.ts @@ -0,0 +1,38 @@ +import { homedir } from 'node:os' +import { OPENCODE_TRANSCRIPT_MAX_WINDOW } from '../../shared/opencode-transcript-page-limit' +import type { AcpStoredUserMessagesReader } from '../acp/acp-recovery-history' +import { resolveOpenCodeDatabasePath } from './opencode-data-directory' +import type { readOpenCodeTranscriptPageViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' + +export type OpenCodeTranscriptPageReader = typeof readOpenCodeTranscriptPageViaWorker + +/** The latest user messages of an OpenCode session, read from the database its pinned account + * selects, through the existing bounded reader. */ +export function openCodeStoredUserMessagesReader( + readPage?: OpenCodeTranscriptPageReader +): AcpStoredUserMessagesReader { + return async ({ env, providerSessionId, signal }) => { + // The child's own home, which its account may name, not this process's. + const home = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() + const dbPath = resolveOpenCodeDatabasePath(env, home) + if (!dbPath) { + return null + } + const read = + readPage ?? + (await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn')) + .readOpenCodeTranscriptPageViaWorker + const page = await read( + { dbPath, sessionId: providerSessionId, limit: OPENCODE_TRANSCRIPT_MAX_WINDOW }, + signal + ) + if (!page) { + return null + } + return page.items.flatMap(({ message }) => + message.role === 'user' && message.timestamp !== null + ? [{ id: message.id, blocks: message.blocks, createdAt: message.timestamp }] + : [] + ) + } +} diff --git a/src/main/opencode/opencode-data-directory.test.ts b/src/main/opencode/opencode-data-directory.test.ts index e3b9073aa17d..df95f0b8ae91 100644 --- a/src/main/opencode/opencode-data-directory.test.ts +++ b/src/main/opencode/opencode-data-directory.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { resolveOpenCodeDataDirectory, + resolveOpenCodeDatabasePath, resolveOpenCodeStorageDirectory } from './opencode-data-directory' @@ -43,4 +44,17 @@ describe('resolveOpenCodeDataDirectory', () => { join('/users/test', '.local', 'share', 'opencode', 'storage') ) }) + + it('resolves default, relative, absolute and in-memory database selections', () => { + const environment = { XDG_DATA_HOME: '/pinned' } + expect(resolveOpenCodeDatabasePath(environment)).toBe( + join('/pinned', 'opencode', 'opencode.db') + ) + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: 'custom.db' })).toBe( + join('/pinned', 'opencode', 'custom.db') + ) + const absolute = join(process.cwd(), 'selected.db') + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: absolute })).toBe(absolute) + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: ':memory:' })).toBeNull() + }) }) diff --git a/src/main/opencode/opencode-data-directory.ts b/src/main/opencode/opencode-data-directory.ts index b3db5598cee5..5010311f7884 100644 --- a/src/main/opencode/opencode-data-directory.ts +++ b/src/main/opencode/opencode-data-directory.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join } from 'node:path' export function resolveOpenCodeDataDirectory( environment: NodeJS.ProcessEnv = process.env, @@ -15,3 +15,17 @@ export function resolveOpenCodeStorageDirectory( ): string { return join(resolveOpenCodeDataDirectory(environment, homeDirectory), 'storage') } + +export function resolveOpenCodeDatabasePath( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string | null { + const selection = environment.OPENCODE_DB?.trim() + if (selection === ':memory:') { + return null + } + const dataDirectory = resolveOpenCodeDataDirectory(environment, homeDirectory) + return selection && isAbsolute(selection) + ? selection + : join(dataDirectory, selection || 'opencode.db') +} diff --git a/src/main/opencode/opencode-structured-account-home.test.ts b/src/main/opencode/opencode-structured-account-home.test.ts new file mode 100644 index 000000000000..34300a3fed43 --- /dev/null +++ b/src/main/opencode/opencode-structured-account-home.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { + environmentForStructuredOpenCodeAccountHome, + resolveStructuredOpenCodeAccountHome +} from './opencode-structured-account-home' + +const first = '123e4567-e89b-42d3-a456-426614174000' +const second = '123e4567-e89b-42d3-a456-426614174001' + +function managedAccounts() { + let activeAccountId: string | null = first + let available = new Set([first, second]) + return { + list: (): ManagedDataAccountsState => ({ + accounts: [...available].map((id) => ({ id, label: id, integrations: [], createdAt: 0 })), + activeAccountId + }), + restoreOriginalEnvironment: (environment: Record) => + restoreManagedDataAccountEnvironment(environment), + environmentForAccount: (_provider: 'opencode' | 'devin', id: string) => { + if (!available.has(id)) { + throw new Error('Managed account not found.') + } + return { + XDG_DATA_HOME: `/profiles/${id}/data`, + XDG_STATE_HOME: `/profiles/${id}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + } + }, + select: (id: string | null) => { + activeAccountId = id + }, + remove: (id: string) => { + available = new Set([...available].filter((entry) => entry !== id)) + } + } +} + +describe('structured OpenCode account binding', () => { + it('records only that the chat has no managed profile', () => { + const accounts = managedAccounts() + accounts.select(null) + expect(resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts })).toEqual({ + kind: 'opencode', + locator: { kind: 'unmanaged' } + }) + }) + + it('uses the pinned managed profile after selection changes and refuses removal', () => { + const accounts = managedAccounts() + const binding = resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }) + accounts.select(second) + const environment = environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment: { PATH: '/bin' } + }) + expect(environment.XDG_DATA_HOME).toBe(`/profiles/${first}/data`) + expect(environment.OPENCODE_DB).toBe('opencode.db') + expect(resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }).locator).toEqual({ + kind: 'managed', + managedProfileId: second + }) + accounts.remove(first) + expect(() => + environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment: {} + }) + ).toThrow('Managed account not found.') + }) + + it("passes the user's inline credentials, relative paths and database through unchanged", () => { + const accounts = managedAccounts() + accounts.select(null) + const baseEnvironment = { + PATH: '/bin', + HOME: 'relative/home', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: 'relative/state', + OPENCODE_DB: 'custom.db', + OPENCODE_AUTH_CONTENT: '{"provider":{"type":"api","key":"inline"}}' + } + const binding = resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }) + expect( + environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment + }) + ).toEqual(baseEnvironment) + }) + + it("undoes Orca's inherited managed overlay before passing the environment through", () => { + const accounts = managedAccounts() + accounts.select(null) + const environment = environmentForStructuredOpenCodeAccountHome( + { kind: 'opencode', locator: { kind: 'unmanaged' } }, + { + managedAccounts: accounts, + baseEnvironment: { + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode', + ORCA_DATA_ACCOUNT_DATA_HOME: '/old/data', + ORCA_DATA_ACCOUNT_STATE_HOME: '/old/state', + ORCA_DATA_ACCOUNT_ORIGINAL_ENV: JSON.stringify({ + XDG_DATA_HOME: '/original/data', + XDG_STATE_HOME: null, + OPENCODE_DB: 'original.db', + OPENCODE_AUTH_CONTENT: null + }), + XDG_DATA_HOME: '/old/data', + XDG_STATE_HOME: '/old/state', + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '', + PATH: '/bin' + } + } + ) + expect(environment).toEqual({ + XDG_DATA_HOME: '/original/data', + OPENCODE_DB: 'original.db', + PATH: '/bin' + }) + }) +}) diff --git a/src/main/opencode/opencode-structured-account-home.ts b/src/main/opencode/opencode-structured-account-home.ts new file mode 100644 index 000000000000..57279a8aa05b --- /dev/null +++ b/src/main/opencode/opencode-structured-account-home.ts @@ -0,0 +1,95 @@ +import { + isLegacyAgentSessionAccountHome, + type OpenCodeAgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { + getManagedDataAccountService, + type ManagedDataAccountService +} from '../managed-data-accounts/service' +import type { AcpAccountBinding } from '../acp/acp-account-binding' + +type AccountReader = Pick< + ManagedDataAccountService, + 'list' | 'restoreOriginalEnvironment' | 'environmentForAccount' +> + +function restoredEnvironment( + environment: NodeJS.ProcessEnv, + managedAccounts: Pick +): Record { + const copy = { ...environment } + managedAccounts.restoreOriginalEnvironment(copy) + delete copy.ORCA_DATA_ACCOUNT_PROVIDER + delete copy.ORCA_DATA_ACCOUNT_DATA_HOME + delete copy.ORCA_DATA_ACCOUNT_STATE_HOME + delete copy.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + return copy +} + +function definedEnvironment( + environment: Record +): Record { + const defined: Record = {} + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined) { + defined[key] = value + } + } + return defined +} + +/** Resolve the current selection on the execution host when creating a chat. */ +export function resolveStructuredOpenCodeAccountHome(input: { + managedAccounts: AccountReader +}): OpenCodeAgentSessionAccountHome { + const selected = input.managedAccounts.list('opencode').activeAccountId + if (selected) { + input.managedAccounts.environmentForAccount('opencode', selected) + return { kind: 'opencode', locator: { kind: 'managed', managedProfileId: selected } } + } + return { kind: 'opencode', locator: { kind: 'unmanaged' } } +} + +/** Resolve a saved binding against the current host, then apply it after inherited overlays. */ +export function environmentForStructuredOpenCodeAccountHome( + binding: OpenCodeAgentSessionAccountHome, + input: { + managedAccounts: Pick + baseEnvironment: Record + } +): Record { + const environment = restoredEnvironment(input.baseEnvironment, input.managedAccounts) + const locator = binding.locator + // Unmanaged: the user's own environment reaches OpenCode as-is. + if (locator.kind === 'unmanaged') { + return definedEnvironment(environment) + } + delete environment.XDG_DATA_HOME + delete environment.XDG_STATE_HOME + delete environment.OPENCODE_DB + delete environment.OPENCODE_AUTH_CONTENT + return { + ...definedEnvironment(environment), + ...input.managedAccounts.environmentForAccount('opencode', locator.managedProfileId) + } +} + +/** OpenCode's account over ACP: a managed profile, or the user's own environment. */ +export function openCodeAcpAccountBinding( + managedAccounts: () => AccountReader = getManagedDataAccountService +): AcpAccountBinding { + return { + pin: { accountLocatorKind: 'opencode' }, + resolve: async () => + resolveStructuredOpenCodeAccountHome({ managedAccounts: managedAccounts() }), + environment: (home, env) => { + if (isLegacyAgentSessionAccountHome(home)) { + throw new Error('OpenCode chat requires a pinned data account') + } + return environmentForStructuredOpenCodeAccountHome(home, { + managedAccounts: managedAccounts(), + baseEnvironment: env + }) + } + } +} diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 5fbf939d1afc..ff840c755b35 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -47,6 +47,7 @@ import { } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' +import { agentSessionAccountHomesEqual } from '../../shared/agent-session-account-home' export type AgentSessionReserveRequest = { /** Host-resolved floating directory committed with the first owner reservation. */ @@ -186,8 +187,7 @@ export function applyAgentSessionReservation( if ( !agentSessionExecutionLocationsEqual(existing.location, request.location) || existing.provider !== request.provider || - existing.accountHome.variable !== request.accountHome.variable || - existing.accountHome.path !== request.accountHome.path + !agentSessionAccountHomesEqual(existing.accountHome, request.accountHome) ) { // Why: location, provider, and account are the session identity; changing one is a fork. throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 0fcdaae95323..7fd0dec42ad8 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -2,7 +2,6 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { resolveCommittedStructuredAgentSessionAdoptionIntent, resolveStructuredAgentSessionAdoptionForCreate @@ -11,6 +10,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' @@ -23,7 +23,7 @@ import { } from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { - agentSessionAccountHome, + isLegacyAgentSessionAccountHome, type AgentSessionAccountHome } from '../../shared/agent-session-account-home' import { @@ -37,24 +37,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR worktreeSelector: string, agent: StructuredAgentId ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) - return resolveStructuredAgentSessionCreateSupport({ + return resolveHostStructuredAgentCreateSupport({ agent, - location, - adapterSupportsCreate: await this.structuredAgentSupportsLocation(agent, location), + worktreeSelector, + location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), + runtime: this, getSettings: () => this.requireStore().getSettings() }) } - /** The agent's own location rule, from its registration: answered without installing the host, - * and false for an agent this runtime does not register. */ - protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { - return structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false - } - /** Where a launch of `agent` finds its account, resolved on this host by the agent's own * registration; null for an agent this runtime does not register, whose create is refused. */ - protected structuredAgentAccountHomePathResolver( + protected structuredAgentAccountHomeResolver( agent: StructuredAgentId, worktree: string, purpose: 'launch' | 'read', @@ -71,7 +65,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceTrustSettings: () => this.requireStore().getSettings() } return async ({ launchEnv, location }) => - registration.resolveAccountHomePath( + registration.resolveAccountHome( { launchEnv, location: location ?? null, @@ -160,18 +154,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : undefined - const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( + const resolveAccountHome = this.structuredAgentAccountHomeResolver( input.agent, input.worktree, 'launch', hostLaunchDirectory ) - if (!resolveAccountHomePath) { + if (!resolveAccountHome) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHome) return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } @@ -183,18 +177,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentAccountHome( agent: StructuredAgentId ): Promise { - const resolvePath = this.structuredAgentAccountHomePathResolver(agent, '', 'read') - if (!resolvePath) { + const resolveAccountHome = this.structuredAgentAccountHomeResolver(agent, '', 'read') + if (!resolveAccountHome) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const definition = this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) - return agentSessionAccountHome(definition, await resolvePath({ launchEnv, location: null })) + return resolveAccountHome({ launchEnv, location: null }) } protected async resolveStructuredAgentSessionIntent( @@ -205,7 +198,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR callerKey?: string resumeFrom?: { providerSessionId: string } }, - resolveAccountHomePath: (context: { + resolveAccountHome: (context: { launchEnv: NodeJS.ProcessEnv location: { executionHostId: string @@ -213,7 +206,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceId: string workspaceKind: 'folder' | 'git-worktree' } - }) => string | Promise + }) => AgentSessionAccountHome | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have @@ -238,21 +231,27 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR if (committedReplay) { return committedReplay } - const selectedAccountHomePath = await resolveAccountHomePath({ launchEnv, location }) + const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) + if (input.resumeFrom && !isLegacyAgentSessionAccountHome(selectedAccountHome)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. - const adoption = input.resumeFrom - ? await resolveStructuredAgentSessionAdoptionForCreate({ - host, - settings, - agent: input.agent, - providerSessionId: input.resumeFrom.providerSessionId, - selfSessionId: input.envelope.sessionId, - selectedAccountHomePath - }) - : null + const adoption = + input.resumeFrom && isLegacyAgentSessionAccountHome(selectedAccountHome) + ? await resolveStructuredAgentSessionAdoptionForCreate({ + host, + settings, + agent: input.agent, + providerSessionId: input.resumeFrom.providerSessionId, + selfSessionId: input.envelope.sessionId, + selectedAccountHomePath: selectedAccountHome.path + }) + : null return { envelope: { sessionId: input.envelope.sessionId, @@ -263,10 +262,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: agentSessionAccountHome( - definition, - adoption ? adoption.accountHomePath : selectedAccountHomePath - ), + accountHome: + adoption && isLegacyAgentSessionAccountHome(selectedAccountHome) + ? { variable: selectedAccountHome.variable, path: adoption.accountHomePath } + : selectedAccountHome, ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 17ea276bfbb2..30c97c425ecc 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -255,6 +255,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) : {}, + resolveAgentCommandSettings: () => this.requireStore().getSettings(), // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 6e313e111af6..00f602c9bea9 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -63,14 +63,17 @@ describe('the structured agent registration list', () => { it("resolves an account home through the agent's registration, with the read purpose", async () => { const claude = structuredAgentRuntimeRegistration('claude')! - vi.spyOn(claude, 'resolveAccountHomePath').mockResolvedValue('/accounts/claude') + vi.spyOn(claude, 'resolveAccountHome').mockResolvedValue({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude' + }) const { runtime, installHost } = runtimeAt() expect(await runtime.resolveStructuredAgentAccountHome('claude')).toEqual({ variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }) - expect(claude.resolveAccountHomePath).toHaveBeenCalledWith( + expect(claude.resolveAccountHome).toHaveBeenCalledWith( expect.objectContaining({ purpose: 'read', location: null, workspacePath: null }), expect.anything() ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts new file mode 100644 index 000000000000..20db04b9aeed --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' +import type { RpcContext } from '../core' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' + +const [method] = STRUCTURED_AGENT_SESSION_AGENTS_METHODS + +const ensureStructuredAgentSessionHost = vi.fn(async () => { + throw new Error('the journal would not open') +}) + +function context(overrides: Partial): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only the caller's capabilities; the runtime stub proves it never installs the host. + return { runtime: { ensureStructuredAgentSessionHost }, ...overrides } as unknown as RpcContext +} + +describe('agentSession.agents', () => { + it("lists this build's registered agents without installing the host", async () => { + const ctx = context({}) + + const result = await method.handler({}, ctx) + + expect(result.agents.map(({ agent }) => agent)).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + expect(result.agents.map(({ agent }) => agent)).toContain('opencode') + expect(ensureStructuredAgentSessionHost).not.toHaveBeenCalled() + }) + + it('still refuses a remote client that cannot read structured sessions', async () => { + await expect( + method.handler({}, context({ clientKind: 'mobile', clientCapabilities: [] })) + ).rejects.toMatchObject({ refusal: { code: 'structured_agent_session_unsupported' } }) + await expect( + method.handler( + {}, + context({ + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + ) + ).resolves.toMatchObject({ agents: expect.any(Array) }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index 6d03767d79f0..b66fc6d51566 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -6,18 +6,25 @@ import type { AgentSessionAgentsResult } from '../../../../shared/agent-session-registered-agents' import { AGENT_SESSION_AGENTS_METHOD } from '../../../../shared/agent-session-registered-agents' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' import { defineMethod } from '../core' -import { requireInstalledStructuredHost } from './structured-agent-session-gate' +import { requireStructuredCapability } from './structured-agent-session-gate' import { AgentsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ name: AGENT_SESSION_AGENTS_METHOD, params: AgentsParams, - handler: async (_params, ctx): Promise => ({ - agents: (await requireInstalledStructuredHost(ctx)) - .agentDefinitions() - .map(({ agent, capabilities }) => ({ agent, capabilities: { ...capabilities } })) - }) + // Read from the registrations the host is built from, as createSupport is: the answer is + // fixed for this build, so it never waits on, or fails with, installing the host. + handler: async (_params, ctx): Promise => { + requireStructuredCapability(ctx) + return { + agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => ({ + agent: definition.agent, + capabilities: { ...definition.capabilities } + })) + } + } }) ] diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts new file mode 100644 index 000000000000..625d3f62fd61 --- /dev/null +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -0,0 +1,131 @@ +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 { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' + +// Live QA run 5 on #25845: the app launched with HOME and every XDG directory pointed into the rig, +// and OpenCode's Command and per-agent PATH both naming a private 1.18.31. The host's whole create +// check, with a real `--version` against stand-in scripts, must admit that OpenCode. + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +let root: string +let privateOpencode: string + +/** A stand-in `opencode` that answers `--version` with `version`; never a real agent CLI. */ +async function fakeOpencode(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'opencode') + await writeFile(file, `#!/bin/sh\necho ${version}\n`) + await chmod(file, 0o755) + return file +} + +function rigSettings(command: string, extraEnv: Record = {}) { + return { + nativeChatInheritShellEnvironment: true, + nativeChatShellEnvironmentVariables: [], + agentCmdOverrides: { opencode: command }, + agentDefaultEnv: { + opencode: { PATH: `${join(root, 'oc-prefix', 'bin')}:/usr/bin:/bin`, ...extraEnv } + } + } +} + +const managedAccounts = { + list: () => ({ accounts: [], activeAccountId: null }), + restoreOriginalEnvironment: restoreManagedDataAccountEnvironment, + environmentForAccount: () => ({}) +} + +/** OpenCode's account binding, as create resolves it: the account a new chat would pin. */ +function pinnedAccount(launchEnv: Record) { + return openCodeAcpAccountBinding(() => managedAccounts).resolve({ launchEnv }) +} + +/** `agentSession.createSupport` on this host for a local git worktree under the rig's settings. */ +function createSupport(settings: ReturnType) { + return resolveHostStructuredAgentCreateSupport({ + agent: 'opencode', + worktreeSelector: 'id:workspace-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + runtime: { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: join(root, 'proj') } }) + }, + getSettings: () => ({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null }) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-rig-')) + await mkdir(join(root, 'proj')) + const homebrew = await fakeOpencode(join(root, 'homebrew'), '2.0.21') + privateOpencode = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') + const home = join(root, 'home-r7') + loginShell.env = { + PATH: `${join(homebrew, '..')}:/usr/bin:/bin`, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + XDG_STATE_HOME: join(home, '.local', 'state'), + XDG_CACHE_HOME: join(home, '.cache') + } +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('OpenCode create support under the QA rig', () => { + it('admits the private 1.18.31 the Command setting names, with every XDG directory set', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + + expect(await createSupport(rigSettings(privateOpencode))).toEqual({ supported: true }) + expect(info).toHaveBeenCalledWith( + `[agent-cli-version] ${privateOpencode} --version: 1.18.31 is supported` + ) + }) + + it('names the check that refused a 2.x Command in the main log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const homebrew = join(root, 'homebrew', 'opencode') + + expect(await createSupport(rigSettings(homebrew))).toEqual({ + supported: false, + reason: 'agent' + }) + expect(warn).toHaveBeenCalledWith( + '[structured-create-support] opencode unsupported: installed-agent check refused (reason agent)' + ) + }) + + it('admits inline credentials and relative folders, recording no folders for the chat', async () => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + const settings = rigSettings(privateOpencode, { + OPENCODE_AUTH_CONTENT: '{}', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: 'relative/state' + }) + + expect(await createSupport(settings)).toEqual({ supported: true }) + await expect(pinnedAccount(settings.agentDefaultEnv.opencode)).resolves.toEqual({ + kind: 'opencode', + locator: { kind: 'unmanaged' } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts new file mode 100644 index 000000000000..cf408c94f912 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -0,0 +1,135 @@ +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 `opencode` that answers `--version` with `version`; never a real agent CLI. */ +async function fakeOpencode(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'opencode') + await writeFile(file, `#!/bin/sh\necho ${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('opencode')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv?.opencode ?? {}, + resolveCommandSettings: () => settings, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null + } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-probe-')) + shellBin = await fakeOpencode(join(root, 'homebrew'), '2.0.21') + privateBin = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') + // Orca's resolved login-shell PATH, with a 2.x `opencode` 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')( + 'OpenCode create check and launch, real probe', + () => { + it('asks the OpenCode the per-agent PATH names, not the one first on the shell PATH', async () => { + const settings = { + ...base, + agentDefaultEnv: { opencode: { PATH: `${privateBin}:/usr/bin:/bin` } } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) + }) + + it('keeps 2.x first on PATH on the terminal chat when nothing points elsewhere', async () => { + const settings = { ...base, agentDefaultEnv: {} } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + false + ) + }) + + it('checks and launches the binary the Command setting names', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { opencode: join(privateBin, 'opencode') } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) + }) + + it('refuses the launch with the setting named when the Command is not runnable', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { opencode: join(root, 'missing', 'opencode') } + } + // Admitted, so the chat states why rather than silently opening a terminal. + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + await expect(launchWith(settings)).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' + }) + }) + } +) diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts new file mode 100644 index 000000000000..5151b93848bf --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -0,0 +1,110 @@ +import { chmod, 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 { 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 { probeAgentCliVersion, loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { probeAgentCliVersion: vi.fn(), loginShell } +}) +vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentCliVersion +})) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +let bin: string +const asked: { program: string; cwd: string; env: Record }[] = [] +function installedVersion(version: string): void { + probeAgentCliVersion.mockImplementation(async (input, supports) => { + asked.push(input) + return supports(version) + }) +} + +const settings = { + agentDefaultEnv: { opencode: { OPENCODE_EXTRA: '1' } }, + nativeChatInheritShellEnvironment: true, + nativeChatShellEnvironmentVariables: [] +} +const runtime = { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repo/worktree' } })) +} + +beforeEach(async () => { + asked.length = 0 + bin = await mkdtemp(join(tmpdir(), 'orca-opencode-bin-')) + const opencode = join(bin, 'opencode') + await writeFile(opencode, '#!/bin/sh\n') + await chmod(opencode, 0o755) + loginShell.env = { PATH: bin, HOME: '/home/user' } +}) +afterEach(async () => { + probeAgentCliVersion.mockReset() + runtime.resolveRuntimeFileTarget.mockClear() + await rm(bin, { recursive: true, force: true }) +}) + +describe('structuredAgentSupportsLaunch', () => { + it('asks nothing for an agent whose location alone decides', async () => { + expect(await structuredAgentSupportsLaunch('grok', 'id:workspace-1', runtime)).toBe(true) + expect(probeAgentCliVersion).not.toHaveBeenCalled() + expect(runtime.resolveRuntimeFileTarget).not.toHaveBeenCalled() + }) + + it('refuses an installed OpenCode 2.x and allows 1.x, in the workspace with the launch env', async () => { + installedVersion('2.0.21') + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + installedVersion('1.18.31') + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(true) + expect(asked[0]).toMatchObject({ + cwd: '/repo/worktree', + env: { PATH: bin, OPENCODE_EXTRA: '1', OPENCODE_CLIENT: 'acp' } + }) + }) + + it('refuses when the version cannot be read', async () => { + probeAgentCliVersion.mockResolvedValue(false) + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'asks about the same binary the launch then spawns', + async () => { + installedVersion('1.18.31') + await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime) + const launch = await createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv.opencode, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null + } + }) + expect(launch.command).toBe(join(bin, 'opencode')) + expect(asked.map(({ program }) => program)).toEqual([launch.command, launch.command]) + } + ) +}) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts new file mode 100644 index 000000000000..4831970a4148 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -0,0 +1,93 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + resolveStructuredAgentSessionCreateSupport, + warnStructuredAgentSessionCreateUnsupported, + type StructuredAgentSessionCreateSupport +} from '../native-chat/structured-agent-session-create-support' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' +import { structuredAgentBaseEnvironment } from './structured-agent-shell-environment' + +type LaunchEnvironmentSettings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +/** The environment every agent's launch on this host starts from, for a check made before the + * session host is built. */ +async function resolveHostStructuredAgentBaseEnvironment( + settings: LaunchEnvironmentSettings +): Promise> { + return structuredAgentBaseEnvironment({ + shellEnv: await resolveLoginShellEnvironment(), + policy: nativeChatShellEnvironmentPolicy(settings) + }) +} + +/** What the check reads from the runtime asking it. */ +type LaunchSupportRuntime = { + requireStore(): { getSettings(): LaunchEnvironmentSettings } + resolveRuntimeFileTarget(selector: string): Promise<{ worktree: { path: string } }> +} + +/** The agent's own check of what is installed on this host, with the environment and Command + * setting its launch starts from, in the workspace it would launch in; true for an agent whose + * location alone decides. */ +export async function structuredAgentSupportsLaunch( + agent: string, + worktreeSelector: string, + runtime: LaunchSupportRuntime +): Promise { + const supportsLaunch = structuredAgentRuntimeRegistration(agent)?.supportsLaunch + if (!supportsLaunch) { + return true + } + const settings = runtime.requireStore().getSettings() + const env = { + ...(await resolveHostStructuredAgentBaseEnvironment(settings)), + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + } + const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path + return supportsLaunch({ cwd, env, commandSettings: settings }) +} + +/** `agentSession.createSupport` on this host: every refusal it can know before spawning. The + * agent's location rule and installed-agent check from its registration, without installing the + * host, then Claude's managed-account gate. A refusal logs which check said no. */ +export async function resolveHostStructuredAgentCreateSupport(input: { + agent: StructuredAgentId + worktreeSelector: string + location: AgentSessionExecutionLocation + runtime: LaunchSupportRuntime + getSettings: () => ClaudeManagedAccountGateSettings +}): Promise { + const { agent, location } = input + const supportsLocation = + structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false + const supportsLaunch = + supportsLocation && + (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) + const support = resolveStructuredAgentSessionCreateSupport({ + agent, + location, + adapterSupportsCreate: supportsLaunch, + getSettings: input.getSettings + }) + warnStructuredAgentSessionCreateUnsupported( + agent, + support, + !supportsLocation + ? 'location' + : !supportsLaunch + ? 'installed-agent' + : 'managed-account' + ) + return support +} 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 5dfc2f7404d2..0c3604fa733a 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -9,7 +9,7 @@ describe('ACP agents in the runtime registrations', () => { it('registers Grok beside Claude and Codex with its declared capabilities', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) - ).toEqual(['codex', 'claude', 'grok']) + ).toEqual(['codex', 'claude', 'grok', 'opencode']) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', @@ -25,6 +25,24 @@ describe('ACP agents in the runtime registrations', () => { }) }) + it('registers OpenCode on the ACP lane pinning its tagged account, with image prompts', () => { + const definition = structuredAgentRuntimeRegistration('opencode')?.definition + expect(definition).toMatchObject({ + agent: 'opencode', + handleTransport: 'acp', + accountLocatorKind: 'opencode', + capabilities: { imagePrompts: true, steering: 'queue', approvalEnforcement: 'orca' } + }) + expect(definition?.accountHomeVariable).toBeUndefined() + }) + + it('leaves OpenCode 2 on its terminal-backed chat, by agent and by installed version', () => { + expect(structuredAgentRuntimeRegistration('opencode2')).toBeNull() + expect(structuredAgentRuntimeRegistration('opencode')?.supportsLaunch).toBeTypeOf('function') + // Grok runs whatever is installed: its create asks nothing of the binary. + expect(structuredAgentRuntimeRegistration('grok')?.supportsLaunch).toBeUndefined() + }) + 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]) @@ -33,7 +51,7 @@ describe('ACP agents in the runtime registrations', () => { }) it('finds the account home on this runtime from the launch env, else the default', async () => { - const { resolveAccountHomePath } = structuredAgentRuntimeRegistration('grok')! + const { resolveAccountHome } = structuredAgentRuntimeRegistration('grok')! // Grok's resolver asks the runtime for nothing: its home is the launch env's, else the default. const unused = (): never => { throw new Error('Grok resolves its account home without the runtime') @@ -44,13 +62,18 @@ describe('ACP agents in the runtime registrations', () => { readCodexLaunchHome: unused, workspaceTrustSettings: unused } - const resolve = (launchEnv: NodeJS.ProcessEnv) => - resolveAccountHomePath( + const resolve = async (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHome( { launchEnv, location: null, purpose: 'read', workspacePath: null }, services ) - await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') - await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) + await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toEqual({ + variable: 'GROK_HOME', + path: '/data/grok' + }) + await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatchObject({ + path: expect.stringMatching(/\.grok$/) + }) }) it('runs Grok only where this runtime supervises the child itself', () => { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 9134b19c7e26..a3bb1543f553 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -6,13 +6,19 @@ import { supportsClaudeStructuredLocation } from '../claude/claude-structured-lo import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + agentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent +import { + isAgentSessionPreSpawnError, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -32,14 +38,17 @@ import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtim import { resolveStructuredClaudeAccountHomePath, resolveStructuredCodexAccountHomePath, - resolveStructuredEnvAccountHomePath, type StructuredClaudeAccountHomeDeps, type StructuredCodexAccountHomeDeps } from './structured-agent-account-home' import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { createAcpAgentConnection } from '../acp/acp-agent-connection' -import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { + acpLaunchVersionSupported, + createAcpStructuredLaunchResolver, + resolveAcpLaunchCommand +} from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' /** What an agent's adapter is built from: the open store and the runtime around it. */ @@ -86,11 +95,18 @@ export type StructuredAgentRuntimeRegistration = { createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter /** Whether this agent's chats can run at `location`; answered without building the host. */ supportsLocation: (location: AgentSessionExecutionLocation) => boolean - /** The account home a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ - resolveAccountHomePath: ( + /** Whether the agent installed on this host runs a structured chat, asked at create with the + * environment a launch starts from; absent when the location alone decides. */ + supportsLaunch?: (input: { + cwd: string + env: Record + commandSettings: StructuredAgentCommandSettings + }) => Promise + /** The account a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ + resolveAccountHome: ( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices - ) => Promise + ) => Promise } function nativeChatVisualsFor(deps: StructuredAgentSessionRuntimeDeps): { @@ -177,22 +193,41 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio return { definition: acpStructuredAgentDefinition(spec), supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), - resolveAccountHomePath: async ({ launchEnv }) => - resolveStructuredEnvAccountHomePath({ - launchEnv, - variable: spec.accountHomeVariable, - defaultPath: spec.defaultAccountHome - }), + ...(spec.supportsVersion + ? { + supportsLaunch: async ({ cwd, env, commandSettings }) => { + const launchEnv = { ...env, ...spec.env } + let command: string + try { + command = resolveAcpLaunchCommand(spec, launchEnv, { commandSettings }) + } catch (error) { + // An unrunnable Command setting is the launch's refusal to state, not a terminal. + if (isAgentSessionPreSpawnError(error)) { + return true + } + throw error + } + return acpLaunchVersionSupported(spec, { command, cwd, env: launchEnv }) + } + } + : {}), + resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), createAdapter: (context) => { const { deps, store, followUps } = context + const readJournal = (sessionId: string) => + replayJournal(context.journalDatabase.db, sessionId) return new AcpStructuredSessionAdapter({ spec, + readJournal, resolveLaunch: createAcpStructuredLaunchResolver(spec, { store, - readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), + readJournal, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}), ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) }), connect: (launch, options) => createAcpAgentConnection(launch, options), @@ -235,18 +270,25 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, supportsLocation: (location) => supportsCodexStructuredLocation(location), - resolveAccountHomePath: resolveCodexAccountHomePath + resolveAccountHome: async (request, services) => + agentSessionAccountHome( + CODEX_STRUCTURED_AGENT, + await resolveCodexAccountHomePath(request, services) + ) }, { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, supportsLocation: supportsClaudeStructuredLocation, - resolveAccountHomePath: async ({ launchEnv, location }, services) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location?.wslDistro ?? null, - getClaudeConfigDirectory: services.getClaudeConfigDirectory - }) + resolveAccountHome: async ({ launchEnv, location }, services) => + agentSessionAccountHome( + CLAUDE_STRUCTURED_AGENT, + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: services.getClaudeConfigDirectory + }) + ) }, ...ACP_LAUNCH_SPECS.map(acpRegistration) ] diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 00cd43ab0189..8fcc2637d460 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -22,6 +22,7 @@ import { } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { @@ -126,6 +127,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record + /** The settings a per-agent Command override is read from, for the same agents. */ + resolveAgentCommandSettings?: () => StructuredAgentCommandSettings /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index db5024bbb897..0a8c0bf005d8 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' +import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { TuiAgent } from '../../../shared/tui-agent' @@ -68,8 +69,11 @@ function pairedStatus(runtimeId: string): RuntimeEnvironmentStatus { } } +type RouteSettings = Partial & + Partial> + function store( - overrides: { settings?: Partial; runtimeId?: string } = {} + overrides: { settings?: RouteSettings; runtimeId?: string } = {} ): AgentLaunchRouteStore { const state = { settings: { ...SETTINGS, ...overrides.settings }, @@ -134,3 +138,58 @@ describe('structured launch of a host-registered agent', () => { ) }) }) + +describe('OpenCode with its Command and environment set (live QA run 5)', () => { + const LISTS_OPENCODE = { + agents: [...LISTS_GROK.agents, { agent: 'opencode', capabilities: {} }] + } + // The rig's settings: a private binary named twice, which must not decide the surface. + const withOverrides = (): AgentLaunchRouteStore => + store({ + settings: { + agentCmdOverrides: { opencode: '/rig/oc-prefix/bin/opencode' }, + agentDefaultEnv: { opencode: { PATH: '/rig/oc-prefix/bin:/usr/bin:/bin' } } + } + }) + let warn: MockInstance + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + + it('opens a chat once the local host listed OpenCode', async () => { + mocks.callRuntimeRpc.mockResolvedValue(LISTS_OPENCODE) + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(withOverrides(), 'opencode')).toBe('structured-native-chat') + }) + + it('says why it opened the terminal chat and asks the host again after a failed startup read', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('host not ready')) + await loadHostStructuredAgents('local', REGISTERED, null) + mocks.callRuntimeRpc.mockResolvedValue(LISTS_OPENCODE) + + // What run 5 saw: the terminal-backed "OpenCode" chat, and no version check on the host. + expect(routeFor(withOverrides(), 'opencode')).toBe('legacy-native-chat') + expect(warn).toHaveBeenCalledWith( + '[agent-launch-route] opencode opens legacy-native-chat: agent-without-structured-session ' + + '(host local, host agents not learned, host capabilities known)' + ) + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(2)) + // The next launch reads the list the re-ask learned. + await vi.waitFor(() => + expect(routeFor(withOverrides(), 'opencode')).toBe('structured-native-chat') + ) + }) + + it('names the settings default when new agent tabs do not open as chats', () => { + expect(routeFor(store({ settings: { openAgentTabsInChatByDefault: false } }), 'opencode')).toBe( + 'terminal-tui' + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('opencode opens terminal-tui: new-tabs-default-to-terminal') + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index eaeda98dcbc4..93bc37ecb520 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -2,7 +2,8 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { prefersStructuredNativeChatByDefault, - resolveStructuredNativeChatSupport + resolveStructuredNativeChatSupport, + type StructuredNativeChatBlocker } from '../../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceLaunchKind } from '../../../shared/workspace-launch-kind' @@ -70,15 +71,38 @@ export function structuredAgentLaunchSupported( ): boolean { return ( input.settings?.experimentalStructuredNativeChat === true && - resolveStructuredNativeChatSupport({ - agent: input.agent, - executionHostId: input.executionHostId, - hostCapabilities: input.hostCapabilities, - ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), - workspaceKind: input.workspaceKind, - projectRuntime: input.projectRuntime, - startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot, - ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) - }).supported + structuredAgentLaunchSupport(input).supported ) } + +function structuredAgentLaunchSupport(input: Omit) { + return resolveStructuredNativeChatSupport({ + agent: input.agent, + executionHostId: input.executionHostId, + hostCapabilities: input.hostCapabilities, + ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), + workspaceKind: input.workspaceKind, + projectRuntime: input.projectRuntime, + startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot, + ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) + }) +} + +/** Why a launch with structured chat turned on did not route to it; null when it did, or when + * structured chat is off. Diagnostics only: the route itself is `resolveAgentLaunchRoute`. */ +export function structuredAgentLaunchDowngrade( + input: AgentLaunchRoutingInput, + route: AgentLaunchRoute +): StructuredNativeChatBlocker | 'new-tabs-default-to-terminal' | null { + if ( + route === 'structured-native-chat' || + input.settings?.experimentalStructuredNativeChat !== true + ) { + return null + } + if (!prefersStructuredNativeChatByDefault(input.settings)) { + return 'new-tabs-default-to-terminal' + } + const support = structuredAgentLaunchSupport(input) + return support.supported ? null : support.blocker +} diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index b2088bdbe3b9..7d7ce15a2217 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -13,7 +13,8 @@ vi.mock('@/lib/agent-launch-route-input', () => ({ })) vi.mock('@/lib/agent-launch-routing', () => ({ resolveAgentLaunchRoute: mocks.resolveAgentLaunchRoute, - structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported + structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported, + structuredAgentLaunchDowngrade: () => null })) vi.mock('@/lib/structured-agent-launch-settlement', () => ({ beginStructuredAgentLaunchSettlement: mocks.beginStructuredAgentLaunchSettlement diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index bbe8ae596503..25a247ca4e39 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -1,6 +1,7 @@ import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { TuiAgent } from '../../../shared/tui-agent' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { StructuredAgentSessionOwnerUnresolvedError } from '@/lib/launch-structured-agent-session' @@ -11,6 +12,7 @@ import { } from '@/lib/agent-launch-route-input' import { resolveAgentLaunchRoute, + structuredAgentLaunchDowngrade, structuredAgentLaunchSupported, type AgentLaunchRoute, type AgentLaunchRoutingInput @@ -24,6 +26,8 @@ import { } from '@/lib/structured-agent-launch-settlement' import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { relearnHostStructuredAgents } from '@/runtime/host-structured-agents' +import { awaitLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { /** The user action this launch serves, minted where that action is handled. */ @@ -171,6 +175,99 @@ export function resolveAgentSessionLaunchRoute( return resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, request)) } +/** Says in the console why a launch with structured chat on opens a terminal, and asks again a + * host whose agent list was never learned, so the next launch can open its chat. */ +function reportStructuredLaunchDowngrade( + store: AgentLaunchRouteStore, + input: AgentLaunchRoutingInput, + route: AgentLaunchRoute +): void { + const downgrade = structuredAgentLaunchDowngrade(input, route) + if (!downgrade) { + return + } + console.warn( + `[agent-launch-route] ${input.agent} opens ${route}: ${downgrade} (host ${input.executionHostId}, ` + + `host agents ${input.hostStructuredAgents ? 'listed' : 'not learned'}, ` + + `host capabilities ${input.hostCapabilities ? 'known' : 'unknown'})` + ) + if (downgrade === 'agent-without-structured-session' && !input.hostStructuredAgents) { + // Bookkeeping: a failed re-ask never touches this launch. + relearnHostStructuredAgents( + input.executionHostId, + input.hostCapabilities, + store.runtimeStatusByEnvironmentId + ).catch((error: unknown) => { + console.warn('[agent-launch-route] could not ask the host for its agents again', error) + }) + } +} + +/** What a launch's chat route is waiting to hear from its host, if anything: the host's agent + * list, or this computer's runtime capabilities while startup is still answering. */ +function structuredRouteAwaits( + input: AgentLaunchRoutingInput +): 'host-agents' | 'local-capabilities' | null { + const downgrade = structuredAgentLaunchDowngrade(input, resolveAgentLaunchRoute(input)) + if ( + downgrade === 'agent-without-structured-session' && + !input.hostStructuredAgents && + // A host that does not publish its agents has no list to wait for. + input.hostCapabilities?.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) !== false + ) { + return 'host-agents' + } + return downgrade === 'runtime-capability-unknown' && + parseExecutionHostId(input.executionHostId)?.kind === 'local' + ? 'local-capabilities' + : null +} + +/** When a launch's chat route waits only on its host's answer (the agent list, or this + * computer's runtime capabilities during a slow startup), asks for it and resolves once it came + * or after `waitMs`, whichever is first; null when the route waits on neither. The caller then + * decides the route again. */ +export function awaitStructuredRouteHostAnswer( + store: AgentLaunchRouteStore, + request: AgentLaunchRouteArgs, + waitMs: number +): Promise | null { + const input = buildAgentLaunchRouteInput(store, request) + const awaits = structuredRouteAwaits(input) + if (!awaits) { + return null + } + let stopCapabilitiesWait = (): void => {} + const ask = async (): Promise => { + let capabilities = input.hostCapabilities + // Either route may be missing this computer's capabilities: wait for the probe that lands + // them, since one that failed at startup answers null at once. + if (capabilities === null && parseExecutionHostId(input.executionHostId)?.kind === 'local') { + const wait = awaitLocalRuntimeCapabilities() + stopCapabilitiesWait = wait.stop + capabilities = await wait.known + } + await relearnHostStructuredAgents( + input.executionHostId, + capabilities, + store.runtimeStatusByEnvironmentId + ) + } + let timer: ReturnType | undefined + const answered = ask().catch((error: unknown) => { + console.warn('[agent-launch-route] could not ask the host', error) + }) + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, waitMs) + }) + return Promise.race([answered, deadline]).finally(() => { + clearTimeout(timer) + stopCapabilitiesWait() + }) +} + /** The one place a launch route is decided. Delivery mode is fixed here too, so the settle loop * later receives exactly the prompt and mode the route was decided on. */ export function planAgentSessionLaunch( @@ -179,6 +276,7 @@ export function planAgentSessionLaunch( ): AgentSessionLaunchPlan { const input = buildAgentLaunchRouteInput(store, request) const route = resolveAgentLaunchRoute(input) + reportStructuredLaunchDowngrade(store, input, route) const executionHostId = route === 'structured-native-chat' ? parseExecutionHostId(input.executionHostId)?.id : undefined return adoptAgentSessionLaunchVerdict({ diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts new file mode 100644 index 000000000000..9e23b70db3c6 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../shared/protocol-version' + +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const SETTINGS = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true +} + +const mocks = vi.hoisted(() => { + const state: { current: Record } = { current: {} } + return { callRuntimeRpc: vi.fn(), state } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state.current } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: vi.fn() })) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: vi.fn(), + getLocalRepoProjectExecutionRuntimeContext: vi.fn() +})) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => false })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({})) + +import { resetHostStructuredAgentsForTests } from '@/runtime/host-structured-agents' +import { + refreshLocalRuntimeCapabilities, + setLocalRuntimeCapabilitiesForTests +} from '@/runtime/local-runtime-capabilities' +import type { LaunchAgentInNewTabArgs } from './launch-agent-in-new-tab' +import type { AgentLaunchRouteArgs } from './agent-launch-route-input' +import { + HOST_ANSWER_WAIT_MS, + launchOnceHostAnswered, + routeNewTabLaunch +} from './launch-agent-in-new-tab-host-agents' + +const LISTS_OPENCODE = { + agents: ['claude', 'codex', 'opencode'].map((agent) => ({ agent, capabilities: {} })) +} +const request: AgentLaunchRouteArgs = { + agent: 'opencode', + workspace: { kind: 'git-worktree', repoId: 'repo-1' } +} +const args: LaunchAgentInNewTabArgs = { + requestId: 'request-1', + agent: 'opencode', + worktreeId: 'wt-1' +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait path only hands the startup plan back to its caller; nothing here reads its fields. +const startupPlan = { launchCommand: 'opencode' } as never + +function storeWithSettings(settings: Record) { + mocks.state.current = { + settings, + runtimeStatusByEnvironmentId: new Map(), + worktreesByRepo: {}, + folderWorkspaces: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route builder reads only the settings, host statuses and workspace rows set here; a missing member reads as absent. + return mocks.state.current as never +} + +/** Stands in for the launcher re-entered with the plan decided after the wait. */ +function fakeRelaunch() { + return vi.fn((relaunched: LaunchAgentInNewTabArgs) => ({ + surface: { kind: 'host-published' as const }, + startupPlan, + pasteDraftAfterLaunch: false, + structuredSettlement: Promise.resolve( + relaunched.agentSessionLaunchPlan?.route === 'structured-native-chat' + ? { kind: 'structured' as const, sessionId: 'session-1' } + : { kind: 'cancelled' as const, sessionId: null } + ) + })) +} + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(LISTS_OPENCODE) + setLocalRuntimeCapabilitiesForTests(REGISTERED) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('a new tab whose chat route waits on the host agent list', () => { + it('opens the chat on the first launch once the host lists the agent', async () => { + const store = storeWithSettings(SETTINGS) + const route = routeNewTabLaunch(store, args, request) + if (!('awaited' in route)) { + throw new Error('expected the launch to wait for the host agent list') + } + const relaunch = fakeRelaunch() + + const result = launchOnceHostAnswered(route, args, startupPlan, relaunch) + + expect(result?.surface).toEqual({ kind: 'host-published' }) + expect(relaunch).not.toHaveBeenCalled() + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'structured', + sessionId: 'session-1' + }) + expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ + requestId: undefined, + agentSessionLaunchPlan: { route: 'structured-native-chat', requestId: 'request-1' } + }) + }) + + it('decides without the list once the bounded wait runs out', async () => { + vi.useFakeTimers() + mocks.callRuntimeRpc.mockReturnValue(new Promise(() => {})) + const store = storeWithSettings(SETTINGS) + const route = routeNewTabLaunch(store, args, request) + if (!('awaited' in route)) { + throw new Error('expected the launch to wait for the host agent list') + } + const relaunch = vi.fn((_relaunched: LaunchAgentInNewTabArgs) => ({ + surface: { kind: 'local-terminal' as const, tabId: 'tab-1' }, + startupPlan, + pasteDraftAfterLaunch: false + })) + + const result = launchOnceHostAnswered(route, args, startupPlan, relaunch) + await vi.advanceTimersByTimeAsync(HOST_ANSWER_WAIT_MS - 1) + expect(relaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'terminal' }) + expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ + agentSessionLaunchPlan: { route: 'legacy-native-chat' } + }) + }) + + it('does not wait once the list is learned, or when structured chat is off', async () => { + const store = storeWithSettings(SETTINGS) + const first = routeNewTabLaunch(store, args, request) + if ('awaited' in first) { + await first.awaited + } + + expect(routeNewTabLaunch(store, args, request)).toMatchObject({ + plan: { route: 'structured-native-chat' } + }) + expect( + routeNewTabLaunch( + storeWithSettings({ ...SETTINGS, experimentalStructuredNativeChat: false }), + args, + request + ) + ).toMatchObject({ plan: { route: 'legacy-native-chat' } }) + }) +}) + +describe("a new tab during startup, before this computer's runtime answered", () => { + // No window bridge here, so the startup probe fails and answers null at once: the rig's case. + function startupProbeFailed(): void { + setLocalRuntimeCapabilitiesForTests(null) + } + + /** A later probe (any reader's) lands the capabilities. */ + async function capabilitiesLand(): Promise { + vi.stubGlobal('window', { + api: { runtime: { getStatus: async () => ({ capabilities: REGISTERED }) } } + }) + await refreshLocalRuntimeCapabilities() + } + + function awaitingRoute(agent: 'claude' | 'opencode') { + startupProbeFailed() + const route = routeNewTabLaunch( + storeWithSettings(SETTINGS), + { ...args, agent }, + { ...request, agent } + ) + if (!('awaited' in route)) { + throw new Error('expected the launch to wait for the runtime capabilities') + } + return route + } + + it.each(['claude', 'opencode'] as const)( + 'opens the %s chat when the capabilities arrive within the wait, after a failed probe', + async (agent) => { + const route = awaitingRoute(agent) + const relaunch = fakeRelaunch() + + const result = launchOnceHostAnswered(route, { ...args, agent }, startupPlan, relaunch) + // The failed probe has answered; the launch is still waiting for one that lands. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(relaunch).not.toHaveBeenCalled() + await capabilitiesLand() + + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'structured', + sessionId: 'session-1' + }) + expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ + agentSessionLaunchPlan: { route: 'structured-native-chat' } + }) + } + ) + + it('opens the terminal chat after the cap when they never arrive, and says why', async () => { + vi.useFakeTimers() + const route = awaitingRoute('claude') + const relaunch = fakeRelaunch() + + const result = launchOnceHostAnswered( + route, + { ...args, agent: 'claude' }, + startupPlan, + relaunch + ) + await vi.advanceTimersByTimeAsync(HOST_ANSWER_WAIT_MS - 1) + expect(relaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'cancelled', + sessionId: null + }) + expect(relaunch.mock.calls[0]?.[0].agentSessionLaunchPlan?.route).not.toBe( + 'structured-native-chat' + ) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('claude opens legacy-native-chat: runtime-capability-unknown') + ) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts new file mode 100644 index 000000000000..b78f34856d44 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts @@ -0,0 +1,91 @@ +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { + awaitStructuredRouteHostAnswer, + planAgentSessionLaunch, + type AgentSessionLaunchPlan, + type AgentSessionLaunchRequest +} from '@/lib/agent-session-launch-plan' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' +import type { + LaunchAgentInNewTabArgs, + LaunchAgentInNewTabResult +} from '@/lib/launch-agent-in-new-tab' + +/** How long a new tab waits for its host's answer before deciding without it; the same bound a + * local chat's admission waits. */ +export const HOST_ANSWER_WAIT_MS = 3_000 + +const DELIVERED: StructuredPromptDeliveryResult = { delivered: true, failureNotified: false } +const NOT_DELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: false } + +type AwaitingHostAnswer = { awaited: Promise; replan: () => AgentSessionLaunchPlan } + +/** The new tab's route: the caller's own plan, a plan decided now, or, when the chat route waits + * only on the host's agent list or this computer's runtime capabilities, the wait for them and + * the plan decided after. */ +export function routeNewTabLaunch( + store: Parameters[0], + args: LaunchAgentInNewTabArgs, + request: Omit +): { plan: AgentSessionLaunchPlan | undefined } | AwaitingHostAnswer { + const { requestId } = args + if (requestId === undefined) { + return { plan: args.agentSessionLaunchPlan } + } + const awaited = awaitStructuredRouteHostAnswer(store, request, HOST_ANSWER_WAIT_MS) + return awaited + ? { + awaited, + replan: () => planAgentSessionLaunch(useAppStore.getState(), { requestId, ...request }) + } + : { plan: planAgentSessionLaunch(store, { requestId, ...request }) } +} + +/** + * Opens nothing until the host answered or the wait ran out, then launches on the route decided + * with what is known: the chat, or the terminal. The first launch after startup is not sent to the + * terminal by an answer that was still loading. + */ +export function launchOnceHostAnswered( + route: AwaitingHostAnswer, + args: LaunchAgentInNewTabArgs, + startupPlan: AgentStartupPlan, + relaunch: (args: LaunchAgentInNewTabArgs) => LaunchAgentInNewTabResult +): LaunchAgentInNewTabResult { + if (args.beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { + return null + } + const opened = route.awaited.then(() => + relaunch({ + ...args, + beforeSurfaceOpen: undefined, + requestId: undefined, + agentSessionLaunchPlan: route.replan() + }) + ) + const structuredSettlement = opened.then( + (result): Promise | StructuredAgentLaunchSettlement => + !result + ? { kind: 'cancelled', sessionId: null } + : (result.structuredSettlement ?? { kind: 'terminal' }), + (error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error }) + ) + const deliversPrompt = + Boolean(args.prompt?.trim()) && (args.promptDelivery ?? 'auto-submit') !== 'draft' + return { + surface: { kind: 'host-published' }, + startupPlan, + pasteDraftAfterLaunch: false, + structuredSettlement, + ...(deliversPrompt + ? { + promptDeliveryResult: opened.then( + (result) => (result ? (result.promptDeliveryResult ?? DELIVERED) : NOT_DELIVERED), + () => NOT_DELIVERED + ) + } + : {}) + } +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts index d44efdc93514..a2c203ac25ab 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts @@ -2,6 +2,7 @@ // launch-agent-in-new-tab.test.ts to keep both files within the lines budget. import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() @@ -87,6 +88,8 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ describe('launchAgentInNewTab terminal tab activation', () => { beforeEach(() => { vi.clearAllMocks() + // The local runtime has answered (without structured support), so no launch waits on it. + setLocalRuntimeCapabilitiesForTests([]) store.settings = placementSettings() mockCreateTab.mockReturnValue({ id: 'tab-1' }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index f9c41da3ef01..e9ad2fba3731 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' @@ -154,6 +155,8 @@ const COMMAND_CODE_CLICK = { describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() + // The local runtime has answered (without structured support), so no launch waits on it. + setLocalRuntimeCapabilitiesForTests([]) mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 270d7b4670b3..97e5b1071e95 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -28,10 +28,11 @@ import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-s import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { - planAgentSessionLaunch, - type AgentSessionLaunchPlan -} from '@/lib/agent-session-launch-plan' + launchOnceHostAnswered, + routeNewTabLaunch +} from '@/lib/launch-agent-in-new-tab-host-agents' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' /** The user action this launch serves: minted where that action is handled, or carried by the @@ -183,20 +184,20 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent // Why first: a structured chat is created on whichever runtime owns the workspace, a paired // server included, so only a non-structured route falls through to the host-published terminal. - const plan = - args.requestId === undefined - ? args.agentSessionLaunchPlan - : planAgentSessionLaunch(store, { - requestId: args.requestId, - agent, - workspace: { kind: workspaceKind, worktreeId }, - prompt: trimmedPrompt, - promptDelivery: viewModePromptDelivery, - tuiCustomization: { cwd: initialCwd }, - initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered, - ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) - }) + const route = routeNewTabLaunch(store, args, { + agent, + workspace: { kind: workspaceKind, worktreeId }, + prompt: trimmedPrompt, + promptDelivery: viewModePromptDelivery, + tuiCustomization: { cwd: initialCwd }, + initialSessionOptions: startupPlan.sessionOptions, + onPromptDelivered, + ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) + }) + if ('awaited' in route) { + return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTabInternal) + } + const { plan } = route if (plan?.route === 'structured-native-chat') { const structured = launchStructuredAgentFromNewTab({ plan, diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index a40a49c6b3bd..2d909918b444 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -161,7 +161,10 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session' })) vi.mock('@/runtime/local-runtime-capabilities', () => ({ - readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities + readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities, + ensureLocalRuntimeCapabilities: async () => hostCapabilities, + // A launch made before the runtime answered hears back that it supports no structured chat. + awaitLocalRuntimeCapabilities: () => ({ known: Promise.resolve([]), stop: () => {} }) })) vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: () => @@ -391,8 +394,12 @@ describe('structured chat adoption guard on the launch path', () => { hostCapabilities = capabilities const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ requestId: 'request-5', agent: 'claude', worktreeId: 'wt-1' }) - launchAgentInNewTab({ requestId: 'request-6', agent: 'codex', worktreeId: 'wt-1' }) + const launches = [ + launchAgentInNewTab({ requestId: 'request-5', agent: 'claude', worktreeId: 'wt-1' }), + launchAgentInNewTab({ requestId: 'request-6', agent: 'codex', worktreeId: 'wt-1' }) + ] + // With no answer yet, each launch waits (bounded) for the runtime before opening its terminal. + await Promise.all(launches.map((launch) => launch?.structuredSettlement)) expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled() expect(mockCreateTab).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts index f755b065db96..79db61d7e5a6 100644 --- a/src/renderer/src/runtime/host-structured-agents-sync.ts +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -3,12 +3,19 @@ import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shar import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { loadHostStructuredAgents, retainHostStructuredAgents } from './host-structured-agents' -import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' +import { + loadHostStructuredAgents, + readHostStructuredAgentsForRuntime, + retainHostStructuredAgents +} from './host-structured-agents' +import { + ensureLocalRuntimeCapabilities, + subscribeLocalRuntimeCapabilitiesKnown +} from './local-runtime-capabilities' import { localStructuredChatsInUse } from './local-structured-chats' -// Why gated: asking a host for its agents installs its structured host, which a profile that never -// uses structured chat must not pay for; only with chats in use can a launch route to one. +// Why gated: a host older than this build installs its structured host to answer, which a profile +// that never uses structured chat must not pay for; only with chats in use can a launch route to one. function structuredChatSettingOn(state: Pick): boolean { return state.settings?.experimentalStructuredNativeChat === true } @@ -38,14 +45,21 @@ function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): vo } /** Reads each host's registered agents once its status says it publishes them and structured chat - * is in use, and again when a paired host's runtime changes. Returns the unsubscribe. */ + * is in use, and again when a paired host's runtime changes. The local host is read when the chat + * setting turns on and whenever its capabilities land, so a read made before they were known is + * made again. Returns the unsubscribe. */ export function installHostStructuredAgentsSync(): () => void { const initial = useAppStore.getState() void syncLocalHost(initial.settings) if (structuredChatSettingOn(initial)) { syncPairedHosts(initial.runtimeStatusByEnvironmentId) } - return useAppStore.subscribe((state, previousState) => { + const stopLocal = subscribeLocalRuntimeCapabilitiesKnown(() => { + if (!readHostStructuredAgentsForRuntime(LOCAL_EXECUTION_HOST_ID, null)) { + void syncLocalHost(useAppStore.getState().settings) + } + }) + const stopStore = useAppStore.subscribe((state, previousState) => { const on = structuredChatSettingOn(state) const turnedOn = on && !structuredChatSettingOn(previousState) if (turnedOn) { @@ -59,6 +73,10 @@ export function installHostStructuredAgentsSync(): () => void { syncPairedHosts(state.runtimeStatusByEnvironmentId) } }) + return () => { + stopLocal() + stopStore() + } } export function useHostStructuredAgentsSync(): void { diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index 41166e2c0e98..63ec2c2dd88a 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => { return { callRuntimeRpc: vi.fn(), ensureLocalRuntimeCapabilities: vi.fn(), + capabilitiesKnown: new Set<(capabilities: readonly string[]) => void>(), state, listeners: new Set<(state: StoreState, previous: StoreState) => void>() } @@ -27,7 +28,11 @@ const mocks = vi.hoisted(() => { vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('./local-runtime-capabilities', () => ({ - ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities + ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities, + subscribeLocalRuntimeCapabilitiesKnown: (listener: (capabilities: readonly string[]) => void) => { + mocks.capabilitiesKnown.add(listener) + return () => mocks.capabilitiesKnown.delete(listener) + } })) vi.mock('./local-structured-chats', () => ({ localStructuredChatsInUse: async (settings: StoreState['settings']) => @@ -95,6 +100,7 @@ beforeEach(() => { resetHostStructuredAgentsForTests() mocks.state = { runtimeStatusByEnvironmentId: new Map(), settings: ON } mocks.listeners.clear() + mocks.capabilitiesKnown.clear() mocks.callRuntimeRpc.mockReset().mockResolvedValue(GROK_LIST) mocks.ensureLocalRuntimeCapabilities.mockReset().mockResolvedValue(REGISTERED) }) @@ -176,6 +182,19 @@ describe('host structured agents', () => { await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) }) + it("reads the local host's agents once its capabilities land, when startup asked too early", async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(null) + uninstall = installHostStructuredAgentsSync() + await Promise.resolve() + await Promise.resolve() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(REGISTERED) + mocks.capabilitiesKnown.forEach((listener) => listener(REGISTERED)) + + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + }) + it('leaves a host unlearned when its reply is not an agent list', async () => { mocks.callRuntimeRpc.mockResolvedValue({ unexpected: true }) uninstall = installHostStructuredAgentsSync() diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts index 4196adff0336..cd290e66a5d4 100644 --- a/src/renderer/src/runtime/host-structured-agents.ts +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -7,6 +7,7 @@ import { parseExecutionHostId } from '../../../shared/execution-host' import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' import { callRuntimeRpc } from './runtime-rpc-client' /** @@ -117,6 +118,25 @@ export function loadHostStructuredAgents( return read } +/** Asks a host again when a launch found its agents unlearned. The startup read runs once per + * runtime, so without this a read that failed, or was skipped while the local runtime's + * capabilities were unknown, kept that host's agents off the chat route for the whole session. */ +export async function relearnHostStructuredAgents( + executionHostId: string, + hostCapabilities: readonly string[] | null | undefined, + statuses: HostStructuredAgentsStatuses +): Promise { + const runtimeId = currentRuntimeId(executionHostId, statuses) + if (runtimeId === undefined || readHostStructuredAgentsForRuntime(executionHostId, runtimeId)) { + return + } + const capabilities = + runtimeId === null && !hostCapabilities + ? await ensureLocalRuntimeCapabilities() + : hostCapabilities + await loadHostStructuredAgents(executionHostId, capabilities, runtimeId) +} + /** Drops hosts no longer paired; a stale runtime is already ignored by the reader. */ export function retainHostStructuredAgents(executionHostIds: ReadonlySet): void { let removed = false diff --git a/src/renderer/src/runtime/local-runtime-capabilities.ts b/src/renderer/src/runtime/local-runtime-capabilities.ts index f4c83880503b..39ff41d84ffc 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.ts @@ -4,6 +4,18 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' // different answers, and a caller that routes on them must be able to tell them apart. let localRuntimeCapabilities: readonly RuntimeCapability[] | null = null let refreshPromise: Promise | null = null +const knownListeners = new Set<(capabilities: readonly RuntimeCapability[]) => void>() + +/** Called each time a probe lands an answer, so work that needs the capabilities runs when they + * become known instead of being skipped while they were not. Returns the unsubscribe. */ +export function subscribeLocalRuntimeCapabilitiesKnown( + listener: (capabilities: readonly RuntimeCapability[]) => void +): () => void { + knownListeners.add(listener) + return () => { + knownListeners.delete(listener) + } +} export function readLocalRuntimeCapabilities(): readonly RuntimeCapability[] { return localRuntimeCapabilities ?? [] @@ -34,6 +46,27 @@ export async function ensureLocalRuntimeCapabilities(): Promise< return localRuntimeCapabilities } +/** Resolves with the capabilities once a probe lands them, probing once now; a failed probe does + * not end the wait, since the next probe anyone makes still answers it. `stop` drops the wait. */ +export function awaitLocalRuntimeCapabilities(): { + known: Promise + stop: () => void +} { + let stop = (): void => {} + const known = new Promise((resolve) => { + if (localRuntimeCapabilities !== null) { + resolve(localRuntimeCapabilities) + return + } + stop = subscribeLocalRuntimeCapabilitiesKnown((capabilities) => { + stop() + resolve(capabilities) + }) + void refreshLocalRuntimeCapabilities() + }) + return { known, stop: () => stop() } +} + /** `refreshLocalRuntimeCapabilities` is not `async`, so a missing or broken preload bridge would * throw synchronously out of it instead of settling into the unknown state its catch owns. */ function startLocalRuntimeCapabilityProbe(): ReturnType { @@ -47,8 +80,10 @@ function startLocalRuntimeCapabilityProbe(): ReturnType { refreshPromise ??= startLocalRuntimeCapabilityProbe() .then((status) => { - localRuntimeCapabilities = [...(status.capabilities ?? [])] - return localRuntimeCapabilities + const capabilities = [...(status.capabilities ?? [])] + localRuntimeCapabilities = capabilities + knownListeners.forEach((listener) => listener(capabilities)) + return capabilities }) .catch(() => { // Stays unknown rather than becoming an empty (== unsupported) list: a failed probe diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 1986ae112d31..bb3778c80e6f 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { agentSessionAccountHome } from './agent-session-account-home' +import { + agentSessionAccountHome, + agentSessionAccountHomesEqual +} from './agent-session-account-home' import { isPersistedAgentSessionRecord } from './agent-session-record' import { agentSessionRecordFixture } from './agent-session-record.test-fixture' import { encodeAgentSessionRecord } from './agent-session-record-stored-form' @@ -25,4 +28,36 @@ describe('agent session account home', () => { expect(withVariable(malformed)).toBe(false) } }) + + it('validates tagged OpenCode locators, keeping ones a newer build added fields to', () => { + const record = encodeAgentSessionRecord(agentSessionRecordFixture()) + const managed = { + kind: 'opencode', + locator: { kind: 'managed', managedProfileId: '123e4567-e89b-42d3-a456-426614174000' } + } as const + const unmanaged = { kind: 'opencode', locator: { kind: 'unmanaged' } } as const + expect(isPersistedAgentSessionRecord({ ...record, accountHome: managed })).toBe(true) + expect(isPersistedAgentSessionRecord({ ...record, accountHome: unmanaged })).toBe(true) + expect(agentSessionAccountHomesEqual(managed, unmanaged)).toBe(false) + expect(agentSessionAccountHomesEqual(managed, { ...managed })).toBe(true) + expect(agentSessionAccountHomesEqual(unmanaged, { ...unmanaged })).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { ...managed, path: '/fake/home' } + }) + ).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { ...unmanaged, locator: { ...unmanaged.locator, channel: 'beta' } } + }) + ).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { ...unmanaged, locator: { kind: 'detached' } } + }) + ).toBe(false) + }) }) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index 1f899edad821..21698ef182a9 100644 --- a/src/shared/agent-session-account-home.ts +++ b/src/shared/agent-session-account-home.ts @@ -1,45 +1,132 @@ -/** - * The account an agent session is pinned to: the agent's config directory, and the environment - * variable that points the agent at it. - * - * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), declared - * by its definition, so a record stores it beside the path. Stored values are exactly what older - * builds wrote and read. - */ +/** Durable account binding: existing single-directory records or OpenCode's data context. */ import type { AgentSessionStoredAgent } from './agent-session-stored-agent' /** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ -export type AgentSessionAccountHome = { +export type LegacyAgentSessionAccountHome = { /** Environment variable naming the agent's config directory. */ variable: string /** Host-resolved absolute path in the execution host's own path syntax. */ path: string } +export type OpenCodeAccountLocator = + | { kind: 'managed'; managedProfileId: string } + // The user's own environment, read at every launch. + | { kind: 'unmanaged' } + +export type OpenCodeAgentSessionAccountHome = { + kind: 'opencode' + locator: OpenCodeAccountLocator +} + +export type AgentSessionAccountHome = + | LegacyAgentSessionAccountHome + | OpenCodeAgentSessionAccountHome + +export function isLegacyAgentSessionAccountHome( + home: AgentSessionAccountHome +): home is LegacyAgentSessionAccountHome { + return !('kind' in home) +} + +export function requireLegacyAgentSessionAccountHome( + home: AgentSessionAccountHome +): LegacyAgentSessionAccountHome { + if (!isLegacyAgentSessionAccountHome(home)) { + throw new Error('Expected a single-directory agent account binding.') + } + return home +} + +export function agentSessionAccountHomesEqual( + left: AgentSessionAccountHome, + right: AgentSessionAccountHome +): boolean { + if (isLegacyAgentSessionAccountHome(left)) { + return ( + isLegacyAgentSessionAccountHome(right) && + left.variable === right.variable && + left.path === right.path + ) + } + if (isLegacyAgentSessionAccountHome(right)) { + return false + } + if (left.locator.kind === 'managed') { + return ( + right.locator.kind === 'managed' && + left.locator.managedProfileId === right.locator.managedProfileId + ) + } + return right.locator.kind === 'unmanaged' +} + /** The account home of `agent` at `path`. */ export function agentSessionAccountHome( - agent: Pick, + agent: { accountHomeVariable: string }, path: string -): AgentSessionAccountHome { +): LegacyAgentSessionAccountHome { return { variable: agent.accountHomeVariable, path } } +/** Whether `home` is the kind of account `agent` pins: its own directory variable, or its locator + * kind. It becomes the child's environment, so a record pinning another agent's kind never starts. */ +export function agentPinsAccountHome( + agent: AgentSessionStoredAgent, + home: AgentSessionAccountHome +): boolean { + return isLegacyAgentSessionAccountHome(home) + ? agent.accountHomeVariable !== undefined && home.variable === agent.accountHomeVariable + : agent.accountLocatorKind === home.kind +} + +function isBoundedAccountString(value: unknown, max: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= max && + !value.includes('\u0000') + ) +} + const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ export const MAX_PATH_LENGTH = 4096 +const PROFILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i /** Shape only: whether the variable is the one the record's agent pins is a launch-time question - * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */ + * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. Fields a + * newer build adds are ignored, so its records stay readable here. */ export function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { - if (typeof value !== 'object' || value === null || !('variable' in value) || !('path' in value)) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false } - const { variable, path } = value - return ( - typeof variable === 'string' && - ENVIRONMENT_VARIABLE_NAME.test(variable) && - typeof path === 'string' && - path.length > 0 && - path.length <= MAX_PATH_LENGTH - ) + if (!('kind' in value)) { + return ( + 'variable' in value && + typeof value.variable === 'string' && + ENVIRONMENT_VARIABLE_NAME.test(value.variable) && + 'path' in value && + isBoundedAccountString(value.path, MAX_PATH_LENGTH) + ) + } + if ( + value.kind !== 'opencode' || + !('locator' in value) || + typeof value.locator !== 'object' || + value.locator === null || + Array.isArray(value.locator) || + !('kind' in value.locator) + ) { + return false + } + const locator = value.locator + if (locator.kind === 'managed') { + return ( + 'managedProfileId' in locator && + typeof locator.managedProfileId === 'string' && + PROFILE_ID.test(locator.managedProfileId) + ) + } + return locator.kind === 'unmanaged' } diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts index a500a604de7c..1e18d960c85f 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -8,6 +8,16 @@ export type AgentSessionStoredAgent = { agent: string /** The protocol whose id space this agent's provider handles live in. */ handleTransport: AgentSessionProviderTransport - /** Environment variable naming the agent's config directory, pinned as the record's account home. */ - accountHomeVariable: string -} +} & ( + | { + /** Environment variable naming the agent's config directory, pinned as the record's account home. */ + accountHomeVariable: string + accountLocatorKind?: never + } + | { + /** The agent's account is more than one directory: its records pin a tagged locator of this + * kind instead (`AgentSessionAccountHome`). */ + accountLocatorKind: 'opencode' + accountHomeVariable?: never + } +) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index b4eeb14f3014..f24ea7224509 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -1,3 +1,5 @@ +import { expect } from 'vitest' + export const SESSION = 'session-alpha' export const WORKSPACE = 'workspace-1' export const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' @@ -181,10 +183,12 @@ export const STRUCTURED_CALLS: { // Teardown runs through the runtime's subscription registry rather than the // host, so its reply is the only signal that the gate opened. { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } }, - // The host's registered agents, each with its declared capability record. + // The host's registered agents, each with its declared capability record. Builds before + // #25845 read them from the installed host; later ones from the registrations that host is + // built from, so they answer without installing it. Either way Codex is listed. { method: 'agentSession.agents', - hostMethod: 'agentDefinitions', - result: { agents: [{ agent: 'codex', capabilities: { compact: true } }] } + hostMethod: null, + result: { agents: expect.arrayContaining([expect.objectContaining({ agent: 'codex' })]) } } ]