fix(native-chat): show a prompt hook's block as its own row for every agent (#30898)

* fix(native-chat): show a prompt hook's block as its own row for every agent

When a prompt hook refused a message, Codex showed nothing, Claude showed
an unmarked warning the turn fold could hide (with a copy of the prompt),
and Grok read as the user stopping the turn.

Each adapter now writes one warning row, "Blocked by hook: <reason>" or
"Hook stopped: <reason>", marked with the hook-outcome presentation that
the turn fold never hides:
- Codex: a blocked or stopped UserPromptSubmit hook/completed frame
- Claude: a warning informational frame that halts the prompt, worded from
  its structured prompt_submit_outcome when present
- Grok: a HookDenied end, from either completion signal under one row
  identity; the turn ends completed instead of interrupted

Nothing new is saved and nothing changes on the wire beyond the new
presentation value.

* fix(native-chat): end a hook-denied Grok turn on its first signal, and clean older Claude block wording

Follow-ups from the size review of the hook-row change:
- Grok: a hook-denied turn_completed now ends the turn and writes the row
  straight away, instead of waiting for the prompt's answer; a later answer
  changes nothing.
- Claude: when an older Claude sends only its sentence, the row keeps the
  hook's reason and drops the hook path and the copy of the prompt. Whether
  the row shows still depends only on prevent_continuation.
- The turn-ending shape is declared once, the Codex hook payload is read
  once, and the Codex hook row returns early instead of threading through
  the generic frame row.

* test(native-chat): cover hook-block rows with real Grok frames and a preserved send

- Replace the source-derived Grok fixture with frames captured from Grok
  1.0.62 (session, prompt ids renamed to the test's).
- One whole-flow file, three parameterized cases (Codex, Claude current and
  older wording, Grok in every completion order), on the shared timeline
  rig with a per-agent journal identity.
- Each case starts with a send on record and checks it is unchanged after
  the reopen.

* fix(native-chat): word every Claude prompt-hook refusal, and draw the Codex hook row like the others

- Claude: a plugin module's dropped_by_hook / not_passed_on refusal reads "Blocked by hook" too.
- Older Claude sentence: an empty reason no longer turns the prompt copy into the reason; each hook's
  path label goes per line, while a hook's own [TAG]: stays.
- Codex: the prompt-hook row is read once, before classification, and carries no raw-frame
  disclosure, so it matches the Claude and Grok rows. The hook/completed table entry is back to main's.
- Comments the change had made wrong are corrected.

* fix(native-chat): strip only the older Claude sentence's first hook label, as before

Command labels without a slash were kept and labels inside a hook's own text were removed; the
first-line strip is back, with the empty-reason fix kept.

* refactor(native-chat): keep a hook's block row visible as a generic turn notice

The fold never hides a row presented as 'turn-notice', a host notice about the turn, instead of
a hook-specific value; the hook rows write it for every agent.

* fix(native-chat): report a Claude prompt hook's exit-2 block once

With hook events on, Claude also sends the blocking UserPromptSubmit hook's exit 2 as an error
hook_response; that is the block itself, already shown by the halting warning, so it writes no
red hook-failed row. Other hook events and exit codes are unchanged.

* docs(native-chat): say a prompt hook's exit 2 may also be a missing plugin script

* test(native-chat): type the Claude hook-outcome frame as a record

* fix(native-chat): keep a blocked Codex prompt's turn on its own message

Codex never echoes a prompt its hook refused, so its turn was anchored on the send only while
the send read in flight. Once the turn ended the host publishes the send as in doubt, the anchor
fell back to the turn record, and the block row moved to a second turn: live, the chat drew two
"Worked for" bars; after a reopen the bar sat on the row instead of the message. A send in doubt
now keeps anchoring the turn it opened (the first send in flight, else the newest in doubt).

* fix(native-chat): keep refused Codex hook notices outside turns

* revert(native-chat): keep Claude's exit-2 prompt hook_response as main's red row

A missing plugin script also reports exit 2 without blocking, so suppressing it hid a real failure main shows. With a user-added --include-hook-events an exit-2 block shows both rows (known limit).
This commit is contained in:
Brennan Benson
2026-10-22 00:56:00 -07:00
committed by GitHub
parent 43c32582e0
commit 7c34a48529
16 changed files with 438 additions and 56 deletions
+16 -1
View File
@@ -51,7 +51,7 @@ export type AcpDialectNotification =
replay?: boolean
at?: number
started?: boolean
end?: { stopReason: string; durationMs?: number; failureDetail?: string }
end?: Omit<AcpTurnEnding, 'at' | 'notSignedIn'>
/** 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. */
+19 -4
View File
@@ -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<typeof completionSchema>
completion: z.infer<typeof completionSchema>,
meta: unknown
): NonNullable<Extract<AcpDialectNotification, { disposition: 'map' }>['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.'
}
+17 -5
View File
@@ -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,
+20 -27
View File
@@ -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) {
@@ -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}}}}
@@ -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}}
+50 -10
View File
@@ -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<unknown, AgentSessionHookOutcome>([
['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<string, unknown>, 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<string, unknown>
): 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 } : {})
}
)
}
@@ -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}}}
@@ -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)
@@ -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<ProviderTimelineAssemblerDeps> = {}
overrides: Partial<ProviderTimelineAssemblerDeps> = {},
journalIdentity: Partial<Pick<TestJournalOptions['identity'], 'agent' | 'providerHandle'>> = {}
): Promise<ProviderTimelineRig> {
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
@@ -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<string, unknown> & { run?: Record<string, unknown> }
frame: Record<string, unknown>
}
afterEach(closeProviderTimelineRigs)
async function captured<T>(agent: 'codex' | 'claude', scenario: string): Promise<T[]> {
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<typeof openProviderTimelineRig>[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<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<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<string, unknown> = { ...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}`)])
})
})
@@ -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
@@ -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'
+26
View File
@@ -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')
}
}
+2
View File
@@ -0,0 +1,2 @@
/** A host notice about the turn that the fold never hides. */
export const AGENT_SESSION_TURN_NOTICE_PRESENTATION = 'turn-notice'
+6 -4
View File
@@ -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)
)
}
}