mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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. */
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
|
||||
+5
-2
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** A host notice about the turn that the fold never hides. */
|
||||
export const AGENT_SESSION_TURN_NOTICE_PRESENTATION = 'turn-notice'
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user