diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 4132b168124..feaf28ea9a4 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -51,7 +51,7 @@ export type AcpDialectNotification = replay?: boolean at?: number started?: boolean - end?: { stopReason: string; durationMs?: number; failureDetail?: string } + end?: Omit /** The provider's own words for why `turn` failed, sent apart from its end. */ failureDetail?: string usage?: AgentSessionContextUsage @@ -59,6 +59,19 @@ export type AcpDialectNotification = subagents?: AcpSubagentUpdate[] } +/** A prompt hook refused the message: the turn ended, but the user did not stop it. */ +export type AcpHookBlock = { reason?: string } + +/** How a turn ended, as the agent reported it. */ +export type AcpTurnEnding = { + stopReason: string + at: number + durationMs?: number + failureDetail?: string + notSignedIn?: boolean + hookBlock?: AcpHookBlock +} + /** How a `/compact` the agent ended normally went, read from its reply; absent compacted. */ export type AcpCompactionReply = { outcome: 'skipped' | 'failed'; detail: string } @@ -102,6 +115,8 @@ export type AcpDialect = { /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined authenticationRequired?(error: AcpAgentError): boolean + /** A hook's refusal, read from the `_meta` of the prompt's answer or of the turn's end. */ + hookBlock?(meta: unknown): AcpHookBlock | undefined /** The row for a failed turn the provider gave no words for. */ failedTurnText?(stopReason: string): string /** An agent that ends a `/compact` it did not do as a normal turn says so in its reply. */ diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index c90e229480d..f62890df84a 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { acpNotificationEnvelopeSchema } from '../acp-context-usage' -import type { AcpDialect, AcpDialectNotification } from './acp-dialect' +import type { AcpDialect, AcpDialectNotification, AcpHookBlock } from './acp-dialect' import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' import { grokModelEfforts } from './grok-model-catalog' @@ -32,6 +32,10 @@ const promptCompleteSchema = z.looseObject({ stopReason: z.string(), agentResult: z.string().nullish() }) +const hookDeniedSchema = z.looseObject({ + cancellationCategory: z.literal('HookDenied'), + cancellationContext: z.looseObject({ reason: z.string().optional() }).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'] @@ -74,14 +78,24 @@ function contextWindow(models: unknown): number | undefined { ) } +/** Grok ends a hook-denied prompt as `cancelled`, naming the hook's verdict in `_meta`. */ +function hookBlock(meta: unknown): AcpHookBlock | undefined { + const parsed = hookDeniedSchema.safeParse(meta) + const reason = parsed.data?.cancellationContext?.reason?.trim() + return parsed.success ? (reason ? { reason } : {}) : undefined +} + function completionEnd( - completion: z.infer + completion: z.infer, + meta: unknown ): NonNullable['end']> { const detail = failureDetail(completion.stop_reason, completion.agent_result) + const blocked = hookBlock(meta) return { stopReason: completion.stop_reason, durationMs: completion.elapsed_ms, - ...(detail ? { failureDetail: detail } : {}) + ...(detail ? { failureDetail: detail } : {}), + ...(blocked ? { hookBlock: blocked } : {}) } } @@ -172,7 +186,7 @@ function notification( ...(turn === undefined ? {} : { turn }), ...(meta.success && meta.data.turnStartMs !== undefined ? { at: meta.data.turnStartMs } : {}), replay: envelope._meta?.isReplay === true, - ...(completion.success ? { end: completionEnd(completion.data) } : {}), + ...(completion.success ? { end: completionEnd(completion.data, envelope._meta) } : {}), ...(retryFailure ? { failureDetail: retryFailure } : {}), ...(response.success ? { @@ -209,6 +223,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { notification, contextWindow, modelEfforts: grokModelEfforts, + hookBlock, promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, failedTurnText: () => 'Grok ended this turn with an error.' } diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index f40ec4eff17..e525e56a665 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -5,7 +5,8 @@ import { type AcpCompaction, type AcpCompactionFrame } from './acp-compaction-turn' -import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AcpDialect, AcpTurnEnding } from './acp-dialects/acp-dialect' +import { hookOutcomeRowBody } from '../../shared/agent-session-hook-outcome' import type { SessionUpdate } from './generated/acp-protocol.generated' export type AcpPromptTurn = { @@ -74,10 +75,7 @@ export class AcpPromptTurns { } /** The running compaction's result row and end, when `turn` is its turn. */ - endCompaction( - turn: string, - ending: { stopReason: string; at: number; failureDetail?: string; notSignedIn?: boolean } - ): ProviderTimelineEvent[] | undefined { + endCompaction(turn: string, ending: AcpTurnEnding): ProviderTimelineEvent[] | undefined { const prompt = this.compacting(turn) if (!prompt?.compaction) { return undefined @@ -124,6 +122,20 @@ export class AcpPromptTurns { } } +/** A hook-denied turn completed rather than stopped: its row, under one identity however often + * the agent reports the end, then the end. */ +export function acpTurnEndEvents( + thread: string, + turn: string, + { stopReason, at, durationMs, hookBlock }: AcpTurnEnding +): ProviderTimelineEvent[] { + const end = acpTurnEnd(turn, hookBlock ? 'end_turn' : stopReason, at, durationMs) + const body = hookBlock && hookOutcomeRowBody('blocked', [hookBlock.reason ?? '']) + return body + ? [{ type: 'item.update', item: `hook-block:${turn}`, body, join: { thread, turn } }, end] + : [end] +} + export function acpTurnEnd( turn: string, stopReason: string, diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index cb462f806a3..b9bca633890 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -3,9 +3,13 @@ import { BoundedMap } from '../../shared/bounded-map' import type { AcpTimelineEvent as ProviderTimelineEvent } from './acp-timeline-event' import { acpNotificationEnvelopeSchema, AcpContextTimeline } from './acp-context-usage' import { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' -import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect' +import { + GENERIC_ACP_DIALECT, + type AcpDialect, + type AcpTurnEnding +} from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' -import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns' +import { acpTurnEndEvents, AcpPromptTurns } from './acp-prompt-turns' import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events' import { translateAcpRequest } from './acp-timeline-requests' import { ACP_SUBSTANTIVE_UPDATES, acpSessionUpdate } from './acp-session-update' @@ -82,7 +86,8 @@ export class AcpTimelineTranslator { result: PromptResponse, at: number ): ProviderTimelineEvent[] { - return this.finishPrompt(clientMessageId, result.stopReason, at) + const hookBlock = this.dialect.hookBlock?.(result._meta) + return this.finishPrompt(clientMessageId, { stopReason: result.stopReason, at, hookBlock }) } /** The prompt failed: the agent's own error answer, or an answer Orca could not read (then no @@ -99,7 +104,8 @@ export class AcpTimelineTranslator { ? this.failures.row(ended.turn, detail, 'error', notSignedIn) : [] } - return this.finishPrompt(clientMessageId, 'error', at, detail, notSignedIn) + const ending = { stopReason: 'error', at, failureDetail: detail, notSignedIn } + return this.finishPrompt(clientMessageId, ending) } authenticationRequired(error: unknown): boolean { @@ -112,21 +118,13 @@ export class AcpTimelineTranslator { return this.prompts.refuse(clientMessageId) ? acpPromptErrorDetail(this.dialect, error) : null } - private finishPrompt( - clientMessageId: string, - stopReason: string, - at: number, - failureDetail?: string, - notSignedIn = false - ): ProviderTimelineEvent[] { + private finishPrompt(clientMessageId: string, ending: AcpTurnEnding): 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, notSignedIn) - ) + const events = this.start(prompt.turn, ending.at) + events.push(...this.endTurn(prompt.turn, { ...ending, durationMs: prompt.durationMs })) return events } @@ -253,13 +251,15 @@ export class AcpTimelineTranslator { } const end = extension?.end if (end && turn) { + // A hook's refusal is the whole turn: it ends here, and its answer adds nothing. if ( this.prompts.current?.turn === turn && - ['end_turn', 'cancelled'].includes(end.stopReason) + ['end_turn', 'cancelled'].includes(end.stopReason) && + !end.hookBlock ) { this.prompts.current.durationMs = end.durationMs } else { - events.push(...this.endTurn(turn, end.stopReason, at, end.durationMs, end.failureDetail)) + events.push(...this.endTurn(turn, { ...end, at })) } return events } @@ -298,18 +298,11 @@ export class AcpTimelineTranslator { } /** 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, - notSignedIn = false - ): ProviderTimelineEvent[] { - const ending = { stopReason, at, failureDetail, notSignedIn } + private endTurn(turn: string, ending: AcpTurnEnding): ProviderTimelineEvent[] { + const { stopReason, failureDetail, notSignedIn } = ending const events = this.prompts.endCompaction(turn, ending) ?? [ ...this.failures.ended(turn, stopReason, failureDetail, notSignedIn), - acpTurnEnd(turn, stopReason, at, durationMs) + ...acpTurnEndEvents(this.options.sessionId, turn, ending) ] this.end(turn) if (this.prompts.current?.turn === turn) { diff --git a/src/main/acp/fixtures/grok-hook-denied.jsonl b/src/main/acp/fixtures/grok-hook-denied.jsonl new file mode 100644 index 00000000000..a6edb07d2d8 --- /dev/null +++ b/src/main/acp/fixtures/grok-hook-denied.jsonl @@ -0,0 +1,15 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"BLOCKME please"}],"_meta":{"promptId":"prompt:p1:3","requestId":"prompt:p1:3"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"/tmp/sl-pr5-grok-capture","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1792448122953,"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":"BLOCKME please","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":"BLOCKME please","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":3},"_meta":{"agentTimestampMs":1792448123048}}}} +{"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":59}},{"name":"global/settings:session_start[0].hooks[0]","status":{"status":"success","elapsed_ms":12}}]},"_meta":{"eventId":"session-1-2","agentTimestampMs":1792448123057}}}} +{"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":45}},{"name":"global/settings:user_prompt_submit[0].hooks[0]","status":{"status":"success","elapsed_ms":12}},{"name":"project/qa-block:user_prompt_submit[0].hooks[0]","status":{"status":"failed","error":"blocked: QA policy: BLOCKME prompts are not allowed","elapsed_ms":9,"blocked":true}}]},"_meta":{"eventId":"session-1-3","agentTimestampMs":1792448123116}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_annotation","message":"⚠ Prompt blocked by project/qa-block: QA policy: BLOCKME prompts are not allowed"},"_meta":{"eventId":"session-1-4","agentTimestampMs":1792448123116}}}} +{"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":"cancelled","elapsed_ms":197},"_meta":{"eventId":"session-1-5","agentTimestampMs":1792448123158,"cancellationCategory":"HookDenied","cancellationContext":{"reason":"QA policy: BLOCKME prompts are not allowed","hook_name":"project/qa-block:user_prompt_submit[0].hooks[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":"stop_cancelled","prompt_id":"prompt:p1:3","count":1},"_meta":{"agentTimestampMs":1792448123159}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"session-1","promptId":"prompt:p1:3","stopReason":"cancelled","agentResult":null,"cancellationCategory":"HookDenied","cancellationContext":{"reason":"QA policy: BLOCKME prompts are not allowed","hook_name":"project/qa-block:user_prompt_submit[0].hooks[0]"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"session-1","title":null,"cwd":"/tmp/sl-pr5-grok-capture","isWorktree":false,"modelId":"grok-4.7","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1792448123163,"origin":{"kind":"local"}}],"removed":[]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","_meta":{"sessionId":"session-1","requestId":"prompt:p1:3","promptId":"prompt:p1:3","totalTokens":1328,"modelId":"grok-4.7","cancellationCategory":"HookDenied","cancellationContext":{"reason":"QA policy: BLOCKME prompts are not allowed","hook_name":"project/qa-block:user_prompt_submit[0].hooks[0]"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"session-1","update":{"sessionUpdate":"hook_execution","event_name":"stop_cancelled","prompt_id":"prompt:p1:3","runs":[{"name":"global/orca-status:stop_cancelled[0].hooks[0]","status":{"status":"success","elapsed_ms":45}}]},"_meta":{"eventId":"session-1-6","agentTimestampMs":1792448123204}}}} diff --git a/src/main/claude/__fixtures__/claude-hook-outcomes.jsonl b/src/main/claude/__fixtures__/claude-hook-outcomes.jsonl new file mode 100644 index 00000000000..d837a109a2d --- /dev/null +++ b/src/main/claude/__fixtures__/claude-hook-outcomes.jsonl @@ -0,0 +1,2 @@ +{"case": "blocked-prompt", "frame": {"type": "system", "subtype": "informational", "content": "UserPromptSubmit operation blocked by hook:\n[/tmp/hook-capture/repo/block.sh]: Deploys are frozen\n\n\nOriginal prompt: BLOCKME please say hi", "level": "warning", "prevent_continuation": true, "prompt_submit_outcome": {"kind": "blocked_by_hook", "prompt_kept": false, "reason": "Deploys are frozen"}, "uuid": "00000000-0000-4000-8000-000000000001", "session_id": "00000000-0000-4000-8000-000000000002"}} +{"case": "blocked-prompt", "frame": {"type": "result", "subtype": "success", "is_error": false, "num_turns": 0, "result": "UserPromptSubmit operation blocked by hook:\n[/tmp/hook-capture/repo/block.sh]: Deploys are frozen\n\n\nOriginal prompt: BLOCKME please say hi", "stop_reason": null, "session_id": "00000000-0000-4000-8000-000000000002", "uuid": "00000000-0000-4000-8000-000000000003", "duration_ms": 33, "duration_api_ms": 0}} diff --git a/src/main/claude/claude-informational-row.ts b/src/main/claude/claude-informational-row.ts index 0a8e113a7de..6eba7cd7532 100644 --- a/src/main/claude/claude-informational-row.ts +++ b/src/main/claude/claude-informational-row.ts @@ -1,21 +1,61 @@ -// Only a `warning` frame (a UserPromptSubmit hook that blocked the prompt, say) earns a row, in its -// own words; `info`, `notice` (a hook's systemMessage) and `suggestion` are session chrome, not chat rows. +// Only a `warning` frame earns a row: a prompt hook's outcome when it halts the prompt, else its own +// words; `info`, `notice` (a hook's systemMessage) and `suggestion` are session chrome, not chat rows. +import { + hookOutcomeRowBody, + type AgentSessionHookOutcome +} from '../../shared/agent-session-hook-outcome' +import { AGENT_SESSION_TURN_NOTICE_PRESENTATION } from '../../shared/agent-session-turn-notice' import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' -import { claudeText } from './claude-structured-item-translation' +import { claudeRecord, claudeText } from './claude-structured-item-translation' export const CLAUDE_INFORMATIONAL_FRAME_KIND = 'message:system:informational' +// `no_verdict` is Claude ending the prompt itself, not a hook's refusal: only its sentence is read. +const STRUCTURED_OUTCOMES = new Map([ + ['blocked_by_hook', 'blocked'], + ['dropped_by_hook', 'blocked'], + ['not_passed_on', 'blocked'], + ['stopped_by_hook', 'stopped'] +]) +// Older Claude sends only its sentence: the hook's `[label]: ` and reason, then a copy of the prompt. +const SENTENCE_OUTCOMES: [RegExp, AgentSessionHookOutcome][] = [ + [/^\S+ operation blocked by hook:\s*/, 'blocked'], + [/^Operation stopped by hook:?\s*/, 'stopped'] +] + +function hookOutcomeOf(message: Record, text: string) { + const structured = claudeRecord(message.prompt_submit_outcome) + const outcome = STRUCTURED_OUTCOMES.get(structured?.kind) + if (outcome) { + return hookOutcomeRowBody(outcome, [claudeText(structured?.reason) ?? '']) + } + const sentence = SENTENCE_OUTCOMES.find(([prefix]) => prefix.test(text)) + if (!sentence) { + return null + } + // The prompt copy goes first, so an empty reason never reads it as the reason. + const [sentenceText = ''] = text.split(/\n+Original prompt:/) + const reason = sentenceText.replace(sentence[0], '').replace(/^\[[^\]\n]+\]: /, '') + return hookOutcomeRowBody(sentence[1], [reason]) +} + /** The row a warning-level note writes; null for every other level and for a warning with no words. */ export function claudeInformationalRowBody( message: Record ): AgentJournalStatusItem | null { const text = message.level === 'warning' ? claudeText(message.content)?.trim() : null - return text - ? { - kind: 'status', - tone: 'warning', - text - } - : null + if (!text) { + return null + } + // A warning that halts the prompt is a hook's outcome, shown whether or not its words are known. + const halts = message.prevent_continuation === true + return ( + (halts ? hookOutcomeOf(message, text) : null) ?? { + kind: 'status', + tone: 'warning', + text, + ...(halts ? { presentation: AGENT_SESSION_TURN_NOTICE_PRESENTATION } : {}) + } + ) } diff --git a/src/main/codex/__fixtures__/codex-hook-outcomes.jsonl b/src/main/codex/__fixtures__/codex-hook-outcomes.jsonl new file mode 100644 index 00000000000..5654579d8f7 --- /dev/null +++ b/src/main/codex/__fixtures__/codex-hook-outcomes.jsonl @@ -0,0 +1,29 @@ +{"case": "blocked-prompt", "method": "turn/started", "params": {"threadId": "00000000-0000-4000-8000-000000000001", "turn": {"id": "00000000-0000-4000-8000-000000000002", "rootTurnId": "00000000-0000-4000-8000-000000000002", "items": [], "itemsView": "notLoaded", "status": "inProgress", "error": null, "startedAt": 1792445343, "completedAt": null, "durationMs": null}}} +{"case": "blocked-prompt", "method": "hook/started", "params": {"threadId": "00000000-0000-4000-8000-000000000001", "turnId": "00000000-0000-4000-8000-000000000002", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "running", "statusMessage": null, "startedAt": 1792445343, "completedAt": null, "durationMs": null, "entries": []}}} +{"case": "blocked-prompt", "method": "hook/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000001", "turnId": "00000000-0000-4000-8000-000000000002", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "blocked", "statusMessage": null, "startedAt": 1792445343, "completedAt": 1792445343, "durationMs": 10, "entries": [{"kind": "feedback", "text": "Deploys are frozen"}]}}} +{"case": "blocked-prompt", "method": "turn/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000001", "turn": {"id": "00000000-0000-4000-8000-000000000002", "rootTurnId": "00000000-0000-4000-8000-000000000002", "items": [], "itemsView": "notLoaded", "status": "completed", "error": null, "startedAt": 1792445343, "completedAt": 1792445343, "durationMs": 41}}} +{"case": "blocked-steer", "method": "turn/started", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turn": {"id": "00000000-0000-4000-8000-000000000004", "rootTurnId": "00000000-0000-4000-8000-000000000004", "items": [], "itemsView": "notLoaded", "status": "inProgress", "error": null, "startedAt": 1792445541, "completedAt": null, "durationMs": null}}} +{"case": "blocked-steer", "method": "hook/started", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "running", "statusMessage": null, "startedAt": 1792445541, "completedAt": null, "durationMs": null, "entries": []}}} +{"case": "blocked-steer", "method": "hook/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "completed", "statusMessage": null, "startedAt": 1792445541, "completedAt": 1792445541, "durationMs": 10, "entries": []}}} +{"case": "blocked-steer", "method": "item/started", "params": {"item": {"type": "userMessage", "id": "00000000-0000-4000-8000-000000000005", "clientId": "cm-1", "content": [{"type": "text", "text": "Run the shell command `sleep 8 && echo done` and then reply with the single word FINISHED.", "text_elements": []}]}, "threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "startedAtMs": 1792445541911}} +{"case": "blocked-steer", "method": "item/completed", "params": {"item": {"type": "userMessage", "id": "00000000-0000-4000-8000-000000000005", "clientId": "cm-1", "content": [{"type": "text", "text": "Run the shell command `sleep 8 && echo done` and then reply with the single word FINISHED.", "text_elements": []}]}, "threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "completedAtMs": 1792445541911}} +{"case": "blocked-steer", "method": "item/started", "params": {"item": {"type": "agentMessage", "id": "msg_0006", "text": "", "phase": "commentary", "memoryCitation": null, "delivery": null, "questions": null}, "threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "startedAtMs": 1792445544852}} +{"case": "blocked-steer", "method": "item/completed", "params": {"item": {"type": "agentMessage", "id": "msg_0006", "text": "I’ll run the command and wait for it to finish.\n", "phase": "commentary", "memoryCitation": null, "delivery": null, "questions": null}, "threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "completedAtMs": 1792445544906}} +{"case": "blocked-steer", "method": "hook/started", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "run": {"id": "pre-tool-use:0:/tmp/hook-capture/codex/hooks.json:exec-00000000-0000-4000-8000-000000000007", "eventName": "preToolUse", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 0, "status": "running", "statusMessage": null, "startedAt": 1792445544, "completedAt": null, "durationMs": null, "entries": []}}} +{"case": "blocked-steer", "method": "hook/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "run": {"id": "pre-tool-use:0:/tmp/hook-capture/codex/hooks.json:exec-00000000-0000-4000-8000-000000000007", "eventName": "preToolUse", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 0, "status": "completed", "statusMessage": null, "startedAt": 1792445544, "completedAt": 1792445545, "durationMs": 59, "entries": []}}} +{"case": "blocked-steer", "method": "item/started", "params": {"item": {"type": "commandExecution", "id": "exec-00000000-0000-4000-8000-000000000007", "pluginId": null, "scriptPath": null, "command": "/bin/zsh -lc 'sleep 8 && echo done'", "cwd": "/tmp/hook-capture/repo", "processId": "15208", "source": "unifiedExecStartup", "status": "inProgress", "commandActions": [{"type": "unknown", "command": "sleep 8 && echo done"}], "aggregatedOutput": null, "exitCode": null, "durationMs": null}, "threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "startedAtMs": 1792445545217}} +{"case": "blocked-steer", "method": "hook/started", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "running", "statusMessage": null, "startedAt": 1792445545, "completedAt": null, "durationMs": null, "entries": []}}} +{"case": "blocked-steer", "method": "hook/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turnId": "00000000-0000-4000-8000-000000000004", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "blocked", "statusMessage": null, "startedAt": 1792445545, "completedAt": 1792445545, "durationMs": 10, "entries": [{"kind": "feedback", "text": "Deploys are frozen"}]}}} +{"case": "blocked-steer", "method": "turn/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000003", "turn": {"id": "00000000-0000-4000-8000-000000000004", "rootTurnId": "00000000-0000-4000-8000-000000000004", "items": [], "itemsView": "notLoaded", "status": "completed", "error": null, "startedAt": 1792445541, "completedAt": 1792445545, "durationMs": 3360}}} +{"case": "blocked-tool", "method": "turn/started", "params": {"threadId": "00000000-0000-4000-8000-000000000008", "turn": {"id": "00000000-0000-4000-8000-000000000009", "rootTurnId": "00000000-0000-4000-8000-000000000009", "items": [], "itemsView": "notLoaded", "status": "inProgress", "error": null, "startedAt": 1792445364, "completedAt": null, "durationMs": null}}} +{"case": "blocked-tool", "method": "hook/started", "params": {"threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "running", "statusMessage": null, "startedAt": 1792445364, "completedAt": null, "durationMs": null, "entries": []}}} +{"case": "blocked-tool", "method": "hook/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "run": {"id": "user-prompt-submit:1:/tmp/hook-capture/codex/hooks.json", "eventName": "userPromptSubmit", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 1, "status": "completed", "statusMessage": null, "startedAt": 1792445364, "completedAt": 1792445364, "durationMs": 8, "entries": []}}} +{"case": "blocked-tool", "method": "item/started", "params": {"item": {"type": "userMessage", "id": "00000000-0000-4000-8000-000000000010", "clientId": "cm-1", "content": [{"type": "text", "text": "Run the shell command `echo hello` with your shell tool, then reply with what it printed.", "text_elements": []}]}, "threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "startedAtMs": 1792445364126}} +{"case": "blocked-tool", "method": "item/completed", "params": {"item": {"type": "userMessage", "id": "00000000-0000-4000-8000-000000000010", "clientId": "cm-1", "content": [{"type": "text", "text": "Run the shell command `echo hello` with your shell tool, then reply with what it printed.", "text_elements": []}]}, "threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "completedAtMs": 1792445364126}} +{"case": "blocked-tool", "method": "item/started", "params": {"item": {"type": "agentMessage", "id": "msg_0011", "text": "", "phase": "commentary", "memoryCitation": null, "delivery": null, "questions": null}, "threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "startedAtMs": 1792445366450}} +{"case": "blocked-tool", "method": "item/completed", "params": {"item": {"type": "agentMessage", "id": "msg_0011", "text": "I’ll run the command now.", "phase": "commentary", "memoryCitation": null, "delivery": null, "questions": null}, "threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "completedAtMs": 1792445366450}} +{"case": "blocked-tool", "method": "hook/started", "params": {"threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "run": {"id": "pre-tool-use:0:/tmp/hook-capture/codex/hooks.json:exec-00000000-0000-4000-8000-000000000012", "eventName": "preToolUse", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 0, "status": "running", "statusMessage": null, "startedAt": 1792445366, "completedAt": null, "durationMs": null, "entries": []}}} +{"case": "blocked-tool", "method": "hook/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "run": {"id": "pre-tool-use:0:/tmp/hook-capture/codex/hooks.json:exec-00000000-0000-4000-8000-000000000012", "eventName": "preToolUse", "handlerType": "command", "executionMode": "sync", "scope": "turn", "sourcePath": "/tmp/hook-capture/codex/hooks.json", "source": "user", "displayOrder": 0, "status": "blocked", "statusMessage": null, "startedAt": 1792445366, "completedAt": 1792445367, "durationMs": 580, "entries": [{"kind": "feedback", "text": "Deploys are frozen"}]}}} +{"case": "blocked-tool", "method": "item/started", "params": {"item": {"type": "agentMessage", "id": "msg_0013", "text": "", "phase": "final_answer", "memoryCitation": null, "delivery": null, "questions": null}, "threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "startedAtMs": 1792445368486}} +{"case": "blocked-tool", "method": "item/completed", "params": {"item": {"type": "agentMessage", "id": "msg_0013", "text": "The command was blocked by an automatic hook stating “Deploys are frozen,” so it printed nothing.", "phase": "final_answer", "memoryCitation": null, "delivery": null, "questions": null}, "threadId": "00000000-0000-4000-8000-000000000008", "turnId": "00000000-0000-4000-8000-000000000009", "completedAtMs": 1792445368907}} +{"case": "blocked-tool", "method": "turn/completed", "params": {"threadId": "00000000-0000-4000-8000-000000000008", "turn": {"id": "00000000-0000-4000-8000-000000000009", "rootTurnId": "00000000-0000-4000-8000-000000000009", "items": [{"type": "agentMessage", "id": "msg_0013", "text": "The command was blocked by an automatic hook stating “Deploys are frozen,” so it printed nothing.", "phase": "final_answer", "memoryCitation": null, "delivery": null, "questions": null}], "itemsView": "summary", "status": "completed", "error": null, "startedAt": 1792445364, "completedAt": 1792445368, "durationMs": 4870}}} diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index 073183fbc4e..5e35472c8a7 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -126,6 +126,7 @@ export class CodexJournalGenericFrames { translated: { body: AgentJournalStatusItem classification: UnhandledProviderFrameJournalItem['classification'] + turnScope?: UnhandledProviderFrameJournalItem['turnScope'] } ): CodexJournalTranslationAdmission { const frameTurnId = readCodexTurnId(payload) ?? this.activeTurn(threadId) @@ -164,6 +165,7 @@ export class CodexJournalGenericFrames { : `provider-frame:codex:${this.deps.acquisitionId ?? 'acquisition'}:${this.fallbackSequence}` } const attribution = this.deps.attributionFor(threadId, frameTurnId) + attribution.turnScope = translated.turnScope ?? attribution.turnScope const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, translated.body, attribution) : (this.deps.sink.appendItem(identity, translated.body, attribution), CODEX_JOURNAL_ADMITTED) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 973d9573b22..525ee6d3ed6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -228,8 +228,10 @@ export function refusingSink( } } +/** `journalIdentity` writes the chat as another agent's, for a lane that writes `eventSink` itself. */ export async function openProviderTimelineRig( - overrides: Partial = {} + overrides: Partial = {}, + journalIdentity: Partial> = {} ): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-')) roots.push(root) @@ -239,7 +241,8 @@ export async function openProviderTimelineRig( workspaceId: 'workspace-1', hostId: 'local', agent: AGENT, - providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' } + providerHandle: { transport: 'acp', agent: AGENT, nativeId: 'provider-session-1' }, + ...journalIdentity }, stateDirectory: root, now: () => 1_000 diff --git a/src/main/native-chat/agent-session-wire/hook-outcome-rows.test.ts b/src/main/native-chat/agent-session-wire/hook-outcome-rows.test.ts new file mode 100644 index 00000000000..304ce65d322 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/hook-outcome-rows.test.ts @@ -0,0 +1,193 @@ +// Captured prompt-hook blocks through each agent's translator, the journal, a reopen and the fold: +// one warning row stays on screen, nothing else explains the turn, and the send on record stays. + +import { readFile } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' +import { + nativeChatRowTurnKeys, + nativeChatTurnBarRows +} from '../../../shared/native-chat-turn-grouping' +import { nativeChatTurnFold, nativeChatTurnFoldRow } from '../../../shared/native-chat-turn-fold' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' +import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' +import { GROK_ACP_DIALECT } from '../../acp/acp-dialects/grok-dialect' +import { readAcpFixture } from '../../acp/acp-timeline-fixture.test-support' +import { AcpTimelineTranslator } from '../../acp/acp-timeline-translator' +import { PromptResponseSchema } from '../../acp/generated/acp-protocol.generated' +import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' +import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + type ProviderTimelineRig +} from '../agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../agent-session-timeline/provider-timeline-event' + +const row = (text: string) => + expect.objectContaining({ tone: 'warning', presentation: 'turn-notice', text }) +const DEPLOYS_FROZEN = row('Blocked by hook: Deploys are frozen') +const CODEX_CHAT = { agent: 'codex' as const, providerHandle: codexProviderHandle('t') } +const CLAUDE_CHAT = { agent: 'claude' as const, providerHandle: claudeProviderHandle('p', 'l') } +const QA_POLICY = 'QA policy: BLOCKME prompts are not allowed' +const SEND = { clientMessageId: 'cm-1', payloadFingerprint: 'fp', fence: 1 } +const GO = [{ type: 'text' as const, text: 'go' }] +type Captured = { + method: string + params: Record & { run?: Record } + frame: Record +} + +afterEach(closeProviderTimelineRigs) + +async function captured(agent: 'codex' | 'claude', scenario: string): Promise { + const url = new URL(`../../${agent}/__fixtures__/${agent}-hook-outcomes.jsonl`, import.meta.url) + const lines = (await readFile(url, 'utf8')).trim().split('\n') + return lines.map((line) => JSON.parse(line)).filter((line) => line.case === scenario) +} + +/** The system rows a settled chat draws, every turn folded. */ +function settledRows(items: readonly AgentJournalRenderItem[]) { + expect(withNativeChatCutTurnNotices(items, { agentName: 'Agent' })).toBe(items) + const messages = projectStructuredItemsToNativeChat(items) + const turnKeys = nativeChatRowTurnKeys(messages) + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => nativeChatTurnFoldRow(message, turnKeys[index], false)), + settledTurnKeys: new Set(turnKeys.flatMap((key) => (key ? [key] : []))), + expandedTurnKeys: new Set() + }) + return messages.flatMap((message, index) => + message.role === 'system' && !foldedRows.has(index) ? message.blocks : [] + ) +} + +/** A chat whose send is on record, written by `write`, then read back by a fresh journal. */ +async function blockedChat( + identity: Parameters[1], + write: (rig: ProviderTimelineRig) => void +) { + const rig = await openProviderTimelineRig({}, identity) + await rig.journal.appendSubmission({ + ...SEND, + body: { kind: 'message', role: 'user', blocks: GO } + }) + const sent = rig.journal.snapshot().submissions + write(rig) + const { items, submissions } = (await rig.reopenJournal()).snapshot() + expect(submissions).toEqual(sent) + return { items, submissions } +} + +describe('a prompt hook that blocked the message', () => { + // A blocked tool call reaches the model as the tool's result, and writes no row of its own. + it.each([ + ['blocked-prompt', [DEPLOYS_FROZEN], 'blocked'], + ['blocked-prompt', [row('Hook stopped: Deploys are frozen')], 'stopped'], + ['blocked-steer', [DEPLOYS_FROZEN], 'blocked'], + ['blocked-tool', [], 'blocked'] + ])('Codex %s (%s, %s)', async (scenario, rows, status) => { + const frames = await captured('codex', scenario) + const { items, submissions } = await blockedChat(CODEX_CHAT, (rig) => { + const deps = { sessionId: 's', primaryThreadId: () => 't', sink: rig.eventSink } + const lane = createCodexJournalTranslator({ ...deps, schedule: (run) => (run(), () => {}) }) + for (const { method, params } of frames) { + const frame = { method, params: { ...params, threadId: 't' } } + if (scenario === 'blocked-prompt' && method === 'hook/completed') { + frame.params.run = { ...params.run, status } + } + lane.handle({ type: 'notification', sessionId: 's', threadId: 't', ...frame }) + } + }) + expect(settledRows(items)).toEqual(rows) + const notices = items.filter( + (item) => item.body.kind === 'status' && item.body.presentation === 'turn-notice' + ) + expect(notices.map((item) => item.turnScope)).toEqual( + rows.map(() => AGENT_JOURNAL_THREAD_SCOPE) + ) + if (scenario === 'blocked-prompt') { + const messages = projectStructuredItemsToNativeChat(items) + for (const dispatchState of ['pending', 'unknown'] as const) { + const journal = { + items, + submissions: submissions.map((sent) => ({ ...sent, dispatchState })) + } + const { turnKeys } = nativeChatTurnMembership(messages, journal) + expect([...nativeChatTurnBarRows(messages, turnKeys).keys()]).toEqual([ + agentJournalSubmissionKey(SEND.clientMessageId) + ]) + } + } + }) + + it.each([ + ['2.1.302', { kind: 'blocked_by_hook', reason: 'Deploys are frozen' }, null, DEPLOYS_FROZEN], + [ + '2.1.302, a plugin module refused it', + { kind: 'not_passed_on' }, + null, + row('Blocked by hook.') + ], + ['older', undefined, null, DEPLOYS_FROZEN], + ['older, no reason', undefined, '', row('Blocked by hook.')] + ])('Claude %s', async (_, outcome, hooks, expected) => { + const frames = await captured('claude', 'blocked-prompt') + const { items } = await blockedChat(CLAUDE_CHAT, (rig) => { + const lane = createClaudeJournalTranslator({ sink: rig.eventSink, fallbackIdPrefix: '1' }) + for (const { frame } of frames) { + const message: Record = { ...frame, prompt_submit_outcome: outcome } + if (hooks !== null && typeof frame.content === 'string') { + message.content = frame.content.replace(/\[.*\]: .*/, hooks) + } + lane.handle({ type: 'message', sessionId: 's', message }) + } + }) + expect(settledRows(items)).toEqual([expected]) + }) + + it.each([ + ['its end alone', ['end']], + ['its answer alone', ['answer']], + ['its end, then its answer', ['end', 'answer']], + ['its answer, then its end', ['answer', 'end']] + ])('Grok, %s', async (_, order) => { + const frames = await readAcpFixture('grok-hook-denied') + const isEnd = (frame: unknown) => JSON.stringify(frame).includes('"turn_completed"') + const answer = frames.find((frame) => frame.message.result !== undefined) + const notices = frames.filter((frame) => frame.message.id === undefined && !isEnd(frame)) + const steps = order.flatMap((step) => (step === 'answer' ? [answer!] : frames.filter(isEnd))) + const events: ProviderTimelineEvent[] = [] + const { items } = await blockedChat({}, (rig) => { + const lane = new AcpTimelineTranslator({ sessionId: 'session-1', dialect: GROK_ACP_DIALECT }) + const apply = (next: ProviderTimelineEvent[]) => { + events.push(...next) + next.forEach((event) => expect(rig.assembler.apply(event).admission.accepted).toBe(true)) + } + apply(lane.openPrompt('p1:3', 1_000).events) + for (const { message } of [...notices, ...steps]) { + apply( + message.result === undefined + ? lane.notification(message.method ?? '', message.params, 2_000) + : lane.promptResult('p1:3', PromptResponseSchema.parse(message.result), 2_000) + ) + } + }) + const accepted = events.filter((event) => event.type === 'input.accepted') + expect(accepted).toEqual([expect.objectContaining({ clientMessageId: 'p1:3' })]) + const ended = events.findIndex((event) => event.type === 'turn.end') + expect(events.indexOf(accepted[0]!)).toBeLessThan(ended) + const turns = items.flatMap((item) => readAgentJournalTurn(item.body) ?? []) + expect(turns).toEqual([expect.objectContaining({ state: 'completed', outcome: 'success' })]) + expect(settledRows(items)).toEqual([row(`Blocked by hook: ${QA_POLICY}`)]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 6e0c1e44ee5..2fffe6d54af 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -37,6 +37,7 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'turn/started': 'status-chrome', 'hook/started': 'suppressed-benign', 'turn/completed': 'status-chrome', + // A failed hook surfaces (catalog below); a refused prompt writes its own row before this. 'hook/completed': 'suppressed-benign', 'turn/diff/updated': 'suppressed-benign', 'turn/plan/updated': 'timeline-substantive', @@ -135,7 +136,7 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'message:system:permission_denied': 'error-surface', 'message:prompt_suggestion': 'status-chrome', 'message:system:mirror_error': 'error-surface', - // Written by its own row (`claude-informational-row`): a warning in its words, else nothing. + // Written by its own row (`claude-informational-row`): a warning, a halting one as a hook's outcome. 'message:system:informational': 'status-chrome', 'message:conversation_reset': 'status-chrome', // A `started`/`completed`/`cancelled` state for one queued command uuid and diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index b1cefaf4242..decc3fd83f3 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -1,4 +1,8 @@ -import type { AgentJournalPlainStatusItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalPlainStatusItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { CLAUDE_LOCAL_COMMAND_OUTPUT_FRAME_KIND } from '../../../shared/native-chat-provider-frame-summary' import { boundPayload, @@ -8,10 +12,12 @@ import { import { codexGoalRowText, codexThreadGoalState } from '../../codex/codex-goal-journal-rows' import { isProviderRecord } from '../../provider-process/provider-json-record' import { classifyProviderFrame } from './provider-frame-disposition' +import { hookOutcomeRowBody } from '../../../shared/agent-session-hook-outcome' export type UnhandledProviderFrameJournalItem = { body: AgentJournalPlainStatusItem - /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ + turnScope?: AgentJournalTurnScope + /** Why the frame surfaced. `error-surface` rows (failures, warnings, a hook's refusal) skip row caps. */ classification: 'timeline-substantive' | 'error-surface' } @@ -80,6 +86,19 @@ function hookText(value: unknown): string | null { return typeof value === 'string' ? value.trim() || null : null } +/** A Codex prompt hook that refused the message: every entry but its context, in Codex's order. + * Other hooks' blocks reach the model as their tool's result, so they write no row of their own. */ +function codexHookOutcomeRow(payload: unknown): AgentJournalPlainStatusItem | null { + const run = isProviderRecord(payload) && isProviderRecord(payload.run) ? payload.run : null + const status = run?.eventName === 'userPromptSubmit' ? run.status : null + const entries: unknown[] = Array.isArray(run?.entries) ? run.entries : [] + const details = entries.flatMap((entry) => { + const text = isProviderRecord(entry) && entry.kind !== 'context' ? hookText(entry.text) : null + return text ? [text] : [] + }) + return status === 'blocked' || status === 'stopped' ? hookOutcomeRowBody(status, details) : null +} + function hookFailureFrameText( provider: string, kind: string, @@ -126,6 +145,19 @@ export function unhandledProviderFrameJournalItem( limits: JournalPayloadLimits = PROVIDER_FRAME_PAYLOAD_LIMITS, options: UnhandledProviderFrameJournalItemOptions = {} ): UnhandledProviderFrameJournalItem | null { + // Uncapped like Codex's own warnings, and drawn the same for every agent: no raw-frame disclosure. + const hook = + provider === 'codex' && kind === 'notification:hook/completed' + ? codexHookOutcomeRow(payload) + : null + if (hook) { + return { + body: hook, + classification: 'error-surface', + // Codex never echoes a refused prompt, so the turn has no message to group under. + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + } const classification = options.reportsFailure === true ? 'error-surface' diff --git a/src/shared/agent-session-hook-outcome.ts b/src/shared/agent-session-hook-outcome.ts new file mode 100644 index 00000000000..667141a3bd8 --- /dev/null +++ b/src/shared/agent-session-hook-outcome.ts @@ -0,0 +1,26 @@ +// A prompt hook that blocked or stopped the user's message: one row in the hook's own reason, +// written the same way for every agent, which the fold never hides. + +import type { AgentJournalPlainStatusItem } from './agent-session-journal-types' +import { AGENT_SESSION_TURN_NOTICE_PRESENTATION } from './agent-session-turn-notice' + +export type AgentSessionHookOutcome = 'blocked' | 'stopped' + +export function hookOutcomeRowBody( + outcome: AgentSessionHookOutcome, + details: readonly string[] +): AgentJournalPlainStatusItem { + const header = outcome === 'blocked' ? 'Blocked by hook' : 'Hook stopped' + const lines = details.map((detail) => detail.trim()).filter((detail) => detail.length > 0) + return { + kind: 'status', + tone: 'warning', + presentation: AGENT_SESSION_TURN_NOTICE_PRESENTATION, + text: + lines.length === 0 + ? `${header}.` + : lines.length === 1 + ? `${header}: ${lines[0]}` + : [header, ...lines].join('\n') + } +} diff --git a/src/shared/agent-session-turn-notice.ts b/src/shared/agent-session-turn-notice.ts new file mode 100644 index 00000000000..bd8a06d8e38 --- /dev/null +++ b/src/shared/agent-session-turn-notice.ts @@ -0,0 +1,2 @@ +/** A host notice about the turn that the fold never hides. */ +export const AGENT_SESSION_TURN_NOTICE_PRESENTATION = 'turn-notice' diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 6d1a5ed07f3..03e691a1e83 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -8,6 +8,7 @@ import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from './agent-session-compaction' import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from './agent-session-orca-stop' +import { AGENT_SESSION_TURN_NOTICE_PRESENTATION } from './agent-session-turn-notice' import { nativeChatRowRendersContent } from './native-chat-row-content' import { isStoppedBeforeStartBlock } from './native-chat-stopped-before-start' import { nativeChatRowRendersProse } from './native-chat-trailing-run' @@ -37,9 +38,9 @@ export type NativeChatTurnFoldRow = { /** Whether a system row reports a failure. It competes with prose to be the * turn's answer, so a turn that failed shows the error as its end. */ reportsFailure: boolean - /** Whether the row explains what became of the turn: a compaction's result, or - * that the response was interrupted. It never folds: hiding it would leave the - * turn's status as the only trace of it. */ + /** Whether the row explains what became of the turn: a compaction's result, that + * the response was interrupted, or a `turn-notice` row. It never folds: hiding + * it would leave the turn's status as the only trace of it. */ explainsTurn: boolean } @@ -74,7 +75,8 @@ export function nativeChatTurnFoldRow( block.type === 'text' && (block.presentation === 'compaction' || block.presentation === AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION || - block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) + block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION || + block.presentation === AGENT_SESSION_TURN_NOTICE_PRESENTATION) ) } }