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[]