From 670c59d23a1d87f9ef3bd63b65f44673f16d28e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:07:22 -0700 Subject: [PATCH] Translate ACP traffic into shared timeline events (#25090) * Add standalone ACP protocol client and session runtime * Protect ACP transport teardown from late stream errors * Retire incoming ACP request ids before publishing responses * Narrow ACP configuration requests and transport message types * Remove redundant ACP request handler return unions * Keep ACP waits caller-owned and preserve protocol extensions * Preserve open ACP decisions through prompt completion * Move the turn message ordinals and the turn-row revision to the neutral timeline folder Pure moves so a shared timeline assembler can use them: Codex's message ordinal counter becomes ProviderTurnMessageOrdinals and Claude's turn-row revision becomes the provider-neutral agent-journal turn-row revision. Only names and import paths change. * Admit one provider event's writes as one transition, and let rows be found again after a restart - A sink transition is admitted whole or not at all; its steps run back to back at their turn in the journal's write queue, and each resolver reads the fold with every earlier write landed. A resolver may also say where the row belongs (turn scope, provider reference), and the writer always hears how the transition landed. A resolved lifecycle batch chooses its settlement mutations from the fold at execution. - New optional row field providerItemRef: the provider's own reference for the item a row is, written only where the row's identity cannot spell it (Codex keys messages by their place in the turn and renumbers its item ids on resume). Set by the creating write, kept by revisions, indexed by the journal fold, never read by clients. A downgrade test shows an older host and client render such rows unchanged. - Provider timeline identity schemes (shared legacy arm, Codex) and the join index that resolves a provider item to its row from memory or the fold: ordinals and request incarnations are read back from the rows, so a restart or an evicted entry finds the original row instead of placing a new one. * Recover message ordinals from the journal's highest place, and forget joins read from a replaced epoch A fresh join index continued a turn's messages at the first free place, so a journal holding only a later ordinal (an imported or removed earlier row) had its sequence back-filled. The place is now one past the highest ordinal any row or echoed send holds there, read through a pure scheme reader. The join caches also drop what they read when the journal's epoch is replaced. * Spell the subagent thread's message slot without spreading an identity union * Add a provider timeline grammar and a shared assembler that decides at its turn in the journal Adapters translate their provider's dialect into a small grammar (turns, items, streamed text, requests, context facts, session end/reset); one shared assembler turns it into the journal rows every structured lane writes. Each event is planned as one sink transition. Which row a write lands on, whether a replay writes anything, and every change to what the assembler knows (its ledger) are decided by the transition's resolvers at the event's turn in the journal's write queue, against the fold as it stands then. A forecast (the ledger plus admitted events still queued) only answers apply() at once. So a refused event allocates nothing, a write the journal rejects leaves no trace in memory, and a restart or evicted cache finds the same rows again. Text and full snapshots of one provider item share one row and one lifecycle; reset always flushes text and settles the old session from the journal; the open-work budget is derived from what is actually open. Codex migration contracts compare against the existing Codex translator, including a restart mid-stream and a repeat that outlives the join cache. * Fix the types and the exhaustive event switch CI reported for the assembler * Let the journal decide stream lifetimes, request reuse, named sends and background work A third review found two blockers with the earlier rounds' cause, a remembered interpretation trusted after the journal moved on: - A reused request id was judged by its earlier prompt's settled turn before asking which turn the new one lands in, so a real approval in a later turn was dropped. The target turn now decides: the old turn again is a replay; a different live turn opens the next prompt beside it. - A text stream checked its row's turn only on its first write, and a turn's end released streams by the turn planning expected. Every write now checks the row, a turn's end stops the streams whose rows are in it, and turn status reads the journal first, so another writer's Stop wins. Also: a message boundary drawn by an event the journal held as a replay no longer splits an anonymous message; a send naming a turn not yet open waits for that turn; the budget charges a stream's thread and turn strings and the turn caches are byte-bounded; the open turn ends when the journal shows it settled; a turn's opener is read from the journal's row. Background work is now Orca's existing background-task row instead of a tool call flagged `outlivesTurn` (a flag remembered only in memory, so a restart failed the task). A turn's end never settles that row, so it survives restarts; session end leaves one in flight unverifiable. Three tests that opened a background tool call with `outlivesTurn` now open a background-task row and keep their original expectations about which turn the row stays in. * Type the unbound assembler helper's drain as the void it reports * Bound the rows kept for a continued anonymous message and the stopped streams A row kept for the anonymous stream that may continue it, and the marker that a stopped stream's queued writes write nothing, lived in the live-stream map and were never removed when no stream followed. They now live in their own bounded maps, so the live map holds open streams only. * Translate ACP session traffic into shared timeline events * Preserve fixture answer linkage and handle all typed ACP updates * Keep replay chunks and long-turn tool snapshots bounded * Forward ACP joins to the journal-derived timeline assembler * Use exact ACP prompt identity and recover incomplete timeline replay * Detect short and Unicode home paths in encoded ACP fixtures * Use generic privacy patterns for ACP fixtures * Translate Grok background work into durable task rows * Recover background task origin from its persisted launch tool * Restate background task fallback from its terminal evidence * Require session identity on Grok task notifications * Honor explicit Grok task completion without an exit code * Match the background reset test to the final timeline grammar * Give task completion fixtures a specific evidence type * Settle ACP background tasks from live and replayed evidence * Honor background task outcomes carried by replayed tool results * Keep the journal store under its line limit after the main merge * Drop the provider item reference, join index and identity schemes from the transition PR Nothing in production reaches the state they defended (an assembler that lost its memory while its child keeps streaming the same turn), and the stored Codex id was positional. The legacy identity scheme moves to the assembler PR with its first caller; the Codex scheme and any persisted reference wait for Codex to move onto the assembler. The Codex ordinal counter goes back to codex/, since no neutral code imports it. * Write a resolved settlement in one transaction through enqueueRows A settlement too large for one row now commits all its rows or none, through the journal's existing all-or-nothing write, instead of a row-by-row writer. Every row is built before any commits, so a settlement naming one item twice is refused before anything is written. * Drop the transition's landing report; keep the turn-row write fire-and-forget Nothing reads which steps of a transition wrote. A failed step fails the sink, leaving the steps before it written; the header says so, and tests cover it plus a settlement whose second row fails inside the transaction. writeAgentJournalTurnRow returns nothing again, as on main. * Generate open ACP enums and check the generated schema offline A newer or vendor enum value (tool kind, tool status, option kind, stop reason) no longer fails the whole message: generated enums accept the known literals plus any other string, typed so callers can still narrow on the known ones. The generated header now records the pinned input digests, the generator digest and a body hash, so `verify:acp-protocol` catches a stale or hand-edited file without network access; it runs in lint and the PR workflow. * Land the ACP runtime contract the agent adapters use - Deliver notifications other than session/update through onExtensionNotification, in arrival order with session updates. - Accept _meta on prompt, setMode, setModel, setConfigOption and cancel. - cancel() always sends session/cancel once the session runs, since the agent can be in a turn it began itself; only a successful send is shared, so a failed write is retried. - Cancel aborts each open agent request's signal and lets its handler send its own answer; -32800 only when the handler rejects. - Permission requests validate only the session, tool call id and options; unreadable fields are dropped with a diagnostic, and any answer Orca cannot send is `cancelled` instead of a JSON-RPC error. Agent-started turns may ask; whether to show it is the caller's decision. - AcpAgentError marks the agent's own errors; AcpInvalidResponseError keeps the raw answer and validation issues for answers Orca could not read. - Lines over the size limit are classified by prefix (shared with the Codex reader): the owed request fails, an oversized agent request is answered with an error, and an unattributable response closes the connection. * Run a transition's steps as a prefix; drop the paced flag and resolved options A failed step no longer lets the steps after it write: each step checks, at its own turn in the journal's queue, whether the write handed over just ahead of it completed, using the queue's count of completed write bodies (a promise would report the failure only after the next step ran). The sink fails only once every step has had its turn. The item step's `paced` size bypass and the resolver's replacement `options` are removed; nothing planned uses them. * Rebuild the timeline assembler on one admission-order state The assembler kept a second copy of its state (a forecast beside a ledger), hydrated the open turn from the journal, and recognised replays, all to recover from losing its memory while the provider child kept streaming. That never happens: one assembler lives exactly as long as one child, and a new child is a new assembler in a new generation whose events land after the dead-generation sweep. - One state, changed only when the sink admits an event (minted keys included), so a refused event takes nothing. - Every journal-dependent choice is made when the write runs, by keyed reads: a turn row is written only where none is, a stream checks its row's turn on every write, a running snapshot never lands in a settled turn or relights a settled tool, a request takes the first incarnation the journal holds no row for. - Rows are found by spelling their ids (provider-timeline-rows.ts); no join cache. - The identity scheme (legacy arm only) lives here with its first caller; requests are spelled in their acquisition generation, since JSON-RPC ids restart per process. - Saved history goes in as `input.history` plus ordinary events with the provider's ids, into an empty journal; `session.reset` and every replay rule are gone. - One terminal-body function (`terminalAgentJournalBody`) is shared with the dead-generation settlement. - The test rig's restart now sweeps and starts a new generation, as production does. * Answer every agent request after an ACP cancel A cancel that lands before a permission handler starts now still runs the permission path, so the agent gets the `cancelled` outcome rather than a request-cancelled error. A handler that ignores the abort no longer leaves the agent waiting: once the abort has run through, any request still unanswered gets request-cancelled. Handlers that answer on abort keep their own reply. Also renames a lint-rejected helper parameter, replaces a Reflect.apply in a test, and stops the permission diagnostic from firing with an empty list. * Cover new running work in a turn the sweep ended * Run a transition's steps in one queued write that loops over them The steps of one event now share one turn in the journal's write queue: a loop writes each in its own transaction through the row writer's synchronous writeRows (split out of enqueueRows) and stops at the first throw. Prefix semantics and "nothing lands between the steps" now hold by construction, so the completed-write counter on the queue, the step gate and the allSettled barrier are gone; the queue is back to main's bytes. * Let each ACP request handler own its answer after a cancel Removes the next-event-loop-turn fallback that answered request-cancelled for any handler still silent after a cancel. It raced answers that were still being saved (an approval mid-journal-write reached the agent as an error) and made the outcome depend on event-loop timing. The handler that owns an agent request now always sends its answer, or throws for request-cancelled; a request it never answers ends when the connection closes. A permission whose handler had not started still answers `cancelled`. * End a turn another writer settled the way the provider's end does A person's Stop settled the open turn's row without a word to the assembler. The assembler then forgot the turn: its running tools and pending prompts were never settled, the provider's own end and withdrawal were dropped, and the turn's text streams stayed counted against the open budget for the life of the process. Text the provider kept streaming afterwards could land as a message outside the stopped turn. - The open turn the journal shows settled ends first, as one transition, through the same settlement the provider's turn.end plans; its streams stop and their keys drop later text until that turn's end or the next turn opens. - turn.end and request.withdrawn are admitted for a turn or request the journal holds; their settlement writes nothing for rows already settled. - The budget's re-check frees streams whose turn settled. - A settled tool keeps its terminal body against any differing write. - Session-end settlement of lost background work uses the journal's own lostLiveWorkJournalBody instead of a copy. - The rig's window elapses before every read, and restart swaps and disposes the old assembler. * Adopt ACP load history only into an empty journal, chosen by the translator's creator The translator now takes `adopt` from whoever creates it instead of guessing from the journal (which reads empty before the sink binds). Without adoption, history a provider replays during session/load is dropped except its context usage. With adoption, history becomes ordinary events with provider or position ids and the user's saved messages become `input.history`, so re-running an interrupted adoption lands the same rows. The translator no longer reads the journal: it lives exactly as long as its provider child. Its tool snapshots are bounded by the assembler's open budget, and a small record of each tool's turn keeps a late background-task notice beside the tool that started it. * Leave a stopped turn's running tools to the agent's own end When another writer settles the open turn (a person's Stop), the assembler now only stops that turn's text and cancels its pending prompts. Running tool calls stay the agent's: a progress update or completion it reports after the Stop lands as reported, and whatever is still running settles at the agent's turn end for that turn, the next turn's open, or the session's end. An agent's end for an earlier turn while a newer one is open no longer clears the open turn's activity line or ends its anonymous reply. An unnamed end right after a Stop ends the stopped turn instead of being dropped. The test rig's restart no longer writes the dead assembler's window text, matching dispose. * Pin that a stopped turn's running tools hold budget until the agent's end * Type the stopped turn's tool progress update as a tool body * List every event the assembler hands to the decision step The type-aware lint requires an exhaustive switch with no default case. Also retitle a Stop test to say what it asserts. * Say why a Grok turn failed, and keep task rows in Grok's own words A failed Grok turn ended with no reason on screen: the translator dropped every copy of Grok's message. The failed turn now gets one status row in Orca's existing "provider did not accept this message" words with Grok's reason, read from whichever copy arrives first (the given-up retry, the turn's end, the prompt's completion notice, or the prompt's error answer); later copies only fill a reason the row still lacks. A running background command no longer reads "Background task started": a task's summary is mapped only once it has settled. A monitor stays a monitor when the agent reads its output: a frame that names no kind keeps the known one, and a "[monitor" command is a monitor. A prompt's turn is marked started, so a late frame for an ended prompt neither reopens it nor becomes the active turn. A tool's turn is held in one place at a time. * Read a monitor from Grok's exact output prefix * Word a failed Grok turn in Grok's own text, not as a refused message A turn that started and then failed was told "The provider did not accept this message", Orca's sentence for a message refused before its turn. The row now reads as a Codex turn-ending error does: an error status row with the provider's own words. With no words, the dialect names the failure ("Grok ended this turn with an error." / "Grok usage limit reached."), else the agent's display name does. * Register the ACP schema verify step in the PR preflight phase test * Read ACP permissions, session events and prompt errors through the protocol client's own types The translator now reads a permission request with the client's lenient reader, a session update with its session-event reader, and takes only the agent's own error answer as a failed prompt's reason, so an Orca-side error never reads as the provider's words. Tests cover protocol values newer than this build. * refactor(native-chat): drop saved-history adoption from the timeline assembler The common pattern discards the history a provider replays while loading a session, so the assembler has no use for an input.history event. * refactor(acp): drop session/load history adoption from the translator The common pattern discards the history an agent replays during session/load, keeping only what it says about the context window. Remove the adoption path (acp-history-adoption.ts, the adopt option, and the historical background-task liveness rewrite it fed) so load replay is always dropped except usage. * refactor(native-chat): a pending input is only Orca's send now Review follow-up to the adoption removal: drop the comment naming the provider's saved message, and make requestedAt required since every pending input comes from input.accepted. * test(acp): keep the task-result status table on live frames Review follow-up to the adoption removal: the result-status mapping was only tested through adopted history, so run the same table on live frames, and cover an unmarked task notice during a load being dropped. * Use current provider handles in transition tests * Use current provider handles in timeline fixtures * Require the ACP directory in the runtime import check --- src/main/acp/acp-background-task-timeline.ts | 57 +++ src/main/acp/acp-context-usage.ts | 69 ++++ src/main/acp/acp-dialects/acp-dialect.ts | 54 +++ .../acp/acp-dialects/grok-background-tasks.ts | 184 +++++++++ src/main/acp/acp-dialects/grok-dialect.ts | 184 +++++++++ src/main/acp/acp-dialects/grok-requests.ts | 144 +++++++ src/main/acp/acp-fixture-privacy.test.ts | 101 +++++ src/main/acp/acp-prompt-turns.ts | 74 ++++ src/main/acp/acp-session-update.ts | 92 +++++ ...-timeline-background-task-evidence.test.ts | 229 +++++++++++ ...p-timeline-background-task-results.test.ts | 109 ++++++ ...acp-timeline-background-task-words.test.ts | 97 +++++ .../acp/acp-timeline-background-tasks.test.ts | 212 +++++++++++ src/main/acp/acp-timeline-dialect.test.ts | 217 +++++++++++ .../acp/acp-timeline-fixture.test-support.ts | 168 +++++++++ src/main/acp/acp-timeline-generic.test.ts | 356 ++++++++++++++++++ src/main/acp/acp-timeline-joins.test.ts | 80 ++++ src/main/acp/acp-timeline-open-enums.test.ts | 104 +++++ src/main/acp/acp-timeline-recordings.test.ts | 141 +++++++ src/main/acp/acp-timeline-recovery.test.ts | 127 +++++++ src/main/acp/acp-timeline-requests.ts | 89 +++++ src/main/acp/acp-timeline-routing.test.ts | 123 ++++++ src/main/acp/acp-timeline-translator.ts | 263 +++++++++++++ .../acp/acp-timeline-turn-failures.test.ts | 234 ++++++++++++ src/main/acp/acp-tool-timeline.ts | 235 ++++++++++++ src/main/acp/acp-turn-failures.ts | 65 ++++ src/main/acp/acp-turn-messages.ts | 35 ++ src/main/acp/fixtures/s1-basic.jsonl | 12 + .../acp/fixtures/s1-full-notifications.jsonl | 142 +++++++ src/main/acp/fixtures/s2-permission.jsonl | 20 + src/main/acp/fixtures/s3-cancel.jsonl | 13 + src/main/acp/fixtures/s4-resume.jsonl | 23 ++ src/main/acp/fixtures/s5-plan-approved.jsonl | 16 + src/main/acp/fixtures/s6-background.jsonl | 18 + .../claude-background-task-row-journal.ts | 18 +- src/shared/native-chat-background-task-row.ts | 11 + 36 files changed, 4101 insertions(+), 15 deletions(-) create mode 100644 src/main/acp/acp-background-task-timeline.ts create mode 100644 src/main/acp/acp-context-usage.ts create mode 100644 src/main/acp/acp-dialects/acp-dialect.ts create mode 100644 src/main/acp/acp-dialects/grok-background-tasks.ts create mode 100644 src/main/acp/acp-dialects/grok-dialect.ts create mode 100644 src/main/acp/acp-dialects/grok-requests.ts create mode 100644 src/main/acp/acp-fixture-privacy.test.ts create mode 100644 src/main/acp/acp-prompt-turns.ts create mode 100644 src/main/acp/acp-session-update.ts create mode 100644 src/main/acp/acp-timeline-background-task-evidence.test.ts create mode 100644 src/main/acp/acp-timeline-background-task-results.test.ts create mode 100644 src/main/acp/acp-timeline-background-task-words.test.ts create mode 100644 src/main/acp/acp-timeline-background-tasks.test.ts create mode 100644 src/main/acp/acp-timeline-dialect.test.ts create mode 100644 src/main/acp/acp-timeline-fixture.test-support.ts create mode 100644 src/main/acp/acp-timeline-generic.test.ts create mode 100644 src/main/acp/acp-timeline-joins.test.ts create mode 100644 src/main/acp/acp-timeline-open-enums.test.ts create mode 100644 src/main/acp/acp-timeline-recordings.test.ts create mode 100644 src/main/acp/acp-timeline-recovery.test.ts create mode 100644 src/main/acp/acp-timeline-requests.ts create mode 100644 src/main/acp/acp-timeline-routing.test.ts create mode 100644 src/main/acp/acp-timeline-translator.ts create mode 100644 src/main/acp/acp-timeline-turn-failures.test.ts create mode 100644 src/main/acp/acp-tool-timeline.ts create mode 100644 src/main/acp/acp-turn-failures.ts create mode 100644 src/main/acp/acp-turn-messages.ts create mode 100644 src/main/acp/fixtures/s1-basic.jsonl create mode 100644 src/main/acp/fixtures/s1-full-notifications.jsonl create mode 100644 src/main/acp/fixtures/s2-permission.jsonl create mode 100644 src/main/acp/fixtures/s3-cancel.jsonl create mode 100644 src/main/acp/fixtures/s4-resume.jsonl create mode 100644 src/main/acp/fixtures/s5-plan-approved.jsonl create mode 100644 src/main/acp/fixtures/s6-background.jsonl diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts new file mode 100644 index 00000000000..c3aa3f51f00 --- /dev/null +++ b/src/main/acp/acp-background-task-timeline.ts @@ -0,0 +1,57 @@ +import { BoundedMap } from '../../shared/bounded-map' +import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' + +/** Partial provider snapshots only; the assembler owns settlement and durable placement. A task + * the bound evicted (128 others updated since) loses its earlier label, parent tool and output + * file on its next update; its row keeps the turn it was placed in. */ +export class AcpBackgroundTaskTimeline { + private readonly snapshots = new BoundedMap({ + maxEntries: 128, + maxBytes: 1024 * 1024, + sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) + }) + + constructor(private readonly toolTurn: (callId: string) => string | undefined) {} + + translate( + updates: AcpBackgroundTaskUpdate[], + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + return updates.map((update) => { + const { fallbackLabel, fallbackKind, ...fields } = update + const previous = this.snapshots.get(update.taskId) + const block: NativeChatBackgroundTaskBlock = { + type: 'background-task', + ...previous, + ...fields, + kind: update.kind ?? previous?.kind ?? fallbackKind ?? 'unknown', + label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, + state: update.state + } + for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { + const text = block[key] + if (text !== undefined) { + block[key] = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + } + } + this.snapshots.set(block.taskId, block) + const turn = block.parentToolUseId ? this.toolTurn(block.parentToolUseId) : undefined + return { + type: 'item.update', + item: `background-task:${block.taskId}`, + body: backgroundTaskJournalBody(block), + join: { ...join, ...(turn === undefined ? {} : { turn }) } + } + }) + } +} diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts new file mode 100644 index 00000000000..007e56413ab --- /dev/null +++ b/src/main/acp/acp-context-usage.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { SessionUpdate, UsageUpdate } from './generated/acp-protocol.generated' + +export class AcpContextTimeline { + private window?: { tokens: number; capturedAt: number } + + models( + models: unknown, + at: number, + dialect: AcpDialect, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const tokens = dialect.contextWindow?.(models) + return tokens === undefined ? [] : this.update({ window: { tokens, capturedAt: at } }, join) + } + + update(usage: AgentSessionContextUsage, join: ProviderTimelineJoin): ProviderTimelineEvent[] { + this.window = usage.window ?? this.window + return [ + { + type: 'context.usage', + usage: { ...usage, ...(this.window ? { window: this.window } : {}) }, + join + } + ] + } + + /** Replayed history is dropped except what it says about the context window. */ + history( + update: SessionUpdate | undefined, + usage: AgentSessionContextUsage | undefined, + at: number, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const events = usage ? this.update(usage, join) : [] + if (update?.sessionUpdate === 'usage_update') { + events.push({ type: 'context.usage', usage: acpWindowUsage(update, at), join }) + } + return events + } +} + +export function acpWindowUsage(update: UsageUpdate, at: number): AgentSessionContextUsage { + return { + window: { tokens: update.size, capturedAt: at }, + used: { + kind: 'estimate', + capturedAt: at, + usage: { + inputTokens: update.used, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0 + } + } + } +} + +export const acpNotificationEnvelopeSchema = z.looseObject({ + sessionId: z.string(), + update: z.looseObject({ sessionUpdate: z.string() }), + _meta: z.looseObject({ isReplay: z.boolean().optional() }).nullish() +}) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts new file mode 100644 index 00000000000..3b38b9cc1bd --- /dev/null +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -0,0 +1,54 @@ +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' +import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' +import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpAgentError } from '../acp-errors' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' + +export type AcpRequestPresentation = { + body: ProviderTimelineRequestBody + reply(response: AgentSessionPromptResponse | null): unknown +} + +export type AcpBackgroundTaskUpdate = Pick & + Partial> & { + /** Used only until a provider description is known. */ + fallbackLabel?: string + /** Used only until a frame names the task's kind. */ + fallbackKind?: NativeChatBackgroundTaskBlock['kind'] + } + +export type AcpDialectNotification = + | { disposition: 'ignore' } + | { + disposition: 'map' + turn?: string + replay?: boolean + at?: number + started?: boolean + end?: { stopReason: string; durationMs?: number; failureDetail?: string } + /** The provider's own words for why `turn` failed, sent apart from its end. */ + failureDetail?: string + usage?: AgentSessionContextUsage + backgroundTasks?: AcpBackgroundTaskUpdate[] + } + +/** Hooks interpret extensions; lifecycle and row identity stay shared. */ +export type AcpDialect = { + injectedPromptIdentity?: true + toolName?(update: ToolCallUpdate): string | undefined + toolBackgroundTasks?( + update: ToolCallUpdate, + tool: AgentJournalToolCallItem + ): AcpBackgroundTaskUpdate[] + notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined + contextWindow?(models: unknown): number | undefined + request?(method: string, params: unknown): AcpRequestPresentation | 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. */ + failedTurnText?(stopReason: string): string +} + +export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts new file mode 100644 index 00000000000..415a765840e --- /dev/null +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -0,0 +1,184 @@ +import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' +import { + isSettledBackgroundTaskState, + normalizeBackgroundTaskKind +} from '../../../shared/native-chat-background-task-row' +import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpBackgroundTaskUpdate, AcpDialectNotification } from './acp-dialect' + +const taskSchema = z.object({ + task_id: z.string().min(1), + command: z.string().optional(), + description: z.string().nullish(), + monitor_description: z.string().nullish(), + display_command: z.string().nullish(), + output_file: z.string().optional(), + summary: z.string().optional(), + error: z.string().optional(), + output: z.string().optional(), + kind: z.string().optional(), + task_type: z.string().optional(), + exit_code: z.number().int().nullish(), + signal: z.union([z.string(), z.number()]).nullish(), + explicitly_killed: z.boolean().optional() +}) +const backgroundedSchema = taskSchema.extend({ + sessionUpdate: z.literal('task_backgrounded'), + tool_call_id: z.string().optional() +}) +const completedSchema = z.object({ + sessionUpdate: z.literal('task_completed'), + task_snapshot: taskSchema +}) +const startedOutputSchema = taskSchema.extend({ type: z.literal('BackgroundTaskStarted') }) +const taskResultSchema = taskSchema.extend({ + status: z.string().optional(), + outcome: z.string().optional() +}) +const resultOutputSchema = z.object({ + type: z.enum(['TaskOutput', 'KillTask']), + Result: taskResultSchema.optional(), + MultiResult: z.object({ results: z.array(taskResultSchema) }).optional() +}) +type GrokTask = z.infer +/** Grok's output reads name a monitor only by this command prefix. */ +const MONITOR_COMMAND = /^\[monitor[:\]]/ + +function completedState(task: GrokTask): NativeChatBackgroundTaskBlock['state'] { + return task.explicitly_killed + ? 'idle' + : task.signal != null || task.error?.trim() || (task.exit_code != null && task.exit_code !== 0) + ? 'blocked' + : 'done' +} + +function resultState( + task: z.infer +): NativeChatBackgroundTaskBlock['state'] | undefined { + if (task.explicitly_killed || ['killed', 'stopped', 'cancelled'].includes(task.status ?? '')) { + return 'idle' + } + if (task.error?.trim() || ['failed', 'error'].includes(task.status ?? '')) { + return 'blocked' + } + if ( + task.exit_code != null || + task.signal != null || + ['completed', 'success', 'succeeded'].includes(task.status ?? '') + ) { + return completedState(task) + } + return ['pending', 'running'].includes(task.status ?? '') ? 'working' : undefined +} + +function snapshot( + task: GrokTask, + state: NativeChatBackgroundTaskBlock['state'] +): AcpBackgroundTaskUpdate { + const kind = task.kind ?? task.task_type + const monitor = + kind === 'monitor' || + task.monitor_description != null || + MONITOR_COMMAND.test(task.command ?? '') + const label = task.monitor_description?.trim() || task.description?.trim() + const fallbackLabel = task.display_command?.trim() || task.command?.trim() + const settled = isSettledBackgroundTaskState(state) + return { + taskId: task.task_id, + state, + ...(monitor + ? { kind: 'monitor' as const } + : kind !== undefined + ? { + kind: + kind === 'bash' || kind === 'shell' || task.command !== undefined + ? ('command' as const) + : normalizeBackgroundTaskKind(kind) + } + : task.command !== undefined + ? { fallbackKind: 'command' as const } + : {}), + ...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}), + ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), + // A running task's summary is Grok's "Background task started", not the task's result. + ...(settled ? { summary: task.summary ?? '' } : {}), + ...(task.error === undefined && !settled ? {} : { error: task.error ?? '' }) + } +} + +const taskInputSchema = z.object({ + description: z.string().optional(), + command: z.string().optional(), + task_id: z.string().trim().min(1).optional(), + task_ids: z.array(z.string().trim().min(1)).optional() +}) + +export function grokToolBackgroundTasks( + update: ToolCallUpdate, + tool: AgentJournalToolCallItem +): AcpBackgroundTaskUpdate[] { + const result = resultOutputSchema.safeParse(update.rawOutput) + if (result.success) { + const tasks = + result.data.MultiResult?.results ?? (result.data.Result ? [result.data.Result] : []) + return tasks.flatMap((task) => { + if (result.data.type === 'KillTask') { + return update.status === 'completed' && task.outcome === 'killed' + ? [snapshot(task, 'idle')] + : [] + } + const state = resultState(task) + return state && !task.command?.startsWith('[subagent:') ? [snapshot(task, state)] : [] + }) + } + const input = taskInputSchema.safeParse(tool.input) + if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) { + // Inference: a successful kill call settles its named tasks without a completion notice. + return [ + ...new Set([ + ...(input.data.task_ids ?? []), + ...(input.data.task_id ? [input.data.task_id] : []) + ]) + ].map((taskId) => ({ taskId, state: 'idle', summary: '', error: '' })) + } + const parsed = startedOutputSchema.safeParse(update.rawOutput) + return parsed.success + ? [ + { + ...snapshot({ ...(input.success ? input.data : {}), ...parsed.data }, 'working'), + parentToolUseId: update.toolCallId + } + ] + : [] +} + +export function grokBackgroundTaskNotification( + canonicalMethod: string, + params: unknown +): AcpDialectNotification | undefined { + if (!['x.ai/task_backgrounded', 'x.ai/task_completed'].includes(canonicalMethod)) { + return undefined + } + const parsed = z + .object({ sessionId: z.string(), update: z.union([backgroundedSchema, completedSchema]) }) + .safeParse(params) + if (!parsed.success) { + return { disposition: 'ignore' } + } + const update = parsed.data.update + if (update.sessionUpdate === 'task_backgrounded') { + return { + disposition: 'map', + backgroundTasks: [ + { + ...snapshot(update, update.monitor_description != null ? 'monitoring' : 'working'), + ...(update.tool_call_id === undefined ? {} : { parentToolUseId: update.tool_call_id }) + } + ] + } + } + const task = update.task_snapshot + return { disposition: 'map', backgroundTasks: [snapshot(task, completedState(task))] } +} diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts new file mode 100644 index 00000000000..09213c97389 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -0,0 +1,184 @@ +import { z } from 'zod' +import { acpNotificationEnvelopeSchema } from '../acp-context-usage' +import type { AcpDialect, AcpDialectNotification } from './acp-dialect' +import { grokRequest } from './grok-requests' +import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' + +const tokenCount = z.number().int().nonnegative() +const toolMetaSchema = z.object({ 'x.ai/tool': z.object({ name: z.string().min(1) }) }) +const turnMetaSchema = z.looseObject({ + promptId: z.string().optional(), + turnStartMs: tokenCount.optional() +}) +const completionSchema = z.looseObject({ + sessionUpdate: z.literal('turn_completed'), + prompt_id: z.string(), + stop_reason: z.string(), + agent_result: z.string().nullish(), + elapsed_ms: tokenCount.optional() +}) +const retrySchema = z.looseObject({ + sessionUpdate: z.literal('retry_state'), + type: z.string(), + message: z.string().optional() +}) +const promptCompleteSchema = z.looseObject({ + sessionId: z.string(), + promptId: z.string(), + stopReason: z.string(), + agentResult: z.string().nullish() +}) +const promptErrorDataSchema = z.looseObject({ message: z.string() }) +/** Grok writes its result text as the failure's reason only for these ends. */ +const FAILED_STOP_REASONS = ['error', 'rate_limit'] + +function failureDetail(stopReason: string, result: string | null | undefined): string | undefined { + return FAILED_STOP_REASONS.includes(stopReason) && result?.trim() ? result : undefined +} +const responseSchema = z.object({ + sessionUpdate: z.literal('response_completed'), + usage: z.object({ + input_tokens: tokenCount, + output_tokens: tokenCount.default(0), + cache_read_input_tokens: tokenCount.default(0), + cache_creation_input_tokens: tokenCount.default(0) + }) +}) +const queueSchema = z.object({ sessionId: z.string(), runningPromptId: z.string().nullish() }) +const modelsSchema = z.object({ + currentModelId: z.string().optional(), + availableModels: z + .array( + z.object({ + modelId: z.string(), + _meta: z.object({ totalContextTokens: z.number().int().positive() }).optional() + }) + ) + .optional() +}) + +function contextWindow(models: unknown): number | undefined { + const parsed = modelsSchema.safeParse(models) + if (!parsed.success) { + return undefined + } + const { currentModelId, availableModels = [] } = parsed.data + return ( + availableModels.find((model) => model.modelId === currentModelId)?._meta?.totalContextTokens ?? + availableModels.find((model) => model._meta)?._meta?.totalContextTokens + ) +} + +function completionEnd( + completion: z.infer +): NonNullable['end']> { + const detail = failureDetail(completion.stop_reason, completion.agent_result) + return { + stopReason: completion.stop_reason, + durationMs: completion.elapsed_ms, + ...(detail ? { failureDetail: detail } : {}) + } +} + +function notification( + method: string, + params: unknown, + at: number +): AcpDialectNotification | undefined { + const canonical = method.startsWith('_') ? method.slice(1) : method + const backgroundTask = grokBackgroundTaskNotification(canonical, params) + if (backgroundTask) { + return backgroundTask + } + if (canonical === 'x.ai/models/update') { + const tokens = contextWindow(params) + return tokens === undefined + ? { disposition: 'ignore' } + : { disposition: 'map', usage: { window: { tokens, capturedAt: at } } } + } + if (canonical === 'x.ai/session/prompt_complete') { + // A copy of the turn's end; only its failure reason is read, in case the end carried none. + const parsed = promptCompleteSchema.safeParse(params) + const detail = parsed.success + ? failureDetail(parsed.data.stopReason, parsed.data.agentResult) + : undefined + return parsed.success && detail + ? { disposition: 'map', turn: parsed.data.promptId, failureDetail: detail } + : { disposition: 'ignore' } + } + if (canonical === 'x.ai/queue/changed') { + const parsed = queueSchema.safeParse(params) + return parsed.success && parsed.data.runningPromptId + ? { disposition: 'map', turn: parsed.data.runningPromptId, started: true } + : { disposition: 'ignore' } + } + if ( + method !== 'session/update' && + !['x.ai/session_notification', 'x.ai/session/update'].includes(canonical) + ) { + return canonical.startsWith('x.ai/') ? { disposition: 'ignore' } : undefined + } + const parsed = acpNotificationEnvelopeSchema.safeParse(params) + if (!parsed.success) { + return method === 'session/update' ? undefined : { disposition: 'ignore' } + } + const envelope = parsed.data + const meta = turnMetaSchema.safeParse(envelope._meta) + const completion = completionSchema.safeParse(envelope.update) + const response = responseSchema.safeParse(envelope.update) + // Only a retry Grok gave up on is read; it names no turn, so it joins the one running. + const retry = retrySchema.safeParse(envelope.update) + const retryFailure = + retry.success && retry.data.type === 'failed' && retry.data.message?.trim() + ? retry.data.message + : undefined + if (method !== 'session/update' && !completion.success && !response.success && !retryFailure) { + return { disposition: 'ignore' } + } + const turn = completion.success + ? completion.data.prompt_id + : meta.success + ? meta.data.promptId + : undefined + return { + disposition: 'map', + ...(turn === undefined ? {} : { turn }), + ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), + replay: envelope._meta?.isReplay === true, + ...(completion.success ? { end: completionEnd(completion.data) } : {}), + ...(retryFailure ? { failureDetail: retryFailure } : {}), + ...(response.success + ? { + usage: { + used: { + kind: 'estimate', + capturedAt: at, + usage: { + inputTokens: response.data.usage.input_tokens, + outputTokens: response.data.usage.output_tokens, + cacheReadInputTokens: response.data.usage.cache_read_input_tokens, + cacheCreationInputTokens: response.data.usage.cache_creation_input_tokens + } + } + } + } + : {}) + } +} + +export const GROK_ACP_DIALECT: AcpDialect = { + injectedPromptIdentity: true, + toolName: (update) => { + const parsed = toolMetaSchema.safeParse(update._meta) + return parsed.success ? parsed.data['x.ai/tool'].name : undefined + }, + request: grokRequest, + toolBackgroundTasks: grokToolBackgroundTasks, + notification, + contextWindow, + promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, + failedTurnText: (stopReason) => + stopReason === 'rate_limit' + ? 'Grok usage limit reached.' + : 'Grok ended this turn with an error.' +} diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts new file mode 100644 index 00000000000..26dbee0fae2 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -0,0 +1,144 @@ +import { z } from 'zod' +import { + isValidAgentSessionQuestionAnswers, + legacyAgentSessionQuestionAnswers +} from '../../../shared/agent-session-question-answer' +import { AcpRpcError } from '../acp-errors' +import { pendingAcpResolution } from '../acp-timeline-requests' +import type { AcpRequestPresentation } from './acp-dialect' + +const questionRequestSchema = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + questions: z + .array( + z.object({ + question: z.string(), + multiSelect: z.boolean().nullish(), + options: z.array(z.object({ label: z.string(), description: z.string().optional() })) + }) + ) + .min(1) +}) +const planRequestSchema = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + planContent: z.string().nullish() +}) + +function questionRequest(params: unknown): AcpRequestPresentation { + const parsed = questionRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent question request') + } + const questions = parsed.data.questions.map((question, index) => ({ + id: `q${index + 1}`, + question: question.question, + multiSelect: question.multiSelect ?? false, + options: question.options.map((option, optionIndex) => ({ + id: `o${optionIndex + 1}`, + label: option.label, + ...(option.description === undefined ? {} : { description: option.description }) + })), + freeTextQuestionId: `q${index + 1}` + })) + const body = { + kind: 'question' as const, + question: questions[0]!.question, + options: questions[0]!.options, + questions, + resolution: pendingAcpResolution + } + return { + body, + reply: (response) => { + if (response === null) { + return { outcome: 'cancelled' } + } + const answers = + response.kind === 'answers' + ? response.answers + : legacyAgentSessionQuestionAnswers(body, response.optionId) + const choices = answers?.map((answer) => + answer.optionIds.length > 0 + ? { questionId: answer.questionId, optionIds: answer.optionIds } + : answer + ) + if (!answers || !choices || !isValidAgentSessionQuestionAnswers(questions, choices)) { + throw new AcpRpcError(-32602, 'Question answer must answer every offered question') + } + return { + outcome: 'accepted', + answers: Object.fromEntries( + questions.map((question) => { + const answer = answers.find((entry) => entry.questionId === question.id)! + const values = answer.optionIds.map( + (id) => question.options.find((option) => option.id === id)!.label + ) + if (values.length === 0 && answer.other?.trim()) { + values.push('Other') + } + return [question.question, question.multiSelect ? values : values[0]] + }) + ), + ...(answers.some((answer) => answer.other?.trim()) + ? { + annotations: Object.fromEntries( + questions.flatMap((question) => { + const notes = answers + .find((answer) => answer.questionId === question.id) + ?.other?.trim() + return notes ? [[question.question, { notes }]] : [] + }) + ) + } + : {}) + } + } + } +} + +function planRequest(params: unknown): AcpRequestPresentation { + const parsed = planRequestSchema.safeParse(params) + if (!parsed.success) { + throw new AcpRpcError(-32602, 'Invalid agent plan approval 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 } + } + } +} + +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-fixture-privacy.test.ts b/src/main/acp/acp-fixture-privacy.test.ts new file mode 100644 index 00000000000..dced40612d1 --- /dev/null +++ b/src/main/acp/acp-fixture-privacy.test.ts @@ -0,0 +1,101 @@ +import { readFile, readdir } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' + +const privateText = + /\/Users\/|\/home\/|[a-z]:[\\/]+Users[\\/]|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\bhostname\b|\b[\w-]+\.(?:local|lan)\b|signature|subscription_tier|skills?/i + +function decodedStrings(value: unknown, depth = 0): string[] { + if (depth > 8) { + return [] + } + if (Array.isArray(value)) { + if ( + value.length > 0 && + value.every( + (entry) => + typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 255 + ) + ) { + return decodedStrings(Buffer.from(value).toString('utf8'), depth + 1) + } + return value.flatMap((entry) => decodedStrings(entry, depth + 1)) + } + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, entry]) => [ + key, + ...decodedStrings(entry, depth + 1) + ]) + } + if (typeof value !== 'string') { + return [] + } + const texts = [value] + try { + const decoded = decodeURIComponent(value) + if (decoded !== value) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } catch { + /* Provider text need not be URI encoded. */ + } + try { + const decoded: unknown = JSON.parse(value) + if (decoded !== value) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } catch { + /* Ordinary text is not JSON. */ + } + for (const token of value.match(/[A-Za-z0-9+/_-]{8,}={0,2}/g) ?? []) { + const decoded = Buffer.from(token, 'base64url').toString('utf8') + if ( + decoded !== token && + !decoded.includes('\uFFFD') && + [...decoded].every( + (char) => + ['\n', '\r', '\t'].includes(char) || + (char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127) + ) + ) { + texts.push(...decodedStrings(decoded, depth + 1)) + } + } + return texts +} + +describe('ACP recording privacy', () => { + it.each([ + '/Users/u', + '/home/u', + '/home/é', + '/Users/private/project', + '/home/private/project', + 'C:\\Users\\private\\project' + ])('detects home paths after nested JSON, URL, base64 and byte encoding: %s', (path) => { + for (const value of [ + path, + encodeURIComponent(path), + Buffer.from(path).toString('base64'), + [...Buffer.from(path)], + JSON.stringify({ nested: JSON.stringify(path) }) + ]) { + expect(decodedStrings(value).some((text) => privateText.test(text))).toBe(true) + } + }) + + it('contains no personal metadata or home-directory paths in any decoded fixture', async () => { + const directory = new URL('./fixtures/', import.meta.url) + const names = (await readdir(directory)).filter((name) => name.endsWith('.jsonl')) + expect(names.length).toBeGreaterThanOrEqual(6) + for (const name of names) { + const content = await readFile(new URL(name, directory), 'utf8') + for (const [index, line] of content.trim().split('\n').entries()) { + const value: unknown = JSON.parse(line) + expect( + decodedStrings(value).some((text) => privateText.test(text)), + `${name}:${index + 1}` + ).toBe(false) + } + } + }) +}) diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts new file mode 100644 index 00000000000..c9fe584dcab --- /dev/null +++ b/src/main/acp/acp-prompt-turns.ts @@ -0,0 +1,74 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' + +export type AcpPromptTurn = { + clientMessageId: string + turn: string + requestedAt: number + opened: boolean + durationMs?: number +} + +/** An injected identity is known before any provider output arrives. */ +export class AcpPromptTurns { + current?: AcpPromptTurn + /** The last prompt that ended, so its answer can still add the failure reason the end lacked. */ + last?: AcpPromptTurn + + constructor( + private readonly sessionId: string, + private readonly injected: boolean + ) {} + + open(clientMessageId: string, at: number): { promptId: string; events: ProviderTimelineEvent[] } { + if (this.current) { + throw new Error('ACP prompt overlaps a prompt or load') + } + const turn = `prompt:${clientMessageId}` + this.current = { clientMessageId, turn, requestedAt: at, opened: false } + return { promptId: turn, events: this.injected ? [] : this.start(turn, at) } + } + + finish(): void { + this.last = this.current + this.current = undefined + } + + start(turn: string, at: number): ProviderTimelineEvent[] { + const prompt = this.current + if (prompt?.turn !== turn || prompt.opened) { + return [] + } + prompt.opened = true + return [ + { type: 'turn.open', turn, at }, + { + type: 'input.accepted', + clientMessageId: prompt.clientMessageId, + requestedAt: prompt.requestedAt, + join: { thread: this.sessionId, turn } + } + ] + } +} + +export function acpTurnEnd( + turn: string, + stopReason: string, + at: number, + durationMs?: number +): ProviderTimelineEvent { + return { + type: 'turn.end', + turn, + at, + state: stopReason === 'cancelled' ? 'interrupted' : 'completed', + ...(stopReason === 'end_turn' + ? { outcome: 'success' as const } + : stopReason === 'cancelled' + ? { outcome: 'cancellation' as const } + : ['refusal', 'max_tokens', 'max_turn_requests', 'error', 'rate_limit'].includes(stopReason) + ? { outcome: 'failure' as const } + : {}), + ...(durationMs === undefined ? {} : { durationMs }) + } +} diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts new file mode 100644 index 00000000000..1f39a4d0ed6 --- /dev/null +++ b/src/main/acp/acp-session-update.ts @@ -0,0 +1,92 @@ +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { acpWindowUsage } from './acp-context-usage' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' +import type { AcpToolTimeline } from './acp-tool-timeline' +import type { SessionNotification } from './generated/acp-protocol.generated' + +export function acpSessionUpdate( + notification: SessionNotification, + turn: string | undefined, + at: number, + context: { + tools: AcpToolTimeline + dialect: AcpDialect + backgroundTasks: AcpBackgroundTaskTimeline + messageKey?: string + } +): ProviderTimelineEvent[] { + const update = notification.update + const join = { join: { thread: notification.sessionId, ...(turn === undefined ? {} : { turn }) } } + switch (update.sessionUpdate) { + case 'agent_message_chunk': + case 'agent_thought_chunk': + return update.content.type === 'text' + ? [ + { + type: 'text.delta', + item: context.messageKey + ? { id: context.messageKey } + : update.messageId + ? { id: `message:${update.messageId}` } + : { stream: update.sessionUpdate }, + channel: update.sessionUpdate === 'agent_thought_chunk' ? 'reasoning' : 'assistant', + text: update.content.text, + ...join + } + ] + : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] + case 'user_message_chunk': + // A live echo of the send is the send's own row. + return [] + case 'tool_call': + case 'tool_call_update': { + const { tools, dialect, backgroundTasks } = context + const events = tools.translate(update, dialect, join.join) + const tool = events[0] + const tasks = + tool && 'body' in tool && tool.body?.kind === 'tool-call' + ? (dialect.toolBackgroundTasks?.(update, tool.body) ?? []) + : [] + return [...events, ...backgroundTasks.translate(tasks, join.join)] + } + case 'plan': + return [ + { + type: 'item.update', + item: `plan:${turn ?? 'thread'}`, + body: { + kind: 'status', + presentation: 'plan-document', + text: boundInlineText( + update.entries + .map( + (entry) => + `- [${entry.status === 'completed' ? 'x' : entry.status === 'in_progress' ? '~' : ' '}] ${entry.content}` + ) + .join('\n'), + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ).text + }, + ...join + } + ] + case 'usage_update': + return [{ type: 'context.usage', usage: acpWindowUsage(update, at), ...join }] + case 'available_commands_update': + case 'current_mode_update': + case 'config_option_update': + case 'session_info_update': + // These feed commands, options and the session title, outside the timeline. + return [] + case 'plan_update': + case 'plan_removed': + case 'compaction_update': + case 'compaction_summary_chunk': + return [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] + } +} diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts new file mode 100644 index 00000000000..d2e8bb4d587 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -0,0 +1,229 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ToolCallUpdate } from './generated/acp-protocol.generated' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' +type Fixture = Awaited> + +async function taskRows(fixture: Fixture) { + return (await fixture.rig.rows()).flatMap((row) => { + const block = + row.body.kind === 'message' ? row.body.blocks.find(isBackgroundTaskBlock) : undefined + return block ? [{ row, block }] : [] + }) +} + +async function launch(fixture: Fixture) { + const frames = await readAcpFixture('s6-background') + const completion = frames.findIndex((frame) => frame.message.method === '_x.ai/task_completed') + expect(completion).toBeGreaterThan(0) + await fixture.feed(frames.slice(0, completion)) + return frames.slice(completion) +} + +function tool(fixture: Fixture, update: ToolCallUpdate, replay = false) { + const events = fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: replay ? 'historic-turn' : 'kill-turn', isReplay: replay }, + update: { sessionUpdate: 'tool_call_update', ...update } + }, + 2000 + ) + for (const event of events) { + expect(fixture.rig.assembler.apply(event).admission, update.toolCallId).toMatchObject({ + accepted: true + }) + } +} + +function taskNotice(fixture: Fixture, id: string, replay = false, completed = false) { + fixture.apply( + fixture.lane().notification( + completed ? '_x.ai/task_completed' : '_x.ai/task_backgrounded', + { + sessionId: 'session-1', + _meta: { isReplay: replay }, + update: completed + ? { sessionUpdate: 'task_completed', task_snapshot: { task_id: id, exit_code: 0 } } + : { sessionUpdate: 'task_backgrounded', task_id: id, command: 'sleep 99' } + }, + 2001 + ) + ) +} + +describe('background task outcome evidence', () => { + it('SF1 keeps multiline stdout out of the task sentence and preserves the description across frames', async () => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + const output = `PASS a.test.js\nPASS b.test.js\n${'log line\n'.repeat(3000)}` + fixture.apply( + fixture.lane().notification( + '_x.ai/task_completed', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'task_completed', + task_snapshot: { + task_id: taskId, + command: 'npm test', + output, + exit_code: 0 + } + } + }, + 3000 + ) + ) + const [task] = await taskRows(fixture) + expect(task?.block).toMatchObject({ + state: 'done', + label: 'Sleep then write bg-marker.txt', + summary: '' + }) + expect(task && backgroundTaskFallbackText(task.block)).toBe( + 'Background command "Sleep then write bg-marker.txt" finished' + ) + expect(task?.row.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Background command "Sleep then write bg-marker.txt" finished' }, + expect.objectContaining({ type: 'background-task', state: 'done' }) + ] + }) + expect(JSON.stringify(task?.row.body)).not.toContain('PASS a.test.js') + }) + + it('uses the command when the provider never gives a description', async () => { + const fixture = await openAcpFixtureRig() + taskNotice(fixture, 'unnamed') + taskNotice(fixture, 'unnamed', false, true) + const [task] = await taskRows(fixture) + expect(task && backgroundTaskFallbackText(task.block)).toBe( + 'Background command "sleep 99" finished' + ) + }) + + it('joins the captured task and queue notices into one completed row beside its launch tool', async () => { + const fixture = await openAcpFixtureRig() + const tail = await launch(fixture) + const [before] = await taskRows(fixture) + expect(before?.block.state).toBe('working') + await fixture.feed(tail) + const [after] = await taskRows(fixture) + expect(await taskRows(fixture)).toHaveLength(1) + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(after?.block).toMatchObject({ + state: 'done', + label: 'Sleep then write bg-marker.txt', + parentToolUseId: 'call-1' + }) + fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + }) + + it.each(['partial', 'eviction'] as const)( + 'SF2 settles named tasks on a completed kill call after %s', + async (boundary) => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + taskNotice(fixture, 'other-task') + const [before] = await taskRows(fixture) + tool(fixture, { + toolCallId: 'kill-1', + status: 'in_progress', + _meta: { 'x.ai/tool': { name: 'kill_command_or_subagent' } }, + rawInput: { task_id: taskId, task_ids: [taskId, 'other-task'] } + }) + await fixture.rig.rows() + if (boundary === 'eviction') { + for (let index = 0; index < 200; index += 1) { + // Pressure only the translator's snapshots: settled ones go first, the running call stays. + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'kill-turn' }, + update: { + sessionUpdate: 'tool_call', + title: 'Read file', + toolCallId: `unrelated-${index}`, + status: 'completed', + name: 'read_file', + rawInput: { content: 'x'.repeat(40000) }, + rawOutput: 'y'.repeat(40000) + } + }, + 2000 + ) + } + await fixture.rig.rows() + } + tool(fixture, { toolCallId: 'kill-1', status: 'completed' }) + const tasks = await taskRows(fixture) + expect(tasks.map((task) => task.block.state)).toEqual(['idle', 'idle']) + expect(tasks[0]?.row.itemId).toBe(before?.row.itemId) + expect(tasks[0]?.row.turnScope).toEqual(before?.row.turnScope) + expect(tasks[0] && backgroundTaskFallbackText(tasks[0].block)).toBe( + 'Background command "Sleep then write bg-marker.txt" was stopped' + ) + taskNotice(fixture, taskId) + taskNotice(fixture, taskId, false, true) + expect((await taskRows(fixture))[0]?.block.state).toBe('idle') + } + ) + + it.each(['pending', 'in_progress', 'failed'] as const)( + 'does not infer a stop from a %s kill call', + async (status) => { + const fixture = await openAcpFixtureRig() + await launch(fixture) + tool(fixture, { + toolCallId: 'kill-1', + name: 'kill_command_or_subagent', + status, + rawInput: { task_id: taskId } + }) + expect((await taskRows(fixture))[0]?.block.state).toBe('working') + } + ) + + it.each(['launch', 'notification'] as const)( + 'SF3 drops replayed %s evidence during load and accepts a later completion', + async (source) => { + const fixture = await openAcpFixtureRig() + fixture.lane().beginLoad() + if (source === 'launch') { + tool( + fixture, + { + toolCallId: 'historic-tool', + status: 'completed', + rawInput: { description: 'Historical task' }, + rawOutput: { + type: 'BackgroundTaskStarted', + task_id: taskId, + task_type: 'bash', + command: 'sleep 99' + } + }, + true + ) + } else { + // Unmarked: a task notice during a load is history too. + taskNotice(fixture, taskId) + } + fixture.finishLoad() + expect(await taskRows(fixture)).toEqual([]) + taskNotice(fixture, taskId, false, true) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + } + ) +}) diff --git a/src/main/acp/acp-timeline-background-task-results.test.ts b/src/main/acp/acp-timeline-background-task-results.test.ts new file mode 100644 index 00000000000..32becd1ffc5 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-results.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +describe('background task results', () => { + it.each([ + ['running', undefined, 'working'], + ['completed', 0, 'done'], + ['completed', undefined, 'done'], + ['failed', 1, 'blocked'], + ['stopped', undefined, 'idle'], + ['unknown', undefined, 'working'] + ] as const)( + 'settles a task from a TaskOutput result with status %s and exit code %s', + async (status, exitCode, state) => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + for (const [index, rawOutput] of [ + { type: 'BackgroundTaskStarted', task_id: 'task-1', command: 'npm test' }, + { + type: 'TaskOutput', + Result: { + task_id: 'task-1', + command: 'npm test', + status, + exit_code: exitCode, + output: 'PASS a.test.js\nPASS b.test.js\n' + } + } + ].entries()) { + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'task-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `tool-${index}`, + status: 'completed', + rawOutput + } + }, + 1000 + index + ) + ) + } + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ state, label: 'npm test' }) + expect(tasks[0]?.summary ?? '').not.toContain('PASS') + } + ) + + it('uses each structured kill result rather than inferring a stop for unsuccessful targets', async () => { + const fixture = await openAcpFixtureRig() + const lane = fixture.lane() + for (const id of ['killed-task', 'kept-task']) { + fixture.apply( + lane.notification( + '_x.ai/task_backgrounded', + { + sessionId: 'session-1', + update: { sessionUpdate: 'task_backgrounded', task_id: id, command: 'sleep 99' } + }, + 1000 + ) + ) + } + fixture.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'kill-turn' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'kill-1', + status: 'completed', + name: 'kill_command_or_subagent', + rawInput: { task_ids: ['killed-task', 'kept-task'] }, + rawOutput: { + type: 'KillTask', + MultiResult: { + results: [ + { task_id: 'killed-task', outcome: 'killed' }, + { task_id: 'kept-task', outcome: 'not_killed' } + ] + } + } + } + }, + 1100 + ) + ) + const tasks = (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) + expect(tasks.map((task) => [task.taskId, task.state])).toEqual([ + ['killed-task', 'idle'], + ['kept-task', 'working'] + ]) + }) +}) diff --git a/src/main/acp/acp-timeline-background-task-words.test.ts b/src/main/acp/acp-timeline-background-task-words.test.ts new file mode 100644 index 00000000000..c167d5c3592 --- /dev/null +++ b/src/main/acp/acp-timeline-background-task-words.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +type Fixture = Awaited> + +async function taskBlocks(fixture: Fixture) { + return (await fixture.rig.rows()).flatMap((row) => + row.body.kind === 'message' ? row.body.blocks.filter(isBackgroundTaskBlock) : [] + ) +} + +function notify(fixture: Fixture, method: string, update: Record) { + fixture.apply( + fixture + .lane() + .notification(method, { sessionId: 'session-1', _meta: { promptId: 'turn-1' }, update }, 2000) + ) +} + +describe('what a background task row says', () => { + it('describes a running recorded command by its description, not the start summary', async () => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s6-background') + const completion = frames.findIndex((frame) => frame.message.method === '_x.ai/task_completed') + await fixture.feed(frames.slice(0, completion)) + const rows = (await fixture.rig.rows()).filter( + (row) => row.body.kind === 'message' && row.body.blocks.some(isBackgroundTaskBlock) + ) + expect(rows).toHaveLength(1) + expect(rows[0]?.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Started background command "Sleep then write bg-marker.txt"' }, + expect.objectContaining({ type: 'background-task', state: 'working' }) + ] + }) + const [task] = await taskBlocks(fixture) + expect(task?.summary).toBeUndefined() + expect(JSON.stringify(rows[0]?.body)).not.toContain('Background task') + }) + + it('keeps a monitor a monitor when the agent reads its output', async () => { + const fixture = await openAcpFixtureRig() + notify(fixture, '_x.ai/task_backgrounded', { + sessionUpdate: 'task_backgrounded', + task_id: 'watch-1', + command: 'for i in 1 2 3; do echo tick $i; sleep 8; done', + monitor_description: 'Watch three tick echoes' + }) + notify(fixture, '_x.ai/task_completed', { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'watch-1', kind: 'monitor', exit_code: 0 } + }) + for (const [index, command] of [ + '[monitor] Watch three tick echoes', + 'for i in 1 2 3; do echo tick $i; sleep 8; done' + ].entries()) { + notify(fixture, 'session/update', { + sessionUpdate: 'tool_call_update', + toolCallId: `read-${index}`, + status: 'completed', + rawOutput: { + type: 'TaskOutput', + Result: { task_id: 'watch-1', command, status: 'completed', exit_code: 0 } + } + }) + } + const [task] = await taskBlocks(fixture) + expect(task).toMatchObject({ kind: 'monitor', state: 'done' }) + expect(task && backgroundTaskFallbackText(task)).toBe( + 'Background monitor "Watch three tick echoes" finished' + ) + }) + + it('reads a monitor from its output command alone, and a named kind still wins', async () => { + const fixture = await openAcpFixtureRig() + notify(fixture, 'session/update', { + sessionUpdate: 'tool_call_update', + toolCallId: 'read-1', + status: 'completed', + rawOutput: { + type: 'TaskOutput', + Result: { task_id: 'watch-2', command: '[monitor:tail] Tail the log', status: 'running' } + } + }) + expect((await taskBlocks(fixture))[0]?.kind).toBe('monitor') + notify(fixture, '_x.ai/task_completed', { + sessionUpdate: 'task_completed', + task_snapshot: { task_id: 'watch-2', task_type: 'bash', command: 'tail -f log', exit_code: 0 } + }) + expect((await taskBlocks(fixture))[0]?.kind).toBe('command') + }) +}) diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts new file mode 100644 index 00000000000..798e96cdaef --- /dev/null +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' +import { deriveNativeChatRowContent } from '../../shared/native-chat-row-content' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +const taskId = '01a10366-1e7f-7191-bcd1-fac24585851f' + +async function runningFrames() { + return (await readAcpFixture('s6-background')).filter( + (frame) => frame.message.method !== '_x.ai/task_completed' + ) +} + +async function taskRows(fixture: Awaited>) { + return (await fixture.rig.rows()).flatMap((row) => { + if (row.body.kind !== 'message') { + return [] + } + const block = row.body.blocks.find(isBackgroundTaskBlock) + return block ? [{ row, block }] : [] + }) +} + +function started(id = taskId) { + return { + sessionId: 'session-1', + update: { + sessionUpdate: 'task_backgrounded', + task_id: id, + tool_call_id: 'call-1', + command: 'sleep 15', + description: 'Wait for the background command', + output_file: '/workspace/output.log' + } + } +} + +type TaskCompletionEvidence = { + exit_code?: number | null + error?: string + output?: string + signal?: string | number | null + explicitly_killed?: boolean +} + +function completed(snapshot: TaskCompletionEvidence = { exit_code: 0 }) { + return { + sessionId: 'session-1', + update: { sessionUpdate: 'task_completed', task_snapshot: { task_id: taskId, ...snapshot } } + } +} + +describe('Grok background tasks through the shared timeline', () => { + it('renders the recorded task outside turn settlement and marks it unverifiable when the session ends', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await runningFrames()) + const [before] = await taskRows(fixture) + expect(before?.block).toMatchObject({ + taskId, + kind: 'command', + state: 'working', + parentToolUseId: 'call-1', + outputFile: '/workspace/file-1' + }) + expect(before?.row.turnScope?.kind).toBe('turn') + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual([ + 'completed', + 'completed' + ]) + expect( + (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + ).toMatchObject({ state: 'completed' }) + if (!before || before.row.body.kind !== 'message') { + throw new Error('Missing background-task row') + } + const render = deriveNativeChatRowContent(before.row.body.blocks) + expect(render.backgroundTasks).toEqual([before.block]) + expect(render.prose).toEqual([]) + + fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }]) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before.row.itemId) + expect(after?.block.state).toBe('unverifiable') + expect(after?.row.body).toMatchObject({ + blocks: [{ type: 'text', text: backgroundTaskFallbackText(after!.block) }, after!.block] + }) + }) + + it.each(['x.ai/', '_x.ai/'])( + 'joins %s task notifications and the tool result into one row that keeps its origin', + async (prefix) => { + const fixture = await openAcpFixtureRig() + const frames = await runningFrames() + await fixture.feed(frames.slice(0, 5)) + fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 1010)) + await fixture.feed(frames.slice(5)) + const [before] = await taskRows(fixture) + expect(await taskRows(fixture)).toHaveLength(1) + expect(before?.block.label).toBe('Sleep then write bg-marker.txt') + fixture.apply( + fixture + .lane() + .notification( + `${prefix}task_completed`, + completed({ exit_code: 0, output: 'Finished' }), + 2000 + ) + ) + const [after] = await taskRows(fixture) + expect(after?.row.itemId).toBe(before?.row.itemId) + expect(after?.row.turnScope).toEqual(before?.row.turnScope) + expect(after?.block).toMatchObject({ + state: 'done', + summary: '', + label: before?.block.label, + parentToolUseId: 'call-1' + }) + expect(await fixture.rig.turns()).toHaveLength(2) + fixture.apply(fixture.lane().notification(`${prefix}task_backgrounded`, started(), 2001)) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + } + ) + + it.each([ + [{ exit_code: 2, error: 'Command failed' }, 'blocked'], + [{ exit_code: null, error: 'Task failed' }, 'blocked'], + [{ exit_code: null, signal: 'SIGTERM' }, 'blocked'], + [{ exit_code: null, explicitly_killed: true }, 'idle'], + [{ exit_code: null }, 'done'] + ] as const)( + 'preserves explicit task completion and the provider outcome %j', + async (snapshot, state) => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await runningFrames()) + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(snapshot), 2000)) + expect((await taskRows(fixture))[0]?.block.state).toBe(state) + } + ) + + it('keeps the row and placement of a task whose snapshot the bound evicted', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed(await runningFrames()) + const [before] = await taskRows(fixture) + for (let index = 0; index < 130; index += 1) { + fixture.apply( + fixture + .lane() + .notification('x.ai/task_backgrounded', started(`task-${index}`), 1500 + index) + ) + } + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 2000)) + const original = (await taskRows(fixture)).find((task) => task.block.taskId === taskId) + expect(original?.row.itemId).toBe(before?.row.itemId) + expect(original?.row.turnScope).toEqual(before?.row.turnScope) + expect(original?.block.state).toBe('done') + }) + + it('places a first task notice beside its original tool after its turn ended and another began', async () => { + const fixture = await openAcpFixtureRig() + await fixture.feed((await runningFrames()).slice(0, 5)) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + update: { sessionUpdate: 'tool_call_update', toolCallId: 'call-1', status: 'completed' } + }, + 1200 + ) + ) + fixture.apply(fixture.lane().promptResult('p1:3', { stopReason: 'end_turn' }, 1300)) + const original = (await fixture.rig.rows()).find((row) => row.body.kind === 'tool-call') + fixture.apply(fixture.lane().openPrompt('next', 1400).events) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'prompt:next' }, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Next reply' } + } + }, + 1500 + ) + ) + fixture.apply(fixture.lane().notification('x.ai/task_backgrounded', started(), 1600)) + expect((await taskRows(fixture))[0]?.row.turnScope).toEqual(original?.turnScope) + expect((await fixture.rig.turns()).map((turn) => turn.state)).toEqual(['completed', 'running']) + fixture.apply(fixture.lane().notification('x.ai/task_completed', completed(), 1700)) + expect((await taskRows(fixture))[0]?.block.state).toBe('done') + expect((await fixture.rig.turns())[1]?.state).toBe('running') + }) + + it('ignores malformed, other-session and replay-only task notifications without opening turns', async () => { + const fixture = await openAcpFixtureRig() + for (const params of [ + { sessionId: 'session-1', update: { sessionUpdate: 'task_completed' } }, + { update: started().update }, + { ...started(), sessionId: 'other' }, + { ...started(), _meta: { isReplay: true } } + ]) { + fixture.apply(fixture.lane().notification('_x.ai/task_backgrounded', params, 1000)) + } + expect(await fixture.rig.rows()).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts new file mode 100644 index 00000000000..195ab2d0a91 --- /dev/null +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function dialectRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + dialect: GROK_ACP_DIALECT + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const notification = (method: string, params: unknown) => + apply(translator.notification(method, params, 1100)) + return { rig, translator, apply, notification } +} + +const question = { + sessionId: 'provider-1', + toolCallId: 'ask-1', + questions: [ + { + question: 'Choose a test?', + multiSelect: null, + options: [{ label: 'Unit', description: 'Fast tests' }, { label: 'Integration' }] + } + ] +} + +describe('ACP dialect request and turn boundaries', () => { + it('preserves provider permission labels, validates selected ids, and cancels unanswered rows on turn end', async () => { + const { rig, translator, apply } = await dialectRig() + apply(translator.openPrompt('send-1', 1000).events) + apply( + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + _meta: { promptId: 'prompt:send-1' }, + update: { sessionUpdate: 'tool_call', toolCallId: 'call-1', title: 'Write example' } + }, + 1001 + ) + ) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'Write example' }, + options: [ + { optionId: 'custom-yes', name: 'Yes, once', kind: 'allow_once' }, + { optionId: 'custom-no', name: 'Keep the file', kind: 'reject_once' } + ] + }, + 0 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + options: [ + { id: 'custom-yes', label: 'Yes, once' }, + { id: 'custom-no', label: 'Keep the file' } + ] + }) + expect(request.presentation?.reply(null)).toEqual({ outcome: { outcome: 'cancelled' } }) + expect(() => request.presentation?.reply({ kind: 'option', optionId: 'allow-once' })).toThrow( + 'offered option' + ) + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + expect((await rig.rows()).find((row) => row.body.kind === 'approval')?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + }) + + it('encodes labels and free text under the question text, validates grouped choices, and gives exact plan replies', async () => { + const { translator } = await dialectRig() + const ask = translator.request('_x.ai/ask_user_question', question, 0).presentation! + expect( + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['o2'] }] }) + ).toEqual({ outcome: 'accepted', answers: { 'Choose a test?': 'Integration' } }) + expect( + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: [], other: 'Smoke' }] }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': 'Other' }, + annotations: { 'Choose a test?': { notes: 'Smoke' } } + }) + const alias = translator.request('x.ai/ask_user_question', question, 3).presentation! + expect( + alias.reply({ + kind: 'answers', + answers: [{ questionId: 'q1', optionIds: ['o1'], other: 'Also run smoke checks' }] + }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': 'Unit' }, + annotations: { 'Choose a test?': { notes: 'Also run smoke checks' } } + }) + expect(ask.reply(null)).toEqual({ outcome: 'cancelled' }) + expect(() => ask.reply({ kind: 'answers', answers: [] })).toThrow('every offered question') + expect(() => + ask.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['o1', 'o2'] }] }) + ).toThrow('every offered question') + const grouped = translator.request( + '_x.ai/ask_user_question', + { + ...question, + questions: [ + { ...question.questions[0], multiSelect: true }, + { question: 'Another?', options: [{ label: 'Yes' }] } + ] + }, + 1 + ).presentation! + expect( + grouped.reply({ + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['o1', 'o2'] }, + { questionId: 'q2', optionIds: ['o1'] } + ] + }) + ).toEqual({ + outcome: 'accepted', + answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } + }) + const plan = translator.request( + '_x.ai/exit_plan_mode', + { 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') + }) + + it('keeps client prompt settlement on the response, and never gives an autonomous turn invented success', async () => { + const { rig, translator, apply, notification } = await dialectRig() + apply(translator.openPrompt('send-1', 1000).events) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'prompt:send-1', + stop_reason: 'end_turn' + } + }) + expect((await rig.turns())[0]!.state).toBe('running') + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + notification('session/update', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Autonomous' } + }, + _meta: { promptId: 'task-completed-1' } + }) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-1', + stop_reason: 'future_stop' + } + }) + const turns = await rig.turns() + expect(turns[0]!.outcome).toBe('cancellation') + expect(turns[1]).toMatchObject({ state: 'completed' }) + expect(turns[1]).not.toHaveProperty('outcome') + }) + + it('keeps live autonomous output while suppressing marked replay during load', async () => { + const { rig, translator, apply, notification } = await dialectRig() + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + await rig.rows() + translator.beginLoad() + notification('session/update', { + sessionId: 'provider-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } }, + _meta: { isReplay: true, promptId: 'prompt:send-1' } + }) + notification('session/update', { + sessionId: 'provider-1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Live' } }, + _meta: { promptId: 'task-completed-live' } + }) + notification('_x.ai/session_notification', { + sessionId: 'provider-1', + update: { + sessionUpdate: 'turn_completed', + prompt_id: 'task-completed-live', + stop_reason: 'end_turn' + } + }) + translator.finishLoad() + expect( + (await rig.rows()).flatMap((row) => + row.body.kind === 'message' ? [messageText(row.body)] : [] + ) + ).toEqual(['Live']) + expect(await rig.turns()).toHaveLength(2) + }) +}) diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts new file mode 100644 index 00000000000..ae9eac456e4 --- /dev/null +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -0,0 +1,168 @@ +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { expect } from 'vitest' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + legacyAgentSessionSelectedOptionId, + type AgentSessionPromptResponse +} from '../../shared/agent-session-question-answer' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import type { AcpRequestPresentation } from './acp-dialects/acp-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' +import { PromptResponseSchema } from './generated/acp-protocol.generated' + +const frameSchema = z.object({ + direction: z.enum(['in', 'out']), + process: z.string(), + message: z.object({ + id: z.union([z.string(), z.number()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional() + }) +}) +export type AcpFixtureFrame = z.infer +const replySchema = z.object({ + outcome: z.union([ + z.string(), + z.object({ outcome: z.string(), optionId: z.string().optional() }) + ]), + answers: z.record(z.string(), z.string()).optional() +}) + +export async function readAcpFixture(name: string): Promise { + const text = await readFile(new URL(`./fixtures/${name}.jsonl`, import.meta.url), 'utf8') + return text + .trim() + .split('\n') + .map((line) => frameSchema.parse(JSON.parse(line))) +} + +function fixtureAnswer( + presentation: AcpRequestPresentation, + result: unknown +): AgentSessionPromptResponse | null { + const reply = replySchema.parse(result) + if (typeof reply.outcome === 'object') { + return reply.outcome.optionId ? { kind: 'option', optionId: reply.outcome.optionId } : null + } + if (presentation.body.kind === 'approval') { + return { kind: 'option', optionId: reply.outcome } + } + return { + kind: 'answers', + answers: (presentation.body.questions ?? []).map((question) => { + const value = reply.answers?.[question.question] + return { + questionId: question.id, + optionIds: question.options + .filter((option) => option.label === value) + .map((option) => option.id) + } + }) + } +} + +/** Captured frames go through translation, assembly and the real serialized journal. */ +export async function openAcpFixtureRig() { + const rig = await openProviderTimelineRig() + let providerSessionId = 'session-1' + const translator = () => + new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT }) + let lane = translator() + const requests = new Map< + string | number, + { request: string; presentation: AcpRequestPresentation } + >() + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const feed = async (frames: AcpFixtureFrame[]) => { + for (const [index, frame] of frames.entries()) { + const { message, direction } = frame + const at = 1000 + index + if (direction === 'out' && message.method === 'session/load') { + lane.beginLoad() + } else if (direction === 'out' && message.method === 'session/prompt') { + const session = z.object({ sessionId: z.string() }).parse(message.params) + if (providerSessionId !== session.sessionId) { + providerSessionId = session.sessionId + lane = translator() + } + apply(lane.openPrompt(`${frame.process}:${message.id}`, at).events) + } else if (direction === 'in' && message.method && message.id !== undefined) { + const request = lane.request(message.method, message.params, message.id) + apply(request.events) + const opened = request.events.find((event) => event.type === 'request.open') + if (opened && request.presentation) { + requests.set(message.id, { request: opened.request, presentation: request.presentation }) + } + } else if (direction === 'out' && message.id !== undefined && !message.method) { + const request = requests.get(message.id) + if (!request) { + continue + } + const response = fixtureAnswer(request.presentation, message.result) + expect(request.presentation.reply(response)).toEqual(message.result) + const row = (await rig.rows()).find( + (item) => + item.body.kind === request.presentation.body.kind && + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + const identity = row ? parseAgentJournalItemKey(row.itemId) : null + if (!row || !identity || (row.body.kind !== 'approval' && row.body.kind !== 'question')) { + throw new Error('Missing request row') + } + await rig.journal.appendItem( + identity, + { + ...row.body, + resolution: { + state: response ? 'resolved' : 'cancelled', + selectedOptionId: + response?.kind === 'option' + ? response.optionId + : row.body.kind === 'question' && response?.kind === 'answers' + ? legacyAgentSessionSelectedOptionId(row.body, response.answers) + : null, + ...(response?.kind === 'answers' ? { answers: response.answers } : {}), + resolvedBy: 'fixture-client', + resolvedAt: at + } + }, + { fence: 1, turnScope: row.turnScope ?? { kind: 'thread' } } + ) + requests.delete(message.id) + } else if (direction === 'in' && message.method) { + apply(lane.notification(message.method, message.params, at)) + } else if (direction === 'in') { + const models = z.object({ models: z.unknown() }).safeParse(message.result) + if (models.success) { + apply(lane.contextModels(models.data.models, at)) + } + const result = PromptResponseSchema.safeParse(message.result) + if (result.success) { + apply(lane.promptResult(`${frame.process}:${message.id}`, result.data, at)) + } + } + } + return rig.rows() + } + return { + rig, + apply, + feed, + lane: () => lane, + /** A new provider child: the sweep, a new generation's assembler and a new translator. */ + restart: async () => { + await rig.restart() + lane = translator() + }, + finishLoad: () => lane.finishLoad() + } +} diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts new file mode 100644 index 00000000000..b7f6fc8a666 --- /dev/null +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -0,0 +1,356 @@ +import { applyPatch } from 'diff' +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { + closeProviderTimelineRigs, + messageText, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpAgentError } from './acp-errors' +import { AcpTimelineTranslator, acpTurnEnd } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function genericRig(options: { agentName?: string } = {}) { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1', ...options }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (update: unknown) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100)) + return { rig, translator, apply, update } +} + +describe('generic ACP translation', () => { + it('keeps named text across non-text barriers, splits anonymous text, and assembles reasoning', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text: 'First ' } + }) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Read', status: 'pending', priority: 'medium' }] + }) + update({ + sessionUpdate: 'agent_message_chunk', + messageId: 'm1', + content: { type: 'text', text: 'reply' } + }) + update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking' } }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Before' } }) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Read', status: 'completed', priority: 'medium' }] + }) + update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'After' } }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const rows = await rig.rows() + expect( + rows.flatMap((row) => (row.body.kind === 'message' ? [messageText(row.body)] : [])) + ).toEqual(['First reply', 'Thinking', 'Before', 'After']) + expect(rows.find((row) => messageText(row.body) === 'Thinking')?.body).toMatchObject({ + role: 'reasoning' + }) + expect( + rows.flatMap((row) => + row.body.kind === 'status' && row.body.presentation === 'plan-document' + ? [row.body.text] + : [] + ) + ).toEqual(['- [x] Read']) + }) + + it('merges tool snapshots, bounds input/output and emits a separately keyed valid diff', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + const oldText = 'same\nbefore\n' + const newText = 'same\nafter\n' + update({ + sessionUpdate: 'tool_call', + toolCallId: 'tool-1', + title: 'Edit', + name: 'edit_file', + rawInput: { text: 'x'.repeat(40000) }, + status: 'pending' + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'in_progress', + content: [{ type: 'content', content: { type: 'text', text: 'progress' } }] + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'completed', + content: [ + { type: 'content', content: { type: 'text', text: 'y'.repeat(40000) } }, + { type: 'diff', path: 'file.ts', oldText, newText } + ] + }) + // A settled journal row refuses a late provider update that says it is running again. + update({ sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'in_progress' }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const body = (await rig.rows()).find( + (row) => row.body.kind === 'tool-call' && row.body.callId === 'tool-1' + )?.body + expect(body).toMatchObject({ + name: 'edit_file', + state: 'completed', + input: { truncated: true }, + output: { truncated: true, byteLength: 40000 } + }) + const diff = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body + if (diff?.kind !== 'diff') { + throw new Error('Missing diff') + } + expect(diff.path).toBe('file.ts') + expect(applyPatch(oldText, diff.patch.head)).toBe(newText) + }) + + it('keeps context occupancy from usage_update instead of aggregate prompt usage', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ sessionUpdate: 'usage_update', used: 42, size: 100 }) + apply( + translator.promptResult( + 'send-1', + { + stopReason: 'end_turn', + usage: { + inputTokens: 50, + outputTokens: 5, + totalTokens: 55, + cachedReadTokens: 20, + cachedWriteTokens: 10 + } + }, + 1200 + ) + ) + const turn = (await rig.turns())[0]! + expect(turn.contextUsage?.window).toEqual({ tokens: 100, capturedAt: 1100 }) + expect(turn.contextUsage?.used).toMatchObject({ + kind: 'estimate', + usage: { + inputTokens: 42, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0 + } + }) + if (turn.contextUsage?.used?.kind !== 'estimate') { + throw new Error('Missing usage') + } + expect(contextTokensFromUsage(turn.contextUsage.used.usage)).toBe(42) + }) + + it.each(['refusal', 'max_tokens', 'max_turn_requests'] as const)( + 'records %s as provider failure without an extra status row', + async (stopReason) => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason }, 1200)) + expect((await rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + (await rig.rows()).some( + (row) => + row.body.kind === 'status' && row.body.providerFrame?.kind === `prompt:${stopReason}` + ) + ).toBe(false) + } + ) + + it.each([ + [undefined, 'The agent ended this turn with an error.'], + ['Agent X', 'Agent X ended this turn with an error.'] + ])( + "writes a failed turn row in the agent's words, or names the agent (%s) without any", + async (agentName, fallback) => { + const { rig, translator, apply } = await genericRig(agentName ? { agentName } : {}) + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1100)) + apply(translator.openPrompt('send-2', 1200).events) + apply(translator.promptFailed('send-2', new AcpAgentError(-32603, 'Upstream failed'), 1300)) + apply(translator.openPrompt('send-3', 1400).events) + apply(translator.promptFailed('send-3', new AcpAgentError(-32603, ''), 1500)) + const rows = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(rows.map((row) => row.body)).toEqual([ + { kind: 'status', tone: 'error', text: 'Upstream failed' }, + { kind: 'status', tone: 'error', text: fallback } + ]) + expect((await rig.turns()).map((turn) => turn.outcome)).toEqual([ + 'success', + 'failure', + 'failure' + ]) + } + ) + + it('keeps unknown verdicts unknown and preserves unknown frames without opening a turn', async () => { + const { rig, translator, apply } = await genericRig() + expect(acpTurnEnd('turn', 'future_stop', 1200)).not.toHaveProperty('outcome') + apply( + translator.sessionEvent( + { + kind: 'unrecognized', + sessionId: 'provider-1', + raw: { + sessionId: 'provider-1', + update: { sessionUpdate: 'future_event', secret: 'z'.repeat(40000) } + } + }, + 1100 + ) + ) + const rows = await rig.rows() + expect(rows).toHaveLength(1) + expect(rows[0]!.body).toMatchObject({ + kind: 'status', + providerFrame: { payload: { truncated: true } } + }) + expect(rows[0]!.turnScope).toEqual({ kind: 'thread' }) + expect(await rig.turns()).toEqual([]) + }) + + it('ignores live user echoes and options metadata, and rejects cross-session requests', async () => { + const { rig, translator, update } = await genericRig() + update({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'echo' } }) + update({ sessionUpdate: 'available_commands_update', availableCommands: [] }) + expect(await rig.rows()).toEqual([]) + expect( + translator.notification( + 'session/update', + { + sessionId: 'other', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'other' } } + }, + 1100 + ) + ).toEqual([]) + expect(() => + translator.request('session/request_permission', { sessionId: 'other' }, 0) + ).toThrow('unknown session') + expect(translator.request('future/request', { sessionId: 'provider-1' }, 0)).toMatchObject({ + events: [{ type: 'provider.frame' }] + }) + }) + + it('drops unmarked load history, except context usage', () => { + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + translator.beginLoad() + const events = [ + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'User' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'History' } }, + { sessionUpdate: 'tool_call', toolCallId: 'old-tool', title: 'Read', status: 'completed' }, + { sessionUpdate: 'usage_update', used: 5000, size: 256000 } + ].flatMap((update) => + translator.notification('session/update', { sessionId: 'provider-1', update }, 1100) + ) + expect(events).toEqual([ + expect.objectContaining({ type: 'context.usage', join: { thread: 'provider-1' } }) + ]) + }) + + it('drops marked replay outside a load and uses typed standard events', async () => { + const { rig, translator, apply } = await genericRig() + expect( + translator.notification( + 'session/update', + { + sessionId: 'provider-1', + _meta: { isReplay: true }, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'History' } + } + }, + 1100 + ) + ).toEqual([]) + apply(translator.openPrompt('send-1', 1000).events) + apply( + translator.sessionEvent( + { + kind: 'known', + notification: { + sessionId: 'provider-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'typed' } + } + } + }, + 1100 + ) + ) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + expect((await rig.rows()).some((row) => messageText(row.body) === 'typed')).toBe(true) + }) + it('evicts completed snapshots during long turns while preserving active tool input', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'tool_call', + toolCallId: 'active', + title: 'Active', + rawInput: { important: true } + }) + for (let index = 0; index < 140; index += 1) { + update({ + sessionUpdate: 'tool_call', + toolCallId: `done-${index}`, + title: 'Read', + status: 'pending' + }) + update({ + sessionUpdate: 'tool_call_update', + toolCallId: `done-${index}`, + status: 'completed' + }) + await rig.rows() + } + update({ sessionUpdate: 'tool_call_update', toolCallId: 'active', status: 'failed' }) + apply(translator.promptResult('send-1', { stopReason: 'cancelled' }, 1200)) + expect( + (await rig.rows()).find( + (row) => row.body.kind === 'tool-call' && row.body.callId === 'active' + )?.body + ).toMatchObject({ + name: 'Active', + input: { important: true }, + state: 'failed' + }) + expect( + (await rig.rows()).filter( + (row) => row.body.kind === 'tool-call' && row.body.state === 'completed' + ) + ).toHaveLength(140) + }) + + it('keeps a valid replacement patch when a diff exceeds the edit-search budget', async () => { + const { rig, update } = await genericRig() + const oldText = Array.from({ length: 520 }, (_, index) => `old-${index}\n`).join('') + const newText = Array.from({ length: 520 }, (_, index) => `new-${index}\n`).join('') + update({ + sessionUpdate: 'tool_call', + toolCallId: 'edit', + title: 'Edit', + status: 'completed', + content: [{ type: 'diff', path: 'file.ts', oldText, newText }] + }) + const body = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body + if (body?.kind !== 'diff') { + throw new Error('Missing diff') + } + expect(body.patch.truncated).toBe(false) + expect(applyPatch(oldText, body.patch.head)).toBe(newText) + }) +}) diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts new file mode 100644 index 00000000000..722ccfb1a8f --- /dev/null +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + GENERATION, + messageText, + openProviderTimelineRig, + providerItemId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +describe('ACP joins through the assembler', () => { + it('keeps provider message and tool ids apart across ACP sessions', async () => { + const rig = await openProviderTimelineRig() + for (const sessionId of ['session-a', 'session-b']) { + const translator = new AcpTimelineTranslator({ sessionId }) + for (const update of [ + { + sessionUpdate: 'agent_message_chunk', + messageId: 'same-message', + content: { type: 'text', text: sessionId } + }, + { + sessionUpdate: 'tool_call', + toolCallId: 'same-tool', + title: sessionId, + status: 'completed' + } + ]) { + for (const event of translator.notification( + 'session/update', + { sessionId, update }, + 1100 + )) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + } + const rows = await rig.rows() + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['session-a', 'session-b']) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.name] : []))).toEqual([ + 'session-a', + 'session-b' + ]) + }) + + it('keeps a request id a new child reuses apart from the old child', async () => { + const rig = await openProviderTimelineRig() + const params = { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'Run?' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] + } + const ask = () => { + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + for (const event of [ + ...translator.openPrompt('send-1', 1000).events, + ...translator.request('session/request_permission', params, 0).events + ]) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + ask() + await rig.restart() + ask() + const request = 'session/request_permission:0' + const approvals = (await rig.rows()).filter((row) => row.body.kind === 'approval') + expect(approvals.map((row) => row.itemId)).toEqual([ + providerItemId('request', request, { generation: GENERATION }), + providerItemId('request', request, { generation: 'gen-2' }) + ]) + // The sweep cancelled the dead child's pending request; the new child's waits. + expect( + approvals.map((row) => row.body.kind === 'approval' && row.body.resolution.state) + ).toEqual(['cancelled', 'pending']) + }) +}) diff --git a/src/main/acp/acp-timeline-open-enums.test.ts b/src/main/acp/acp-timeline-open-enums.test.ts new file mode 100644 index 00000000000..08907152afa --- /dev/null +++ b/src/main/acp/acp-timeline-open-enums.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function genericRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ sessionId: 'provider-1' }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (update: unknown) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update }, 1100)) + return { rig, translator, apply, update } +} + +// The protocol's enums are open: a value newer than this build reads as known and falls back. +describe('ACP values newer than this build', () => { + it('keeps a tool with a newer kind or status as a tool row in its last known state', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'Open page', + kind: 'browse', + status: 'pending' + }) + update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'paused' }) + const running = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + expect(running).toMatchObject({ name: 'Open page', state: 'running' }) + update({ sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + const rows = (await rig.rows()).filter((row) => row.body.kind !== 'turn') + expect(rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ kind: 'tool-call', state: 'completed' }) + ]) + }) + + it('reads a plan entry with a newer status or priority as not done', async () => { + const { rig, translator, apply, update } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + update({ + sessionUpdate: 'plan', + entries: [{ content: 'Wait', status: 'blocked', priority: 'urgent' }] + }) + apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) + expect( + (await rig.rows()).filter((row) => row.body.kind !== 'turn').map((row) => row.body) + ).toEqual([expect.objectContaining({ presentation: 'plan-document', text: '- [ ] Wait' })]) + }) + + it('ends a turn with a newer stop reason without inventing an outcome', async () => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + apply(translator.promptResult('send-1', { stopReason: 'paused_for_review' }, 1200)) + const [turn] = await rig.turns() + expect(turn).toMatchObject({ state: 'completed' }) + expect(turn).not.toHaveProperty('outcome') + expect((await rig.rows()).filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('shows a permission the runtime could only partly read, with what it could read', async () => { + const { rig, translator, apply } = await genericRig() + apply(translator.openPrompt('send-1', 1000).events) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 't1', title: 42 }, + options: [{ optionId: 'allow', kind: 'allow_for_project' }, { name: 'no id' }] + }, + 7 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + kind: 'approval', + title: 'Permission requested', + options: [{ id: 'allow', label: 'allow' }] + }) + expect(request.presentation?.reply({ kind: 'option', optionId: 'allow' })).toEqual({ + outcome: { outcome: 'selected', optionId: 'allow' } + }) + expect((await rig.rows()).map((row) => row.body.kind)).toEqual(['turn', 'approval']) + expect(() => + translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: {}, + options: [{ optionId: 'allow', kind: 'allow_once' }] + }, + 8 + ) + ).toThrow('Invalid ACP permission request') + }) +}) diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts new file mode 100644 index 00000000000..e4402b37e86 --- /dev/null +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(closeProviderTimelineRigs) + +describe('recorded ACP traffic through the journal', () => { + it('assembles basic chunks and partial tool calls with stable identities and usage', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s1-basic')) + const tools = rows.filter((row) => row.body.kind === 'tool-call') + expect(tools).toHaveLength(2) + expect(tools.map((row) => row.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'list_dir', + callId: 'call-1', + state: 'completed', + output: expect.objectContaining({ truncated: false }) + }), + expect.objectContaining({ name: 'read_file', callId: 'call-2', state: 'completed' }) + ]) + ) + expect(rows.filter((row) => row.body.kind === 'message')).toHaveLength(1) + expect((await fixture.rig.turns())[0]).toMatchObject({ + state: 'completed', + outcome: 'success', + durationMs: 5289, + contextUsage: { used: { kind: 'estimate' } } + }) + expect(tools.every((tool) => tool.turnScope?.kind === 'turn')).toBe(true) + }) + + it('shows permission options verbatim and keeps allow and reject answers after turn settlement', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s2-permission')) + const approvals = rows.flatMap((row) => (row.body.kind === 'approval' ? [row.body] : [])) + expect(approvals).toHaveLength(2) + expect(approvals[0]!.options.map((option) => option.id)).toEqual([ + 'allow-edits-session', + 'allow-once', + 'reject-once' + ]) + expect(approvals.map((body) => body.resolution)).toMatchObject([ + { state: 'resolved', selectedOptionId: 'allow-once' }, + { state: 'resolved', selectedOptionId: 'reject-once' } + ]) + expect((await fixture.rig.turns()).map((turn) => turn.outcome)).toEqual([ + 'success', + 'cancellation' + ]) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'completed', + 'failed' + ]) + }) + + it('settles the abandoned in_progress tool on cancel and accepts the next prompt', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s3-cancel')) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'failed' + ]) + expect((await fixture.rig.turns()).map((turn) => [turn.state, turn.outcome])).toEqual([ + ['interrupted', 'cancellation'], + ['completed', 'success'] + ]) + expect(rows.some((row) => messageText(row.body)?.includes('ok'))).toBe(true) + }) + + it('drops load history, except its context usage', async () => { + const fixture = await openAcpFixtureRig() + const frames = await readAcpFixture('s4-resume') + await fixture.feed(frames.filter((frame) => frame.process === 'A')) + const before = await fixture.rig.rows() + await fixture.restart() + const loading = frames.filter((frame) => frame.process === 'B') + const nextPrompt = loading.findIndex((frame) => frame.message.method === 'session/prompt') + await fixture.feed(loading.slice(0, nextPrompt)) + fixture.apply( + fixture.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true }, + update: { sessionUpdate: 'usage_update', used: 5000, size: 256000 } + }, + 1900 + ) + ) + fixture.finishLoad() + const after = await fixture.rig.rows() + const items = (rows: typeof before) => rows.filter((row) => row.body.kind !== 'turn') + expect(items(after)).toEqual(items(before)) + const [turn] = await fixture.rig.turns() + expect(turn?.contextUsage?.window?.tokens).toBe(256000) + expect( + turn?.contextUsage?.used?.kind === 'estimate' && + contextTokensFromUsage(turn.contextUsage.used.usage) + ).toBe(5000) + await fixture.feed(loading.slice(nextPrompt)) + expect(await fixture.rig.turns()).toHaveLength(2) + }) + + it('answers questions and plan approval with the recorded exact reply shapes', 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((await fixture.rig.turns())[0]!.outcome).toBe('success') + }) + + it('opens and ends the background completion turn without a client prompt', async () => { + const fixture = await openAcpFixtureRig() + const rows = await fixture.feed(await readAcpFixture('s6-background')) + expect((await fixture.rig.turns()).map((turn) => [turn.state, turn.outcome])).toEqual([ + ['completed', 'success'], + ['completed', 'success'] + ]) + const messages = rows.filter( + (row) => row.body.kind === 'message' && row.body.role === 'assistant' + ) + expect(messages).toHaveLength(2) + expect(messageText(messages[1]!.body)).toBe('The background') + expect(messages[0]!.turnScope).not.toEqual(messages[1]!.turnScope) + expect(rows.flatMap((row) => (row.body.kind === 'tool-call' ? [row.body.state] : []))).toEqual([ + 'completed' + ]) + }) +}) diff --git a/src/main/acp/acp-timeline-recovery.test.ts b/src/main/acp/acp-timeline-recovery.test.ts new file mode 100644 index 00000000000..6bc11b59475 --- /dev/null +++ b/src/main/acp/acp-timeline-recovery.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { contextTokensFromUsage } from '../../shared/agent-session-context-usage' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support' +import { AcpTimelineTranslator } from './acp-timeline-translator' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +afterEach(closeProviderTimelineRigs) + +describe('ACP reviewed traffic and recovery', () => { + it('replays every notification in the full recording without extension status rows or persisted signatures', async () => { + const f = await openAcpFixtureRig() + const frames = await readAcpFixture('s1-full-notifications') + expect(frames).toHaveLength(142) + const rows = await f.feed(frames) + expect(rows.filter((row) => row.body.kind === 'status')).toEqual([]) + expect(rows.filter((row) => row.body.kind === 'tool-call')).toHaveLength(2) + expect(rows.filter((row) => row.body.kind === 'message')).toHaveLength(2) + expect(await f.rig.turns()).toHaveLength(1) + expect(JSON.stringify(rows)).not.toContain('signature') + const context = (await f.rig.turns())[0]?.contextUsage + expect(context?.window?.tokens).toBe(256000) + expect(context?.used?.kind === 'estimate' && contextTokensFromUsage(context.used.usage)).toBe( + 34622 + ) + }) + + it('uses the last of nine model calls instead of the aggregate turn usage', async () => { + const f = await openAcpFixtureRig() + await f.feed(await readAcpFixture('s5-plan-approved')) + const usage = (await f.rig.turns())[0]?.contextUsage?.used + expect(usage?.kind === 'estimate' && contextTokensFromUsage(usage.usage)).toBe(39190) + }) + + it.each([undefined, null])( + 'accepts an empty plan request and replies with the valid approval shape (%s)', + async (planContent) => { + const f = await openAcpFixtureRig() + for (const method of ['_x.ai/exit_plan_mode', 'x.ai/exit_plan_mode']) { + const presentation = 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' + }) + } + } + ) + + it('degrades tool snapshots under forty large concurrent tools without throwing or losing later updates', () => { + const lane = new AcpTimelineTranslator({ sessionId: 'session-1', dialect: GROK_ACP_DIALECT }) + const big = 'x"\\'.repeat(12000) + for (let index = 0; index < 40; index += 1) { + expect(() => + lane.notification( + 'session/update', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call', + toolCallId: `t${index}`, + title: 'Write', + status: 'in_progress', + rawInput: { content: big }, + rawOutput: big + } + }, + index + ) + ).not.toThrow() + } + for (let index = 0; index < 40; index += 1) { + expect( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `t${index}`, + status: 'completed' + } + }, + 50 + index + ) + ).toMatchObject([{ type: 'item.close', body: { state: 'completed' } }]) + } + }) + + it('ignores stray replay and both extension method aliases by default', async () => { + const f = await openAcpFixtureRig() + for (const method of ['_x.ai/session_notification', 'x.ai/session_notification']) { + expect( + f.lane().notification( + method, + { + sessionId: 'session-1', + update: { sessionUpdate: 'response_completed', signature: 'opaque' } + }, + 1000 + ) + ).toEqual([]) + } + expect(f.lane().notification('_unknown/extension', { sessionId: 'session-1' }, 1000)).toEqual( + [] + ) + expect( + f.lane().notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { isReplay: true }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Old' } } + }, + 1000 + ) + ).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts new file mode 100644 index 00000000000..aa7e396c136 --- /dev/null +++ b/src/main/acp/acp-timeline-requests.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +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' +import { AcpRpcError } from './acp-errors' +import { readAcpPermissionRequest } from './acp-permission-requests' + +export const pendingAcpResolution = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { + // The runtime already read this request and reported any field it dropped. + const request = readAcpPermissionRequest(params, () => {}) + if (!request) { + throw new AcpRpcError(-32602, 'Invalid ACP permission request') + } + const { toolCall, options } = request + return { + body: { + kind: 'approval', + title: toolCall.title ?? 'Permission requested', + detail: null, + options: options.map((option) => ({ id: option.optionId, label: option.name })), + resolution: pendingAcpResolution + }, + reply: (response) => { + if (response === null) { + return { outcome: { outcome: 'cancelled' } } + } + if ( + response.kind !== 'option' || + !options.some((option) => option.optionId === response.optionId) + ) { + throw new AcpRpcError(-32602, 'Permission answer must select an offered option') + } + return { outcome: { outcome: 'selected', optionId: response.optionId } } + } + } +} + +const requestSessionSchema = z.object({ sessionId: z.string() }) +const requestToolSchema = z.object({ + toolCallId: z.string().optional(), + toolCall: z.object({ toolCallId: z.string() }).optional() +}) + +export function translateAcpRequest( + method: string, + params: unknown, + id: string | number, + options: { + sessionId: string + dialect: AcpDialect + tools: AcpToolTimeline + } +): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + 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 }) } + if (!presentation) { + return { + events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] + } + } + return { + presentation, + events: [ + { + type: 'request.open', + request: `${method}:${JSON.stringify(id)}`, + body: presentation.body, + join + } + ] + } +} diff --git a/src/main/acp/acp-timeline-routing.test.ts b/src/main/acp/acp-timeline-routing.test.ts new file mode 100644 index 00000000000..4ea6c30df29 --- /dev/null +++ b/src/main/acp/acp-timeline-routing.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + messageText +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' +import { AcpAgentError } from './acp-errors' + +const chunk = (promptId: string, text: string) => ({ + sessionId: 'session-1', + _meta: { promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } +}) +const done = (promptId: string, stopReason = 'end_turn') => ({ + sessionId: 'session-1', + update: { sessionUpdate: 'turn_completed', prompt_id: promptId, stop_reason: stopReason } +}) +afterEach(closeProviderTimelineRigs) + +describe('ACP exact prompt identity', () => { + it.each(['task-completed-2', 'subagent-completed-2', 'notifications-9', 'future-wake'])( + 'opens %s independently of a queued user prompt', + async (wake) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + expect(prompt.events).toEqual([]) + f.apply(lane.notification('session/update', chunk(wake, 'background'), 1001)) + f.apply(lane.notification('_x.ai/session_notification', done(wake), 1002)) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1003)) + f.apply(lane.notification('_x.ai/session_notification', done(prompt.promptId), 1004)) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1005)) + const rows = await f.rig.rows() + const messages = rows.filter((row) => row.body.kind === 'message') + expect(messages.map((row) => messageText(row.body))).toEqual(['background', 'user reply']) + expect(messages[0]?.turnScope).not.toEqual(messages[1]?.turnScope) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success']) + } + ) + + it('keeps a streaming background turn open when the user sends and scopes its permission to the tool', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + f.apply(lane.notification('session/update', chunk('background', 'first'), 1000)) + f.apply( + lane.notification( + 'session/update', + { + sessionId: 'session-1', + _meta: { promptId: 'background' }, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'bg-tool', + title: 'Write', + status: 'pending' + } + }, + 1001 + ) + ) + const prompt = lane.openPrompt('c1', 1002) + f.apply(prompt.events) + const request = lane.request( + 'session/request_permission', + { + sessionId: 'session-1', + toolCall: { toolCallId: 'bg-tool', title: 'Write' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }] + }, + 0 + ) + expect(request.events[0]).toMatchObject({ join: { turn: 'background' } }) + f.apply(request.events) + f.apply(lane.notification('session/update', chunk('background', 'second'), 1003)) + f.apply(lane.notification('_x.ai/session_notification', done('background'), 1004)) + f.apply( + lane.notification( + '_x.ai/queue/changed', + { sessionId: 'session-1', runningPromptId: prompt.promptId }, + 1005 + ) + ) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'user reply'), 1006)) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1007)) + const rows = await f.rig.rows() + expect(rows.find((row) => row.body.kind === 'approval')?.turnScope).toEqual( + rows.find((row) => row.body.kind === 'tool-call')?.turnScope + ) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['success', 'success']) + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['first', 'second', 'user reply']) + }) + + it.each(['error', 'rate_limit'])( + 'reports %s before an RPC rejection and accepts the next prompt', + async (stopReason) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('session/update', chunk(prompt.promptId, 'partial'), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', done(prompt.promptId, stopReason), 1002) + ) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Provider rejected prompt'), 1003)) + const next = lane.openPrompt('c2', 1004) + f.apply(next.events) + f.apply(lane.notification('session/update', chunk(next.promptId, 'next'), 1005)) + expect(lane.promptResult('c1', { stopReason: 'end_turn' }, 1006)).toEqual([]) + f.apply(lane.promptResult('c2', { stopReason: 'end_turn' }, 1007)) + expect((await f.rig.turns()).map((turn) => turn.outcome)).toEqual(['failure', 'success']) + } + ) + + it('settles an RPC-only failure even if the provider never streamed a frame', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + lane.openPrompt('c1', 1000) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Rejected'), 1001)) + expect((await f.rig.turns())[0]).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(() => lane.openPrompt('c2', 1002)).not.toThrow() + }) +}) diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts new file mode 100644 index 00000000000..9f927bceed0 --- /dev/null +++ b/src/main/acp/acp-timeline-translator.ts @@ -0,0 +1,263 @@ +import { z } from 'zod' +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 { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' +import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' +import { translateAcpRequest } from './acp-timeline-requests' +import { acpSessionUpdate } from './acp-session-update' +import { AcpToolTimeline } from './acp-tool-timeline' +import { AcpTurnFailures, acpPromptErrorDetail } from './acp-turn-failures' +import { AcpTurnMessages } from './acp-turn-messages' +import type { PromptResponse } from './generated/acp-protocol.generated' + +export { acpTurnEnd } from './acp-prompt-turns' + +const requestSessionSchema = z.object({ sessionId: z.string() }) +const SUBSTANTIVE_UPDATES = [ + 'user_message_chunk', + 'agent_message_chunk', + 'agent_thought_chunk', + 'tool_call', + 'tool_call_update', + 'plan' +] + +export type AcpTimelineTranslatorOptions = { + sessionId: string + dialect?: AcpDialect + /** The agent's display name, for a failed turn the provider gave no words for. */ + agentName?: string +} + +/** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long + * as its provider child, so it never reads the journal. */ +export class AcpTimelineTranslator { + private readonly dialect: AcpDialect + private readonly prompts: AcpPromptTurns + private readonly tools = new AcpToolTimeline() + private readonly backgroundTasks: AcpBackgroundTaskTimeline + private readonly messages = new AcpTurnMessages() + private readonly started = new BoundedMap({ maxEntries: 128 }) + private readonly failures: AcpTurnFailures + private loading = false + private readonly context = new AcpContextTimeline() + private activeTurn?: string + + constructor(private readonly options: AcpTimelineTranslatorOptions) { + this.dialect = options.dialect ?? GENERIC_ACP_DIALECT + this.failures = new AcpTurnFailures(options.sessionId, this.dialect, options.agentName) + this.backgroundTasks = new AcpBackgroundTaskTimeline((callId) => this.tools.turn(callId)) + this.prompts = new AcpPromptTurns( + options.sessionId, + this.dialect.injectedPromptIdentity === true + ) + } + + /** The host injects promptId as session/prompt._meta.promptId (and requestId). */ + openPrompt( + clientMessageId: string, + at: number + ): { promptId: string; events: ProviderTimelineEvent[] } { + if (this.loading) { + throw new Error('ACP prompt overlaps a prompt or load') + } + return this.prompts.open(clientMessageId, at) + } + + promptResult( + clientMessageId: string, + result: PromptResponse, + at: number + ): ProviderTimelineEvent[] { + 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) + 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. + return ended?.clientMessageId === clientMessageId && this.failures.has(ended.turn) + ? this.failures.row(ended.turn, detail) + : [] + } + return this.finishPrompt(clientMessageId, 'error', at, detail) + } + + private finishPrompt( + clientMessageId: string, + stopReason: string, + at: number, + failureDetail?: string + ): ProviderTimelineEvent[] { + const prompt = this.prompts.current + if (prompt?.clientMessageId !== clientMessageId) { + return [] + } + const events = this.start(prompt.turn, at) + events.push(...this.endTurn(prompt.turn, stopReason, at, prompt.durationMs, failureDetail)) + return events + } + + contextModels(models: unknown, at: number): ProviderTimelineEvent[] { + return this.context.models(models, at, this.dialect, { thread: this.options.sessionId }) + } + + /** The history `session/load` replays is dropped, except what it says about the context window. */ + beginLoad(): void { + if (this.prompts.current || this.loading) { + throw new Error('ACP load overlaps a prompt or load') + } + this.loading = true + } + + finishLoad(): void { + this.loading = false + } + + sessionEvent(event: AcpSessionEvent, at: number): ProviderTimelineEvent[] { + return this.notification( + 'session/update', + event.kind === 'known' ? event.notification : event.raw, + at + ) + } + + notification(method: string, params: unknown, at: number): ProviderTimelineEvent[] { + const session = requestSessionSchema.safeParse(params) + if (session.success && session.data.sessionId !== this.options.sessionId) { + return [] + } + const extension = this.dialect.notification?.(method, params, at) + if (extension?.disposition === 'ignore') { + return [] + } + if (method !== 'session/update' && !extension) { + return [] + } + const envelope = acpNotificationEnvelopeSchema.safeParse(params) + const read = method === 'session/update' ? readAcpSessionEvent(params) : null + const standard = read?.kind === 'known' ? read.notification : undefined + const update = standard?.update + const markedReplay = + (envelope.success && envelope.data._meta?.isReplay === true) || extension?.replay === true + if (markedReplay || (extension?.replay === undefined && this.loading)) { + return this.context.history(update, extension?.usage, at, { thread: this.options.sessionId }) + } + const providerTurn = extension?.turn + const opens = + (update !== undefined && SUBSTANTIVE_UPDATES.includes(update.sessionUpdate)) || + extension?.end !== undefined || + extension?.started === true + const turn = + providerTurn ?? (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn) + const events: ProviderTimelineEvent[] = [] + if (turn && opens) { + events.push(...this.start(turn, extension?.at ?? at)) + } + const join = { thread: this.options.sessionId, ...(turn === undefined ? {} : { turn }) } + if (extension?.backgroundTasks) { + events.push(...this.backgroundTasks.translate(extension.backgroundTasks, join)) + } + if (extension?.usage) { + events.push(...this.context.update(extension.usage, join)) + } + if (extension?.failureDetail && turn) { + events.push(...this.failures.row(turn, extension.failureDetail)) + } + const end = extension?.end + if (end && turn) { + if ( + this.prompts.current?.turn === turn && + ['end_turn', 'cancelled'].includes(end.stopReason) + ) { + this.prompts.current.durationMs = end.durationMs + } else { + events.push(...this.endTurn(turn, end.stopReason, at, end.durationMs, end.failureDetail)) + } + return events + } + if (standard) { + const messageKey = + turn && this.dialect.injectedPromptIdentity + ? this.messages.key(turn, standard.update) + : undefined + return [ + ...events, + ...acpSessionUpdate(standard, turn, at, { + tools: this.tools, + dialect: this.dialect, + backgroundTasks: this.backgroundTasks, + messageKey + }) + ] + } + if (method === 'session/update') { + events.push({ type: 'provider.frame', frameKind: method, payload: params, join }) + } + return events + } + + request( + method: string, + params: unknown, + id: string | number + ): { events: ProviderTimelineEvent[]; presentation?: AcpRequestPresentation } { + return translateAcpRequest(method, params, id, { + sessionId: this.options.sessionId, + dialect: this.dialect, + tools: this.tools + }) + } + + /** A failed end writes the reason row before the end, so it joins the turn while open. */ + private endTurn( + turn: string, + stopReason: string, + at: number, + durationMs: number | undefined, + failureDetail: string | undefined + ): ProviderTimelineEvent[] { + const events = this.failures.ended(turn, stopReason, failureDetail) + events.push(acpTurnEnd(turn, stopReason, at, durationMs)) + this.end(turn) + if (this.prompts.current?.turn === turn) { + this.prompts.finish() + } + return events + } + + private start(turn: string, at: number): ProviderTimelineEvent[] { + if (turn === this.prompts.current?.turn) { + const events = this.prompts.start(turn, at) + // Marked so a late frame for this prompt after its end neither reopens nor retargets it. + this.started.set(turn, true) + this.activeTurn = turn + return events + } + if (this.started.has(turn)) { + return [] + } + this.started.set(turn, true) + this.activeTurn = turn + return [{ type: 'turn.open', turn, at }] + } + + private end(turn: string): void { + this.tools.end(turn) + this.messages.end(turn) + if (this.activeTurn === turn) { + this.activeTurn = undefined + } + } +} diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts new file mode 100644 index 00000000000..801b85fd9fb --- /dev/null +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + messageText, + providerTurnItemId +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { openAcpFixtureRig } from './acp-timeline-fixture.test-support' +import { AcpAgentError } from './acp-errors' + +afterEach(closeProviderTimelineRigs) + +// The shape Grok sends for a prompt its API refused, with the reason scrubbed to a placeholder. +const REASON = 'API error (status 400 Bad Request): invalid_request_error: placeholder reason' +const session = { sessionId: 'session-1' } +const queued = (promptId: string) => ({ ...session, entries: [], runningPromptId: promptId }) +const grokUpdate = (update: Record) => ({ + ...session, + update, + _meta: { agentTimestampMs: 1 } +}) +const retryFailed = grokUpdate({ + sessionUpdate: 'retry_state', + type: 'failed', + error_type: 'api', + message: REASON +}) +const ended = (promptId: string, stopReason: string, agentResult?: string) => + grokUpdate({ + sessionUpdate: 'turn_completed', + prompt_id: promptId, + stop_reason: stopReason, + ...(agentResult === undefined ? {} : { agent_result: agentResult }), + elapsed_ms: 729 + }) +const promptComplete = (promptId: string, stopReason: string, agentResult: string | null) => ({ + ...session, + promptId, + stopReason, + agentResult +}) +const rpcError = new AcpAgentError(-32603, 'Internal error', { message: REASON }) + +function statusRows(rows: AgentJournalRenderItem[]) { + return rows.flatMap((row) => (row.body.kind === 'status' ? [{ row, body: row.body }] : [])) +} + +describe('a failed ACP turn says why', () => { + it('writes the reason once when Grok sends it four times, in the failed turn', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error', REASON), 1003) + ) + expect(statusRows(await f.rig.rows())[0]?.body.text).toBe(REASON) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'error', REASON), + 1004 + ) + ) + f.apply(lane.promptFailed('c1', rpcError, 1005)) + const next = lane.openPrompt('c2', 1006) + f.apply(lane.notification('_x.ai/queue/changed', queued(next.promptId), 1007)) + f.apply( + lane.notification( + 'session/update', + { + ...session, + _meta: { promptId: next.promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } } + }, + 1008 + ) + ) + f.apply(lane.notification('_x.ai/session_notification', ended(next.promptId, 'end_turn'), 1009)) + f.apply(lane.promptResult('c2', { stopReason: 'end_turn' }, 1010)) + const rows = await f.rig.rows() + const failures = statusRows(rows) + expect(failures).toHaveLength(1) + // The turn ran, so the row reads as Codex's turn-ending error does: no refusal sentence or fact. + expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) + const turns = await f.rig.turns() + expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ + ['completed', 'failure'], + ['completed', 'success'] + ]) + expect(failures[0]?.row.turnScope).toEqual({ + kind: 'turn', + turnItemId: providerTurnItemId(prompt.promptId) + }) + expect( + rows.filter((row) => row.body.kind === 'message').map((row) => messageText(row.body)) + ).toEqual(['ok']) + }) + + it('takes the reason from the prompt error answer when no frame carried it', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1002)) + expect(statusRows(await f.rig.rows())[0]?.body).toEqual({ + kind: 'status', + tone: 'error', + text: 'Grok ended this turn with an error.' + }) + f.apply(lane.promptFailed('c1', rpcError, 1003)) + f.apply(lane.promptFailed('c1', rpcError, 1004)) + const failures = statusRows(await f.rig.rows()) + expect(failures).toHaveLength(1) + expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) + }) + + it.each(['retry_state', 'turn_completed', 'prompt_complete', 'prompt error answer'] as const)( + 'reads the reason when only the %s carries it', + async (source) => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + const reason = (carrier: typeof source) => (carrier === source ? REASON : undefined) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + if (source === 'retry_state') { + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + } + f.apply( + lane.notification( + '_x.ai/session_notification', + ended(prompt.promptId, 'error', reason('turn_completed')), + 1003 + ) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'error', reason('prompt_complete') ?? null), + 1004 + ) + ) + f.apply( + lane.promptFailed( + 'c1', + source === 'prompt error answer' ? rpcError : new AcpAgentError(-32603, ''), + 1005 + ) + ) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) + } + ) + + it('keeps the reason when a later copy carries none', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply(lane.notification('_x.ai/session_notification', retryFailed, 1002)) + f.apply(lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'error'), 1003)) + f.apply(lane.promptFailed('c1', new AcpAgentError(-32603, 'Internal error'), 1004)) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body.text)).toEqual([REASON]) + }) + + it('names a rate-limited turn the provider gave no words for, without inventing a reason', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'rate_limit'), 1002) + ) + f.apply( + lane.notification( + '_x.ai/session/prompt_complete', + promptComplete(prompt.promptId, 'rate_limit', null), + 1003 + ) + ) + const failures = statusRows(await f.rig.rows()) + expect(failures.map((failure) => failure.body)).toEqual([ + { kind: 'status', tone: 'error', text: 'Grok usage limit reached.' } + ]) + expect((await f.rig.turns())[0]?.outcome).toBe('failure') + }) + + it('writes no failure row for a turn the provider ended on purpose', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'max_tokens'), 1002) + ) + f.apply(lane.promptResult('c1', { stopReason: 'max_tokens' }, 1003)) + expect(statusRows(await f.rig.rows())).toEqual([]) + }) + + it('does not reopen or retarget an ended prompt for its late frame', async () => { + const f = await openAcpFixtureRig() + const lane = f.lane() + const prompt = lane.openPrompt('c1', 1000) + f.apply(lane.notification('_x.ai/queue/changed', queued(prompt.promptId), 1001)) + f.apply( + lane.notification('_x.ai/session_notification', ended(prompt.promptId, 'end_turn'), 1002) + ) + f.apply(lane.promptResult('c1', { stopReason: 'end_turn' }, 1003)) + const late = lane.notification( + 'session/update', + { + ...session, + _meta: { promptId: prompt.promptId }, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'late' } } + }, + 1004 + ) + expect(late.filter((event) => event.type === 'turn.open')).toEqual([]) + const promptless = lane.notification( + 'session/update', + { + ...session, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'x' } } + }, + 1005 + ) + expect(promptless.find((event) => event.type === 'text.delta')).toMatchObject({ + join: { thread: 'session-1' } + }) + expect(promptless.find((event) => event.type === 'text.delta')?.join).not.toHaveProperty('turn') + }) +}) diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts new file mode 100644 index 00000000000..28762bc4646 --- /dev/null +++ b/src/main/acp/acp-tool-timeline.ts @@ -0,0 +1,235 @@ +import { createPatch } from 'diff' +import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { BoundedMap } from '../../shared/bounded-map' +import { + MAX_PROVIDER_TIMELINE_OPEN_BYTES, + MAX_PROVIDER_TIMELINE_OPEN_ENTRIES +} from '../native-chat/agent-session-timeline/provider-timeline-budget' +import { + boundPayload, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { + ProviderTimelineEvent, + ProviderTimelineJoin +} from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { ToolCallUpdate } from './generated/acp-protocol.generated' + +const rawStreamsSchema = z.object({ stdout: z.string().optional(), stderr: z.string().optional() }) + +function outputText(update: ToolCallUpdate): string | undefined { + const text = update.content?.flatMap((block) => + block.type === 'content' && block.content.type === 'text' ? [block.content.text] : [] + ) + if (text?.length) { + return text.join('\n') + } + if (update.rawOutput === undefined) { + return undefined + } + if (typeof update.rawOutput === 'string') { + return update.rawOutput + } + const streams = rawStreamsSchema.safeParse(update.rawOutput) + if (streams.success && (streams.data.stdout !== undefined || streams.data.stderr !== undefined)) { + return [streams.data.stdout, streams.data.stderr] + .filter((value) => value !== undefined) + .join('\n') + } + return JSON.stringify(update.rawOutput) ?? '' +} + +/** A whole-file replacement is a linear-time unified patch, even for unrelated large files. */ +function replacementPatch(path: string, oldText: string, newText: string): string { + const lines = (text: string, prefix: string): { count: number; text: string } => { + if (!text) { + return { count: 0, text: '' } + } + const entries = text.split('\n') + if (entries.at(-1) === '') { + entries.pop() + } + return { + count: entries.length, + text: + entries.map((line) => `${prefix}${line}\n`).join('') + + (text.endsWith('\n') ? '' : '\\ No newline at end of file\n') + } + } + const oldLines = lines(oldText, '-') + const newLines = lines(newText, '+') + return `--- ${path}\n+++ ${path}\n@@ -${oldLines.count ? 1 : 0},${oldLines.count} +${newLines.count ? 1 : 0},${newLines.count} @@\n${oldLines.text}${newLines.text}` +} + +function boundedDiffPatch(path: string, oldText: string, newText: string): string { + const patch = + oldText.length + newText.length <= 256 * 1024 + ? createPatch(path, oldText, newText, undefined, undefined, { maxEditLength: 1024 }) + : undefined + return patch ?? replacementPatch(path, oldText, newText) +} + +type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } + +/** Holds the snapshots that fill ACP's partial tool updates, settled ones evicted first. Its bound + * is the assembler's open budget, which counts each running tool at no fewer bytes, so a running + * tool is evicted only after the assembler refused one past that budget, which ends the session. + * An update for an evicted settled tool is dropped by the assembler as settled. + * A tool's turn is held once: on its snapshot while that lives, then in `turns`. */ +export class AcpToolTimeline { + private readonly tools = new Map() + private bytes = 0 + /** Turns of tools whose snapshot is gone, so a later task or request still lands beside the tool + * that started it. */ + private readonly turns = new BoundedMap({ + maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES + }) + + translate( + update: ToolCallUpdate, + dialect: AcpDialect, + join: ProviderTimelineJoin + ): ProviderTimelineEvent[] { + const previous = this.tools.get(update.toolCallId) + const output = outputText(update) + const state = + update.status === 'completed' + ? 'completed' + : update.status === 'failed' + ? 'failed' + : update.status === 'pending' || update.status === 'in_progress' + ? 'running' + : (previous?.body.state ?? 'running') + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + callId: update.toolCallId, + name: + dialect.toolName?.(update) ?? + update.name ?? + previous?.body.name ?? + update.title ?? + update.kind ?? + 'tool', + input: + update.rawInput === undefined + ? (previous?.body.input ?? null) + : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state, + ...(output === undefined + ? previous?.body.output + ? { output: previous.body.output } + : {} + : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) + } + const turn = join.turn ?? this.turn(update.toolCallId) + this.turns.delete(update.toolCallId) + this.remember(update.toolCallId, { body, ...(turn === undefined ? {} : { turn }) }) + const events: ProviderTimelineEvent[] = [ + { + type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', + item: `tool:${update.toolCallId}`, + body, + join + } + ] + for (const content of update.content ?? []) { + if (content.type === 'diff') { + events.push({ + type: 'item.update', + item: `diff:${JSON.stringify([update.toolCallId, content.path])}`, + body: { + kind: 'diff', + path: content.path, + patch: boundPayload( + boundedDiffPatch(content.path, content.oldText ?? '', content.newText), + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ) + }, + join + }) + } else if (content.type === 'terminal' || content.content.type !== 'text') { + events.push({ + type: 'provider.frame', + frameKind: `tool-content:${content.type}`, + payload: content, + join + }) + } + } + return events + } + + turn(toolCallId: string): string | undefined { + return this.tools.get(toolCallId)?.turn ?? this.turns.get(toolCallId) + } + + end(turn: string): void { + for (const [key, snapshot] of this.tools) { + if (snapshot.turn === turn) { + this.bytes -= this.size(key, snapshot) + this.retire(key, snapshot) + } + } + } + + private retire(key: string, snapshot: ToolSnapshot): void { + this.tools.delete(key) + if (snapshot.turn !== undefined) { + this.turns.set(key, snapshot.turn) + } + } + + private fits(count: number, bytes: number): boolean { + return count <= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES && bytes <= MAX_PROVIDER_TIMELINE_OPEN_BYTES + } + + private size(key: string, snapshot: ToolSnapshot): number { + return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(snapshot)) + } + + private remember(key: string, snapshot: ToolSnapshot): void { + const previous = this.tools.get(key) + if (this.size(key, snapshot) > MAX_PROVIDER_TIMELINE_OPEN_BYTES) { + if (previous) { + this.bytes -= this.size(key, previous) + } + this.retire(key, snapshot) + return + } + let bytes = this.bytes - (previous ? this.size(key, previous) : 0) + this.size(key, snapshot) + let count = this.tools.size + (previous ? 0 : 1) + const evicted: string[] = [] + for (const [candidate, stored] of this.tools) { + if (this.fits(count, bytes)) { + break + } + if (candidate !== key && stored.body.state !== 'running') { + evicted.push(candidate) + count -= 1 + bytes -= this.size(candidate, stored) + } + } + for (const [candidate, stored] of this.tools) { + if (this.fits(count, bytes)) { + break + } + if (candidate !== key && !evicted.includes(candidate)) { + evicted.push(candidate) + count -= 1 + bytes -= this.size(candidate, stored) + } + } + for (const candidate of evicted) { + const stored = this.tools.get(candidate) + if (stored) { + this.retire(candidate, stored) + } + } + this.tools.delete(key) + this.tools.set(key, snapshot) + this.bytes = bytes + } +} diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts new file mode 100644 index 00000000000..f4266b1704b --- /dev/null +++ b/src/main/acp/acp-turn-failures.ts @@ -0,0 +1,65 @@ +import { providerDiagnostic } from '../../shared/agent-session-failure' +import { BoundedMap } from '../../shared/bounded-map' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpAgentError } from './acp-errors' + +/** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ +const FAILED_STOP_REASONS = ['error', 'rate_limit'] + +function acpStopReasonFailed(stopReason: string): boolean { + return FAILED_STOP_REASONS.includes(stopReason) +} + +/** The provider's words in its error answer to `session/prompt`. */ +export function acpPromptErrorDetail(dialect: AcpDialect, error: AcpAgentError): string { + return dialect.promptErrorDetail?.(error) ?? error.message +} + +/** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: + * the message was accepted and the turn ran, so it is no refusal. Providers send that reason several + * times (beside the end, after it, in the prompt's error answer), so a later copy only adds a + * reason the row still lacks. */ +export class AcpTurnFailures { + /** The reason each failed turn's row holds; '' for none yet. */ + private readonly rows = new BoundedMap({ maxEntries: 128 }) + + constructor( + private readonly sessionId: string, + private readonly dialect: AcpDialect, + private readonly agentName: string | undefined + ) {} + + has(turn: string): boolean { + return this.rows.has(turn) + } + + /** The row an end writes, if it failed. */ + ended(turn: string, stopReason: string, text: string | undefined): ProviderTimelineEvent[] { + return acpStopReasonFailed(stopReason) ? this.row(turn, text, stopReason) : [] + } + + row(turn: string, text: string | undefined, stopReason = 'error'): ProviderTimelineEvent[] { + const written = this.rows.peek(turn) + const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') + if (written !== undefined && (written !== '' || !detail)) { + return [] + } + this.rows.set(turn, detail?.text ?? '') + return [ + { + type: 'item.update', + item: `turn-failure:${turn}`, + body: { + kind: 'status', + tone: 'error', + text: + detail?.text ?? + this.dialect.failedTurnText?.(stopReason) ?? + `${this.agentName ?? 'The agent'} ended this turn with an error.` + }, + join: { thread: this.sessionId, turn } + } + ] + } +} diff --git a/src/main/acp/acp-turn-messages.ts b/src/main/acp/acp-turn-messages.ts new file mode 100644 index 00000000000..0cf39c5834c --- /dev/null +++ b/src/main/acp/acp-turn-messages.ts @@ -0,0 +1,35 @@ +import { BoundedMap } from '../../shared/bounded-map' +import type { SessionUpdate } from './generated/acp-protocol.generated' + +type MessagePosition = { ordinal: number; channel?: string } + +/** Stable turn-relative message positions. */ +export class AcpTurnMessages { + private readonly turns = new BoundedMap({ maxEntries: 128 }) + + key(turn: string, update: SessionUpdate): string | undefined { + const state = this.turns.get(turn) ?? { ordinal: 0 } + this.turns.set(turn, state) + if ( + update.sessionUpdate !== 'agent_message_chunk' && + update.sessionUpdate !== 'agent_thought_chunk' + ) { + if (['tool_call', 'tool_call_update', 'plan'].includes(update.sessionUpdate)) { + state.channel = undefined + } + return undefined + } + if (update.messageId) { + return `message:${update.messageId}` + } + if (state.channel !== update.sessionUpdate) { + state.ordinal += 1 + state.channel = update.sessionUpdate + } + return `turn-message:${JSON.stringify([turn, state.ordinal])}` + } + + end(turn: string): void { + this.turns.delete(turn) + } +} diff --git a/src/main/acp/fixtures/s1-basic.jsonl b/src/main/acp/fixtures/s1-basic.jsonl new file mode 100644 index 00000000000..b36e8ce2f1d --- /dev/null +++ b/src/main/acp/fixtures/s1-basic.jsonl @@ -0,0 +1,12 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"read_file","rawInput":{"target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"read","title":"Read `/workspace/file-2`","locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"ReadFile","target_file":"/workspace/file-2"},"_meta":{"x.ai/tool":{"name":"read_file","kind":"read"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"/workspace/file-2","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-3\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":446,"output_tokens":74,"cache_read_input_tokens":34176,"cache_creation_input_tokens":0,"reasoning_tokens":24}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0},"elapsed_ms":5289}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68922,"outputTokens":195,"cachedReadTokens":35840,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s1-full-notifications.jsonl b/src/main/acp/fixtures/s1-full-notifications.jsonl new file mode 100644 index 00000000000..de1e19fd50a --- /dev/null +++ b/src/main/acp/fixtures/s1-full-notifications.jsonl @@ -0,0 +1,142 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"clientInfo":{"name":"orca-acp-capture","title":"Orca ACP capture harness","version":"0.0.1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"cached_token","name":"cached_token","description":"Cached token from ~/.grok/auth.json"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"cached_token","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"Fixture value","agentVersion":"1.0.46","agentId":"","agentInstanceId":"","modelState":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"Fixture value","mcpServers":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"agent_build","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/settings/update","params":{"show_resolved_model":false,"sharing_enabled":false,"privacy_notice_rollout":true,"privacy_banner_reshow_days":0,"session_picker_grouped":null,"tips":["Use @ to attach files like @src/main.rs.","Use @! for hidden or ignored files: @!.github/workflows.","Press Ctrl+O to toggle auto-approve mode.","Use Shift+Tab to cycle between modes like Plan mode.","Run /compact [context] when chat gets long.","Press Ctrl+B to background a running terminal command.","Start Grok in a fresh worktree with `-w`; add `-r ` to resume an existing session there.","Use Ctrl+Enter to interject messages. Or just Enter to queue messages.","Run /dashboard (or Ctrl+\\) to see and manage all your agents in one place.","Try out workflows using /workflows."],"slash_command_tags":{"learn":"new","memory":"new"},"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}],"campaigns":[{"id":"grok-4.7-launch","models":{"default":"grok-4.7"}}],"gate_message":null,"gate_url":null,"gate_label":null,"allow_access":true,"consent_gate":null,"auto_permission_mode_enabled":null,"prompt_suggestions_enabled":null,"permission_mode":null,"group_tool_verbs":null,"collapsed_edit_blocks":null,"subscription_watch_interval_secs":60,"dock_enabled":true,"terminal_theme_enabled":null,"subagent_model_inheritance_enabled":true}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057313,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"session-1","mcpToolCount":0,"elapsedMs":0}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/settings/update","params":{"show_resolved_model":false,"sharing_enabled":false,"privacy_notice_rollout":true,"privacy_banner_reshow_days":0,"session_picker_grouped":null,"tips":["Use @ to attach files like @src/main.rs.","Use @! for hidden or ignored files: @!.github/workflows.","Press Ctrl+O to toggle auto-approve mode.","Use Shift+Tab to cycle between modes like Plan mode.","Run /compact [context] when chat gets long.","Press Ctrl+B to background a running terminal command.","Start Grok in a fresh worktree with `-w`; add `-r ` to resume an existing session there.","Use Ctrl+Enter to interject messages. Or just Enter to queue messages.","Run /dashboard (or Ctrl+\\) to see and manage all your agents in one place.","Try out workflows using /workflows."],"slash_command_tags":{"learn":"new","memory":"new"},"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}],"campaigns":[{"id":"grok-4.7-launch","models":{"default":"grok-4.7"}}],"gate_message":null,"gate_url":null,"gate_label":null,"allow_access":true,"consent_gate":null,"auto_permission_mode_enabled":null,"prompt_suggestions_enabled":null,"permission_mode":null,"group_tool_verbs":null,"collapsed_edit_blocks":null,"subscription_watch_interval_secs":60,"dock_enabled":true,"terminal_theme_enabled":null,"subagent_model_inheritance_enabled":true}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057314,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"model_switch","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1833,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-0","agentTimestampMs":1791057313672,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1833,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-1","agentTimestampMs":1791057313680,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"model_changed","model_id":"grok-4.7","reasoning_effort":"high"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"sessionId":"session-1","models":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.7","options":[{"value":"grok-4.7","name":"Grok 4.7"},{"value":"grok-4.7-build-fast","name":"Grok 4.7 Fast"},{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High","description":"Maximum reasoning for the hardest tasks."},{"value":"high","name":"High","description":"Thorough reasoning and quality. Recommended."},{"value":"medium","name":"Medium","description":"Strong quality with a faster turnaround."},{"value":"low","name":"Low","description":"Fastest responses. Best for simple tasks."}]}],"_meta":{"currentWorkingDirectory":"Fixture value","codebaseIndexed":[],"isGitRepo":false,"gitRoot":null,"showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.7","category":"model","label":"Grok 4.7","selected":true},{"id":"grok-4.7-build-fast","category":"model","label":"Grok 4.7 Fast","selected":false},{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":false},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","selected":false},{"id":"high","category":"mode","label":"High","description":"Thorough reasoning and quality. Recommended.","selected":true},{"id":"medium","category":"mode","label":"Medium","description":"Strong quality with a faster turnaround.","selected":false},{"id":"low","category":"mode","label":"Low","description":"Fastest responses. Best for simple tasks.","selected":false}]},"x.ai/sessionDetail":{"sessionId":"session-1","kind":"build","cwd":"Fixture value","currentModelId":"grok-4.7"},"x.ai/memoryMode":"v2"}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"list the files in this folder and read README.md"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"session_start","runs":[{"name":"global/orca-status:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":9}},{"name":"global/settings:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":14}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-2","agentTimestampMs":1791057313704}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"Fixture value","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791057313707,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[{"id":"prompt:p1:3","version":0,"kind":"prompt","text":"list the files in this folder and read README.md","position":0}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"prompt:p1:3","runningText":"list the files in this folder and read README.md","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"user_prompt_submit","prompt_id":"prompt:p1:3","count":2},"_meta":{"agentTimestampMs":1791057313720}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"user_prompt_submit","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-3","agentTimestampMs":1791057313739}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/models/update","params":{"currentModelId":"grok-4.7","availableModels":[{"modelId":"grok-4.7","name":"Grok 4.7","description":"Fixture value","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.7-build-fast","name":"Grok 4.7 Fast","description":"Fast variant. 2x the price.","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Maximum reasoning for the hardest tasks.","default":false},{"id":"high","value":"high","label":"High","description":"Thorough reasoning and quality. Recommended.","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Strong quality with a faster turnaround.","default":false},{"id":"low","value":"low","label":"Low","description":"Fastest responses. Best for simple tasks.","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":256000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low","description":"Quick, fast implementations","default":false}],"contextWindows":[256000,500000]}}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/announcements/update","params":{"gen":1791057315,"announcements":[{"id":"grok-4.7","message":"Select 'Grok 4.7' under /model.","severity":"info","title":"Grok 4.7 is here!","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null},{"id":"Fixture value","message":"Ask Grok to /learn from your past traces and tune Grok Build to how you work.","severity":"info","title":"Fixture value","cta":null,"updated_at":null,"expires_at":null,"dismissible":null,"persistent":null}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-6","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" list"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-7","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-8","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":3}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" folder"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-9","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":4}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" contents"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-10","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":5}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-11","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":6}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-12","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":7}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-13","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":8}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-14","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":9}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-15","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":10}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`."}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-16","agentTimestampMs":1791057316114,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":11}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","tool_index":0,"name":"list_dir"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"Fixture value"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-17","agentTimestampMs":1791057316633,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-18","agentTimestampMs":1791057316655,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1851,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-19","agentTimestampMs":1791057316666,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","tool_index":1,"name":"read_file"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":1,"arguments_delta":"Fixture value"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-20","agentTimestampMs":1791057316961,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":32636,"output_tokens":121,"cache_read_input_tokens":1664,"cache_creation_input_tokens":0,"reasoning_tokens":28}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"pre_tool_use","tool_name":"list_dir","count":2},"_meta":{"agentTimestampMs":1791057316962}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","title":"list_dir","rawInput":{"target_directory":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"List Files","read_only":true}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-21","agentTimestampMs":1791057316962,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCall","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","title":"list_dir","kind":"Other","status":"Pending"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"pre_tool_use","tool_name":"list_dir","runs":[{"name":"global/orca-status:pre_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":11}},{"name":"global/settings:pre_tool_use[1].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-22","agentTimestampMs":1791057316986}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","kind":"permission"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","kind":"other","title":"Fixture value","locations":[{"path":"Fixture value"}],"rawInput":{"variant":"ListDir","target_directory":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"List Files","read_only":true,"input":{"directory":"Fixture value"}}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-23","agentTimestampMs":1791057316986,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":null}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"pre_tool_use","tool_name":"read_file","count":2},"_meta":{"agentTimestampMs":1791057316988}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","title":"read_file","rawInput":{"target_file":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"Read","read_only":true}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-24","agentTimestampMs":1791057316988,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCall","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","title":"read_file","kind":"Other","status":"Pending"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"pre_tool_use","tool_name":"read_file","runs":[{"name":"global/orca-status:pre_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:pre_tool_use[1].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-25","agentTimestampMs":1791057317008}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","kind":"permission"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","kind":"read","title":"Fixture value","locations":[{"path":"Fixture value"}],"rawInput":{"variant":"ReadFile","target_file":"Fixture value"},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"Read","read_only":true,"input":{"path":"Fixture value"}}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-26","agentTimestampMs":1791057317008,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":null}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"post_tool_use","tool_name":"read_file","count":2},"_meta":{"agentTimestampMs":1791057317012}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"post_tool_use","tool_name":"read_file","runs":[{"name":"global/orca-status:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-28","agentTimestampMs":1791057317032}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"post_tool_use","tool_name":"list_dir","count":2},"_meta":{"agentTimestampMs":1791057317035}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","content_concise":"1\u2192# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","absolute_path":"Fixture value","offset":null,"raw_output":"# Scratch project 1\n\nA tiny throwaway project used to record Grok ACP traffic.\n","total_lines":4}}},"_meta":{"totalTokens":34429,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-27","agentTimestampMs":1791057317032,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-1","status":"Completed"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"post_tool_use","tool_name":"list_dir","runs":[{"name":"global/orca-status:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:post_tool_use[0].hooks[0]","status":{"status":"success","elapsed_ms":19}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-30","agentTimestampMs":1791057317063}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"Fixture value","absolute_root_path":"Fixture value"}}},"_meta":{"totalTokens":34449,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-29","agentTimestampMs":1791057317063,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call-d473a4c2-cca8-48da-a279-35e37479b59a-0","status":"Completed"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":"Fixture value","result":{"result":{"reloaded":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-33","agentTimestampMs":1791057317622,"promptId":"prompt:p1:3","streamStartMs":1791057314618,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_summary_generated","session_summary":"List files and read README.md"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"List files and read README.md"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"This"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-34","agentTimestampMs":1791057318881,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" folder"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-35","agentTimestampMs":1791057318881,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" has"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-36","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":3}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" three"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-37","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":4}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" files"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-38","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":5}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-39","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":6}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-40","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":7}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-41","agentTimestampMs":1791057318882,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":8}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"math"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-42","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":9}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".js"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-43","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":10}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-44","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":11}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-45","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":12}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-46","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":13}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"notes"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-47","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":14}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".txt"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-48","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":15}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-49","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":16}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"-"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-50","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":17}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-51","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":18}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-52","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":19}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-53","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":20}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-54","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":21}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-55","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":22}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"README"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-56","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":23}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".md"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-57","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":24}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-58","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":25}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" says"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-59","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":26}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-60","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":27}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-61","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":28}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"markdown"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-62","agentTimestampMs":1791057318888,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":29}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-63","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":30}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"#"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-64","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":31}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Scratch"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-65","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":32}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" project"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-66","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":33}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" "}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-67","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":34}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"1"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-68","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":35}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-69","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":36}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-70","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":37}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tiny"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-71","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":38}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" throw"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-72","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":39}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"away"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-73","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":40}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" project"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-74","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":41}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" used"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-75","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":42}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-76","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":43}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" record"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-77","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":44}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" G"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-78","agentTimestampMs":1791057318889,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":45}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"rok"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-79","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":46}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" ACP"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-80","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":47}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" traffic"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-81","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":48}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-82","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":49}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}},"_meta":{"totalTokens":34592,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-83","agentTimestampMs":1791057318890,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AgentMessageChunk","chunkId":50}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","web_fetch","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":34705,"eventId":"01a10355-beef-7083-805c-806758f9a7d2-84","agentTimestampMs":1791057318943,"promptId":"prompt:p1:3","streamStartMs":1791057317825,"turnStartMs":1791057313774,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":59}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":446,"output_tokens":74,"cache_read_input_tokens":34176,"cache_creation_input_tokens":0,"reasoning_tokens":24}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_run_started","event_name":"stop","prompt_id":"prompt:p1:3","count":2},"_meta":{"agentTimestampMs":1791057318944}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"stop","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:stop[0].hooks[0]","status":{"status":"success","elapsed_ms":8}},{"name":"global/settings:stop[0].hooks[0]","status":{"status":"success","elapsed_ms":11}}]},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-87","agentTimestampMs":1791057318964}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600,"modelUsage":{"grok-4.7-build":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600}},"numTurns":2},"elapsed_ms":5289},"_meta":{"eventId":"01a10355-beef-7083-805c-806758f9a7d2-88","agentTimestampMs":1791057319003}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"session-1","promptId":"prompt:p1:3","stopReason":"end_turn","agentResult":null}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"Fixture value","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791057319035,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"sessionId":"session-1","requestId":"prompt:p1:3","promptId":"prompt:p1:3","totalTokens":34705,"modelId":"grok-4.7","inputTokens":34622,"outputTokens":74,"cachedReadTokens":34176,"reasoningTokens":24,"usage":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600,"modelUsage":{"grok-4.7-build":{"inputTokens":68922,"outputTokens":195,"totalTokens":69117,"cachedReadTokens":35840,"cacheCreationTokens":0,"reasoningTokens":52,"modelCalls":2,"apiDurationMs":5045,"costUsdTicks":289863600}},"numTurns":2}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"last_turn_summary","summary":"Three files; README is a tiny Grok ACP scratch project","prompt_id":"prompt:p1:3"},"_meta":{"agentTimestampMs":1791057320779}}}} diff --git a/src/main/acp/fixtures/s2-permission.jsonl b/src/main/acp/fixtures/s2-permission.jsonl new file mode 100644 index 00000000000..66bb39db336 --- /dev/null +++ b/src/main/acp/fixtures/s2-permission.jsonl @@ -0,0 +1,20 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"write","rawInput":{"file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1`","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call-1","kind":"edit","title":"Write `/workspace/file-1` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-1","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"diff","path":"/workspace/file-1","oldText":"","newText":"hello from grok\n"}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"","new_string":"hello from grok\n","tool_output_for_prompt":"The file /workspace/file-1 has been created.","tool_output_for_prompt_concise":"The file /workspace/file-1 has been created.","absolute_path":"/workspace/file-1","edits":{"details":[{"old_string":"","old_line":1,"new_string":"hello from grok\n","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0},"elapsed_ms":8673}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69120,"outputTokens":260,"cachedReadTokens":36096,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":5,"method":"session/prompt","params":{"sessionId":"session-2","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:5"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" create"}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"write","rawInput":{"file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2`","content":[{"type":"diff","path":"/workspace/file-2","oldText":"","newText":"hello from grok\n"}],"locations":[{"path":"/workspace/file-2"}],"rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"session-2","toolCall":{"toolCallId":"call-2","kind":"edit","title":"Write `/workspace/file-2` \u2014 hook 'plugin/capture-force-ask/hooks:pre_tool_use[0].hooks[0]' asks: capture harness forces a permission prompt","rawInput":{"variant":"Write","file_path":"/workspace/file-2","content":"hello from grok\n"},"_meta":{"x.ai/tool":{"name":"write","kind":"write"}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"reject-once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-2","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"failed","content":[{"type":"content","content":{"type":"text","text":"User rejected the execution for tool `write`"}}]},"_meta":{"promptId":"prompt:p1:5","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-2","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:5","stop_reason":"cancelled","usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":4475}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":5,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34448,"outputTokens":163,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s3-cancel.jsonl b/src/main/acp/fixtures/s3-cancel.jsonl new file mode 100644 index 00000000000..72c56ee2edf --- /dev/null +++ b/src/main/acp/fixtures/s3-cancel.jsonl @@ -0,0 +1,13 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 20; echo finished > cancel-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep 20s then write cancel marker"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 20; echo finished > cancel-marker.txt","description":"Sleep 20s then write cancel marker","is_background":false,"block_until_ms":35000},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 20; echo finished > cancel-marker.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/workspace/file-1","output_file":"","total_bytes":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"cancelled","usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0},"elapsed_ms":7773}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","_meta":{"usage":{"inputTokens":34344,"outputTokens":318,"cachedReadTokens":1664,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:4"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:4","stop_reason":"end_turn","usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0},"elapsed_ms":3373}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34876,"outputTokens":189,"cachedReadTokens":34304,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s4-resume.jsonl b/src/main/acp/fixtures/s4-resume.jsonl new file mode 100644 index 00000000000..93adbc92a2f --- /dev/null +++ b/src/main/acp/fixtures/s4-resume.jsonl @@ -0,0 +1,23 @@ +{"direction":"out","process":"A","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:A:3"}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"grep","rawInput":{"pattern":".","glob":"notes.txt"},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"search","title":".","locations":[],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"list_dir","rawInput":{"target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"List `/workspace/file-1`","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:A:3","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043}}}} +{"direction":"in","process":"A","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Read notes.txt and tell me its second line."},"_meta":{"promptIndex":0}},"_meta":{"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll read `notes.txt` and report the second line."}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"List `/workspace/file-1`","status":"completed","locations":[{"path":"/workspace/file-1"}],"rawInput":{"variant":"ListDir","target_directory":"/workspace/file-1"},"rawOutput":{"type":"ListDir","Content":{"content":"- /workspace/file-2\n - math.js\n - notes.txt\n - README.md","absolute_root_path":"/workspace/file-1"}},"_meta":{"x.ai/tool":{"name":"list_dir","kind":"list"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":".","kind":"search","status":"completed","content":[{"type":"content","content":{"type":"text","text":"found 3 matches"}}],"rawInput":{"variant":"Grep","pattern":".","path":null,"glob":"notes.txt","-i":false,"type":null,"multiline":false},"rawOutput":{"type":"GrepSearch","stdout":[60,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,32,119,111,114,107,115,112,97,99,101,95,112,97,116,104,61,34,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,34,62,10,47,119,111,114,107,115,112,97,99,101,47,112,114,111,106,101,99,116,47,110,111,116,101,115,46,116,120,116,58,101,120,97,109,112,108,101,10,60,47,119,111,114,107,115,112,97,99,101,95,114,101,115,117,108,116,62],"stderr":[],"exit_code":0,"match_count":3,"file_matches":[{"path":"/workspace/file-3","matches":[{"line_number":1,"content":"apples"},{"line_number":2,"content":"bananas"},{"line_number":3,"content":"cherries"}]}]},"_meta":{"x.ai/tool":{"name":"grep","kind":"search"}}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The second line of `notes.txt` is `bananas`."}},"_meta":{"promptId":"prompt:A:3","turnStartMs":1000,"isReplay":true}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:A:3","stop_reason":"end_turn","usage":{"inputTokens":68864,"outputTokens":121,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5043},"_meta":{"isReplay":true}}}} +{"direction":"out","process":"B","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:B:3"}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"prompt:B:3","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" second"}},"_meta":{"promptId":"prompt:B:3","turnStartMs":1000}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:B:3","stop_reason":"end_turn","usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0},"elapsed_ms":1829}}}} +{"direction":"in","process":"B","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":34621,"outputTokens":36,"cachedReadTokens":34432,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s5-plan-approved.jsonl b/src/main/acp/fixtures/s5-plan-approved.jsonl new file mode 100644 index 00000000000..29e87decdb0 --- /dev/null +++ b/src/main/acp/fixtures/s5-plan-approved.jsonl @@ -0,0 +1,16 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:4"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" look"}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"ask_user_question","rawInput":{"questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}]}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"other","title":"Ask: Which test approach do you want for the subtract function?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"name":"ask_user_question","kind":"ask_user"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"_x.ai/ask_user_question","params":{"sessionId":"session-1","toolCallId":"call-1","questions":[{"question":"Which test approach do you want for the subtract function?","options":[{"label":"node:test (Recommended)","description":"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."},{"label":"Plain assert script","description":"A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework."},{"label":"Add a package.json and a test script","description":"Add package.json with a test script that runs the same Node built-in tests, so npm test works."}],"multiSelect":null}],"mode":"default"}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":"accepted","answers":{"Which test approach do you want for the subtract function?":"node:test (Recommended)"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which test approach do you want for the subtract function?\"=\"node:test (Recommended)\". You can now continue with the user's answers in mind."}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"exit_plan_mode","rawInput":{},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","kind":"other","title":"Plan: Exit","locations":[],"rawInput":{"variant":"ExitPlanMode"},"_meta":{"x.ai/tool":{"name":"exit_plan_mode","kind":"exit_plan"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"_x.ai/exit_plan_mode","params":{"sessionId":"session-1","toolCallId":"call-2","planContent":"# Plan\n\nUpdate the example and run its tests."}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":"approved"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed","title":"Plan mode exited","content":[{"type":"content","content":{"type":"text","text":"Plan file: /workspace/file-1"}}],"rawOutput":{"type":"ExitPlanMode","PlanReady":{"message":"Your plan has been approved. You can now start coding.","plan_content":"# Add subtract and a node:test\n\n## Problem\n\n`math.js` only exports `add(a, b)`. There is no `subtract`, and nothing in this folder can check that the math functions return the right values.\n\n## User-facing change\n\nCallers can import `subtract` from `math.js` and get `a - b`. A new test file checks that behavior and can be run with Node's built-in test runner.\n\n## Approach\n\nStay inside this folder. No new dependencies and no `package.json`. You chose `node:test`.\n\n1. In `math.js`, add `subtract(a, b)` next to `add`, using the same style: a named export that returns `a - b`.\n2. Add `math.test.js` that imports `subtract` and uses `node:test` plus `node:assert/strict`. Cover a positive difference, a negative difference, and zero.\n3. Run `node --test math.test.js` from this folder and confirm the tests pass.\n\n## Out of scope\n\n- Changing `add`\n- Editing `README.md` or `notes.txt`\n- Adding a package manager, test script, or extra files\n","plan_file_path":"/workspace/file-1"}}},"_meta":{"promptId":"prompt:p1:4","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":278,"output_tokens":255,"cache_read_input_tokens":38912,"cache_creation_input_tokens":0,"reasoning_tokens":138}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:4","stop_reason":"end_turn","usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0},"elapsed_ms":64864}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":331949,"outputTokens":3858,"cachedReadTokens":294144,"cacheCreationTokens":0}}}}} diff --git a/src/main/acp/fixtures/s6-background.jsonl b/src/main/acp/fixtures/s6-background.jsonl new file mode 100644 index 00000000000..5b183795517 --- /dev/null +++ b/src/main/acp/fixtures/s6-background.jsonl @@ -0,0 +1,18 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Fixture prompt"}],"_meta":{"promptId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[{"id":"prompt:p1:3","version":0,"kind":"prompt","text":"Start this shell command as a BACKGROUND task (do not wait for it): sleep 15; echo bg-done > bg-marker.txt . Then immediately end your turn with a one-line reply. When the background task finishes later, tell me.","position":0}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"prompt:p1:3","runningText":"Start this shell command as a BACKGROUND task (do not wait for it): sleep 15; echo bg-done > bg-marker.txt . Then immediately end your turn with a one-line reply. When the background task finishes later, tell me.","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I'll"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" start"}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"run_terminal_command","rawInput":{"command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","kind":"execute","title":"Execute `sleep 15; echo bg-done > bg-marker.txt`","content":[{"type":"content","content":{"type":"text","text":"Sleep then write bg-marker.txt"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 15; echo bg-done > bg-marker.txt","description":"Sleep then write bg-marker.txt","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/task_backgrounded","params":{"sessionId":"session-1","update":{"sessionUpdate":"task_backgrounded","tool_call_id":"call-1","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","command":"sleep 15; echo bg-done > bg-marker.txt","cwd":"/workspace/project","output_file":"/workspace/file-1","description":"Sleep then write bg-marker.txt"},"_meta":{"eventId":"session-1-24","agentTimestampMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"[bg] sleep 15; echo bg-done > bg-marker.txt (01a10366)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","task_type":"bash","output_file":"/workspace/file-1","status":"running","command":"sleep 15; echo bg-done > bg-marker.txt","summary":"Background task 01a10366-1e7f-7191-bcd1-fac24585851f started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a10366-1e7f-7191-bcd1-fac24585851f\"] when you need the output."}},"_meta":{"promptId":"prompt:p1:3","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"prompt:p1:3","stop_reason":"end_turn","usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0},"elapsed_ms":5774}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","_meta":{"usage":{"inputTokens":69127,"outputTokens":207,"cachedReadTokens":35968,"cacheCreationTokens":0}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"session-1","entries":[],"runningPromptId":"task-completed-2","runningText":"\nBackground task \"01a10366-1e7f-7191-bcd1-fac24585851f\" completed (exit code: 0).\nDescription: Sleep then write bg-marker.txt | Duration: 15.0s\nUse get_command_or_subagent_output(\"01a10366-1e7f-7191-bcd1-fac24585851f\") to see the full output.\n","runningKind":"prompt"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/task_completed","params":{"sessionId":"session-1","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"01a10366-1e7f-7191-bcd1-fac24585851f","command":"sleep 15; echo bg-done > bg-marker.txt","cwd":"/workspace/project","start_time":{"secs_since_epoch":1791058386,"nanos_since_epoch":559567000},"end_time":{"secs_since_epoch":1791058401,"nanos_since_epoch":567941000},"output":"","output_file":"/workspace/file-1","truncated":false,"output_total_bytes":0,"exit_code":0,"signal":null,"completed":true,"kind":"bash","block_waited":false,"explicitly_killed":false,"kill_result_delivered":false,"owner_session_id":"session-1","description":"Sleep then write bg-marker.txt","is_backgrounded":true},"will_wake":true},"_meta":{"eventId":"session-1-47","agentTimestampMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" background"}},"_meta":{"promptId":"task-completed-2","turnStartMs":1000}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-2","stop_reason":"end_turn","usage":{"inputTokens":34928,"outputTokens":219,"cachedReadTokens":34688,"cacheCreationTokens":0},"elapsed_ms":4520}}}} diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index 45f8dfc2ae1..1419729c5cf 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,13 +1,9 @@ import type { - AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' -import { - isBackgroundTaskBlock, - type NativeChatBackgroundTaskBlock -} from '../../shared/native-chat-types' +import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleJournal, @@ -37,15 +33,7 @@ export function claudeBackgroundTaskIdentity( return { provider: 'orca', clientMessageId: key } } -export function claudeBackgroundTaskBody( - block: NativeChatBackgroundTaskBlock -): AgentJournalItemBody { - return { - kind: 'message', - role: 'system', - blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] - } -} +export const claudeBackgroundTaskBody = backgroundTaskJournalBody /** Reconcile one queued row against the durable run identity after a rebind. */ export function resolveClaudeBackgroundTaskIdentity( diff --git a/src/shared/native-chat-background-task-row.ts b/src/shared/native-chat-background-task-row.ts index 9be089b7486..717c8691386 100644 --- a/src/shared/native-chat-background-task-row.ts +++ b/src/shared/native-chat-background-task-row.ts @@ -5,6 +5,7 @@ // the block. The frozen sentence beside it is built here too, so the twin and // the block can never describe the task differently. +import type { AgentJournalMessageItem } from './agent-session-journal-types' import { isBackgroundTaskBlock, type NativeChatBackgroundTaskBlock, @@ -113,6 +114,16 @@ export function backgroundTaskFallbackText(block: NativeChatBackgroundTaskBlock) return `${subject.charAt(0).toUpperCase()}${subject.slice(1)} ${verb}` } +export function backgroundTaskJournalBody( + block: NativeChatBackgroundTaskBlock +): AgentJournalMessageItem { + return { + kind: 'message', + role: 'system', + blocks: [{ type: 'text', text: backgroundTaskFallbackText(block) }, { ...block }] + } +} + /** The background-task rows in `blocks`. */ export function backgroundTaskBlocks( blocks: readonly NativeChatBlock[]