diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index c8705ab6cc7a..76053a82edc3 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -54,6 +54,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 3b38b9cc1bdd..596ddcb4049a 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -11,6 +11,10 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } +/** A request the client answers at once, asking no one: the reply, and a plan it carried for the + * chat's existing plan row. */ +export type AcpRequestSettlement = { reply: unknown; plan?: string } + export type AcpBackgroundTaskUpdate = Pick & Partial> & { /** Used only until a provider description is known. */ @@ -45,6 +49,8 @@ export type AcpDialect = { notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined request?(method: string, params: unknown): AcpRequestPresentation | undefined + /** Requests answered at once instead of shown to the person. */ + settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined /** The row for a failed turn the provider gave no words for. */ diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 09213c973894..609a9c313c5e 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' -import { grokRequest } from './grok-requests' +import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' const tokenCount = z.number().int().nonnegative() @@ -173,6 +173,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { return parsed.success ? parsed.data['x.ai/tool'].name : undefined }, request: grokRequest, + settleRequest: grokSettleRequest, toolBackgroundTasks: grokToolBackgroundTasks, notification, contextWindow, diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index 26dbee0fae25..b86788120616 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -5,7 +5,7 @@ import { } from '../../../shared/agent-session-question-answer' import { AcpRpcError } from '../acp-errors' import { pendingAcpResolution } from '../acp-timeline-requests' -import type { AcpRequestPresentation } from './acp-dialect' +import type { AcpRequestPresentation, AcpRequestSettlement } from './acp-dialect' const questionRequestSchema = z.object({ sessionId: z.string(), @@ -98,47 +98,35 @@ function questionRequest(params: unknown): AcpRequestPresentation { } } -function planRequest(params: unknown): AcpRequestPresentation { +/** What Grok hears once Orca has shown its plan: no approval is given for the person. */ +const PLAN_SHOWN_FEEDBACK = + 'Orca showed your plan to the user. Stop here and wait for their feedback or a request to implement it in a later turn.' + +/** Grok's plan-mode exit: its plan goes to the chat's plan row and the gate is answered at once + * (`abandoned`), so the turn ends instead of waiting on an approval card. */ +function settlePlanRequest(params: unknown): AcpRequestSettlement { const parsed = planRequestSchema.safeParse(params) if (!parsed.success) { - throw new AcpRpcError(-32602, 'Invalid agent plan approval request') + throw new AcpRpcError(-32602, 'Invalid agent plan request') } return { - body: { - kind: 'approval', - title: 'Approve plan', - detail: null, - subject: { - kind: 'plan', - text: parsed.data.planContent ?? 'The agent exited plan mode without writing a plan.' - }, - options: [ - { id: 'approved', label: 'Approve plan' }, - { id: 'request_changes', label: 'Request changes' } - ], - resolution: pendingAcpResolution - }, - reply: (response) => { - if (response === null) { - return { outcome: 'abandoned' } - } - if ( - response.kind !== 'option' || - !['approved', 'request_changes'].includes(response.optionId) - ) { - throw new AcpRpcError(-32602, 'Plan answer must select an offered option') - } - return { outcome: response.optionId } - } + reply: { outcome: 'abandoned', feedback: PLAN_SHOWN_FEEDBACK }, + plan: parsed.data.planContent?.trim() || 'The agent exited plan mode without writing a plan.' } } +export function grokSettleRequest( + method: string, + params: unknown +): AcpRequestSettlement | undefined { + return ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method) + ? settlePlanRequest(params) + : undefined +} + export function grokRequest(method: string, params: unknown): AcpRequestPresentation | undefined { if (['_x.ai/ask_user_question', 'x.ai/ask_user_question'].includes(method)) { return questionRequest(params) } - if (['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode'].includes(method)) { - return planRequest(params) - } return undefined } diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts new file mode 100644 index 000000000000..2b705aa0dd7f --- /dev/null +++ b/src/main/acp/acp-launch-specs.ts @@ -0,0 +1,76 @@ +// How Orca starts each ACP agent, as data: adding an agent is one more row (plus a dialect when it +// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name. + +import { join } from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +export type AcpLaunchSpec = { + /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ + agent: string + 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. */ + env: Readonly> + dialect: AcpDialect + /** The agent's own sign-in command, for a person to run when it reports auth required. */ + loginCommand: readonly string[] + /** Which advertised sign-in method to use when the agent reports auth required, read from the + * environment it was launched with (on its own machine); none leaves it not signed in. */ + authMethod?(input: { + 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 + /** Where the agent installs its own binary, searched after PATH. */ + installDirectories(accountHomePath: string): string[] +} + +const GROK_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'grok', + command: 'grok', + // `--always-approve` only for full access, as the user's setting chooses. + args: ({ fullAccess }) => ['agent', ...(fullAccess ? ['--always-approve'] : []), 'stdio'], + env: {}, + dialect: GROK_ACP_DIALECT, + loginCommand: ['grok', 'login'], + // An API key in Grok's own environment, else the sign-in Grok already cached; never interactive. + authMethod: ({ advertised, env }) => + env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key') + ? 'xai.api_key' + : advertised.includes('cached_token') + ? 'cached_token' + : undefined, + accountHomeVariable: 'GROK_HOME', + defaultAccountHome: (homePath) => join(homePath, '.grok'), + installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] + +export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { + return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null +} + +/** + * A pane's identity in the inherited environment would let the agent's own Orca status hooks + * report for this session too; the structured session is its one status producer. + */ +export const ACP_CHILD_ENV_TO_DELETE: readonly string[] = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS +] diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index c9fe584dcabe..257ffc68523d 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -33,6 +33,15 @@ export class AcpPromptTurns { this.current = undefined } + /** Forgets a prompt the agent refused before its turn opened; false for any other. */ + refuse(clientMessageId: string): boolean { + if (this.current?.clientMessageId !== clientMessageId || this.current.opened) { + return false + } + this.current = undefined + return true + } + start(turn: string, at: number): ProviderTimelineEvent[] { const prompt = this.current if (prompt?.turn !== turn || prompt.opened) { diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts new file mode 100644 index 000000000000..90c7442d24b6 --- /dev/null +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -0,0 +1,65 @@ +// What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat +// created and never exchanged a turn on, which the agent says it does not hold, is superseded: +// there was nothing to remember. Any other is replaced by a new session that names it, and the +// chat says once that the agent forgot: the row names the lost conversation, so a later start +// can tell whether it was ever written. + +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ +const ACP_RESOURCE_NOT_FOUND = -32002 +export const ACP_REOPEN_FAILED = 'ACP agent could not reopen its saved session; starting a new one' + +/** The fields the new session's link carries; rethrows `error` when the start must fail instead. */ +export function acpReopenTakeover( + error: unknown, + resume: NonNullable, + start: { over: boolean; now: number; warn: (fields: { scope: string; error: unknown }) => void } +): Pick { + // Signed out, or the start is over (Close, Stop, a lost agent): a new session would not help. + if (error instanceof AcpAuthRequiredError || start.over) { + throw error + } + if ( + error instanceof AcpRpcError && + error.code === ACP_RESOURCE_NOT_FOUND && + resume.mayBeUnsaved() + ) { + return { supersedesKey: resume.key } + } + start.warn({ scope: 'acp-restore-failed', error }) + return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } } +} + +/** The provider item key of the row saying the conversation under chain key `lostKey` was lost. */ +export function acpSessionNotRestoredItem(lostKey: string): string { + return `session-not-restored:${lostKey}` +} + +/** The one row a lost conversation gets: the agent no longer remembers what came before. */ +export function acpSessionNotRestoredRow( + lostKey: string, + providerSessionId: string, + agentName: string +): ProviderTimelineEvent[] { + return [ + { + type: 'item.update', + item: acpSessionNotRestoredItem(lostKey), + body: { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords(agentSessionFailureFact('sessionNotRestored'), { + surface: 'row', + agentName + }) + }, + join: { thread: providerSessionId } + } + ] +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts new file mode 100644 index 000000000000..cf7466f27d8f --- /dev/null +++ b/src/main/acp/acp-structured-acquire.ts @@ -0,0 +1,330 @@ +// Making a reservation real for an ACP agent: open its connection (which spawns and owns the +// process), record it before any handshake, initialize with the client's file system and terminals +// off, then reattach the session this chat proved with `session/load` (`session/resume` only for an +// agent that cannot load) or start a new one. A saved session the agent cannot reopen is replaced +// by a new one, with a warning row that any later start writes if this attach never did. The +// journal already holds a reattached chat, so whatever the agent sends while it reattaches is not +// written, except context usage. The handshake has no time bound: the acquire's abort signal +// (Close, Stop, quit) stops it at any point. + +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { + providerSpawnedProcessIdentity, + PROVIDER_SPAWN_TOKEN_ENV +} from '../provider-process/provider-spawned-process-identity' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' +import { AcpAuthRequiredError } from './acp-errors' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { + ACP_REOPEN_FAILED, + acpReopenTakeover, + acpSessionNotRestoredRow +} from './acp-session-reopen-failure' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredPrompts } from './acp-structured-prompts' +import { + asReattachHistory, + routeAcpSessionEvent, + type AcpStructuredSession +} from './acp-structured-session' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' +import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' + +/** Frames an agent may send before its session exists; past this they are dropped. */ +const MAX_EARLY_FRAMES = 2_048 +export function acpAgentName(agent: string): string { + return isTuiAgent(agent) ? TUI_AGENT_DISPLAY_NAMES[agent] : agent +} + +export async function acquireAcpStructuredSession(input: { + acquire: StructuredAgentSessionAcquireInput + deps: AcpStructuredSessionAdapterDeps + generation: string + /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + abandoned: () => boolean + /** Registers the connection so a close during the acquire can stop it. */ + track: (connection: AcpStructuredConnection) => void + /** The process's exit, observed while or after the session exists. */ + onExit: (session: AcpStructuredSession | null) => void + /** The protocol broke with the process perhaps still running. Null while starting: then the + * start itself fails. */ + onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void + onSettled: AcpStructuredTurnsDeps['settle'] + forceClose: (sessionId: string) => void +}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> { + const { acquire, deps, generation } = input + const { spec } = deps + const sessionId = acquire.identity.sessionId + const now = deps.now ?? Date.now + const events = acquire.events + const sink = events ? providerTimelineSink(events) : null + if (!sink) { + throw new AgentSessionPreSpawnError(new Error(`${spec.agent} chats need a journal sink`)) + } + const closedBeforeSpawn = () => { + if (input.abandoned()) { + throw new AgentSessionPreSpawnError( + new Error(`${acpAgentName(spec.agent)} was closed before it started`) + ) + } + } + closedBeforeSpawn() + const launch: AcpStructuredLaunch = await deps + .resolveLaunch({ identity: acquire.identity }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + closedBeforeSpawn() + let session: AcpStructuredSession | null = null + // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. + const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { + lane: null, + reattaching: false + } + const options = new AcpStructuredOptions() + // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself + // has nobody waiting on it. What a settled request carries still shows in that turn. + const prompts = new AcpStructuredPrompts( + () => slot.lane, + () => session?.turns.acceptsRequests === true, + () => + session !== null && + (session.turns.acceptsRequests || + (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) + ) + const early: (() => void)[] = [] + const whenLane = (deliver: () => void): void => { + if (slot.lane) { + deliver() + } else if (early.length < MAX_EARLY_FRAMES) { + early.push(deliver) + } + } + const connection = deps.connect( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: { + ...structuredSessionChildIdentityEnv(sessionId, launch.env), + [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken + }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + { + clientInfo: { name: 'orca', version: '1' }, + onPermission: (request, context) => { + if (!session?.turns.acceptsRequests) { + // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer + // is cutting it short: nobody is there to ask. + return { outcome: { outcome: 'cancelled' } } + } + if (launch.fullAccess) { + // Full access: Orca answers yes for the person, as the agent's own bypass flag would. + const allow = request.options.find((option) => option.kind === 'allow_once') + if (allow) { + return { outcome: { outcome: 'selected', optionId: allow.optionId } } + } + } + return prompts + .handle('session/request_permission', request, context) + .then((reply) => RequestPermissionResponseSchema.parse(reply)) + }, + onRequest: (method, params, context) => prompts.handle(method, params, context), + onExtensionNotification: (method, params) => + whenLane(() => + slot.lane?.apply( + slot.lane.translator.notification( + method, + slot.reattaching ? asReattachHistory(params) : params, + now() + ) + ) + ), + onDiagnostic: (message) => + deps.logger?.warn('ACP agent protocol diagnostic', { + scope: 'acp-diagnostic', + sessionId, + message + }), + // The protocol broke while the process may still run; the connection is already ending it. + // Its stdout ending alone is not this: the agent may still answer Stop's cancel or exit. + onClose: (error) => input.onConnectionLost(session, error), + // A crash usually ends stdout first; the session's end still reads the agent's last words at + // this proven exit. + onExit: () => input.onExit(session) + } + ) + // From here an abort closes this connection (the start's one canceller): whatever the agent left + // unanswered fails at once and the process ends, whether or not its exit is proven yet. + input.track(connection) + connection.subscribe((event: AcpSessionEvent) => + whenLane( + () => + slot.lane && + routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) + ) + ) + const identity = providerSpawnedProcessIdentity( + acquire, + `${spec.agent} ACP agent`, + deps.readProcessStartTime + ) + /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ + const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { + const lane = new AcpStructuredLane({ + sink, + sessionId, + agent: spec.agent, + agentName: acpAgentName(spec.agent), + generation, + providerSessionId, + dialect: spec.dialect, + onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId), + onFailed: () => input.forceClose(sessionId) + }) + slot.lane = lane + slot.reattaching = attaching + if (attaching) { + lane.translator.beginLoad() + } + for (const deliver of early.splice(0)) { + deliver() + } + return lane + } + await connection.spawned + const pid = connection.pid + if (pid === undefined) { + throw new AgentSessionPreSpawnError( + new Error(connection.stderrTail() || `${launch.command} could not be started`) + ) + } + await identity.onSpawned(pid) + const agentName = acpAgentName(spec.agent) + try { + const initialized = await connection.initialize() + // Chosen here, on the machine Grok runs on, from the environment it was launched with. + const authMethodId = spec.authMethod?.({ + advertised: (initialized.authMethods ?? []).map((method) => method.id), + env: launch.env + }) + const auth = authMethodId === undefined ? {} : { authMethodId } + const resume = launch.resume + let started: Awaited> | null = null + let liveLane: AcpStructuredLane | null = null + let takeover: ReturnType | null = null + if (resume) { + const attaching = makeLane(resume.sessionId, true) + liveLane = attaching + try { + started = await connection.start({ + cwd: launch.cwd, + mcpServers: [], + sessionId: resume.sessionId, + ...auth + }) + slot.reattaching = false + attaching.translator.finishLoad() + } catch (error) { + takeover = acpReopenTakeover(error, resume, { + over: connection.closed || acquire.signal?.aborted === true, + now: now(), + warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) + }) + // A new session takes the old one's place, with a new lane, so nothing of the failed + // attach's window outlives it. + } + } + if (!started || !liveLane) { + liveLane?.dispose() + slot.lane = null + started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) + liveLane = makeLane(started.sessionId) + } + options.adoptSession(started.response) + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) + const process = await identity.read(pid) + const link: AgentSessionProviderHandleLink = { + linkId: + deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), + handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, + origin: started.kind === 'new' ? 'created' : 'resumed', + mintedAtFence: acquire.fence, + observedAt: now(), + ...takeover + } + session = { + sessionId, + fence: acquire.fence, + acquisitionGeneration: generation, + spec, + connection, + lane: liveLane, + prompts, + options, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: input.onSettled + }), + restoreSkipped, + closeRequested: false, + journalClosed: null, + ended: false, + exitObservedAt: null + } + const reading = liveLane + const unbind = events?.bindReadingControl?.({ + pauseReading: () => connection.pauseReading(), + resumeReading: () => { + connection.resumeReading() + reading.retry() + } + }) + if (unbind) { + session.unbindReadingControl = unbind + } + if (connection.exited || connection.closed) { + throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) + } + // The chat says once per lost conversation that the agent forgot it, this start's loss included. + const lost = [ + ...(launch.resume?.unannouncedLosses() ?? []), + ...(takeover?.replaces ? [takeover.replaces.key] : []) + ] + for (const key of lost) { + liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) + } + return { acquisition: { process, link, acquisitionGeneration: generation }, session } + } catch (error) { + session = null + slot.lane?.dispose() + if (error instanceof AcpAuthRequiredError) { + throw new AgentSessionAcquisitionRefusal( + `${spec.agent} reported that it is not signed in: ${error.message}`, + 'notSignedIn' + ) + } + throw error + } +} diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts new file mode 100644 index 000000000000..e39ade6c7b97 --- /dev/null +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -0,0 +1,296 @@ +// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real +// protocol runtime, translator and assembler, and a real on-disk journal to read back. + +import { vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { ProviderProcessExit } from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + openProviderTimelineRig, + SESSION, + type ProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } 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' +import { AcpSessionRuntime } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const GROK = acpLaunchSpecFor('grok')! +export const PROVIDER_SESSION = 'acp-session-1' +export const PID = 4242 + +export const GROK_CONFIG_OPTIONS = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'grok-4.7', + options: [ + { value: 'grok-4.7', name: 'Grok 4.7' }, + { value: 'grok-4.6', name: 'Grok 4.6' } + ] + }, + { + id: 'reasoning_effort', + name: 'Reasoning Effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [ + { value: 'high', name: 'High' }, + { value: 'low', name: 'Low' } + ] + } +] + +/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, + * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes + * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ +export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { + readonly agent: AcpScriptedAgent + readonly pid: number | undefined = PID + readonly spawned = Promise.resolve() + stderr = '' + processTreeUnproven = false + private exitListeners: ((exit: ProviderProcessExit) => void)[] = [] + private gone = false + private closing = false + private lostWith: Error | undefined + closes = 0 + + constructor( + readonly launch: ProviderProcessLaunch, + private readonly connectionOptions: AcpAgentConnectionOptions, + agent = new AcpScriptedAgent() + ) { + const lifecycle: { child: FakeAcpChild | null } = { child: null } + super(agent.stdout, agent.stdin, { + ...connectionOptions, + peer: { ...connectionOptions.peer, closeOnInputEnd: false }, + onClose: (error) => lifecycle.child?.lost(error) + }) + lifecycle.child = this + this.agent = agent + } + + get exited() { + return this.gone + } + onExit(listener: (exit: ProviderProcessExit) => void): void { + if (this.gone) { + listener(FAKE_EXIT) + } else { + this.exitListeners.push(listener) + } + } + stderrTail(): string { + return this.stderr + } + pauseReading(): void { + this.agent.stdout.pause() + } + resumeReading(): void { + this.agent.stdout.resume() + } + /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ + proveClose = async (): Promise => { + this.exit() + return true + } + override close(error?: Error): Promise { + this.closes += 1 + this.closing ||= !this.gone + super.close(error) + return this.proveClose() + } + /** The agent process ends on its own (or Orca's close landed). */ + exit(): void { + if (this.gone) { + return + } + this.gone = true + const error = + this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`) + super.close(error) + this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT }) + for (const listener of this.exitListeners.splice(0)) { + listener(FAKE_EXIT) + } + } + private lost(error: Error): void { + this.lostWith ??= error + if (this.closing || this.gone) { + return + } + this.connectionOptions.onClose?.(error) + } +} + +const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false } + +export type AcpAdapterRig = { + rig: ProviderTimelineRig + adapter: AcpStructuredSessionAdapter + child: () => FakeAcpChild + spawned: string[] + lifecycle: StructuredAgentSessionLifecycleEvent[] + settled: Parameters>[0][] + acquire(options?: { + fence?: number + onSpawned?: () => Promise + signal?: AbortSignal + }): ReturnType + /** Frames Orca wrote to the agent with this method. */ + sent(method: string): FakeFrame[] + /** Waits for the `index`th frame Orca writes with this method. */ + frame(method: string, index?: number): Promise + settle(): Promise +} + +export async function openAcpAdapterRig( + options: { + launch?: Partial + initialize?: Record + script?: (agent: AcpScriptedAgent) => void + deps?: Partial + } = {} +): Promise { + const rig = await openProviderTimelineRig() + let current: FakeAcpChild | null = null + const spawned: string[] = [] + const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] + const settled: AcpAdapterRig['settled'] = [] + const adapter = new AcpStructuredSessionAdapter({ + spec: GROK, + resolveLaunch: async () => ({ + spec: GROK, + command: '/opt/grok/bin/grok', + args: GROK.args({ fullAccess: false }), + cwd: '/workspace/project', + env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + fullAccess: false, + resume: null, + ...options.launch + }), + connect: (launch, connectionOptions) => { + spawned.push('spawn') + const child = new FakeAcpChild(launch, connectionOptions) + current = child + const { agent } = child + agent.on('initialize', (frame) => { + spawned.push('initialize') + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + ...options.initialize + }) + }) + const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + agent.on('session/new', (frame) => agent.reply(frame, opened)) + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + options.script?.(agent) + return child + }, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent: (event) => lifecycle.push(event), + onDispatchSettledLate: (settlement) => settled.push(settlement), + mintGeneration: () => 'gen-acp', + now: () => 5_000, + ...options.deps + }) + const frames = () => current?.agent.frames ?? [] + return { + rig, + adapter, + child: () => { + if (!current) { + throw new Error('no ACP child spawned') + } + return current + }, + spawned, + lifecycle, + settled, + acquire: (acquireOptions = {}) => + adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: acquireOptions.fence ?? 1, + spawnToken: 'spawn-1', + events: rig.eventSink, + ...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}), + onSpawned: async () => { + spawned.push('onSpawned') + await acquireOptions.onSpawned?.() + } + }), + sent: (method) => frames().filter((frame) => frame.method === method), + frame: (method, index = 0) => + waitFor(() => { + const frame = frames().filter((entry) => entry.method === method)[index] + if (!frame) { + throw new Error(`no ${method} frame #${index} yet`) + } + return frame + }), + settle: async () => { + for (let round = 0; round < 5; round += 1) { + await tick() + } + await rig.rows() + } + } +} + +const HELLO: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +/** A person's send of `hello` under `clientMessageId`. */ +export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: HELLO, fence }) +} + +/** One streamed reply chunk of the turn Grok runs under `promptId`. */ +export function replyChunk(promptId: string, text: string, meta: Record = {}) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId, ...meta } + } +} + +/** A shell command Grok runs inside the turn it runs under `promptId`. */ +export function shellCall(promptId: string, status: 'in_progress' | 'completed') { + return { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: status === 'in_progress' ? 'tool_call' : 'tool_call_update', + toolCallId: 'sleep-1', + title: 'sleep 25; echo first-done', + kind: 'execute', + status + }, + _meta: { promptId } + } +} + +export function waitFor(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 2_000, interval: 5 }) +} diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts new file mode 100644 index 000000000000..d51816db450e --- /dev/null +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -0,0 +1,39 @@ +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { AcpLaunchSpec } from './acp-launch-specs' + +/** Every ACP agent's handles live in one protocol's id space; the agent part keeps them apart. */ +export const ACP_HANDLE_TRANSPORT = 'acp' + +/** Session options an ACP agent takes: its model, and the model's reasoning effort. */ +const ACP_OPTION_KEYS: ReadonlySet = new Set(['model', 'effort']) + +export function isAcpStructuredOptionKey(key: string): boolean { + return ACP_OPTION_KEYS.has(key) +} + +/** What the host knows about an ACP agent before any of its sessions runs. */ +export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAgentDefinition { + return { + agent: spec.agent, + handleTransport: ACP_HANDLE_TRANSPORT, + accountHomeVariable: spec.accountHomeVariable, + 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, + steering: 'queue', + // Orca answers every permission request the agent sends; the agent decides when it asks. + approvalEnforcement: 'orca' + }, + restingOptions: { + acceptsKey: isAcpStructuredOptionKey, + // The agent lists its models over the protocol once it runs; Orca keeps no list of its own. + fallbackModels: () => null, + effortDefaultsToModel: false + } + } +} diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts new file mode 100644 index 000000000000..92f27513cd51 --- /dev/null +++ b/src/main/acp/acp-structured-auth.test.ts @@ -0,0 +1,109 @@ +// Grok signs in on the machine it runs on: on auth required, with the API key in its own launch +// environment, else the sign-in it already cached; with neither, the chat reports it signed out. + +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const METHOD = z.object({ methodId: z.string() }) + +const BOTH = [ + { id: 'xai.api_key', name: 'API key' }, + { id: 'cached_token', name: 'Cached sign-in' } +] + +/** Grok refuses to open a session until it is authenticated. */ +function requiresSignIn(agent: AcpScriptedAgent): void { + let signedIn = false + agent.on('authenticate', (frame) => { + signedIn = true + agent.reply(frame, {}) + }) + const opens = (method: string, result: unknown) => + agent.on(method, (frame) => + signedIn ? agent.reply(frame, result) : agent.fail(frame, -32000, 'Authentication required') + ) + opens('session/new', { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS }) + opens('session/load', { configOptions: GROK_CONFIG_OPTIONS }) +} + +async function signInWith(options: { + env: Record + authMethods: { id: string; name: string }[] + resume?: boolean +}) { + const rig = await openAcpAdapterRig({ + launch: { + env: options.env, + ...(options.resume + ? { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp-key', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } + : {}) + }, + initialize: { authMethods: options.authMethods }, + script: requiresSignIn + }) + const acquired = await rig.acquire().catch((error: unknown) => error) + const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) + return { rig, acquired, methods } +} + +describe('Grok sign-in on its own machine', () => { + it("uses the API key in Grok's launch environment when Grok offers that method", async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'created' } }) + }) + + it('uses the cached sign-in without a key, or when Grok does not offer the key method', async () => { + const noKey = await signInWith({ env: {}, authMethods: BOTH }) + expect(noKey.methods).toEqual(['cached_token']) + expect(noKey.acquired).toMatchObject({ link: { origin: 'created' } }) + const keyNotOffered = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'cached_token', name: 'Cached sign-in' }] + }) + expect(keyNotOffered.methods).toEqual(['cached_token']) + }) + + it('reports Grok signed out when it offers neither, sending no sign-in', async () => { + const { acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: [{ id: 'browser', name: 'Browser' }] + }) + expect(methods).toEqual([]) + expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(acquired).toMatchObject({ reason: 'notSignedIn' }) + }) + + it('signs in the same way when it reopens a saved session', async () => { + const { rig, acquired, methods } = await signInWith({ + env: { XAI_API_KEY: 'xai-1' }, + authMethods: BOTH, + resume: true + }) + expect(methods).toEqual(['xai.api_key']) + expect(acquired).toMatchObject({ link: { origin: 'resumed' } }) + expect(rig.sent('session/new')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts new file mode 100644 index 000000000000..5b2d6bf1e275 --- /dev/null +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -0,0 +1,162 @@ +// Real children (plain Node, never an agent CLI) behind the real connection. One stops talking +// mid-turn by closing its stdout while the process keeps running: nothing exited and Orca can still +// write to it, so, as in the common pattern, the turn runs on until a Stop or close ends the agent. +// The other exits while a process it started still holds its stdout open: the exit alone ends it. + +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + sendHello +} from './acp-structured-adapter.test-support' +import { createAcpAgentConnection } from './acp-agent-connection' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +// Answers the handshake and echoes one reply chunk for the prompt, then runs `onPrompt`. +const agentScript = (onPrompt: string): string => String.raw` + const fs = require('node:fs') + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid)) + const send = (frame) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...frame }) + '\n') + let buffered = '' + process.stdin.setEncoding('utf8').on('data', (chunk) => { + buffered += chunk + let newline + while ((newline = buffered.indexOf('\n')) !== -1) { + const frame = JSON.parse(buffered.slice(0, newline)) + buffered = buffered.slice(newline + 1) + if (frame.method === 'initialize') { + send({ id: frame.id, result: { protocolVersion: 1, agentCapabilities: {} } }) + } else if (frame.method === 'session/new') { + send({ id: frame.id, result: JSON.parse(process.env.ORCA_TEST_SESSION) }) + } else if (frame.method === 'session/prompt') { + const update = { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'partial' } } + send({ + method: 'session/update', + params: { sessionId: frame.params.sessionId, update, _meta: frame.params._meta } + }) + process.stdout.write('', () => { + ${onPrompt} + }) + } + } + }) + setInterval(() => {}, 60000) +` +const CLOSES_STDOUT_MID_TURN = agentScript('fs.closeSync(1)') +// Starts a process that inherits its stdout and outlives it, records that process, then exits. +const EXITS_HOLDING_STDOUT = agentScript(String.raw` + const held = require('node:child_process').spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 60000)'], + { stdio: ['ignore', 'inherit', 'ignore'] } + ) + fs.writeFileSync(process.env.ORCA_TEST_PID_FILE + '.held', String(held.pid)) + process.exit(3) +`) + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe('ACP agent that closes its stdout but keeps running', () => { + it('leaves the turn running and the agent alive until Orca closes it', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', CLOSES_STDOUT_MID_TURN], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + }) + await rig.acquire() + await sendHello(rig, 'broken') + const turns = async () => + (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + await vi.waitFor( + async () => expect((await turns()).at(-1)).toMatchObject({ state: 'running' }), + { timeout: 10_000, interval: 20 } + ) + const pid = Number(readFileSync(pidFile, 'utf8')) + // Long enough for a closed stream to have reached the adapter. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.lifecycle).toEqual([]) + expect(alive(pid)).toBe(true) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(alive(pid)).toBe(false) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }, 30_000) +}) + +describe('ACP agent that exits while a process it started holds its stdout open', () => { + it('ends the session at the exit, with the turn interrupted', async () => { + const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-held-stdout-')), 'pid') + const rig = await openAcpAdapterRig({ + deps: { + connect: (_launch, options) => + createAcpAgentConnection( + { + command: process.execPath, + args: ['-e', EXITS_HOLDING_STDOUT], + cwd: process.cwd(), + env: { + ORCA_TEST_PID_FILE: pidFile, + ORCA_TEST_SESSION: JSON.stringify({ + sessionId: PROVIDER_SESSION, + configOptions: GROK_CONFIG_OPTIONS + }) + } + }, + options + ) + } + }) + onTestFinished(async () => { + await rig.adapter.closeAll().catch(() => {}) + const held = Number(readFileSync(`${pidFile}.held`, 'utf8')) + if (alive(held)) { + process.kill(held) + } + }) + await rig.acquire() + await sendHello(rig, 'held') + await vi.waitFor(() => expect(rig.lifecycle).toHaveLength(1), { timeout: 10_000, interval: 20 }) + expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) + expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }, 30_000) +}) diff --git a/src/main/acp/acp-structured-connection.ts b/src/main/acp/acp-structured-connection.ts new file mode 100644 index 000000000000..1ac06b7620d8 --- /dev/null +++ b/src/main/acp/acp-structured-connection.ts @@ -0,0 +1,54 @@ +// The ACP agent as the adapter holds it: one connection that owns both the agent's process and its +// protocol (`createAcpAgentConnection`). Supervision, the process-tree teardown, Windows-safe +// spawning and stdio are the connection's; turns, Stop and steering are the adapter's. + +import type { AcpAgentConnection, AcpAgentConnectionOptions } from './acp-agent-connection' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' + +/** What the adapter uses of a connection; tests stand in a scripted agent behind the same surface. */ +export type AcpStructuredConnection = Pick< + AcpAgentConnection, + | 'initialize' + | 'start' + | 'prompt' + | 'cancel' + | 'setConfigOption' + | 'setModel' + | 'subscribe' + | 'closed' + | 'spawned' + | 'pid' + | 'exited' + | 'stderrTail' + | 'onExit' + | 'pauseReading' + | 'resumeReading' + | 'close' + | 'processTreeUnproven' +> + +export type ConnectAcpAgent = ( + launch: ProviderProcessLaunch, + options: AcpAgentConnectionOptions +) => AcpStructuredConnection + +/** Resolves once `connection`'s process exits, `signal` aborts, or `ms` pass, whichever is first. */ +export function waitForAcpExit( + connection: AcpStructuredConnection, + ms: number, + signal: AbortSignal +): Promise { + if (connection.exited || signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + connection.onExit(done) + }) +} diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts new file mode 100644 index 000000000000..0a63542a1997 --- /dev/null +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -0,0 +1,33 @@ +// The ACP lane runs in whatever process hosts the runtime, the headless one included, so nothing +// it reaches may import Electron. + +import { resolve } from 'node:path' +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 () => { + 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') + ], + bundle: true, + write: false, + outdir: 'out', + platform: 'node', + format: 'esm', + target: 'node22', + packages: 'external', + metafile: true, + logLevel: 'silent' + }) + const imports = Object.values(result.metafile.outputs).flatMap((output) => + output.imports.map((entry) => entry.path) + ) + expect(imports.filter((path) => path === 'electron' || path.startsWith('electron/'))).toEqual( + [] + ) + }, 60_000) +}) diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts new file mode 100644 index 000000000000..c4cf1605c797 --- /dev/null +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -0,0 +1,102 @@ +// Plays a recorded Grok session back as the agent behind the real adapter. Orca's own frames +// advance the recording: the agent's lines after each one are sent until the recording waits on +// Orca again. The recording's prompt ids and session id are rewritten to the ones this run uses, +// as the real agent echoes the prompt id it was sent. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AcpFixtureFrame } from './acp-timeline-fixture.test-support' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { PROVIDER_SESSION } from './acp-structured-adapter.test-support' + +/** Recordings name their sessions `session-N`; a replay plays them all as this run's one. */ +const RECORDED_SESSION = /"session-\d+"/g +const promptMetaSchema = z.object({ _meta: z.object({ promptId: z.string() }) }) +const messageSchema = z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() +}) + +function promptIdOf(params: unknown): string | undefined { + return promptMetaSchema.safeParse(params).data?._meta.promptId +} + +export class GrokFixtureReplay { + private cursor = 0 + private agent: AcpScriptedAgent | null = null + private readonly promptIds = new Map() + private readonly requestIds = new Map() + + constructor(private readonly frames: AcpFixtureFrame[]) {} + + attach(agent: AcpScriptedAgent): void { + this.agent = agent + agent.on('session/prompt', (frame) => this.orcaSent('session/prompt', frame)) + agent.on('session/cancel', (frame) => this.orcaSent('session/cancel', frame)) + } + + /** What the recording waits for Orca to send next; null once it has played to the end. */ + get awaiting(): string | null { + const next = this.frames[this.cursor] + if (!next) { + return null + } + return next.direction === 'out' ? (next.message.method ?? 'reply') : 'agent' + } + + private orcaSent(method: string, frame: FakeFrame): void { + const recorded = this.frames[this.cursor] + expect(recorded?.direction).toBe('out') + expect(recorded?.message.method).toBe(method) + if (method === 'session/prompt' && recorded) { + const recordedId = promptIdOf(recorded.message.params) + const sentId = promptIdOf(frame.params) + if (recordedId && sentId) { + this.promptIds.set(recordedId, sentId) + } + this.requestIds.set(recorded.message.id ?? '', frame.id) + } + this.cursor += 1 + this.play() + } + + private play(): void { + const agent = this.agent + while (agent && this.frames[this.cursor]?.direction === 'in') { + const recorded = this.frames[this.cursor]! + this.cursor += 1 + const message = this.rewrite(recorded.message) + if (message.method !== undefined && message.id !== undefined) { + // The agent asks Orca something; the recording resumes once Orca answers it. + void agent.request(message.id, message.method, message.params).then(() => { + expect(this.frames[this.cursor]?.direction).toBe('out') + this.cursor += 1 + this.play() + }) + return + } + if (message.method !== undefined) { + agent.notify(message.method, message.params) + } else { + agent.send({ + jsonrpc: '2.0', + id: this.requestIds.get(message.id ?? '') ?? message.id, + result: message.result + }) + } + } + } + + private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + let text = JSON.stringify(message).replaceAll( + RECORDED_SESSION, + JSON.stringify(PROVIDER_SESSION) + ) + for (const [recorded, sent] of this.promptIds) { + text = text.replaceAll(JSON.stringify(recorded), JSON.stringify(sent)) + } + return messageSchema.parse(JSON.parse(text)) + } +} diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts new file mode 100644 index 000000000000..a656ac08de4c --- /dev/null +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -0,0 +1,120 @@ +// A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one +// `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the +// adapter's failure with Grok's last words. Whether Grok's pipes closed before its exit was seen +// changes only what the chat shows in between. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../shared/agent-session-stop-row-identity' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLifecycleBatchInput } from '../native-chat/agent-session-journal/journal-store-contracts' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { replyChunk, shellCall, waitFor } from './acp-structured-adapter.test-support' +import { openAttachedHostRig, promptIdOf, send } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const LAST_WORDS = 'qa-cell20-last-words' + +/** Grok mid-reply ("Oak") with a command running, every lifecycle batch the journal takes recorded. */ +async function midReply() { + const rig = await openAttachedHostRig() + const { host } = rig + await send(host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Oak')) + child.agent.notify('session/update', shellCall(promptIdOf(prompt), 'in_progress')) + await rig.rig.settle() + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const batches: JournalLifecycleBatchInput[] = [] + const append = journal.appendLifecycleBatch.bind(journal) + vi.spyOn(journal, 'appendLifecycleBatch').mockImplementation((batch) => { + batches.push(batch) + return append(batch) + }) + child.stderr = LAST_WORDS + return { ...rig, child, batches } +} + +async function settledCrash(rig: Awaited>) { + const providerExits = () => + rig.batches.filter((batch) => + batch.settlementId.startsWith(`dead-generation:${PROVIDER_EXIT_ROW_PREFIX}`) + ) + await waitFor(() => expect(providerExits()).toHaveLength(1)) + const rows = await rig.rows() + const turn = rows.find((row) => readAgentJournalTurn(row.body) !== null) + const statuses = rows.filter((row) => row.body.kind === 'status') + return { batch: providerExits()[0]!, rows, turn, statuses } +} + +function expectOneTurnScopedCutShortRow(settled: Awaited>) { + const { turn, statuses, rows } = settled + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'interrupted' }) + expect(statuses).toHaveLength(1) + const [row] = statuses + expect(decodeURIComponent(row?.itemId ?? '')).toContain(PROVIDER_EXIT_ROW_PREFIX) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect( + readAgentSessionFailureFact(row?.body.kind === 'status' ? row.body.failure : undefined) + ).toEqual({ kind: 'providerExited', detail: { text: LAST_WORDS, audience: 'person' } }) + expect(rows.find((item) => item.body.kind === 'tool-call')?.body).toMatchObject({ + state: 'failed', + endedAs: 'interrupted' + }) +} + +describe('a Grok crash with its exit seen first', () => { + it('ends the turn at the exit itself; the provider-exit batch only reports on it', async () => { + const rig = await midReply() + rig.child.exit() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The child's own end landed first, interrupted at the exit and never `unverifiable`: the host + // has no turn or call of it left to revise. + expect( + settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind) + ).toEqual(['status']) + await rig.host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose pipes close before its exit is seen', () => { + it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => { + const rig = await midReply() + // The child outlives its stream until Orca's close of it is proven. + let exitNow: () => void = () => undefined + rig.child.proveClose = () => + new Promise((resolve) => { + exitNow = () => { + rig.child.exit() + resolve(true) + } + }) + rig.child.agent.close() + await waitFor(async () => + expect((await rig.turns()).map((turn) => turn.state)).toEqual(['unverifiable']) + ) + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + + exitNow() + const settled = await settledCrash(rig) + expectOneTurnScopedCutShortRow(settled) + // The turn's revision, its call's and the row land together. + const turnKey = settled.turn!.itemId + expect( + settled.batch.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [agentJournalItemKey(mutation.identity)] : [] + ) + ).toEqual(expect.arrayContaining([settled.statuses[0]!.itemId, turnKey])) + // No later stale-session pass rewrites it: nothing is left for one. + expect(rig.batches.filter((batch) => batch.settlementId.includes('stale-session'))).toEqual([]) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts new file mode 100644 index 000000000000..a1486cd3263e --- /dev/null +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -0,0 +1,287 @@ +// The ACP adapter behind the real host: the host's own order (a fresh sink per start, bound only +// once the start proved its owner; Close and Stop reaching a start from outside the session's +// queue), with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { + GROK_CONFIG_OPTIONS, + PROVIDER_SESSION, + replyChunk, + waitFor +} from './acp-structured-adapter.test-support' +import { + attachParams, + hello, + launch, + openAttachedHostRig, + openHostRig, + promptIdOf, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** On every reopen, Grok sends what its load may carry: the saved `reply` marked as replay, and a + * task the dead process left running, ended by the restart. */ +function loads(reply: string, count: { loads: number }) { + return (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + count.loads += 1 + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) +} + +describe('reopening a Grok chat through the host', () => { + it('loads a chat the journal holds and writes its exchange once', async () => { + const count = { loads: 0 } + let resumed = false + const { host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: loads('hi', count), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('hi', true) + expect(await messages()).toEqual(['hello', 'hi']) + const before = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(count.loads).toBe(1) + expect(await messages()).toEqual(['hello', 'hi']) + const after = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(after.filter((row) => row.itemId.includes('background-task'))).toEqual([]) + expect(after.filter((row) => readAgentJournalTurn(row.body))).toHaveLength( + before.filter((row) => readAgentJournalTurn(row.body)).length + ) + await host.close(SESSION, 'user-close') + }) + + it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => { + let resumed = false + const { rig, host, fence, messages, exchange } = await openHostRig({ + initialize: RESUMES, + script: loads('complete saved reply', { loads: 0 }), + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await exchange('complete', false) + // Grok dies mid-reply; the host ends its record, which moves the chat's fence. + const cutAt = fence() + rig.child().exit() + await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await host.flushStreamedEvents(SESSION) + expect(await messages()).toEqual(['hello', 'complete']) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain( + 'completed' + ) + // The transcript explains the cut the way a Claude or Codex chat's does. + const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' }) + expect( + transcript.some( + (row) => + row.body.kind === 'status' && + (row.itemId.includes('cut-turn-notice') || + readAgentSessionFailureFact(row.body.failure)?.kind === 'providerExited') + ) + ).toBe(true) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok crash whose exit is not proven yet', () => { + it('rejects a send meanwhile as never sent, and a later Stop does not turn the crash into one', async () => { + const { rig, host, rows } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + child.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.stderr = 'panic: out of memory' + // Both pipes close as the process dies, before its exit is seen. + child.agent.close() + await rig.settle() + const next = await send(host, 'next') + await waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === next + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: 'Grok stopped before this message was sent.' + }) + ) + await stop(host) + proves = true + child.exit() + await rig.settle() + await host.flushStreamedEvents(SESSION) + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + // The crash's own notice, as for any crash; not a Stop's. + expect( + transcript.flatMap((row) => + row.body.kind === 'status' + ? [{ text: row.body.text, failure: readAgentSessionFailureFact(row.body.failure)?.kind }] + : [] + ) + ).toEqual([ + { + text: 'Grok stopped while this response was in progress. You can continue in this conversation.', + failure: 'providerExited' + } + ]) + await host.close(SESSION, 'user-close') + }) +}) + +describe('closing or stopping a Grok chat while it starts', () => { + it('keeps a failed start whose child is not proven gone; a close leaves it, the next start asks it again', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.proveClose = vi.fn(async () => false) + await host.close(SESSION, 'user-close') + expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + child.proveClose = vi.fn(async () => { + child.exit() + return true + }) + await host.close(SESSION, 'user-close') + expect(child.proveClose).not.toHaveBeenCalled() + expect(child.exited).toBe(false) + // As Claude's: the next start asks the child again before it spawns another. + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(child.exited).toBe(true)) + await waitFor(() => expect(rig.child()).not.toBe(child)) + await host.close(SESSION, 'user-close') + }) + + it('lets a Stop reach a child Grok never finished initializing', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + // With no close behind it. + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect((await attaching).ok).toBe(false) + expect(await stopping).toMatchObject({ ok: true }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + }) + + it('cancels a queued send whose start a Stop ended, with no start failure in the chat', async () => { + let spawns = 0 + const { rig, host } = await openHostRig({ + script: (agent) => { + spawns += 1 + if (spawns > 1) { + agent.on('initialize', () => {}) + } + } + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again, and that start never answers. + await host.close(SESSION, 'user-close') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: hello }), + body: hello + }) + await waitFor(() => expect(spawns).toBe(2)) + await rig.frame('initialize') + const restarted = rig.child() + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(restarted.exited).toBe(true) + await host.flushStreamedEvents(SESSION) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + await host.close(SESSION, 'user-close') + }) + + it('leaves a start alone for a Stop that names a turn of a child already gone', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + const stopping = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), + turnId: 'turn-of-an-old-child' + }) + await rig.settle() + expect(rig.child().closes).toBe(0) + await host.close(SESSION, 'user-close') + await attaching + await stopping + }) + + it('never spawns a child for a start closed while its launch was still resolving', async () => { + let releaseLaunch: () => void = () => {} + const resolving = { started: false } + const { rig, host } = await openHostRig({ + deps: { + resolveLaunch: async () => { + resolving.started = true + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return launch(() => false)() + } + } + }) + const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error) + await waitFor(() => expect(resolving.started).toBe(true)) + const closing = host.close(SESSION, 'user-close') + releaseLaunch() + expect(await attaching).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + await closing + expect(rig.spawned).toEqual([]) + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts new file mode 100644 index 000000000000..32f0a6df64f6 --- /dev/null +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -0,0 +1,88 @@ +// A model or effort pick Grok never answers, through the real host: it must not hold the chat's +// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +function pick(host: StructuredAgentSessionHost, value: string) { + const fields = { key: 'model', value } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +/** Whether `promise` settled within `ms`, and how. */ +async function within(promise: Promise, ms: number) { + let timer: ReturnType | undefined + const late = new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), ms) + }) + try { + return await Promise.race([ + promise.then( + (value) => ({ settled: 'resolved' as const, value }), + (error: unknown) => ({ settled: 'rejected' as const, value: error }) + ), + late + ]) + } finally { + clearTimeout(timer) + } +} + +const failed = (outcome: Awaited>) => + outcome !== 'pending' && + (outcome.settled === 'rejected' || + (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value + ? outcome.value.ok === false + : false)) + +describe('a Grok model pick Grok never answers', () => { + it('does not hold a Stop behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(failed(await within(picking, 1_000))).toBe(true) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') + }) + + it('does not hold a Close behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ + settled: 'resolved' + }) + expect(failed(await within(picking, 1_000))).toBe(true) + }) + + it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(failed(await within(picking, 2_000))).toBe(true) + // The chat's queue moved on: a later operation runs. + expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { + configOptions: GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + ) + }) + await waitFor(async () => + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + ) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts new file mode 100644 index 000000000000..519d19e6ce70 --- /dev/null +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -0,0 +1,302 @@ +// A Grok session Grok cannot reopen, through the real host, record store, journal and launch +// resolver: a fresh session continues the chat, the chain records what it replaced, and the chat +// says so once, unless nothing was ever exchanged on the lost session. A start that succeeds +// writes the row an earlier attach failure dropped. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey +} from '../../shared/agent-session-provider-handle' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { GROK, GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' +import { ACP_HANDLE_TRANSPORT } from './acp-structured-agent-definitions' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' +import { + attachParams, + openHostRig, + promptIdOf, + RESUMES, + send +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const FRESH = 'grok-fresh-session' +const FRESH_2 = 'grok-second-fresh-session' +const keyOf = (nativeId: string) => + agentSessionProviderHandleKey({ transport: ACP_HANDLE_TRANSPORT, agent: 'grok', nativeId }) + +type Lost = Map + +/** Grok opens sessions in turn and cannot reopen any session in `lost`; it reopens every other. */ +function sessionsLost(loads: string[], lost: Lost) { + const opened = [PROVIDER_SESSION, FRESH, FRESH_2] + return (agent: AcpScriptedAgent) => { + agent.on('session/new', (frame) => { + agent.reply(frame, { + sessionId: opened.shift() ?? 'grok-unexpected-session', + configOptions: GROK_CONFIG_OPTIONS + }) + }) + agent.on('session/load', (frame) => { + const { sessionId } = z.object({ sessionId: z.string() }).parse(frame.params) + loads.push(sessionId) + const failure = lost.get(sessionId) + if (failure) { + agent.fail(frame, failure.code, failure.message) + } else { + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + } + }) + } +} + +async function openRestoreRig(code: number, message: string) { + const loads: string[] = [] + const lost: Lost = new Map([[PROVIDER_SESSION, { code, message }]]) + const resolver: { resolve: ReturnType | null } = { + resolve: null + } + const opened = await openHostRig({ + initialize: RESUMES, + script: sessionsLost(loads, lost), + deps: { + resolveLaunch: (input) => { + if (!resolver.resolve) { + throw new Error('launch resolver not ready') + } + return resolver.resolve(input) + } + } + }) + const { rig, host, store, journalDatabase } = opened + resolver.resolve = createAcpStructuredLaunchResolver(GROK, { + store, + readJournal: (sessionId) => replayJournal(journalDatabase.db, sessionId), + resolveWorkspacePath: async () => '/workspace', + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/fake/grok' + }) + const warnings = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items.filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + } + /** A send Grok answers in `providerSession`, ending its turn. */ + const exchange = async (text: string, reply: string, providerSession: string) => { + await send(host, text) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ sessionId: providerSession }) + rig.child().agent.notify('session/update', { + sessionId: providerSession, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: reply } }, + _meta: { promptId: promptIdOf(prompt) } + }) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { ...opened, loads, lost, warnings, exchange } +} + +describe('a saved Grok session Grok cannot reopen', () => { + it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { + const { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + const rows = await warnings() + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + kind: 'status', + tone: 'warning', + text: "Grok couldn't reopen its earlier session, so this chat continues in a new one. Grok doesn't remember the earlier messages." + }) + + // The chat keeps working in the fresh session. + await exchange('still there?', 'yes', FRESH) + const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => (row.body.kind === 'message' ? row.body.blocks : [])) + expect(messages.flat()).toEqual( + expect.arrayContaining([ + { type: 'text', text: 'still there?' }, + { type: 'text', text: 'yes' } + ]) + ) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a created Grok session Grok reports missing on the first reopen', () => { + it('replaces it and says so once when the chat exchanged a turn on it', async () => { + const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await exchange('remember 42', 'noted', PROVIDER_SESSION) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(head?.supersedesKey).toBeUndefined() + expect(await warnings()).toHaveLength(1) + // The person still sees what Grok forgot. + expect(await messages()).toEqual(expect.arrayContaining(['remember 42', 'noted'])) + + // The next reopen loads the fresh session, and says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('supersedes it silently when nothing was exchanged on it', async () => { + const { host, store, fence, loads, warnings } = await openRestoreRig( + -32002, + 'Resource not found' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + + expect(loads).toEqual([PROVIDER_SESSION]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(1) + const head = agentSessionProviderHandleChainHead(chain) + expect(head).toMatchObject({ + origin: 'created', + handle: { nativeId: FRESH }, + supersedesKey: keyOf(PROVIDER_SESSION) + }) + expect(head?.replaces).toBeUndefined() + expect(await warnings()).toEqual([]) + await host.close(SESSION, 'user-close') + }) +}) + +describe('the warning row of a replacement whose attach failed', () => { + async function replaceThenFailAttach() { + const opened = await openRestoreRig(-32603, 'session file is corrupt') + const { host, store, fence, journalDatabase, warnings } = opened + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + // The journal's open fails after the fresh session's link is durable, before its row is written. + vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { + throw new Error('journal path unavailable') + }) + await expect(host.attach(CALLER, attachParams(fence()))).rejects.toThrow( + 'journal path unavailable' + ) + expect( + agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) + ).toMatchObject({ + handle: { nativeId: FRESH }, + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toEqual([]) + return opened + } + + it('is written by the next start that loads the fresh session, and only once', async () => { + const { host, fence, loads, warnings } = await replaceThenFailAttach() + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) + + it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => { + const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach() + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] + expect(chain).toHaveLength(2) + expect(agentSessionProviderHandleChainHead(chain)).toMatchObject({ + handle: { nativeId: FRESH_2 }, + supersedesKey: keyOf(FRESH), + replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) + +describe('a replacement Grok never saved, superseded after its row was written', () => { + it('keeps the one row: the conversation lost is still the first one', async () => { + const { host, fence, loads, lost, warnings } = await openRestoreRig( + -32603, + 'session file is corrupt' + ) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + lost.set(FRESH, { code: -32002, message: 'Resource not found' }) + + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + expect(await warnings()).toHaveLength(1) + // The newer session carries the same loss, so reopening it says nothing new. + await host.close(SESSION, 'user-close') + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) + expect(await warnings()).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts new file mode 100644 index 000000000000..216a495e1bc9 --- /dev/null +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -0,0 +1,209 @@ +// A Grok start the host aborts — at quit, by a close, or by an admitted Stop — through the one +// signal the host owns, with a scripted Grok, the real record store and an on-disk journal. + +import { afterEach, describe, expect, it } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' +import { + attachParams, + launch, + openHostRig, + RESUMES, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const STALLED = Symbol('stalled') +const within = (promise: Promise, ms: number): Promise => + Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, ms, STALLED)) + ]) + +/** Runs `atEnd` once, right before the host ends an attach's controller: after its commit. */ +function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): void { + const aborts = host.collaboratorsForTests().runtimeState.acquireAborts + const begin = aborts.begin.bind(aborts) + let fired = false + aborts.begin = (sessionId) => { + const started = begin(sessionId) + return { + signal: started.signal, + end: () => { + if (!fired) { + fired = true + atEnd() + } + started.end() + } + } + } +} + +/** Runs `during` once, while the attach is still probing the previous owner, before any launch. */ +function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): void { + const state = host.collaboratorsForTests().runtimeState + const probe = state.probeOwner.bind(state) + let fired = false + state.probeOwner = async (sessionId) => { + if (!fired) { + fired = true + during() + } + return probe(sessionId) + } +} + +describe('a Grok start the host aborts', () => { + it('quits promptly while Grok never answers its handshake, and stops the child', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const attaching = host.attach(CALLER, attachParams()) + await rig.frame('initialize') + // The start has no bound of its own: the quit is what ends it. + expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) + expect((await attaching).ok).toBe(false) + expect(rig.child().exited).toBe(true) + await rig.adapter.closeAll() + }) + + it('closes a chat whose start already returned as a close Orca asked for, not a crash', async () => { + const { rig, host, store } = await openHostRig() + const ended: unknown[] = [] + const handle = host.handleAdapterEvent.bind(host) + host.handleAdapterEvent = (event) => { + if (event.type === 'ended') { + ended.push(event) + } + return handle(event) + } + let closing: Promise | undefined + beforeAttachEnds(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await closing + expect(rig.child().exited).toBe(true) + expect(ended).toMatchObject([{ cause: 'requested-close' }]) + expect(store.getRecord(SESSION)?.lease.deathEvidence?.detail).not.toContain('exited') + }) + + it('never launches Grok for a chat closed while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + let closing: Promise | undefined + duringOwnerProbe(host, () => { + closing = host.close(SESSION, 'user-close') + }) + expect( + await host.attach(CALLER, attachParams()).catch((error: unknown) => error) + ).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(await within(closing!, 2_000)).not.toBe(STALLED) + expect(rig.spawned).not.toContain('spawn') + }) + + it('never launches Grok for a start a Stop reached while its attach was still reconciling', async () => { + const { rig, host } = await openHostRig() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + // The chat closes; the next send has to start Grok again. + await host.close(SESSION, 'user-close') + let stopping: ReturnType | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + }) + await send(host, 'hello') + await waitFor(() => expect(stopping).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await host.flushStreamedEvents(SESSION) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'rejected', rejection: { kind: 'cancelled' } } + ]) + }) + + it('starts Grok afresh for a message sent right after a Stop reached a reconciling start', async () => { + const { rig, host } = await openClosedResumableChat() + let stopping: ReturnType | undefined + let second: Promise | undefined + duringOwnerProbe(host, () => { + stopping = stop(host) + // Accepted while the aborted start is still unwinding. + second = send(host, 'second') + }) + await send(host, 'hello') + await waitFor(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await expectDeliveredAfterStop({ rig, host }, await second!) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(2) + }) + + it('starts Grok afresh for a message sent right after a Stop ended a hung handshake', async () => { + const { rig, host, children } = await openClosedResumableChat({ hangsHandshake: 2 }) + const first = send(host, 'hello') + // The restart's own child, not the first one's. + await waitFor(() => expect(children()).toBe(2)) + await rig.frame('initialize') + const stopping = stop(host) + const second = send(host, 'second') + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await first + await expectDeliveredAfterStop({ rig, host }, await second) + expect(children()).toBe(3) + }) +}) + +/** A Grok chat the user closed; each later start loads it. `hangsHandshake`: that child never + * answers `initialize`. */ +async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) { + let children = 0 + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => { + if (++children === options.hangsHandshake) { + agent.on('initialize', () => {}) + } + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + ) + }, + deps: { resolveLaunch: launch(() => resumed) } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + await rig.host.close(SESSION, 'user-close') + return { ...rig, children: () => children } +} + +/** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */ +async function expectDeliveredAfterStop( + { rig, host }: Pick>, 'rig' | 'host'>, + secondId: string +): Promise { + const prompt = await rig.frame('session/prompt') + expect(JSON.stringify(prompt.params)).toContain('second') + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await waitFor(() => + expect( + journal.submissions().find((entry) => entry.clientMessageId === secondId)?.dispatchState + ).toBe('accepted') + ) + expect(journal.submissions().map((entry) => entry.rejection?.kind ?? null)).toEqual([ + 'cancelled', + null + ]) + await host.flushStreamedEvents(SESSION) + const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) +} diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts new file mode 100644 index 000000000000..2e6677390d65 --- /dev/null +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -0,0 +1,184 @@ +// A Grok chat's Stop through the real host: it ends the process once Grok settles its turn, and the +// next send resumes the session. + +import { afterEach, describe, expect, it } from 'vitest' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' +import { + closeProviderTimelineRigs, + providerTurnId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { PROVIDER_SESSION, replyChunk, waitFor } from './acp-structured-adapter.test-support' +import { + framesOf, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +describe('a Grok chat Stop', () => { + it('ends Grok once it settles the turn, and the next send resumes with no notice', async () => { + const { rig, host, count, rows, turns, messages } = await openAttachedHostRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + // Grok takes a moment to wind the turn down; the host waits for it before ending the process. + const answered: { cancel: boolean; atClose: boolean | null } = { cancel: false, atClose: null } + first.agent.on('session/cancel', () => + setTimeout(() => { + answered.cancel = true + first.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 50) + ) + const close = first.close.bind(first) + first.close = async () => { + answered.atClose ??= answered.cancel + return close() + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/cancel')).toHaveLength(1) + expect(answered.atClose).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + + await send(host, 'again') + await waitFor(() => expect(rig.child()).not.toBe(first)) + const second = rig.child() + await rig.frame('session/prompt') + expect(count.loads).toBe(1) + expect(framesOf(second, 'session/new')).toHaveLength(0) + expect(await messages()).toEqual(['hello', 'partial', 'again']) + // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. + const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + expect( + transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) + ).toEqual(['Cancellation requested.']) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok at the grace when Grok never reads the cancel, never waiting on its write', async () => { + const { rig, host, turns } = await openAttachedHostRig({ stopGraceMs: 30 }) + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + // A write that never completes, as a full pipe to a Grok that stopped reading. + let cancels = 0 + first.cancel = () => { + cancels += 1 + return new Promise(() => {}) + } + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(cancels).toBe(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) + + it('ends Grok on a Stop of a turn it began itself, once that turn ends', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + await rig.settle() + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + await host.close(SESSION, 'user-close') + }) + + it('leaves no Grok behind whose background work could begin a turn after the Stop', async () => { + const { rig, host, rows, turns } = await openAttachedHostRig() + const first = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'started a build')) + first.agent.notify('x.ai/task_backgrounded', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'task_backgrounded', task_id: 'build-1', command: 'make all' } + }) + first.agent.on('session/cancel', () => first.agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await host.flushStreamedEvents(SESSION) + await stop(host) + await waitFor(() => expect(first.exited).toBe(true)) + // The task ended with Grok's process; Orca cannot say how, so its row says no more than that. + const task = (await rows()).find((row) => row.itemId.includes('background-task')) + expect(task?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "make all" stopped reporting' }, + { type: 'background-task', taskId: 'build-1', state: 'unverifiable' } + ] + }) + const settled = await turns() + // What a kept process would do once its backgrounded build finished. + first.agent.notify('session/update', replyChunk('task-completed-background-1', 'Build done')) + await rig.settle() + expect(await turns()).toEqual(settled) + expect(settled.map((turn) => turn.state)).not.toContain('running') + await host.close(SESSION, 'user-close') + }) +}) + +describe('a Grok chat Stop naming a turn that has ended', () => { + it('stops neither the turn running now nor Grok', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const current = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(current), 'working')) + await rig.settle() + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + await rig.settle() + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().exited).toBe(false) + expect((await turns()).at(-1)).toMatchObject({ state: 'running' }) + await host.close(SESSION, 'user-close') + }) + + it('ends Grok in the gap before it echoes the next prompt, as a Claude Stop does', async () => { + const { rig, host } = await openAttachedHostRig() + const { agent } = rig.child() + await send(host, 'old') + const old = await rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(old), 'done')) + agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await send(host, 'new') + const next = await rig.frame('session/prompt', 1) + agent.on('session/cancel', () => agent.reply(next, { stopReason: 'cancelled' })) + await host.flushStreamedEvents(SESSION) + const stopped = await stop(host, providerTurnId(promptIdOf(old), PROVIDER_SESSION)) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(rig.child().exited).toBe(true)) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1) + await host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts new file mode 100644 index 000000000000..afc1c1c42401 --- /dev/null +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -0,0 +1,200 @@ +// The ACP adapter behind the real host, with a scripted Grok, the real record store and an +// on-disk journal. + +import { expect } from 'vitest' +import { z } from 'zod' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { messageText } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + GROK, + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { + ACP_HANDLE_TRANSPORT, + acpStructuredAgentDefinition +} from './acp-structured-agent-definitions' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +export const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +export const attachParams = (fence: number | null = null) => + hostTestAttachParams(fence, { + provider: 'grok', + agent: 'grok', + accountHome: { variable: 'GROK_HOME', path: '/grok' }, + providerHandle: undefined + }) + +/** A launch that resumes the provider session once the chat has one. */ +export function launch(resume: () => boolean): () => Promise { + return async () => ({ + spec: GROK, + command: '/fake/grok', + args: [], + cwd: '/workspace', + env: {}, + fullAccess: false, + resume: resume() + ? { + sessionId: PROVIDER_SESSION, + key: agentSessionProviderHandleKey({ + transport: ACP_HANDLE_TRANSPORT, + agent: 'grok', + nativeId: PROVIDER_SESSION + }), + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + : null + }) +} + +export async function openHostRig( + options: { + script?: (agent: AcpScriptedAgent) => void + initialize?: Record + deps?: Partial + } = {} +) { + const state = hostTestState() + const store = await openTestAgentSessionRecordStore(state.root) + let generation = 0 + // As the runtime wires it: every exit and late send settlement the adapter observes reaches the host. + const hosted: { host: StructuredAgentSessionHost | null } = { host: null } + const journalDatabase = openTestJournalHostDatabase(state.root) + const rig = await openAcpAdapterRig({ + ...options, + deps: { + onEvent: (event) => void hosted.host?.handleAdapterEvent(event), + onDispatchSettledLate: (settlement) => void hosted.host?.settleLateDispatch(settlement), + now: () => HOST_TEST_NOW, + readProcessStartTime: async () => 1_700_000_000_000 + ++generation, + mintGeneration: () => `generation-${generation}`, + ...options.deps + } + }) + const host = new StructuredAgentSessionHost({ + agents: new StructuredAgentRegistry([ + { definition: acpStructuredAgentDefinition(GROK), adapter: rig.adapter } + ]), + logger: state.log.logger, + store, + adapter: rig.adapter, + journalDatabase, + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + hosted.host = host + replaceHostTestState({ store, host }) + const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + const messages = async () => + (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items + .filter((row) => row.body.kind === 'message') + .map((row) => messageText(row.body)) + /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ + const exchange = async (text: string, end: boolean) => { + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + fence: fence(), + turnScope: { kind: 'thread' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'm1', + body: hello, + fence: fence() + }) + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', text)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() + await host.flushStreamedEvents(SESSION) + } + return { rig, host, store, journalDatabase, fence, messages, exchange } +} + +/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ +export const RESUMES = { + agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } +} + +const promptMeta = z.object({ _meta: z.object({ promptId: z.string() }) }) +export const promptIdOf = (frame: FakeFrame): string => + promptMeta.parse(frame.params)._meta.promptId + +export function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = message(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +export function stop(host: StructuredAgentSessionHost, turnId?: string) { + return turnId === undefined + ? host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +export const framesOf = (child: FakeAcpChild, method: string) => + child.agent.frames.filter((frame) => frame.method === method) + +/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ +export async function openAttachedHostRig(deps: Partial = {}) { + const count = { loads: 0 } + let resumed = false + const rig = await openHostRig({ + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + count.loads += 1 + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }), + deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + resumed = true + const rows = async () => { + await rig.host.flushStreamedEvents(SESSION) + return (await rig.host.history({ sessionId: SESSION, direction: 'tail' })).page.items + } + const turns = async () => (await rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + return { ...rig, count, rows, turns } +} diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts new file mode 100644 index 000000000000..958d20dc47ef --- /dev/null +++ b/src/main/acp/acp-structured-lane.ts @@ -0,0 +1,158 @@ +// One ACP child's path into the journal: the translator turns its traffic into grammar events, and +// the shared assembler writes them. Events apply strictly in order; one the sink refuses under +// backpressure holds every later one until the sink drains, so nothing is reordered or dropped. + +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler +} from '../native-chat/agent-session-timeline/provider-timeline-assembler' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +/** How long a held event waits for the sink to say it drained before trying again on its own. */ +const BACKPRESSURE_RETRY_MS = 250 + +export type AcpStructuredLaneDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: string + /** The agent's name as a person reads it, for the rows that name it. */ + agentName: string + generation: string + providerSessionId: string + dialect: AcpDialect + /** A send of Orca's reached the agent: the turn it opened has its first provider event. */ + onInputAccepted: (clientMessageId: string) => void + /** The sink refused for good; nothing more this child says can be journaled. */ + onFailed: (reason: string) => void +} + +export class AcpStructuredLane { + readonly translator: AcpTimelineTranslator + private readonly assembler: ProviderTimelineAssembler + private readonly backlog: ProviderTimelineEvent[] = [] + private readonly turnWatchers = new Set<() => void>() + private readonly requestIdentity: ReturnType + private retryTimer: ReturnType | null = null + private failed = false + private disposed = false + + constructor(private readonly deps: AcpStructuredLaneDeps) { + this.translator = new AcpTimelineTranslator({ + sessionId: deps.providerSessionId, + dialect: deps.dialect, + agentName: deps.agentName + }) + this.requestIdentity = createLegacyProviderTimelineIdentityScheme({ + agent: deps.agent, + sessionId: deps.sessionId + }) + this.assembler = createProviderTimelineAssembler({ + sink: deps.sink, + sessionId: deps.sessionId, + agent: deps.agent, + generation: deps.generation, + namespace: deps.providerSessionId + }) + } + + get openTurnId(): string | null { + return this.assembler.openTurnId + } + + apply(events: readonly ProviderTimelineEvent[]): void { + if (this.failed || this.disposed) { + return + } + this.backlog.push(...events) + this.drain() + } + + /** Resolves once `turnId` is no longer the open turn, or nothing more can be written. */ + whenTurnLeaves(turnId: string): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.failed || this.disposed || this.openTurnId !== turnId) { + this.turnWatchers.delete(check) + resolve() + } + } + this.turnWatchers.add(check) + check() + }) + } + + /** The sink drained: whatever it held back goes now. */ + retry(): void { + this.drain() + } + + /** Whether the journal row `itemId` is the row of request `requestKey`, which the assembler + * writes under this acquisition's generation (any incarnation a reused key adds). */ + isRequestRow(itemId: string, requestKey: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) { + return false + } + const first = this.requestIdentity.request({ + generation: this.deps.generation, + key: requestKey, + incarnation: 1 + }) + const prefix = first.provider === 'legacy' ? first.recordId : null + return identity.recordId === prefix || identity.recordId.startsWith(`${prefix}#`) + } + + flush(): void { + this.assembler.flush() + } + + dispose(): void { + this.disposed = true + this.clearRetry() + this.backlog.length = 0 + this.assembler.dispose() + this.notifyTurnWatchers() + } + + private drain(): void { + this.clearRetry() + while (this.backlog.length > 0 && !this.failed && !this.disposed) { + const event = this.backlog[0] + const { admission } = this.assembler.apply(event) + if (!admission.accepted) { + if (admission.reason === 'backpressure') { + this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS) + return + } + this.failed = true + this.backlog.length = 0 + this.notifyTurnWatchers() + this.deps.onFailed(admission.reason) + return + } + this.backlog.shift() + if (event.type === 'input.accepted') { + this.deps.onInputAccepted(event.clientMessageId) + } + } + this.notifyTurnWatchers() + } + + private notifyTurnWatchers(): void { + for (const check of this.turnWatchers) { + check() + } + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + } +} diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts new file mode 100644 index 000000000000..0e7af326a604 --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionProviderHandleKey, + type AgentSessionProviderHandle +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createLegacyProviderTimelineIdentityScheme, + spellProviderTimelineKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' +import { createAcpStructuredLaunchResolver } from './acp-structured-launch-resolution' + +const GROK = acpLaunchSpecFor('grok')! +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null +} + +function grokRecord(overrides: Partial = {}): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'grok', + providerHandleChain: [], + accountHome: { variable: 'GROK_HOME', path: '/home/user/.grok-work' }, + ...overrides + } +} + +/** A journal holding one turn of each named provider session. */ +function journalWithTurns(...providerSessions: string[]): JournalLoad { + const state = createJournalReducerState(identity.sessionId, 'epoch-1') + providerSessions.forEach((providerSession, index) => { + const turnId = spellProviderTimelineKey(providerSession, { + source: 'provider', + value: `prompt:m${index}` + }) + state.items.set(`turn-${index}`, { + itemId: `turn-${index}`, + revision: 1, + sequence: index + 2, + observedAt: 1, + body: { kind: 'turn', turnId, state: 'completed' } + }) + }) + return { state, newer: null, damage: null } +} + +function resolver( + record: AgentSessionRecord, + fullAccess = false, + readJournal: () => JournalLoad | null = () => null +) { + const searched: (string | null | undefined)[] = [] + return { + searched, + resolve: createAcpStructuredLaunchResolver(GROK, { + store: { getRecord: () => record }, + readJournal, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => ({ GROK_EXTRA: '1' }), + resolveFullAccess: () => fullAccess, + resolveCommand: (command, options) => { + searched.push(options?.pathEnv) + return `/resolved/${command}` + } + }) + } +} + +describe('ACP launch resolution', () => { + it('pins the record account home and searches the agent install dir', async () => { + const { resolve, searched } = resolver(grokRecord()) + const launch = await resolve({ identity }) + expect(launch).toMatchObject({ + command: '/resolved/grok', + args: ['agent', 'stdio'], + cwd: '/repo/worktree', + fullAccess: false, + resume: null + }) + expect(launch.env).toMatchObject({ + GROK_HOME: '/home/user/.grok-work', + GROK_EXTRA: '1' + }) + expect(searched[0]).toContain('/home/user/.grok-work/bin') + }) + + it('asks the agent to approve everything only under full access', async () => { + const launch = await resolver(grokRecord(), true).resolve({ identity }) + expect(launch.args).toContain('--always-approve') + }) + + it('resumes the chain head by its key, possibly unsaved only when this chat created it', async () => { + const link = (origin: 'created' | 'resumed') => ({ + linkId: `link-${origin}`, + origin, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + }) + const created = await resolver(grokRecord({ providerHandleChain: [link('created')] })).resolve({ + identity + }) + const handle: AgentSessionProviderHandle = { + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + } + const key = agentSessionProviderHandleKey(handle) + expect(created.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(created.resume?.mayBeUnsaved()).toBe(true) + const resumed = await resolver( + grokRecord({ providerHandleChain: [link('created'), link('resumed')] }), + false, + () => journalWithTurns() + ).resolve({ identity }) + expect(resumed.resume).toMatchObject({ sessionId: 'acp-1', key }) + expect(resumed.resume?.mayBeUnsaved()).toBe(false) + }) + + it('counts a created session as possibly unsaved only while the journal proves no turn on it', async () => { + const created = grokRecord({ + providerHandleChain: [ + { + linkId: 'link-created', + origin: 'created', + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId: 'acp-1' } + } + ] + }) + const unsaved = async (readJournal: () => JournalLoad | null) => + (await resolver(created, false, readJournal).resolve({ identity })).resume?.mayBeUnsaved() + const reads: string[] = [] + const launch = await resolver(created, false, () => { + reads.push('read') + return null + }).resolve({ identity }) + // Read only when asked: a reopen that works never replays the journal. + expect(reads).toEqual([]) + expect(launch.resume?.mayBeUnsaved()).toBe(true) + + expect(await unsaved(() => journalWithTurns())).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other'))).toBe(true) + expect(await unsaved(() => journalWithTurns('acp-other', 'acp-1'))).toBe(false) + // A journal that does not read whole, or at all, proves nothing. + expect( + await unsaved(() => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toBe(false) + expect(await unsaved(() => ({ ...journalWithTurns(), newer: { sequence: 3 } }))).toBe(false) + expect( + await unsaved(() => { + throw new Error('journal_closed') + }) + ).toBe(false) + }) + + it('names the lost conversations whose warning row no session of the chat wrote', async () => { + const link = (nativeId: string, replaced?: string) => ({ + linkId: `link-${nativeId}`, + origin: 'created' as const, + mintedAtFence: 1, + observedAt: 1, + handle: { transport: 'acp', agent: 'grok', nativeId }, + ...(replaced + ? { + replaces: { + key: agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: replaced + }), + reason: 'restore-failed', + replacedAt: 1 + } + } + : {}) + }) + const lostKey = agentSessionProviderHandleKey({ + transport: 'acp', + agent: 'grok', + nativeId: 'acp-1' + }) + const replaced = grokRecord({ providerHandleChain: [link('acp-1'), link('acp-2', 'acp-1')] }) + /** A journal holding the row for `lostKey`, written while `providerSession` ran. */ + const withRow = (providerSession: string): JournalLoad => { + const load = journalWithTurns() + const itemId = agentJournalItemKey( + createLegacyProviderTimelineIdentityScheme({ + agent: 'grok', + sessionId: identity.sessionId + }).item({ + namespace: providerSession, + family: 'item', + key: { source: 'provider', value: acpSessionNotRestoredItem(lostKey) }, + thread: providerSession + }) + ) + load.state.items.set(itemId, { + itemId, + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'status', tone: 'warning', text: 'forgot' } + }) + return load + } + const losses = async (record: AgentSessionRecord, readJournal: () => JournalLoad | null) => + (await resolver(record, false, readJournal).resolve({ identity })).resume?.unannouncedLosses() + + const reads: string[] = [] + // A chain that lost nothing never reads the journal. + expect( + await losses(grokRecord({ providerHandleChain: [link('acp-1')] }), () => { + reads.push('read') + return null + }) + ).toEqual([]) + expect(reads).toEqual([]) + expect(await losses(replaced, () => null)).toEqual([lostKey]) + expect(await losses(replaced, () => journalWithTurns('acp-2'))).toEqual([lostKey]) + expect(await losses(replaced, () => withRow('acp-2'))).toEqual([]) + // A row the superseded replacement wrote still counts. + expect(await losses(replaced, () => withRow('acp-gone'))).toEqual([]) + // A journal that does not read whole proves nothing, so no row is written. + expect( + await losses(replaced, () => ({ + ...journalWithTurns(), + damage: { sequence: 3, cause: 'sequence-gap' } + })) + ).toEqual([]) + expect( + await losses(replaced, () => { + throw new Error('journal_closed') + }) + ).toEqual([]) + }) + + it('refuses a record pinned to another host or another agent', async () => { + const remote = grokRecord() + remote.location = { ...remote.location, executionHostId: 'ssh:box' } + await expect(resolver(remote).resolve({ identity })).rejects.toThrow(/run on this runtime/) + await expect(resolver(agentSessionRecordFixture()).resolve({ identity })).rejects.toThrow( + /claude session/ + ) + }) +}) + +describe('Grok status: the structured session is the only producer', () => { + it('strips every pane identity and hook endpoint an inherited environment carries', () => { + const inherited = { + PATH: '/usr/bin', + ORCA_PANE_KEY: 'tab-1:pane-1', + ORCA_AGENT_PANE: 'tab-1:pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'secret', + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/endpoint' + } + const spec = createProviderSpawnSpec( + { + command: 'grok', + args: ['agent', 'stdio'], + env: { GROK_HOME: '/home/user/.grok' }, + envToDelete: ACP_CHILD_ENV_TO_DELETE + }, + inherited, + 'win32' + ) + expect(Object.keys(spec.env).filter((key) => key.startsWith('ORCA_'))).toEqual([]) + expect(spec.env).toMatchObject({ PATH: '/usr/bin', GROK_HOME: '/home/user/.grok' }) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts new file mode 100644 index 000000000000..dbe372495a0a --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -0,0 +1,192 @@ +// How a durable session record becomes an ACP agent launch, on the machine this runtime runs on. +// +// Every input is read back from the record the store made durable, never from the call that +// triggered the acquire: the working directory, the account home, and the provider session a +// resume names are the ones this session proved. + +import { delimiter } from 'node:path' +import { homedir } from 'node:os' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + 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 { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { + isProviderTimelineTurnInNamespace, + providerTimelineKeyPart, + spelledProviderTimelineItemKey +} from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { acpSessionNotRestoredItem } from './acp-session-reopen-failure' + +export type AcpStructuredLaunch = { + spec: AcpLaunchSpec + command: string + args: string[] + cwd: string + env: Record + fullAccess: boolean + /** The provider session to load; null starts a new one. */ + resume: { + sessionId: string + /** The chain key of the session to load, which a fresh session names if it takes over. */ + key: string + /** Only a session this chat created and never exchanged a turn on may be one the agent never + * saved, and so be superseded. Read only when the agent cannot reopen it. */ + mayBeUnsaved: () => boolean + /** Keys of the conversations the chain says the agent lost whose warning row the chat does not + * hold, as when the attach that would have written it failed. Read once the start succeeds. */ + unannouncedLosses: () => string[] + } | null +} + +export type AcpStructuredLaunchResolverDeps = { + store: Pick + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal: (sessionId: string) => JournalLoad | null + resolveWorkspacePath: (workspaceId: string) => Promise + /** The shared base env; re-read per acquisition. */ + resolveEnvironment: () => Promise> + /** The user's per-agent environment overlay from settings. */ + resolveLaunchEnv?: (agent: string) => Record + /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ + resolveFullAccess?: (agent: string) => boolean + resolveCommand?: typeof resolveCliCommand + homePath?: string +} + +export function createAcpStructuredLaunchResolver( + spec: AcpLaunchSpec, + deps: AcpStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + if (record.provider !== spec.agent) { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + const { location, accountHome } = record + // A session pinned elsewhere belongs to that host's runtime; starting it here would put a + // second writer on the same provider session. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + throw new Error( + `${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 = { + ...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() + }) + 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), + env, + fullAccess, + resume: head + ? { + sessionId: head.handle.nativeId, + key: agentSessionProviderHandleKey(head.handle), + mayBeUnsaved: () => + head.origin === 'created' && + nothingExchangedOn(deps.readJournal, identity.sessionId, head.handle.nativeId), + unannouncedLosses: () => + unannouncedLosses(deps.readJournal, identity.sessionId, record.providerHandleChain) + } + : null + } + } +} + +/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole, + * or does not read at all, proves nothing. */ +function nothingExchangedOn( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + nativeId: string +): boolean { + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return false + } + if (!load) { + return true + } + if (load.damage || load.newer) { + return false + } + for (const item of load.state.items.values()) { + const turn = readAgentJournalTurn(item.body) + if (turn && isProviderTimelineTurnInNamespace(turn.turnId, nativeId)) { + return false + } + } + return true +} + +/** Derived on every start, never stored: a row an attach failure dropped is written by the next + * start that succeeds. A journal that does not read whole proves nothing, so none is written. */ +function unannouncedLosses( + readJournal: AcpStructuredLaunchResolverDeps['readJournal'], + sessionId: string, + chain: AgentSessionProviderHandleChain +): string[] { + const lost = chain.flatMap((link) => + link.replaces?.reason === 'restore-failed' ? [link.replaces.key] : [] + ) + if (lost.length === 0) { + return [] + } + let load: JournalLoad | null + try { + load = readJournal(sessionId) + } catch { + return [] + } + if (!load) { + return lost + } + if (load.damage || load.newer) { + return [] + } + const written = new Set() + for (const itemId of load.state.items.keys()) { + const identity = parseAgentJournalItemKey(itemId) + const spelled = + identity?.provider === 'legacy' ? spelledProviderTimelineItemKey(identity.recordId) : null + if (spelled !== null) { + written.add(spelled) + } + } + // Any session's row counts: one an unused replacement wrote stays when the agent supersedes it. + return lost.filter((key) => !written.has(providerTimelineKeyPart(acpSessionNotRestoredItem(key)))) +} diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts new file mode 100644 index 000000000000..662c3c98c1a9 --- /dev/null +++ b/src/main/acp/acp-structured-options.ts @@ -0,0 +1,234 @@ +// What a running ACP agent reports about its own choices — config options (model, reasoning +// effort), the older model list, and its `/` commands — as Orca's option and command surfaces. +// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. + +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' +import { AcpRpcError } from './acp-errors' +import type { AcpStructuredConnection } from './acp-structured-connection' +import { + SessionConfigSelectGroupSchema, + SessionConfigSelectOptionSchema, + type AvailableCommand, + type SessionConfigOption, + type SessionConfigSelectOption, + type SessionModelState +} from './generated/acp-protocol.generated' + +type SelectOption = Extract + +/** How an Orca option key reaches the agent: a config option it declared, or the model method. */ +export type AcpOptionWrite = + | { method: 'config'; configId: string; value: string } + | { method: 'model'; modelId: string } + +function selectChoices(option: SelectOption): SessionConfigSelectOption[] { + const choices: SessionConfigSelectOption[] = [] + for (const entry of option.options) { + const group = SessionConfigSelectGroupSchema.safeParse(entry) + if (group.success) { + choices.push(...group.data.options) + continue + } + const choice = SessionConfigSelectOptionSchema.safeParse(entry) + if (choice.success) { + choices.push(choice.data) + } + } + return choices +} + +function isSelect(option: SessionConfigOption): option is SelectOption { + return option.type === 'select' +} + +export class AcpStructuredOptions { + private configOptions: SessionConfigOption[] = [] + private models: SessionModelState | null = null + private commands: AgentSessionSlashCommand[] | undefined + + /** The session state a new, load or resume response reported. */ + adoptSession(response: { + configOptions?: SessionConfigOption[] | null + models?: SessionModelState | null + }): void { + this.configOptions = response.configOptions ?? [] + this.models = response.models ?? null + } + + adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void { + if (configOptions) { + this.configOptions = configOptions + } + } + + adoptModel(modelId: string): void { + if (this.models) { + this.models = { ...this.models, currentModelId: modelId } + } + } + + adoptCommands(commands: readonly AvailableCommand[]): void { + this.commands = commands.map((command) => ({ + name: command.name, + kind: 'command', + ...(command.description ? { description: command.description } : {}), + ...(command.input?.hint ? { argumentHint: command.input.hint } : {}) + })) + } + + readCommands(): AgentSessionSlashCommand[] | undefined { + return this.commands + } + + /** Where a pick of `key` goes; null when the agent offers nothing for it. */ + write(key: string, value: string): AcpOptionWrite | null { + if (key === 'model') { + const option = this.select('model') + if (option) { + return { method: 'config', configId: option.id, value } + } + return this.models ? { method: 'model', modelId: value } : null + } + if (key === 'effort') { + const option = this.select('thought_level') + return option ? { method: 'config', configId: option.id, value } : null + } + return null + } + + read(): Pick { + const modelOption = this.select('model') + const effortOption = this.select('thought_level') + const efforts: AgentSessionOptionChoice[] = effortOption + ? selectChoices(effortOption).map((choice) => ({ + value: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : [] + const currentModel = modelOption?.currentValue ?? this.models?.currentModelId ?? '' + const listed: { id: string; label: string; description?: string }[] = modelOption + ? selectChoices(modelOption).map((choice) => ({ + id: choice.value, + label: choice.name, + ...(choice.description ? { description: choice.description } : {}) + })) + : (this.models?.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}) + })) ?? []) + const models: AgentSessionModelOption[] = listed.map((model) => ({ + ...model, + isDefault: model.id === currentModel, + efforts, + ...(effortOption ? { defaultEffort: effortOption.currentValue } : {}) + })) + const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] + return { + models, + current: { + model: currentModel, + ...(effortOption ? { effort: effortOption.currentValue } : {}), + confirmed + } + } + } + + /** The values the agent reports now, as the record's options would name them. */ + reported(): Readonly> { + const { current } = this.read() + return { + ...(current.model ? { model: current.model } : {}), + ...(current.effort ? { effort: current.effort } : {}) + } + } + + private select(category: string): SelectOption | null { + const option = this.configOptions.find( + (entry) => entry.category === category || (category === 'model' && entry.id === 'model') + ) + return option && isSelect(option) ? option : null + } +} + +/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is + * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ +export function writeAcpSessionOption( + connection: Pick, + options: AcpStructuredOptions, + write: AcpOptionWrite, + bound: { agent: string; timeoutMs: number; signal?: AbortSignal } +): Promise { + const { agent, timeoutMs, signal } = bound + if (signal?.aborted) { + return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + } + const applied = + write.method === 'config' + ? connection + .setConfigOption(write.configId, write.value) + .then((result) => options.adoptConfigOptions(result.configOptions)) + : connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + return new Promise((resolve, reject) => { + const fail = (error: Error): void => { + settle() + reject(error) + } + const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const timer = setTimeout( + () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), + timeoutMs + ) + const settle = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } + signal?.addEventListener('abort', onAbort, { once: true }) + applied.then(() => { + settle() + resolve() + }, fail) + }) +} + +/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and + * reported, never retried. */ +export async function restoreAcpSessionOptions( + connection: Pick, + options: AcpStructuredOptions, + saved: Readonly> | undefined +): Promise { + const skipped: string[] = [] + for (const [key, value] of Object.entries(saved ?? {})) { + const reported = options.reported() + if (!isAcpStructuredOptionKey(key) || reported[key] === value) { + continue + } + const write = options.write(key, value) + try { + if (write?.method === 'config') { + options.adoptConfigOptions( + (await connection.setConfigOption(write.configId, value)).configOptions + ) + } else if (write?.method === 'model') { + await connection.setModel(write.modelId) + options.adoptModel(write.modelId) + } else { + skipped.push(key) + } + } catch (error) { + if (!(error instanceof AcpRpcError)) { + throw error + } + skipped.push(key) + } + } + return skipped +} diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts new file mode 100644 index 000000000000..7f9516e9827a --- /dev/null +++ b/src/main/acp/acp-structured-prompts.ts @@ -0,0 +1,170 @@ +// The agent's open requests to the user (permission, question, plan approval), each held as the +// JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. +// An answer claims the request, commits the journal compare-and-set while the claim is held, and +// only then replies, so a second client loses the commit and the agent hears exactly one answer. +// Each request is this module's to answer: once the agent cancels it, or a Stop or steer withdraws +// what is open (`withdrawAll`), an unclaimed one is answered with the agent's own cancelled reply +// and a claimed one still sends the committed answer — except a permission the agent itself +// cancelled, which the protocol answers `cancelled` the moment it is cancelled. + +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpRpcError } from './acp-errors' +import type { AcpRequestContext } from './acp-json-rpc-peer' +import type { AcpStructuredLane } from './acp-structured-lane' + +type OpenRequest = { + key: string + presentation: AcpRequestPresentation + /** A permission, whose cancellation the protocol answers itself. */ + permission: boolean + signal: AbortSignal + claimed: boolean + /** A Stop or steer withdrew it while an answer was being saved: a failed save withdraws it. */ + withdrawing: boolean + settle: (reply: unknown) => void +} + +export class AcpStructuredPrompts { + private readonly open = new Map() + + constructor( + private readonly lane: () => AcpStructuredLane | null, + /** Whether a request arriving now belongs to a turn that may still ask the person. */ + private readonly admits: () => boolean, + /** Whether its turn may still show what a request answered at once carried. */ + private readonly shows: () => boolean + ) {} + + get size(): number { + return this.open.size + } + + /** Answers one agent request: a row the user can act on, or the translator's verdict on it. */ + handle(method: string, params: unknown, context: AcpRequestContext): Promise { + const lane = this.lane() + if (!lane || context.id === null) { + throw new AcpRpcError(-32603, 'ACP request arrived with no session to show it') + } + const translated = lane.translator.request(method, params, context.id) + if (translated.settled) { + // Answered at once; what it carried shows only while its turn may still show anything. + if (this.shows()) { + lane.apply(translated.events) + } + return Promise.resolve(translated.settled.reply) + } + if (translated.presentation && !this.admits()) { + // No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own + // cancelled reply and no card opens. + return Promise.resolve(translated.presentation.reply(null)) + } + lane.apply(translated.events) + const opened = translated.events.find((event) => event.type === 'request.open') + if (!translated.presentation || opened?.type !== 'request.open') { + throw new AcpRpcError(-32601, `Unsupported ACP client method: ${method}`) + } + const key = opened.request + const presentation = translated.presentation + return new Promise((resolve) => { + const entry: OpenRequest = { + key, + presentation, + permission: method === 'session/request_permission', + signal: context.signal, + claimed: false, + withdrawing: false, + settle: (reply) => { + if (this.open.get(key) === entry) { + this.open.delete(key) + } + resolve(reply) + } + } + this.open.set(key, entry) + // The agent or a Stop gave up on it: its card can no longer be answered, and the agent hears + // its own cancelled reply. A claimed one finishes its answer. + context.signal.addEventListener( + 'abort', + () => { + if (this.open.get(key) === entry && !entry.claimed) { + this.withdraw(entry) + } + }, + { once: true } + ) + }) + } + + async answer(input: { + itemId: string + response: AgentSessionPromptResponse + commit: () => Promise + }): Promise { + const entry = this.find(input.itemId) + if (!entry) { + throw new AgentSessionPromptUnavailableError(input.itemId) + } + let reply: unknown + try { + reply = entry.presentation.reply(input.response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : String(error) + ) + } + entry.claimed = true + try { + await input.commit() + } catch (error) { + entry.claimed = false + if (entry.signal.aborted || entry.withdrawing) { + this.withdraw(entry) + } + throw error + } + entry.settle(reply) + if (entry.permission && entry.signal.aborted) { + // Cancelled while the answer was saved: the agent already heard `cancelled`, not this answer. + throw new Error(`the agent stopped waiting for the answer to ${input.itemId}`) + } + } + + /** A Stop or steer: the agent hears its own cancelled reply to everything no answer has claimed. */ + withdrawAll(): void { + for (const entry of this.open.values()) { + if (entry.claimed) { + entry.withdrawing = true + } else { + this.withdraw(entry) + } + } + } + + private withdraw(entry: OpenRequest): void { + entry.settle(entry.presentation.reply(null)) + this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) + } + + /** The child is gone: nobody can answer for it any more. */ + clear(): void { + this.open.clear() + } + + private find(itemId: string): OpenRequest | null { + const lane = this.lane() + if (!lane) { + return null + } + for (const entry of this.open.values()) { + if (!entry.claimed && lane.isRequestRow(itemId, entry.key)) { + return entry + } + } + return null + } +} diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts new file mode 100644 index 000000000000..3bed54f87c9c --- /dev/null +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -0,0 +1,121 @@ +// What Grok asks reaches the person only while Orca's prompt runs and may still ask: a Stop or a +// steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with +// no card, as does a turn Grok began itself; an answer the person already gave is still sent. + +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const question = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCallId, + questions: [{ question: 'Which file?', options: [{ label: 'a.ts' }, { label: 'b.ts' }] }] +}) + +const permission = (toolCallId: string) => ({ + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId, title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] +}) + +async function runningPrompt() { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + const cards = async () => + (await rig.rig.rows()).filter( + (row) => row.body.kind === 'question' || row.body.kind === 'approval' + ) + return { rig, agent: rig.child().agent, cards } +} + +describe('Grok requests and the turn they belong to', () => { + it('shows a question while its turn runs, and withdraws it on Stop with Grok own reply', async () => { + const { rig, agent, cards } = await runningPrompt() + const asked = agent.request(1, 'x.ai/ask_user_question', question('call-1')) + await rig.settle() + expect(await cards()).toHaveLength(1) + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + }) + + it('withdraws an open question when a steer cancels its turn', async () => { + const { rig, agent } = await runningPrompt() + const asked = agent.request(6, 'x.ai/ask_user_question', question('call-6')) + await rig.settle() + await sendHello(rig, 'steer') + expect(await asked).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('refuses a question that arrives after a Stop, opening no card', async () => { + const { rig, agent, cards } = await runningPrompt() + await rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }) + const late = await agent.request(2, 'x.ai/ask_user_question', question('call-2')) + expect(late).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(await cards()).toEqual([]) + }) + + it('refuses a permission or a question that arrives while a steer cuts the turn short', async () => { + const { rig, agent, cards } = await runningPrompt() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect( + await agent.request(3, 'session/request_permission', permission('call-3')) + ).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(await agent.request(4, 'x.ai/ask_user_question', question('call-4'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect(await cards()).toEqual([]) + }) + + it('refuses a question during a turn Grok began itself, opening no card, yet shows its plan', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + expect(await agent.request(7, 'x.ai/ask_user_question', question('call-7'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect( + await agent.request(8, 'x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-8', + planContent: '# Plan' + }) + ).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'question')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ kind: 'status', presentation: 'plan-document', text: '# Plan' }) + ) + }) + + it('refuses a question when no turn is open', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + expect(await agent.request(5, 'x.ai/ask_user_question', question('call-5'))).toMatchObject({ + result: { outcome: 'cancelled' } + }) + expect((await rig.rig.rows()).filter((row) => row.body.kind === 'question')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts new file mode 100644 index 000000000000..3f59f4db3e4a --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -0,0 +1,42 @@ +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { ConnectAcpAgent } from './acp-structured-connection' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +/** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ +export const ACP_STOP_GRACE_MS = 4_000 +/** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */ +export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 + +export type AcpStructuredSessionAdapterDeps = { + spec: AcpLaunchSpec + resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ + connect: ConnectAcpAgent + readProcessStartTime?: (pid: number) => Promise + /** Every exit, expected or not: the host ends that child's record. */ + onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void + /** A send this adapter admitted, settled once the agent answered for it. */ + onDispatchSettledLate?: ( + settlement: { sessionId: string; clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) + ) => void + logger?: StructuredAgentSessionLogger + now?: () => number + mintGeneration?: () => string + mintLinkId?: () => string + /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ + stopGraceMs?: number + /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ + optionWriteTimeoutMs?: number + isWindowsProcessStartTimeAvailable?: () => boolean +} diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts new file mode 100644 index 000000000000..bb845eb429fb --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -0,0 +1,466 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + providerTurnId, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' +import { tick } from './acp-scripted-agent.test-support' +import { + GROK, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +async function journalTurns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +const turnIdOf = (promptId: string) => providerTurnId(promptId, PROVIDER_SESSION) + +describe('ACP Stop names a turn', () => { + it('a Stop naming a turn that has ended stops nothing running now', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt', 1) + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } }) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['old', 'new']) + }) + + it('a Stop naming an ended turn during the gap before the next prompt opens stops that prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'old') + const old = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) + rig.child().agent.reply(old, { stopReason: 'end_turn' }) + await rig.settle() + await sendHello(rig, 'new') + await rig.frame('session/prompt', 1) + await rig.settle() + // The journal shows no turn running: Grok has not echoed the new prompt yet, as with Claude. + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: turnIdOf('prompt:old'), + resolveLiveTurnId: () => null + }) + ).resolves.toEqual({ cancelled: true }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('a Stop naming the turn running now stops it and withdraws the steer waiting on its cancel', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'new') + const current = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) + await sendHello(rig, 'follow-up') + await rig.settle() + rig + .child() + .agent.on('session/cancel', () => + rig.child().agent.reply(current, { stopReason: 'cancelled' }) + ) + const live = turnIdOf('prompt:new') + await expect( + rig.adapter.cancelTurn({ + sessionId: SESSION, + fence: 1, + turnId: live, + resolveLiveTurnId: () => live + }) + ).resolves.toEqual({ cancelled: true }) + // The steer's cancel, then the Stop's own bounded one. + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'follow-up', state: 'rejected' }) + ]) + ) + }) +}) + +describe('ACP Stop of a turn Grok began itself', () => { + it('cancels a turn opened by a background completion; the Stop waits for it to end', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + agent.on('session/cancel', () => + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-background-1', + stop_reason: 'cancelled' + } + }) + ) + expect(rig.adapter.stopEndsSession()).toBe(true) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect(rig.sent('session/cancel')).toHaveLength(1) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' }) + // The host ends the child next; the adapter does not end it on its own. + expect(rig.child().closes).toBe(0) + }) + + it("ends the Stop's wait at its grace when Grok never ends its own turn", async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await rig.acquire() + rig.child().agent.notify('session/update', replyChunk('task-completed-background-1', 'Busy')) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP connection loss', () => { + it('settles a send the agent never took as unknown when its stdin breaks, stops the child and tells the host', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + await sendHello(rig, 'held') + rig.child().agent.stdin.destroy() + await rig.settle() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'lost', state: 'unknown' }), + expect.objectContaining({ clientMessageId: 'held', state: 'rejected' }) + ]) + ) + expect(rig.child().closes).toBe(1) + // Nothing was opened for a send the agent never echoed, so nothing claims it completed. + expect(await journalTurns(rig)).toEqual([]) + await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) + }) + + it("ends a crash whose stdout closed before its exit at that exit, with Grok's last words", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().stderr = 'panic: out of memory' + rig.child().agent.stdout.end() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + type: 'ended', + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: out of memory', + failure: { kind: 'providerExited', detail: { text: 'panic: out of memory' } } + }) + }) + + it('keeps a crash a crash when a stop Orca asks for lands before its unproven exit', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const child = rig.child() + child.proveClose = async () => false + child.stderr = 'panic: late' + child.agent.close() + await rig.settle() + expect(rig.lifecycle).toEqual([]) + // Never left Orca, so it is not recorded as unconfirmed. + expect( + await rig.adapter.dispatch({ + sessionId: SESSION, + clientMessageId: 'after', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] }, + fence: 1 + }) + ).toEqual({ + state: 'rejected', + reason: 'Grok stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) + // The next start's stop of the old child is one Orca asked for; it does not rename the crash. + await expect(rig.acquire()).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + child.exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.lifecycle[0]).toMatchObject({ + cause: 'unexpected-exit', + reason: 'grok ACP agent exited: panic: late', + failure: { kind: 'providerExited', detail: { text: 'panic: late' } } + }) + }) + + it('leaves a running turn unverifiable, never completed, when its stdin breaks mid-turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'lost') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) + await rig.settle() + rig.child().agent.stdin.destroy() + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'unverifiable' }) + ) + expect(rig.child().closes).toBe(1) + }) + + it('ends the turn as failed on an answer Orca cannot read, and sends the next message', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'garbled') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) + // Usage without the totals the schema requires: the answer fails to parse. + rig.child().agent.reply(prompt, { + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 2 } + }) + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + await sendHello(rig, 'next') + const next = await rig.frame('session/prompt', 1) + expect(rig.sent('session/cancel')).toEqual([]) + rig.child().agent.notify('session/update', replyChunk('prompt:next', 'ok')) + rig.child().agent.reply(next, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next']) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + + it('leaves Grok running when it ends its stdout but can still be written to', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'quiet') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:quiet', 'partial')) + await rig.settle() + rig.child().agent.stdout.end() + await rig.settle() + expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' }) + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) +}) + +describe('ACP refusals', () => { + it('rejects a send the agent refused before starting its turn', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/prompt', (frame) => agent.fail(frame, -32602, 'unsupported input')) + }) + await rig.acquire() + await sendHello(rig, 'refused') + await waitFor(() => + expect(rig.settled).toMatchObject([ + { clientMessageId: 'refused', state: 'rejected', rejection: { kind: 'providerRejected' } } + ]) + ) + expect(await journalTurns(rig)).toEqual([]) + // The session takes the next send. + rig.child().agent.on('session/prompt', () => {}) + await sendHello(rig, 'next') + await rig.frame('session/prompt', 1) + }) + + it('ends a started turn as failed when the agent answers its prompt with an error', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'failing') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:failing', 'partial')) + await rig.settle() + rig.child().agent.fail(prompt, -32603, 'model overloaded') + await waitFor(async () => + expect((await journalTurns(rig)).at(-1)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + ) + expect(rig.settled).toMatchObject([{ clientMessageId: 'failing', providerIdentity: {} }]) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('ACP startup that never answers', () => { + it('lets a close stop the child while the handshake is unanswered', async () => { + const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) + const queue = new StructuredAgentSessionTaskQueue() + const start = new AbortController() + const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal })) + const failed = acquiring.catch((error: unknown) => error) + await rig.frame('initialize') + // What the host's close does outside the queue, then its queued stop. + start.abort() + const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION)) + expect(rig.child().closes).toBe(1) + expect(await failed).toMatchObject({ message: 'Grok was closed while starting' }) + await expect(closing).resolves.toBe(true) + expect(rig.lifecycle).toEqual([]) + }) + + it('leaves the child of a start that returned alone when its signal aborts later', async () => { + const rig = await openAcpAdapterRig() + const start = new AbortController() + await rig.acquire({ signal: start.signal }) + // The host's close, landing while it still commits the attach, stops the child its own way. + start.abort(new Error('closed while starting')) + await rig.settle() + expect(rig.child().closes).toBe(0) + expect(rig.lifecycle).toEqual([]) + }) + + it('lets a handshake Grok never answers run on until the start is aborted', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('session/new', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + let settled = false + void failed.then(() => { + settled = true + }) + await rig.settle() + vi.advanceTimersByTime(30 * 60_000) + await rig.settle() + expect(settled).toBe(false) + expect(rig.child().closes).toBe(0) + start.abort() + expect(await failed).toBeInstanceOf(Error) + expect(rig.child().closes).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => agent.on('initialize', () => {}) + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + await rig.frame('initialize') + const child = rig.child() + child.proveClose = vi.fn(async () => false) + start.abort() + expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) + // Every later stop asks the child again, and none answers for it. + expect(await rig.adapter.closeSession(SESSION)).toBe(false) + await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') + expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionAcquisitionExitUnprovenError' + }) + expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) + child.exit() + expect(await rig.adapter.closeSession(SESSION)).toBe(true) + }) + + it('spawns nothing for a start a close reached before its spawn', async () => { + let releaseLaunch: () => void = () => {} + const rig = await openAcpAdapterRig({ + deps: { + resolveLaunch: async () => { + await new Promise((resolve) => { + releaseLaunch = resolve + }) + return { + spec: GROK, + command: '/opt/grok/bin/grok', + args: [], + cwd: '/workspace/project', + env: {}, + fullAccess: false, + resume: null + } + } + } + }) + const start = new AbortController() + const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + await tick() + start.abort() + releaseLaunch() + expect(await failed).toMatchObject({ name: 'AgentSessionPreSpawnError' }) + expect(rig.spawned).toEqual([]) + }) +}) + +describe('ACP session release', () => { + it('stops nothing for a chat it never started, as one an earlier Orca left', async () => { + const rig = await openAcpAdapterRig() + await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true) + expect(rig.spawned).toEqual([]) + expect(rig.lifecycle).toEqual([]) + }) + + it('keeps nothing of a chat once its child is proven closed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined() + await rig.adapter.closeSession(SESSION) + // Both answer from the entry the adapter holds for a chat; none is left. + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + }) + + it('keeps nothing of a chat whose child exited on its own', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().exit() + await waitFor(() => expect(rig.lifecycle).toHaveLength(1)) + expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-recordings.test.ts b/src/main/acp/acp-structured-session-adapter-recordings.test.ts new file mode 100644 index 000000000000..8b29e3fbb220 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-recordings.test.ts @@ -0,0 +1,122 @@ +// The adapter against Grok's recorded traffic: 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 { + 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): Promise<{ rig: AcpAdapterRig; replay: GrokFixtureReplay }> { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ script: (agent) => replay.attach(agent) }) + await rig.acquire() + 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) +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 }) +} + +describe('Grok recordings through the adapter', () => { + it('s1: a read-only turn lands as one completed turn with its tools and reply', async () => { + const { rig, replay } = await replaying('s1-basic') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const tools = await rowsOf(rig, 'tool-call') + expect(tools.length).toBeGreaterThan(0) + expect( + tools.every((row) => row.body.kind === 'tool-call' && row.body.state === 'completed') + ).toBe(true) + expect( + (await rig.rig.rows()).some( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + ).toBe(true) + expect(rig.settled).toMatchObject([{ clientMessageId: 'send-1', providerIdentity: {} }]) + }) + + it('s2: an allowed and then a rejected permission are each answered once, after the commit', async () => { + const { rig, replay } = await replaying('s2-permission') + const answer = async (optionId: string, index: number) => { + 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 () => {} + }) + } + await send(rig, 'send-1') + await answer('allow-once', 0) + await waitFor(async () => expect(await turns(rig)).toHaveLength(1)) + await waitFor(() => expect(replay.awaiting).toBe('session/prompt')) + await send(rig, 'send-2') + await answer('reject-once', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([ + { state: 'completed', outcome: 'success' }, + { state: 'interrupted', outcome: 'cancellation' } + ]) + ) + }) + + it('s3: a Stop mid-tool ends the turn interrupted and the session takes the next prompt', async () => { + const { rig, replay } = await replaying('s3-cancel') + await send(rig, 'send-1') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + 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' }]) + ) + // The recording left the shell call `in_progress`; the turn's end cuts it short. + const tool = (await rowsOf(rig, 'tool-call')).at(-1) + expect(tool?.body).toMatchObject({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' }) + await send(rig, 'send-2') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect((await turns(rig)).at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts new file mode 100644 index 000000000000..1818beee8456 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -0,0 +1,419 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + messageText, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import type { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { ACP_REOPEN_FAILED } from './acp-session-reopen-failure' +import { + GROK_CONFIG_OPTIONS, + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +/** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ +const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } +const resume = { + launch: { + resume: { + sessionId: PROVIDER_SESSION, + key: 'acp:grok:saved', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + } +} + +/** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the + * dead process left running ended by the restart, and its context usage. */ +function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hello' } }, + _meta: { isReplay: true } + }) + agent.notify('session/update', replyChunk('prompt:m1', reply, { isReplay: true })) + agent.notify('x.ai/task_completed', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'task-orphan', command: 'sleep 600', signal: 'session_restart' } + } + }) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) +} + +/** Orca's send of `hello` as `m1` and Grok's reply, ended or cut off. */ +async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promise { + await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { + turnScope: { kind: 'thread' } + }) + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:m1', reply)) + if (end) { + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + } + await rig.settle() +} + +const texts = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).flatMap((row) => messageText(row.body) ?? []) + +const notRestoredRows = async (rig: AcpAdapterRig) => + (await rig.rig.rows()).filter( + (row) => + row.body.kind === 'status' && + readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' + ) + +/** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */ +function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void { + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: 'prompt:old', stop_reason: 'end_turn' } + }) +} + +describe('the reattach window', () => { + const resumesUnmarked = { + ...resume, + initialize: RESUMES, + script: (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + sendsUnmarkedWhileAttaching(agent) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + } + + it('writes nothing Grok sends while it loads, even unmarked as replay', async () => { + const rig = await openAcpAdapterRig(resumesUnmarked) + await rig.acquire() + await rig.settle() + expect(await rig.rig.rows()).toEqual([]) + }) + + it('leaves no turn from the window for a later Stop to find', async () => { + const rig = await openAcpAdapterRig({ + ...resumesUnmarked, + script: (agent: AcpScriptedAgent) => + agent.on('session/load', (frame) => { + // An old reply with no end and no replay mark. + agent.notify('session/update', replyChunk('prompt:old', 'stale text')) + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('the context meter across a reopen', () => { + it('keeps the last reading the journal holds; the load only refreshes the window', async () => { + const models = { + currentModelId: 'grok-4.7', + availableModels: [ + { modelId: 'grok-4.7', name: 'Grok 4.7', _meta: { totalContextTokens: 256_000 } } + ] + } + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + // A load that replays no usage; its answer carries only the models. + agent.on('session/load', (frame) => + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) + ) + }) + await rig.acquire() + await sendHello(rig, 'm1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + agent.notify('session/update', replyChunk('prompt:m1', 'hi')) + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'usage_update', used: 4_000, size: 200_000 } + }) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const usage = (await rig.rig.rows()) + .flatMap((row) => readAgentJournalTurn(row.body) ?? []) + .at(-1)?.contextUsage + expect(usage).toMatchObject({ + window: { tokens: 256_000 }, + used: { usage: { inputTokens: 4_000 } } + }) + }) +}) + +describe('reattaching a Grok chat the journal holds', () => { + it('loads with session/load though Grok also resumes, and writes nothing it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = (await rig.rig.rows()).map((row) => row.itemId) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(rig.sent('session/load')).toHaveLength(1) + expect(rig.sent('session/resume')).toHaveLength(0) + // No second user bubble, no stale task row, and no turn opened by the load's own traffic. + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before) + expect(await texts(rig)).toEqual(['hello', 'hi']) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) + + it('loads an agent that only loads, writing none of what it replays', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + const before = await rig.rig.rows() + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + expect(rig.sent('session/resume')).toHaveLength(0) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + expect(await texts(rig)).toEqual(['hello', 'hi']) + }) + + it('leaves a reply cut off mid-turn as it was cut, never completed from what Grok saved', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'complete saved reply') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'complete', false) + await rig.adapter.closeSession(SESSION) + await rig.acquire({ fence: 2 }) + await rig.settle() + const rows = await rig.rig.rows() + expect(await texts(rig)).toEqual(['hello', 'complete']) + expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).toEqual([ + 'unverifiable' + ]) + }) + + it('writes nothing of the reattach into a sink the host binds only after the start', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + sendsWhileAttaching(agent, 'hi') + agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) + }) + }) + await rig.acquire() + await exchange(rig, 'hi', true) + await rig.adapter.closeSession(SESSION) + const before = await rig.rig.rows() + // The host's order: a fresh sink per start, bound to the journal once the start succeeded. + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION)) + await rig.adapter.acquire({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'grok', + providerHandle: null + }, + fence: 2, + spawnToken: 'spawn-2', + events: deferred.sink + }) + deferred.bind({ journal: rig.rig.journal, fence: 2, publish: () => {} }) + expect(await deferred.drained()).toMatchObject({ ok: true }) + expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) + await rig.adapter.closeSession(SESSION) + deferred.close() + }) + + it('starts a new session in place of a created one that session/load reports missing', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'never-saved', + key: 'acp:grok:never-saved', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + supersedesKey: 'acp:grok:never-saved' + }) + // The failed load left the translator taking prompts. + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + // Nothing was forgotten: the agent never saved that session. + expect(await notRestoredRows(rig)).toEqual([]) + }) + + it.each([ + ['a saved session', false, -32603, 'session file is corrupt'], + ['a created session that fails for another reason', true, -32603, 'session file is corrupt'], + ['a saved session the agent reports missing', false, -32002, 'Resource not found'] + ])( + 'continues %s it cannot reopen in a new one, says so once, and keeps working', + async (_label, mayBeUnsaved, code, message) => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => mayBeUnsaved, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) + }) + const acquired = await rig.acquire() + expect(acquired.link).toMatchObject({ + origin: 'created', + handle: { nativeId: PROVIDER_SESSION }, + replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 } + }) + expect(acquired.link.supersedesKey).toBeUndefined() + expect(rig.sent('session/new')).toHaveLength(1) + const rows = await notRestoredRows(rig) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ kind: 'status', tone: 'warning' }) + await exchange(rig, 'hi', true) + expect(await texts(rig)).toEqual(['hello', 'hi']) + expect(await notRestoredRows(rig)).toHaveLength(1) + } + ) + + it('refuses a signed-out agent at reopen rather than replacing its session', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.sent('session/new')).toEqual([]) + }) + + it('fails a start closed while the agent reopens rather than opening a new session', async () => { + const start = new AbortController() + const logger = { warn: vi.fn(), error: vi.fn() } + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'saved-1', + key: 'acp-key-saved-1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } + }, + initialize: RESUMES, + deps: { logger }, + script: (agent) => + agent.on('session/load', (frame) => { + start.abort() + agent.fail(frame, -32603, 'session file is corrupt') + }) + }) + expect( + await rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + ).toBeInstanceOf(Error) + expect(rig.sent('session/new')).toEqual([]) + expect(await notRestoredRows(rig)).toEqual([]) + // Nothing was replaced, so nothing says it was. + expect(logger.warn).not.toHaveBeenCalledWith(ACP_REOPEN_FAILED, expect.anything()) + }) + + it('keeps the slash commands Grok reports while it loads', async () => { + const rig = await openAcpAdapterRig({ + ...resume, + initialize: RESUMES, + script: (agent) => + agent.on('session/load', (frame) => { + agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [{ name: 'compact', description: 'Compress', input: null }] + } + }) + agent.reply(frame, { configOptions: [] }) + }) + }) + await rig.acquire() + await rig.settle() + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress' } + ]) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts new file mode 100644 index 000000000000..9d9e3022826e --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -0,0 +1,162 @@ +// A close, dispose or quit while a turn runs: the agent is asked to cancel it and given the Stop's +// grace to end it before its process goes, as a Stop does; an idle close goes at once. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { tick, type FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + sendHello, + type AcpAdapterRig, + type FakeAcpChild +} from './acp-structured-adapter.test-support' +import { framesOf, openAttachedHostRig, send } from './acp-structured-host.test-support' +import { ACP_STOP_GRACE_MS } from './acp-structured-session-adapter-deps' + +afterEach(async () => { + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +/** What Orca had written, and whether the turn had ended, when it first asked the child to close. */ +function watchClose(child: FakeAcpChild, turn: { ended: boolean }) { + const atClose: { cancels: number | null; turnEnded: boolean | null } = { + cancels: null, + turnEnded: null + } + const close = child.close.bind(child) + child.close = async (error?: Error) => { + atClose.cancels ??= framesOf(child, 'session/cancel').length + atClose.turnEnded ??= turn.ended + return close(error) + } + return atClose +} + +/** The agent ends the cancelled turn a moment after Orca asks. */ +function answerCancel(child: FakeAcpChild, prompt: FakeFrame, turn: { ended: boolean }) { + child.agent.on('session/cancel', () => + setTimeout(() => { + turn.ended = true + child.agent.reply(prompt, { stopReason: 'cancelled' }) + }, 30) + ) +} + +async function runningPrompt(rig: AcpAdapterRig): Promise { + await rig.acquire() + await sendHello(rig, 'send-1') + return rig.frame('session/prompt') +} + +describe('ACP teardown of a running turn', () => { + it('a close cancels the turn first and ends the child once the agent ends it', async () => { + const rig = await openAcpAdapterRig() + const prompt = await runningPrompt(rig) + const turn = { ended: false } + answerCancel(rig.child(), prompt, turn) + const atClose = watchClose(rig.child(), turn) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('a dispose ends the child at the grace when the agent never ends its turn', async () => { + const rig = await openAcpAdapterRig() + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const closing = rig.adapter.disposeSession(SESSION) + await vi.advanceTimersByTimeAsync(ACP_STOP_GRACE_MS - 1) + await tick() + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(child.closes).toBe(1) + }) + + it('a quit cancels every running turn before it ends the children, within the grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const atClose = watchClose(rig.child(), { ended: false }) + await rig.adapter.closeAll() + expect(atClose).toEqual({ cancels: 1, turnEnded: false }) + expect(rig.child().closes).toBe(1) + }) + + it('a close still ends the child when the cancel cannot be written', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) + await runningPrompt(rig) + const cancel = vi.spyOn(rig.child(), 'cancel').mockRejectedValue(new Error('EPIPE')) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).toHaveBeenCalledTimes(1) + expect(rig.child().closes).toBe(1) + }) + + it('a close after a Stop asks no second cancel and waits only what is left of its grace', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await vi.advanceTimersByTimeAsync(20_000) + const closing = rig.adapter.closeSession(SESSION) + await vi.advanceTimersByTimeAsync(40_000 - 1) + expect(child.closes).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await expect(closing).resolves.toBe(true) + expect(framesOf(child, 'session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP teardown with nothing to cancel', () => { + it('an idle close writes no cancel and ends the child at once', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + expect(rig.child().closes).toBe(1) + }) + + it('a close after the connection broke writes no cancel and waits for nothing', async () => { + const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) + await runningPrompt(rig) + const child = rig.child() + const cancel = vi.spyOn(child, 'cancel') + let proves = false + child.proveClose = async () => { + if (proves) { + child.exit() + } + return proves + } + child.agent.stdin.destroy() + await vi.waitFor(() => expect(child.closes).toBe(1)) + proves = true + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(cancel).not.toHaveBeenCalled() + }) +}) + +describe('a Grok chat closed through the host while its turn runs', () => { + it('cancels the turn before ending the process, and the turn ends interrupted', async () => { + const { rig, host, turns } = await openAttachedHostRig() + const child = rig.child() + await send(host, 'hello') + const prompt = await rig.frame('session/prompt') + const turn = { ended: false } + answerCancel(child, prompt, turn) + const atClose = watchClose(child, turn) + await host.close(HOST_TEST_SESSION, 'user-close') + expect(atClose).toEqual({ cancels: 1, turnEnded: true }) + expect(child.exited).toBe(true) + expect((await turns()).at(-1)).toMatchObject({ state: 'interrupted' }) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts new file mode 100644 index 000000000000..3fad79da2362 --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -0,0 +1,463 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' +import { ACP_CHILD_ENV_TO_DELETE } from './acp-launch-specs' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' +import { + GROK, + openAcpAdapterRig, + PID, + PROVIDER_SESSION, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const hello: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] +} + +function send(rig: AcpAdapterRig, clientMessageId: string) { + return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: hello, fence: 1 }) +} + +function chunk(promptId: string, text: string) { + return { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + _meta: { promptId } + } +} + +describe('ACP structured session adapter: acquire', () => { + it('records the child before the handshake and starts a new session with client fs and terminals off', async () => { + const rig = await openAcpAdapterRig() + const acquired = await rig.acquire() + expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) + expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) + expect(acquired.link).toMatchObject({ + handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, + origin: 'created', + mintedAtFence: 1 + }) + expect(rig.sent('initialize')[0]?.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(rig.sent('session/new')[0]?.params).toEqual({ + cwd: '/workspace/project', + mcpServers: [] + }) + }) + + it('launches without any pane identity, so the agent own status hooks stay silent', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const launch = rig.child().launch + expect(launch.envToDelete).toEqual(expect.arrayContaining([...ACP_CHILD_ENV_TO_DELETE])) + expect(launch.envToDelete).toEqual( + expect.arrayContaining(['ORCA_PANE_KEY', 'ORCA_AGENT_PANE', ...AGENT_HOOK_RUNTIME_ENV_KEYS]) + ) + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-1' + }) + }) + + it('replaces a created session the agent never saved with a new one', async () => { + const rig = await openAcpAdapterRig({ + launch: { + resume: { + sessionId: 'lost', + key: 'old-key', + mayBeUnsaved: () => true, + unannouncedLosses: () => [] + } + }, + script: (agent) => + agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) + }) + const acquired = await rig.acquire({ fence: 2 }) + expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) + }) + + it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal) + expect(failure).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.child().closes).toBe(1) + }) +}) + +it('reports an agent that exits while starting with its own last words', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + child.exit() + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) + expect(rig.lifecycle).toEqual([]) +}) + +it('reads those last words when the agent ends its stdout before its exit is seen', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/new', () => { + const child = rig.child() + child.stderr = 'grok: config.toml is invalid' + // On POSIX a dying process's stdout ends first; its exit is observed a moment later. + child.agent.stdout.end() + setTimeout(() => child.exit(), 5) + }) + }) + const failure = await rig.acquire().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) + expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' }) +}) + +describe('ACP structured session adapter: turns', () => { + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { + const rig = await openAcpAdapterRig({ + deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } + }) + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + expect(prompt.params).not.toHaveProperty('_meta') + }) + + it('sends a prompt under an injected id and settles it on the agent first event', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(await send(rig, 'send-1')).toEqual({ state: 'admitted' }) + const prompt = await rig.frame('session/prompt') + expect(prompt.params).toMatchObject({ + prompt: [{ type: 'text', text: 'hello' }], + _meta: { promptId: 'prompt:send-1', requestId: 'prompt:send-1' } + }) + rig.child().agent.notify('session/update', chunk('prompt:send-1', 'Hi there')) + await waitFor(() => + expect(rig.settled).toEqual([ + { + sessionId: SESSION, + clientMessageId: 'send-1', + providerIdentity: { provider: 'orca', clientMessageId: 'send-1' } + } + ]) + ) + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + }) + + it('stops a running turn: open permissions answer cancelled, session/cancel goes out', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(7, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await rig.settle() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await permission).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect(rig.sent('session/cancel')).toHaveLength(1) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + }) + }) + + it('answers a Stop with no turn running as one the agent was not running', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: false, + refusal: { turnNotRunning: true } + }) + }) +}) + +describe('ACP structured session adapter: approvals', () => { + it('commits the journal answer before replying with the option the person chose', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const reply = agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [ + { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' } + ] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + const order: string[] = [] + let answered = false + void reply.then(() => { + answered = true + order.push('reply') + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'reject-once' }, + fence: 1, + commit: async () => { + order.push('commit') + expect(answered).toBe(false) + } + }) + expect(await reply).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'reject-once' } } + }) + expect(order).toEqual(['commit', 'reply']) + await expect( + rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + commit: async () => {} + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('answers cancelled a permission Grok asks during a turn it began itself, with no card', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', chunk('task-completed-background-1', 'Build finished; now')) + await rig.settle() + const reply = await agent.request(3, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + expect(reply).toMatchObject({ result: { outcome: { outcome: 'cancelled' } } }) + expect((await rig.rig.rows()).some((row) => row.body.kind === 'approval')).toBe(false) + }) +}) + +describe('ACP structured session adapter: requests a Stop cancels', () => { + it("answers an open question with Grok's own cancelled reply once a Stop cancels it", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const question = agent.request(5, '_x.ai/ask_user_question', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-2', + questions: [{ question: 'Which?', options: [{ label: 'A' }] }] + }) + await waitFor(async () => { + expect((await rig.rig.rows()).some((row) => row.body.kind === 'question')).toBe(true) + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + expect(await question).toMatchObject({ result: { outcome: 'cancelled' } }) + expect(agent.frames.filter((frame) => frame.id === 5)).toHaveLength(1) + }) + + it("shows Grok's plan in the plan row and answers its plan-mode exit at once, with no card", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + const { agent } = rig.child() + const plan = await agent.request(7, '_x.ai/exit_plan_mode', { + sessionId: PROVIDER_SESSION, + toolCallId: 'call-3', + planContent: '# Plan\n- step' + }) + expect(plan).toMatchObject({ result: { outcome: 'abandoned' } }) + await rig.settle() + const rows = await rig.rig.rows() + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n- step' + }) + ) + }) + + it('sends a permission answer whose save was under way when the Stop landed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + const prompt = await rig.frame('session/prompt') + const { agent } = rig.child() + const permission = agent.request(6, 'session/request_permission', { + sessionId: PROVIDER_SESSION, + toolCall: { toolCallId: 'call-1', title: 'Write file' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }] + }) + const row = await waitFor(async () => { + const found = (await rig.rig.rows()).find((item) => item.body.kind === 'approval') + expect(found).toBeDefined() + return found! + }) + agent.on('session/cancel', () => agent.reply(prompt, { stopReason: 'cancelled' })) + const answering = rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'allow-once' }, + fence: 1, + // The Stop lands while the person's answer is being saved. + commit: () => rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }).then(() => {}) + }) + // The Stop withdraws only what no answer has claimed. + await expect(answering).resolves.toBeUndefined() + expect(await permission).toMatchObject({ + result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } + }) + expect(rig.sent('session/cancel')).toHaveLength(1) + }) +}) + +describe('ACP structured session adapter: options and commands', () => { + it('reads the agent own models and effort and writes a pick through set_config_option', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) + }) + await rig.acquire() + const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 }) + expect(options.current).toMatchObject({ model: 'grok-4.7', effort: 'high' }) + expect(options.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) + expect(options.models[0]?.efforts.map((effort) => effort.value)).toEqual(['high', 'low']) + await rig.adapter.setOption({ sessionId: SESSION, key: 'model', value: 'grok-4.6', fence: 1 }) + expect(rig.sent('session/set_config_option')[0]?.params).toMatchObject({ + configId: 'model', + value: 'grok-4.6' + }) + await expect( + rig.adapter.setOption({ sessionId: SESSION, key: 'fastMode', value: 'true', fence: 1 }) + ).rejects.toThrow(/no session option/) + }) + + it('reports the agent own slash commands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + rig.child().agent.notify('session/update', { + sessionId: PROVIDER_SESSION, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'compact', description: 'Compress history', input: null }, + { name: 'always-approve', description: 'Toggle', input: { hint: 'on|off' } } + ] + } + }) + await waitFor(() => + expect(rig.adapter.readCommands(SESSION)).toEqual([ + { name: 'compact', kind: 'command', description: 'Compress history' }, + { name: 'always-approve', kind: 'command', description: 'Toggle', argumentHint: 'on|off' } + ]) + ) + }) +}) + +describe('ACP structured session adapter: close and exit', () => { + it('reports a close whose root exited but whose process tree was not proven gone', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + rig.child().processTreeUnproven = true + await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + }) + + it('closes with proof and reports a requested close', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toMatchObject([ + { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } + ]) + await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) + expect(rig.lifecycle).toHaveLength(1) + }) + + it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await send(rig, 'send-1') + await rig.frame('session/prompt') + await send(rig, 'send-2') + rig.child().stderr = 'grok: crashed' + rig.child().exit() + await waitFor(() => + expect(rig.lifecycle).toMatchObject([ + { + type: 'ended', + cause: 'unexpected-exit', + acquisitionGeneration: 'gen-acp', + failure: { kind: 'providerExited' } + } + ]) + ) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'send-2', state: 'rejected' }), + expect.objectContaining({ clientMessageId: 'send-1', state: 'unknown' }) + ]) + ) + await expect(send(rig, 'send-3')).rejects.toThrow(/no live grok child/) + }) +}) + +describe('Grok launch spec', () => { + it('runs `grok agent stdio`, asking for always-approve only with full access', () => { + expect(GROK.args({ fullAccess: false })).toEqual(['agent', 'stdio']) + expect(GROK.args({ fullAccess: true })).toEqual(['agent', '--always-approve', 'stdio']) + expect(GROK.env).toEqual({}) + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts new file mode 100644 index 000000000000..b0dd9804b5ea --- /dev/null +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -0,0 +1,341 @@ +// A structured chat over the Agent Client Protocol: one adapter per registered ACP agent, which +// the router drives like the Claude and Codex lanes. Rewind, compaction and goals are absent, so +// the chat hides them; everything else maps onto ACP methods. + +import { randomUUID } from 'node:crypto' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + type AgentSessionAcquisition, + type AgentSessionDispatchOutcome, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionSetOptionInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire' +import { + closeAcpSessionJournal, + endAcpStructuredSession, + type AcpStructuredSession +} from './acp-structured-session' +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 { + ACP_OPTION_WRITE_TIMEOUT_MS, + ACP_STOP_GRACE_MS, + type AcpStructuredSessionAdapterDeps +} from './acp-structured-session-adapter-deps' +import { writeAcpSessionOption } from './acp-structured-options' + +export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { + /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ + private readonly sessions = new Map() + private readonly starts = new AcpStructuredStarts() + + constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + + // The child runs on this runtime's own machine; Windows needs process start-time proof. + supportsLocation = (location: AgentSessionExecutionLocation): boolean => + supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + const attempt = this.starts.begin(input.signal) + try { + if (!(await this.stop(sessionId))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error( + `the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped` + ) + ) + } + return await this.start(input, attempt) + } finally { + this.starts.end(attempt) + } + } + + private async start( + input: StructuredAgentSessionAcquireInput, + attempt: AcpStartAttempt + ): Promise { + const sessionId = input.identity.sessionId + const generation = this.deps.mintGeneration?.() ?? randomUUID() + try { + const { acquisition, session } = await acquireAcpStructuredSession({ + acquire: input, + deps: this.deps, + generation, + abandoned: () => attempt.signal.aborted, + track: (connection) => this.starts.track(attempt, connection), + onExit: (session) => { + if (session && this.sessions.get(sessionId) === session) { + this.finish(session, this.now()) + } + }, + onConnectionLost: (session, error) => this.connectionLost(session, error), + onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), + forceClose: (id) => void this.forceCloseSession(id) + }) + if (attempt.signal.aborted) { + session.lane.dispose() + throw new Error('closed while starting') + } + this.sessions.set(sessionId, session) + return acquisition + } catch (error) { + const { connection } = attempt + if (connection && error instanceof AcpConnectionClosedError) { + // A protocol that broke before the exit was seen: wait (bounded) for that exit, so the + // failure carries the agent's last words, as a running session's end does. + await waitForAcpExit(connection, this.stopGraceMs(), attempt.signal) + } + // Checked before the close below, which would make any exit look like one Orca asked for. + const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted + if (connection && !(await connection.close().catch(() => false))) { + // Kept, so the next start or quit closes this same process again; no second one spawns. + this.starts.retainFailed(sessionId, connection) + throw new AgentSessionAcquisitionExitUnprovenError(error) + } + if (attempt.signal.aborted) { + const closed = new Error( + `${acpAgentName(this.deps.spec.agent)} was closed while starting`, + { cause: error } + ) + throw connection ? closed : new AgentSessionPreSpawnError(closed) + } + if (connection && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { + // The agent's own last words are what a person can act on. + throw new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error(connection.stderrTail() || String(error), { cause: error }) + ) + ) + } + throw error + } + } + + async dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + requestedAt?: number + beforeDispatch?: () => Promise + }): Promise { + const lost = this.sessions.get(input.sessionId) + if (lost && lost.journalClosed !== null) { + // The connection broke and the exit is not yet proven: the message never left Orca. + return this.rejected(lost, 'providerExited') + } + const session = this.live(input.sessionId) + const prompt = acpPromptBlocks(input.body) + if (!prompt) { + return this.rejected(session, 'attachmentInvalid') + } + await input.beforeDispatch?.() + session.turns.dispatch({ + clientMessageId: input.clientMessageId, + prompt, + requestedAt: input.requestedAt ?? this.now() + }) + // The write is the admission; the agent's first event for the turn settles it. + return { state: 'admitted' } + } + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { + const session = this.live(input.sessionId) + // The Stop ends the child unless it is declined here. Claude's rule: a Stop naming an ended turn + // while another one is live stops nothing; in the gap before a follow-up's turn opens, which no + // client can name, it stops what is in flight. + const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId + if (input.turnId !== undefined && liveTurnId !== null && input.turnId !== liveTurnId) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + if (!session.turns.running && session.lane.openTurnId === null && !session.turns.holdsSteers) { + return { cancelled: false, refusal: { turnNotRunning: true } } + } + // The agent may end its turn its own way; the host ends the process once that lands or the + // grace runs out (`awaitStoppedRequestEnd`), never waiting on the cancel's write. + interruptAcpTurn(session) + return { cancelled: true } + } + + // Stop is a session boundary for every ACP agent, as in the common pattern: a cancel ends only + // the running turn, and work the agent moved to the background could begin a turn of its own. + // The next send reloads the session. + stopEndsSession = (): boolean => true + + awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { + const session = this.sessions.get(sessionId) + if (session) { + await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) + } + } + + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.live(input.sessionId).prompts.answer(input) + + async setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> { + const session = this.live(input.sessionId) + const write = session.options.write(input.key, input.value) + if (!write) { + throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + } + // Bounded, and abandoned by a close or Stop: the session's queue waits on it. + await writeAcpSessionOption(session.connection, session.options, write, { + agent: session.spec.agent, + timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, + ...(input.signal ? { signal: input.signal } : {}) + }) + return session.options.reported() + } + + readOptions = async (input: { sessionId: string; fence: number }) => + this.live(input.sessionId).options.read() + + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.sessions.get(sessionId)?.restoreSkipped ?? [] + + readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + + // ACP has no way to stop one background task the agent started. + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined + + closeSession = (sessionId: string): Promise => this.close(sessionId) + disposeSession = (sessionId: string): Promise => this.close(sessionId) + releaseAcquisition = (input: { sessionId: string }): Promise => + this.close(input.sessionId) + /** After a sink failure: the exit is recovered as unexpected. */ + forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) + + async closeAll(): Promise { + const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()]) + // A start still under way is the host's to abort: its teardown does, before this runs. + const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true))) + if (proven.includes(false)) { + throw new Error('an ACP agent child could not be proven stopped') + } + } + + /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ + private async close(sessionId: string): Promise { + const connection = this.sessions.get(sessionId)?.connection + const closed = await this.stop(sessionId, true) + if (closed && connection?.processTreeUnproven) { + throw new AgentSessionAcquisitionRootExitObservedError( + new Error( + `${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone` + ) + ) + } + return closed + } + + /** True only once every child is proven gone, or when this adapter runs none for the session: + * a failed start's child, and the session's own. */ + private async stop(sessionId: string, requested = true): Promise { + const [failedStart, session] = await Promise.all([ + this.starts.stopFailed(sessionId), + this.stopSession(sessionId, requested) + ]) + return failedStart && session + } + + private async stopSession(sessionId: string, requested: boolean): Promise { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + return true + } + if (requested && session.journalClosed === null) { + // A close, dispose or quit ends a running turn as a Stop does before the child goes. + session.closeRequested = true + await windDownAcpTurn(session, this.stopGraceMs()) + if (session.ended) { + return true + } + } + // A connection loss already decided why the child ends; a later stop does not relabel it. + if (session.journalClosed === null) { + session.closeRequested ||= requested + session.lane.flush() + } + const proven = await session.connection.close() + if (proven) { + this.finish(session, session.exitObservedAt ?? this.now()) + } + return proven + } + + /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ + private finish(session: AcpStructuredSession, observedAt: number): void { + endAcpStructuredSession(session, observedAt, this.deps.onEvent) + if (this.sessions.get(session.sessionId) === session) { + this.sessions.delete(session.sessionId) + } + } + + /** The connection broke while the child may still run: nothing more it says can be journaled, so + * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ + private connectionLost(session: AcpStructuredSession | null, error: Error): void { + if ( + !session || + this.sessions.get(session.sessionId) !== session || + session.journalClosed !== null + ) { + return + } + closeAcpSessionJournal( + session, + `${session.spec.agent} ACP connection closed: ${error.message || error.name}` + ) + void this.stop(session.sessionId, false) + } + + private rejected( + session: AcpStructuredSession, + kind: 'providerExited' | 'attachmentInvalid' + ): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + } + + private live(sessionId: string): AcpStructuredSession { + const session = this.sessions.get(sessionId) + if (!session || session.journalClosed !== null) { + throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) + } + return session + } + + private stopGraceMs(): number { + return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS + } + + private now(): number { + return (this.deps.now ?? Date.now)() + } +} diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts new file mode 100644 index 000000000000..6b9fef556e43 --- /dev/null +++ b/src/main/acp/acp-structured-session.ts @@ -0,0 +1,145 @@ +// One live ACP agent connection and what Orca keeps beside it, and how its traffic and its +// process's exit reach the journal and the host. + +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + UNVERIFIABLE_TURN_VERDICT, + type StructuredAgentSessionTurnVerdict +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpSessionEvent } from './acp-session-runtime' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredOptions } from './acp-structured-options' +import type { AcpStructuredPrompts } from './acp-structured-prompts' +import type { AcpStructuredTurns } from './acp-structured-turns' + +export type AcpStructuredSession = { + sessionId: string + fence: number + acquisitionGeneration: string + spec: AcpLaunchSpec + /** The agent's process and its protocol, one owner. */ + connection: AcpStructuredConnection + lane: AcpStructuredLane + prompts: AcpStructuredPrompts + options: AcpStructuredOptions + turns: AcpStructuredTurns + /** Saved picks the agent refused when this child started. */ + restoreSkipped: readonly string[] + /** Orca asked this child to stop; its exit is then a requested close. */ + closeRequested: boolean + /** Why nothing this child says reaches the journal any more; null while it does. */ + journalClosed: string | null + /** Its exit is proven and the host was told. */ + ended: boolean + exitObservedAt: number | null + unbindReadingControl?: () => void +} + +/** Every agent frame goes through here once the lane exists: options and commands first, so a + * read right after the frame sees them, then the journal. `reattaching`: see `asReattachHistory`. */ +export function routeAcpSessionEvent( + session: Pick, + event: AcpSessionEvent, + at: number, + reattaching = false +): void { + if (event.kind === 'known') { + const { update } = event.notification + if (update.sessionUpdate === 'available_commands_update') { + session.options.adoptCommands(update.availableCommands) + } else if (update.sessionUpdate === 'config_option_update') { + session.options.adoptConfigOptions(update.configOptions) + } + } + const { translator } = session.lane + session.lane.apply( + reattaching + ? translator.notification( + 'session/update', + asReattachHistory(event.kind === 'known' ? event.notification : event.raw), + at + ) + : translator.sessionEvent(event, at) + ) +} + +/** While a chat reattaches, everything the agent sends is replayed history, whatever the agent + * marked: marked as replay, the translator drops it and keeps only its usage. */ +export function asReattachHistory(params: unknown): unknown { + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return params + } + const meta = '_meta' in params ? params._meta : undefined + const marked = typeof meta === 'object' && meta !== null && !Array.isArray(meta) ? meta : {} + return { ...params, _meta: { ...marked, isReplay: true } } +} + +/** + * Nothing more this child says reaches the journal, once: on its exit, or when its connection broke + * while it may still run. Open requests die, held sends never left Orca, the running send's fate is + * unknown, and the running turn ends as `verdict` says: `unverifiable` while the child may still + * run (the host's settlement of its proven exit revises it), interrupted at a proven exit. + */ +export function closeAcpSessionJournal( + session: AcpStructuredSession, + reason: string, + verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT +): void { + if (session.journalClosed !== null) { + return + } + session.journalClosed = reason + session.prompts.clear() + session.turns.end(reason) + session.lane.apply([{ type: 'session.ended', verdict }]) + session.lane.flush() + session.lane.dispose() + session.unbindReadingControl?.() +} + +/** The child's proven exit, once: the journal closes if it has not, and the host hears `ended` so + * it releases the session. */ +export function endAcpStructuredSession( + session: AcpStructuredSession, + observedAt: number, + onEvent: ((event: StructuredAgentSessionEndedEvent) => void) | undefined +): void { + if (session.ended) { + return + } + session.ended = true + session.exitObservedAt = observedAt + const stderr = session.connection.stderrTail() + // Read at the proven exit: a crash usually ends the agent's stdout before its exit is observed, + // so the connection's close comes first, but the agent's own last words are what explain it. + const reason = session.closeRequested + ? `${session.spec.agent} ACP agent closed by Orca` + : session.journalClosed !== null && !stderr + ? session.journalClosed + : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` + // A death of its own ended the running turn at the exit, as the host's exit settlement reads it + // (`exitedRootTurnScope`). A close Orca asked for leaves it `unverifiable`; that settlement revises it. + closeAcpSessionJournal( + session, + reason, + session.closeRequested + ? UNVERIFIABLE_TURN_VERDICT + : { state: 'interrupted', completedAt: observedAt } + ) + const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined + onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + reason, + failure: session.closeRequested + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', detail ? { detail } : {}), + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt + }) +} diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/acp/acp-structured-starts.ts new file mode 100644 index 000000000000..b87911174ca1 --- /dev/null +++ b/src/main/acp/acp-structured-starts.ts @@ -0,0 +1,70 @@ +// The adapter's starts: each acquire under way, stopped when the host aborts its signal (a close, +// a Stop that must not wait behind it, or quit), and each failed start's connection until its +// process's exit is proven, so the next start or quit retries that same connection's close instead +// of answering for a process it no longer knows (and never spawns a second one meanwhile). + +import type { AcpStructuredConnection } from './acp-structured-connection' + +export type AcpStartAttempt = { + /** The host's: the start's one canceller. */ + readonly signal: AbortSignal + connection: AcpStructuredConnection | null + /** What the signal's abort does while the start runs; detached once it ends. */ + readonly stopConnection: () => void +} + +export class AcpStructuredStarts { + private readonly failed = new Map() + + /** Registered before anything awaits, so an abort from here on stops this start. */ + begin(signal: AbortSignal | undefined): AcpStartAttempt { + const attempt: AcpStartAttempt = { + signal: signal ?? new AbortController().signal, + connection: null, + stopConnection: () => void attempt.connection?.close().catch(() => false) + } + attempt.signal.addEventListener('abort', attempt.stopConnection, { once: true }) + return attempt + } + + /** The start has its connection; one already aborted goes as soon as it exists. */ + track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void { + attempt.connection = connection + if (attempt.signal.aborted) { + void connection.close().catch(() => false) + } + } + + /** A connection the start handed over is the session's: an abort after this goes through its + * stop, which knows the close was asked for, not this listener, which would read as a crash. */ + end(attempt: AcpStartAttempt): void { + attempt.signal.removeEventListener('abort', attempt.stopConnection) + } + + /** A failed start whose process is not proven gone keeps its connection until its exit is. */ + retainFailed(sessionId: string, connection: AcpStructuredConnection): void { + this.failed.set(sessionId, connection) + connection.onExit(() => { + if (this.failed.get(sessionId) === connection) { + this.failed.delete(sessionId) + } + }) + } + + /** Asks a failed start's connection to close again: true once none is left unproven. */ + async stopFailed(sessionId: string): Promise { + const connection = this.failed.get(sessionId) + if (!connection) { + return true + } + const proven = await connection.close().catch(() => false) + if (proven && this.failed.get(sessionId) === connection) { + this.failed.delete(sessionId) + } + return proven + } + + failedSessionIds(): string[] { + return [...this.failed.keys()] + } +} diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts new file mode 100644 index 000000000000..19a6ee5cf5e1 --- /dev/null +++ b/src/main/acp/acp-structured-steer.test.ts @@ -0,0 +1,362 @@ +// A message sent while a Grok prompt runs steers: the running prompt is cancelled (the session +// stays) and the message goes as the next prompt. The adapter holds a steer only while that cancel +// lands; the host's queue holds everything else. + +import { afterEach, describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { + closeProviderTimelineRigs, + SESSION as ADAPTER_SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { + CALLER, + envelope +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' +import { + openAcpAdapterRig, + PROVIDER_SESSION, + replyChunk, + sendHello, + waitFor +} from './acp-structured-adapter.test-support' +import { + framesOf, + message, + openAttachedHostRig, + promptIdOf, + send, + stop +} from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +/** A turn Grok began itself, as a background task's completion wakes it. */ +const GROK_TURN = 'task-completed-background-1' + +function endsGrokTurn(agent: AcpScriptedAgent): void { + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'cancelled' } + }) +} + +/** Grok answers each cancel by ending the prompt it is running, as `cancelled`. */ +function answersCancels(agent: AcpScriptedAgent): void { + agent.on('session/cancel', () => { + const answered = new Set(agent.frames.flatMap((frame) => (frame.method ? [] : [frame.id]))) + const running = agent.frames.findLast( + (frame): frame is FakeFrame => frame.method === 'session/prompt' && !answered.has(frame.id) + ) + if (running) { + agent.reply(running, { stopReason: 'cancelled' }) + } + }) +} + +describe('a send while a Grok prompt runs', () => { + it('cancels the running prompt, then sends as the next one', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.sent('session/prompt')).toHaveLength(1) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + const second = await rig.frame('session/prompt', 1) + expect(promptIdOf(second)).toBe('prompt:steer') + rig.child().agent.notify('session/update', replyChunk('prompt:steer', 'on it')) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'running']) + }) + // A cancel, not a Stop: the session stays. + expect(rig.child().closes).toBe(0) + }) + + it('runs the last of two quick steers; the one between is sent and cancelled', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + await sendHello(rig, 'steer-1') + await sendHello(rig, 'steer-2') + await rig.settle() + // One cancel per running prompt, however many steers wait behind it. + expect(rig.sent('session/cancel')).toHaveLength(1) + answersCancels(rig.child().agent) + rig.child().agent.reply(rig.sent('session/prompt')[0]!, { stopReason: 'cancelled' }) + await rig.frame('session/prompt', 2) + await rig.settle() + expect(rig.sent('session/prompt').map(promptIdOf)).toEqual([ + 'prompt:first', + 'prompt:steer-1', + 'prompt:steer-2' + ]) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first', 'steer-1']) + }) + + it('asks again on the next steer when the first cancel was never written', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + await rig.settle() + const child = rig.child() + const cancel = child.cancel.bind(child) + let failNext = true + child.cancel = (options) => { + if (failNext) { + failNext = false + return Promise.reject(new Error('write failed')) + } + return cancel(options) + } + await sendHello(rig, 'steer-1') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(0) + await sendHello(rig, 'steer-2') + await sendHello(rig, 'steer-3') + await rig.settle() + // The failed write freed the running prompt's one cancel; the next steer used it. + expect(rig.sent('session/cancel')).toHaveLength(1) + }) + + it('withdraws a steer a Stop reaches before its cancel lands', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + const first = await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await expect(rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 })).resolves.toEqual( + { + cancelled: true + } + ) + rig.child().agent.reply(first, { stopReason: 'cancelled' }) + await rig.settle() + expect(rig.sent('session/prompt')).toHaveLength(1) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) + + it('waits as long as Grok takes to answer a steer cancel, and a Stop then still ends it', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await sendHello(rig, 'first') + await rig.frame('session/prompt') + rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) + await rig.settle() + await sendHello(rig, 'steer') + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) + // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. + await rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 }) + expect(rig.sent('session/cancel')).toHaveLength(2) + expect(rig.settled).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'steer', state: 'rejected' }) + ]) + ) + }) +}) + +describe('a send while a turn Grok began itself runs', () => { + it('goes to Grok at once, with no cancel of that turn', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'during') + const prompt = await rig.frame('session/prompt') + expect(rig.sent('session/cancel')).toHaveLength(0) + agent.notify('x.ai/session_notification', { + sessionId: PROVIDER_SESSION, + update: { sessionUpdate: 'turn_completed', prompt_id: GROK_TURN, stop_reason: 'end_turn' } + }) + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'on it')) + agent.reply(prompt, { stopReason: 'end_turn' }) + await rig.settle() + expect(rig.settled).toEqual([ + expect.objectContaining({ clientMessageId: 'during', providerIdentity: expect.anything() }) + ]) + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['completed', 'completed']) + }) + + it("cuts Grok's turn on a second send and runs both messages, never ending Grok", async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + const { agent } = rig.child() + agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.settle() + await sendHello(rig, 'a') + const a = await rig.frame('session/prompt') + // Grok queues `a` behind its own turn; a cancel ends only the running turn and promotes `a`. + agent.on('session/cancel', () => { + endsGrokTurn(agent) + agent.notify('session/update', replyChunk(promptIdOf(a), 'working on a')) + }) + await sendHello(rig, 'b') + await rig.settle() + expect(rig.sent('session/cancel')).toHaveLength(1) + // `a` runs a whole turn: a steer's cancel never ends Grok. + await new Promise((resolve) => setTimeout(resolve, 120)) + await rig.settle() + expect(rig.lifecycle).toEqual([]) + expect(rig.child().closes).toBe(0) + agent.reply(a, { stopReason: 'end_turn' }) + const b = await rig.frame('session/prompt', 1) + expect(promptIdOf(b)).toBe('prompt:b') + agent.notify('session/update', replyChunk('prompt:b', 'on b')) + agent.reply(b, { stopReason: 'end_turn' }) + await waitFor(async () => { + const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) + expect(turns.map((turn) => turn.state)).toEqual(['interrupted', 'completed', 'completed']) + }) + expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['a', 'b']) + expect(rig.child().closes).toBe(0) + }) +}) + +describe('Steer on a Grok card, through the host', () => { + /** A card queued behind whatever turn runs. */ + async function queueCard(rig: Awaited>) { + const { host } = rig + await host.flushStreamedEvents(SESSION) + const body = message('and then this') + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued card') + } + const cardId = queued.value.queued.messageId + const sendNow = () => + host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + const page = async () => { + await host.flushStreamedEvents(SESSION) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) + return history.ok ? history.page : null + } + const sends = async () => + (await host.journalSnapshot(SESSION)).submissions + .filter((entry) => entry.queuedMessageId === cardId) + .map((entry) => ({ state: entry.dispatchState, reason: entry.reason })) + return { cardId, sendNow, page, sends } + } + + /** A Grok turn running, and a card queued behind it. */ + async function cardBehindARunningTurn() { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + rig.rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + return { ...rig, prompt, ...(await queueCard(rig)) } + } + + it('leaves the card in the host queue while the turn runs, and cancels then sends it on Steer', async () => { + const { rig, host, prompt, sendNow, page, sends } = await cardBehindARunningTurn() + const { agent } = rig.child() + await rig.settle() + // Queued, not sent: nothing reaches Grok and the card stays editable. + expect((await page())?.queuedMessages?.map((card) => card.state)).toEqual(['waiting']) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) + + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + agent.reply(prompt, { stopReason: 'cancelled' }) + const steered = await rig.frame('session/prompt', 1) + agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect((await page())?.queuedMessages ?? []).toEqual([]) + expect(rig.child().exited).toBe(false) + await host.close(SESSION, 'user-close') + }) + + it("steers an older client's send the same way", async () => { + const rig = await openAttachedHostRig() + const { agent } = rig.rig.child() + await send(rig.host, 'hello') + const prompt = await rig.rig.frame('session/prompt') + agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) + await rig.rig.settle() + await rig.host.flushStreamedEvents(SESSION) + // No queued-message delivery: the host hands it over while the turn runs. + await send(rig.host, 'steer') + await waitFor(() => expect(framesOf(rig.rig.child(), 'session/cancel')).toHaveLength(1)) + agent.reply(prompt, { stopReason: 'cancelled' }) + await rig.rig.frame('session/prompt', 1) + await rig.host.close(SESSION, 'user-close') + }) + + it('brings the card back paused when a Stop reaches it before the cancel lands', async () => { + const { rig, host, sendNow, page, sends } = await cardBehindARunningTurn() + expect(await sendNow()).toMatchObject({ ok: true }) + await waitFor(() => expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(1)) + const first = rig.child() + expect(await stop(host)).toMatchObject({ ok: true, value: { cancelled: true } }) + await waitFor(() => expect(first.exited).toBe(true)) + expect(framesOf(first, 'session/prompt')).toHaveLength(1) + await waitFor(async () => { + const current = await page() + expect({ + pause: current?.queuePause ?? null, + cards: (current?.queuedMessages ?? []).map((card) => card.state), + sends: await sends() + }).toEqual({ + pause: { reason: 'stopped' }, + cards: ['waiting'], + sends: [{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + }) + }) + await host.close(SESSION, 'user-close') + }) + + it('sends the card at once on Steer during a turn Grok began itself, with no cancel', async () => { + const rig = await openAttachedHostRig({ stopGraceMs: 20 }) + const child = rig.rig.child() + child.agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) + await rig.rig.settle() + const { sendNow, sends } = await queueCard(rig) + expect(await sendNow()).toMatchObject({ ok: true }) + const steered = await rig.rig.frame('session/prompt') + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + endsGrokTurn(child.agent) + child.agent.notify('session/update', replyChunk(promptIdOf(steered), 'on it')) + await waitFor(async () => expect(await sends()).toMatchObject([{ state: 'accepted' }])) + expect(framesOf(child, 'session/cancel')).toHaveLength(0) + expect(child.exited).toBe(false) + await rig.host.close(SESSION, 'user-close') + }) +}) diff --git a/src/main/acp/acp-structured-stop.ts b/src/main/acp/acp-structured-stop.ts new file mode 100644 index 000000000000..85e48625b92b --- /dev/null +++ b/src/main/acp/acp-structured-stop.ts @@ -0,0 +1,62 @@ +// How a running ACP turn ends before its child does, for a Stop and a requested close alike: the +// cancel goes out once, and the agent gets a bounded grace, counted from it, to end its turn. + +import type { AcpStructuredSession } from './acp-structured-session' + +type AcpStoppableSession = Pick + +/** A turn a cancel can end: Orca's prompt, or one the agent opened itself. */ +function acpTurnInFlight(session: AcpStoppableSession): boolean { + return session.turns.running || session.lane.openTurnId !== null +} + +/** As a Stop: held steers and the agent's open requests are withdrawn, then the cancel is written, + * never awaited; a failed write leaves the grace to run out. */ +export function interruptAcpTurn(session: AcpStoppableSession): void { + session.turns.stop(Date.now()) + if (!acpTurnInFlight(session)) { + return + } + session.prompts.withdrawAll() + void session.connection.cancel().catch(() => undefined) +} + +/** Resolves once the running turn ended, or `graceMs` after `stoppedAt` (`Date.now()` time). */ +export async function awaitAcpTurnEnd( + session: AcpStoppableSession, + stoppedAt: number, + graceMs: number +): Promise { + let timer: ReturnType | undefined + const elapsed = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, stoppedAt + graceMs - Date.now())) + }) + const turnId = session.lane.openTurnId + try { + await Promise.race([ + Promise.all([ + session.turns.whenIdle(), + turnId === null ? undefined : session.lane.whenTurnLeaves(turnId) + ]), + elapsed + ]) + } finally { + clearTimeout(timer) + } +} + +/** Before a requested close: a running turn is cancelled, unless a Stop already did, and waited on + * within the grace that cancel started. */ +export async function windDownAcpTurn( + session: AcpStoppableSession, + graceMs: number +): Promise { + const { connection, turns } = session + if (connection.exited || connection.closed || !acpTurnInFlight(session)) { + return + } + if (turns.stoppedAt === null) { + interruptAcpTurn(session) + } + await awaitAcpTurnEnd(session, turns.stoppedAt ?? Date.now(), graceMs) +} diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts new file mode 100644 index 000000000000..df5495f65edc --- /dev/null +++ b/src/main/acp/acp-structured-turns.ts @@ -0,0 +1,270 @@ +// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt +// runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt +// once the agent answers the cancel, however long that takes. Each running prompt gets one cancel, +// however many steers arrive; steers wait here, the last one runs, and a Stop withdraws what waits. +// A turn the agent began itself is not Orca's to cut short: a message sent during it goes to the +// agent at once. Each send is settled exactly once: +// accepted when the agent's first event for its turn (or its answer) arrives, rejected when the +// agent refused it or it never left Orca, unknown when the agent died with it or its connection +// broke before it answered. + +import { + agentSessionFailureFact, + providerDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} 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' +import type { ContentBlock } from './generated/acp-protocol.generated' + +export type AcpDispatchSettlement = { clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { 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 = { + connection: Pick + /** Answers the agent's open requests cancelled, as a steer's cancel ends what they belong to. */ + withdrawRequests: () => void + lane: AcpStructuredLane + agentName: string + now: () => number + settle: (settlement: AcpDispatchSettlement) => void +} + +export class AcpStructuredTurns { + private active: Send | null = null + /** The running send a steer already cancelled: one cancel per prompt, retried if its write failed. */ + private steerCancelled: Send | null = null + /** Steers waiting for the prompt ahead of them to answer its cancel. */ + private readonly steers: Send[] = [] + private readonly unsettled = new Set() + private readonly idleWaiters = new Set<() => void>() + private ended = false + /** When a Stop or a requested close reached this session; it ends with the process. */ + private stopAt: number | null = null + + constructor(private readonly deps: AcpStructuredTurnsDeps) {} + + get running(): boolean { + return this.active !== null + } + + /** Whether an agent request may reach the person: Orca's prompt runs and nobody is cutting it + * short (no steer's cancel, no Stop). */ + get acceptsRequests(): boolean { + return ( + this.stopAt === null && + this.active !== null && + this.steerCancelled !== this.active && + !this.ended + ) + } + + /** Steers wait behind the running prompt's cancel. */ + get holdsSteers(): boolean { + return this.steers.length > 0 + } + + /** Whether a Stop reached this session. */ + get stopped(): boolean { + return this.stopAt !== null + } + + /** When the first Stop reached this session (`Date.now()` time); null before one did. */ + get stoppedAt(): number | null { + return this.stopAt + } + + /** A Stop: held steers never reach the agent, and nothing it asks from now on is shown. */ + stop(at: number): boolean { + this.stopAt ??= at + return this.withdrawSteers() + } + + /** Resolves once no prompt of Orca's is running. */ + whenIdle(): Promise { + if (!this.active) { + return Promise.resolve() + } + return new Promise((resolve) => this.idleWaiters.add(resolve)) + } + + dispatch(send: Send): void { + this.unsettled.add(send.clientMessageId) + if (this.active) { + this.steers.push(send) + this.cancelForSteer() + return + } + this.start(send) + } + + /** The agent took the send: its turn's first event, or its answer, arrived. */ + accept(clientMessageId: string): void { + if (this.unsettled.delete(clientMessageId)) { + this.deps.settle({ + clientMessageId, + providerIdentity: { provider: 'orca', clientMessageId } + }) + } + } + + private withdrawSteers(): boolean { + const withdrawn = this.steers.splice(0) + for (const send of withdrawn) { + this.reject(send.clientMessageId, agentSessionFailureFact('cancelled')) + } + return withdrawn.length > 0 + } + + /** The child is gone: held sends never left Orca, and the running one's fate is unknown. */ + end(reason: string): void { + this.ended = true + for (const send of this.steers.splice(0)) { + this.reject(send.clientMessageId, agentSessionFailureFact('providerExited')) + } + const active = this.active + if (active && this.unsettled.delete(active.clientMessageId)) { + this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) + } + this.active = null + this.steerCancelled = null + this.notifyIdle() + } + + private start(send: Send): void { + const { lane } = this.deps + this.active = send + const opened = lane.translator.openPrompt(send.clientMessageId, send.requestedAt) + lane.apply(opened.events) + // An agent whose dialect echoes this id on every event of the turn gets it, so its rows join the + // turn Orca opened; no other agent is sent the extension. + const meta = lane.translator.injectsPromptIdentity + ? { promptId: opened.promptId, requestId: opened.promptId } + : undefined + const answered = this.deps.connection.prompt(send.prompt, meta) + if (this.steers.length > 0) { + this.cancelForSteer() + } + answered.then( + (result) => { + if (this.active !== send) { + return + } + lane.apply(lane.translator.promptResult(send.clientMessageId, result, this.deps.now())) + this.accept(send.clientMessageId) + this.finish(send) + }, + (error: unknown) => { + if (this.active !== send || error instanceof AcpConnectionClosedError) { + // A lost connection: the session's end settles the send, as unknown. + return + } + if (!(error instanceof AcpAgentError)) { + // Answered, but not in a way Orca can read: the turn failed and the next send may go. + lane.apply( + lane.translator.promptFailed( + send.clientMessageId, + error instanceof Error ? error : new Error(String(error)), + this.deps.now() + ) + ) + this.accept(send.clientMessageId) + this.finish(send) + return + } + const refusal = lane.translator.promptRefused(send.clientMessageId, error) + if (refusal !== null) { + // The agent answered the prompt with an error before starting it: its own refusal. + const detail = providerDiagnostic(refusal, 'person') + this.reject( + send.clientMessageId, + agentSessionFailureFact('providerRejected', detail ? { detail } : {}) + ) + } else { + lane.apply(lane.translator.promptFailed(send.clientMessageId, error, this.deps.now())) + } + this.finish(send) + } + ) + } + + private finish(send: Send): void { + if (this.active === send) { + this.active = null + } + if (this.steerCancelled === send) { + this.steerCancelled = null + } + const next = this.ended ? undefined : this.steers.shift() + if (next) { + this.start(next) + } else if (!this.active) { + this.notifyIdle() + } + } + + /** Asks once per running prompt to end it; never bounded, so a slow answer only delays the steer, + * and a Stop still ends the session. */ + private cancelForSteer(): void { + const send = this.active + if (!send) { + return + } + this.deps.withdrawRequests() + if (this.steerCancelled === send) { + return + } + this.steerCancelled = send + this.deps.connection.cancel().catch(() => { + // Never written: the next steer may ask again. + if (this.steerCancelled === send) { + this.steerCancelled = null + } + }) + } + + private notifyIdle(): void { + for (const wake of this.idleWaiters) { + wake() + } + this.idleWaiters.clear() + } + + private reject(clientMessageId: string, fact: SubmissionRejectionFact): void { + if (!this.unsettled.delete(clientMessageId)) { + return + } + this.deps.settle({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(fact, { surface: 'rejection', agentName: this.deps.agentName }) + }) + } +} diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 195ab2d0a91a..27ce8c83d8e5 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -82,7 +82,7 @@ describe('ACP dialect request and turn boundaries', () => { }) }) - it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + it('encodes labels and free text under the question text, validates grouped choices, and settles a plan without a gate', async () => { const { translator } = await dialectRig() const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! expect( @@ -134,17 +134,17 @@ describe('ACP dialect request and turn boundaries', () => { outcome: 'accepted', answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } }) + // A plan is shown, never put to the person as an approval, and never approved for them. const plan = translator.request( '_x.ai/exit_plan_mode', - { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: 'Plan' }, + { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: '# Plan' }, 2 - ).presentation! - expect(plan.reply({ kind: 'option', optionId: 'approved' })).toEqual({ outcome: 'approved' }) - expect(plan.reply({ kind: 'option', optionId: 'request_changes' })).toEqual({ - outcome: 'request_changes' - }) - expect(plan.reply(null)).toEqual({ outcome: 'abandoned' }) - expect(() => plan.reply({ kind: 'option', optionId: 'allow' })).toThrow('offered option') + ) + expect(plan.presentation).toBeUndefined() + expect(plan.settled?.reply).toEqual({ outcome: 'abandoned', feedback: expect.any(String) }) + expect(plan.events).toMatchObject([ + { body: { kind: 'status', presentation: 'plan-document', text: '# Plan' } } + ]) }) it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index e4402b37e864..017f53c3fec9 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -106,18 +106,20 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - it('answers questions and plan approval with the recorded exact reply shapes', async () => { + it('answers questions with the recorded reply shape and shows the plan as a plan, not a gate', async () => { const fixture = await openAcpFixtureRig() const rows = await fixture.feed(await readAcpFixture('s5-plan-approved')) expect(rows.flatMap((row) => (row.body.kind === 'question' ? [row.body] : []))).toMatchObject([ { resolution: { state: 'resolved', answers: [{ questionId: 'q1', optionIds: ['o1'] }] } } ]) - expect(rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : []))).toMatchObject([ - { - subject: { kind: 'plan', text: '# Plan\n\nUpdate the example and run its tests.' }, - resolution: { state: 'resolved', selectedOptionId: 'approved' } - } - ]) + expect(rows.filter((row) => row.body.kind === 'approval')).toEqual([]) + expect(rows.map((row) => row.body)).toContainEqual( + expect.objectContaining({ + kind: 'status', + presentation: 'plan-document', + text: '# Plan\n\nUpdate the example and run its tests.' + }) + ) expect((await fixture.rig.turns())[0]!.outcome).toBe('success') }) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts index 6bc11b594756..84c1cbfcc2cc 100644 --- a/src/main/acp/acp-timeline-recovery.test.ts +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -33,24 +33,25 @@ describe('ACP reviewed traffic and recovery', () => { }) it.each([undefined, null])( - 'accepts an empty plan request and replies with the valid approval shape (%s)', + 'accepts an empty plan request: placeholder plan row, answered at once without approving (%s)', async (planContent) => { const f = await openAcpFixtureRig() for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { - const presentation = f + const translated = f .lane() - .request( - method, - { sessionId: 'session-1', toolCallId: 'plan', planContent }, - 1 - ).presentation - expect(presentation?.body).toMatchObject({ - kind: 'approval', - subject: { kind: 'plan', text: 'The agent exited plan mode without writing a plan.' } - }) - expect(presentation?.reply({ kind: 'option', optionId: 'approved' })).toEqual({ - outcome: 'approved' - }) + .request(method, { sessionId: 'session-1', toolCallId: 'plan', planContent }, 1) + expect(translated.presentation).toBeUndefined() + expect(translated.events).toMatchObject([ + { + type: 'item.update', + body: { + kind: 'status', + presentation: 'plan-document', + text: 'The agent exited plan mode without writing a plan.' + } + } + ]) + expect(translated.settled?.reply).toMatchObject({ outcome: 'abandoned' }) } } ) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index aa7e396c1368..176967409c9e 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' @@ -57,19 +61,49 @@ export function translateAcpRequest( dialect: AcpDialect tools: AcpToolTimeline } -): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { +): { + events: ProviderTimelineEvent[] + presentation?: AcpRequestPresentation + /** Answered at once: nobody is asked. */ + settled?: { reply: unknown } +} { const session = requestSessionSchema.safeParse(params) if (!session.success || session.data.sessionId !== options.sessionId) { throw new AcpRpcError(-32602, 'ACP request belongs to an unknown session') } - const presentation = - method === 'session/request_permission' - ? acpPermissionPresentation(params) - : options.dialect.request?.(method, params) const tool = requestToolSchema.safeParse(params) const callId = tool.success ? (tool.data.toolCall?.toolCallId ?? tool.data.toolCallId) : undefined const turn = callId ? options.tools.turn(callId) : undefined const join = { thread: options.sessionId, ...(turn === undefined ? {} : { turn }) } + const settlement = + method === 'session/request_permission' + ? undefined + : options.dialect.settleRequest?.(method, params) + if (settlement) { + const plan = settlement.plan + return { + settled: { reply: settlement.reply }, + events: + plan === undefined + ? [] + : [ + { + type: 'item.update', + item: `${method}:${JSON.stringify(id)}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText(plan, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + }, + join + } + ] + } + } + const presentation = + method === 'session/request_permission' + ? acpPermissionPresentation(params) + : options.dialect.request?.(method, params) if (!presentation) { return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index 9f927bceed0f..30af2bf56444 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -3,12 +3,8 @@ import { BoundedMap } from '../../shared/bounded-map' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import { - GENERIC_ACP_DIALECT, - type AcpDialect, - type AcpRequestPresentation -} from './acp-dialects/acp-dialect' -import type { AcpAgentError } from './acp-errors' +import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' +import { AcpAgentError } from './acp-errors' import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' @@ -61,6 +57,11 @@ export class AcpTimelineTranslator { ) } + /** Whether the agent's dialect echoes an injected prompt identity on the turn's events. */ + get injectsPromptIdentity(): boolean { + return this.dialect.injectedPromptIdentity === true + } + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ openPrompt( clientMessageId: string, @@ -80,10 +81,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, result.stopReason, at) } - /** The agent's own error answer to the prompt; Orca's errors about it (a timeout, an unreadable - * answer, a closed connection) are no provider words and never reach here. */ - promptFailed(clientMessageId: string, error: AcpAgentError, at: number): ProviderTimelineEvent[] { - const detail = acpPromptErrorDetail(this.dialect, error) + /** The prompt failed: the agent's own error answer, or an answer Orca could not read (then no + * words are the agent's, and the row says only that the turn failed). A closed connection never + * reaches here. */ + promptFailed(clientMessageId: string, error: Error, at: number): ProviderTimelineEvent[] { + const detail = + error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined const ended = this.prompts.last if (this.prompts.current?.clientMessageId !== clientMessageId) { // The provider already ended this turn; its answer may carry the only copy of the reason. @@ -94,6 +97,12 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, 'error', at, detail) } + /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. + * Null once the turn opened, when the refusal ends that turn instead (`promptFailed`). */ + promptRefused(clientMessageId: string, error: AcpAgentError): string | null { + return this.prompts.refuse(clientMessageId) ? acpPromptErrorDetail(this.dialect, error) : null + } + private finishPrompt( clientMessageId: string, stopReason: string, @@ -212,7 +221,7 @@ export class AcpTimelineTranslator { method: string, params: unknown, id: string | number - ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + ): ReturnType { return translateAcpRequest(method, params, id, { sessionId: this.options.sessionId, dialect: this.dialect, diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 326a8d322eee..30e652a6cb4c 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -252,10 +252,21 @@ describe('a launch into a workspace that already exists', () => { describe('an agent with no structured session', () => { it('stays a terminal without asking the host', async () => { const h = harness({}) - const result = await h.run({ agent: 'grok', target: { kind: 'existing', worktree: 'wt-7' } }) + const result = await h.run({ agent: 'gemini', target: { kind: 'existing', worktree: 'wt-7' } }) expect(h.calls).toEqual(['createTerminalAgent']) expect(result.receipt).toMatchObject({ reason: 'agent_without_structured_session' }) }) + + it('asks the host for an agent it registered beyond Claude and Codex', async () => { + const h = harness({}) + const result = await h.run({ ...CREATE_INTENT, agent: 'grok' }) + expect(h.calls).toEqual([ + 'createWorktree(startupAgent=undefined)', + 'createSupport', + 'createStructuredSession' + ]) + expect(result.receipt).toMatchObject({ mode: 'structured' }) + }) }) describe('the prompt receipt', () => { diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 500329a9da6e..3b5794a0ba74 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -42,7 +42,6 @@ import { settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' -import type { TuiAgent } from '../../shared/tui-agent' import { workspaceKindForWorktreeId, type WorkspaceLaunchKind @@ -71,6 +70,8 @@ export type AgentLaunchExecution = { surfaces: AgentLaunchSurfaceFactory workspaces?: AgentLaunchWorkspaceFactory vocabulary?: AgentLaunchModeVocabulary + /** False when the calling client cannot show the agent's chat; absent for the host's own callers. */ + callerRendersStructured?: boolean /** Attributes a throw to the step that was running, the way a dispatch's own stages do. */ onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void /** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */ @@ -102,7 +103,8 @@ export async function executeAgentLaunch( ...(intent.cwd ? { cwd: intent.cwd } : {}), ...(intent.target.kind === 'existing' && intent.target.workspacePath ? { workspacePath: intent.target.workspacePath } - : {}) + : {}), + ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {}) }, settings, vocabulary @@ -283,7 +285,7 @@ async function createSurface( settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution - if (settled.mode === 'structured' && isStructuredProvider(intent.agent)) { + if (settled.mode === 'structured') { // One reservation serves either route: the tab half of the reserved pane is the chat's tab. const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined const session = await surfaces.createStructuredSession({ @@ -384,10 +386,6 @@ function combineLaunchWarnings( return `${create} Also ${surface[0].toLowerCase()}${surface.slice(1)}` } -function isStructuredProvider(agent: TuiAgent): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' -} - function existingWorktreeId(target: AgentLaunchTarget): string { return target.kind === 'existing' ? target.worktree : '' } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 7b3105b07d89..2a96c4f58088 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -24,6 +24,7 @@ import type { AgentLaunchModeReceipt } from '../../shared/agent-launch-intent' import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../runtime/structured-agent-runtime-registrations' import { prefersStructuredNativeChatByDefault, resolveStructuredNativeChatSupport, @@ -77,8 +78,15 @@ export type AgentLaunchModePlacement = { /** The root of the workspace the launch lands in, when the host has resolved it. Without it a * requested `cwd` cannot be proven to name the root and is read as custom. */ workspacePath?: string + /** False when the client asking for the launch cannot show this agent's chat; absent for the + * host's own callers, which can. */ + callerRendersStructured?: boolean } +const REGISTERED_STRUCTURED_AGENTS: readonly string[] = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => definition.agent +) + const DOWNGRADE_DETAIL: Record, string> = { remote_execution_host: 'this launch runs on a remote execution host', reused_terminal: 'it reuses a running terminal agent', @@ -123,6 +131,9 @@ export function decideAgentLaunchMode(args: { placement: AgentLaunchModePlacement settings: AgentLaunchModeSettings | null | undefined vocabulary?: AgentLaunchModeVocabulary + /** Registered agents (beyond Claude and Codex) this surface can open as structured; defaults to + * every agent this host registers. */ + registeredStructuredAgents?: readonly string[] }): AgentLaunchModeReceipt { const { placement, settings } = args const vocabulary = args.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY @@ -139,13 +150,15 @@ export function decideAgentLaunchMode(args: { if (placement.on) { return downgraded('remote_execution_host', vocabulary) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; isAgentSessionHandleProvider rejects it and the launch downgrades to a terminal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an unrecognized agent name is handled rather than trusted; the registered-agent check rejects it and the launch downgrades to a terminal. const agent = placement.agent as TuiAgent const support = resolveStructuredNativeChatSupport({ agent, executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, + // This host is the one that will run the agent, so its own registrations answer. + hostStructuredAgents: args.registeredStructuredAgents ?? REGISTERED_STRUCTURED_AGENTS, // The host resolves the floating workspace to its configured directory; create-support still // answers for the resolved workspace, including whether it uses WSL. ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), @@ -159,6 +172,10 @@ export function decideAgentLaunchMode(args: { if (!support.supported) { return downgraded(BLOCKER_REASON[support.blocker], vocabulary) } + // A chat its caller can neither show nor close is no launch; that caller gets the terminal. + if (placement.callerRendersStructured === false) { + return downgraded(BLOCKER_REASON['client-capability'], vocabulary) + } return { mode: 'structured', preferred: 'structured', @@ -195,7 +212,7 @@ async function readStructuredCreateSupport( worktreeId: string, agent: TuiAgent | undefined ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } | null> { - if (agent !== 'claude' && agent !== 'codex') { + if (!agent || !REGISTERED_STRUCTURED_AGENTS.includes(agent)) { return { supported: false, reason: 'agent' } } try { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index cf8736408610..b9cf2e8c6b5c 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -5,6 +5,7 @@ */ import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an @@ -13,7 +14,8 @@ import type { TuiAgent } from '../../shared/tui-agent' export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string - agent: 'claude' | 'codex' + /** An agent this host registered as structured; the launch mode already checked it. */ + agent: StructuredAgentId options?: Readonly> /** The caller-minted session id; refused with `AgentLaunchSessionAlreadyExistsError` if taken. */ sessionId?: string diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index c1078a82a57a..5bb0a3754745 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -27,13 +27,19 @@ vi.mock('./claude-agent-sdk-exit-proof', () => ({ proveClaudeChildExit: (...args: unknown[]) => mocks.proveClaudeChildExit(...args) })) -function fakeChild(): ChildProcessWithoutNullStreams { +function fakeChild( + streams: { stdout: PassThrough; stderr: PassThrough } = { + stdout: new PassThrough(), + stderr: new PassThrough() + } +): ChildProcessWithoutNullStreams { const child = new EventEmitter() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only pid, the three pipes and kill, which this fake carries. return Object.assign(child, { pid: 424242, stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), + stdout: streams.stdout, + stderr: streams.stderr, kill: vi.fn() }) as unknown as ChildProcessWithoutNullStreams } @@ -134,6 +140,52 @@ describe('Claude stream-json close ordering', () => { await expect(connection.close()).resolves.toBe(true) }) + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const stdout = new PassThrough() + const stderr = new PassThrough() + const child = fakeChild({ stdout, stderr }) + // The SDK's stream ends with the child's stdout. + const next = vi + .fn<() => Promise>>>() + .mockResolvedValue({ value: undefined, done: true }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const exits: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { onExit: (error, exit) => exits.push(`${exit?.expected}: ${error.message}`) }, + () => child, + queryImpl + ) + + stderr.write('session limit reached\n') + stdout.end() + await vi.waitFor(() => expect(next).toHaveBeenCalledOnce()) + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + + child.emit('exit', 1, null) + await vi.waitFor(() => + expect(exits).toEqual(['false: claude stream-json exited (code 1): session limit reached']) + ) + await connection.close() + }) + it('returns an unproven close without waiting on a live output reader', async () => { mocks.refresh.mockReset() mocks.proveClaudeChildExit.mockReset() diff --git a/src/main/codex/codex-app-server-connection-exit-order.test.ts b/src/main/codex/codex-app-server-connection-exit-order.test.ts new file mode 100644 index 000000000000..661d099df73c --- /dev/null +++ b/src/main/codex/codex-app-server-connection-exit-order.test.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { openCodexAppServerConnection } from './codex-app-server-connection' + +function stubChild() { + const child = Object.assign(new EventEmitter(), { + // Outside any real process table so teardown never mistakes an unrelated process for it. + pid: 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection reads only events, pid, streams and kill from this stub. + const spawnImpl = (() => child) as unknown as typeof spawnProcess + return { child, spawnImpl } +} + +describe('Codex app-server exit order', () => { + // Stdout can end before the exit is seen, as when the provider supervisor's own exit closes it. + it('reports an exit whose stdout ended first once the exit is seen, with its usual reason', async () => { + const { child, spawnImpl } = stubChild() + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + child.stderr.write('codex crashed\n') + child.stdout.end() + // Stream writes land a tick later, so the stderr tail is only complete here. + await new Promise((resolve) => setImmediate(resolve)) + expect(exits).toEqual([]) + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect(exits).toHaveLength(1) + expect(exits[0]).toContain('codex crashed') + await connection.close() + }) +}) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 96f182b45435..4fbbc7516ce9 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -31,7 +31,10 @@ import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electr import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { readsAgentLaunchTabClosed } from '../runtime/rpc/methods/agent-launch-replay' -import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' +import { + createSupportFollowsHostSetting, + structuredAgentsReadBy +} from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' // Every paired transport sends the shared base plus the Electron list, so compare the union. @@ -121,6 +124,21 @@ describe('desktop renderer runtime client capabilities', () => { ) }) + // The renderer reads `agentSession.agents` (host-structured-agents.ts) and renders any listed + // agent's chat, so each host serves it every agent's tabs. + it.each([ + ['a paired host', PAIRED_HOST_RECEIVES], + ['its own main process', DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + ] as const)('reads every agent %s registered', (_host, clientCapabilities) => { + expect( + structuredAgentsReadBy({ clientKind: 'runtime', clientCapabilities }, [ + 'claude', + 'codex', + 'grok' + ]) + ).toBeUndefined() + }) + it('diverges from what a paired host receives only where a decision was recorded', () => { expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( [...REMOTE_ONLY_BY_DECISION].sort() diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index c622f5a24e19..ab205b559758 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -7,6 +7,7 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, 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' @@ -39,6 +40,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts index 6dd47f03ec53..f1c0038b4d86 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-identity.ts @@ -76,6 +76,22 @@ export function spellProviderTimelineKey( : `m:${providerTimelineKeyPart(key.value)}` } +/** Whether a turn id names a turn the provider keyed inside session `namespace`. */ +export function isProviderTimelineTurnInNamespace(turnId: string, namespace: string): boolean { + return turnId.startsWith(`p:${providerTimelineKeyPart(namespace)}:`) +} + +/** The spelled key of a provider-keyed item's record id, whichever session and thread wrote it; + * null for any other row. Compare it with `providerTimelineKeyPart(key)`. */ +export function spelledProviderTimelineItemKey(recordId: string): string | null { + if (!recordId.startsWith('item:p:')) { + return null + } + // Every part is URI-encoded, so `:` and `/` only ever separate parts. + const scoped = recordId.slice(recordId.lastIndexOf(':') + 1) + return scoped.slice(scoped.lastIndexOf('/') + 1) +} + /** The existing `legacy` arm. Turn rows keep the `turn-lifecycle:` record prefix the other lanes * write; provider keys are spelled inside their namespace, and apart from minted ones. */ export function createLegacyProviderTimelineIdentityScheme(input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts new file mode 100644 index 000000000000..a3eabd09f0e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -0,0 +1,40 @@ +// The acquire each session has in flight, owned by the host that runs it, or another provider wait +// its queue is on (an option write). The queue runs one step at a time, so a session has at most +// one; a close, a Stop admitted now, or quit aborts it from outside the queue instead of waiting +// behind a provider that may never answer. + +export class StructuredAgentSessionAcquireAborts { + private readonly inFlight = new Map() + /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ + private quitReason: Error | null = null + + /** For the attach about to run; `end` once it settles. */ + begin(sessionId: string): { signal: AbortSignal; end: () => void } { + const controller = new AbortController() + if (this.quitReason) { + controller.abort(this.quitReason) + } + this.inFlight.set(sessionId, controller) + return { + signal: controller.signal, + end: () => { + if (this.inFlight.get(sessionId) === controller) { + this.inFlight.delete(sessionId) + } + } + } + } + + /** A no-op when the session has nothing in flight. */ + abort(sessionId: string, reason: string): void { + this.inFlight.get(sessionId)?.abort(new Error(reason)) + } + + /** Quit: every start under way stops, and so does any the attach drain still runs. */ + abortAll(reason: string): void { + this.quitReason ??= new Error(reason) + for (const controller of this.inFlight.values()) { + controller.abort(this.quitReason) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index af986ae7e442..3389bc615e58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -29,6 +29,8 @@ export async function acquireOwner( try { try { await input.onAcquiring?.() + // A close or Stop that landed while the attach was still reconciling launches nothing. + input.acquireSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } @@ -40,6 +42,7 @@ export async function acquireOwner( ...(record.options ? { options: record.options } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), onSpawned: async (process) => { record = await input.store.commitProcessIdentity({ sessionId: record.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index f111081ab44b..da30324c423b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -258,6 +258,10 @@ export type StructuredAgentSessionAcquireInput = { /** Durably records the child's identity the moment it exists, before any handshake, so a crash * mid-start leaves an owner recovery can stop. The acquisition's `process` must match it. */ onSpawned?: (process: AgentSessionProcessIdentity) => Promise + /** Aborted by a close, or by a Stop admitted now, that must not wait behind this acquire: the + * adapter stops what it started and the acquire fails. An adapter whose acquire never waits on + * the provider's handshake may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionSetOptionInput = { @@ -265,6 +269,9 @@ export type StructuredAgentSessionSetOptionInput = { key: string value: string fence: number + /** Aborted by a close, a Stop admitted now, or quit, which must not wait behind a pick the + * provider never answers: the write fails. An adapter that bounds its own write may ignore it. */ + signal?: AbortSignal } export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 85501dd8a319..9046e40dbbe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -50,6 +50,8 @@ export type StructuredAgentSessionResumeOutcome = /** What the provider said about the failed start, for the chat's own record; host-side * only, never on the refusal. */ diagnostic?: ProviderDiagnostic + /** The host's own close, Stop or quit aborted the start, so it failed nothing it was for. */ + aborted?: true argumentProblem?: AgentSessionArgumentProblem } @@ -165,11 +167,15 @@ async function startStructuredAgentSessionAgent( } let attached: AgentSessionMutationResult let acquisitionError: unknown + let aborted = false try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), onAcquisitionFailed: (error) => { acquisitionError = error + }, + onAborted: () => { + aborted = true } }) } catch (error) { @@ -184,16 +190,17 @@ async function startStructuredAgentSessionAgent( error ) if (settled) { - return withDiagnostic(settled.refusal, error) + return withDiagnostic(settled.refusal, error, aborted) } throw error } - return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError, aborted) } function withDiagnostic( refusal: AgentSessionWireRefusal, - error: unknown + error: unknown, + aborted: boolean ): StructuredAgentSessionResumeOutcome { const diagnostic = providerDiagnosticOf(error) const argumentProblem = argumentProblemOf(error) @@ -201,6 +208,7 @@ function withDiagnostic( ok: false, refusal, ...(diagnostic ? { diagnostic } : {}), + ...(aborted ? { aborted: true as const } : {}), ...(argumentProblem ? { argumentProblem } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 453a40075aed..9c287da62e2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -61,6 +61,8 @@ export type AttachFlowInput = { params: AgentSessionAttachParams now: () => number recordPhase?: AgentSessionCreatePhaseRecorder + /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ + acquireSignal?: AbortSignal /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is * true only when this attach spawned the provider child, so a re-attach to a live one is not * mistaken for a cold acquire. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 3a13e1424203..e801a99042da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -49,6 +49,8 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ + onAborted?: () => void } /** @@ -105,6 +107,25 @@ async function runAttach( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions +): Promise> { + // Begun first, so a close or Stop during any phase below still stops the start. + const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + try { + return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + } finally { + acquire.end() + if (acquire.signal.aborted) { + options.onAborted?.() + } + } +} + +async function runAttachUnderAbort( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions, + acquireSignal: AbortSignal ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -171,6 +192,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + acquireSignal, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 45fad626ed1e..a983753e2a99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -21,12 +21,12 @@ import { type StructuredAgentSessionLeaseStore } from './structured-agent-session-lease-release' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, type DeadGenerationJournal, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { evictStructuredAgentSession } from './structured-agent-session-eviction' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' @@ -186,6 +186,9 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). await close?.recorded const generation = child.generation ?? 'unknown' + // The exit proves this child gone, as the record's death evidence later says: what it left + // `unverifiable` is revised now, not at the next open. + const watched = { ownerFence: child.fence, observedAt } const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -205,12 +208,14 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< unfinishedStructuredAgentSessionWorkWasInterrupted( unfinishedWork, session.journal, - observedAt + observedAt, + watched )), ...(startFailure ? { exitFailure: startFailure } : {}), ...(startFailed && child.generation ? { exitedDuringStartup: { generation: child.generation } } : {}), + exit: watched, ...(unrunRejection ? { unrunRejection } : {}) }) if (!settled.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts new file mode 100644 index 000000000000..2823e9c5d651 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts @@ -0,0 +1,74 @@ +// A close must not wait behind a start the provider never answers: the start runs under the +// session's queue, and the close's own stop is queued behind it. The close aborts the start's +// signal instead, at whatever phase the attach is in. + +import { expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attachParams, + CALLER, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +function openHost(state: ReturnType): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) + return host +} + +it('a close stops a start the provider never answers instead of queueing behind it', async () => { + const state = hostTestState() + let signal: AbortSignal | undefined + // A start the provider never answers ends only when its acquire is aborted. + state.acquire.mockImplementation( + (input) => + new Promise((_resolve, reject) => { + signal = input.signal + input.signal?.addEventListener('abort', () => reject(new Error('closed while starting'))) + }) + ) + const host = openHost(state) + const attaching = host.attach(CALLER, attachParams()) + await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) + await host.close(SESSION, 'user-close') + expect(signal?.aborted).toBe(true) + expect((await attaching).ok).toBe(false) +}) + +it('a close while the attach still probes the previous owner asks no adapter to start', async () => { + const state = hostTestState() + // An adapter that never reads the signal: only the host's own check keeps it from starting. + const host = openHost(state) + const runtimeState = host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let closing: Promise | undefined + runtimeState.probeOwner = async (sessionId) => { + closing ??= host.close(SESSION, 'user-close') + return probe(sessionId) + } + expect(await host.attach(CALLER, attachParams()).catch((error: unknown) => error)).toMatchObject({ + name: 'AgentSessionPreSpawnError' + }) + await closing + expect(state.acquire).not.toHaveBeenCalled() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7ee9d070f4c9..f6d7d6d4be7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -19,6 +19,7 @@ import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -28,7 +29,6 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 2e5c30dd4afd..28393709a641 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -155,8 +155,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent; a person's message stays as a held card. */ - close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => - serialize(sessionId, async () => { + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { + // Outside the queue: a start the provider never answers must not hold the close behind it. + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + return serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { @@ -166,5 +168,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 1df56dc5228a..18d76e5cd093 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -11,11 +11,11 @@ import { providerDiagnostic } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { captureUnfinishedStructuredAgentSessionWork, - settleStructuredAgentSessionDeadGeneration, unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' +} from './structured-agent-session-unfinished-work' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -248,7 +248,9 @@ describe('dead structured-session generation settlement', () => { | 'rejectPendingSubmissions' | 'rejectQueuedSubmissions' | 'appendLifecycleBatch' + | 'itemFence' > = { + itemFence: () => undefined, snapshot: () => ({ ...settledSnapshot, items: [settledItem] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index b9217f948afc..ce11c2dc34c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -3,19 +3,21 @@ import { MAX_PROVIDER_DIAGNOSTIC_CHARS, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { endedUnseenMessageBody, - requiresTerminalSettlement, runningCallEnd, terminalAgentJournalBody } from '../agent-session-journal/journal-terminal-settlement' @@ -37,76 +39,25 @@ import { runningTurnLifecycleRevisions, stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, - type StructuredAgentSessionTurnVerdict + watchedExitRevisions, + type StructuredAgentSessionTurnVerdict, + type StructuredAgentSessionWatchedExit } from './structured-agent-session-stale-turn-verdict' import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' import { - withdrawCodexSendsNoTurnOpenedFor, - type UnopenedSendJournal -} from './structured-agent-session-unopened-send-withdrawal' + hasUnfinishedStructuredAgentSessionWork, + isInProgressStructuredAgentSessionItem, + type DeadGenerationJournal +} from './structured-agent-session-unfinished-work' +import { withdrawCodexSendsNoTurnOpenedFor } from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -export type DeadGenerationJournal = UnopenedSendJournal & { - appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] - markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] - rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] -} - -export type StructuredAgentSessionUnfinishedWork = { - items: AgentJournalRenderItem[] - hadUnsettledSubmissions: boolean -} - -export function captureUnfinishedStructuredAgentSessionWork( - journal: DeadGenerationJournal -): StructuredAgentSessionUnfinishedWork { - return { - items: journal.snapshot().items.filter(isUnfinishedItem), - hadUnsettledSubmissions: hasUnsettledSubmission(journal) - } -} - -function hasUnfinishedStructuredAgentSessionWork(journal: DeadGenerationJournal): boolean { - const work = captureUnfinishedStructuredAgentSessionWork(journal) - return work.hadUnsettledSubmissions || work.items.length > 0 -} - -export function unfinishedStructuredAgentSessionWorkWasInterrupted( - before: StructuredAgentSessionUnfinishedWork, - journal: DeadGenerationJournal, - observedExitAt: number -): boolean { - const currentSnapshot = journal.snapshot() - if (hasUnsettledSubmission(journal) || currentSnapshot.items.some(isInProgressItem)) { - return true - } - if ( - currentSnapshot.items.some((item) => { - const turn = readAgentJournalTurn(item.body) - return turn?.state === 'interrupted' && turn.completedAt === observedExitAt - }) - ) { - return true - } - const inProgressBefore = before.items.filter(isInProgressItem) - if (inProgressBefore.length === 0) { - return false - } - const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) - const runningTurns = inProgressBefore.filter( - (item) => readAgentJournalTurn(item.body)?.state === 'running' - ) - const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore - return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) -} - /** Whether the settlement was written, and what stopped it when it was not. */ export type StructuredAgentSessionDeadGenerationSettlement = | { ok: true } @@ -127,11 +78,14 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** The exit, watched: what that child's own translator could only end `unverifiable` (its stream + * closed before the exit was proven) is revised in this batch. */ + exit?: StructuredAgentSessionWatchedExit /** A person's Stop ended a starting child before what it was handed could run: each is rejected so. */ unrunRejection?: SubmissionRejectionFact }): Promise { try { - const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) + const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal, input.exit) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } @@ -153,6 +107,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ? input.journal.rejectPendingSubmissions(input.fence, unrun) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items + const proven = watchedExitRevisions(items, input.exit, input.journal) const mutations: JournalLifecycleMutationInput[] = [] if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { const startKey = input.exitedDuringStartup.generation ?? input.settlementId @@ -181,7 +136,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ), tone: 'error' }, - turnScope: exitedRootTurnScope(items, input.verdict) + turnScope: exitedRootTurnScope(withRevisions(items, proven), input.verdict) }) } const bodies = new Map(items.map((item) => [item.itemId, item.body])) @@ -200,8 +155,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) - mutations.push(...turnEnds) + mutations.push(...runningTurnLifecycleRevisions(items, input.verdict), ...proven) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ @@ -274,7 +228,10 @@ export async function settleStaleStructuredAgentSessionState(input: { if ( evidence && (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) && + items.some( + (item) => + isInProgressStructuredAgentSessionItem(item) && verdictFor(item).state === 'interrupted' + )) && !endedByPersonsStop(journal, turnEnds) ) { mutations.unshift({ @@ -308,43 +265,14 @@ export async function settleStaleStructuredAgentSessionState(input: { return mutations.length } -function isUnfinishedItem(item: AgentJournalRenderItem): boolean { - return requiresTerminalSettlement(item.body) -} - -/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider - * waiting on the user, so dying while one sits there interrupted nothing — it still needs - * cancelling, but it must not claim a response was in progress. */ -function isInProgressItem(item: AgentJournalRenderItem): boolean { - return ( - readAgentJournalTurn(item.body)?.state === 'running' || - (item.body.kind === 'tool-call' && item.body.state === 'running') +function withRevisions( + items: readonly AgentJournalRenderItem[], + revisions: readonly JournalLifecycleMutationInput[] +): AgentJournalRenderItem[] { + const bodies = new Map( + revisions.flatMap((revision): [string, AgentJournalItemBody][] => + revision.kind === 'item' ? [[agentJournalItemKey(revision.identity), revision.body]] : [] + ) ) -} - -function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { - const turn = readAgentJournalTurn(item?.body) - if (turn) { - return turn.state === 'completed' - } - if (item?.body.kind === 'tool-call') { - return item.body.state === 'completed' - } - if (item?.body.kind === 'approval' || item?.body.kind === 'question') { - return item.body.resolution.state === 'resolved' - } - return false -} - -function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { - const submissions = journal.submissions?.() - return submissions - ? submissions.some( - (submission) => - // A queued message is not work in progress: nothing has it yet. - (submission.dispatchState === 'pending' && - !(submission.handoverRecorded && submission.handedOverAt === undefined)) || - (submission.dispatchState === 'unknown' && submission.recovered !== true) - ) - : (journal.pendingSubmissions?.().length ?? 0) > 0 + return items.map((item) => ({ ...item, body: bodies.get(item.itemId) ?? item.body })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 8c28932baf85..c12a0b9f3d4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -73,9 +73,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Step = 'continue' | 'stop' +type RefusedStart = Extract + type Prepared = | 'stop' - | Extract + | RefusedStart | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ @@ -116,6 +118,11 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { + if (prepared.aborted) { + // A close, Stop or quit stopped this start on purpose, and settled what it was for; a + // message accepted since gets its own start, and quit's next step stops the loop. + continue + } await this.deps.serialize(sessionId, () => this.fail(sessionId, this.refusedStart(sessionId, prepared)) ) @@ -258,11 +265,7 @@ export class StructuredAgentSessionDeliveryLoop { /** A start the session refused, as the failure every queued message it was for is rejected with. */ private refusedStart( sessionId: string, - { - refusal, - diagnostic, - argumentProblem - }: Extract + { refusal, diagnostic, argumentProblem }: RefusedStart ): StartFailure { // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 93c86fc16df1..6fad454e993f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,6 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -189,7 +190,8 @@ function attachContext( mintEventSink: () => eventSink, adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), - discardEventSink: () => undefined + discardEventSink: () => undefined, + acquireAborts: new StructuredAgentSessionAcquireAborts() }, sessions, subscribers: { snapshot: () => undefined, publish: () => undefined }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 8055af641583..5a0537e5a5ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -34,11 +34,14 @@ import { cancelPlan, promptPlan, sendPlan, - setOptionPlan + setOptionPlan, + type MutationPlan } from './structured-agent-session-mutation-plans' +import { agentSessionMutationAdmitsNow } from './structured-agent-session-mutation-admits-now' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' +import { performSetOption } from './structured-agent-session-turns-options' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -110,6 +113,9 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params + if (!prompt && params.turnId === undefined) { + abortAcquireForStop(context, caller, params.envelope, plan) + } // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). const stopped = prompt ? { envelope: params.envelope } : params @@ -124,6 +130,31 @@ export function cancelStructuredAgentSessionTurn( ) } +/** A Stop must not wait behind a start the provider may never answer: the start the session's + * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. + * Only a Stop admission would run now; one naming a turn is about a child already gone, so it + * leaves a newer start alone. */ +function abortAcquireForStop( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): void { + const { store } = context.deps + if ( + !agentSessionMutationAdmitsNow({ + store, + callerKey: caller.callerKey, + envelope, + plan, + now: context.now + }) + ) { + return + } + context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') +} + export function respondToStructuredAgentSessionPrompt( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, @@ -158,10 +189,18 @@ export async function setStructuredAgentSessionOption( get conversationWrite() { return atRest() ? (true as const) : undefined }, - run: (ctx) => - atRest() - ? recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - : plan.run(ctx) + run: async (ctx) => { + if (atRest()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. + const wait = context.acquireAborts.begin(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } }, openForProviderWrite(context, params.envelope) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 83b53777d4e5..cc5be644cc13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -6,6 +6,7 @@ import { type StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { heldProviderChildReader, @@ -15,6 +16,7 @@ import { resolveStructuredSessionRecovery } from './structured-agent-session-rec export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + readonly acquireAborts = new StructuredAgentSessionAcquireAborts() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index dde5f82dae59..152d84a3bee6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -23,7 +23,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -33,6 +37,7 @@ describe('structured agent-session host teardown', () => { 'begin-resume-markers', 'dispose-idle-sweep', 'stop-lease-renewal', + 'abort-acquires', 'drain-attaches', 'evict-owned-sessions', 'record-resume-markers', @@ -67,7 +72,11 @@ describe('structured agent-session host teardown', () => { const evict = structuredAgentSessionHostTeardownPhases({ logger: createStructuredAgentSessionLogger(), idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + runtimeState: { + stopLeaseRenewal: () => undefined, + flushAllEventSinks: noop, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, beginResumeMarkers: () => {}, @@ -97,7 +106,11 @@ describe('structured agent-session host teardown', () => { const phases = structuredAgentSessionHostTeardownPhases({ logger: log.logger, idleSweep: { dispose: cleaned }, - runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, + runtimeState: { + stopLeaseRenewal: () => {}, + flushAllEventSinks: flush, + acquireAborts: { abortAll: () => {} } + }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, beginResumeMarkers: () => {}, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index fc93e4a43d44..0c9e59fba766 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -60,6 +60,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { runtimeState: { stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise + acquireAborts: { abortAll: (reason: string) => void } } tasks: { drainAttaches: () => Promise } evictOwnedSessions: () => Promise @@ -83,6 +84,11 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { }, { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, + // Before the drain, which would otherwise wait out a start the provider never answers. + { + name: 'abort-acquires', + run: () => collaborators.runtimeState.acquireAborts.abortAll('closed by quit') + }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { name: 'evict-owned-sessions', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c71d182a0869..835f9f9e204f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -14,10 +14,7 @@ import { createRestartReconciler } from './structured-agent-session-restart-reco import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { - createStructuredAgentSessionHostRestore, - revealStructuredAgentSession -} from './structured-agent-session-reveal' +import * as reveal from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' @@ -88,7 +85,7 @@ export class StructuredAgentSessionHost { private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType - private readonly restore: ReturnType + private readonly restore: ReturnType private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery private readonly eventRecovery: StructuredAgentSessionEventRecovery @@ -126,7 +123,7 @@ export class StructuredAgentSessionHost { agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), clientDelivery: this.clientDelivery }) - this.restore = createStructuredAgentSessionHostRestore(deps, { + this.restore = reveal.createStructuredAgentSessionHostRestore(deps, { reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), @@ -245,7 +242,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) + reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) private serialize = this.tasks.serialize.bind(this.tasks) @@ -295,6 +292,7 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), + acquireAborts: this.runtimeState.acquireAborts, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts new file mode 100644 index 000000000000..cc1e2bd5076c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admits-now.ts @@ -0,0 +1,38 @@ +import { + admitAgentSessionMutation, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' + +/** Whether admission would run `plan` for the first time now, placing nothing: for an effect that + * must not wait in the session's queue for its turn. */ +export function agentSessionMutationAdmitsNow( + request: Pick, 'store' | 'callerKey' | 'envelope' | 'now'> & { + plan: Pick, 'method' | 'fields' | 'operationIdScope' | 'conversationWrite'> + } +): boolean { + const { plan, envelope } = request + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const evaluated = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + return ( + evaluated !== null && + admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(plan.conversationWrite ? { conversationWrite: true } : {}) + }).decision === 'admit' + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index fb12399c2370..099b356cb5e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -13,6 +13,7 @@ import { } from './structured-agent-session-mutation-admission' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -44,6 +45,9 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void + /** The provider wait each session's serialize is on (a start, an option write), which a caller + * outside that serialize aborts. */ + acquireAborts: Pick now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 4913ee123caa..1420b1e38533 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -633,7 +633,7 @@ describe('a quit with a message still queued', () => { expect(await keptCards()).toEqual([]) }) - it('waits for the start already in flight and stops the child it produced (R2)', async () => { + it('stops a start already in flight before it launches a child (R2)', async () => { const starting = deferred() const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } @@ -647,13 +647,20 @@ describe('a quit with a message still queued', () => { }) const id = await accept('hello', { person: true }) await eventually(() => expect(recovering).toHaveBeenCalled()) + const acquiresBefore = acquire.mock.calls.length const quit = host.flushAllStreamedEvents() starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. + expect(acquire).toHaveBeenCalledTimes(acquiresBefore) + expect(closeSession).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { detail: 'reservation failed before spawn' } + }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) expect(await keptCards()).toEqual([id]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts index c54a65a5862c..2ea74bd77499 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts @@ -13,10 +13,10 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { - captureUnfinishedStructuredAgentSessionWork, settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { captureUnfinishedStructuredAgentSessionWork } from './structured-agent-session-unfinished-work' const SESSION = 'session-reasoning-sweep' const THREAD = 'thread-1' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index ccd58b4af4ed..2491eb2f2be2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -124,6 +124,32 @@ export function provenUnverifiableTurnRevisions( }) } +/** An exit this host watched, of the child that holds `ownerFence`. */ +export type StructuredAgentSessionWatchedExit = { ownerFence: number; observedAt: number } + +/** What a watched exit revises of what its child left `unverifiable` (its stream closed before the + * exit was proven), calls and turns alike, as the record's death evidence later would. The exit's + * instant is the end, so no Stop mark is weighed. */ +export function watchedExitRevisions( + items: readonly AgentJournalRenderItem[], + exit: StructuredAgentSessionWatchedExit | undefined, + journal: Pick +): JournalLifecycleMutationInput[] { + if (!exit) { + return [] + } + const proof: AgentSessionDeathEvidence = { kind: 'exit-observed', detail: '', ...exit } + return [ + ...provenUnverifiedToolCallRevisions(items, proof, journal), + ...items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === exit.ownerFence + ? turnLifecycleRevision(item, turn, { state: 'interrupted', completedAt: exit.observedAt }) + : [] + }) + ] +} + /** The calls those settles closed with no proof, revised by the same proof: each only when it names * the owner that wrote the call, so a call that failed on its own stays failed. */ export function provenUnverifiedToolCallRevisions( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 07f54bc2ab4a..0f11d0c6949a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -25,6 +25,7 @@ function startedSession(): StructuredAgentSessionChildExitSession & { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts new file mode 100644 index 000000000000..d0eb9f953f02 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts @@ -0,0 +1,44 @@ +// A Stop the host admits aborts the start the queue is waiting on, for any agent: the host checks +// the start's signal before it asks the adapter. A message sent after that Stop is no part of the +// aborted start, so it gets a start of its own rather than that start's refusal. + +import { afterEach, expect, it } from 'vitest' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +it('starts a Codex chat afresh for a message sent right after a Stop reached its reconciling start', async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) + // The chat's child is gone; the next send has to start one. + await rig.host.close(SESSION, 'evict') + const runtimeState = rig.host.collaboratorsForTests().runtimeState + const probe = runtimeState.probeOwner.bind(runtimeState) + let stopping: ReturnType | undefined + let second: ReturnType | undefined + runtimeState.probeOwner = async (sessionId) => { + if (!stopping) { + stopping = rig.stop() + // Accepted while the aborted start is still unwinding. + second = rig.send('second') + } + return probe(sessionId) + } + const first = rig.send('hello') + await first.result + await eventually(() => expect(second).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + await second!.result + await eventually(async () => + expect((await rig.submission(second!.id))?.handedOverAt).toBeDefined() + ) + expect((await rig.submission(first.id))?.rejection).toMatchObject({ kind: 'cancelled' }) + expect((await rig.submission(second!.id))?.rejection).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index 9230e56cbe83..ffee03165f3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -4,14 +4,16 @@ import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-se export async function performSetOption( ctx: AgentSessionTurnContext, - input: { key: string; value: string } + input: { key: string; value: string }, + signal?: AbortSignal ): Promise> { let applied: void | Readonly> try { applied = await ctx.adapter.setOption({ sessionId: ctx.sessionId, ...input, - fence: ctx.fence + fence: ctx.fence, + ...(signal ? { signal } : {}) }) } catch (error) { if (isAgentSessionOptionRejectedError(error)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index e5222eae1b6a..4d099a26394d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -171,6 +171,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -290,6 +291,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -349,6 +351,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -402,6 +405,7 @@ describe('provider-exit settlement', () => { journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), itemBody: () => null, + itemFence: () => undefined, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts new file mode 100644 index 000000000000..26148f1c8d4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -0,0 +1,124 @@ +// What a gone child generation left unfinished in the journal, and whether its exit interrupted it. + +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { requiresTerminalSettlement } from '../agent-session-journal/journal-terminal-settlement' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + watchedExitRevisions, + type StructuredAgentSessionWatchedExit +} from './structured-agent-session-stale-turn-verdict' +import type { UnopenedSendJournal } from './structured-agent-session-unopened-send-withdrawal' + +export type DeadGenerationJournal = UnopenedSendJournal & { + appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] + markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] + rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] + pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] + itemFence: AgentSessionJournal['itemFence'] +} + +export type StructuredAgentSessionUnfinishedWork = { + items: AgentJournalRenderItem[] + hadUnsettledSubmissions: boolean +} + +export function captureUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal +): StructuredAgentSessionUnfinishedWork { + return { + items: journal.snapshot().items.filter(isUnfinishedItem), + hadUnsettledSubmissions: hasUnsettledSubmission(journal) + } +} + +/** `exit`: see `unfinishedStructuredAgentSessionWorkWasInterrupted`. */ +export function hasUnfinishedStructuredAgentSessionWork( + journal: DeadGenerationJournal, + exit?: StructuredAgentSessionWatchedExit +): boolean { + const work = captureUnfinishedStructuredAgentSessionWork(journal) + return ( + work.hadUnsettledSubmissions || + work.items.length > 0 || + watchedExitRevisions(journal.snapshot().items, exit, journal).length > 0 + ) +} + +export function unfinishedStructuredAgentSessionWorkWasInterrupted( + before: StructuredAgentSessionUnfinishedWork, + journal: DeadGenerationJournal, + observedExitAt: number, + exit?: StructuredAgentSessionWatchedExit +): boolean { + const currentSnapshot = journal.snapshot() + if ( + hasUnsettledSubmission(journal) || + currentSnapshot.items.some(isInProgressStructuredAgentSessionItem) + ) { + return true + } + // A turn the exited child left `unverifiable` (its stream closed first) was running when it went. + if (watchedExitRevisions(currentSnapshot.items, exit, journal).length > 0) { + return true + } + if ( + currentSnapshot.items.some((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.state === 'interrupted' && turn.completedAt === observedExitAt + }) + ) { + return true + } + const inProgressBefore = before.items.filter(isInProgressStructuredAgentSessionItem) + if (inProgressBefore.length === 0) { + return false + } + const currentItems = new Map(currentSnapshot.items.map((item) => [item.itemId, item])) + const runningTurns = inProgressBefore.filter( + (item) => readAgentJournalTurn(item.body)?.state === 'running' + ) + const outcomeItems = runningTurns.length > 0 ? runningTurns : inProgressBefore + return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) +} + +function isUnfinishedItem(item: AgentJournalRenderItem): boolean { + return requiresTerminalSettlement(item.body) +} + +/** Work that means the provider was MID-RESPONSE. A pending approval or question is the provider + * waiting on the user, so dying while one sits there interrupted nothing — it still needs + * cancelling, but it must not claim a response was in progress. */ +export function isInProgressStructuredAgentSessionItem(item: AgentJournalRenderItem): boolean { + return ( + readAgentJournalTurn(item.body)?.state === 'running' || + (item.body.kind === 'tool-call' && item.body.state === 'running') + ) +} + +function isCleanlySettled(item: AgentJournalRenderItem | undefined): boolean { + const turn = readAgentJournalTurn(item?.body) + if (turn) { + return turn.state === 'completed' + } + if (item?.body.kind === 'tool-call') { + return item.body.state === 'completed' + } + if (item?.body.kind === 'approval' || item?.body.kind === 'question') { + return item.body.resolution.state === 'resolved' + } + return false +} + +function hasUnsettledSubmission(journal: DeadGenerationJournal): boolean { + const submissions = journal.submissions?.() + return submissions + ? submissions.some( + (submission) => + // A queued message is not work in progress: nothing has it yet. + (submission.dispatchState === 'pending' && + !(submission.handoverRecorded && submission.handedOverAt === undefined)) || + (submission.dispatchState === 'unknown' && submission.recovered !== true) + ) + : (journal.pendingSubmissions?.().length ?? 0) > 0 +} diff --git a/src/main/provider-process/provider-spawned-process-identity.ts b/src/main/provider-process/provider-spawned-process-identity.ts new file mode 100644 index 000000000000..b0f04b29f27c --- /dev/null +++ b/src/main/provider-process/provider-spawned-process-identity.ts @@ -0,0 +1,69 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +// What the lease records about a provider child: the process a later owner probe re-proves. + +/** The child echoes its spawn token here so the owner probe can tell a live child of THIS + * reservation from a same-pid stranger. */ +export const PROVIDER_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +type SpawnedIdentityInput = { + identity: AgentSessionJournalIdentity + spawnToken: string + onSpawned?: (process: AgentSessionProcessIdentity) => Promise +} + +/** + * The child's identity, read once. A connection that reports its spawn before the handshake makes + * it durable there through `onSpawned`, so a crash mid-start leaves an owner recovery can stop; one + * that reports no spawn is identified once it is open. + */ +export function providerSpawnedProcessIdentity( + input: SpawnedIdentityInput, + label: string, + readStartTime?: (pid: number) => Promise +): { + onSpawned: (pid: number) => Promise + read: (pid: number | undefined) => Promise +} { + let spawned: Promise | undefined + return { + onSpawned: async (pid) => { + spawned = providerProcessIdentity({ ...input, pid }, label, readStartTime) + await input.onSpawned?.(await spawned) + }, + read: (pid) => spawned ?? providerProcessIdentity({ ...input, pid }, label, readStartTime) + } +} + +export async function providerProcessIdentity( + input: { identity: AgentSessionJournalIdentity; spawnToken: string; pid: number | undefined }, + label: string, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error(`${label} started without a pid`) + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`${label} start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} diff --git a/src/main/provider-process/supervised-provider-child-location.ts b/src/main/provider-process/supervised-provider-child-location.ts new file mode 100644 index 000000000000..52a5f5b6c6df --- /dev/null +++ b/src/main/provider-process/supervised-provider-child-location.ts @@ -0,0 +1,16 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +/** Where an agent child this runtime spawns and supervises itself can run: on this machine, outside + * WSL, and on Windows only where a process start time proves which process a record names. */ +export function supportsSupervisedProviderChildLocation( + location: AgentSessionExecutionLocation, + hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable +): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) + ) +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index bd812e4324ba..8b7d19af50b0 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -32,7 +32,11 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { structuredWorkerOwesWork } from './structured-worker-custody' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolvedTuiAgentArgsBypassPermissions, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../shared/tui-agent-config' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' @@ -216,6 +220,17 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis claudeStructuredPermissionModeForSettings(this.requireStore().getSettings()), resolveCodexPermissionPolicy: () => codexStructuredPermissionPolicyForSettings(this.requireStore().getSettings()), + resolveAgentFullAccess: (agent) => + isTuiAgent(agent) && + resolvedTuiAgentArgsBypassPermissions( + agent, + this.requireStore().getSettings(), + process.platform + ), + resolveAgentLaunchEnv: (agent) => + isTuiAgent(agent) + ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) + : {}, // 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 99e4670ead35..6e313e111af6 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 @@ -48,12 +48,14 @@ describe('the structured agent registration list', () => { it('refuses an agent it does not hold without installing the host', async () => { const { runtime, installHost } = runtimeAt() - - expect(await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'grok')).toEqual({ + // No structured registration exists for Cursor. + expect( + await runtime.getStructuredAgentSessionCreateSupport('id:workspace-1', 'cursor') + ).toEqual({ supported: false, reason: 'agent' }) - await expect(runtime.resolveStructuredAgentAccountHome('grok')).rejects.toMatchObject({ + await expect(runtime.resolveStructuredAgentAccountHome('cursor')).rejects.toMatchObject({ message: 'structured_agent_session_unsupported' }) expect(installHost).not.toHaveBeenCalled() diff --git a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts index f509f859635a..1c47ab967e89 100644 --- a/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-boundary.test.ts @@ -157,8 +157,9 @@ describe('agent turn send boundary', () => { 'native-chat/agent-session-wire/structured-conversation-command-controller.ts', // The pointer lane's port, whose `send` is sendAgentTurn in structured-mailbox-pointer-host. 'runtime/orchestration/structured-mailbox-pointer-delivery.ts', - // A real-host test rig the shared scan does not count as a test file. + // Real-host test rigs the shared scan does not count as test files. 'native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts', + 'acp/acp-structured-host.test-support.ts', // An Electron WebContents IPC send, not a chat. 'browser/doc-preview-guest-policy.ts' ].sort() diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts new file mode 100644 index 000000000000..9ad8705529b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-caller-renders.test.ts @@ -0,0 +1,103 @@ +/** + * `agent.launch` makes a chat only for a caller that can show it. `agent.launch.v2` vouches for + * Claude and Codex chats; any other agent's chat needs the client to read it, by the rule tabs and + * restart offers use, else it gets a terminal. The host's own callers (CLI, orchestration over the + * runtime socket) carry no capability list and are unaffected. With the structured-chat setting + * off, every agent, Grok included, opens as a terminal. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + STRUCTURED_PREFERENCE +} from './agent-launch.test-fixture' + +const createStructuredSession = vi.fn(async (_args: Record) => ({ + ok: true as const, + value: { sessionId: 'sess-1' } +})) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (args: Record) => + createStructuredSession(args) +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +async function launchInto( + agent: string, + context: Partial, + settings?: Record +) { + const runtime = runtimeStub(settings ? { settings } : {}) + const parsed = AGENT_LAUNCH.params.safeParse({ + agent, + target: { kind: 'existing', worktree: 'id:wt-7' } + }) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } + const result = await AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, context)) + return { result, runtime } +} + +beforeEach(() => { + createStructuredSession.mockClear() +}) + +describe('a launch the caller cannot show as a chat', () => { + it('opens Grok as a terminal for a client that reads only Claude and Codex chats', async () => { + const { result, runtime } = await launchInto('grok', CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', preferred: 'structured' }) + expect(createStructuredSession).not.toHaveBeenCalled() + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + }) + + it('still opens Claude and Codex as chats for that client', async () => { + for (const agent of ['claude', 'codex']) { + const { result } = await launchInto(agent, CAPABLE_CLIENT) + expect(result.outcome).toMatchObject({ kind: 'structured' }) + } + }) + + it('opens Grok as a chat for a client that reads registered agents', async () => { + const { result, runtime } = await launchInto('grok', { + ...CAPABLE_CLIENT, + clientCapabilities: [ + ...(CAPABLE_CLIENT.clientCapabilities ?? []), + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] + }) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it("opens Grok as a chat for the host's own callers, which carry no capability list", async () => { + const { result } = await launchInto('grok', {}) + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: 'sess-1' }) + }) +}) + +describe('Grok with the structured-chat setting off', () => { + it('opens as a terminal for every caller, as Claude and Codex do', async () => { + const off = { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + for (const agent of ['grok', 'claude']) { + const { result, runtime } = await launchInto(agent, {}, off) + expect(result.outcome).toMatchObject({ kind: 'terminal' }) + expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'user_default' }) + expect(runtime.createTerminal).toHaveBeenCalledTimes(1) + } + expect(createStructuredSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 910aeadc1966..4f15465565e6 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -493,7 +493,7 @@ describe('the terminal factory', () => { it('takes an existing workspace without creating one', async () => { const runtime = runtimeStub() const result = await launch( - { agent: 'grok', target: { kind: 'existing', worktree: 'id:wt-7' } }, + { agent: 'gemini', target: { kind: 'existing', worktree: 'id:wt-7' } }, runtime ) @@ -504,8 +504,9 @@ describe('the terminal factory', () => { // Resolved to an id first: everything below re-prefixes it, so a raw selector reaches the // runtime as `id:id:wt-7`. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { - startupAgent: 'grok', - viewMode: 'chat', + startupAgent: 'gemini', + // No native chat renderer for this agent, so its tab opens as the terminal. + viewMode: 'terminal', onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index f65833c42006..15aeb28bd125 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -54,8 +54,13 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { clientRendersStructuredAgent } from './structured-agent-session-policy' import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' -import { publishEarlyTab, withPlacement, type AgentLaunchView } from './agent-launch-tab-publication' +import { + publishEarlyTab, + withPlacement, + type AgentLaunchView +} from './agent-launch-tab-publication' /** * Advertising `agent.launch.v2` is a client's statement that it understands EITHER outcome — a @@ -74,6 +79,23 @@ export function supportsAgentLaunch( ) } +/** + * `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those + * two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart + * offers use; a client that does not gets that agent as a terminal. + */ +function callerRendersLaunchedChat( + context: Pick, + agent: string +): boolean { + return ( + context.clientKind === undefined || + agent === 'claude' || + agent === 'codex' || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} + /** What a launch admitted under an operation id carries into its execution. */ type ReplaySafeLaunch = { attachOperationId: string @@ -104,6 +126,7 @@ async function runAgentLaunch( view.early ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }), // The tab is shown as it is published, not after a prompt that can take a minute to land. onSurfacePublished: (surface) => { view.early?.surfacePublished(surface) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 9cd9f51e0ac6..fcdaade4702b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -55,7 +55,10 @@ export function decideWorkerStartMode(args: { return decideAgentLaunchMode({ placement: args.params, settings: args.settings, - vocabulary: WORKER_START_VOCABULARY + vocabulary: WORKER_START_VOCABULARY, + // The structured worker factory creates Claude and Codex only; any other agent (Grok included) + // starts as a terminal worker until structured workers take registered agents. + registeredStructuredAgents: [] }) } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts index 4a228ebc676c..4648880586b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt-wording.test.ts @@ -74,7 +74,7 @@ describe('worker-start mode receipt wording', () => { ], [ 'agent with no structured session', - { agent: 'grok' }, + { agent: 'gemini' }, 'agent_without_structured_session', 'this agent has no structured session' ] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts new file mode 100644 index 000000000000..175f650c7a1f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-registered-agent.test.ts @@ -0,0 +1,64 @@ +// An orchestration worker for a registered agent (Grok) opens as a terminal worker, as before Grok +// had a structured chat: the structured worker factory creates Claude and Codex only, so a +// structured verdict for Grok would fail the start. Other launches still open Grok as a chat. + +import { describe, expect, it } from 'vitest' +import { decideAgentLaunchMode } from '../../../agent-launch/agent-launch-mode' +import { + decideWorkerStartMode, + resolveWorkerStartModeOnHost +} from './orchestration-worker-start-mode' +import { createStructuredWorkerSessionForWorktree } from './orchestration/worker/worker-topology' + +const STRUCTURED_PREFERENCE = { + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true +} as const + +describe('a Grok worker and the structured-chat setting', () => { + it('starts as a terminal worker while the setting is on; Claude starts structured', async () => { + const grok = decideWorkerStartMode({ + params: { agent: 'grok' }, + settings: STRUCTURED_PREFERENCE + }) + expect(grok).toMatchObject({ mode: 'terminal', reason: 'agent_without_structured_session' }) + const host = { getStructuredAgentSessionCreateSupport: async () => ({ supported: true }) } + expect(await resolveWorkerStartModeOnHost(host, grok, 'wt-1', 'grok')).toMatchObject({ + mode: 'terminal' + }) + expect( + decideWorkerStartMode({ params: { agent: 'claude' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured', reason: 'user_default' }) + }) + + it('still opens Grok as a structured chat for other launches', () => { + expect( + decideAgentLaunchMode({ placement: { agent: 'grok' }, settings: STRUCTURED_PREFERENCE }) + ).toMatchObject({ mode: 'structured' }) + }) + + it('would fail a structured Grok worker: the factory creates Claude and Codex only', async () => { + await expect( + createStructuredWorkerSessionForWorktree({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the factory refuses the agent before it reads the runtime. + runtime: {} as never, + worktreeId: 'wt-1', + agent: 'grok', + dispatchId: 'd-1', + effects: [] + }) + ).rejects.toThrow(/Structured workers support claude and codex/) + }) + + it('starts as a terminal agent while the setting is off, as Claude does', () => { + for (const agent of ['grok', 'claude']) { + expect( + decideWorkerStartMode({ + params: { agent }, + settings: { ...STRUCTURED_PREFERENCE, experimentalStructuredNativeChat: false } + }) + ).toMatchObject({ mode: 'terminal', preferred: 'terminal', reason: 'user_default' }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts index 9937a3eb3d89..2750b8e62267 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-dismiss-fence.test.ts @@ -1,7 +1,6 @@ // "Dismiss all" from the desktop prompt, through the context the desktop renderer really dispatches -// with: a paired runtime client without the registered-agents capability. On a host whose agents it -// all shows, the dismissal is the unscoped, fenced one; one that cannot show some agent leaves that -// agent's offers alone. +// with. On a host whose agents it all shows, the dismissal is the unscoped, fenced one; a desktop +// from before registered agents, which cannot show some agent, leaves that agent's offers alone. import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -11,6 +10,7 @@ import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { CLAUDE_STRUCTURED_AGENT } from '../../../claude/claude-structured-agent-definition' import { CODEX_STRUCTURED_AGENT } from '../../../codex/codex-structured-agent-definition' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -30,6 +30,12 @@ const DESKTOP = { clientKind: 'runtime' as const, clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] } +const OLDER_DESKTOP = { + ...DESKTOP, + clientCapabilities: DESKTOP.clientCapabilities.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) +} // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a registry reads only the methods a declaration needs; these declare none. const NO_METHODS = {} as StructuredAgentSessionAdapter @@ -41,7 +47,7 @@ const declaring = (definition: typeof CLAUDE_STRUCTURED_AGENT) => ({ adapter: NO_METHODS }) -/** This build's two agents plus one the desktop does not render yet. */ +/** Claude and Codex plus one agent an older desktop does not render. */ function withPilot(): StructuredAgentRegistry { return new StructuredAgentRegistry([ declaring(CLAUDE_STRUCTURED_AGENT), @@ -57,8 +63,8 @@ async function storedFence(root: string) { return dismissedAt ? { dismissedAt } : {} } -async function dismissAllFromDesktop() { - return call('agentSession.restartResumableDismiss', {}, DESKTOP) +async function dismissAllFromDesktop(client = DESKTOP) { + return call('agentSession.restartResumableDismiss', {}, client) } it('fences a dismissed offer against a late teardown write when the desktop sees every agent', async () => { @@ -79,7 +85,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees }) }) -it('leaves offers it was not shown when the host runs an agent the desktop cannot show', async () => { +it('leaves offers it was not shown when the host runs an agent an older desktop cannot show', async () => { const { host, root, marker } = await interruptedRestart( undefined, undefined, @@ -92,7 +98,10 @@ it('leaves offers it was not shown when the host runs an agent the desktop canno const unreadable = { ...marker!, sessionId: 'unreadable-session' } await capsule.record([unreadable], NOW) - expect(await dismissAllFromDesktop()).toMatchObject({ ok: true, result: { dismissed: 1 } }) + expect(await dismissAllFromDesktop(OLDER_DESKTOP)).toMatchObject({ + ok: true, + result: { dismissed: 1 } + }) expect(await capsule.list(NOW)).toEqual([unreadable]) // A scoped dismissal writes no fence: nothing it did not clear may be dropped later. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index aacdcdc7f74e..5662731c9a1d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -22,11 +22,13 @@ afterEach(clearStructuredHostStub) const CLAUDE_OFFER = { sessionId: 'claude-session', agent: 'claude' } const GROK_OFFER = { sessionId: 'grok-session', agent: 'grok' } -// What the desktop renderer really dispatches with: every client that calls these methods -// advertises Claude support. +// A desktop from before registered agents: every client that calls these methods advertises Claude +// support, and this one nothing beyond Claude and Codex. const OLD_CLIENT = { ...STRUCTURED_CLIENT, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts index c8154f530bd9..c66a6bcdf380 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts @@ -31,9 +31,12 @@ import { call, clearStructuredHostStub } from './structured-agent-session-rpc.te const NOW = 1_800_000_000_000 const FAILED = 'saved-grok-failure' const PENDING = 'saved-grok-offer' +// A desktop from before registered agents: today's list without that one capability. const OLD_CLIENT = { clientKind: 'runtime' as const, - clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) } const NEW_CLIENT = { ...OLD_CLIENT, diff --git a/src/main/runtime/structured-agent-account-home.ts b/src/main/runtime/structured-agent-account-home.ts index 060fbea5b1fd..cfac35e84de6 100644 --- a/src/main/runtime/structured-agent-account-home.ts +++ b/src/main/runtime/structured-agent-account-home.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override' // The one resolver for "which account home would a structured launch pin right // now". The create path fills `record.accountHome` with it, and the model @@ -54,3 +55,18 @@ export async function resolveStructuredCodexAccountHomePath( getSystemCodexHomePath() ) } + +/** An agent whose config directory is one environment variable with a default under the user's + * home: the launch's own value, then this runtime's, then the default. */ +export function resolveStructuredEnvAccountHomePath(input: { + launchEnv: NodeJS.ProcessEnv + variable: string + defaultPath: (homePath: string) => string + processEnv?: NodeJS.ProcessEnv + homePath?: string +}): string { + return resolveAbsoluteDirOverride( + input.launchEnv[input.variable] ?? (input.processEnv ?? process.env)[input.variable], + input.defaultPath(input.homePath ?? homedir()) + ) +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts new file mode 100644 index 000000000000..5dfc2f7404d2 --- /dev/null +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + structuredAgentRuntimeRegistration, + type StructuredAgentAccountHomeServices +} from './structured-agent-runtime-registrations' + +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']) + expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ + handleTransport: 'acp', + accountHomeVariable: 'GROK_HOME', + capabilities: { + rewind: false, + compact: false, + threadGoal: false, + contextUsage: true, + imagePrompts: false, + steering: 'queue', + approvalEnforcement: 'orca' + } + }) + }) + + 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]) + expect(resting.acceptsKey('fastMode')).toBe(false) + expect(resting.fallbackModels()).toBeNull() + }) + + it('finds the account home on this runtime from the launch env, else the default', async () => { + const { resolveAccountHomePath } = 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') + } + const services: StructuredAgentAccountHomeServices = { + getClaudeConfigDirectory: unused, + prepareCodexLaunchHome: unused, + readCodexLaunchHome: unused, + workspaceTrustSettings: unused + } + const resolve = (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHomePath( + { 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$/) + }) + + it('runs Grok only where this runtime supervises the child itself', () => { + const { supportsLocation } = structuredAgentRuntimeRegistration('grok')! + const local = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } as const + expect(supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false) + expect(supportsLocation({ ...local, executionHostId: 'ssh:box' })).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 5347acb89e13..a0c0089c8d33 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -3,6 +3,7 @@ import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' +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 { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' @@ -16,6 +17,8 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' import type { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' @@ -24,14 +27,21 @@ 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 { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { deps: StructuredAgentSessionRuntimeDeps store: AgentSessionRecordStore + journalDatabase: JournalHostDatabase environment: ReturnType /** Hands the host an exit or other lifecycle event the agent observed. */ deliverLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void @@ -142,6 +152,43 @@ function createClaudeAdapter( }) } +function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistration { + return { + definition: acpStructuredAgentDefinition(spec), + supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), + resolveAccountHomePath: async ({ launchEnv }) => + resolveStructuredEnvAccountHomePath({ + launchEnv, + variable: spec.accountHomeVariable, + defaultPath: spec.defaultAccountHome + }), + createAdapter: (context) => { + const { deps, store, followUps } = context + return new AcpStructuredSessionAdapter({ + spec, + resolveLaunch: createAcpStructuredLaunchResolver(spec, { + store, + readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: context.environment.resolveBaseEnvironment, + ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) + }), + connect: (launch, options) => createAcpAgentConnection(launch, options), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + onDispatchSettledLate: followUps.onDispatchSettledLate, + logger: deps.logger, + // Every exit, expected or not: the host ends that child's record. + onEvent: (event) => { + if (event.type === 'ended') { + context.deliverLifecycle(event) + } + } + }) + } + } +} + async function resolveCodexAccountHomePath( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices @@ -179,7 +226,8 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun wslDistro: location?.wslDistro ?? null, getClaudeConfigDirectory: services.getClaudeConfigDirectory }) - } + }, + ...ACP_LAUNCH_SPECS.map(acpRegistration) ] /** The registration of `agent`; null for an agent this runtime does not drive. */ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index eb12f16935c1..7a833a74d530 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -115,6 +115,10 @@ export type StructuredAgentSessionRuntimeDeps = { resolveClaudePermissionMode?: () => Promise | PermissionMode /** The same setting for Codex, as app-server thread policy. */ resolveCodexPermissionPolicy?: () => CodexStructuredPermissionPolicy + /** The same setting for a protocol-driven (ACP) agent: whether it runs with full access. */ + resolveAgentFullAccess?: (agent: string) => boolean + /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ + resolveAgentLaunchEnv?: (agent: string) => Record /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise @@ -272,6 +276,7 @@ async function installOnJournal( const context: StructuredAgentAdapterContext = { deps, store, + journalDatabase, environment: envResolvers, deliverLifecycle: lifecycle.deliver, followUps: createStructuredAgentSessionDispatchFollowUps({ diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index 7233772445a6..ed0d628d3d11 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -91,6 +91,8 @@ export function createStructuredAgentEnvironmentResolvers( ): { resolveCodexEnvironment: () => Promise resolveClaudeInheritedEnv: () => Promise> + /** The shared base every agent's child env starts from, before its own overlay. */ + resolveBaseEnvironment: () => Promise> } { const shellEnvironment = (sources.resolveEnvironment ?? resolveLoginShellEnvironment)() const resolveBase = async (): Promise> => @@ -105,6 +107,7 @@ export function createStructuredAgentEnvironmentResolvers( ...(await sources.resolveLaunchEnvOverlay?.()), ...sources.resolveCodexOverrides?.() }), - resolveClaudeInheritedEnv: resolveBase + resolveClaudeInheritedEnv: resolveBase, + resolveBaseEnvironment: resolveBase } } diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 3bb29d17afca..d64925a8a3b3 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPol import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' +import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' @@ -41,6 +42,7 @@ export function useAppShellServices(): void { useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() useLocalStructuredSessionTabsSync() + useHostStructuredAgentsSync() // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts index e29ffe9afd7b..9a49b4c389d3 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { projectGroupIdFromRepoId } from '../../../shared/folder-workspace-worktree' import type { RepoIcon } from '../../../shared/repo-icon' import type { AgentSessionRestartActivity } from '../../../shared/agent-session-restart-activity' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' /** * The offered chats, arranged the way the sidebar arranges workspaces: project/repo, then workspace, @@ -16,7 +17,9 @@ import type { AgentSessionRestartActivity } from '../../../shared/agent-session- export type ResumeCandidate = { sessionId: string workspaceId: string - agent: 'claude' | 'codex' + /** Any agent the host registered; a client without the registered-agents capability gets + * only Claude's and Codex's offers. */ + agent: StructuredAgentId trigger: 'quit' | 'update' latestPrompt: string recordedAt: number diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index ead71a2a0ff2..e91318149b05 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -155,6 +155,7 @@ const NativeChatComposerPane = forwardRef { - const threadGoal = controller.threadGoal - const setThreadGoalObjective = threadGoal - ? (objective: string) => threadGoal.change({ kind: 'set', objective }) - : null - return { - send: ( - text: string, - attachments: readonly { id: string; path: string }[] - ): boolean | 'queued' => { - let admission: boolean | 'queued' = false - const accepted = sendThroughRelaunch(() => { - admission = controller.send( - text, - attachments.map((attachment) => ({ - path: attachment.path, - previewUri: attachment.path - })) - ) - return admission !== false - }) - return accepted ? admission : false - }, - dispatchCommand: (text: string) => - dispatchStructuredAgentSessionComposerCommand(text, { - agent: props.agent, - snapshot: controller.optionSnapshot, - invokeAction: async (id) => { - setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) - return true - }, - setOption: controller.setStructuredOption, - conversationCommands: controller.conversationCommands, - runConversationCommand: controller.runConversationCommand, - ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) - }), - ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), - optionsSurface: controller.optionSurface, - conversationCommands: controller.conversationCommands, - optionSnapshot: controller.optionSnapshot, - optionPickerRequest, - sessionCommands: controller.sessionCommands, - contextUsage: controller.contextUsage, - worktreeId: ownerWorktreeId ?? undefined, - onError: setComposerError, - onSubmitted: revealLatest, - runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', - sessionId: props.sessionId, - runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null), - queueHold: submits.queuedMessages.queueHold, - queueResume: submits.queuedMessages.queueResume - } - }, [ + const structuredTransport = useNativeChatStructuredComposerTransport({ + props, controller, - optionPickerRequest, - ownerWorktreeId, - props.agent, - props.sessionId, - props.target, - revealLatest, sendThroughRelaunch, - submits.queuedMessages - ]) + worktreeId: ownerWorktreeId ?? undefined, + optionPickerRequest, + setOptionPickerRequest, + onError: setComposerError, + onSubmitted: revealLatest, + queuedMessages: submits.queuedMessages + }) return (
- unifiedTabs.filter( - (tab): tab is StructuredAgentSessionTab => - tab.contentType === 'agent-session' && - isAgentSessionHandleProvider(tab.agentSessionAgent) - ), + () => unifiedTabs.filter((tab): tab is StructuredAgentSessionTab => isStructuredTab(tab)), [unifiedTabs] ) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 981d287cb688..beeb27600cbb 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -702,10 +702,25 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(feed().target).toEqual({ kind: 'environment', environmentId: 'server-1' }) }) - it('does not project an unknown provider as Codex', async () => { + // Hosts publish chat tabs only of agents they registered; each projects as itself. + it("projects a host-registered agent's status as that agent, never as Codex", async () => { mocks.store?.setState({ unifiedTabsByWorktree: { - 'wt-1': [{ ...structuredTab, agentSessionAgent: 'gemini' }] + 'wt-1': [{ ...structuredTab, agentSessionAgent: 'grok' }] + } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + + expect(statuses()).toEqual([expect.objectContaining({ agentType: 'grok' })]) + }) + + it('does not project a tab naming no agent', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { + 'wt-1': [{ ...structuredTab, agentSessionAgent: undefined }] } }) render() diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 0fa34b19aaab..b71260fa4b24 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -292,6 +292,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.sessionNotRestored', + COPY.sessionNotRestored, + values ) } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 954627c84a4a..923f103bb0d8 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -39,6 +39,8 @@ export type NativeChatStructuredComposerTransport = { /** The `/` surface the running session reports. Absent keeps the curated * per-agent catalog, which is what an older host leaves the client with. */ sessionCommands?: readonly AgentSessionSlashCommand[] + /** False when the agent takes no image input, as its host registered it. */ + acceptsImages?: boolean /** The session's context usage; null until the journal can state it. */ contextUsage?: StructuredAgentContextUsage | null worktreeId?: string diff --git a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts new file mode 100644 index 000000000000..a44c2fa68c1c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { AgentSessionCapabilities } from '../../../../shared/agent-session-capabilities' +import type { Tab } from '../../../../shared/tab-types' +import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}), subscribe: () => () => {} } })) + +import { structuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import { isStructuredTab } from './structured-agent-session-tabs' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' + +const chatTab = (agentSessionAgent: unknown) => ({ + id: 'agent-session:grok_1', + entityId: 'grok_1', + groupId: 'group-1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent, + label: 'Grok Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 +}) + +function reloadedTab(agentSessionAgent: unknown): Tab { + const parsed = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { wt: [chatTab(agentSessionAgent)] } + }) + if (!parsed.ok || !parsed.value.unifiedTabs?.wt[0]) { + throw new Error('session did not parse') + } + return parsed.value.unifiedTabs.wt[0] +} + +describe('a host-registered agent on the structured chat surface', () => { + it("renders a registered agent's chat tab, including after a reload", () => { + expect(isStructuredTab(reloadedTab('grok'))).toBe(true) + expect(isStructuredTab(reloadedTab('claude'))).toBe(true) + // A malformed id degrades to no agent, which mounts no chat pane. + expect(isStructuredTab(reloadedTab('not an agent!'))).toBe(false) + }) + + it("starts an unshipped agent's picker empty and fills it only from the session", () => { + expect(structuredAgentSessionSeedCatalog('grok').models).toEqual([]) + expect(structuredAgentSessionSeedCatalog('claude')).toBe(getAgentSessionOptionCatalog('claude')) + expect(structuredAgentSessionSeedCatalog('codex')).toBe(getAgentSessionOptionCatalog('codex')) + }) + + it("attaches images only when the host's record for the agent takes them", () => { + const capabilities = (imagePrompts: boolean): AgentSessionCapabilities => ({ + rewind: false, + compact: false, + threadGoal: false, + contextUsage: false, + imagePrompts, + steering: 'queue', + approvalEnforcement: 'orca' + }) + const record = (imagePrompts: boolean) => ({ + agent: 'grok', + capabilities: capabilities(imagePrompts) + }) + expect(structuredAgentAcceptsImages(record(false), 'grok')).toBe(false) + expect(structuredAgentAcceptsImages(record(true), 'grok')).toBe(true) + // Unlisted: an older host's built-ins keep images; any other agent claims none. + expect(structuredAgentAcceptsImages(undefined, 'claude')).toBe(true) + expect(structuredAgentAcceptsImages(undefined, 'grok')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts new file mode 100644 index 000000000000..690a70a1388a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts @@ -0,0 +1,21 @@ +import { + getAgentSessionOptionCatalog, + type AgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import type { AgentType } from '../../../../shared/agent-status-types' + +// Over `agentSession.*` only the live read and `setOption` apply a pick, so no launch apply is named. +const LIVE_ONLY_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: {} } + +/** + * What a structured chat's picker shows before the session reports its own options. Only the agents + * every build ships have a seed written against their structured option ids; another agent's + * terminal catalog names CLI flags and models its structured session never offered, so its picker + * starts empty and lists exactly what `agentSession.options` reports. + */ +export function structuredAgentSessionSeedCatalog(agent: AgentType): AgentSessionOptionCatalog { + return isAgentSessionHandleProvider(agent) + ? (getAgentSessionOptionCatalog(agent) ?? LIVE_ONLY_CATALOG) + : LIVE_ONLY_CATALOG +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts new file mode 100644 index 000000000000..63ecacd33bdb --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts @@ -0,0 +1,42 @@ +// Whether a structured chat offers Stop, and what Stop does, from what this view knows of the chat. +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' + +export function structuredAgentSessionStopControl(input: { + /** The host has published this chat to this view. */ + published: boolean + /** The Stop the host is asked for once it has published this chat. */ + host: { + /** The host takes a Stop naming no turn (and this view holds its fence). */ + stopsConversation: boolean + stop: (turnId: string | null, withdrawUnsent: () => void) => Promise + } + transportState: { + turnId: string | null + isWorking: boolean + submissions: readonly AgentJournalSubmission[] + } + /** This client's sends the host may not have taken yet. */ + outbox: { outbox: readonly StructuredAgentSessionOutboxEntry[]; withdrawUnsent: () => void } +}): { canStop: boolean; stop: () => Promise } { + const { published, host } = input + const { turnId, isWorking, submissions } = input.transportState + const { withdrawUnsent } = input.outbox + const holdsUnsent = hasUnsentStructuredAgentSessionOutboxEntry(input.outbox.outbox, submissions) + return { + // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes + // back what this client holds, so a start that never answers cannot hold the message hostage. + canStop: + turnId !== null || + (!published && holdsUnsent) || + (host.stopsConversation && (isWorking || holdsUnsent)), + stop: () => { + if (!published) { + withdrawUnsent() + return Promise.resolve(null) + } + return host.stop(turnId, withdrawUnsent) + } + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts index 3c4819c6dc05..a3bbcd97830e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts @@ -1,10 +1,11 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isStructuredAgentId } from '../../../../shared/agent-session-provider-handle' import type { Tab } from '../../../../shared/tab-types' export type StructuredTab = Tab & { contentType: 'agent-session' } +/** A chat tab of any agent its host registered; the host publishes tabs of no other agent. */ export function isStructuredTab(tab: Tab): tab is StructuredTab { - return tab.contentType === 'agent-session' && isAgentSessionHandleProvider(tab.agentSessionAgent) + return tab.contentType === 'agent-session' && isStructuredAgentId(tab.agentSessionAgent) } const structuredTabsByUnifiedTabsSnapshot = new WeakMap< diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index b8c65b6e17d1..292b17729fde 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -10,7 +10,6 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { isHostModelListingWaitInFlight, joinHostModelListingWait, @@ -62,7 +61,8 @@ export function useHostModelCatalogUpgrade(args: { isHostModelListingWaitInFlight(waitKey) ) useEffect(() => { - if (!enabled || !optionCatalog || !isAgentSessionHandleProvider(agent)) { + // Any agent the host registered: it answers `unknown` for one whose catalog it does not keep. + if (!enabled || !optionCatalog) { return } let stale = false diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 0d49b35d17f2..1ea1d159913e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -28,6 +28,8 @@ import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string + /** False when the agent takes no image input; see `useNativeChatResolvedPathAttachments`. */ + acceptsImages?: boolean allowWithoutTarget?: boolean caret: number disabled: boolean @@ -41,6 +43,7 @@ export type UseNativeChatComposerAttachmentsArgs = { export function useNativeChatComposerAttachments({ attachmentScopeKey, + acceptsImages = true, allowWithoutTarget = false, caret, disabled, @@ -158,6 +161,7 @@ export function useNativeChatComposerAttachments({ const { attachResolvedPaths, disabledRef, flushPendingAttachments } = useNativeChatResolvedPathAttachments({ + acceptsImages, appendImageAttachments, attachmentTargetBlocked, caret, @@ -174,7 +178,8 @@ export function useNativeChatComposerAttachments({ // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( (previewUrl?: string): string | null => { - if (disabledRef.current) { + // Without image input the saved paste is attached by path once it lands, so no image chip. + if (disabledRef.current || !acceptsImages) { return null } if (attachmentTargetBlocked()) { @@ -190,6 +195,7 @@ export function useNativeChatComposerAttachments({ return id }, [ + acceptsImages, attachmentTargetBlocked, disabledRef, nextAttachmentId, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx new file mode 100644 index 000000000000..661669ab6775 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, createElement, useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { + clearNativeChatAttachmentCacheForTests, + useNativeChatComposerAttachments +} from './use-native-chat-composer-attachments' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +type AttachmentApi = ReturnType + +function Probe(props: { + acceptsImages: boolean + onReady: (api: AttachmentApi) => void +}): React.JSX.Element { + const [caret, setCaret] = useState(0) + const [draft, setDraft] = useState('') + const textareaRef = useRef(null) + const api = useNativeChatComposerAttachments({ + attachmentScopeKey: 'structured-session', + allowWithoutTarget: true, + acceptsImages: props.acceptsImages, + caret, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret, + setDraft: (updater) => setDraft((previous) => updater(previous)), + setNotice: () => {} + }) + const { onReady } = props + useEffect(() => { + onReady(api) + }, [api, onReady]) + return createElement('div', null, [ + createElement('textarea', { key: 'input', ref: textareaRef }), + createElement('output', { key: 'draft', 'data-draft': true }, draft) + ]) +} + +async function attach(acceptsImages: boolean, path: string) { + const container = document.createElement('div') + document.body.append(container) + let api: AttachmentApi | null = null + const root = createRoot(container) + await act(async () => { + root.render( + createElement(Probe, { + acceptsImages, + onReady: (next) => { + api = next + } + }) + ) + }) + const latest = (): AttachmentApi => { + if (!api) { + throw new Error('Probe did not render') + } + return api + } + const pendingChip = latest().beginPendingImageAttachment('blob:preview') + await act(async () => latest().attachResolvedPaths([path])) + const result = { + chips: latest().imageAttachments.map((attachment) => attachment.path), + pendingChip, + draft: container.querySelector('[data-draft]')?.textContent ?? '' + } + act(() => root.unmount()) + return result +} + +describe('structured composer image input', () => { + afterEach(() => { + clearNativeChatAttachmentCacheForTests() + document.body.replaceChildren() + }) + + it('holds an image as a chip when the agent takes images', async () => { + const result = await attach(true, '/tmp/shot.png') + expect(result.chips).toContain('/tmp/shot.png') + expect(result.pendingChip).not.toBeNull() + }) + + it('references an image by path, with no chip, when the agent takes none', async () => { + const result = await attach(false, '/tmp/shot.png') + expect(result.chips).toEqual([]) + expect(result.pendingChip).toBeNull() + expect(result.draft).toContain('/tmp/shot.png') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts index 47af01ccb854..4af35932097f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts @@ -16,6 +16,8 @@ type ResolvedAttachmentPath = { } & NativeChatResolvedPathOptions type Args = { + /** False when the agent takes no image input: an image is then referenced by path like any file. */ + acceptsImages?: boolean appendImageAttachments: (paths: { path: string; connectionId?: string | null }[]) => void attachmentTargetBlocked: (targetOwned?: boolean) => boolean caret: number @@ -29,6 +31,7 @@ type Args = { } export function useNativeChatResolvedPathAttachments({ + acceptsImages = true, appendImageAttachments, attachmentTargetBlocked, caret, @@ -116,9 +119,11 @@ export function useNativeChatResolvedPathAttachments({ noteAttachmentTargetBlocked() return } - const imagePaths = attachable.filter(({ path }) => isNativeChatImageAttachmentPath(path)) + const attachesAsImage = (path: string): boolean => + acceptsImages && isNativeChatImageAttachmentPath(path) + const imagePaths = attachable.filter(({ path }) => attachesAsImage(path)) const filePaths = attachable - .filter(({ path }) => !isNativeChatImageAttachmentPath(path)) + .filter(({ path }) => !attachesAsImage(path)) .map(({ path }) => path) // Images ride along on submit so chips and the TUI input cannot diverge. appendImageAttachments(imagePaths.map(({ path, connectionId }) => ({ path, connectionId }))) @@ -133,6 +138,7 @@ export function useNativeChatResolvedPathAttachments({ } }, [ + acceptsImages, appendImageAttachments, attachmentTargetBlocked, insertFileReferences, diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts new file mode 100644 index 000000000000..a56f0c7ea009 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -0,0 +1,92 @@ +import { useMemo, type Dispatch, type SetStateAction } from 'react' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' +import { useStructuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' +import type { + NativeChatOptionPickerRequest, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' +import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' + +/** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ +export function useNativeChatStructuredComposerTransport(args: { + props: Pick + controller: ReturnType + sendThroughRelaunch: (send: () => boolean) => boolean + worktreeId: string | undefined + optionPickerRequest: NativeChatOptionPickerRequest | null + setOptionPickerRequest: Dispatch> + onError: (message: string | null) => void + onSubmitted: () => void + queuedMessages: Pick +}): NativeChatStructuredComposerTransport { + const { props, controller, sendThroughRelaunch, worktreeId, optionPickerRequest } = args + const { setOptionPickerRequest, onError, onSubmitted, queuedMessages } = args + const acceptsImages = useStructuredAgentAcceptsImages(props.target, props.agent) + return useMemo((): NativeChatStructuredComposerTransport => { + const threadGoal = controller.threadGoal + const setThreadGoalObjective = threadGoal + ? (objective: string) => threadGoal.change({ kind: 'set', objective }) + : null + return { + send: (text, attachments) => { + let admission: boolean | 'queued' = false + const accepted = sendThroughRelaunch(() => { + admission = controller.send( + text, + attachments.map((attachment) => ({ + path: attachment.path, + previewUri: attachment.path + })) + ) + return admission !== false + }) + return accepted ? admission : false + }, + dispatchCommand: (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + agent: props.agent, + snapshot: controller.optionSnapshot, + invokeAction: async (id) => { + setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) + return true + }, + setOption: controller.setStructuredOption, + conversationCommands: controller.conversationCommands, + runConversationCommand: controller.runConversationCommand, + ...(setThreadGoalObjective ? { setThreadGoalObjective } : {}) + }), + ...(setThreadGoalObjective ? { threadGoal: { setObjective: setThreadGoalObjective } } : {}), + optionsSurface: controller.optionSurface, + conversationCommands: controller.conversationCommands, + optionSnapshot: controller.optionSnapshot, + optionPickerRequest, + sessionCommands: controller.sessionCommands, + acceptsImages, + contextUsage: controller.contextUsage, + worktreeId, + onError, + onSubmitted, + runtime: props.target.kind === 'local' ? 'local' : 'remote', + sessionId: props.sessionId, + runtimeEnvironmentId: + props.target.kind === 'local' ? null : (props.target.environmentId ?? null), + queueHold: queuedMessages.queueHold, + queueResume: queuedMessages.queueResume + } + }, [ + acceptsImages, + controller, + worktreeId, + onError, + onSubmitted, + optionPickerRequest, + props.agent, + props.sessionId, + props.target, + queuedMessages, + sendThroughRelaunch, + setOptionPickerRequest + ]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index b6d76bd5a498..5734f7e7fafc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -3,10 +3,8 @@ import type { AgentSessionConversationCommand } from '../../../../shared/agent-s import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' import type { AgentType } from '../../../../shared/agent-status-types' -import { - getAgentSessionOptionCatalog, - type AgentSessionOptionCatalog -} from '../../../../shared/agent-session-option-catalog' +import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import { applyStructuredAgentSessionOptions, createStructuredAgentSessionOptionState, @@ -80,7 +78,7 @@ export function useStructuredAgentSessionOptionState(args: { optionIdentityRef.current = identity const seeded = createStructuredAgentSessionOptionState( agent, - getAgentSessionOptionCatalog(agent) + structuredAgentSessionSeedCatalog(agent) ) // A host catalog is the account's, not the fence's: keep it rather than blank the default. const next = diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 91b4a0a8084a..09ae0e5852d7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -5,7 +5,7 @@ import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { applyStructuredAgentSessionOptions, @@ -64,7 +64,7 @@ export function useStructuredAgentSessionOptions(args: { const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. const acceptsPicks = !transportEnabled || fence !== null - const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const identity = `${agent}:${sessionId}` const { optionState, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 257ca019eef8..fda9871c73ec 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -2,16 +2,22 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - hold: vi.fn((_args: { enabled?: boolean }) => ({ error: null })), - read: vi.fn<(args: { isVisible?: boolean }) => void>(), - outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), - send: vi.fn<(text: string) => boolean>(), - retry: vi.fn<(clientMessageId: string) => void>() -})) +const mocks = vi.hoisted(() => { + const outboxEntries: StructuredAgentSessionOutboxEntry[] = [] + return { + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + hold: vi.fn((_args: { enabled?: boolean }) => ({ error: null })), + read: vi.fn<(args: { isVisible?: boolean }) => void>(), + outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), + send: vi.fn<(text: string) => boolean>(), + retry: vi.fn<(clientMessageId: string) => void>(), + withdrawUnsent: vi.fn<() => void>(), + outboxEntries + } +}) let readState: StructuredAgentSessionState @@ -43,10 +49,11 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ }) => { mocks.outbox(args) return { - outbox: [], + outbox: mocks.outboxEntries, error: null, send: mocks.send, - retry: mocks.retry + retry: mocks.retry, + withdrawUnsent: mocks.withdrawUnsent } } })) @@ -99,6 +106,7 @@ function sessionState(): StructuredAgentSessionState { describe('useStructuredAgentSession provisional launch gate', () => { beforeEach(() => { vi.clearAllMocks() + mocks.outboxEntries = [] readState = sessionState() mocks.send.mockReturnValue(true) mocks.call.mockResolvedValue(OPTIONS) @@ -145,6 +153,43 @@ describe('useStructuredAgentSession provisional launch gate', () => { expect(methodsCalled()).toEqual(['agentSession.modelCatalog']) }) + it('offers Stop for a message sent while the launch is unpublished, and takes it back locally', async () => { + const render = () => + renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'grok', + isVisible: true, + transportEnabled: false, + launch: { kind: 'new', heldOptions: {} } + }) + ) + expect(render().result.current.canStop).toBe(false) + mocks.outboxEntries = [ + { + clientMessageId: 'sent-while-starting', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } + ] + const { result } = render() + expect(result.current.canStop).toBe(true) + + await act(async () => { + await result.current.stop() + }) + + expect(mocks.withdrawUnsent).toHaveBeenCalledTimes(1) + // Nothing reached a host, so nothing is asked of one. + expect(methodsCalled().filter((method) => method === 'agentSession.cancel')).toEqual([]) + }) + it('shows no stored selection for a chat this view did not launch', () => { const { result } = renderHook(() => useStructuredAgentSession({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 7531d8dce6e3..2f1d8d0fd8ed 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -14,7 +14,7 @@ import { supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' -import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' +import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' import { legacyAgentSessionSelectedOptionId, type AgentSessionPromptResponse @@ -187,11 +187,6 @@ export function useStructuredAgentSession(args: { blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, write }) - const canStop = - transportState.turnId !== null || - (stopControl.stopsConversation && - (transportState.isWorking || - hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking || transportState.queueSendsNext const transcriptOutbox = useMemo( @@ -272,9 +267,13 @@ export function useStructuredAgentSession(args: { turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, - canStop, + ...structuredAgentSessionStopControl({ + published: transportEnabled, + host: stopControl, + transportState, + outbox: outboxController + }), stopPressed: stopControl.pressed, - stop: () => stopControl.stop(transportState.turnId, outboxController.withdrawUnsent), queuedMessages: queuedController, /** A send made now while the agent works is held as a queued card: the host queues, and this * send asks it to (the setting is on and no pending prompt blocks the queue). */ diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 8325045138da..66b795329f11 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -260,7 +260,7 @@ describe('ExperimentalPane', () => { expect(container.textContent).toContain('Use updated structured native chat') // The one setting governs both providers, so its copy must not name only Codex. - expect(container.textContent).toContain('Open new Codex and Claude agents as structured chats.') + expect(container.textContent).toContain('Open new agents as structured chats where supported.') // The setting picks what new agents open as; existing chats are left alone. expect(container.textContent).toContain('Chats that already exist stay as they are.') // Paired Orca servers run structured chats too; only WSL and SSH stay on terminal chat. diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 2e1fef30a217..fae3362eb747 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -135,7 +135,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' + 'Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.' )}

diff --git a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx index 9d7b7a8cbd26..a112611c3b4d 100644 --- a/src/renderer/src/components/tab-bar/TabBarItemRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarItemRow.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import { shallow } from 'zustand/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { OpenFile } from '../../store/slices/editor' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' @@ -119,8 +118,8 @@ function TabBarItemRow({ color: item.data.color, sortOrder: item.data.sortOrder, createdAt: item.data.createdAt, - ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) - ? { launchAgent: item.data.agentSessionAgent as TuiAgent } + ...(isTuiAgent(item.data.agentSessionAgent) + ? { launchAgent: item.data.agentSessionAgent } : {}) } return ( diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index ef45ae6cb3ec..db2977de3c5e 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -34,7 +34,8 @@ vi.mock('@/lib/worktree-agent-activation-gate', () => ({ gateWorktreeAgentActivation: mocks.gate })) vi.mock('@/lib/structured-agent-session-launch', () => ({ - getStructuredAgentLaunchStatus: mocks.launchStatus + hasStructuredAgentLaunchInWorktree: (worktreeId: string) => + ['claude', 'codex', 'grok'].some((agent) => mocks.launchStatus(worktreeId, agent) !== 'idle') })) vi.mock('@/lib/resume-sleeping-agent-session', () => ({ resumeSleepingAgentSessionsForWorktree: mocks.resume @@ -105,6 +106,7 @@ describe('passive terminal seeding during native chat creation', () => { ['codex', 'pending', 0], ['claude', 'unknown', 0], ['codex', 'unknown', 0], + ['grok', 'pending', 0], ['claude', 'idle', 1] ] as const)('handles %s launch status %s', async (agent, status, expectedTabs) => { let finishGate!: (outcome: 'empty') => void diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 39764c873457..bef809166b2d 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -13,9 +13,8 @@ import { useAppStore } from '@/store' import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority' -import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +import { hasStructuredAgentLaunchInWorktree } from '@/lib/structured-agent-session-launch' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' -import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle' import type { TerminalColdActivationController } from './terminal-cold-activation' import { selectParkedEquivalentMountTabIds } from './terminal/startup-terminal-tab-hold' @@ -236,11 +235,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) return } // A pending or unanswered chat create owns the surface even before its tab is published. - if ( - AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS.some( - (agent) => getStructuredAgentLaunchStatus(activeWorktreeId, agent) !== 'idle' - ) - ) { + if (hasStructuredAgentLaunchInWorktree(activeWorktreeId)) { return } // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2933c7538f5f..04292bd7b800 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2802,6 +2802,7 @@ "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 92322d7db166..728499796b2d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7408,7 +7408,7 @@ "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", - "structuredCopy": "Open new Codex and Claude agents as structured chats. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", + "structuredCopy": "Open new agents as structured chats where supported. Off opens them in the terminal-backed chat. Chats that already exist stay as they are.", "structuredScope": "Runs on this machine and on paired Orca servers running a version that supports it; older servers keep terminal chat. WSL and SSH hosts continue to use terminal chat.", "structuredToggleLabel": "Toggle updated structured native chat", "resumeTitle": "Resume working chats automatically after a restart", @@ -17846,6 +17846,7 @@ "providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.", "providerRetryLastError": "Last error: {{detail}}.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", + "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", "argumentsUnsupportedOption": "Saved Arguments contain an unsupported option ({{option}}).", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5af0be4c76b5..393cefdd281f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7199,7 +7199,7 @@ "shellEnvToggleLabel": "Alternar el uso del entorno de tu shell", "resumeCopy": "Cuando Orca se cierra o instala una actualización, los chats que estaban trabajando se reanudan automáticamente al volver a abrir Orca.", "structuredTitle": "Utilice el chat nativo estructurado actualizado", - "structuredCopy": "Abra nuevos agentes de Codex y Claude como chats estructurados. Si está desactivado, los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", + "structuredCopy": "Abra nuevos agentes como chats estructurados cuando sea posible. Si está desactivado, los abre en el chat respaldado por la terminal. Los chats que ya existen permanecen como están.", "structuredScope": "Se ejecuta en esta máquina y en servidores Orca emparejados que ejecutan una versión que lo admita; los servidores más antiguos mantienen el chat de terminal. Los hosts WSL y SSH continúan usando el chat de terminal.", "structuredToggleLabel": "Alternar chat nativo estructurado actualizado", "resumeTitle": "Reanudar los chats de trabajo automáticamente después de reiniciar", @@ -17624,7 +17624,8 @@ "providerRetryNumber": "Reintento {{attempt}}.", "providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.", "providerRetryLastError": "Último error: {{detail}}.", - "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}." + "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}.", + "sessionNotRestored": "{{agent}} no pudo reabrir su sesión anterior, así que este chat continúa en una nueva. {{agent}} no recuerda los mensajes anteriores." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 93954938d4c3..ed07e0166964 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7303,7 +7303,7 @@ "shellEnvCopy": "Les chats Codex et Claude démarrent avec toutes les variables exportées par votre shell de connexion, comme un terminal. Désactivez pour choisir celles qu'ils reçoivent.", "shellEnvToggleLabel": "Activer l'utilisation de l'environnement de votre shell", "structuredTitle": "Utiliser le chat natif structuré mis à jour", - "structuredCopy": "Ouvre les nouveaux agents Codex et Claude sous forme de discussions structurées. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", + "structuredCopy": "Ouvre les nouveaux agents sous forme de discussions structurées lorsque c'est possible. Désactivé, ils s'ouvrent dans le chat basé sur le terminal. Les discussions existantes restent telles quelles.", "structuredScope": "Fonctionne sur cette machine et sur les serveurs Orca appairés dont la version le prend en charge ; les serveurs plus anciens conservent le chat du terminal. WSL et les hôtes SSH continuent d'utiliser le chat du terminal.", "structuredToggleLabel": "Basculer le chat natif structuré mis à jour", "resumeTitle": "Reprendre automatiquement les discussions de travail après un redémarrage", @@ -17725,7 +17725,8 @@ "providerRetryNumber": "Nouvelle tentative {{attempt}}.", "providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.", "providerRetryLastError": "Dernière erreur : {{detail}}.", - "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}." + "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}.", + "sessionNotRestored": "{{agent}} n'a pas pu rouvrir sa session précédente, cette discussion continue donc dans une nouvelle. {{agent}} ne se souvient pas des messages précédents." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 676b1d728d91..fb941f2e9d55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7183,7 +7183,7 @@ "shellEnvCopy": "Codex と Claude のチャットは、ターミナルと同じく、ログインシェルがエクスポートするすべての変数を引き継いで開始します。オフにすると、渡す変数を選択できます。", "shellEnvToggleLabel": "シェル環境の使用の切り替え", "structuredTitle": "更新された構造化ネイティブチャットを使用する", - "structuredCopy": "新規の Codex および Claude の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", + "structuredCopy": "対応している場合、新規の Agent を構造化チャットとして開きます。オフにすると、ターミナルを使用したチャットで開きます。既存のチャットはそのまま残ります。", "structuredScope": "このマシンと、対応するバージョンを実行しているペアリング済みの Orca サーバーで動作します。古いサーバーはターミナルチャットのままです。WSL および SSH ホストは引き続きターミナルチャットを使用します。", "structuredToggleLabel": "更新された構造化ネイティブチャットの切り替え", "resumeTitle": "再起動後に作業中のチャットを自動的に再開する", @@ -17659,7 +17659,8 @@ "providerRetryNumber": "{{attempt}} 回目の再試行です。", "providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。", "providerRetryLastError": "直近のエラー: {{detail}}。", - "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。" + "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。", + "sessionNotRestored": "{{agent}} は以前のセッションを再開できなかったため、このチャットは新しいセッションで続行します。{{agent}} は以前のメッセージを覚えていません。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 829fa949ea67..9ae84e3f85ec 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7183,7 +7183,7 @@ "shellEnvCopy": "Codex 및 Claude 채팅은 터미널과 마찬가지로 로그인 셸이 내보내는 모든 변수를 가지고 시작합니다. 끄면 전달할 변수를 선택할 수 있습니다.", "shellEnvToggleLabel": "셸 환경 사용 전환", "structuredTitle": "업데이트된 구조화된 기본 채팅 사용", - "structuredCopy": "새 Codex 및 Claude 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", + "structuredCopy": "지원되는 경우 새 에이전트를 구조화된 채팅으로 엽니다. 끄면 터미널 기반 채팅으로 엽니다. 이미 있는 채팅은 그대로 유지됩니다.", "structuredScope": "이 컴퓨터와 이를 지원하는 버전을 실행하는 페어링된 Orca 서버에서 실행됩니다. 이전 서버는 터미널 채팅을 유지합니다. WSL 및 SSH 호스트는 계속해서 터미널 채팅을 사용합니다.", "structuredToggleLabel": "업데이트된 구조화된 기본 채팅 전환", "resumeTitle": "다시 시작한 후 작업 중인 채팅을 자동으로 재개합니다.", @@ -17659,7 +17659,8 @@ "providerRetryNumber": "{{attempt}}번째 재시도입니다.", "providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.", "providerRetryLastError": "마지막 오류: {{detail}}.", - "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다." + "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다.", + "sessionNotRestored": "{{agent}}이(가) 이전 세션을 다시 열 수 없어 이 채팅은 새 세션에서 계속됩니다. {{agent}}은(는) 이전 메시지를 기억하지 않습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 48e88654708f..502212d62fdb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7183,7 +7183,7 @@ "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "将新的 Codex 和 Claude 智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", + "structuredCopy": "在支持的情况下,将新的智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", @@ -17624,7 +17624,8 @@ "providerRetryNumber": "第 {{attempt}} 次重试。", "providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。", "providerRetryLastError": "上次错误:{{detail}}。", - "previousExitUnverifiable": "无法停止之前的 {{agent}}。" + "previousExitUnverifiable": "无法停止之前的 {{agent}}。", + "sessionNotRestored": "{{agent}} 无法重新打开之前的会话,因此此聊天将在新会话中继续。{{agent}} 不记得之前的消息。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index ce4f616e59be..6ac1121ae248 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -25,6 +25,7 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' import { pairedHostClientCapabilities } from '@/runtime/paired-host-client-capabilities' +import { readHostStructuredAgents } from '@/runtime/host-structured-agents' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' export type ProspectiveWorkspaceKind = NonNullable @@ -123,6 +124,14 @@ function resolveTranscriptIsLocalReadable( return host?.kind === 'ssh' ? isNativeChatTranscriptLocalReadable(host.targetId) : true } +function hostStructuredAgentsInput( + store: AgentLaunchRouteStore, + executionHostId: string +): Pick { + const agents = readHostStructuredAgents(executionHostId, store.runtimeStatusByEnvironmentId) + return agents ? { hostStructuredAgents: agents.map((row) => row.agent) } : {} +} + /** The one place that gathers what a launch route decision needs; only the planner resolves on it. */ export function buildAgentLaunchRouteInput( store: AgentLaunchRouteStore, @@ -163,6 +172,7 @@ export function buildAgentLaunchRouteInput( resolveFolderWorkspacePath: (folderWorkspaceId) => store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath }), - initialSessionOptions: args.initialSessionOptions + initialSessionOptions: args.initialSessionOptions, + ...hostStructuredAgentsInput(store, executionHostId) } } 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 new file mode 100644 index 000000000000..db5024bbb897 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -0,0 +1,136 @@ +import { 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' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { TuiAgent } from '../../../shared/tui-agent' + +const mocks = vi.hoisted(() => ({ + readLocalRuntimeCapabilitiesOrUnknown: vi.fn(), + callRuntimeRpc: vi.fn() +})) + +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/local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalRuntimeCapabilitiesOrUnknown +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({})) + +import { + loadHostStructuredAgents, + resetHostStructuredAgentsForTests +} from '@/runtime/host-structured-agents' +import type { AgentLaunchRouteStore, ProspectiveWorkspace } from './agent-launch-route-input' +import { planAgentSessionLaunch } from './agent-session-launch-plan' + +const STRUCTURED: RuntimeCapability[] = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const LISTS_GROK = { + agents: [ + { agent: 'claude', capabilities: {} }, + { agent: 'codex', capabilities: {} }, + { agent: 'grok', capabilities: {} } + ] +} +const SETTINGS = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true +} +const LOCAL: ProspectiveWorkspace = { kind: 'git-worktree', repoId: 'repo-1' } +const PAIRED: ProspectiveWorkspace = { kind: 'git-worktree', runtimeEnvironmentId: 'env-1' } + +function pairedStatus(runtimeId: string): RuntimeEnvironmentStatus { + return { + status: { + runtimeId, + capabilities: REGISTERED, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }, + checkedAt: 1 + } +} + +function store( + overrides: { settings?: Partial; runtimeId?: string } = {} +): AgentLaunchRouteStore { + const state = { + settings: { ...SETTINGS, ...overrides.settings }, + runtimeStatusByEnvironmentId: new Map([['env-1', pairedStatus(overrides.runtimeId ?? 'rt-1')]]), + worktreesByRepo: {}, + folderWorkspaces: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route builder reads only the settings, host statuses and workspace rows given here; a missing member reads as absent, as the sibling route tests rely on. + return state as unknown as AgentLaunchRouteStore +} + +const routeFor = (appStore: AgentLaunchRouteStore, agent: TuiAgent, workspace = LOCAL) => + planAgentSessionLaunch(appStore, { requestId: 'request-1', agent, workspace }).route + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(LISTS_GROK) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReset().mockReturnValue(REGISTERED) +}) + +describe('structured launch of a host-registered agent', () => { + it('opens a chat for an agent the local host listed', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(store(), 'grok')).toBe('structured-native-chat') + }) + + it('never offers it from a host without the registered-agents capability', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue(STRUCTURED) + + expect(routeFor(store(), 'grok')).not.toBe('structured-native-chat') + // The agents every build ships stay offered there. + expect(routeFor(store(), 'claude')).toBe('structured-native-chat') + }) + + it('does not offer it before the host has listed it', () => { + expect(routeFor(store(), 'grok')).not.toBe('structured-native-chat') + expect(routeFor(store(), 'codex')).toBe('structured-native-chat') + }) + + it('does not offer an agent the host did not list', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(store(), 'gemini')).not.toBe('structured-native-chat') + }) + + it('keeps the experimental chat setting authoritative', async () => { + await loadHostStructuredAgents('local', REGISTERED, null) + + expect( + routeFor(store({ settings: { experimentalStructuredNativeChat: false } }), 'grok') + ).not.toBe('structured-native-chat') + }) + + it("reads a paired host's list only from the runtime that gave it", async () => { + await loadHostStructuredAgents('runtime:env-1', REGISTERED, 'rt-1') + + expect(routeFor(store(), 'grok', PAIRED)).toBe('structured-native-chat') + expect(routeFor(store({ runtimeId: 'rt-2' }), 'grok', PAIRED)).not.toBe( + 'structured-native-chat' + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index f2f534f80e55..eaeda98dcbc4 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -36,13 +36,15 @@ export type AgentLaunchRoutingInput = { nativeChatTranscriptIsLocalReadable?: boolean startsOutsideWorkspaceRoot?: boolean initialSessionOptions?: Readonly> + /** The agents the target host listed as structured; absent until it has. */ + hostStructuredAgents?: readonly string[] } export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLaunchRoute { // Why: structured eligibility is decided before the view-mode decider. That decider applies the // terminal mirror gate (a TUI cannot clear more than forty lines of prefilled draft), which has // no meaning for a session that seeds the composer store directly. Its other gates are already - // implied here: the structured resolver admits only claude/codex, both native-chat agents, and + // implied here: the structured resolver admits only agents the host runs as chats, and // only hosts with an Orca runtime, and a structured session reads its journal over RPC rather // than the transcript file, so local transcript readability does not apply either. if ( @@ -75,7 +77,8 @@ export function structuredAgentLaunchSupported( ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, - startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot + startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot, + ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) }).supported ) } 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 9e8ba3902fde..b2088bdbe3b9 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -111,10 +111,28 @@ describe('planAgentSessionLaunch', () => { ) }) - it('returns null for an agent that cannot hold a structured session even on the structured route', async () => { + // The route is the one gate: it admits only an agent the host registered as structured. + it('launches any agent the structured route admitted', async () => { const plan = planAgentSessionLaunch(store, { requestId: 'request-4', - agent: 'gemini', + agent: 'grok', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + await expect(plan.launch(hooks)).resolves.toEqual(STRUCTURED) + expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith( + 'wt-1', + 'grok', + { requestId: 'request-4', executionHostId: 'local' }, + hooks + ) + }) + + it('opens nothing structured on a route that is not', async () => { + mocks.resolveAgentLaunchRoute.mockReturnValue('legacy-native-chat') + const plan = planAgentSessionLaunch(store, { + requestId: 'request-4b', + agent: 'grok', workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } }) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 8fca05729b7f..375198226b7a 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -1,4 +1,3 @@ -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { TuiAgent } from '../../../shared/tui-agent' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' @@ -98,7 +97,8 @@ function beginStructuredPlanLaunch( hooks: StructuredAgentLaunchHooks, target?: AgentSessionLaunchTarget ): StructuredAgentLaunchHandle | null { - if (verdict.route !== 'structured-native-chat' || !isAgentSessionHandleProvider(verdict.agent)) { + // The route already admitted the agent: its host registered it as structured. + if (verdict.route !== 'structured-native-chat') { return null } const worktreeId = target?.worktreeId ?? verdict.worktreeId diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 1762d88d20d8..11625fd8161f 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -52,7 +52,7 @@ export type StructuredAgentSessionLaunchIntent = { executionHostId: ExecutionHostId /** The runtime serving `executionHostId`. */ target: RuntimeClientTarget - agent: AgentSessionHandleProvider + agent: TuiAgent params: StructuredAgentSessionCreateParams /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> @@ -65,7 +65,7 @@ type LaunchSeed = Readonly> | undefined function launchSeedOptions( state: ReturnType, owner: Pick, - agent: AgentSessionHandleProvider, + agent: TuiAgent, hostSeedOptions: LaunchSeed ): { seedOptions?: Readonly> } { const seedOptions = @@ -111,7 +111,7 @@ function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string * one, or the launch is refused rather than sent to whichever host a fallback picks. */ export function createStructuredAgentSessionLaunchIntent( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, hostSeedOptions?: LaunchSeed @@ -134,7 +134,7 @@ export function createStructuredAgentSessionLaunchIntent( function buildStructuredAgentSessionLaunchIntent( worktreeId: string, owner: Pick, - agent: AgentSessionHandleProvider, + agent: TuiAgent, sessionId: string, resumeFrom: StructuredAgentSessionResumeSource | undefined, hostSeedOptions: LaunchSeed @@ -183,7 +183,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { worktreeId: string executionHostId: ExecutionHostId sessionId: string - agent: AgentSessionHandleProvider + agent: TuiAgent clientOperationId: string payloadFingerprint: string expectedRuntimeFence: number | null diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index 69c3a58ddffe..694748ed8db7 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { @@ -98,7 +98,7 @@ async function settleStartedStructuredAgentLaunch( /** Exposes the durable identity before host acquisition so its chat can render immediately. */ export function beginStructuredAgentLaunchSettlement( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): StructuredAgentLaunchHandle { @@ -139,7 +139,7 @@ export function beginStructuredAgentLaunchSettlement( /** Compatibility wrapper for callers that do not need the provisional identity. */ export function settleStructuredAgentLaunch( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): Promise { diff --git a/src/renderer/src/lib/structured-agent-session-host-admission.ts b/src/renderer/src/lib/structured-agent-session-host-admission.ts index 3474ce1c45dd..97b1467888db 100644 --- a/src/renderer/src/lib/structured-agent-session-host-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-host-admission.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' @@ -46,7 +46,7 @@ export type HostCreateSupport = export async function askHostCreateSupport( target: RuntimeClientTarget, worktree: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): Promise { for (let attempt = 0; ; attempt += 1) { try { @@ -84,7 +84,7 @@ export async function askHostCreateSupport( export async function admitStructuredLaunchOnHost( target: RuntimeClientTarget, worktree: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): Promise { const support = await askHostCreateSupport(target, worktree, agent) return support.kind === 'unreachable' ? { kind: 'unreachable' } : support diff --git a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts index 45d7970217ef..f4162aa2957f 100644 --- a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts +++ b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab } from '../../../shared/tab-types' import { useAppStore } from '@/store' @@ -38,7 +38,7 @@ function holdsUnadoptedLaunchDraft(tabId: string): boolean { * sent into and whose composer is untouched. Prefers the group's active tab, else the newest. */ export function findIdleEmptyStructuredChat( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, executionHostId?: ExecutionHostId, groupId?: string ): IdleEmptyStructuredChat | undefined { diff --git a/src/renderer/src/lib/structured-agent-session-launch-draft.ts b/src/renderer/src/lib/structured-agent-session-launch-draft.ts index 2e1447a17787..40fd721440b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-draft.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-draft.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { useAppStore } from '@/store' import type { StructuredAgentLaunchOptions } from './structured-agent-session-launch-callers' @@ -7,7 +7,7 @@ import type { StructuredAgentLaunchOptions } from './structured-agent-session-la * and a structured session has no TUI copy, so a gated seed here would be a lost prompt. */ export function seedStructuredAgentLaunchDraft( sessionId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): void { if (options.promptDelivery !== 'draft' || !options.prompt) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts index bde1adbf8c77..c9c56377fbd9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts @@ -50,6 +50,28 @@ describe('structured agent launch persistence', () => { }) }) + it("keeps a registered agent's launch across a reload and drops a record naming no agent", () => { + localStorage.setItem( + 'orca:structuredAgentLaunches:v1', + JSON.stringify( + [ + ['grok_session', 'grok'], + ['bogus_session', 'not-an-agent'] + ].map(([sessionId, agent]) => ({ + sessionId, + agent, + lifecycle: 'pending', + clientOperationId: `${sessionId}-op`, + payloadFingerprint: 'fingerprint', + expectedRuntimeFence: null + })) + ) + ) + + expect(readStructuredAgentLaunchRecord('grok_session')?.agent).toBe('grok') + expect(readStructuredAgentLaunchRecord('bogus_session')).toBeUndefined() + }) + it('keeps the host a launch was created on across a reload', () => { writeStructuredAgentLaunchRecord({ sessionId: 'claude_remote', diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index a8ee2b30d8be..bd241b890bce 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,7 +1,5 @@ -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../shared/agent-session-provider-handle' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../shared/tui-agent' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' @@ -18,7 +16,7 @@ export type StructuredAgentLaunchPersistedRecord = { /** The host the chat was created on. Records written before paired hosts could hold a chat lack * it and load as local, the only host a chat could then be launched on. */ executionHostId: ExecutionHostId - agent: AgentSessionHandleProvider + agent: TuiAgent lifecycle: StructuredAgentLaunchPersistedLifecycle clientOperationId: string payloadFingerprint: string @@ -96,7 +94,7 @@ function validRecord(value: unknown): value is Omit< (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && typeof sessionId === 'string' && sessionId.length > 0 && - isAgentSessionHandleProvider(agent) && + isTuiAgent(agent) && (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && typeof clientOperationId === 'string' && typeof payloadFingerprint === 'string' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts new file mode 100644 index 000000000000..772a501f64a2 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-registered-agent.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [{ id: 'grok', label: 'Grok' }] })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + LOCAL_STRUCTURED_SESSION_OWNER: 'local', + refreshLocalStructuredSessionTabs: vi.fn(async () => []) +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ unifiedTabsByWorktree: {} }), subscribe: () => () => {} } +})) + +import { + getStructuredAgentLaunchStatus, + hasStructuredAgentLaunchInWorktree, + startStructuredAgentLaunch +} from './structured-agent-session-launch' + +describe('a launch of a host-registered agent', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.createSupport' ? { supported: true } : new Promise(() => {}) + ) + }) + + it('creates the chat for that agent and counts as a chat starting in the worktree', async () => { + const worktreeId = 'wt-grok' + expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(false) + + const launch = startStructuredAgentLaunch(worktreeId, 'grok', { requestId: 'request-1' }) + await vi.waitFor(() => + expect(mocks.call).toHaveBeenCalledWith( + expect.anything(), + 'agentSession.create', + expect.objectContaining({ agent: 'grok', worktree: `id:${worktreeId}` }) + ) + ) + + expect(launch.sessionId.startsWith('grok_')).toBe(true) + expect(hasStructuredAgentLaunchInWorktree(worktreeId)).toBe(true) + expect(hasStructuredAgentLaunchInWorktree('wt-other')).toBe(false) + expect(getStructuredAgentLaunchStatus(worktreeId, 'grok')).toBe('pending') + expect(getStructuredAgentLaunchStatus('wt-other', 'grok')).toBe('idle') + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 07586be1f655..5d4bcfbb196e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -70,7 +70,7 @@ export function subscribeStructuredAgentLaunchStatus(listener: () => void): () = // Why keyed by conversation: a resume must not coalesce onto an unrelated blank launch. export function structuredLaunchIdentity( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, resumeFrom?: StructuredAgentSessionResumeSource ): string { return resumeFrom diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts index 64d42bab136a..6535eb6497d8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-status.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts @@ -1,14 +1,15 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders' import { structuredLaunchIdentity, + structuredLaunchStates, type StructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' /** Whether a chat for this pair is being created here: its own first create, or an adoption. */ export function getStructuredAgentLaunchStatus( worktreeId: string, - agent: AgentSessionHandleProvider + agent: TuiAgent ): StructuredAgentLaunchStatus { // Any launch holding an identity for this pair, adopted conversations included, is starting here. // A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat. @@ -21,3 +22,16 @@ export function getStructuredAgentLaunchStatus( } return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending' } + +/** Whether any agent's chat create in this worktree is still unsettled: pending or unanswered. */ +export function hasStructuredAgentLaunchInWorktree(worktreeId: string): boolean { + for (const state of structuredLaunchStates()) { + if ( + state.intent.worktreeId === worktreeId && + getStructuredAgentLaunchStatus(worktreeId, state.intent.agent) !== 'idle' + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index b80d299e7d45..917a2fba29f9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { @@ -158,7 +158,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { function joinStructuredLaunchState( existing: StructuredLaunchState, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions, request: StructuredLaunchRequest ): StructuredLaunchStateResult | undefined { @@ -207,7 +207,7 @@ function joinStructuredLaunchState( function structuredAgentLaunchState( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): StructuredLaunchStateResult { const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom) @@ -288,7 +288,7 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin export function startStructuredAgentLaunch( worktreeId: string, - agent: AgentSessionHandleProvider, + agent: TuiAgent, options: StructuredAgentLaunchOptions ): StructuredAgentLaunchResult { const { state, caller } = structuredAgentLaunchState(worktreeId, agent, options) diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.ts b/src/renderer/src/lib/structured-agent-session-paired-admission.ts index 4e52f2fe31d0..59ccbc792521 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-paired-admission.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner' -import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import type { ExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' @@ -45,7 +45,7 @@ type AdmittedLaunch = { const DELIVERED: StructuredPromptDeliveryResult = { delivered: true, failureNotified: false } const NOT_DELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: false } -function notifyHostDeclined(agent: AgentSessionHandleProvider): void { +function notifyHostDeclined(agent: TuiAgent): void { const agentLabel = structuredAgentLabel(agent) toast.info( translate( @@ -63,10 +63,7 @@ function notifyHostDeclined(agent: AgentSessionHandleProvider): void { ) } -function notifyHostUnreachable( - agent: AgentSessionHandleProvider, - executionHostId: ExecutionHostId -): void { +function notifyHostUnreachable(agent: TuiAgent, executionHostId: ExecutionHostId): void { const hostLabel = selectExecutionHostDisplayLabel(useAppStore.getState(), executionHostId) toast.error( translate( @@ -129,7 +126,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: { * equivalent, fails); unreachable opens nothing and says so. There is nothing to undo either way. */ export function beginPairedStructuredLaunch(args: { - plan: AgentSessionLaunchPlan & { agent: AgentSessionHandleProvider } + plan: AgentSessionLaunchPlan & { agent: TuiAgent } hooks: StructuredAgentLaunchHooks worktreeId: string executionHostId: ExecutionHostId diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 0a4c1e5aaad9..1992833170d2 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -11,10 +11,7 @@ import type { } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' -import { - isAgentSessionHandleProvider, - type AgentSessionHandleProvider -} from '../../../shared/agent-session-provider-handle' +import type { TuiAgent } from '../../../shared/tui-agent' import { beginPairedStructuredLaunch, openDeclinedStructuredLaunchTerminal, @@ -40,7 +37,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The host the chat is created on; every later operation on the tab reads it. */ executionHostId: ExecutionHostId sessionId: string - agent: AgentSessionHandleProvider + agent: TuiAgent targetGroupId?: string activate?: boolean }): Tab { @@ -114,19 +111,15 @@ export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - const agent = args.plan.agent - const paired = - worktreeId && isAgentSessionHandleProvider(agent) - ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) - : null - if (!paired || !worktreeId || !isAgentSessionHandleProvider(agent)) { + const paired = worktreeId ? structuredLaunchPairedOwner(args.plan, worktreeId, args.target) : null + if (!paired || !worktreeId) { return beginLocalProvisionalLaunch(args) } if (args.beforeOpen?.() === false) { return null } return beginPairedStructuredLaunch({ - plan: { ...args.plan, agent }, + plan: args.plan, hooks: args.hooks, worktreeId, executionHostId: paired.executionHostId, @@ -164,8 +157,8 @@ function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisio if (!handle) { return null } - if (!worktreeId || !isAgentSessionHandleProvider(args.plan.agent)) { - throw new Error('A provisional structured launch needs its workspace and provider.') + if (!worktreeId) { + throw new Error('A provisional structured launch needs its workspace.') } try { if (args.beforeOpen?.(handle.sessionId) === false) { diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 76ed411c67b9..d029cfadead9 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -13,7 +13,6 @@ import { formatWorkspaceCreateError, getWorkspaceCreateErrorToastMessage } from '@/lib/workspace-create-error-format' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -283,11 +282,7 @@ export async function executeWorktreeCreation( let structuredLaunchAccepted = structuredLaunch const { agentLaunchRoute } = preparedRequest - const structuredAgent = preparedRequest.agent - if ( - agentLaunchRoute === 'structured-native-chat' && - isAgentSessionHandleProvider(structuredAgent) - ) { + if (agentLaunchRoute === 'structured-native-chat' && preparedRequest.agent) { let structuredSession: WorktreeCreationStructuredSessionResult | null = null try { structuredSession = await launchStructuredWorktreeSession({ diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 7c3e6d940b6a..9cc64b670176 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -1,6 +1,5 @@ import { useAppStore } from '@/store' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -49,7 +48,8 @@ export async function launchStructuredWorktreeSession( let { activation, primaryTabId } = args const settled = { accepted: true, cancelled: false } const { agent } = args.request - if (!isAgentSessionHandleProvider(agent)) { + // The composer's route already asked the host whether it runs this agent as a chat. + if (!agent || args.agentLaunchRoute !== 'structured-native-chat') { return { ...settled, activation, primaryTabId } } const isCancelled = (): boolean => diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts new file mode 100644 index 000000000000..f755b065db96 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +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 { 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. +function structuredChatSettingOn(state: Pick): boolean { + return state.settings?.experimentalStructuredNativeChat === true +} + +async function syncLocalHost(settings: AppState['settings']): Promise { + if (!(await localStructuredChatsInUse(settings))) { + return + } + await loadHostStructuredAgents( + LOCAL_EXECUTION_HOST_ID, + await ensureLocalRuntimeCapabilities(), + null + ) +} + +function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): void { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const [environmentId, entry] of statuses) { + const executionHostId = toRuntimeExecutionHostId(environmentId) + hostIds.add(executionHostId) + const status = lastVerifiedRuntimeStatus(entry) + if (status) { + void loadHostStructuredAgents(executionHostId, status.capabilities, status.runtimeId) + } + } + retainHostStructuredAgents(hostIds) +} + +/** 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. */ +export function installHostStructuredAgentsSync(): () => void { + const initial = useAppStore.getState() + void syncLocalHost(initial.settings) + if (structuredChatSettingOn(initial)) { + syncPairedHosts(initial.runtimeStatusByEnvironmentId) + } + return useAppStore.subscribe((state, previousState) => { + const on = structuredChatSettingOn(state) + const turnedOn = on && !structuredChatSettingOn(previousState) + if (turnedOn) { + void syncLocalHost(state.settings) + } + if ( + on && + (turnedOn || + state.runtimeStatusByEnvironmentId !== previousState.runtimeStatusByEnvironmentId) + ) { + syncPairedHosts(state.runtimeStatusByEnvironmentId) + } + }) +} + +export function useHostStructuredAgentsSync(): void { + useEffect(() => installHostStructuredAgentsSync(), []) +} diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts new file mode 100644 index 000000000000..41166e2c0e98 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { RuntimeCapability } from '../../../shared/protocol-version' + +type Statuses = Map +type StoreState = { + runtimeStatusByEnvironmentId: Statuses + settings: { experimentalStructuredNativeChat: boolean } | null +} + +const ON = { experimentalStructuredNativeChat: true } + +const mocks = vi.hoisted(() => { + const state: StoreState = { runtimeStatusByEnvironmentId: new Map(), settings: null } + return { + callRuntimeRpc: vi.fn(), + ensureLocalRuntimeCapabilities: vi.fn(), + state, + listeners: new Set<(state: StoreState, previous: StoreState) => void>() + } +}) + +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('./local-runtime-capabilities', () => ({ + ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities +})) +vi.mock('./local-structured-chats', () => ({ + localStructuredChatsInUse: async (settings: StoreState['settings']) => + settings?.experimentalStructuredNativeChat === true +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.state, + subscribe: (listener: (state: StoreState, previous: StoreState) => void) => { + mocks.listeners.add(listener) + return () => mocks.listeners.delete(listener) + } + } +})) + +import { + readHostStructuredAgents, + resetHostStructuredAgentsForTests +} from './host-structured-agents' +import { installHostStructuredAgentsSync } from './host-structured-agents-sync' + +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const GROK_LIST = { + agents: [ + { agent: 'claude', capabilities: { imagePrompts: true } }, + { agent: 'grok', capabilities: { contextUsage: true, steering: 'queue' } } + ] +} + +function pairedStatus( + runtimeId: string, + capabilities: RuntimeCapability[] +): RuntimeEnvironmentStatus { + return { + status: { + runtimeId, + capabilities, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + }, + checkedAt: 1 + } +} + +function setStatuses(statuses: Statuses): void { + const previous = mocks.state + mocks.state = { ...previous, runtimeStatusByEnvironmentId: statuses } + mocks.listeners.forEach((listener) => listener(mocks.state, previous)) +} + +const agentIds = (hostId: string): string[] | undefined => + readHostStructuredAgents(hostId, mocks.state.runtimeStatusByEnvironmentId)?.map( + (row) => row.agent + ) + +let uninstall: (() => void) | undefined + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.state = { runtimeStatusByEnvironmentId: new Map(), settings: ON } + mocks.listeners.clear() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(GROK_LIST) + mocks.ensureLocalRuntimeCapabilities.mockReset().mockResolvedValue(REGISTERED) +}) + +afterEach(() => { + uninstall?.() + uninstall = undefined +}) + +describe('host structured agents', () => { + // The capability promises a renderer that reads the list; it ships only with that reader. + it('is advertised to paired hosts, whose lists this reader serves', () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) + }) + + it("reads the local host's agents once it advertises them", async () => { + uninstall = installHostStructuredAgentsSync() + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.agents', + {}, + {} + ) + }) + + it('never asks a host that does not advertise registered agents', async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + uninstall = installHostStructuredAgentsSync() + setStatuses( + new Map([['env-1', pairedStatus('rt-1', [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY])]]) + ) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(agentIds('local')).toBeUndefined() + expect(agentIds('runtime:env-1')).toBeUndefined() + }) + + it("reads a paired host's agents per runtime, and a restarted host is unlearned until it answers", async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(null) + uninstall = installHostStructuredAgentsSync() + setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'agentSession.agents', + {}, + { expectedEnvironmentRuntimeId: 'rt-1' } + ) + + let answer!: (value: unknown) => void + mocks.callRuntimeRpc.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + setStatuses(new Map([['env-1', pairedStatus('rt-2', REGISTERED)]])) + expect(agentIds('runtime:env-1')).toBeUndefined() + + answer({ agents: [{ agent: 'claude', capabilities: {} }] }) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude'])) + }) + + // Asking installs a host's structured store; a profile with structured chat off never pays it. + it('asks no host until structured chat is turned on', async () => { + mocks.state = { ...mocks.state, settings: { experimentalStructuredNativeChat: false } } + uninstall = installHostStructuredAgentsSync() + setStatuses(new Map([['env-1', pairedStatus('rt-1', REGISTERED)]])) + await Promise.resolve() + await Promise.resolve() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + const previous = mocks.state + mocks.state = { ...previous, settings: ON } + mocks.listeners.forEach((listener) => listener(mocks.state, previous)) + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) + }) + + it('leaves a host unlearned when its reply is not an agent list', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ unexpected: true }) + uninstall = installHostStructuredAgentsSync() + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await Promise.resolve() + + expect(agentIds('local')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts new file mode 100644 index 000000000000..4196adff0336 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -0,0 +1,145 @@ +import { + AGENT_SESSION_AGENTS_METHOD, + decodeAgentSessionAgentsResult, + type AgentSessionRegisteredAgent +} from '../../../shared/agent-session-registered-agents' +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 { callRuntimeRpc } from './runtime-rpc-client' + +/** + * The structured agents each host registered, as `agentSession.agents` listed them: the one cache + * every renderer reader asks. Only a host advertising the registered-agents capability is asked; + * a host that was not, or has not answered, offers only the agents every build ships. + * + * Keyed by execution host id and stamped with the runtime instance that answered, so a host that + * restarted (and may register different agents) reads as unlearned until it answers again. + */ + +type HostAgentsEntry = { + /** The paired host's runtime id when it answered; null for the local runtime, which lives as + * long as this renderer's main process. */ + runtimeId: string | null + agents: readonly AgentSessionRegisteredAgent[] +} + +export type HostStructuredAgentsStatuses = ReadonlyMap | undefined + +const entries = new Map() +const inflight = new Map>() +const listeners = new Set<() => void>() + +function publish(): void { + listeners.forEach((listener) => listener()) +} + +/** The runtime instance a host's list must come from: null locally, undefined when unknown. */ +function currentRuntimeId( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): string | null | undefined { + const host = parseExecutionHostId(executionHostId) + if (host?.kind === 'local') { + return null + } + if (host?.kind !== 'runtime') { + return undefined + } + return lastVerifiedRuntimeStatus(statuses?.get(host.environmentId))?.runtimeId +} + +/** The agents a host's given runtime listed; undefined when that runtime has not answered. */ +export function readHostStructuredAgentsForRuntime( + executionHostId: string, + runtimeId: string | null | undefined +): readonly AgentSessionRegisteredAgent[] | undefined { + const entry = entries.get(executionHostId) + return entry && runtimeId !== undefined && entry.runtimeId === runtimeId + ? entry.agents + : undefined +} + +/** The agents this host listed, or undefined when none were learned from its current runtime. */ +export function readHostStructuredAgents( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): readonly AgentSessionRegisteredAgent[] | undefined { + return readHostStructuredAgentsForRuntime( + executionHostId, + currentRuntimeId(executionHostId, statuses) + ) +} + +/** Asks a host for its agents unless its current runtime already answered. Never rejects: a + * failed read leaves the host unlearned, and the next status change asks again. */ +export function loadHostStructuredAgents( + executionHostId: string, + hostCapabilities: readonly string[] | null | undefined, + runtimeId: string | null +): Promise { + const host = parseExecutionHostId(executionHostId) + if ( + (host?.kind !== 'local' && host?.kind !== 'runtime') || + !hostCapabilities?.includes(STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY) || + entries.get(executionHostId)?.runtimeId === runtimeId + ) { + return Promise.resolve() + } + const key = `${executionHostId}\u0000${runtimeId ?? ''}` + const pending = inflight.get(key) + if (pending) { + return pending + } + const read = callRuntimeRpc( + host.kind === 'local' + ? { kind: 'local' } + : { kind: 'environment', environmentId: host.environmentId }, + AGENT_SESSION_AGENTS_METHOD, + {}, + runtimeId === null ? {} : { expectedEnvironmentRuntimeId: runtimeId } + ) + .then((result) => { + const agents = decodeAgentSessionAgentsResult(result) + if (agents) { + entries.set(executionHostId, { runtimeId, agents }) + publish() + } + }) + .catch((error: unknown) => { + console.warn('[native-chat] could not read the host structured agents', error) + }) + .finally(() => { + inflight.delete(key) + }) + inflight.set(key, read) + return read +} + +/** Drops hosts no longer paired; a stale runtime is already ignored by the reader. */ +export function retainHostStructuredAgents(executionHostIds: ReadonlySet): void { + let removed = false + for (const id of entries.keys()) { + if (!executionHostIds.has(id)) { + entries.delete(id) + removed = true + } + } + if (removed) { + publish() + } +} + +export function subscribeHostStructuredAgents(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function resetHostStructuredAgentsForTests(): void { + entries.clear() + inflight.clear() + publish() +} diff --git a/src/renderer/src/runtime/use-host-structured-agent.ts b/src/renderer/src/runtime/use-host-structured-agent.ts new file mode 100644 index 000000000000..adb6a983f618 --- /dev/null +++ b/src/renderer/src/runtime/use-host-structured-agent.ts @@ -0,0 +1,50 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { AgentSessionRegisteredAgent } from '../../../shared/agent-session-registered-agents' +import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' +import { useAppStore } from '@/store' +import { + readHostStructuredAgentsForRuntime, + subscribeHostStructuredAgents +} from './host-structured-agents' +import type { RuntimeClientTarget } from './runtime-client-target' + +/** Whether a chat may carry images: what its host registered for the agent, else what every build + * has always sent the agents it ships. An unlisted agent claims nothing. */ +export function structuredAgentAcceptsImages( + record: AgentSessionRegisteredAgent | undefined, + agent: string +): boolean { + return record ? record.capabilities.imagePrompts : isAgentSessionHandleProvider(agent) +} + +/** The agent's record as the chat's host listed it; undefined until (or unless) it has. */ +export function useHostStructuredAgent( + target: RuntimeClientTarget, + agent: string +): AgentSessionRegisteredAgent | undefined { + const environmentId = target.kind === 'environment' ? target.environmentId : null + const runtimeId = useAppStore((state) => + environmentId === null + ? null + : lastVerifiedRuntimeStatus(state.runtimeStatusByEnvironmentId.get(environmentId))?.runtimeId + ) + const executionHostId = + environmentId === null ? LOCAL_EXECUTION_HOST_ID : toRuntimeExecutionHostId(environmentId) + const read = useCallback( + () => + readHostStructuredAgentsForRuntime(executionHostId, runtimeId)?.find( + (row) => row.agent === agent + ), + [agent, executionHostId, runtimeId] + ) + return useSyncExternalStore(subscribeHostStructuredAgents, read, read) +} + +export function useStructuredAgentAcceptsImages( + target: RuntimeClientTarget, + agent: string +): boolean { + return structuredAgentAcceptsImages(useHostStructuredAgent(target, agent), agent) +} diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 4575485b456b..b55e2609ec16 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -94,7 +94,9 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRetryNumber: 'Retry {{attempt}}.', providerRetryNumberOf: 'Retry {{attempt}} of {{maxRetries}}.', providerRetryLastError: 'Last error: {{detail}}.', - previousExitUnverifiable: "Couldn't stop {{agent}} from before." + previousExitUnverifiable: "Couldn't stop {{agent}} from before.", + sessionNotRestored: + "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index b8dacdfd8136..9640951e79b4 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -285,7 +285,9 @@ const FAILURE_SENTENCES = { ) : providerRetryWords(say, agent(say, context), retry), previousExitUnverifiable: (context, _fact, _surface, say) => - say('previousExitUnverifiable', agent(say, context)) + say('previousExitUnverifiable', agent(say, context)), + sessionNotRestored: (context, _fact, _surface, say) => + say('sessionNotRestored', agent(say, context)) } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index fe69dd2243e5..341c782dbae6 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -57,7 +57,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', /** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */ - 'previousExitUnverifiable' + 'previousExitUnverifiable', + /** The agent could not reopen the chat's saved session, so a fresh one without its memory continues it. */ + 'sessionNotRestored' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -73,7 +75,8 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', - 'previousExitUnverifiable' + 'previousExitUnverifiable', + 'sessionNotRestored' ] as const satisfies readonly AgentSessionFailureKind[] /** Why a message was not sent. A new failure kind is one of these until listed above. */ diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 52aa497f83c2..1501453361fe 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -7,11 +7,11 @@ import { agentSessionReadHistoryRefusalParts, agentSessionRefusalCauseParts, agentSessionRefusalNotice, - agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts } from './agent-session-refusal-notice' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticeSentence diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 47ab6f75bc43..17bdebceb79c 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -7,6 +7,7 @@ import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' @@ -20,6 +21,8 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // The renderer that reads a paired host's structured chats is the one that reads its own. STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + // Same renderer: it reads each host's `agentSession.agents` and renders any agent listed there. + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, // The desktop picks each launch's mode from its own settings before asking the host. STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 28d5fe8d7663..1ca1dd7f6274 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -1,4 +1,4 @@ -import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import type { StructuredAgentId } from './agent-session-provider-handle' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { createStructuredAgentSessionOperationId, @@ -21,7 +21,7 @@ export type StructuredAgentSessionResumeSource = { export type StructuredAgentSessionCreateParams = { envelope: AgentSessionMutationEnvelope worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY`. */ tabId?: string @@ -30,7 +30,7 @@ export type StructuredAgentSessionCreateParams = { /** Provider-prefixed so a session id names its lane on sight, and underscore-only * so the id stays a single token everywhere it is embedded (tab ids, log keys). */ export function createStructuredAgentSessionId( - agent: AgentSessionHandleProvider, + agent: StructuredAgentId, randomUuid: () => string ): string { return `${agent}_${randomUuid().replaceAll('-', '_')}` @@ -52,7 +52,7 @@ export function isStructuredAgentSessionIdFor(agent: string, sessionId: string): export function structuredAgentSessionCreateParams(args: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource tabId?: string randomUuid: () => string diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 16f6f4f45831..d075af5d28bc 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,7 +1,7 @@ import { sha256 } from './sha256' import { isAgentSessionHandleProvider, - type AgentSessionHandleProvider + type StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { @@ -45,7 +45,7 @@ export function structuredAgentSessionDomainFingerprint(input: { export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string - agent: AgentSessionHandleProvider + agent: StructuredAgentId resumeFrom?: { providerSessionId: string } tabId?: string }): string { diff --git a/src/shared/tui-agent-launch-defaults.test.ts b/src/shared/tui-agent-launch-defaults.test.ts index a99e4451a159..78d8e8f2c1c1 100644 --- a/src/shared/tui-agent-launch-defaults.test.ts +++ b/src/shared/tui-agent-launch-defaults.test.ts @@ -4,6 +4,8 @@ import { resolveTuiAgentLaunchArgs, tuiAgentArgsBypassPermissions } from './tui-agent-launch-defaults' +import { applyAgentPermissionMode, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { isTuiAgent } from './tui-agent-config' describe('tuiAgentArgsBypassPermissions', () => { // The Agent Permissions toggle has no storage of its own: Yolo is the presence of the agent's @@ -25,7 +27,16 @@ describe('tuiAgentArgsBypassPermissions', () => { ['codex', '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol', true], ['codex', '--model gpt-5.6-sol', false], ['codex', '--config "note=--dangerously-bypass-approvals-and-sandbox only as text"', false], - ['codex', '-- --dangerously-bypass-approvals-and-sandbox', false] + ['codex', '-- --dangerously-bypass-approvals-and-sandbox', false], + // Two-token bypass flags match as a token sequence, not one token. + ['grok', '--permission-mode bypassPermissions', true], + ['grok', '--model grok-4.7 --permission-mode bypassPermissions', true], + ['grok', '--permission-mode default', false], + ['grok', '--permission-mode', false], + ['grok', '--permission-mode "bypassPermissions now"', false], + ['grok', '-- --permission-mode bypassPermissions', false], + ['devin', '--permission-mode bypass --respect-workspace-trust false', true], + ['devin', '--permission-mode bypass', false] ] as const)('reads %s args %s as %s', (agent, args, expected) => { expect(tuiAgentArgsBypassPermissions(agent, args, 'posix')).toBe(expected) }) @@ -80,6 +91,19 @@ describe('tuiAgentArgsBypassPermissions', () => { }) describe('resolvedTuiAgentArgsBypassPermissions', () => { + // The settings shape the Agent Permissions toggle writes, for every agent it covers. + it.each(Object.keys(YOLO_TUI_AGENT_ARGS).filter(isTuiAgent))( + "reads the toggle's own Yolo and Manual writes for %s", + (agent) => { + for (const platform of ['darwin', 'win32'] as const) { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + expect(resolvedTuiAgentArgsBypassPermissions(agent, yolo, platform)).toBe(true) + const manual = applyAgentPermissionMode({ mode: 'manual' }) + expect(resolvedTuiAgentArgsBypassPermissions(agent, manual, platform)).toBe(false) + } + } + ) + it('fails closed when the configured arguments cannot be tokenized', () => { expect( resolvedTuiAgentArgsBypassPermissions( diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index 0cd9c0475c02..5574f31bfc9d 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -47,12 +47,16 @@ export function tuiAgentArgsBypassPermissions( return false } const tokenized = tokenizeStartupCommand(value, shell) - if (!tokenized.ok) { + const bypass = tokenizeStartupCommand(bypassArg, shell) + if (!tokenized.ok || !bypass.ok || bypass.tokens.length === 0) { return false } const terminator = tokenized.tokens.indexOf('--') const options = terminator === -1 ? tokenized.tokens : tokenized.tokens.slice(0, terminator) - return options.includes(bypassArg) + // A token sequence: several agents' bypass is a flag and its value (`--permission-mode X`). + return options.some((_, start) => + bypass.tokens.every((token, offset) => options[start + offset] === token) + ) } function sanitizeTuiAgentLaunchArgs(agent: TuiAgent, args: string): string {