From f88e316c72c19d4e332bfcec68abe7fdc5888290 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 20:20:15 +0000 Subject: [PATCH 1/3] fix(events): correct Claude and Codex subagent hooks Replace the stale unavailable evidence with pinned native contracts and keep event projection honest about start/stop control semantics. --- .changeset/correct-subagent-event-support.md | 8 ++ examples/rsc-agent-runtime/README.md | 19 ++++ .../adapters/capabilities/claude-2.1.250.json | 18 ++-- .../adapters/capabilities/codex-0.147.0.json | 27 ++++-- packages/agent-bundle/src/adapters/claude.ts | 7 +- packages/agent-bundle/src/adapters/codex.ts | 7 +- .../src/adapters/hook-contract.ts | 86 ++++++++++++++++-- packages/agent-bundle/src/adapters/plugin.ts | 2 +- .../adapters/schemas/claude/PROVENANCE.json | 4 +- .../adapters/schemas/codex/PROVENANCE.json | 3 +- .../subagent-start.command.input.schema.json | 63 +++++++++++++ .../subagent-start.command.output.schema.json | 51 +++++++++++ .../subagent-stop.command.input.schema.json | 75 ++++++++++++++++ .../subagent-stop.command.output.schema.json | 45 ++++++++++ packages/agent-bundle/src/events/project.ts | 53 ++++++++++- .../tests/adapter-capability-states.test.ts | 23 +++-- .../tests/adapter-metadata.test.ts | 53 ++++++++++- .../agent-bundle/tests/event-project.test.ts | 90 +++++++++++++++++++ .../events/claude-subagent-start.json | 8 ++ .../fixtures/events/claude-subagent-stop.json | 14 +++ .../fixtures/events/codex-subagent-start.json | 11 +++ .../fixtures/events/codex-subagent-stop.json | 14 +++ .../tests/generated-route-server.test.ts | 69 ++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 89 +++++++++++++++++- .../agent-bundle/tests/plugin-bundle.test.ts | 33 +++++++ 25 files changed, 822 insertions(+), 50 deletions(-) create mode 100644 .changeset/correct-subagent-event-support.md create mode 100644 packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json create mode 100644 packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json create mode 100644 packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json create mode 100644 packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json create mode 100644 packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json create mode 100644 packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json create mode 100644 packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json create mode 100644 packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json diff --git a/.changeset/correct-subagent-event-support.md b/.changeset/correct-subagent-event-support.md new file mode 100644 index 000000000..dfd6a06dc --- /dev/null +++ b/.changeset/correct-subagent-event-support.md @@ -0,0 +1,8 @@ +--- +"agent-bundle": minor +--- + +Correct Claude Code and Codex event-route capabilities for native +`SubagentStart` and `SubagentStop` hooks, including host-specific input +validation, result projection, plugin packaging, and pinned Codex wire-schema +evidence. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 252440f82..ad579712f 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -225,6 +225,25 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the | Claude Code | Native package contract: `${CLAUDE_PLUGIN_ROOT}` MCP/hook paths and `Write|Edit` hook | Real host run is a skip-gated manual opt-in; no attached native evidence in this snapshot; never an iframe renderer | | Codex CLI | Native package contract: marketplace, relative MCP path, `${PLUGIN_ROOT}` hook, and `apply_patch` matcher | Real host run is a skip-gated manual opt-in; no attached native evidence in this snapshot; native PostToolUse/shared state remains unproven under `exec --ephemeral` | +### Semantic event-route support + +| Event family | Cursor | Claude Code 2.1.250 | Codex 0.147.0 | +| --- | --- | --- | --- | +| `session/start` | Supported | `SessionStart` | `SessionStart` | +| `tool/before` | Supported | `PreToolUse` | `PreToolUse` | +| `tool/after` | Supported | `PostToolUse` | `PostToolUse` | +| `stop` | Supported | `Stop` | `Stop` | +| `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` | +| `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` | +| `workspace/open` | `workspaceOpen` | Unavailable | Unavailable | + +`agent/start` is context-injection-only on Claude Code and Codex and cannot +block subagent creation. Their `agent/stop` routes can continue the subagent +with the native `decision: "block"` plus `reason` contract. Codex +`SubagentStop` exit-0 output is always JSON; its generated 0.147.0 output +schema has no `additionalContext` field, so the route projection rejects that +unsupported effect rather than silently fabricating one. + ## Extension-author guide The definition and lowerer keep serializable namespaced descriptor, resource, and result `_meta` opaque. Add a vendor extension as `_meta["vendor.example/feature"]`, merge it with `mergeSerializableMetadata`, and keep it out of model-visible text unless it belongs there. The optional Claude resource domain is added only to `resources/read` content metadata when an explicit public MCP URL is supplied; it is not added to resource registration by default. 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 55d11b301..b843d43ea 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 @@ -3,20 +3,16 @@ "hooks": { "config": "hooks/hooks.json", "events": { + "agentStart": "SubagentStart", + "agentStop": "SubagentStop", "afterTool": "PostToolUse", "beforeTool": "PreToolUse", "sessionStart": "SessionStart", "stop": "Stop" }, "eventRoutes": { - "agent/start": { - "reason": "The pinned Claude Code 2.1.250 hooks contract has no subagent-start event.", - "state": "unavailable" - }, - "agent/stop": { - "reason": "The pinned Claude Code 2.1.250 hooks contract has no subagent-stop event.", - "state": "unavailable" - }, + "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" }, + "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, "session/start": { "nativeEvent": "SessionStart", "state": "supported" }, "stop": { "nativeEvent": "Stop", "state": "supported" }, "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, @@ -92,7 +88,11 @@ "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.", "Plugin developer tools reference: `claude plugin validate ` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.", "`claude plugin validate --strict` promotes tolerated warnings such as unrecognized or near-miss fields and non-object experimental/metadata values to exit failure; the reference recommends strict mode in CI.", - "Development tools include `claude --plugin-dir plugin list --json` for registration proof, `claude plugin details ` for component inventory and host-owned token estimates, `claude plugin tag`, and `claude --debug` for loading diagnostics." + "Development tools include `claude --plugin-dir plugin list --json` for registration proof, `claude plugin details ` for component inventory and host-owned token estimates, `claude plugin tag`, and `claude --debug` for loading diagnostics.", + "https://code.claude.com/docs/en/hooks documents SubagentStart when Agent spawns a subagent and SubagentStop when it finishes; both match agent_type, including anchored plugin-scoped identifiers such as ^my-plugin:reviewer$.", + "SubagentStart adds agent_id and agent_type to common hook fields. It cannot block subagent creation; hookSpecificOutput.additionalContext injects context before the first subagent prompt, and exit-2 stderr is only a non-blocking notice in the subagent transcript.", + "SubagentStop adds stop_hook_active, agent_id, agent_type, agent_transcript_path, and last_assistant_message. decision:block plus reason or exit 2 keeps the subagent running; hookSpecificOutput.additionalContext provides non-error feedback that also continues it.", + "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." ] } } 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 c8eb29fe8..f70b12abd 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 @@ -3,20 +3,16 @@ "hooks": { "config": "hooks/hooks.json", "events": { + "agentStart": "SubagentStart", + "agentStop": "SubagentStop", "afterTool": "PostToolUse", "beforeTool": "PreToolUse", "sessionStart": "SessionStart", "stop": "Stop" }, "eventRoutes": { - "agent/start": { - "reason": "The pinned Codex 0.147.0 hooks contract has no subagent-start event.", - "state": "unavailable" - }, - "agent/stop": { - "reason": "The pinned Codex 0.147.0 hooks contract has no subagent-stop event.", - "state": "unavailable" - }, + "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" }, + "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, "session/start": { "nativeEvent": "SessionStart", "state": "supported" }, "stop": { "nativeEvent": "Stop", "state": "supported" }, "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, @@ -46,5 +42,20 @@ "pluginData": false, "pluginRoot": "relative-with-plugin-root-cwd", "workspaceRoot": false + }, + "provenance": { + "observedAt": "2026-09-01", + "source": "https://learn.chatgpt.com/docs/hooks", + "evidence": [ + "The Codex 0.147.0 hooks reference lists SubagentStart and SubagentStop as lifecycle events whose matcher filters agent_type; subagent hook session_id is the parent session id.", + "SubagentStart adds turn_id, agent_id, agent_type, and permission_mode. Plain stdout or hookSpecificOutput.additionalContext adds developer context to the subagent; continue:false is parsed but does not stop creation.", + "SubagentStop adds turn_id, agent_id, agent_type, agent_transcript_path, stop_hook_active, and last_assistant_message. Exit-0 stdout must be JSON; decision:block plus reason or exit 2 continues the subagent flow, while continue:false takes precedence.", + "Codex discovers enabled plugin hooks at plugin-root hooks/hooks.json by default or from a ./-prefixed path, path array, inline object, or inline-object array in .codex-plugin/plugin.json. Paths must remain inside the plugin root; installation does not auto-trust non-managed hooks.", + "Plugin hook commands receive PLUGIN_ROOT and PLUGIN_DATA plus CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA compatibility aliases.", + "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-start.command.input.schema.json (sha256 ce7dc9b5ae8826d1e0c59ffcea793e558aebceb7917a2eb9bb2edd8a7ac37aa9).", + "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json (sha256 34e8ec95393d2aa930d7932a34c3fb29a5e5f90c264fdbcc581393c5838b4660).", + "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-stop.command.input.schema.json (sha256 94dc8df29f4691195ac2338ae6de876230e5100a10b94ef48df4e732424b5df5).", + "Pinned generated schema: https://github.com/openai/codex/blob/rust-v0.147.0/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json (sha256 8ba2cd7899ae4544193764e67e988235edebe984abe5788634d123bbf13e3e3a)." + ] } } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index d4bb783ea..a7a616717 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -121,7 +121,8 @@ const hookContract = Object.freeze({ capabilityRevision: capabilityTable.observedCliVersion, commandRoot: '${CLAUDE_PLUGIN_ROOT}', encodePlaygroundInput: encodeNativeHookPlaygroundInput, - encodePlaygroundOutput: encodeNativeHookPlaygroundOutput, + encodePlaygroundOutput: (result, event, nativeEvent) => + encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'claude'), eventNames: capabilityTable.hooks.events, eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes), manifestPath: 'hooks/hooks.json', @@ -131,9 +132,9 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: '13bc41224c5343b33d259986a66feb279e15431c5019bb2a1c443eaa60e9a9ea', + capabilitySha256: 'a9c8821ee5cbc6aef65816c5025170389fd490b6d1c4e4e893599aa6a36f2265', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index b845da9c7..96bf547ff 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -108,7 +108,8 @@ const hookContract = Object.freeze({ capabilityRevision: capabilityTable.observedCliVersion, commandRoot: '${PLUGIN_ROOT}', encodePlaygroundInput: encodeNativeHookPlaygroundInput, - encodePlaygroundOutput: encodeNativeHookPlaygroundOutput, + encodePlaygroundOutput: (result, event, nativeEvent) => + encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'codex'), eventNames: capabilityTable.hooks.events, eventRouteNames: supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes), manifestPath: 'hooks/hooks.json', @@ -118,9 +119,9 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: 'f2c7109e3572ebde8739e08f6fdfa7a7b5d7817e3e216406b9c855f1570e2bd9', + capabilitySha256: '44e697be71a29db9ec029ed7d9eb8807b90e95d6a15f3a71a47148125c902194', 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 02e3d7891..ca3850e67 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -173,9 +173,16 @@ export const readCursorNativeHookCommands = (document: unknown): TargetNativeHoo }; const nativeHookInputFields = Object.freeze([ + Object.freeze({ canonical: 'agentId', native: 'agent_id' }), + Object.freeze({ canonical: 'agentTranscriptPath', native: 'agent_transcript_path' }), + Object.freeze({ canonical: 'agentType', native: 'agent_type' }), Object.freeze({ canonical: 'cwd', native: 'cwd' }), + Object.freeze({ canonical: 'effort', native: 'effort' }), Object.freeze({ canonical: 'hookEventName', native: 'hook_event_name' }), Object.freeze({ canonical: 'lastAssistantMessage', native: 'last_assistant_message' }), + Object.freeze({ canonical: 'model', native: 'model' }), + Object.freeze({ canonical: 'permissionMode', native: 'permission_mode' }), + Object.freeze({ canonical: 'promptId', native: 'prompt_id' }), Object.freeze({ canonical: 'sessionId', native: 'session_id' }), Object.freeze({ canonical: 'source', native: 'source' }), Object.freeze({ canonical: 'stopHookActive', native: 'stop_hook_active' }), @@ -184,6 +191,7 @@ const nativeHookInputFields = Object.freeze([ Object.freeze({ canonical: 'toolResponse', native: 'tool_response' }), Object.freeze({ canonical: 'toolUseId', native: 'tool_use_id' }), Object.freeze({ canonical: 'transcriptPath', native: 'transcript_path' }), + Object.freeze({ canonical: 'turnId', native: 'turn_id' }), ]); const defined = (value: Record): Record => @@ -216,9 +224,20 @@ export const encodeNativeHookPlaygroundOutput = ( result: Readonly> | undefined, canonicalEvent: CanonicalHookEvent, nativeEvent: string, + target?: 'claude' | 'codex', ): Readonly> | undefined => { if (result === undefined) return undefined; - if (canonicalEvent === 'stop') { + if (canonicalEvent === 'stop' || canonicalEvent === 'agentStop') { + if (canonicalEvent === 'agentStop' && target === 'claude' && result.outcome !== 'deny') { + return result.additionalContext === undefined + ? undefined + : { + hookSpecificOutput: defined({ + additionalContext: result.additionalContext, + hookEventName: nativeEvent, + }), + }; + } return result.outcome === 'deny' ? defined({ decision: 'block', reason: result.reason }) : undefined; @@ -339,7 +358,11 @@ const eventRouteHookWrapperSource = ( ' return input;', ' }', ' requireString(input, "session_id");', - ' requireString(input, "transcript_path");', + ' if (target === "codex") {', + ' if (input.transcript_path !== null && typeof input.transcript_path !== "string") fail("native transcript_path must be a string or null");', + ' } else {', + ' requireString(input, "transcript_path");', + ' }', ' requireString(input, "cwd");', ' if (canonicalEvent === "session/start") requireString(input, "source");', ' if (canonicalEvent === "tool/before" || canonicalEvent === "tool/after") {', @@ -348,6 +371,21 @@ const eventRouteHookWrapperSource = ( ' requireString(input, "tool_use_id");', ' if (canonicalEvent === "tool/after" && !isRecord(input.tool_response)) fail("native tool_response must be an object");', ' }', + ' if (canonicalEvent === "agent/start" || canonicalEvent === "agent/stop") {', + ' requireString(input, "agent_id");', + ' requireString(input, "agent_type");', + ' if (target === "codex") {', + ' requireString(input, "turn_id");', + ' requireString(input, "model");', + ' requireString(input, "permission_mode");', + ' if (!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"].includes(input.permission_mode)) fail("native permission_mode is invalid");', + ' }', + ' if (canonicalEvent === "agent/stop") {', + ' if (typeof input.stop_hook_active !== "boolean") fail("native stop_hook_active must be a boolean");', + ' if (input.agent_transcript_path !== null && typeof input.agent_transcript_path !== "string") fail("native agent_transcript_path must be a string or null");', + ' if (input.last_assistant_message !== null && typeof input.last_assistant_message !== "string") fail("native last_assistant_message must be a string or null");', + ' }', + ' }', ' if (canonicalEvent === "stop") {', ' if (typeof input.stop_hook_active !== "boolean") fail("native stop_hook_active must be a boolean");', ' requireString(input, "last_assistant_message");', @@ -811,16 +849,23 @@ export const nativeHookWrapperSource = ( ' if (result.reason !== undefined && typeof result.reason !== "string") fail("handler result reason must be a string");', ' if (result.additionalContext !== undefined && typeof result.additionalContext !== "string") fail("handler result additionalContext must be a string");', ' if (result.updatedInput !== undefined && !isRecord(result.updatedInput)) fail("handler result updatedInput must be an object");', - ' if (result.reason !== undefined && !(result.outcome === "deny" && (canonicalEvent === "beforeTool" || canonicalEvent === "stop"))) fail("reason is only valid for a denied beforeTool or stop hook");', - ' if (result.outcome === "deny" && (canonicalEvent === "beforeTool" || canonicalEvent === "stop") && (typeof result.reason !== "string" || result.reason.trim().length === 0)) fail(`denied ${canonicalEvent} hook requires a nonempty reason`);', - ' if ((canonicalEvent === "sessionStart" || canonicalEvent === "afterTool") && (result.outcome === "deny" || result.outcome === "stop" || result.updatedInput !== undefined)) fail(`${canonicalEvent} cannot deny, stop, or replace input`);', + ' const supportsDeniedReason = canonicalEvent === "beforeTool" || canonicalEvent === "stop" || canonicalEvent === "agentStop";', + ' if (result.reason !== undefined && !(result.outcome === "deny" && supportsDeniedReason)) fail("reason is only valid for a denied beforeTool, stop, or agentStop hook");', + ' if (result.outcome === "deny" && supportsDeniedReason && (typeof result.reason !== "string" || result.reason.trim().length === 0)) fail(`denied ${canonicalEvent} hook requires a nonempty reason`);', + ' if ((canonicalEvent === "sessionStart" || canonicalEvent === "afterTool" || canonicalEvent === "agentStart") && (result.outcome === "deny" || result.outcome === "stop" || result.updatedInput !== undefined)) fail(`${canonicalEvent} cannot deny, stop, or replace input`);', ' if (canonicalEvent === "beforeTool" && (result.outcome === "stop" || (result.outcome === "deny" && result.updatedInput !== undefined))) fail("beforeTool cannot stop or replace input while denying");', ' if (canonicalEvent === "stop" && (result.outcome === "stop" || result.updatedInput !== undefined || result.additionalContext !== undefined)) fail("stop only accepts continue or deny with a reason");', + ' if (canonicalEvent === "agentStop" && (result.outcome === "stop" || result.updatedInput !== undefined)) fail("agentStop cannot stop the parent flow or replace input");', + ' if (canonicalEvent === "agentStop" && target === "codex" && result.additionalContext !== undefined) fail("Codex SubagentStop does not support additionalContext");', ' return result;', '};', 'const encodeOutput = (result) => {', ' if (result === undefined) return undefined;', - ' if (canonicalEvent === "stop") return result.outcome === "deny" ? defined({ decision: "block", reason: result.reason }) : undefined;', + ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") {', + ' if (result.outcome === "deny") return defined({ decision: "block", reason: result.reason });', + ' if (canonicalEvent === "agentStop" && target === "claude" && result.additionalContext !== undefined) return { hookSpecificOutput: { additionalContext: result.additionalContext, hookEventName: nativeEvent } };', + ' return undefined;', + ' }', ' const output = defined({', ' additionalContext: result.additionalContext,', ' hookEventName: nativeEvent,', @@ -832,7 +877,11 @@ export const nativeHookWrapperSource = ( '};', 'const decodeOutput = (nativeOutput) => {', ' if (nativeOutput === undefined) return undefined;', - ' if (canonicalEvent === "stop") return nativeOutput.decision === "block" ? defined({ outcome: "deny", reason: nativeOutput.reason }) : undefined;', + ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") {', + ' if (nativeOutput.decision === "block") return defined({ outcome: "deny", reason: nativeOutput.reason });', + ' if (canonicalEvent === "agentStop" && target === "claude" && isRecord(nativeOutput.hookSpecificOutput)) return defined({ additionalContext: nativeOutput.hookSpecificOutput.additionalContext, outcome: "continue" });', + ' return undefined;', + ' }', ' const output = nativeOutput.hookSpecificOutput;', ' if (!isRecord(output)) fail("native hook output is malformed");', ' return defined({', @@ -845,11 +894,17 @@ export const nativeHookWrapperSource = ( 'const requireString = (input, field) => {', ' if (typeof input[field] !== "string") fail(`native ${field} must be a string`);', '};', + 'const requireNullableString = (input, field) => {', + ' if (input[field] !== null && typeof input[field] !== "string") fail(`native ${field} must be a string or null`);', + '};', 'const validateNativeInput = (input) => {', ' requireString(input, "session_id");', - ' requireString(input, "transcript_path");', + ' if (target === "codex") requireNullableString(input, "transcript_path"); else requireString(input, "transcript_path");', ' requireString(input, "cwd");', ' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);', + ' if (input.prompt_id !== undefined) requireString(input, "prompt_id");', + ' if (input.permission_mode !== undefined) requireString(input, "permission_mode");', + ' if (input.model !== undefined) requireString(input, "model");', ' if (canonicalEvent === "sessionStart") { requireString(input, "source"); return; }', ' if (canonicalEvent === "beforeTool" || canonicalEvent === "afterTool") {', ' requireString(input, "tool_name");', @@ -858,6 +913,21 @@ export const nativeHookWrapperSource = ( ' if (canonicalEvent === "afterTool" && !isRecord(input.tool_response)) fail("native PostToolUse tool_response must be an object");', ' return;', ' }', + ' if (canonicalEvent === "agentStart" || canonicalEvent === "agentStop") {', + ' requireString(input, "agent_id");', + ' requireString(input, "agent_type");', + ' if (target === "codex") {', + ' requireString(input, "turn_id");', + ' requireString(input, "model");', + ' requireString(input, "permission_mode");', + ' if (!["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"].includes(input.permission_mode)) fail("native permission_mode is invalid");', + ' }', + ' if (canonicalEvent === "agentStart") return;', + ' if (typeof input.stop_hook_active !== "boolean") fail("native SubagentStop stop_hook_active must be a boolean");', + ' requireNullableString(input, "agent_transcript_path");', + ' requireNullableString(input, "last_assistant_message");', + ' return;', + ' }', ' if (typeof input.stop_hook_active !== "boolean") fail("native Stop stop_hook_active must be a boolean");', ' requireString(input, "last_assistant_message");', '};', diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 0cc1b92bf..6f3e7d188 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -167,7 +167,7 @@ const artifactValidation = Object.freeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion}`, capabilitySha256: claudeAdapter.metadata.capabilitySha256, observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}`, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 3f96307d1..9ea03b134 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -1,8 +1,8 @@ { "observedCliVersion": "2.1.250", - "retrievedAt": "2026-08-28", + "retrievedAt": "2026-09-01", "schemaSource": "https://docs.anthropic.com/en/docs/claude-code/plugins", - "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code 2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server.", + "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json.", "schemas": { "hooks.schema.json": { "bytes": 1108, diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index 3f7ca881a..6f2e2aa45 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -1,7 +1,8 @@ { "observedCliVersion": "0.147.0", - "retrievedAt": "2026-08-14", + "retrievedAt": "2026-09-01", "schemaSource": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md", + "notes": "The generated/subagent-{start,stop}.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. 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.", "schemas": { "hooks.schema.json": { "bytes": 1107, diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json new file mode 100644 index 000000000..a7bbe8a70 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.input.schema.json @@ -0,0 +1,63 @@ +{ + "$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": "SubagentStart", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_type", + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "transcript_path", + "turn_id" + ], + "title": "subagent-start.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json new file mode 100644 index 000000000..eb1fffda1 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-start.command.output.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "SubagentStartHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "const": "SubagentStart", + "type": "string" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/SubagentStartHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-start.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json new file mode 100644 index 000000000..32b34ca6b --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.input.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "SubagentStop", + "type": "string" + }, + "last_assistant_message": { + "$ref": "#/definitions/NullableString" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "stop_hook_active": { + "type": "boolean" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "agent_id", + "agent_transcript_path", + "agent_type", + "cwd", + "hook_event_name", + "last_assistant_message", + "model", + "permission_mode", + "session_id", + "stop_hook_active", + "transcript_path", + "turn_id" + ], + "title": "subagent-stop.command.input", + "type": "object" +} diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json new file mode 100644 index 000000000..ff9619275 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/subagent-stop.command.output.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "BlockDecisionWire": { + "enum": [ + "block" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/BlockDecisionWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.", + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "subagent-stop.command.output", + "type": "object" +} diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index 848fb3ad1..b707f5d5b 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -104,12 +104,61 @@ export const projectEventDocument = ( appendContext(document.root, contexts); const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); const parsedValue = document.value === undefined ? undefined : resultValueSchema.parse(document.value); + const requireDenyReason = (): string => { + if (parsedValue?.outcome !== 'deny') { + throw new TypeError(`${event} did not request a blocking outcome.`); + } + if (parsedValue.reason === undefined) { + throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`); + } + return parsedValue.reason; + }; if (event === 'stop') { if (parsedValue?.outcome !== 'deny') return undefined; return target === 'cursor' - ? Object.freeze({ followup_message: parsedValue.reason }) - : Object.freeze({ decision: 'block', reason: parsedValue.reason }); + ? Object.freeze({ followup_message: requireDenyReason() }) + : Object.freeze({ decision: 'block', reason: requireDenyReason() }); + } + if (event === 'agent/start') { + if (parsedValue?.outcome === 'deny') { + throw new TypeError('agent/start cannot block subagent creation on any supported host.'); + } + if (parsedValue?.updatedInput !== undefined) { + throw new TypeError('agent/start cannot replace native input.'); + } + if (additionalContext === undefined) return undefined; + return target === 'cursor' + ? Object.freeze({ additional_context: additionalContext }) + : Object.freeze({ + hookSpecificOutput: Object.freeze({ + additionalContext, + hookEventName: nativeEvent, + }), + }); + } + if (event === 'agent/stop') { + if (parsedValue?.updatedInput !== undefined) { + throw new TypeError('agent/stop cannot replace native input.'); + } + if (parsedValue?.outcome === 'deny') { + if (target === 'cursor') { + throw new TypeError('agent/stop cannot block subagent completion on cursor.'); + } + return Object.freeze({ decision: 'block', reason: requireDenyReason() }); + } + if (additionalContext === undefined) return undefined; + if (target === 'codex') { + throw new TypeError('agent/stop additional context is not supported by the Codex SubagentStop output schema.'); + } + return target === 'cursor' + ? Object.freeze({ additional_context: additionalContext }) + : Object.freeze({ + hookSpecificOutput: Object.freeze({ + additionalContext, + hookEventName: nativeEvent, + }), + }); } if (event === 'tool/before') { if (target === 'cursor') { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 7dfd51510..ebe83b465 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -190,17 +190,24 @@ 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 existing = ['event:session/start', 'event:tool/before', 'event:tool/after', 'event:stop']; - const cursorOnly = ['event:agent/start', 'event:agent/stop', 'event:workspace/open']; - - for (const capability of [...existing, ...cursorOnly]) { + const allNativeHosts = [ + 'event:agent/start', + 'event:agent/stop', + 'event:session/start', + 'event:stop', + 'event:tool/after', + 'event:tool/before', + ]; + const cursorOnly = ['event:workspace/open']; + + for (const capability of [...allNativeHosts, ...cursorOnly]) { expect(registry.get('cursor').capabilities[capability]).toMatchObject({ evidence: { observedVersion: '2026-08-28', target: 'cursor' }, state: 'supported', }); } for (const target of ['claude', 'codex'] as const) { - for (const capability of existing) { + for (const capability of allNativeHosts) { expect(registry.get(target).capabilities[capability]).toMatchObject({ evidence: { target }, state: 'supported', @@ -213,6 +220,12 @@ it('reports the evidence-backed G10 event family matrix without inferred support }); } } + for (const capability of ['event:agent/start', 'event:agent/stop']) { + expect(registry.get('plugin').capabilities[capability]).toMatchObject({ + evidence: { target: 'claude+codex' }, + state: 'supported', + }); + } expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ state: 'unavailable', }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index ed81f8548..66d21ec51 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { expect, it } from '@rstest/core'; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; +import { createDraft7AdapterValidator } from '../src/adapters/types.ts'; import { sha256Hex } from '../src/core/digest.ts'; const validMetadata = () => ({ @@ -72,9 +73,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'codex')).toEqual({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: '0.147.0', - capabilitySha256: 'f2c7109e3572ebde8739e08f6fdfa7a7b5d7817e3e216406b9c855f1570e2bd9', + capabilitySha256: '44e697be71a29db9ec029ed7d9eb8807b90e95d6a15f3a71a47148125c902194', observedVersion: '0.147.0', schemas: [ { @@ -100,9 +101,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', capabilityRevision: '2.1.250', - capabilitySha256: '13bc41224c5343b33d259986a66feb279e15431c5019bb2a1c443eaa60e9a9ea', + capabilitySha256: 'a9c8821ee5cbc6aef65816c5025170389fd490b6d1c4e4e893599aa6a36f2265', observedVersion: '2.1.250', schemas: [ { @@ -196,6 +197,50 @@ it('rehashes every declared capability and schema snapshot against its pinned pr } }); +it('pins and validates the Codex 0.147.0 subagent wire schemas', async () => { + const schemas = [ + { + fixture: 'codex-subagent-start.json', + name: 'subagent-start.command.input.schema.json', + sha256: 'ce7dc9b5ae8826d1e0c59ffcea793e558aebceb7917a2eb9bb2edd8a7ac37aa9', + }, + { + name: 'subagent-start.command.output.schema.json', + output: { + hookSpecificOutput: { + additionalContext: 'Review the repository test conventions first.', + hookEventName: 'SubagentStart', + }, + }, + sha256: '34e8ec95393d2aa930d7932a34c3fb29a5e5f90c264fdbcc581393c5838b4660', + }, + { + fixture: 'codex-subagent-stop.json', + name: 'subagent-stop.command.input.schema.json', + sha256: '94dc8df29f4691195ac2338ae6de876230e5100a10b94ef48df4e732424b5df5', + }, + { + name: 'subagent-stop.command.output.schema.json', + output: { decision: 'block', reason: 'Run one more focused pass.' }, + sha256: '8ba2cd7899ae4544193764e67e988235edebe984abe5788634d123bbf13e3e3a', + }, + ] as const; + const validator = createDraft7AdapterValidator(); + + for (const schema of schemas) { + const bytes = await readFile(new URL(`../src/adapters/schemas/codex/generated/${schema.name}`, import.meta.url)); + // Repository text files carry one POSIX trailing newline; the pinned + // upstream generated files do not. Hash the byte-identical upstream body. + expect(bytes.at(-1)).toBe(10); + expect(sha256Hex(bytes.subarray(0, -1))).toBe(schema.sha256); + 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; + expect(validate(value), JSON.stringify(validate.errors)).toBe(true); + } +}); + it('returns frozen, detached metadata snapshots while retaining the original adapter methods', () => { const metadata = validMetadata(); const registered = adapter('custom', metadata); diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index 502d4be0f..ad0056b97 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -33,3 +33,93 @@ it('resolves nested Server Components in explicit standalone event routes', asyn }, }); }); + +it('projects subagent-start context without fabricating a blocking effect', async () => { + const document = await renderStandaloneEventRoute( + async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Review the repository test conventions first.'), + ), + createCanonicalEventProps( + 'agent/start', + { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' }, + 'codex', + 'SubagentStart', + '0.147.0', + new AbortController().signal, + ), + ); + + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(document, 'agent/start', target, 'SubagentStart')).toEqual({ + hookSpecificOutput: { + additionalContext: 'Review the repository test conventions first.', + hookEventName: 'SubagentStart', + }, + }); + } + + const blocked = await renderStandaloneEventRoute( + async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Do not start.' } }), + createCanonicalEventProps( + 'agent/start', + { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' }, + 'codex', + 'SubagentStart', + '0.147.0', + new AbortController().signal, + ), + ); + expect(() => projectEventDocument(blocked, 'agent/start', 'codex', 'SubagentStart')) + .toThrow(/agent\/start cannot block subagent creation/u); +}); + +it('projects subagent-stop continuation only through supported host contracts', async () => { + const blocked = await renderStandaloneEventRoute( + async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Run one more focused pass.' } }), + createCanonicalEventProps( + 'agent/stop', + { + agent_id: 'agent-1', + agent_transcript_path: '/workspace/subagents/agent-1.jsonl', + agent_type: 'Explore', + hook_event_name: 'SubagentStop', + last_assistant_message: 'Done.', + session_id: 'parent-1', + stop_hook_active: false, + }, + 'codex', + 'SubagentStop', + '0.147.0', + new AbortController().signal, + ), + ); + + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(blocked, 'agent/stop', target, 'SubagentStop')).toEqual({ + decision: 'block', + reason: 'Run one more focused pass.', + }); + } + + const feedback = await renderStandaloneEventRoute( + async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.')), + createCanonicalEventProps( + 'agent/stop', + { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStop', session_id: 'session-1' }, + 'claude', + 'SubagentStop', + '2.1.250', + new AbortController().signal, + ), + ); + expect(projectEventDocument(feedback, 'agent/stop', 'claude', 'SubagentStop')).toEqual({ + hookSpecificOutput: { + additionalContext: 'Check the final result.', + hookEventName: 'SubagentStop', + }, + }); + expect(() => projectEventDocument(feedback, 'agent/stop', 'codex', 'SubagentStop')) + .toThrow(/not supported by the Codex SubagentStop output schema/u); +}); diff --git a/packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json b/packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json new file mode 100644 index 000000000..c0f3a26f6 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-subagent-start.json @@ -0,0 +1,8 @@ +{ + "agent_id": "agent-claude-1", + "agent_type": "Explore", + "cwd": "/workspace", + "hook_event_name": "SubagentStart", + "session_id": "parent-session-claude", + "transcript_path": "/workspace/.claude/projects/session.jsonl" +} diff --git a/packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json b/packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json new file mode 100644 index 000000000..d66166266 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-subagent-stop.json @@ -0,0 +1,14 @@ +{ + "agent_id": "agent-claude-1", + "agent_transcript_path": "/workspace/.claude/projects/session/subagents/agent-agent-claude-1.jsonl", + "agent_type": "Explore", + "background_tasks": [], + "cwd": "/workspace", + "hook_event_name": "SubagentStop", + "last_assistant_message": "Analysis complete.", + "permission_mode": "default", + "session_crons": [], + "session_id": "parent-session-claude", + "stop_hook_active": false, + "transcript_path": "/workspace/.claude/projects/session.jsonl" +} diff --git a/packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json b/packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json new file mode 100644 index 000000000..d8bec8bdc --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/codex-subagent-start.json @@ -0,0 +1,11 @@ +{ + "agent_id": "agent-codex-1", + "agent_type": "explore", + "cwd": "/workspace", + "hook_event_name": "SubagentStart", + "model": "gpt-5.6-codex", + "permission_mode": "default", + "session_id": "parent-session-codex", + "transcript_path": "/workspace/.codex/rollout.jsonl", + "turn_id": "turn-codex-1" +} diff --git a/packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json b/packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json new file mode 100644 index 000000000..94814fa59 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/codex-subagent-stop.json @@ -0,0 +1,14 @@ +{ + "agent_id": "agent-codex-1", + "agent_transcript_path": "/workspace/.codex/subagents/agent-codex-1.jsonl", + "agent_type": "explore", + "cwd": "/workspace", + "hook_event_name": "SubagentStop", + "last_assistant_message": "Analysis complete.", + "model": "gpt-5.6-codex", + "permission_mode": "default", + "session_id": "parent-session-codex", + "stop_hook_active": false, + "transcript_path": "/workspace/.codex/rollout.jsonl", + "turn_id": "turn-codex-1" +} diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 08556d4c6..e6cd748c5 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -549,3 +549,72 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo tool_use_id: 'tool-1', })).resolves.toEqual({ additional_context: 'standalone:Write' }); }); + +it('replays Claude and Codex subagent fixtures through standalone event-route wrappers', { timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-events-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + react: '19.2.8', + }, + name: 'subagent-events-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'subagent-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/events/agent/start.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };", + 'export default async function AgentStart({ native }) {', + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${native.session_id}:${native.agent_id}:${native.agent_type}`));', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/agent/stop.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };", + 'export default async function AgentStop({ native }) {', + " return createElement(Agent.Result, { value: { outcome: 'deny', reason: `Review ${native.agent_id} once more.` } });", + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['claude', 'codex'] }); + expect(compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStart')).toHaveLength(2); + expect(compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStop')).toHaveLength(2); + + for (const target of ['claude', 'codex'] as const) { + const start = compiled.build.compiledHooks.find((hook) => hook.target === target && hook.event === 'agentStart')!; + const stop = compiled.build.compiledHooks.find((hook) => hook.target === target && hook.event === 'agentStop')!; + const startInput = JSON.parse(await readFile( + new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url), + 'utf8', + )) as Record; + const stopInput = JSON.parse(await readFile( + new URL(`./fixtures/events/${target}-subagent-stop.json`, import.meta.url), + 'utf8', + )) as Record; + + await expect(runHook(start.output, startInput)).resolves.toEqual({ + hookSpecificOutput: { + additionalContext: `${String(startInput.session_id)}:${String(startInput.agent_id)}:${String(startInput.agent_type)}`, + hookEventName: 'SubagentStart', + }, + }); + await expect(runHook(stop.output, stopInput)).resolves.toEqual({ + decision: 'block', + reason: `Review ${String(stopInput.agent_id)} once more.`, + }); + } +}); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index edc8dfa90..60f1ce334 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1001,6 +1001,87 @@ it('runs the embedded Codex and Claude native codecs through their published wra } }, 15_000); +it('round-trips Claude and Codex subagent fields through published wrappers', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-hook-codecs-')); + const sourceRoot = join(root, 'src', 'hooks'); + const outputRoot = join(root, 'dist'); + const base = hookModel(root); + const model: NormalizedPlugin = { + ...base, + hooks: [ + { + ...base.hooks[0]!, + event: 'agentStart', + id: 'hook:agent-start:subagent-start', + name: 'subagent-start', + source: join(sourceRoot, 'subagent-start.ts'), + }, + { + ...base.hooks[3]!, + event: 'agentStop', + id: 'hook:agent-stop:subagent-stop', + name: 'subagent-stop', + source: join(sourceRoot, 'subagent-stop.ts'), + }, + ], + }; + + try { + await mkdir(sourceRoot, { recursive: true }); + await Promise.all([ + writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile( + join(sourceRoot, 'subagent-start.ts'), + "export default (event: Record) => ({ outcome: 'continue' as const, additionalContext: `${String(event.sessionId)}:${String(event.agentId)}:${String(event.agentType)}:${String(event.turnId)}` });\n", + ), + writeFile( + join(sourceRoot, 'subagent-stop.ts'), + "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}` });\n", + ), + ]); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + + for (const target of ['codex', 'claude'] as const) { + const manifest = JSON.parse(await readFile(join(outputRoot, target, 'hooks', 'hooks.json'), 'utf8')) as { + readonly hooks: Readonly>; + }; + expect(manifest.hooks.SubagentStart).toHaveLength(1); + expect(manifest.hooks.SubagentStop).toHaveLength(1); + + const startInput = JSON.parse(await readFile( + new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url), + 'utf8', + )) as Record; + const stopInput = JSON.parse(await readFile( + new URL(`./fixtures/events/${target}-subagent-stop.json`, import.meta.url), + 'utf8', + )) as Record; + const expectedTurn = target === 'codex' ? 'turn-codex-1' : 'undefined'; + await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ + code: 0, + stderr: '', + stdout: JSON.stringify({ + hookSpecificOutput: { + additionalContext: `${String(startInput.session_id)}:${String(startInput.agent_id)}:${String(startInput.agent_type)}:${expectedTurn}`, + hookEventName: 'SubagentStart', + }, + }), + }); + await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + code: 0, + stderr: '', + stdout: JSON.stringify({ + decision: 'block', + reason: `${String(stopInput.agent_transcript_path)}:${String(stopInput.stop_hook_active)}:${String(stopInput.last_assistant_message)}`, + }), + }); + } + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 15_000); + it('rejects malformed event-specific native input before calling generated Codex and Claude hooks', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-native-input-')); const sourceRoot = join(root, 'src', 'hooks'); @@ -1091,11 +1172,11 @@ it('rejects canonical reason combinations whose selected native hook cannot repr await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); const hooksRoot = join(outputRoot, 'codex', 'hooks'); const assertions: readonly [string, Record, string][] = [ - ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool or stop hook'], - ['before-allow-reason-00000002.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-1' }, 'reason is only valid for a denied beforeTool or stop hook'], + ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], + ['before-allow-reason-00000002.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-1' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], ['before-deny-reason-00000003.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-2' }, 'denied beforeTool hook requires a nonempty reason'], - ['after-reason-00000004.mjs', { ...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_response: {}, tool_use_id: 'use-3' }, 'reason is only valid for a denied beforeTool or stop hook'], - ['stop-continue-reason-00000005.mjs', { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: false }, 'reason is only valid for a denied beforeTool or stop hook'], + ['after-reason-00000004.mjs', { ...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_response: {}, tool_use_id: 'use-3' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], + ['stop-continue-reason-00000005.mjs', { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: false }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], ['stop-deny-reason-00000006.mjs', { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: false }, 'denied stop hook requires a nonempty reason'], ]; for (const [name, input, message] of assertions) { diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 9686dec75..cdaa0caf5 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -158,6 +158,39 @@ it('lays both host manifests over one shared bundle root', () => { }); }); +it('bundles subagent hooks at Codex default hooks/hooks.json location', () => { + const model: NormalizedPlugin = { + ...bundleModel, + hooks: [ + { + ...bundleModel.hooks[0]!, + event: 'agentStart', + id: 'hook:agent-start', + name: 'agent-start', + source: '/workspace/src/hooks/agent-start.ts', + }, + { + ...bundleModel.hooks[0]!, + event: 'agentStop', + id: 'hook:agent-stop', + name: 'agent-stop', + source: '/workspace/src/hooks/agent-stop.ts', + }, + ], + }; + const documents = writeContents(model); + const codexManifest = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; + const hooks = JSON.parse(documents['hooks/hooks.json']!) as { + readonly hooks: Readonly>; + }; + + // Codex discovers this plugin-root path by convention when the manifest + // omits `hooks`; both documented plugin-bundled forms are compliant. + expect(codexManifest).not.toHaveProperty('hooks'); + expect(hooks.hooks.SubagentStart).toHaveLength(1); + expect(hooks.hooks.SubagentStop).toHaveLength(1); +}); + it('emits Claude-only LSP configuration at the shared composite root', () => { const model = { ...bundleModel, From e9656fe2c240155e648115f7bab301db9659b847 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 20:52:28 +0000 Subject: [PATCH 2/3] test: align semantic hook expectations Keep the example's route-only tests in their registered pool and align provider and Workbench assertions with the landed semantic event route. --- examples/rsc-agent-runtime/rstest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/rsc-agent-runtime/rstest.config.ts b/examples/rsc-agent-runtime/rstest.config.ts index ee62079f1..d036d31ae 100644 --- a/examples/rsc-agent-runtime/rstest.config.ts +++ b/examples/rsc-agent-runtime/rstest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ // swept into this plain Node pool where no test manifest is registered. exclude: ['tests/route-unit/**'], include: ['tests/**/*.test.{ts,tsx}'], + exclude: ['tests/route-unit/**/*.test.{ts,tsx}'], pool: { maxWorkers: 1 }, testEnvironment: 'node', // Every suite here runs real rsbuild compiles and spawned children, which a From 2404a00c8d065eb592212742089fdb2aad9e49e2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:24:21 +0000 Subject: [PATCH 3/3] fix(events): dispatch composite routes by host Resolve Claude and Codex invocations before validating or projecting shared plugin event routes so host-specific wire contracts remain intact. --- .changeset/correct-subagent-event-support.md | 3 +- .../src/adapters/hook-contract.ts | 13 ++- packages/agent-bundle/src/events/project.ts | 3 + .../agent-bundle/tests/event-project.test.ts | 2 + .../tests/generated-route-server.test.ts | 96 ++++++++++++++++++- 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/.changeset/correct-subagent-event-support.md b/.changeset/correct-subagent-event-support.md index dfd6a06dc..465e3e670 100644 --- a/.changeset/correct-subagent-event-support.md +++ b/.changeset/correct-subagent-event-support.md @@ -5,4 +5,5 @@ Correct Claude Code and Codex event-route capabilities for native `SubagentStart` and `SubagentStop` hooks, including host-specific input validation, result projection, plugin packaging, and pinned Codex wire-schema -evidence. +evidence. Resolve the actual Claude or Codex invoker before a composite plugin +route validates input or projects output. diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index ca3850e67..7c65ebbfe 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -320,6 +320,14 @@ const eventRouteHookWrapperSource = ( ): string => { const route = entry.hook.eventRoute!; const standalone = route.runtime === 'standalone' || route.fallback === 'standalone'; + const targetSource = entry.target === 'plugin' + ? [ + 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', + 'const target = declaredHost === "claude" || declaredHost === "codex"', + ' ? declaredHost', + ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', + ] + : ['const target = artifactTarget;']; return [ "import { dirname, resolve } from 'node:path';", `import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, @@ -334,11 +342,12 @@ const eventRouteHookWrapperSource = ( `const canonicalEvent = ${JSON.stringify(route.event)};`, `const capabilityRevision = ${JSON.stringify(capabilityRevision)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, - `const target = ${JSON.stringify(entry.target)};`, + `const artifactTarget = ${JSON.stringify(entry.target)};`, + ...targetSource, `const runtimeMode = ${JSON.stringify(route.runtime)};`, `const fallbackMode = ${JSON.stringify(route.fallback)};`, `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, - "const endpointId = `${artifactEpoch}:${target}:${dirname(dirname(resolve(process.argv[1])))}`;", + "const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;", '', 'const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index b707f5d5b..ff45f002d 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -100,6 +100,9 @@ export const projectEventDocument = ( target: string, nativeEvent: string, ): Readonly> | undefined => { + if (target === 'plugin') { + throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); + } const contexts: string[] = []; appendContext(document.root, contexts); const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index ad0056b97..cc79c033c 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -122,4 +122,6 @@ it('projects subagent-stop continuation only through supported host contracts', }); expect(() => projectEventDocument(feedback, 'agent/stop', 'codex', 'SubagentStop')) .toThrow(/not supported by the Codex SubagentStop output schema/u); + expect(() => projectEventDocument(feedback, 'agent/stop', 'plugin', 'SubagentStop')) + .toThrow(/must resolve the invoking host/u); }); diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index e6cd748c5..a4fe7abb7 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -25,8 +25,16 @@ const writeProjectFile = async (root: string, path: string, contents: string): P const runHook = async ( entry: string, input: Readonly>, + env: Readonly = {}, ): Promise> | undefined> => new Promise((resolve, reject) => { - const child = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'pipe'] }); + const childEnv = { ...process.env, ...env }; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete childEnv[key]; + } + const child = spawn(process.execPath, [entry], { + env: childEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); @@ -618,3 +626,89 @@ it('replays Claude and Codex subagent fixtures through standalone event-route wr }); } }); + +it('dispatches composite plugin event routes through the invoking host contract', { timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-subagent-events-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + react: '19.2.8', + }, + name: 'plugin-subagent-events-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'plugin-subagent-events-fixture', version: '1.0.0' }, targets: ['plugin'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/events/agent/start.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone', targets: ['plugin'] };", + 'export default async function AgentStart({ canonical, native }) {', + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${native.agent_id}`));', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/agent/stop.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone', targets: ['plugin'] };", + 'export default async function AgentStop() {', + " return createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.'));", + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['plugin'] }); + const start = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStart')!; + const stop = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStop')!; + + for (const target of ['claude', 'codex'] as const) { + const input = JSON.parse(await readFile( + new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url), + 'utf8', + )) as Record; + if (target === 'codex') input.transcript_path = null; + const env = target === 'codex' + ? { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output } + : { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }; + await expect(runHook(start.output, input, env)).resolves.toEqual({ + hookSpecificOutput: { + additionalContext: `${target}:${String(input.agent_id)}`, + hookEventName: 'SubagentStart', + }, + }); + } + + const claudeStop = JSON.parse(await readFile( + new URL('./fixtures/events/claude-subagent-stop.json', import.meta.url), + 'utf8', + )) as Record; + await expect(runHook(stop.output, claudeStop, { + AGENT_BUNDLE_HOOK_HOST: undefined, + PLUGIN_ROOT: undefined, + })).resolves.toEqual({ + hookSpecificOutput: { + additionalContext: 'Check the final result.', + hookEventName: 'SubagentStop', + }, + }); + + const codexStop = JSON.parse(await readFile( + new URL('./fixtures/events/codex-subagent-stop.json', import.meta.url), + 'utf8', + )) as Record; + codexStop.transcript_path = null; + await expect(runHook(stop.output, codexStop, { + AGENT_BUNDLE_HOOK_HOST: undefined, + PLUGIN_ROOT: output, + })).rejects.toThrow(/not supported by the Codex SubagentStop output schema/u); +});