diff --git a/.changeset/promote-failure-compact-events.md b/.changeset/promote-failure-compact-events.md new file mode 100644 index 000000000..b906b8f46 --- /dev/null +++ b/.changeset/promote-failure-compact-events.md @@ -0,0 +1,8 @@ +--- +"agent-bundle": minor +--- + +Promote canonical `tool/failure`, `compact/before`, and `compact/after` +event-route families across Claude Code, Codex, Cursor, and composite plugin +artifacts with evidence-pinned envelope validation and fail-closed result +projection. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 7975193a3..540e84495 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -234,6 +234,9 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the | `prompt/submit` | `beforeSubmitPrompt` (deny) | `UserPromptSubmit` (deny + context) | `UserPromptSubmit` (deny + context) | | `tool/before` | Supported | `PreToolUse` | `PreToolUse` | | `tool/after` | Supported | `PostToolUse` | `PostToolUse` | +| `tool/failure` | `postToolUseFailure` (observe-only) | `PostToolUseFailure` (context) | Unavailable | +| `compact/before` | `preCompact` (observe-only; native `user_message` not modeled) | `PreCompact` (deny) | `PreCompact` (observe-only) | +| `compact/after` | Unavailable | `PostCompact` (observe-only) | `PostCompact` (observe-only; no summary field) | | `stop` | Supported | `Stop` | `Stop` | | `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` | | `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` | @@ -257,6 +260,13 @@ observe-only. `prompt/submit` maps canonical deny to each host's blocking contract; Claude Code and Codex also accept `Agent.Context`, while Cursor has no context or prompt-rewrite channel. Cursor cloud agents do not expose `sessionEnd`. +`tool/failure`, `compact/before`, and `compact/after` are also event-route-only +families. Claude Code alone maps `tool/failure` context and +`compact/before` deny; every other result effect fails closed. Cursor's +`preCompact.user_message` is user-facing rather than agent context, and the +Codex compact schemas do not pin event-level semantics for their common output +fields, so neither channel is projected. Codex has no tool-failure event, +Cursor has no post-compaction event, and Codex `PostCompact` carries no summary. ## Extension-author guide diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 245154a7b..d07d33dba 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -25,12 +25,15 @@ "eventRoutes": { "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" }, "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, + "compact/after": { "nativeEvent": "PostCompact", "state": "supported" }, + "compact/before": { "nativeEvent": "PreCompact", "state": "supported" }, "prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" }, "session/end": { "nativeEvent": "SessionEnd", "state": "supported" }, "session/start": { "nativeEvent": "SessionStart", "state": "supported" }, "stop": { "nativeEvent": "Stop", "state": "supported" }, "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "PreToolUse", "state": "supported" }, + "tool/failure": { "nativeEvent": "PostToolUseFailure", "state": "supported" }, "workspace/open": { "reason": "The pinned Claude Code 2.1.250 hooks contract has no workspace-open event.", "state": "unavailable" @@ -631,6 +634,10 @@ "2026-09-02: live Claude Code 2.1.257 non-interactive capture recorded UserPromptSubmit with session_id, transcript_path, cwd, prompt_id, permission_mode, hook_event_name, and prompt; the scrubbed envelope is tests/fixtures/events/claude-user-prompt-submit.json.", "retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents SessionEnd input as the common hook envelope plus reason, with matcher filtering by reason; SessionEnd has no decision control, discards JSON output, and does not honor exit code 2 as a block.", "2026-09-02: live Claude Code 2.1.257 non-interactive capture recorded SessionEnd with session_id, transcript_path, cwd, prompt_id, hook_event_name, and reason; the scrubbed envelope is tests/fixtures/events/claude-session-end.json.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents PostToolUseFailure input as tool_name, tool_input, tool_use_id, error, optional is_interrupt, and optional duration_ms. It can add hookSpecificOutput.additionalContext after the failure but cannot deny or rewrite the completed tool call.", + "2026-09-02: live Claude Code 2.1.257 non-interactive captures recorded PostToolUseFailure for failed Write, Read, and Bash calls; the scrubbed Bash envelope is tests/fixtures/events/claude-post-tool-use-failure.json.", + "retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents PreCompact input as trigger plus nullable custom_instructions and allows decision:block with reason, while discarding continue and systemMessage. PostCompact adds compact_summary, has no decision control, and discards continue and systemMessage.", + "2026-09-02: live Claude Code 2.1.257 non-interactive captures recorded PreCompact and PostCompact for manual compaction; scrubbed envelopes are tests/fixtures/events/claude-pre-compact.json and tests/fixtures/events/claude-post-compact.json.", "The hooks reference states prompt_id requires Claude Code 2.1.196 or later; the pinned 2.1.250 release covers that input field without a version bump.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it.", "2026-09-01: https://code.claude.com/docs/en/plugins \"Ship default settings with your plugin\": a plugin may include settings.json at the plugin root \"to apply default configuration when the plugin is enabled. Currently, only the `agent` and `subagentStatusLine` keys are supported.\" The file-locations table of https://code.claude.com/docs/en/plugins-reference repeats the same bound: \"Settings | settings.json | Default configuration applied when the plugin is enabled. Only the agent and subagentStatusLine keys are supported\".", diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index d98e1d753..bb067792d 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -25,12 +25,18 @@ "eventRoutes": { "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" }, "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, + "compact/after": { "nativeEvent": "PostCompact", "state": "supported" }, + "compact/before": { "nativeEvent": "PreCompact", "state": "supported" }, "prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" }, "session/end": { "nativeEvent": "SessionEnd", "state": "supported" }, "session/start": { "nativeEvent": "SessionStart", "state": "supported" }, "stop": { "nativeEvent": "Stop", "state": "supported" }, "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "PreToolUse", "state": "supported" }, + "tool/failure": { + "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory and native event inventory contain no tool-failure event.", + "state": "unavailable" + }, "workspace/open": { "reason": "The pinned Codex 0.147.0 hooks contract has no workspace-open event.", "state": "unavailable" @@ -257,6 +263,10 @@ "liveRevision": "codex-cli 0.147.0 app-server protocol", "matchingRelativePaths": [], "missingPinnedRelativePaths": [ + "post-compact.command.input.schema.json", + "post-compact.command.output.schema.json", + "pre-compact.command.input.schema.json", + "pre-compact.command.output.schema.json", "session-end.command.input.schema.json", "subagent-start.command.input.schema.json", "subagent-start.command.output.schema.json", @@ -266,6 +276,10 @@ "user-prompt-submit.command.output.schema.json" ], "pinnedRepositorySha256": { + "post-compact.command.input.schema.json": "d5cecd14bd2ca18605ba8209108f76291f886ffe3cb4762d70e712c148836f31", + "post-compact.command.output.schema.json": "811b7ae2a4b277cd51c9df989f347e494fa981d01e346bc2a757506e97734882", + "pre-compact.command.input.schema.json": "5728b5da4c63e1e07f2d53ac8b2adc18306cd3dc8b01b556fc422e75b32a8734", + "pre-compact.command.output.schema.json": "98ba0dbf0848d8283cfe48a85122f4d9122baae1b457f50e239504864900247e", "session-end.command.input.schema.json": "99bf6e75091525b96926dd85a0adf6589dea93fe0d181394027fabda8139402e", "subagent-start.command.input.schema.json": "e1cacc5cd92217e96e327cf182038fa93099d194c3107439b4dad4b806d414cc", "subagent-start.command.output.schema.json": "531f7a457ad8430de82388319ff2bf030fd3a1dc0e9a0d4078447bc30948448b", @@ -300,7 +314,11 @@ "retrieved 2026-09-02: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json pins UserPromptSubmit input (sha256 e6b923bc519896197c44c4fc267a9d115cef24ac418dde9c27db699f4e3b65fd).", "retrieved 2026-09-02: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json pins decision:block plus reason and hookSpecificOutput.additionalContext, with no prompt-rewrite field (sha256 5e290303db710f3ccc12f4a2744e8586e7749b3ca2b6bf9f57781ed75bf17b2b).", "retrieved 2026-09-02: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/session-end.command.input.schema.json pins observe-only SessionEnd input with reason const other (sha256 23b1b69f92fa8ac29f8319478984b5aa5aaf09e5ca355ce90aa010452937e41c); the same generated directory has no session-end.command.output.schema.json (GitHub contents API returned 404), so no output is projected.", - "2026-09-02: live codex-cli 0.147.0 non-interactive capture recorded UserPromptSubmit and SessionEnd; the scrubbed envelopes are tests/fixtures/events/codex-user-prompt-submit.json and tests/fixtures/events/codex-session-end.json." + "2026-09-02: live codex-cli 0.147.0 non-interactive capture recorded UserPromptSubmit and SessionEnd; the scrubbed envelopes are tests/fixtures/events/codex-user-prompt-submit.json and tests/fixtures/events/codex-session-end.json.", + "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory and hooks/src/lib.rs inventory contain no tool-failure event, so tool/failure is unavailable rather than inferred from PostToolUse.", + "retrieved 2026-09-02: rust-v0.147.0 pre-compact.command.input.schema.json pins PreCompact trigger, model, turn_id, session, cwd, and nullable transcript input (sha256 065f0ae3cd628ac9af8c0cf9bd1d5a673bcbd5ea1d7dcdc0c6437f34dd0189d9); its output schema accepts only common continue, stopReason, suppressOutput, and systemMessage fields (sha256 c392f3054ae6750f427d4dec07380fd67e8c58a7939a35d5c69bfa070c7ca032).", + "retrieved 2026-09-02: rust-v0.147.0 post-compact.command.input.schema.json pins PostCompact with the same input fields and no compact summary field (sha256 4a4b3f3022c939a15ab12e95f5c5c17b18bb20f74fe962ae0a51b2a3e76e63f9); its output schema accepts only the same common fields (sha256 48355bfcb568259cf396beb6ade2ac32827f50bf6a3c20b395c337dce184cbed).", + "2026-09-02: the generated compact output schemas do not document event-level runtime effects for continue:false or the other common fields, so Agent Bundle exposes no canonical compact result channel until runtime evidence pins those semantics." ] } } diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index a65d3c93b..ddcd1d73c 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -25,6 +25,11 @@ "eventRoutes": { "agent/start": { "nativeEvent": "subagentStart", "state": "supported" }, "agent/stop": { "nativeEvent": "subagentStop", "state": "supported" }, + "compact/after": { + "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no postCompact event.", + "state": "unavailable" + }, + "compact/before": { "nativeEvent": "preCompact", "state": "supported" }, "prompt/submit": { "nativeEvent": "beforeSubmitPrompt", "state": "supported" }, "session/end": { "availability": { @@ -41,6 +46,7 @@ "stop": { "nativeEvent": "stop", "state": "supported" }, "tool/after": { "nativeEvent": "postToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "preToolUse", "state": "supported" }, + "tool/failure": { "nativeEvent": "postToolUseFailure", "state": "supported" }, "workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" } }, "matchers": { @@ -108,6 +114,10 @@ "retrieved 2026-09-02: https://cursor.com/docs/hooks documents beforeSubmitPrompt input as prompt plus file/rule attachments, matcher evaluation against the literal UserPromptSubmit, and output as continue plus optional user_message. continue:false blocks submission; no additional-context or prompt-rewrite channel is documented.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents sessionEnd input as session_id, reason, duration_ms, is_background_agent, final_status, and optional error_message. It is fire-and-forget; output is logged but unused. The same source states cloud agents have no sessionEnd because they lack an editor-lifetime session boundary.", "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture was attempted with trusted project hooks, but the binary required Cursor authentication unavailable in the capture environment; the pinned official contract is the evidence source for beforeSubmitPrompt and sessionEnd.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents postToolUseFailure input as tool_name, tool_input, tool_use_id, cwd, error_message, failure_type, duration, and is_interrupt, with no supported output fields. Unlike Claude PostToolUseFailure, failure_type includes permission_denied.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preCompact as observational and unable to block or modify compaction. Its optional user_message is user-facing rather than agent context, so the canonical Agent.Result and Agent.Context vocabulary exposes no output channel for it.", + "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks Agent and cloud event inventories document no postCompact event, so compact/after is unavailable rather than inferred from preCompact.", + "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for postToolUseFailure and preCompact could not dispatch because the binary required unavailable authentication; the pinned official contract is the evidence source.", "2026-09-01: https://cursor.com/docs/plugins and https://prod.cursor.com/docs/reference/plugins document agents as a full Cursor Plugin component alongside rules and commands; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it." ] } diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 244d6e651..f48319031 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -12,6 +12,14 @@ "eventRoutes": { "agent/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "agent/stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, + "compact/after": { + "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PostCompact equivalent (https://agent-plugins.org/).", + "state": "unavailable" + }, + "compact/before": { + "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PreCompact/preCompact equivalent (https://agent-plugins.org/).", + "state": "unavailable" + }, "prompt/submit": { "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native UserPromptSubmit/beforeSubmitPrompt equivalent (https://agent-plugins.org/).", "state": "unavailable" @@ -24,6 +32,10 @@ "stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "tool/after": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "tool/before": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, + "tool/failure": { + "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PostToolUseFailure/postToolUseFailure equivalent (https://agent-plugins.org/).", + "state": "unavailable" + }, "workspace/open": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" } }, "host": "portable", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 98420a5a2..1f01939e4 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -426,7 +426,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.20.0', + adapterRevision: '1.21.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index f771a7601..75845972d 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -273,7 +273,7 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 2ec7fb8e6..83a08e5a7 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -132,6 +132,55 @@ export const createNativeEventStarter = ( ? { model: 'default', turn_id: 'lifecycle-replay-turn' } : {}), }); + case 'tool/failure': + return deepFreeze(target === 'cursor' + ? { + ...base, + cwd: '/tmp', + duration: 0, + error_message: 'Lifecycle replay tool failure.', + failure_type: 'error', + is_interrupt: false, + tool_input: toolInput, + tool_name: toolName, + tool_use_id: 'lifecycle-replay-tool', + } + : { + ...base, + duration_ms: 0, + error: 'Lifecycle replay tool failure.', + is_interrupt: false, + tool_input: toolInput, + tool_name: toolName, + tool_use_id: 'lifecycle-replay-tool', + }); + case 'compact/before': + return deepFreeze(target === 'cursor' + ? { + ...base, + context_tokens: 0, + context_usage_percent: 0, + context_window_size: 1, + is_first_compaction: true, + message_count: 0, + messages_to_compact: 0, + trigger: 'manual', + } + : { + ...base, + trigger: 'manual', + ...(target === 'codex' + ? { model: 'default', turn_id: 'lifecycle-replay-turn' } + : { custom_instructions: null }), + }); + case 'compact/after': + return deepFreeze({ + ...base, + trigger: 'manual', + ...(target === 'codex' + ? { model: 'default', turn_id: 'lifecycle-replay-turn' } + : { compact_summary: 'Lifecycle replay compact summary.' }), + }); case 'tool/before': return deepFreeze({ ...base, @@ -322,6 +371,9 @@ const canonicalEventOrder: readonly NormalizedHookEvent[] = [ 'agentStop', 'workspaceOpen', 'sessionEnd', + 'toolFailure', + 'compactBefore', + 'compactAfter', ]; export const canonicalHookEventFor = (event: string): CanonicalHookEvent | undefined => diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index d4fd63383..bdaefeea3 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -58,7 +58,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.4.0', + adapterRevision: '1.5.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index 0c1ebd07f..85daf21c1 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -2,7 +2,7 @@ "observedCliVersion": "0.147.0", "retrievedAt": "2026-09-02", "schemaSource": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md", - "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. The generated subagent-{start,stop}.command.{input,output}.schema.json, user-prompt-submit.command.{input,output}.schema.json, and session-end.command.input.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. That tag has no session-end.command.output.schema.json. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", + "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. The generated subagent-{start,stop}.command.{input,output}.schema.json, user-prompt-submit.command.{input,output}.schema.json, session-end.command.input.schema.json, and {pre,post}-compact.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. That tag has no session-end.command.output.schema.json or tool-failure schema. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", "schemaTightenings": { "plugin.schema.json": [ "Top-level and author objects are closed; author admits only the documented name, email, and url fields.", diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/post-compact.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-compact.command.input.schema.json new file mode 100644 index 000000000..7c648878f --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-compact.command.input.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PostCompact", + "type": "string" + }, + "model": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "trigger": { + "enum": [ + "manual", + "auto" + ], + "type": "string" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "session_id", + "transcript_path", + "trigger", + "turn_id" + ], + "title": "post-compact.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/post-compact.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-compact.command.output.schema.json new file mode 100644 index 000000000..483bcc323 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/post-compact.command.output.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "post-compact.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-compact.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-compact.command.input.schema.json new file mode 100644 index 000000000..9e41bb3bc --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-compact.command.input.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PreCompact", + "type": "string" + }, + "model": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "trigger": { + "enum": [ + "manual", + "auto" + ], + "type": "string" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "session_id", + "transcript_path", + "trigger", + "turn_id" + ], + "title": "pre-compact.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-compact.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-compact.command.output.schema.json new file mode 100644 index 000000000..c2714eb2b --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/pre-compact.command.output.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "pre-compact.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 4ea33a921..33ed96727 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -110,6 +110,9 @@ const hookEventForRoute: Readonly>, fiel } }; +const requireNativeNumber = (input: Readonly>, field: string): void => { + if (typeof input[field] !== 'number') { + nativeEventError(`native ${field} must be a number`); + } +}; + +const requireNativeBoolean = (input: Readonly>, field: string): void => { + if (typeof input[field] !== 'boolean') { + nativeEventError(`native ${field} must be a boolean`); + } +}; + +const requireCompactTrigger = (native: Readonly>): void => { + if (native.trigger !== 'manual' && native.trigger !== 'auto') { + nativeEventError('native trigger must equal manual or auto'); + } +}; + const requirePermissionMode = (native: Readonly>): void => { requireNativeString(native, 'permission_mode'); if (!['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'].includes(String(native.permission_mode))) { @@ -125,6 +143,35 @@ export const validateNativeEventEnvelope = ( } return native; } + if (canonicalEvent === 'tool/failure') { + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + requireNativeString(native, 'cwd'); + requireNativeStringValue(native, 'error_message'); + if (!['timeout', 'error', 'permission_denied'].includes(String(native.failure_type))) { + return nativeEventError('native failure_type is invalid'); + } + requireNativeNumber(native, 'duration'); + requireNativeBoolean(native, 'is_interrupt'); + return native; + } + if (canonicalEvent === 'compact/before') { + requireCompactTrigger(native); + for (const field of [ + 'context_usage_percent', + 'context_tokens', + 'context_window_size', + 'message_count', + 'messages_to_compact', + ]) { + requireNativeNumber(native, field); + } + requireNativeBoolean(native, 'is_first_compaction'); + return native; + } if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { requireNativeString(native, 'tool_name'); if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { @@ -162,6 +209,29 @@ export const validateNativeEventEnvelope = ( requireNativeString(native, 'model'); } } + if (canonicalEvent === 'tool/failure') { + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + requireNativeStringValue(native, 'error'); + if (Object.hasOwn(native, 'is_interrupt')) requireNativeBoolean(native, 'is_interrupt'); + if (Object.hasOwn(native, 'duration_ms')) requireNativeNumber(native, 'duration_ms'); + } + if (canonicalEvent === 'compact/before' || canonicalEvent === 'compact/after') { + requireCompactTrigger(native); + if (target === 'codex') { + requireNativeString(native, 'turn_id'); + requireNativeString(native, 'model'); + } else if (canonicalEvent === 'compact/before') { + if (native.custom_instructions !== null && typeof native.custom_instructions !== 'string') { + return nativeEventError('native custom_instructions must be a string or null'); + } + } else { + requireNativeStringValue(native, 'compact_summary'); + } + } if (canonicalEvent === 'session/start') requireNativeString(native, 'source'); if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { requireNativeString(native, 'tool_name'); @@ -366,6 +436,70 @@ export const projectEventDocument = ( }), }); } + if (event === 'tool/failure') { + if ( + parsedValue?.outcome === 'deny' + || parsedValue?.reason !== undefined + || parsedValue?.updatedInput !== undefined + ) { + throw new TypeError('tool/failure cannot deny or replace native input; the tool has already failed.'); + } + if (target !== 'claude' && additionalContext !== undefined) { + throw new TypeError( + target === 'cursor' + ? 'Cursor postToolUseFailure has no context/output channel.' + : 'tool/failure additional context is supported only by Claude PostToolUseFailure.', + ); + } + if (additionalContext === undefined) return undefined; + return deepFreeze({ + hookSpecificOutput: { + additionalContext, + hookEventName: nativeEvent, + }, + }); + } + if (event === 'compact/before') { + if (parsedValue?.updatedInput !== undefined) { + throw new TypeError('compact/before cannot replace native input on any supported host.'); + } + if (additionalContext !== undefined) { + throw new TypeError( + target === 'cursor' + ? 'Cursor preCompact user_message is user-facing and cannot be represented by Agent.Context.' + : `${target === 'codex' ? 'Codex PreCompact' : 'Claude PreCompact'} has no additional-context channel.`, + ); + } + if (target === 'claude') { + if (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') { + throw new TypeError('compact/before reason is only valid when outcome is deny on Claude.'); + } + return parsedValue?.outcome === 'deny' + ? Object.freeze({ decision: 'block', reason: requireDenyReason() }) + : undefined; + } + if (parsedValue?.outcome === 'deny' || parsedValue?.reason !== undefined) { + throw new TypeError( + target === 'cursor' + ? 'Cursor preCompact is observational and cannot block compaction.' + : 'Codex PreCompact common-control runtime semantics are unproven and are not projected.', + ); + } + return undefined; + } + if (event === 'compact/after') { + if ( + parsedValue?.outcome === 'deny' + || parsedValue?.reason !== undefined + || parsedValue?.updatedInput !== undefined + ) { + throw new TypeError('compact/after is observation-only on every supported host and cannot deny or replace native input.'); + } + if (additionalContext !== undefined) { + throw new TypeError('compact/after is observation-only on every supported host and has no context/output channel.'); + } + return undefined; + } if (event === 'tool/before') { if (target === 'cursor') { if (parsedValue?.outcome === 'deny') { diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 8bbabfc06..dee981291 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -18,6 +18,9 @@ export const canonicalAgentEvents = Object.freeze([ 'workspace/open', 'session/end', 'prompt/submit', + 'tool/failure', + 'compact/before', + 'compact/after', ] as const); export type CanonicalAgentEvent = (typeof canonicalAgentEvents)[number]; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 98a5420c2..138d43c79 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -914,9 +914,10 @@ it('surfaces built-in adapter metadata as immutable capability evidence', () => it('reports the evidence-backed G10 event family matrix without inferred support', () => { const registry = createDefaultRegistry(); - const allNativeHosts = [ + const sharedNativeHosts = [ 'event:agent/start', 'event:agent/stop', + 'event:compact/before', 'event:prompt/submit', 'event:session/end', 'event:session/start', @@ -925,7 +926,7 @@ it('reports the evidence-backed G10 event family matrix without inferred support 'event:tool/before', ]; - for (const capability of allNativeHosts) { + for (const capability of [...sharedNativeHosts, 'event:tool/failure']) { expect(registry.get('cursor').capabilities[capability]).toMatchObject({ evidence: { observedVersion: '2026-08-28', target: 'cursor' }, state: 'supported', @@ -942,8 +943,28 @@ it('reports the evidence-backed G10 event family matrix without inferred support }, desktop: { state: 'supported' }, }); + expect(registry.get('cursor').capabilities['event:compact/after']).toMatchObject({ + reason: expect.stringContaining('no postCompact'), + state: 'unavailable', + }); + for (const capability of [...sharedNativeHosts, 'event:compact/after', 'event:tool/failure']) { + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + evidence: { target: 'claude' }, + state: 'supported', + }); + } + for (const capability of [...sharedNativeHosts, 'event:compact/after']) { + expect(registry.get('codex').capabilities[capability]).toMatchObject({ + evidence: { target: 'codex' }, + state: 'supported', + }); + } + expect(registry.get('codex').capabilities['event:tool/failure']).toMatchObject({ + reason: expect.stringContaining('no tool-failure'), + state: 'unavailable', + }); for (const target of ['claude', 'codex'] as const) { - for (const capability of allNativeHosts) { + for (const capability of sharedNativeHosts) { expect(registry.get(target).capabilities[capability]).toMatchObject({ evidence: { target }, state: 'supported', @@ -957,6 +978,7 @@ it('reports the evidence-backed G10 event family matrix without inferred support for (const capability of [ 'event:agent/start', 'event:agent/stop', + 'event:compact/before', 'event:prompt/submit', 'event:session/end', ]) { @@ -965,16 +987,21 @@ it('reports the evidence-backed G10 event family matrix without inferred support state: 'supported', }); } - expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ - reason: expect.not.stringContaining('pluginPaths'), + expect(registry.get('plugin').capabilities['event:tool/failure']).toMatchObject({ + reason: expect.stringContaining('no tool-failure'), state: 'unavailable', }); - expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ - reason: expect.stringContaining('Claude Code 2.1.250'), + expect(registry.get('plugin').capabilities['event:compact/after']).toMatchObject({ + reason: expect.stringContaining('no postCompact'), + state: 'unavailable', }); - expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ - reason: expect.stringContaining('Codex 0.147.0'), + const workspaceOpen = registry.get('plugin').capabilities['event:workspace/open']; + expect(workspaceOpen).toMatchObject({ + reason: expect.not.stringContaining('pluginPaths'), + state: 'unavailable', }); + expect(workspaceOpen).toMatchObject({ reason: expect.stringContaining('Claude Code 2.1.250') }); + expect(workspaceOpen).toMatchObject({ reason: expect.stringContaining('Codex 0.147.0') }); }); it('reports evidence-backed installation support only for real host targets', () => { diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 116aec780..25ad058ca 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -51,7 +51,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.4.0', + adapterRevision: '1.5.0', observedVersion: '1.0.0', schemas: [ { @@ -98,7 +98,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.20.0', + adapterRevision: '1.21.0', observedVersion: '2.1.250', schemas: [ { @@ -144,7 +144,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.6.0', + adapterRevision: '1.7.0', observedVersion: '2026-08-28', schemas: [ { @@ -210,10 +210,10 @@ it('records observed capability versions and rehashes schema snapshots against p } if (target === 'claude') { - expect(sha256Hex(capability)).toBe('a767ea61ff5a9379133e75096fc44478fdec5d89ea6efa4bee0c9c869649f465'); + expect(sha256Hex(capability)).toBe('6052f0f66b2116d95d6551cd608c40905400546f4248073fae5ea1852a92047d'); } if (target === 'cursor') { - expect(sha256Hex(capability)).toBe('f25f3d27b6f9ef4b75ecb02039f67cc3f3f942faec916b55cdccc49abf1f4f6e'); + expect(sha256Hex(capability)).toBe('dcd7277289b9c7f161d582e40eedcd08e007bc0e233cec883f41363f46af5b54'); const pluginSchema = JSON.parse(await readFile( new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url), 'utf8', @@ -290,6 +290,42 @@ it('pins and validates the Codex 0.147.0 event wire schemas', async () => { name: 'session-end.command.input.schema.json', sha256: '23b1b69f92fa8ac29f8319478984b5aa5aaf09e5ca355ce90aa010452937e41c', }, + { + input: { + cwd: '/workspace', + hook_event_name: 'PreCompact', + model: 'gpt-5.6-sol', + session_id: 'session-codex-1', + transcript_path: null, + trigger: 'manual', + turn_id: 'turn-codex-1', + }, + name: 'pre-compact.command.input.schema.json', + sha256: '065f0ae3cd628ac9af8c0cf9bd1d5a673bcbd5ea1d7dcdc0c6437f34dd0189d9', + }, + { + name: 'pre-compact.command.output.schema.json', + output: { continue: true }, + sha256: 'c392f3054ae6750f427d4dec07380fd67e8c58a7939a35d5c69bfa070c7ca032', + }, + { + input: { + cwd: '/workspace', + hook_event_name: 'PostCompact', + model: 'gpt-5.6-sol', + session_id: 'session-codex-1', + transcript_path: null, + trigger: 'manual', + turn_id: 'turn-codex-1', + }, + name: 'post-compact.command.input.schema.json', + sha256: '4a4b3f3022c939a15ab12e95f5c5c17b18bb20f74fe962ae0a51b2a3e76e63f9', + }, + { + name: 'post-compact.command.output.schema.json', + output: {}, + sha256: '48355bfcb568259cf396beb6ade2ac32827f50bf6a3c20b395c337dce184cbed', + }, ] as const; const validator = createDraft7AdapterValidator(); @@ -302,7 +338,9 @@ it('pins and validates the Codex 0.147.0 event wire schemas', async () => { const validate = validator.compile(JSON.parse(bytes.toString())); const value = 'fixture' in schema ? JSON.parse(await readFile(new URL(`./fixtures/events/${schema.fixture}`, import.meta.url), 'utf8')) - : schema.output; + : 'input' in schema + ? schema.input + : schema.output; expect(validate(value), JSON.stringify(validate.errors)).toBe(true); } }); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 084f5f178..fb4439a02 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -431,7 +431,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated expect(toolWrapper?.virtualSource).toContain('tool_output'); }); -it('lowers route-only prompt and session families without exposing config hook names', () => { +it('lowers supported route-only families without exposing config hook names', () => { const model = plugin(); const plan = cursorAdapter.plan({ ...model, @@ -456,6 +456,26 @@ it('lowers route-only prompt and session families without exposing config hook n targets: ['cursor'], tools: [], }, + { + event: 'toolFailure', + eventRoute: { event: 'tool/failure', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:tool-failure', + name: 'event-route-tool-failure', + provenance: { kind: 'conventional', sourcePath: '/workspace/src/events/tool/failure.tsx' }, + source: '/workspace/src/events/tool/failure.tsx', + targets: ['cursor'], + tools: [], + }, + { + event: 'compactBefore', + eventRoute: { event: 'compact/before', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:compact-before', + name: 'event-route-compact-before', + provenance: { kind: 'conventional', sourcePath: '/workspace/src/events/compact/before.tsx' }, + source: '/workspace/src/events/compact/before.tsx', + targets: ['cursor'], + tools: [], + }, ], }); @@ -471,6 +491,12 @@ it('lowers route-only prompt and session families without exposing config hook n sessionEnd: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-session-end.mjs"', }], + postToolUseFailure: [{ + command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-tool-failure.mjs"', + }], + preCompact: [{ + command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-compact-before.mjs"', + }], }, version: 1, }); diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index 1d64b4e03..429b03bd7 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -160,3 +160,109 @@ it('validates prompt/submit and session/end host envelopes fail closed', () => { target: 'codex', })).toThrow(/native reason must equal other/u); }); + +it('validates failure and compaction envelopes without flattening host differences', () => { + const claudeFailure = { + cwd: '/workspace', + duration_ms: 134, + error: 'Exit code 9\nfailure', + hook_event_name: 'PostToolUseFailure', + is_interrupt: false, + session_id: 'session-1', + tool_input: { command: 'exit 9' }, + tool_name: 'Bash', + tool_use_id: 'tool-1', + transcript_path: '/workspace/transcript.jsonl', + }; + expect(validateNativeEventEnvelope(claudeFailure, { + canonicalEvent: 'tool/failure', + nativeEvent: 'PostToolUseFailure', + target: 'claude', + })).toBe(claudeFailure); + + const cursorFailure = { + conversation_id: 'session-1', + cwd: '/workspace', + duration: 5, + error_message: 'Denied by policy.', + failure_type: 'permission_denied', + hook_event_name: 'postToolUseFailure', + is_interrupt: false, + tool_input: { command: 'rm -rf build' }, + tool_name: 'Shell', + tool_use_id: 'tool-1', + }; + expect(validateNativeEventEnvelope(cursorFailure, { + canonicalEvent: 'tool/failure', + nativeEvent: 'postToolUseFailure', + target: 'cursor', + })).toBe(cursorFailure); + expect(() => validateNativeEventEnvelope({ ...cursorFailure, failure_type: 'denied' }, { + canonicalEvent: 'tool/failure', + nativeEvent: 'postToolUseFailure', + target: 'cursor', + })).toThrow(/native failure_type is invalid/u); + + const claudeBefore = { + custom_instructions: null, + cwd: '/workspace', + hook_event_name: 'PreCompact', + session_id: 'session-1', + transcript_path: '/workspace/transcript.jsonl', + trigger: 'manual', + }; + expect(validateNativeEventEnvelope(claudeBefore, { + canonicalEvent: 'compact/before', + nativeEvent: 'PreCompact', + target: 'claude', + })).toBe(claudeBefore); + + const codexAfter = { + cwd: '/workspace', + hook_event_name: 'PostCompact', + model: 'gpt-5.6-sol', + session_id: 'session-1', + transcript_path: null, + trigger: 'auto', + turn_id: 'turn-1', + }; + expect(validateNativeEventEnvelope(codexAfter, { + canonicalEvent: 'compact/after', + nativeEvent: 'PostCompact', + target: 'codex', + })).toBe(codexAfter); + + const cursorBefore = { + context_tokens: 120_000, + context_usage_percent: 85, + context_window_size: 128_000, + conversation_id: 'session-1', + hook_event_name: 'preCompact', + is_first_compaction: true, + message_count: 45, + messages_to_compact: 30, + trigger: 'auto', + }; + expect(validateNativeEventEnvelope(cursorBefore, { + canonicalEvent: 'compact/before', + nativeEvent: 'preCompact', + target: 'cursor', + })).toBe(cursorBefore); + expect(() => validateNativeEventEnvelope({ ...cursorBefore, context_tokens: '120000' }, { + canonicalEvent: 'compact/before', + nativeEvent: 'preCompact', + target: 'cursor', + })).toThrow(/native context_tokens must be a number/u); + + expect(() => validateNativeEventEnvelope({ + cwd: '/workspace', + hook_event_name: 'PostCompact', + session_id: 'session-1', + transcript_path: '/workspace/transcript.jsonl', + trigger: 'manual', + }, { + canonicalEvent: 'compact/after', + nativeEvent: 'PostCompact', + target: 'claude', + })).toThrow(/native compact_summary must be a string/u); +}); diff --git a/packages/agent-bundle/tests/fixtures/events/claude-post-compact.json b/packages/agent-bundle/tests/fixtures/events/claude-post-compact.json new file mode 100644 index 000000000..97f052d77 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-post-compact.json @@ -0,0 +1,9 @@ +{ + "compact_summary": "Summary of the compacted conversation.", + "cwd": "/workspace", + "hook_event_name": "PostCompact", + "prompt_id": "prompt-claude-1", + "session_id": "session-claude-1", + "transcript_path": "/workspace/.claude/projects/session.jsonl", + "trigger": "manual" +} diff --git a/packages/agent-bundle/tests/fixtures/events/claude-post-tool-use-failure.json b/packages/agent-bundle/tests/fixtures/events/claude-post-tool-use-failure.json new file mode 100644 index 000000000..3607fc696 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-post-tool-use-failure.json @@ -0,0 +1,17 @@ +{ + "cwd": "/workspace", + "duration_ms": 134, + "error": "Exit code 9\nfailure", + "hook_event_name": "PostToolUseFailure", + "is_interrupt": false, + "permission_mode": "bypassPermissions", + "prompt_id": "prompt-claude-1", + "session_id": "session-claude-1", + "tool_input": { + "command": "bash -lc 'printf failure >&2; exit 9'", + "description": "Print failure to stderr and exit 9" + }, + "tool_name": "Bash", + "tool_use_id": "tool-claude-1", + "transcript_path": "/workspace/.claude/projects/session.jsonl" +} diff --git a/packages/agent-bundle/tests/fixtures/events/claude-pre-compact.json b/packages/agent-bundle/tests/fixtures/events/claude-pre-compact.json new file mode 100644 index 000000000..ffd36d60a --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-pre-compact.json @@ -0,0 +1,9 @@ +{ + "custom_instructions": null, + "cwd": "/workspace", + "hook_event_name": "PreCompact", + "prompt_id": "prompt-claude-1", + "session_id": "session-claude-1", + "transcript_path": "/workspace/.claude/projects/session.jsonl", + "trigger": "manual" +} diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index fd08d813a..ad90a0f93 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -48,20 +48,31 @@ const registry: NormalizationTargetRegistry = { supports: (name, capability) => capability === 'hooks' && name !== 'portable', }; -it('maps prompt/submit and session/end only through event-route contracts', () => { +it('maps promoted families only through event-route contracts', () => { const targetRegistry = createDefaultRegistry(); for (const target of ['claude', 'codex', 'cursor']) { const contract = targetRegistry.hookContract(target); - expect(contract?.eventNames).not.toHaveProperty('promptSubmit'); - expect(contract?.eventNames).not.toHaveProperty('sessionEnd'); + for (const event of ['promptSubmit', 'sessionEnd', 'toolFailure', 'compactBefore', 'compactAfter']) { + expect(contract?.eventNames).not.toHaveProperty(event); + } expect(contract?.eventRouteNames?.['prompt/submit']).toBe( target === 'cursor' ? 'beforeSubmitPrompt' : 'UserPromptSubmit', ); expect(contract?.eventRouteNames?.['session/end']).toBe( target === 'cursor' ? 'sessionEnd' : 'SessionEnd', ); + expect(contract?.eventRouteNames?.['tool/failure']).toBe( + target === 'codex' ? undefined : target === 'cursor' ? 'postToolUseFailure' : 'PostToolUseFailure', + ); + expect(contract?.eventRouteNames?.['compact/before']).toBe(target === 'cursor' ? 'preCompact' : 'PreCompact'); + expect(contract?.eventRouteNames?.['compact/after']).toBe(target === 'cursor' ? undefined : 'PostCompact'); expect(contract?.nativeEventStarter?.('prompt/submit')).toBeDefined(); expect(contract?.nativeEventStarter?.('session/end')).toBeDefined(); + if (target === 'codex') expect(contract?.nativeEventStarter?.('tool/failure')).toBeUndefined(); + else expect(contract?.nativeEventStarter?.('tool/failure')).toBeDefined(); + expect(contract?.nativeEventStarter?.('compact/before')).toBeDefined(); + if (target === 'cursor') expect(contract?.nativeEventStarter?.('compact/after')).toBeUndefined(); + else expect(contract?.nativeEventStarter?.('compact/after')).toBeDefined(); } }); diff --git a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts index e95a36093..a592ea2e1 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts @@ -38,6 +38,14 @@ const graph = Object.freeze({ provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/after.tsx' }), source: '/project/src/events/tool/after.tsx', }, + { + config: Object.freeze({}), + event: 'tool/failure', + id: 'event:tool/failure', + kind: 'event-route', + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/failure.tsx' }), + source: '/project/src/events/tool/failure.tsx', + }, ]), providers: Object.freeze([]), scripts: Object.freeze([]), @@ -188,6 +196,27 @@ it('preserves stale and real native-envelope diagnostics at the HTTP boundary', message: 'Agent Bundle event route error: native prompt must be a string', }, }); + + const malformedFailure = await post(`${started.url}/api/lifecycles/replays`, { + binding: { manifestDigest: 'manifest-a', routeId: 'event:tool/failure', target: 'claude' }, + native: { + cwd: '/tmp/lifecycle-replay', + hook_event_name: 'PostToolUseFailure', + session_id: 'session-1', + tool_input: {}, + tool_name: 'Bash', + tool_use_id: 'tool-1', + transcript_path: '/tmp/lifecycle-replay/transcript.jsonl', + }, + source: 'observed', + }); + expect(malformedFailure.status).toBe(400); + await expect(malformedFailure.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8211', + message: 'Agent Bundle event route error: native error must be a string', + }, + }); } finally { await started.close(); } diff --git a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts index b0900e91f..e4556972e 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts @@ -9,6 +9,22 @@ const graph = Object.freeze({ diagnostics: Object.freeze([]), digest: 'manifest-a', events: Object.freeze([ + { + config: Object.freeze({}), + event: 'compact/after', + id: 'event:compact/after', + kind: 'event-route', + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/compact/after.tsx' }), + source: '/project/src/events/compact/after.tsx', + }, + { + config: Object.freeze({}), + event: 'compact/before', + id: 'event:compact/before', + kind: 'event-route', + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/compact/before.tsx' }), + source: '/project/src/events/compact/before.tsx', + }, { config: Object.freeze({}), event: 'prompt/submit', @@ -33,6 +49,14 @@ const graph = Object.freeze({ provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/after.tsx' }), source: '/project/src/events/tool/after.tsx', }, + { + config: Object.freeze({}), + event: 'tool/failure', + id: 'event:tool/failure', + kind: 'event-route', + provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/failure.tsx' }), + source: '/project/src/events/tool/failure.tsx', + }, ]), providers: Object.freeze([]), scripts: Object.freeze([]), @@ -54,7 +78,7 @@ it('projects event routes across concrete hosts and diagnoses excluded targets', const listed = service().list(); expect(listed.manifestDigest).toBe('manifest-a'); - expect(listed.lifecycles).toHaveLength(3); + expect(listed.lifecycles).toHaveLength(6); expect(listed.lifecycles.find((lifecycle) => lifecycle.event === 'tool/after')).toMatchObject({ event: 'tool/after', routeId: 'event:tool/after', @@ -65,13 +89,35 @@ it('projects event routes across concrete hosts and diagnoses excluded targets', { nativeEvent: 'postToolUse', target: 'cursor' }, ], }); - for (const event of ['prompt/submit', 'session/end'] as const) { + for (const event of ['compact/before', 'prompt/submit', 'session/end'] as const) { expect(listed.lifecycles.find((lifecycle) => lifecycle.event === event)?.targets).toEqual([ expect.objectContaining({ target: 'claude' }), expect.objectContaining({ target: 'codex' }), expect.objectContaining({ target: 'cursor' }), ]); } + expect(listed.lifecycles.find((lifecycle) => lifecycle.event === 'tool/failure')?.targets).toEqual([ + expect.objectContaining({ nativeEvent: 'PostToolUseFailure', target: 'claude' }), + expect.objectContaining({ nativeEvent: 'postToolUseFailure', target: 'cursor' }), + ]); + expect(listed.lifecycles.find((lifecycle) => lifecycle.event === 'tool/failure')?.diagnostics) + .toContainEqual({ + code: 'lifecycle.target.unsupported', + message: 'Lifecycle replay target "codex" cannot map canonical event "tool/failure".', + severity: 'error', + target: 'codex', + }); + expect(listed.lifecycles.find((lifecycle) => lifecycle.event === 'compact/after')?.targets).toEqual([ + expect.objectContaining({ nativeEvent: 'PostCompact', target: 'claude' }), + expect.objectContaining({ nativeEvent: 'PostCompact', target: 'codex' }), + ]); + expect(listed.lifecycles.find((lifecycle) => lifecycle.event === 'compact/after')?.diagnostics) + .toContainEqual({ + code: 'lifecycle.target.unsupported', + message: 'Lifecycle replay target "cursor" cannot map canonical event "compact/after".', + severity: 'error', + target: 'cursor', + }); const toolAfter = listed.lifecycles.find((lifecycle) => lifecycle.event === 'tool/after'); expect(toolAfter?.targets.every((target) => target.target !== 'plugin')).toBe(true); expect(toolAfter?.diagnostics).toContainEqual({ diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index cce8569c5..f18bd24ce 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -764,6 +764,8 @@ it('discovers the canonical event families and validates their component contrac await writeTree(root, { 'src/events/agent/start.tsx': eventSource, 'src/events/agent/stop.tsx': eventSource, + 'src/events/compact/after.tsx': eventSource, + 'src/events/compact/before.tsx': eventSource, 'src/events/message/receive.tsx': eventSource, 'src/events/prompt/submit.tsx': eventSource, 'src/events/session/end.tsx': eventSource, @@ -771,6 +773,7 @@ it('discovers the canonical event families and validates their component contrac 'src/events/stop.tsx': eventSource, 'src/events/tool/after.tsx': eventSource, 'src/events/tool/before.tsx': 'export default function BeforeTool() { return undefined; }\n', + 'src/events/tool/failure.tsx': eventSource, 'src/events/workspace/open.tsx': eventSource, }); @@ -779,23 +782,29 @@ it('discovers the canonical event families and validates their component contrac expect(graph.events.map((route) => route.id)).toEqual([ 'event:agent/start', 'event:agent/stop', + 'event:compact/after', + 'event:compact/before', 'event:prompt/submit', 'event:session/end', 'event:session/start', 'event:stop', 'event:tool/after', 'event:tool/before', + 'event:tool/failure', 'event:workspace/open', ]); expect(graph.events.map((route) => route.event)).toEqual([ 'agent/start', 'agent/stop', + 'compact/after', + 'compact/before', 'prompt/submit', 'session/end', 'session/start', 'stop', 'tool/after', 'tool/before', + 'tool/failure', 'workspace/open', ]); expect(graph.diagnostics.map(({ code }) => code)).toEqual(['AB4823', 'AB4810']); diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index 4e93a7924..d1c5f2642 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -309,3 +309,148 @@ it('projects session/end as observation-only on every host', async () => { expect(() => projectEventDocument(context.document, 'session/end', 'claude', 'SessionEnd')) .toThrow(/session\/end is observation-only.*no context\/output channel/u); }); + +it('projects tool/failure context only through Claude PostToolUseFailure', async () => { + const props = createCanonicalEventProps( + 'tool/failure', + { + cwd: '/workspace', + error: 'Exit code 9\nfailure', + hook_event_name: 'PostToolUseFailure', + session_id: 'session-1', + tool_input: { command: 'exit 9' }, + tool_name: 'Bash', + tool_use_id: 'tool-1', + transcript_path: '/workspace/transcript.jsonl', + }, + 'claude', + 'PostToolUseFailure', + '2.1.250', + new AbortController().signal, + ); + const routeInput = { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:tool/failure', + } as const; + const context = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Inspect the failed command output.'), + ), + }, routeInput); + expect(projectEventDocument(context.document, 'tool/failure', 'claude', 'PostToolUseFailure')).toEqual({ + hookSpecificOutput: { + additionalContext: 'Inspect the failed command output.', + hookEventName: 'PostToolUseFailure', + }, + }); + expect(() => projectEventDocument(context.document, 'tool/failure', 'cursor', 'postToolUseFailure')) + .toThrow(/Cursor postToolUseFailure has no context\/output channel/u); + + const denied = await renderRoute({ + default: async () => createElement( + Agent.Result, + { value: { outcome: 'deny', reason: 'Undo the failure.' } }, + ), + }, routeInput); + expect(() => projectEventDocument(denied.document, 'tool/failure', 'claude', 'PostToolUseFailure')) + .toThrow(/tool\/failure cannot deny or replace native input/u); +}); + +it('projects compact/before only through Claude blocking control', async () => { + const props = createCanonicalEventProps( + 'compact/before', + { + custom_instructions: null, + cwd: '/workspace', + hook_event_name: 'PreCompact', + session_id: 'session-1', + transcript_path: '/workspace/transcript.jsonl', + trigger: 'manual', + }, + 'claude', + 'PreCompact', + '2.1.250', + new AbortController().signal, + ); + const routeInput = { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:compact/before', + } as const; + const observed = await renderRoute({ + default: async () => createElement(Agent.Result), + }, routeInput); + for (const target of ['claude', 'codex', 'cursor']) { + expect(projectEventDocument(observed.document, 'compact/before', target, target === 'cursor' ? 'preCompact' : 'PreCompact')) + .toBeUndefined(); + } + + const denied = await renderRoute({ + default: async () => createElement( + Agent.Result, + { value: { outcome: 'deny', reason: 'Preserve the current context.' } }, + ), + }, routeInput); + expect(projectEventDocument(denied.document, 'compact/before', 'claude', 'PreCompact')).toEqual({ + decision: 'block', + reason: 'Preserve the current context.', + }); + expect(() => projectEventDocument(denied.document, 'compact/before', 'codex', 'PreCompact')) + .toThrow(/Codex PreCompact common-control runtime semantics are unproven/u); + expect(() => projectEventDocument(denied.document, 'compact/before', 'cursor', 'preCompact')) + .toThrow(/Cursor preCompact is observational/u); + + const context = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Compaction notice.'), + ), + }, routeInput); + expect(() => projectEventDocument(context.document, 'compact/before', 'cursor', 'preCompact')) + .toThrow(/user_message is user-facing.*cannot be represented by Agent.Context/u); +}); + +it('projects compact/after as observation-only on supported hosts', async () => { + const props = createCanonicalEventProps( + 'compact/after', + { + compact_summary: 'Summary of the compacted conversation.', + cwd: '/workspace', + hook_event_name: 'PostCompact', + session_id: 'session-1', + transcript_path: '/workspace/transcript.jsonl', + trigger: 'manual', + }, + 'claude', + 'PostCompact', + '2.1.250', + new AbortController().signal, + ); + const routeInput = { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:compact/after', + } as const; + const observed = await renderRoute({ + default: async () => createElement(Agent.Result), + }, routeInput); + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(observed.document, 'compact/after', target, 'PostCompact')).toBeUndefined(); + } + + const rejectedValues: readonly JsonValue[] = [ + { outcome: 'deny', reason: 'Restore the previous context.' }, + { updatedInput: { compact_summary: 'Replacement.' } }, + ]; + for (const value of rejectedValues) { + const rejected = await renderRoute({ + default: async () => createElement(Agent.Result, { value }), + }, routeInput); + expect(() => projectEventDocument(rejected.document, 'compact/after', 'claude', 'PostCompact')) + .toThrow(/compact\/after is observation-only/u); + } +}); diff --git a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts index 2f9865b37..9a8033bda 100644 --- a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts +++ b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts @@ -30,6 +30,22 @@ const createFixtureProject = async () => { await symlink(join(process.cwd(), 'node_modules'), join(root, 'node_modules'), 'dir'); await Promise.all([ writeProjectFile(root, 'package.json', JSON.stringify({ name: 'lifecycle-replay-fixture', type: 'module' })), + writeProjectFile(root, 'src/events/compact/after.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + 'export default async function CompactAfter() {', + ' return createElement(Agent.Result);', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/compact/before.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + 'export default async function CompactBefore() {', + ' return createElement(Agent.Result);', + '}', + '', + ].join('\n')), writeProjectFile(root, 'src/events/prompt/submit.tsx', [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", @@ -60,12 +76,28 @@ const createFixtureProject = async () => { '}', '', ].join('\n')), + writeProjectFile(root, 'src/events/tool/failure.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { targets: ['claude'] };", + 'export default async function ToolFailure() {', + ' return createElement(', + ' Agent.Result,', + ' null,', + " createElement(Agent.Context, null, 'Lifecycle failure context.'),", + ' );', + '}', + '', + ].join('\n')), ]); const graph = await compileRouteGraph(root, { targets: ['claude', 'codex'] } as never); expect(graph.events.map((route) => route.id)).toEqual([ + 'event:compact/after', + 'event:compact/before', 'event:prompt/submit', 'event:session/end', 'event:tool/after', + 'event:tool/failure', ]); return { graph, root }; }; @@ -241,6 +273,65 @@ it('preserves replay invocation provenance for an in-process route observing age }); }); +it('replays captured failure and compact envelopes through native projection', { timeout: 30_000 }, async () => { + const { graph } = await createFixtureProject(); + const service = new LifecycleReplayService({ + prepared: () => ({ graph, targets: ['claude', 'codex'] }), + }); + const failureNative = JSON.parse(await readFile( + new URL('../fixtures/events/claude-post-tool-use-failure.json', import.meta.url), + 'utf8', + )) as Record; + const failure = await service.replay({ + binding: { + manifestDigest: graph.digest, + routeId: 'event:tool/failure', + target: 'claude', + }, + native: failureNative, + source: 'fixture', + }); + expect('diagnostics' in failure).toBe(false); + expect((failure as LifecycleReplay).nativeResponse).toEqual({ + hookSpecificOutput: { + additionalContext: 'Lifecycle failure context.', + hookEventName: 'PostToolUseFailure', + }, + }); + + for (const phase of ['before', 'after'] as const) { + const claudeNative = JSON.parse(await readFile( + new URL(`../fixtures/events/claude-${phase === 'before' ? 'pre' : 'post'}-compact.json`, import.meta.url), + 'utf8', + )) as Record; + const claude = await service.replay({ + binding: { + manifestDigest: graph.digest, + routeId: `event:compact/${phase}`, + target: 'claude', + }, + native: claudeNative, + source: 'fixture', + }); + expect('diagnostics' in claude).toBe(false); + expect((claude as LifecycleReplay).nativeResponse).toBeUndefined(); + + const lifecycle = service.list().lifecycles.find((candidate) => candidate.routeId === `event:compact/${phase}`); + const codexTarget = lifecycle?.targets.find((candidate) => candidate.target === 'codex'); + const codex = await service.replay({ + binding: { + manifestDigest: graph.digest, + routeId: `event:compact/${phase}`, + target: 'codex', + }, + native: codexTarget!.fixture!.native, + source: 'fixture', + }); + expect('diagnostics' in codex).toBe(false); + expect((codex as LifecycleReplay).nativeResponse).toBeUndefined(); + } +}); + it('replays the Cursor workspaceOpen starter as an observation with no native response', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-lifecycle-replay-workspace-open-')); roots.push(root); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index f764f81e1..a9f297647 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -82,6 +82,9 @@ it('keeps event-route-only hook identities out of config hook parsing', () => { expect(canonicalHookEventFor('sessionStart')).toBe('sessionStart'); expect(canonicalHookEventFor('sessionEnd')).toBeUndefined(); expect(canonicalHookEventFor('promptSubmit')).toBeUndefined(); + expect(canonicalHookEventFor('toolFailure')).toBeUndefined(); + expect(canonicalHookEventFor('compactBefore')).toBeUndefined(); + expect(canonicalHookEventFor('compactAfter')).toBeUndefined(); }); it('plans route-only hook identities through event route mappings', () => {