From f9654dc30f80b55d6d7032a30544413178501829 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:15:29 +0000 Subject: [PATCH 1/3] feat(events): give event routes a canonical per-family payload beside the native envelope (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentEventRouteProps.canonical.payload carries the fields at least two hosts report for the route's family — tool name/input/response, session id, transcript path, cwd, prompt, agent id/type, stop re-entry, … — each as { value, nativeKey } naming the host key it was read from and absent when the host did not send it. The per-family table (agentEventPayloadFields) and the per-host key table (agentEventPayloadNativeKeys) live in routes/events.ts; events/payload.ts projects through them inside createCanonicalEventProps, so the standalone wrapper, the shared runtime, agent-bundle/test, and the Workbench replay all agree. Each pinned capability table mirrors its host's mapping under hooks.eventRoutes..payload (held equal by tests/event-payload.test.ts) and the generated events reference renders the field × host → native key matrix per family. agent-bundle/test gains createEventRouteInput; worktree-proximity and rsc-agent-runtime read canonical.payload instead of hand-parsing native; host-test keeps recording native as the host-specific example. --- .changeset/466-canonical-event-payload.md | 5 + docs/entry-conventions.md | 2 +- .../host-test/tests/route-unit/routes.test.ts | 29 +- examples/rsc-agent-runtime/README.md | 8 +- .../src/events/tool/after.tsx | 46 ++- .../rsc-agent-runtime/src/hook/normalize.ts | 50 +-- .../tests/route-unit/event-route.test.ts | 38 +- .../worktree-proximity/src/event-support.ts | 61 +-- .../src/events/agent/start.tsx | 15 +- .../src/events/session/start.tsx | 10 +- .../worktree-proximity/src/events/stop.tsx | 9 +- .../src/events/tool/after.tsx | 5 +- .../src/events/tool/before.tsx | 9 +- .../tests/route-unit/routes.test.ts | 30 +- .../adapters/capabilities/claude-2.1.260.json | 323 +++++++++++++++- .../adapters/capabilities/codex-0.147.0.json | 171 ++++++++- .../capabilities/cursor-2026-08-28.json | 77 ++++ .../src/adapters/capability-state.ts | 8 + packages/agent-bundle/src/api.ts | 15 +- packages/agent-bundle/src/events/payload.ts | 108 ++++++ packages/agent-bundle/src/events/project.ts | 1 + .../agent-bundle/src/events/projection.ts | 20 +- packages/agent-bundle/src/index.ts | 16 +- packages/agent-bundle/src/routes/events.ts | 362 ++++++++++++++++++ packages/agent-bundle/src/routes/index.ts | 17 +- packages/agent-bundle/src/routes/public.ts | 68 ++-- packages/agent-bundle/src/test/event-input.ts | 70 ++++ packages/agent-bundle/src/test/index.ts | 2 + .../agent-bundle/tests/event-payload.test.ts | 304 +++++++++++++++ .../tests/generated-route-server.test.ts | 12 +- .../tests/lifecycle-replay-routes.test.ts | 1 + .../tests/route-register-typegen.test.ts | 47 ++- .../src/lifecycles/lifecycle-client.ts | 8 + .../src/lifecycles/lifecycles-model.ts | 21 + .../src/lifecycles/lifecycles-page.tsx | 1 + .../lifecycles-page-browser-fixture.tsx | 1 + .../workbench/tests/lifecycle-client.test.ts | 4 + .../workbench/tests/lifecycles-model.test.ts | 5 + .../workbench/tests/lifecycles-page.test.ts | 1 + website/docs/en/guide/authoring/hooks.mdx | 48 ++- website/docs/en/guide/development/testing.mdx | 6 +- website/docs/zh/guide/authoring/hooks.mdx | 38 +- website/docs/zh/guide/development/testing.mdx | 6 +- website/plugins/generated-reference.ts | 44 +++ 44 files changed, 1876 insertions(+), 246 deletions(-) create mode 100644 .changeset/466-canonical-event-payload.md create mode 100644 packages/agent-bundle/src/events/payload.ts create mode 100644 packages/agent-bundle/src/routes/events.ts create mode 100644 packages/agent-bundle/src/test/event-input.ts create mode 100644 packages/agent-bundle/tests/event-payload.test.ts diff --git a/.changeset/466-canonical-event-payload.md b/.changeset/466-canonical-event-payload.md new file mode 100644 index 000000000..93d35d4a7 --- /dev/null +++ b/.changeset/466-canonical-event-payload.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Give every event route a canonical, per-family `canonical.payload` beside the raw `native` envelope (`AgentEventRouteProps`): the fields at least two hosts report — `toolName`, `toolInput`, `toolUseId`, `toolResponse`, `sessionId` (Claude and Codex `session_id`, Cursor `conversation_id`), `transcriptPath`, `cwd`, `model`, `permissionMode`, `agentId`/`agentType`, `agentTranscriptPath`, `prompt`, `reason`, `source`, `trigger`, `error`/`isInterrupt`, `lastAssistantMessage`, and `reentry` (Claude and Codex `stop_hook_active`, Cursor `loop_count > 0`) — each delivered as `{ value, nativeKey }` naming the host key it was read from, and absent when the host did not send it, never fabricated. Cursor's `tool_output` JSON string is parsed into `toolResponse` (kept as the string when it is not valid JSON). Type a route to its family (`AgentEventRouteProps<'tool/after'>`) and `payload` narrows to that family's fields, in the route and in the generated `.agent-bundle/routes.d.ts` that `renderRoute` reads; `AgentEventCanonicalIdentity` gains the same parameter. The per-family table ships as `agentEventPayloadFields`, the per-host key table as `agentEventPayloadNativeKeys` (with `AgentEventPayload`, `AgentEventPayloadField`, `AgentEventPayloadFieldName`, `AgentEventPayloadNativeKey`, and `agentEventPayloadFieldKinds`), and each pinned capability table mirrors its host's mapping under `hooks.eventRoutes..payload`, so the generated events reference documents field × host → native key per family. `agent-bundle/test` gains `createEventRouteInput(event, native, { host })`, which validates a host envelope and builds the `{ canonical, native }` input the harness takes, payload included; the Workbench Lifecycles view lists the mapped payload beside the canonical identity. Additive: `native` is unchanged, `idempotencyKey` still hashes only the envelope, and the bare `AgentEventRouteProps` keeps working with every field optional; only code that constructs `AgentEventCanonicalIdentity` by hand must add `payload`. (#466) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 389516efc..8b452c2c3 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -80,7 +80,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | -| `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | +| `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. `canonical.payload` is the family's cross-host reading of the envelope (#466) — the fields at least two hosts report (`toolName`, `toolInput`, `toolResponse`, `sessionId`, `transcriptPath`, `cwd`, `prompt`, `agentId`/`agentType`, `reentry`, …), each as `{ value, nativeKey }` naming the host key it came from and absent when the host did not send it; `E` narrows it to the route's family. The per-family field table is `agentEventPayloadFields` and the per-host key table `agentEventPayloadNativeKeys` (`routes/events.ts`), mirrored under `hooks.eventRoutes..payload` in each pinned capability table so the generated events reference documents the mapping per host. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | | `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | | `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` | diff --git a/examples/host-test/tests/route-unit/routes.test.ts b/examples/host-test/tests/route-unit/routes.test.ts index 3c2457b5c..97235e3aa 100644 --- a/examples/host-test/tests/route-unit/routes.test.ts +++ b/examples/host-test/tests/route-unit/routes.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, expect, it } from '@rstest/core'; import { available, type AgentLineage } from '@agent-bundle/runtime'; -import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; +import { createEventRouteInput, expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; import { LOG_DIR_ENV } from '../../src/log.js'; import { DEFAULT_DUMP_LIMIT } from '../../src/mcp/host-test/tools/dump.js'; @@ -19,25 +19,24 @@ let logDir: string; let mounted: MountedTestState; let sequence = 0; +// The probe records the whole envelope, so its tests hand it partial ones +// (`validate: false`); the harness still projects `canonical.payload` from them. const eventInput = ( event: 'agent/start' | 'agent/stop' | 'session/start' | 'tool/before', native: Record, host = 'claude', -) => ({ - canonical: { - event, - idempotencyKey: `${event}:${String(sequence)}`, - observedAt: `2026-09-03T08:00:${String(sequence++).padStart(2, '0')}.000Z`, - provenance: { - host, - hostContractRevision: 'route-unit', - nativeEvent: native.hook_event_name as string, - source: 'native', +) => { + const built = createEventRouteInput(event, native, { host, validate: false }); + return { + canonical: { + ...built.canonical, + idempotencyKey: `${event}:${String(sequence)}`, + observedAt: `2026-09-03T08:00:${String(sequence++).padStart(2, '0')}.000Z`, + sequence, }, - sequence, - }, - native, -}); + native: built.native, + }; +}; const render = async ( route: string, diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index fe4d87b41..fef1170d3 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -14,16 +14,18 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha Native hooks are fresh requests: the compiler-generated client validates one host event, invokes `src/events/tool/after.tsx` in its explicit standalone mode, projects the final Agent Document, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls. ```tsx -// The semantic event route receives canonical identity plus the complete native payload. +// The semantic event route receives canonical identity — including the family's +// cross-host `payload` — plus the complete native envelope for host-specific fields. import { Agent } from '@agent-bundle/runtime'; import type { AgentEventRouteProps } from 'agent-bundle'; export const config = { runtime: 'standalone', targets: ['claude', 'codex'] }; -export default async function AfterFileEdit({ canonical, native }: AgentEventRouteProps) { +export default async function AfterFileEdit({ canonical }: AgentEventRouteProps<'tool/after'>) { + const toolName = canonical.payload.toolName?.value ?? 'an unnamed tool'; return ( - {`Recorded ${String(native.tool_name)} from ${canonical.provenance.host}.`} + {`Recorded ${toolName} from ${canonical.provenance.host}.`} ); } diff --git a/examples/rsc-agent-runtime/src/events/tool/after.tsx b/examples/rsc-agent-runtime/src/events/tool/after.tsx index 74e5e349b..8bde70023 100644 --- a/examples/rsc-agent-runtime/src/events/tool/after.tsx +++ b/examples/rsc-agent-runtime/src/events/tool/after.tsx @@ -5,7 +5,8 @@ import type { AgentEventRouteProps } from 'agent-bundle'; import * as React from 'react'; import { writeEvalProbe } from '../../hook/eval-probe.js'; -import { normalizeClaudeHook, normalizeCodexHook } from '../../hook/normalize.js'; +import { editedPath } from '../../hook/normalize.js'; +import type { CanonicalPostToolUse } from '../../runtime/contracts.js'; import { createFileRuntimeKernel, resolveImplicitRuntimeStateFile } from '../../runtime/state-file.js'; export const config = { @@ -15,21 +16,44 @@ export const config = { tools: ['file.write'], }; +const requiredField = (field: string, mapped: { readonly value: Value } | undefined): Value => { + if (mapped === undefined) { + throw new Error(`Native hook input requires ${field}`); + } + return mapped.value; +}; + +/** + * The shared fields come from `canonical.payload`, which the framework + * projects the same way from Claude's and Codex's PostToolUse envelopes; only + * the edited-path reading stays host-specific (see `editedPath`). + */ +const normalizedEvent = ( + { idempotencyKey, payload, provenance }: AgentEventRouteProps<'tool/after'>['canonical'], +): CanonicalPostToolUse => { + const host = provenance.host; + if (host !== 'claude' && host !== 'codex') { + throw new Error(`Unsupported event-route host ${JSON.stringify(host)}`); + } + const cwd = requiredField('cwd', payload.cwd); + const toolName = requiredField('tool_name', payload.toolName); + return { + cwd, + host, + idempotencyKey, + path: editedPath(host, cwd, toolName, requiredField('tool_input', payload.toolInput)), + sessionId: requiredField('session_id', payload.sessionId), + toolName, + }; +}; + export default async function AfterFileEdit({ canonical, native, signal, -}: AgentEventRouteProps) { +}: AgentEventRouteProps<'tool/after'>) { try { - const host = canonical.provenance.host; - const normalized = host === 'claude' - ? normalizeClaudeHook(native) - : host === 'codex' - ? normalizeCodexHook(native) - : undefined; - if (normalized === undefined) { - throw new Error(`Unsupported event-route host ${JSON.stringify(host)}`); - } + const normalized = normalizedEvent(canonical); const configuredStateFile = process.env.AGENT_RUNTIME_STATE_FILE; const stateFile = configuredStateFile === undefined || configuredStateFile.trim() === '' diff --git a/examples/rsc-agent-runtime/src/hook/normalize.ts b/examples/rsc-agent-runtime/src/hook/normalize.ts index 1c62ec94c..21c2a813a 100644 --- a/examples/rsc-agent-runtime/src/hook/normalize.ts +++ b/examples/rsc-agent-runtime/src/hook/normalize.ts @@ -55,39 +55,45 @@ const resolveNativePath = (cwd: string, path: string): string => { return resolve(cwd, path); }; -export const normalizeClaudeHook = (input: NativeHookInput): CanonicalPostToolUse => { - const event = readBaseEvent('claude', input); - if (event.toolName !== 'Write' && event.toolName !== 'Edit') { +/** + * The edited file a tool call names. This is the one genuinely host-specific + * reading left in the hook: Claude's `Write`/`Edit` carry `file_path`, while + * Codex's `apply_patch` names the file inside its patch header. The tool name + * and input themselves arrive host-independently on `canonical.payload`. + */ +export const editedPath = ( + host: CanonicalPostToolUse['host'], + cwd: string, + toolName: string, + rawToolInput: unknown, +): string => { + if (host === 'claude' && toolName !== 'Write' && toolName !== 'Edit') { throw new Error('Claude hook supports only Write and Edit'); } - - const toolInput = asRecord(input.tool_input); - if (toolInput === undefined) { - throw new Error('Native hook input requires tool_input'); - } - - return { - ...event, - path: resolveNativePath(event.cwd, readRequiredString(toolInput, 'file_path')), - }; -}; - -export const normalizeCodexHook = (input: NativeHookInput): CanonicalPostToolUse => { - const event = readBaseEvent('codex', input); - if (event.toolName !== 'apply_patch') { + if (host === 'codex' && toolName !== 'apply_patch') { throw new Error('Codex hook supports only apply_patch'); } - - const toolInput = asRecord(input.tool_input); + const toolInput = asRecord(rawToolInput); if (toolInput === undefined) { throw new Error('Native hook input requires tool_input'); } - + if (host === 'claude') { + return resolveNativePath(cwd, readRequiredString(toolInput, 'file_path')); + } const command = readRequiredString(toolInput, 'command'); const path = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+?)\s*$/m.exec(command)?.[1]; if (path === undefined) { throw new Error('Codex apply_patch command requires a file header'); } + return resolveNativePath(cwd, path); +}; - return { ...event, path: resolveNativePath(event.cwd, path) }; +export const normalizeClaudeHook = (input: NativeHookInput): CanonicalPostToolUse => { + const event = readBaseEvent('claude', input); + return { ...event, path: editedPath('claude', event.cwd, event.toolName, input.tool_input) }; +}; + +export const normalizeCodexHook = (input: NativeHookInput): CanonicalPostToolUse => { + const event = readBaseEvent('codex', input); + return { ...event, path: editedPath('codex', event.cwd, event.toolName, input.tool_input) }; }; diff --git a/examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts b/examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts index 021a3d5e0..1fbd0e965 100644 --- a/examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts +++ b/examples/rsc-agent-runtime/tests/route-unit/event-route.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, beforeEach, expect, it } from '@rstest/core'; -import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; +import { createEventRouteInput, expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; /** * The route-unit proof level for the demo's PostToolUse migration: the hook is @@ -46,21 +46,9 @@ it('compiles the PostToolUse hook as a real event route rather than configuratio it('renders a native Claude PostToolUse envelope into the document the host projects from', async () => { const native = JSON.parse(await readFile(fixture, 'utf8')) as Record; const rendered = await renderRoute('event:tool/after', { - input: { - canonical: { - event: 'tool/after', - idempotencyKey: 'route-unit-claude-write', - observedAt: '2026-09-01T00:00:00.000Z', - provenance: { - host: 'claude', - hostContractRevision: 'route-unit', - nativeEvent: 'PostToolUse', - source: 'native', - }, - sequence: 1, - }, - native: { ...native, cwd: workspace }, - }, + // The harness validates the Claude envelope and projects `canonical.payload` + // (cwd, sessionId, toolName, toolInput) exactly as the artifact's wrapper does. + input: createEventRouteInput('tool/after', { ...native, cwd: workspace }, { host: 'claude' }), }); expect(rendered.invocation.kind).toBe('event'); @@ -77,21 +65,9 @@ it('appends a value-free eval hook probe when AGENT_RUNTIME_HOOK_PROBE_FILE is s const native = JSON.parse(await readFile(fixture, 'utf8')) as Record; await renderRoute('event:tool/after', { - input: { - canonical: { - event: 'tool/after', - idempotencyKey: 'route-unit-claude-write', - observedAt: '2026-09-01T00:00:00.000Z', - provenance: { - host: 'claude', - hostContractRevision: 'route-unit', - nativeEvent: 'PostToolUse', - source: 'native', - }, - sequence: 1, - }, - native: { ...native, cwd: workspace }, - }, + // The harness validates the Claude envelope and projects `canonical.payload` + // (cwd, sessionId, toolName, toolInput) exactly as the artifact's wrapper does. + input: createEventRouteInput('tool/after', { ...native, cwd: workspace }, { host: 'claude' }), }); const probe = JSON.parse(await readFile(probeFile, 'utf8')); diff --git a/examples/worktree-proximity/src/event-support.ts b/examples/worktree-proximity/src/event-support.ts index 559692878..36b4a3102 100644 --- a/examples/worktree-proximity/src/event-support.ts +++ b/examples/worktree-proximity/src/event-support.ts @@ -6,6 +6,7 @@ import { type AgentRecipient, type Observed, } from '@agent-bundle/runtime'; +import type { AgentEventCanonicalIdentity, AgentEventPayload } from 'agent-bundle'; import type { AvailableWorktree } from './api.js'; import type { TopologyAccess } from './coordination.js'; @@ -16,10 +17,14 @@ export const ROOT_ACTOR_PREFIX = 'session:'; /** The application's own fallback identity for a worktree no envelope names an agent for. */ export const DERIVED_ACTOR_PREFIX = 'worktree:'; -export interface EventIdentity { - readonly idempotencyKey: string; - readonly observedAt: string; -} +/** + * The slice of an event's canonical identity the topology reads: the + * idempotency key and timestamp it journals under, and the cross-host + * `payload` the framework projected from the envelope (session id, agent id, + * tool name and input) — the same fields on Claude and Codex, so no route + * here spells a host key. + */ +export type EventIdentity = Pick; export interface ExtractedIntent { readonly dependencies: readonly string[]; @@ -81,32 +86,28 @@ export const noticeRecipientFor = (actor: Actor | undefined, worktreeRoot: strin /** * The child actor one envelope carries: the runtime lineage first, then the - * host's own `agent_id` (Claude and Codex put the subagent's id on every one - * of its hook payloads) with the root `session_id` as its parent. `undefined` - * means the envelope names no subagent. + * payload's `agentId` (Claude and Codex put the subagent's id on every one of + * its hook payloads) with the payload's `sessionId` as its parent. `undefined` + * means the envelope names no subagent. The payload carries a field only when + * the host sent it, so an absent id is the host's silence, never a default. */ export const carriedChild = async ( - native: Readonly>, + payload: AgentEventPayload, ): Promise => { const fromLineage = childFromLineage(await requestLineage()); if (fromLineage !== undefined) return fromLineage; - const agentId = nativeString(native, 'agent_id'); - const sessionId = nativeString(native, 'session_id'); + const agentId = nonEmpty(payload.agentId?.value); + const sessionId = nonEmpty(payload.sessionId?.value); if (agentId === undefined || sessionId === undefined) return undefined; return { id: agentId, parentSessionId: sessionId, source: 'native' }; }; -export const nativeString = ( - native: Readonly>, - key: string, -): string | undefined => { - const value = native[key]; - return typeof value === 'string' && value.trim() !== '' ? value : undefined; -}; +export const nonEmpty = (value: string | undefined): string | undefined => + value !== undefined && value.trim() !== '' ? value : undefined; -const inputRecord = (native: Readonly>): Readonly> => { - const input = native.tool_input; - return input !== null && typeof input === 'object' && !Array.isArray(input) +const inputRecord = (payload: AgentEventPayload): Readonly> => { + const input = payload.toolInput?.value; + return input !== undefined && input !== null && typeof input === 'object' && !Array.isArray(input) ? input as Readonly> : {}; }; @@ -123,10 +124,10 @@ const dependenciesFrom = (input: Readonly>): readonly st }; export const extractIntent = ( - native: Readonly>, + payload: AgentEventPayload, ): ExtractedIntent => { - const input = inputRecord(native); - const toolName = nativeString(native, 'tool_name'); + const input = inputRecord(payload); + const toolName = payload.toolName?.value; const path = input.file_path; const paths = (toolName === 'Write' || toolName === 'Edit' || toolName === 'Read') @@ -142,20 +143,20 @@ export const extractIntent = ( /** * The actor a tool or stop envelope belongs to, in order of evidence: the - * child the envelope itself names (runtime lineage, then native `agent_id`), - * the active actor already bound to the event worktree, and finally the - * explicit derived identity `worktree:`. A carried child the topology - * has not seen yet (its `agent/start` was missed) is observed and bound with - * the provenance the evidence carried; a derived actor is never upgraded. + * child the envelope itself names (runtime lineage, then the payload's + * `agentId`), the active actor already bound to the event worktree, and + * finally the explicit derived identity `worktree:`. A carried child + * the topology has not seen yet (its `agent/start` was missed) is observed + * and bound with the provenance the evidence carried; a derived actor is + * never upgraded. */ export const actorForWorktree = async ( topology: TopologyAccess, worktree: AvailableWorktree, canonical: EventIdentity, - native: Readonly> = {}, ): Promise<{ readonly actor: ResolvedActor; readonly snapshot: TopologyState }> => { const before = await topology.read(); - const carried = await carriedChild(native); + const carried = await carriedChild(canonical.payload); if (carried !== undefined) { const known = before.state.actors.find((actor) => actor.id === carried.id); if (known !== undefined) { diff --git a/examples/worktree-proximity/src/events/agent/start.tsx b/examples/worktree-proximity/src/events/agent/start.tsx index d0177b616..8c12397c4 100644 --- a/examples/worktree-proximity/src/events/agent/start.tsx +++ b/examples/worktree-proximity/src/events/agent/start.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { worktree } from '../../api.js'; import { withNotices, withTopology } from '../../coordination.js'; -import { carriedChild, deliveryContexts, nativeString } from '../../event-support.js'; +import { carriedChild, deliveryContexts, nonEmpty } from '../../event-support.js'; export const config = { runtime: 'shared', @@ -13,8 +13,7 @@ export const config = { export default async function AgentStart({ canonical, - native, -}: AgentEventRouteProps) { +}: AgentEventRouteProps<'agent/start'>) { const currentWorktree = await worktree(); if (currentWorktree.state === 'unavailable') { return ( @@ -25,12 +24,12 @@ export default async function AgentStart({ } // The runtime's `request.lineage` names the child and its parent when the - // registry resolved this start; the native `agent_id` + root `session_id` - // pair is the fallback. Neither present is a refusal, never a guess. - const child = await carriedChild(native); + // registry resolved this start; the payload's `agentId` + `sessionId` pair + // is the fallback. Neither present is a refusal, never a guess. + const child = await carriedChild(canonical.payload); if (child === undefined) { - const sessionId = nativeString(native, 'session_id'); - const refusal = nativeString(native, 'agent_id') === undefined + const sessionId = nonEmpty(canonical.payload.sessionId?.value); + const refusal = nonEmpty(canonical.payload.agentId?.value) === undefined ? 'agent/start omitted native agent_id; refused to fabricate a topology edge' : 'agent/start omitted native session_id; refused to fabricate a topology edge'; const topologyResult = await withTopology(async (topology) => { diff --git a/examples/worktree-proximity/src/events/session/start.tsx b/examples/worktree-proximity/src/events/session/start.tsx index 31e000323..857856ca8 100644 --- a/examples/worktree-proximity/src/events/session/start.tsx +++ b/examples/worktree-proximity/src/events/session/start.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { worktree } from '../../api.js'; import { withNotices, withTopology } from '../../coordination.js'; -import { ROOT_ACTOR_PREFIX, deliveryContexts, nativeString, requestLineage } from '../../event-support.js'; +import { ROOT_ACTOR_PREFIX, deliveryContexts, nonEmpty, requestLineage } from '../../event-support.js'; export const config = { runtime: 'shared', @@ -13,8 +13,7 @@ export const config = { export default async function SessionStart({ canonical, - native, -}: AgentEventRouteProps) { +}: AgentEventRouteProps<'session/start'>) { const currentWorktree = await worktree(); if (currentWorktree.state === 'unavailable') { return ( @@ -24,12 +23,13 @@ export default async function SessionStart({ ); } // The runtime's lineage names the root conversation on every host; the - // native `session_id` is the fallback when no lineage was resolved. + // payload's `sessionId` (Claude and Codex `session_id`) is the fallback when + // no lineage was resolved. const lineage = await requestLineage(); const root = lineage.state === 'available' ? { id: lineage.value.root, source: lineage.value.resolution } : (() => { - const sessionId = nativeString(native, 'session_id'); + const sessionId = nonEmpty(canonical.payload.sessionId?.value); return sessionId === undefined ? undefined : { id: sessionId, source: 'native' as const }; })(); if (root === undefined) { diff --git a/examples/worktree-proximity/src/events/stop.tsx b/examples/worktree-proximity/src/events/stop.tsx index acf897da3..34e3fe72f 100644 --- a/examples/worktree-proximity/src/events/stop.tsx +++ b/examples/worktree-proximity/src/events/stop.tsx @@ -18,8 +18,7 @@ export const config = { export default async function Stop({ canonical, - native, -}: AgentEventRouteProps) { +}: AgentEventRouteProps<'stop'>) { const currentWorktree = await worktree(); if (currentWorktree.state === 'unavailable') { return ( @@ -28,9 +27,9 @@ export default async function Stop({ ); } - // A stop names its own actor through the runtime lineage or the native - // `agent_id`; only an anonymous stop falls back to the worktree binding. - const carried = await carriedChild(native); + // A stop names its own actor through the runtime lineage or the payload's + // `agentId`; only an anonymous stop falls back to the worktree binding. + const carried = await carriedChild(canonical.payload); const topologyResult = await withTopology(async (topology): Promise => { const resolved: ResolvedActor = carried === undefined ? (await actorForWorktree(topology, currentWorktree, canonical)).actor diff --git a/examples/worktree-proximity/src/events/tool/after.tsx b/examples/worktree-proximity/src/events/tool/after.tsx index 7e2ce4709..c29675839 100644 --- a/examples/worktree-proximity/src/events/tool/after.tsx +++ b/examples/worktree-proximity/src/events/tool/after.tsx @@ -13,8 +13,7 @@ export const config = { export default async function AfterTool({ canonical, - native, -}: AgentEventRouteProps) { +}: AgentEventRouteProps<'tool/after'>) { const currentWorktree = await worktree(); if (currentWorktree.state === 'unavailable') { return ( @@ -24,7 +23,7 @@ export default async function AfterTool({ ); } const topologyResult = await withTopology(async (topology) => { - const resolved = await actorForWorktree(topology, currentWorktree, canonical, native); + const resolved = await actorForWorktree(topology, currentWorktree, canonical); await topology.dispatch('intentRecorded', { actorId: resolved.actor.id, dependencies: [], diff --git a/examples/worktree-proximity/src/events/tool/before.tsx b/examples/worktree-proximity/src/events/tool/before.tsx index 0d0828ca0..16632553d 100644 --- a/examples/worktree-proximity/src/events/tool/before.tsx +++ b/examples/worktree-proximity/src/events/tool/before.tsx @@ -19,8 +19,7 @@ export const config = { export default async function BeforeTool({ canonical, - native, -}: AgentEventRouteProps) { +}: AgentEventRouteProps<'tool/before'>) { const currentWorktree = await worktree(); if (currentWorktree.state === 'unavailable') { return ( @@ -29,9 +28,11 @@ export default async function BeforeTool({ ); } - const intent = extractIntent(native); + // `canonical.payload` is the framework's cross-host reading of the envelope: + // `toolName`/`toolInput` under Claude's PreToolUse and Codex's alike. + const intent = extractIntent(canonical.payload); const topologyResult = await withTopology(async (topology) => { - const { actor } = await actorForWorktree(topology, currentWorktree, canonical, native); + const { actor } = await actorForWorktree(topology, currentWorktree, canonical); const committed = await topology.dispatch('intentRecorded', { actorId: actor.id, dependencies: [...intent.dependencies], diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index 2d9972c36..0398593a8 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; import { agent, available, runAgentRequest, type AgentLineage, type Observed } from '@agent-bundle/runtime'; -import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; +import { createEventRouteInput, expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; import BeforeTool from '../../src/events/tool/before.js'; import agentTopologyProvider from '../../src/providers/agent-topology.js'; @@ -39,25 +39,25 @@ const providers = (root: string) => ({ gitWorktree: provider(root), }); +// The harness projects the envelope into `canonical.payload` exactly as the +// artifact does; the journeys keep their own readable idempotency keys and +// clock. `validate: false` admits the deliberately partial envelopes below. const eventInput = ( event: 'agent/start' | 'session/start' | 'stop' | 'tool/after' | 'tool/before', native: Record, id: string, -) => ({ - canonical: { - event, - idempotencyKey: id, - observedAt: `2026-09-01T20:00:${String(sequence++).padStart(2, '0')}.000Z`, - provenance: { - host: 'claude', - hostContractRevision: 'route-unit', - nativeEvent: native.hook_event_name as string, - source: 'native', +) => { + const built = createEventRouteInput(event, native, { host: 'claude', validate: false }); + return { + canonical: { + ...built.canonical, + idempotencyKey: id, + observedAt: `2026-09-01T20:00:${String(sequence++).padStart(2, '0')}.000Z`, + sequence, }, - sequence, - }, - native, -}); + native: built.native, + }; +}; const renderEventInput = async ( route: string, diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json index cd5a1431a..e61c15f38 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json @@ -119,27 +119,308 @@ "stop": "Stop" }, "eventRoutes": { - "agent/idle": { "nativeEvent": "TeammateIdle", "state": "supported" }, - "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" }, - "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, - "compact/after": { "nativeEvent": "PostCompact", "state": "supported" }, - "compact/before": { "nativeEvent": "PreCompact", "state": "supported" }, - "config/change": { "nativeEvent": "ConfigChange", "state": "supported" }, - "file/change": { "nativeEvent": "FileChanged", "state": "supported" }, - "model-switch/after": { "nativeEvent": "PostModelSwitch", "state": "supported" }, - "model-switch/before": { "nativeEvent": "PreModelSwitch", "state": "supported" }, - "permission/denied": { "nativeEvent": "PermissionDenied", "state": "supported" }, - "permission/request": { "nativeEvent": "PermissionRequest", "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" }, - "stop/failure": { "nativeEvent": "StopFailure", "state": "supported" }, - "task/complete": { "nativeEvent": "TaskCompleted", "state": "supported" }, - "task/create": { "nativeEvent": "TaskCreated", "state": "supported" }, - "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, - "tool/before": { "nativeEvent": "PreToolUse", "state": "supported" }, - "tool/failure": { "nativeEvent": "PostToolUseFailure", "state": "supported" }, + "agent/idle": { + "nativeEvent": "TeammateIdle", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "teammateName": "teammate_name", + "teamName": "team_name", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "agent/start": { + "nativeEvent": "SubagentStart", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "agent/stop": { + "nativeEvent": "SubagentStop", + "payload": { + "agentId": "agent_id", + "agentTranscriptPath": "agent_transcript_path", + "agentType": "agent_type", + "cwd": "cwd", + "lastAssistantMessage": "last_assistant_message", + "permissionMode": "permission_mode", + "reentry": "stop_hook_active", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "compact/after": { + "nativeEvent": "PostCompact", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path", + "trigger": "trigger" + }, + "state": "supported" + }, + "compact/before": { + "nativeEvent": "PreCompact", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path", + "trigger": "trigger" + }, + "state": "supported" + }, + "config/change": { + "nativeEvent": "ConfigChange", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "filePath": "file_path", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "source": "source", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "file/change": { + "nativeEvent": "FileChanged", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "filePath": "file_path", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "model-switch/after": { + "nativeEvent": "PostModelSwitch", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "fromModel": "from_model", + "permissionMode": "permission_mode", + "requestedModel": "requested_model", + "sessionId": "session_id", + "source": "source", + "toModel": "to_model", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "model-switch/before": { + "nativeEvent": "PreModelSwitch", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "fromModel": "from_model", + "permissionMode": "permission_mode", + "requestedModel": "requested_model", + "sessionId": "session_id", + "source": "source", + "toModel": "to_model", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "permission/denied": { + "nativeEvent": "PermissionDenied", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "permission/request": { + "nativeEvent": "PermissionRequest", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "prompt/submit": { + "nativeEvent": "UserPromptSubmit", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "prompt": "prompt", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "session/end": { + "nativeEvent": "SessionEnd", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "reason": "reason", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "session/start": { + "nativeEvent": "SessionStart", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "source": "source", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "stop": { + "nativeEvent": "Stop", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "lastAssistantMessage": "last_assistant_message", + "permissionMode": "permission_mode", + "reentry": "stop_hook_active", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "stop/failure": { + "nativeEvent": "StopFailure", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "error": "error", + "lastAssistantMessage": "last_assistant_message", + "permissionMode": "permission_mode", + "reentry": "stop_hook_active", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "task/complete": { + "nativeEvent": "TaskCompleted", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "taskDescription": "task_description", + "taskId": "task_id", + "taskSubject": "task_subject", + "teammateName": "teammate_name", + "teamName": "team_name", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "task/create": { + "nativeEvent": "TaskCreated", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "taskDescription": "task_description", + "taskId": "task_id", + "taskSubject": "task_subject", + "teammateName": "teammate_name", + "teamName": "team_name", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "tool/after": { + "nativeEvent": "PostToolUse", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolResponse": "tool_response", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "tool/before": { + "nativeEvent": "PreToolUse", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "tool/failure": { + "nativeEvent": "PostToolUseFailure", + "payload": { + "cwd": "cwd", + "error": "error", + "isInterrupt": "is_interrupt", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, "workspace/open": { "reason": "The pinned Claude Code 2.1.260 hooks contract has no workspace-open event.", "state": "unavailable" 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 beefb3c8d..7a1c3f9a1 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 @@ -216,10 +216,62 @@ "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no teammate-idle event.", "state": "unavailable" }, - "agent/start": { "nativeEvent": "SubagentStart", "state": "supported" }, - "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, - "compact/after": { "nativeEvent": "PostCompact", "state": "supported" }, - "compact/before": { "nativeEvent": "PreCompact", "state": "supported" }, + "agent/start": { + "nativeEvent": "SubagentStart", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "agent/stop": { + "nativeEvent": "SubagentStop", + "payload": { + "agentId": "agent_id", + "agentTranscriptPath": "agent_transcript_path", + "agentType": "agent_type", + "cwd": "cwd", + "lastAssistantMessage": "last_assistant_message", + "model": "model", + "permissionMode": "permission_mode", + "reentry": "stop_hook_active", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "compact/after": { + "nativeEvent": "PostCompact", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path", + "trigger": "trigger" + }, + "state": "supported" + }, + "compact/before": { + "nativeEvent": "PreCompact", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "transcriptPath": "transcript_path", + "trigger": "trigger" + }, + "state": "supported" + }, "config/change": { "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no config-change event.", "state": "unavailable" @@ -240,11 +292,77 @@ "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no permission-denied event.", "state": "unavailable" }, - "permission/request": { "nativeEvent": "PermissionRequest", "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" }, + "permission/request": { + "nativeEvent": "PermissionRequest", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "prompt/submit": { + "nativeEvent": "UserPromptSubmit", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "prompt": "prompt", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "session/end": { + "nativeEvent": "SessionEnd", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "reason": "reason", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "session/start": { + "nativeEvent": "SessionStart", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "source": "source", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "stop": { + "nativeEvent": "Stop", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "lastAssistantMessage": "last_assistant_message", + "model": "model", + "permissionMode": "permission_mode", + "reentry": "stop_hook_active", + "sessionId": "session_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, "stop/failure": { "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no stop-failure event.", "state": "unavailable" @@ -257,8 +375,39 @@ "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no task-created event.", "state": "unavailable" }, - "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, - "tool/before": { "nativeEvent": "PreToolUse", "state": "supported" }, + "tool/after": { + "nativeEvent": "PostToolUse", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolResponse": "tool_response", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, + "state": "supported" + }, + "tool/before": { + "nativeEvent": "PreToolUse", + "payload": { + "agentId": "agent_id", + "agentType": "agent_type", + "cwd": "cwd", + "model": "model", + "permissionMode": "permission_mode", + "sessionId": "session_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, + "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" 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 90671531e..a0c2751ff 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 @@ -125,11 +125,27 @@ "agent/start": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "subagentStart", + "payload": { + "agentId": "subagent_id", + "agentType": "subagent_type", + "model": "model", + "sessionId": "conversation_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "agent/stop": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "subagentStop", + "payload": { + "agentId": "subagent_id", + "agentTranscriptPath": "agent_transcript_path", + "agentType": "subagent_type", + "model": "model", + "reentry": { "decode": "positive-count", "nativeKey": "loop_count" }, + "sessionId": "conversation_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "compact/after": { @@ -139,6 +155,12 @@ "compact/before": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "preCompact", + "payload": { + "model": "model", + "sessionId": "conversation_id", + "transcriptPath": "transcript_path", + "trigger": "trigger" + }, "state": "supported" }, "config/change": { @@ -168,6 +190,12 @@ "prompt/submit": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "beforeSubmitPrompt", + "payload": { + "model": "model", + "prompt": "prompt", + "sessionId": "conversation_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "session/end": { @@ -179,6 +207,12 @@ "desktop": { "state": "supported" } }, "nativeEvent": "sessionEnd", + "payload": { + "model": "model", + "reason": "reason", + "sessionId": "conversation_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "session/start": { @@ -190,11 +224,22 @@ "desktop": { "state": "supported" } }, "nativeEvent": "sessionStart", + "payload": { + "model": "model", + "sessionId": "conversation_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "stop": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "stop", + "payload": { + "model": "model", + "reentry": { "decode": "positive-count", "nativeKey": "loop_count" }, + "sessionId": "conversation_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "stop/failure": { @@ -212,16 +257,45 @@ "tool/after": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "postToolUse", + "payload": { + "cwd": "cwd", + "model": "model", + "sessionId": "conversation_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolResponse": { "decode": "json-string", "nativeKey": "tool_output" }, + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "tool/before": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "preToolUse", + "payload": { + "cwd": "cwd", + "model": "model", + "sessionId": "conversation_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "tool/failure": { "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, "nativeEvent": "postToolUseFailure", + "payload": { + "cwd": "cwd", + "error": "error_message", + "isInterrupt": "is_interrupt", + "sessionId": "conversation_id", + "toolInput": "tool_input", + "toolName": "tool_name", + "toolUseId": "tool_use_id", + "transcriptPath": "transcript_path" + }, "state": "supported" }, "workspace/open": { @@ -233,6 +307,9 @@ "desktop": { "state": "supported" } }, "nativeEvent": "workspaceOpen", + "payload": { + "workspaceRoots": "workspace_roots" + }, "state": "supported" } }, diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 9e33622e3..f709550bd 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -46,6 +46,14 @@ export const unavailableCapability = (reason: string): CapabilityState => Object export interface EventRouteCapabilityTableEntry { readonly nativeEvent?: string; + /** + * The host's spelling of each canonical payload field for this family (#466): + * the native key, or `{ nativeKey, decode }` when a transformation applies. + * Mirrors `agentEventPayloadNativeKeys` in `routes/events.ts` (the runtime + * table) so the generated events reference documents the mapping per host; + * `tests/event-payload.test.ts` holds the two equal. + */ + readonly payload?: Readonly>; readonly reason?: string; /** JSON imports widen literals; unsupported table states fail closed below. */ readonly state: string; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 3a4acb7bb..c42cc39d6 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -41,12 +41,25 @@ export type { McpAppProfileId } from './dev/mcp-app-profile-descriptors.ts'; import { deepFreeze } from './core/freeze.ts'; export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts'; -export { canonicalAgentEvents } from './routes/public.ts'; +export { + agentEventPayloadFieldKinds, + agentEventPayloadFields, + agentEventPayloadNativeKeys, + canonicalAgentEvents, +} from './routes/public.ts'; export type { AgentEventCanonicalIdentity, AgentEventDelivery, AgentEventFallbackMode, AgentEventNativePayload, + AgentEventPayload, + AgentEventPayloadField, + AgentEventPayloadFieldKind, + AgentEventPayloadFieldName, + AgentEventPayloadFields, + AgentEventPayloadFieldTypes, + AgentEventPayloadHost, + AgentEventPayloadNativeKey, AgentEventProvenance, AgentEventRouteConfig, AgentEventRouteProps, diff --git a/packages/agent-bundle/src/events/payload.ts b/packages/agent-bundle/src/events/payload.ts new file mode 100644 index 000000000..c2329c11c --- /dev/null +++ b/packages/agent-bundle/src/events/payload.ts @@ -0,0 +1,108 @@ +import type { JsonValue } from '../core/strict-json.ts'; +import { + agentEventPayloadFieldKinds, + agentEventPayloadFields, + agentEventPayloadNativeKeys, + isAgentEventPayloadHost, + type AgentEventPayload, + type AgentEventPayloadField, + type AgentEventPayloadFieldKind, + type AgentEventPayloadFieldName, + type AgentEventPayloadNativeKey, + type CanonicalAgentEvent, +} from '../routes/events.ts'; + +const isJsonValue = (value: unknown): value is JsonValue => { + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + return true; + case 'object': + if (value === null) return true; + if (Array.isArray(value)) return value.every(isJsonValue); + return Object.values(value as Record).every(isJsonValue); + default: + return false; + } +}; + +/** Reads one native value as the field's declared JSON shape; `undefined` when the host sent another shape. */ +const decodeKind = (kind: AgentEventPayloadFieldKind, value: unknown): unknown => { + switch (kind) { + case 'boolean': + return typeof value === 'boolean' ? value : undefined; + case 'json': + return isJsonValue(value) ? value : undefined; + case 'nullable-string': + return value === null || typeof value === 'string' ? value : undefined; + case 'string': + return typeof value === 'string' ? value : undefined; + case 'string-array': + return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined; + case 'trigger': + return value === 'manual' || value === 'auto' ? value : undefined; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +/** Applies the host-specific transformation named by the mapping, then the field's shape check. */ +const decodeNative = ( + field: AgentEventPayloadFieldName, + mapping: AgentEventPayloadNativeKey, + raw: unknown, +): unknown => { + const kind = agentEventPayloadFieldKinds[field]; + switch (mapping.decode) { + case 'json-string': { + if (typeof raw !== 'string') return undefined; + // Cursor documents tool_output as a JSON-stringified record; a payload + // that is not valid JSON is still what the host said, so it stays a string. + try { + return decodeKind(kind, JSON.parse(raw)); + } catch { + return raw; + } + } + case 'positive-count': + return typeof raw === 'number' && Number.isFinite(raw) ? raw > 0 : undefined; + case undefined: + return decodeKind(kind, raw); + default: { + const exhaustive: never = mapping.decode; + return exhaustive; + } + } +}; + +/** + * Projects a validated native envelope into the canonical payload of its + * family, reading each admitted field through the host's own key from + * {@link agentEventPayloadNativeKeys}. A field the host did not send — or + * sent in another shape — is omitted rather than fabricated; a host without a + * mapping table (the portable target, an unknown host) yields an empty + * payload and the route still has `native`. + */ +export const projectEventPayload = ( + event: E, + native: Readonly>, + target: string, +): AgentEventPayload => { + const payload: Record> = {}; + const mappings = isAgentEventPayloadHost(target) ? agentEventPayloadNativeKeys[target][event] : undefined; + if (mappings !== undefined) { + for (const field of agentEventPayloadFields[event]) { + const mapping = mappings[field]; + if (mapping === undefined || !Object.hasOwn(native, mapping.nativeKey)) continue; + const value = decodeNative(field, mapping, native[mapping.nativeKey]); + if (value === undefined) continue; + // Values are shared with the frozen `native` snapshot and keep its depth of + // freezing; only the payload's own records are frozen here. + payload[field] = Object.freeze({ nativeKey: mapping.nativeKey, value }); + } + } + return Object.freeze(payload) as AgentEventPayload; +}; diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index 4e515d466..e07479f06 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -1,3 +1,4 @@ +export { projectEventPayload } from './payload.ts'; export { createCanonicalEventProps, projectEventDocument, diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index c27283c12..eb7e69042 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -9,6 +9,7 @@ import type { AgentEventRouteProps, CanonicalAgentEvent, } from '../routes/public.ts'; +import { projectEventPayload } from './payload.ts'; /** * The route result vocabulary. `continue` (or no value at all) is the @@ -418,21 +419,32 @@ export const validateNativeEventEnvelope = ( return native; }; -export const createCanonicalEventProps = ( - event: CanonicalAgentEvent, +/** + * Builds the props an event route receives from one validated native + * envelope: the frozen `native` snapshot beside the canonical identity, whose + * `payload` is the family's cross-host reading of that same envelope. Every + * surface that renders an event route — the standalone hook wrapper, the + * shared runtime, `agent-bundle/test`, and the Workbench replay — goes + * through here, so the payload can never disagree between them. + */ +export const createCanonicalEventProps = ( + event: E, nativeInput: Readonly>, target: string, nativeEvent: string, hostContractRevision: string, signal: AbortSignal, -): AgentEventRouteProps => { +): AgentEventRouteProps => { const native = snapshotNative(nativeInput); - const canonical: AgentEventCanonicalIdentity = Object.freeze({ + const canonical: AgentEventCanonicalIdentity = Object.freeze({ event, + // The key hashes the envelope, not the payload: the payload is derived, so + // a mapping-table change never re-identifies an already-observed event. idempotencyKey: createHash('sha256') .update(JSON.stringify({ event, native, target }), 'utf8') .digest('hex'), observedAt: new Date().toISOString(), + payload: projectEventPayload(event, native, target), provenance: Object.freeze({ host: target, hostContractRevision, diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 88d362acd..9314bf6b5 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -20,12 +20,26 @@ export type { SkillTokenId, SkillTreeLayoutDecision, } from './skills/index.ts'; -export { canonicalAgentEvents, MAX_ROUTE_RENDER_ELAPSED_MS } from './routes/public.ts'; +export { + agentEventPayloadFieldKinds, + agentEventPayloadFields, + agentEventPayloadNativeKeys, + canonicalAgentEvents, + MAX_ROUTE_RENDER_ELAPSED_MS, +} from './routes/public.ts'; export type { AgentEventCanonicalIdentity, AgentEventDelivery, AgentEventFallbackMode, AgentEventNativePayload, + AgentEventPayload, + AgentEventPayloadField, + AgentEventPayloadFieldKind, + AgentEventPayloadFieldName, + AgentEventPayloadFields, + AgentEventPayloadFieldTypes, + AgentEventPayloadHost, + AgentEventPayloadNativeKey, AgentEventProvenance, AgentEventRouteConfig, AgentEventRouteProps, diff --git a/packages/agent-bundle/src/routes/events.ts b/packages/agent-bundle/src/routes/events.ts new file mode 100644 index 000000000..678b1c553 --- /dev/null +++ b/packages/agent-bundle/src/routes/events.ts @@ -0,0 +1,362 @@ +import type { JsonValue } from '../core/strict-json.ts'; + +/** The event-route families admitted by the recorded #97 v1/G10 decision. */ +export const canonicalAgentEvents = Object.freeze([ + 'session/start', + 'tool/before', + 'tool/after', + 'stop', + 'agent/start', + 'agent/stop', + 'workspace/open', + 'session/end', + 'prompt/submit', + 'tool/failure', + 'compact/before', + 'compact/after', + 'permission/request', + 'permission/denied', + 'stop/failure', + 'file/change', + 'config/change', + 'task/create', + 'task/complete', + 'agent/idle', + 'model-switch/before', + 'model-switch/after', +] as const); + +export type CanonicalAgentEvent = (typeof canonicalAgentEvents)[number]; + +/** + * The canonical payload vocabulary of event routes (#466): every field a + * route may read from `canonical.payload`, with the JSON shape it carries on + * every host. A field is admitted only when at least two hosts report it for + * the same family (or the family exists on one host only, in which case its + * defining fields are admitted); everything else stays host-specific and is + * read from `native`. The per-host spelling of each field lives in + * {@link agentEventPayloadNativeKeys}, and the generated events reference + * renders the same table per host from the pinned capability JSON. + */ +export interface AgentEventPayloadFieldTypes { + /** The subagent the event belongs to (Claude and Codex name it on every hook fired inside one). */ + readonly agentId: string; + /** The subagent's own transcript, `null` before the host has written it. */ + readonly agentTranscriptPath: string | null; + readonly agentType: string; + readonly cwd: string; + /** The failed tool's error text (`tool/failure`) or the API error string (`stop/failure`). */ + readonly error: string; + readonly filePath: string; + /** The model in use before a switch (`model-switch/*`, Claude `from_model`). */ + readonly fromModel: string; + readonly isInterrupt: boolean; + /** The turn's final assistant text; `null` when the host reports none. */ + readonly lastAssistantMessage: string | null; + readonly model: string; + readonly permissionMode: string; + readonly prompt: string; + /** `reason` on `session/end`: the host's own vocabulary (`clear`, `other`, `completed`, …). */ + readonly reason: string; + /** The model name as the user typed it (`model-switch/*`, Claude `requested_model`); `null` when the switch was not user-initiated. */ + readonly requestedModel: string | null; + /** + * Whether this stop hook is already running for the turn — Claude and Codex + * `stop_hook_active`, Cursor `loop_count > 0`. Check it before returning + * `deny` from a `stop` route, or the continuation loops until the host caps it. + */ + readonly reentry: boolean; + /** The conversation id: `session_id` on Claude and Codex, `conversation_id` on Cursor. */ + readonly sessionId: string; + /** How the session began (`session/start`), which settings layer changed (`config/change`), or what triggered a model switch (`model-switch/*`). */ + readonly source: string; + readonly taskDescription: string; + readonly taskId: string; + readonly taskSubject: string; + readonly teamName: string; + readonly teammateName: string; + /** The canonical name of the model being switched to (`model-switch/*`, Claude `to_model`). */ + readonly toModel: string; + /** The pending call's input; an object on Claude and Cursor, any JSON value on Codex. */ + readonly toolInput: JsonValue; + readonly toolName: string; + /** + * The completed call's response. Claude and Codex deliver `tool_response` + * as JSON (a plain string for MCP tools on Claude); Cursor's `tool_output` + * JSON string is parsed when it is valid JSON and kept as the string otherwise. + */ + readonly toolResponse: JsonValue; + readonly toolUseId: string; + readonly transcriptPath: string | null; + readonly trigger: 'manual' | 'auto'; + readonly workspaceRoots: readonly string[]; +} + +export type AgentEventPayloadFieldName = keyof AgentEventPayloadFieldTypes; + +/** The JSON shape each canonical payload field is decoded as; the runtime twin of {@link AgentEventPayloadFieldTypes}. */ +export type AgentEventPayloadFieldKind = + | 'boolean' + | 'json' + | 'nullable-string' + | 'string' + | 'string-array' + | 'trigger'; + +export const agentEventPayloadFieldKinds = Object.freeze({ + agentId: 'string', + agentTranscriptPath: 'nullable-string', + agentType: 'string', + cwd: 'string', + error: 'string', + filePath: 'string', + fromModel: 'string', + isInterrupt: 'boolean', + lastAssistantMessage: 'nullable-string', + model: 'string', + permissionMode: 'string', + prompt: 'string', + reason: 'string', + reentry: 'boolean', + requestedModel: 'nullable-string', + sessionId: 'string', + source: 'string', + taskDescription: 'string', + taskId: 'string', + taskSubject: 'string', + teamName: 'string', + teammateName: 'string', + toModel: 'string', + toolInput: 'json', + toolName: 'string', + toolResponse: 'json', + toolUseId: 'string', + transcriptPath: 'nullable-string', + trigger: 'trigger', + workspaceRoots: 'string-array', +} as const satisfies Readonly>); + +// The session fields Claude and Codex put on every envelope (Cursor adds +// `cwd` on tool events only and has no permission mode or per-hook agent id). +const sessionFields = ['sessionId', 'cwd', 'transcriptPath', 'permissionMode', 'agentId', 'agentType'] as const; +// `model` reaches the payload only where two hosts send it: Codex and Cursor +// on every envelope, Claude on SessionStart alone — so the families all three +// hosts support carry it, and the Claude/Codex-only families do not. +const threeHostFields = [...sessionFields, 'model'] as const; +const toolFields = [...threeHostFields, 'toolName', 'toolInput', 'toolUseId'] as const; +const taskFields = [...sessionFields, 'taskId', 'taskSubject', 'taskDescription', 'teammateName', 'teamName'] as const; +// Claude-only families (PreModelSwitch / PostModelSwitch, 2.1.251+; pinned at +// 2.1.260) admit their defining fields: the switch itself plus what triggered it. +const modelSwitchFields = [...sessionFields, 'fromModel', 'toModel', 'requestedModel', 'source'] as const; + +/** + * The canonical payload fields of every event-route family, in the order the + * payload object carries them. This is the one per-family table; the types + * ({@link AgentEventPayload}) and the runtime projection derive from it. + */ +export const agentEventPayloadFields = Object.freeze({ + 'agent/idle': [...sessionFields, 'teammateName', 'teamName'], + 'agent/start': threeHostFields, + 'agent/stop': [...threeHostFields, 'agentTranscriptPath', 'reentry', 'lastAssistantMessage'], + 'compact/after': [...sessionFields, 'trigger'], + 'compact/before': [...threeHostFields, 'trigger'], + 'config/change': [...sessionFields, 'source', 'filePath'], + 'file/change': [...sessionFields, 'filePath'], + 'model-switch/after': modelSwitchFields, + 'model-switch/before': modelSwitchFields, + 'permission/denied': [...sessionFields, 'toolName', 'toolInput'], + 'permission/request': [...sessionFields, 'toolName', 'toolInput'], + 'prompt/submit': [...threeHostFields, 'prompt'], + 'session/end': [...threeHostFields, 'reason'], + 'session/start': [...threeHostFields, 'source'], + stop: [...threeHostFields, 'reentry', 'lastAssistantMessage'], + 'stop/failure': [...sessionFields, 'error', 'reentry', 'lastAssistantMessage'], + 'task/complete': taskFields, + 'task/create': taskFields, + 'tool/after': [...toolFields, 'toolResponse'], + 'tool/before': toolFields, + // Claude and Cursor: neither shares the other's permission mode or agent id here. + 'tool/failure': ['sessionId', 'cwd', 'transcriptPath', 'toolName', 'toolInput', 'toolUseId', 'error', 'isInterrupt'], + 'workspace/open': ['workspaceRoots'], +} as const satisfies Readonly>); + +export type AgentEventPayloadFields = typeof agentEventPayloadFields; + +/** + * One canonical payload field as a route receives it: the decoded value plus + * the host's own key it was read from, so a consumer can tell a mapped field + * from a missing one and still name the native spelling in its own output. + */ +export interface AgentEventPayloadField { + readonly nativeKey: string; + readonly value: Value; +} + +/** + * The canonical payload of event family `E`: each admitted field is present + * with its provenance when the host sent it and absent (`undefined`) when the + * host did not — never fabricated. For the wide `CanonicalAgentEvent` the + * payload admits every field of every family, all optional. + */ +export type AgentEventPayload = { + readonly [Field in AgentEventPayloadFields[E][number]]?: AgentEventPayloadField; +}; + +/** The hosts whose native envelopes the framework maps into canonical payloads. */ +export type AgentEventPayloadHost = 'claude' | 'codex' | 'cursor'; + +/** + * How a host spells one canonical field. `decode` names a transformation + * beyond reading the key: `json-string` parses a JSON-encoded string + * (Cursor `tool_output`), `positive-count` reads a counter as a boolean + * (Cursor `loop_count` → `reentry`). Absent means the value is taken as is. + */ +export interface AgentEventPayloadNativeKey { + readonly decode?: 'json-string' | 'positive-count'; + readonly nativeKey: string; +} + +type NativeKeyTable = Readonly>>; + +const key = (nativeKey: string, decode?: AgentEventPayloadNativeKey['decode']): AgentEventPayloadNativeKey => + Object.freeze(decode === undefined ? { nativeKey } : { decode, nativeKey }); + +/** The shared Claude/Codex envelope spellings; Cursor deviates per field below. */ +const standardKeys = Object.freeze({ + agentId: key('agent_id'), + agentTranscriptPath: key('agent_transcript_path'), + agentType: key('agent_type'), + cwd: key('cwd'), + error: key('error'), + filePath: key('file_path'), + fromModel: key('from_model'), + isInterrupt: key('is_interrupt'), + lastAssistantMessage: key('last_assistant_message'), + model: key('model'), + permissionMode: key('permission_mode'), + prompt: key('prompt'), + reason: key('reason'), + reentry: key('stop_hook_active'), + requestedModel: key('requested_model'), + sessionId: key('session_id'), + source: key('source'), + taskDescription: key('task_description'), + taskId: key('task_id'), + taskSubject: key('task_subject'), + teamName: key('team_name'), + teammateName: key('teammate_name'), + toModel: key('to_model'), + toolInput: key('tool_input'), + toolName: key('tool_name'), + toolResponse: key('tool_response'), + toolUseId: key('tool_use_id'), + transcriptPath: key('transcript_path'), + trigger: key('trigger'), +} as const satisfies NativeKeyTable); + +const cursorKeys = Object.freeze({ + ...standardKeys, + agentId: key('subagent_id'), + agentType: key('subagent_type'), + error: key('error_message'), + reentry: key('loop_count', 'positive-count'), + sessionId: key('conversation_id'), + toolResponse: key('tool_output', 'json-string'), + workspaceRoots: key('workspace_roots'), +} as const satisfies NativeKeyTable); + +const pick = ( + keys: Keys, + fields: readonly (keyof Keys & AgentEventPayloadFieldName)[], +): NativeKeyTable => Object.freeze(Object.fromEntries(fields.map((field) => [field, keys[field]]))); + +// Claude Code common input fields (hooks reference, "Common input fields", +// 2026-09-03): session_id, transcript_path, cwd, permission_mode ("not all +// events receive this field"), plus agent_id/agent_type inside a subagent; +// `model` reaches SessionStart only. +const claudeSession = ['sessionId', 'cwd', 'transcriptPath', 'permissionMode', 'agentId', 'agentType'] as const; +const claudeTool = [...claudeSession, 'toolName', 'toolInput', 'toolUseId'] as const; +const claudeTask = [...claudeSession, 'taskId', 'taskSubject', 'taskDescription', 'teammateName', 'teamName'] as const; +// The pinned rust-v0.147.0 input schemas require session_id, transcript_path +// (nullable), cwd, model, and permission_mode on every event; agent_id and +// agent_type ride along inside a subagent (fixtures/host-lineage/codex-0.147.0.ndjson). +// `model` is mapped only on the families where a second host also sends it. +const codexSession = ['sessionId', 'cwd', 'transcriptPath', 'permissionMode', 'agentId', 'agentType'] as const; +const codexThreeHost = [...codexSession, 'model'] as const; +const codexTool = [...codexThreeHost, 'toolName', 'toolInput', 'toolUseId'] as const; +// https://cursor.com/docs/hooks (2026-08-28) plus fixtures/host-lineage/cursor-3.18.25.ndjson: +// every envelope carries conversation_id, model, and transcript_path (nullable); +// cwd arrives on the tool events only; there is no permission mode. +const cursorSession = ['sessionId', 'transcriptPath', 'model'] as const; +const cursorTool = [...cursorSession, 'cwd', 'toolName', 'toolInput', 'toolUseId'] as const; + +/** + * The per-host native spelling of each canonical payload field, per family: + * the one mapping table the runtime projection reads. A family a host does + * not support has no entry; a field a host never sends for a family is + * absent from its entry, so the table doubles as the coverage matrix the + * generated events reference renders. The pinned capability tables mirror + * it under `hooks.eventRoutes..payload`, held equal by + * `tests/event-payload.test.ts`. + */ +export const agentEventPayloadNativeKeys: Readonly< + Record>>> +> = Object.freeze({ + claude: Object.freeze({ + 'agent/idle': pick(standardKeys, [...claudeSession, 'teammateName', 'teamName']), + 'agent/start': pick(standardKeys, claudeSession), + 'agent/stop': pick(standardKeys, [...claudeSession, 'agentTranscriptPath', 'reentry', 'lastAssistantMessage']), + 'compact/after': pick(standardKeys, [...claudeSession, 'trigger']), + 'compact/before': pick(standardKeys, [...claudeSession, 'trigger']), + 'config/change': pick(standardKeys, [...claudeSession, 'source', 'filePath']), + 'file/change': pick(standardKeys, [...claudeSession, 'filePath']), + // hooks reference "PreModelSwitch input" / "PostModelSwitch input" (2.1.251+): + // from_model, to_model, requested_model (string or null), source; the + // cache and pricing fields stay host-specific in `native`. + 'model-switch/after': pick(standardKeys, modelSwitchFields), + 'model-switch/before': pick(standardKeys, modelSwitchFields), + 'permission/denied': pick(standardKeys, [...claudeSession, 'toolName', 'toolInput']), + 'permission/request': pick(standardKeys, [...claudeSession, 'toolName', 'toolInput']), + 'prompt/submit': pick(standardKeys, [...claudeSession, 'prompt']), + 'session/end': pick(standardKeys, [...claudeSession, 'reason']), + 'session/start': pick(standardKeys, [...claudeSession, 'model', 'source']), + stop: pick(standardKeys, [...claudeSession, 'reentry', 'lastAssistantMessage']), + 'stop/failure': pick(standardKeys, [...claudeSession, 'error', 'reentry', 'lastAssistantMessage']), + 'task/complete': pick(standardKeys, claudeTask), + 'task/create': pick(standardKeys, claudeTask), + 'tool/after': pick(standardKeys, [...claudeTool, 'toolResponse']), + 'tool/before': pick(standardKeys, claudeTool), + 'tool/failure': pick(standardKeys, ['sessionId', 'cwd', 'transcriptPath', 'toolName', 'toolInput', 'toolUseId', 'error', 'isInterrupt']), + }), + codex: Object.freeze({ + 'agent/start': pick(standardKeys, codexThreeHost), + 'agent/stop': pick(standardKeys, [...codexThreeHost, 'agentTranscriptPath', 'reentry', 'lastAssistantMessage']), + 'compact/after': pick(standardKeys, [...codexSession, 'trigger']), + 'compact/before': pick(standardKeys, [...codexThreeHost, 'trigger']), + 'permission/request': pick(standardKeys, [...codexSession, 'toolName', 'toolInput']), + 'prompt/submit': pick(standardKeys, [...codexThreeHost, 'prompt']), + 'session/end': pick(standardKeys, [...codexThreeHost, 'reason']), + 'session/start': pick(standardKeys, [...codexThreeHost, 'source']), + stop: pick(standardKeys, [...codexThreeHost, 'reentry', 'lastAssistantMessage']), + 'tool/after': pick(standardKeys, [...codexTool, 'toolResponse']), + 'tool/before': pick(standardKeys, codexTool), + }), + cursor: Object.freeze({ + 'agent/start': pick(cursorKeys, [...cursorSession, 'agentId', 'agentType']), + // subagentStop documents subagent_type and agent_transcript_path; subagent_id + // is observed on Cursor 3.18.25 (fixtures/host-lineage/cursor-3.18.25.ndjson). + 'agent/stop': pick(cursorKeys, [...cursorSession, 'agentId', 'agentType', 'agentTranscriptPath', 'reentry']), + 'compact/before': pick(cursorKeys, [...cursorSession, 'trigger']), + 'prompt/submit': pick(cursorKeys, [...cursorSession, 'prompt']), + 'session/end': pick(cursorKeys, [...cursorSession, 'reason']), + 'session/start': pick(cursorKeys, cursorSession), + stop: pick(cursorKeys, [...cursorSession, 'reentry']), + 'tool/after': pick(cursorKeys, [...cursorTool, 'toolResponse']), + 'tool/before': pick(cursorKeys, cursorTool), + 'tool/failure': pick(cursorKeys, ['sessionId', 'transcriptPath', 'cwd', 'toolName', 'toolInput', 'toolUseId', 'error', 'isInterrupt']), + 'workspace/open': pick(cursorKeys, ['workspaceRoots']), + }), +}); + +export const isAgentEventPayloadHost = (target: string): target is AgentEventPayloadHost => + target === 'claude' || target === 'codex' || target === 'cursor'; diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 610b0016d..31bef7438 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -51,12 +51,27 @@ export { export type { RouteModuleExports } from './contract.ts'; export { routeRenderLimits, validateRouteRenderConfig } from './render-budget.ts'; export type { RouteRenderBudget, ValidatedRouteRenderConfig } from './render-budget.ts'; -export { appResourceUri, canonicalAgentEvents, MAX_ROUTE_RENDER_ELAPSED_MS } from './public.ts'; +export { + agentEventPayloadFieldKinds, + agentEventPayloadFields, + agentEventPayloadNativeKeys, + appResourceUri, + canonicalAgentEvents, + MAX_ROUTE_RENDER_ELAPSED_MS, +} from './public.ts'; export type { AgentEventCanonicalIdentity, AgentEventDelivery, AgentEventFallbackMode, AgentEventNativePayload, + AgentEventPayload, + AgentEventPayloadField, + AgentEventPayloadFieldKind, + AgentEventPayloadFieldName, + AgentEventPayloadFields, + AgentEventPayloadFieldTypes, + AgentEventPayloadHost, + AgentEventPayloadNativeKey, AgentEventProvenance, AgentEventRouteConfig, AgentEventRouteProps, diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index f20bb8fc8..bf0a62f82 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -1,5 +1,24 @@ import type { JsonValue } from '../core/strict-json.ts'; import type { AgentTerminal } from '../terminal-capability.ts'; +import type { AgentEventPayload, CanonicalAgentEvent } from './events.ts'; + +export { + agentEventPayloadFieldKinds, + agentEventPayloadFields, + agentEventPayloadNativeKeys, + canonicalAgentEvents, +} from './events.ts'; +export type { + AgentEventPayload, + AgentEventPayloadField, + AgentEventPayloadFieldKind, + AgentEventPayloadFieldName, + AgentEventPayloadFields, + AgentEventPayloadFieldTypes, + AgentEventPayloadHost, + AgentEventPayloadNativeKey, + CanonicalAgentEvent, +} from './events.ts'; /** The structural schema surface route props infer without coupling to one schema library. */ export interface RouteSchema { @@ -8,34 +27,6 @@ export interface RouteSchema { export type RouteSchemaOutput = Schema extends RouteSchema ? Output : never; -/** The event-route families admitted by the recorded #97 v1/G10 decision. */ -export const canonicalAgentEvents = Object.freeze([ - 'session/start', - 'tool/before', - 'tool/after', - 'stop', - 'agent/start', - 'agent/stop', - 'workspace/open', - 'session/end', - 'prompt/submit', - 'tool/failure', - 'compact/before', - 'compact/after', - 'permission/request', - 'permission/denied', - 'stop/failure', - 'file/change', - 'config/change', - 'task/create', - 'task/complete', - 'agent/idle', - 'model-switch/before', - 'model-switch/after', -] as const); - -export type CanonicalAgentEvent = (typeof canonicalAgentEvents)[number]; - export interface AgentEventProvenance { readonly host: string; readonly hostContractRevision: string; @@ -43,11 +34,19 @@ export interface AgentEventProvenance { readonly source: 'native'; } -/** Cross-host identity supplied to an event route without fabricated host fields. */ -export interface AgentEventCanonicalIdentity { - readonly event: CanonicalAgentEvent; +/** + * Cross-host identity supplied to an event route without fabricated host + * fields, plus the canonical `payload` of its family (#466): the fields at + * least two hosts report — tool name, input, and response, session id, + * transcript path, stop re-entry, prompt text, agent id and type, … — each + * carrying the host's own key as provenance, and absent when the host did + * not send it. `E` narrows `payload` to the family the route handles. + */ +export interface AgentEventCanonicalIdentity { + readonly event: E; readonly idempotencyKey: string; readonly observedAt: string; + readonly payload: AgentEventPayload; readonly provenance: AgentEventProvenance; readonly sequence: number; } @@ -57,6 +56,9 @@ export type AgentEventNativePayload = Readonly>; /** * Props received by an event route's async default Server Component. + * `canonical.payload` is the cross-host reading of the envelope for the + * route's family; `native` is the frozen host envelope itself, for the + * host-specific fields the payload does not model. * * Read transport-owned request context with `await agent()` from * `@agent-bundle/runtime`. The invocation, host, session, actor, workspace, @@ -67,8 +69,8 @@ export type AgentEventNativePayload = Readonly>; * is unavailable on hook-driven event scopes. The framework never derives or * surfaces the operator's identity from a host payload. */ -export interface AgentEventRouteProps { - readonly canonical: AgentEventCanonicalIdentity; +export interface AgentEventRouteProps { + readonly canonical: AgentEventCanonicalIdentity; readonly native: AgentEventNativePayload; readonly signal: AbortSignal; } diff --git a/packages/agent-bundle/src/test/event-input.ts b/packages/agent-bundle/src/test/event-input.ts new file mode 100644 index 000000000..021153b44 --- /dev/null +++ b/packages/agent-bundle/src/test/event-input.ts @@ -0,0 +1,70 @@ +import { createCanonicalEventProps, validateNativeEventEnvelope } from '../events/project.ts'; +import type { AgentEventCanonicalIdentity, AgentEventNativePayload, CanonicalAgentEvent } from '../routes/public.ts'; +import { AgentTestError, captured } from './errors.ts'; + +export interface CreateEventRouteInputOptions { + /** The host the envelope came from; selects the canonical payload mapping and the validator. */ + readonly host: 'claude' | 'codex' | 'cursor' | (string & {}); + /** + * The pinned host contract revision recorded in `canonical.provenance`. + * A route-unit test rarely depends on it; defaults to `'route-unit'`. + */ + readonly hostContractRevision?: string; + /** The host-native event name; defaults to the envelope's own `hook_event_name`. */ + readonly nativeEvent?: string; + /** + * Validate the envelope with the same per-host, per-event rules the + * generated wrapper applies before it reaches a route (default `true`). + * Pass `false` to hand a route a deliberately partial envelope. + */ + readonly validate?: boolean; +} + +/** The `{ canonical, native }` half of `AgentEventRouteProps`; the harness supplies `signal`. */ +export interface AgentEventRouteInput { + readonly canonical: AgentEventCanonicalIdentity; + readonly native: AgentEventNativePayload; +} + +/** + * Builds the `renderRoute` input of an event route from one host-native + * envelope, exactly as the generated wrapper does: the envelope is validated + * per host and event, frozen as `native`, and projected into the family's + * canonical `payload` through the same table the artifact uses (#466). A + * route test therefore reads `canonical.payload.toolName` from a real Claude, + * Codex, or Cursor fixture instead of hand-writing the identity. + * + * ```ts + * const rendered = await renderRoute('event:tool/after', { + * input: createEventRouteInput('tool/after', claudeFixture, { host: 'claude' }), + * }); + * ``` + */ +export const createEventRouteInput = ( + event: E, + native: Readonly>, + options: CreateEventRouteInputOptions, +): AgentEventRouteInput => { + const nativeEvent = options.nativeEvent ?? native.hook_event_name; + if (typeof nativeEvent !== 'string' || nativeEvent.trim() === '') { + throw new AgentTestError('invalid-input', 'An event-route input needs the host-native event name.', { + details: [ + `event: ${event}`, + `received: ${captured(native.hook_event_name)}`, + ], + recovery: 'Put hook_event_name on the envelope, as every host does, or pass options.nativeEvent.', + }); + } + const validated = options.validate === false + ? native + : validateNativeEventEnvelope(native, { canonicalEvent: event, nativeEvent, target: options.host }); + const props = createCanonicalEventProps( + event, + validated, + options.host, + nativeEvent, + options.hostContractRevision ?? 'route-unit', + new AbortController().signal, + ); + return Object.freeze({ canonical: props.canonical, native: props.native }); +}; diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index dc3adf0eb..83342030b 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -96,6 +96,8 @@ export type { TargetCapabilityFixtureInput, TargetCapabilityProjection, } from './target-capabilities.ts'; +export { createEventRouteInput } from './event-input.ts'; +export type { AgentEventRouteInput, CreateEventRouteInputOptions } from './event-input.ts'; export { expectEvents } from './events.ts'; export type { AgentRenderEventType, diff --git a/packages/agent-bundle/tests/event-payload.test.ts b/packages/agent-bundle/tests/event-payload.test.ts new file mode 100644 index 000000000..df80a8528 --- /dev/null +++ b/packages/agent-bundle/tests/event-payload.test.ts @@ -0,0 +1,304 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +import { expect, it } from '@rstest/core'; + +import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; +import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; +import cursorCapabilityTable from '../src/adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; +import type { EventRouteCapabilityTableEntry } from '../src/adapters/capability-state.ts'; +import { hookEventFields, type HookHandlerEventName } from '../src/adapters/hook-handler.ts'; +import { createCanonicalEventProps, projectEventPayload } from '../src/events/project.ts'; +import { + agentEventPayloadFieldKinds, + agentEventPayloadFields, + agentEventPayloadNativeKeys, + canonicalAgentEvents, + type AgentEventPayloadField, + type AgentEventPayloadFieldName, + type AgentEventPayloadHost, + type AgentEventPayloadNativeKey, + type CanonicalAgentEvent, +} from '../src/routes/public.ts'; + +/** + * The canonical payload of an event route (#466), proven against the live + * host captures under `fixtures/host-lineage/`: every envelope those runs + * observed on Claude Code, Codex, and Cursor is projected through the same + * table the artifact uses, and each mapped field is checked back against the + * host key it claims to come from. + */ + +interface CapturedEvent { + readonly event: CanonicalAgentEvent; + readonly host: AgentEventPayloadHost; + readonly native: Readonly>; + readonly nativeEvent: string; +} + +const liveFixtures: Readonly> = { + claude: 'claude-2.1.259-orchestration', + codex: 'codex-0.147.0', + cursor: 'cursor-3.18.25', +}; + +const hostTables: Readonly>>> = { + claude: claudeCapabilityTable.hooks.eventRoutes, + codex: codexCapabilityTable.hooks.eventRoutes, + cursor: cursorCapabilityTable.hooks.eventRoutes, +}; + +const hosts = Object.keys(liveFixtures) as AgentEventPayloadHost[]; +const signal = new AbortController().signal; + +const capturedEvents = async (host: AgentEventPayloadHost): Promise => { + const text = await readFile(new URL(`../../../fixtures/host-lineage/${liveFixtures[host]}.ndjson`, import.meta.url), 'utf8'); + const events: CapturedEvent[] = []; + for (const line of text.split('\n')) { + if (line.trim() === '') continue; + const record = JSON.parse(line) as { + readonly event?: { + readonly canonical: { readonly event: CanonicalAgentEvent; readonly provenance: { readonly nativeEvent: string } }; + readonly native: Readonly>; + }; + readonly host: AgentEventPayloadHost; + readonly kind: string; + }; + if (record.kind !== 'event' || record.event === undefined) continue; + events.push({ + event: record.event.canonical.event, + host: record.host, + native: record.event.native, + nativeEvent: record.event.canonical.provenance.nativeEvent, + }); + } + expect(events.length, `${host} captured events`).toBeGreaterThan(0); + return events; +}; + +const payloadEntries = ( + payload: Readonly | undefined>>, +): readonly (readonly [AgentEventPayloadFieldName, AgentEventPayloadField])[] => + Object.entries(payload) + .filter((entry): entry is [AgentEventPayloadFieldName, AgentEventPayloadField] => entry[1] !== undefined); + +it('projects every live-captured envelope through the host mapping, and every field names the key it was read from', async () => { + for (const host of hosts) { + const seen = new Set(); + for (const captured of await capturedEvents(host)) { + const mapping = agentEventPayloadNativeKeys[host][captured.event]; + expect(mapping, `${host} ${captured.event} has a payload mapping`).toBeDefined(); + const props = createCanonicalEventProps(captured.event, captured.native, host, captured.nativeEvent, 'live', signal); + const admitted: readonly string[] = agentEventPayloadFields[captured.event]; + for (const [field, mapped] of payloadEntries(props.canonical.payload)) { + seen.add(`${captured.event}:${field}`); + expect(admitted, `${host} ${captured.event} admits ${field}`).toContain(field); + expect(mapped.nativeKey).toBe(mapping![field]!.nativeKey); + expect(Object.hasOwn(captured.native, mapped.nativeKey), `${host} ${captured.event} ${field} came from the envelope`).toBe(true); + if (mapping![field]!.decode === undefined) { + expect(mapped.value).toEqual(captured.native[mapped.nativeKey]); + } + } + // Whatever the envelope carried under a mapped key of the right shape is on the payload: nothing is dropped. + for (const [field, mapped] of Object.entries(mapping!) as [AgentEventPayloadFieldName, AgentEventPayloadNativeKey][]) { + if (!Object.hasOwn(captured.native, mapped.nativeKey) || mapped.decode !== undefined) continue; + const raw = captured.native[mapped.nativeKey]; + const shaped = agentEventPayloadFieldKinds[field] === 'string' ? typeof raw === 'string' + : agentEventPayloadFieldKinds[field] === 'boolean' ? typeof raw === 'boolean' + : true; + if (shaped) expect(props.canonical.payload[field], `${host} ${captured.event} ${field}`).toBeDefined(); + } + // Frozen, like the rest of the props. + expect(Object.isFrozen(props.canonical.payload)).toBe(true); + } + // The captures exercise the fields the issue is about on every host. + for (const expected of ['tool/before:toolName', 'tool/before:toolInput', 'tool/before:toolUseId', 'tool/after:toolResponse', 'stop:reentry', 'agent/start:agentId', 'agent/start:agentType', 'tool/before:sessionId', 'prompt/submit:prompt']) { + expect(seen.has(expected), `${host} captured ${expected}`).toBe(true); + } + } +}); + +it('lets a tool/before route read toolName and toolInput identically under the Claude, Codex, and Cursor captures', async () => { + for (const host of hosts) { + const captured = (await capturedEvents(host)).find((candidate) => candidate.event === 'tool/before')!; + const { payload } = createCanonicalEventProps('tool/before', captured.native, host, captured.nativeEvent, 'live', signal).canonical; + expect(typeof payload.toolName?.value).toBe('string'); + expect(payload.toolName?.nativeKey).toBe('tool_name'); + expect(typeof payload.toolInput?.value).toBe('object'); + expect(payload.toolInput?.nativeKey).toBe('tool_input'); + expect(typeof payload.toolUseId?.value).toBe('string'); + expect(payload.sessionId?.value).toBe(host === 'cursor' ? captured.native.conversation_id : captured.native.session_id); + expect(payload.sessionId?.nativeKey).toBe(host === 'cursor' ? 'conversation_id' : 'session_id'); + } +}); + +it('reads stop re-entry as one boolean: stop_hook_active on Claude and Codex, loop_count > 0 on Cursor', () => { + const standard = { cwd: '/repo', hook_event_name: 'Stop', last_assistant_message: 'Done.', session_id: 's', transcript_path: '/t.jsonl' }; + for (const host of ['claude', 'codex'] as const) { + expect(projectEventPayload('stop', { ...standard, stop_hook_active: true }, host).reentry) + .toEqual({ nativeKey: 'stop_hook_active', value: true }); + expect(projectEventPayload('stop', { ...standard, stop_hook_active: false }, host).reentry) + .toEqual({ nativeKey: 'stop_hook_active', value: false }); + expect(projectEventPayload('stop', { ...standard, stop_hook_active: false }, host).lastAssistantMessage) + .toEqual({ nativeKey: 'last_assistant_message', value: 'Done.' }); + } + const cursor = { conversation_id: 'c', hook_event_name: 'stop', status: 'completed' }; + expect(projectEventPayload('stop', { ...cursor, loop_count: 1 }, 'cursor').reentry).toEqual({ nativeKey: 'loop_count', value: true }); + expect(projectEventPayload('stop', { ...cursor, loop_count: 0 }, 'cursor').reentry).toEqual({ nativeKey: 'loop_count', value: false }); + // Cursor's stop carries no final assistant text; the field is absent, not fabricated from `status`. + expect('lastAssistantMessage' in projectEventPayload('stop', { ...cursor, loop_count: 0 }, 'cursor')).toBe(false); + // agent/stop shares the same re-entry reading. + expect(projectEventPayload('agent/stop', { ...cursor, hook_event_name: 'subagentStop', loop_count: 2, subagent_type: 'explore' }, 'cursor')) + .toMatchObject({ agentType: { nativeKey: 'subagent_type', value: 'explore' }, reentry: { nativeKey: 'loop_count', value: true } }); +}); + +it('parses Cursor tool_output when it is JSON and keeps it as the string the host sent otherwise', () => { + const base = { conversation_id: 'c', cwd: '/repo', hook_event_name: 'postToolUse', tool_input: { command: 'ls' }, tool_name: 'Shell', tool_use_id: 'call-1' }; + expect(projectEventPayload('tool/after', { ...base, tool_output: '{"success":true,"lines":3}' }, 'cursor').toolResponse) + .toEqual({ nativeKey: 'tool_output', value: { lines: 3, success: true } }); + expect(projectEventPayload('tool/after', { ...base, tool_output: 'plain terminal text' }, 'cursor').toolResponse) + .toEqual({ nativeKey: 'tool_output', value: 'plain terminal text' }); + // Claude and Codex deliver tool_response as JSON already — an object for built-in tools, a string for MCP tools on Claude. + const claude = { cwd: '/repo', hook_event_name: 'PostToolUse', session_id: 's', tool_input: {}, tool_name: 'Write', tool_use_id: 't', transcript_path: '/t' }; + expect(projectEventPayload('tool/after', { ...claude, tool_response: { filePath: '/a', success: true } }, 'claude').toolResponse) + .toEqual({ nativeKey: 'tool_response', value: { filePath: '/a', success: true } }); + expect(projectEventPayload('tool/after', { ...claude, tool_response: 'text' }, 'claude').toolResponse) + .toEqual({ nativeKey: 'tool_response', value: 'text' }); +}); + +it('never fabricates a field: hosts that do not send one leave it absent, null stays null, and wrong shapes are dropped', () => { + const cursorStart = projectEventPayload('session/start', { conversation_id: 'c', hook_event_name: 'sessionStart', model: 'default', transcript_path: null }, 'cursor'); + expect(cursorStart).toEqual({ + model: { nativeKey: 'model', value: 'default' }, + sessionId: { nativeKey: 'conversation_id', value: 'c' }, + transcriptPath: { nativeKey: 'transcript_path', value: null }, + }); + expect(Object.keys(cursorStart)).not.toContain('source'); + expect(Object.keys(cursorStart)).not.toContain('cwd'); + expect(Object.keys(cursorStart)).not.toContain('permissionMode'); + // A Cursor envelope that also spells session_id still maps sessionId from the documented conversation_id. + expect(projectEventPayload('session/start', { conversation_id: 'c', hook_event_name: 'sessionStart', session_id: 'c' }, 'cursor').sessionId) + .toEqual({ nativeKey: 'conversation_id', value: 'c' }); + // A wrong shape is not coerced: the field is absent and `native` still has the raw value. + expect(projectEventPayload('stop', { hook_event_name: 'Stop', session_id: 's', stop_hook_active: 'yes' }, 'claude').reentry).toBeUndefined(); + expect(projectEventPayload('compact/before', { hook_event_name: 'PreCompact', session_id: 's', trigger: 'sometimes' }, 'claude').trigger).toBeUndefined(); + expect(projectEventPayload('stop', { conversation_id: 'c', hook_event_name: 'stop', loop_count: 'one' }, 'cursor').reentry).toBeUndefined(); + // A host without a mapping table gets an empty payload, never a guess. + expect(projectEventPayload('tool/before', { hook_event_name: 'PreToolUse', tool_name: 'Write' }, 'portable')).toEqual({}); + expect(projectEventPayload('tool/before', { hook_event_name: 'PreToolUse', tool_name: 'Write' }, 'plugin')).toEqual({}); + // A family a host does not support has no mapping either. + expect(projectEventPayload('task/create', { hook_event_name: 'TaskCreated', session_id: 's', task_id: 't' }, 'codex')).toEqual({}); +}); + +it('projects the Claude-only model-switch families from the documented PreModelSwitch / PostModelSwitch input (2.1.260 re-pin)', async () => { + const fixture = async (name: string): Promise>> => + JSON.parse(await readFile(new URL(`./fixtures/events/${name}.json`, import.meta.url), 'utf8')) as Readonly>; + const before = projectEventPayload('model-switch/before', await fixture('claude-pre-model-switch'), 'claude'); + expect(before).toEqual({ + cwd: { nativeKey: 'cwd', value: '/workspace' }, + fromModel: { nativeKey: 'from_model', value: 'claude-sonnet-5' }, + requestedModel: { nativeKey: 'requested_model', value: 'opus' }, + sessionId: { nativeKey: 'session_id', value: 'session-claude-1' }, + source: { nativeKey: 'source', value: 'command' }, + toModel: { nativeKey: 'to_model', value: 'claude-opus-5' }, + transcriptPath: { nativeKey: 'transcript_path', value: '/workspace/.claude/projects/session.jsonl' }, + }); + // An automatic switch names no requested model: `null` is kept as the host sent it. + const after = projectEventPayload('model-switch/after', await fixture('claude-post-model-switch'), 'claude'); + expect(after.requestedModel).toEqual({ nativeKey: 'requested_model', value: null }); + expect(after.source).toEqual({ nativeKey: 'source', value: 'auto' }); + // The cache and pricing fields stay host-specific: read them from `native`. + expect(Object.keys(after)).not.toContain('contextTokens'); + expect(Object.keys(after)).not.toContain('pricing'); + // No other host maps the family. + expect(agentEventPayloadNativeKeys.codex['model-switch/before']).toBeUndefined(); + expect(agentEventPayloadNativeKeys.cursor['model-switch/after']).toBeUndefined(); +}); + +it('admits every field the config hook handler contract (#488) guarantees for the same family, under the same name', () => { + // `HookEvent` (adapters/hook-handler.ts) is the wrapper-decoded payload + // of the six plain-hook events; `canonical.payload` is the provenance- + // carrying payload of every event-route family. The two tables are held + // to one vocabulary where they overlap: a field the handler contract + // requires on every host is admitted here for the matching family. + const families: Readonly> = { + afterTool: 'tool/after', + agentStart: 'agent/start', + agentStop: 'agent/stop', + beforeTool: 'tool/before', + sessionStart: 'session/start', + stop: 'stop', + }; + const spelling: Readonly> = { stopHookActive: 'reentry' }; + for (const [handlerEvent, family] of Object.entries(families) as [HookHandlerEventName, CanonicalAgentEvent][]) { + const admitted: readonly string[] = agentEventPayloadFields[family]; + for (const field of hookEventFields[handlerEvent].required) { + expect(admitted, `${family} admits ${handlerEvent}.${field}`).toContain(spelling[field] ?? field); + } + } +}); + +it('keeps the idempotency key a hash of the envelope alone, so the derived payload never re-identifies an event', () => { + const native = { cwd: '/repo', hook_event_name: 'PreToolUse', session_id: 's', tool_input: { a: 1 }, tool_name: 'Write', tool_use_id: 't', transcript_path: '/t' }; + const props = createCanonicalEventProps('tool/before', native, 'claude', 'PreToolUse', '2.1.250', signal); + expect(props.canonical.idempotencyKey).toBe( + createHash('sha256').update(JSON.stringify({ event: 'tool/before', native, target: 'claude' }), 'utf8').digest('hex'), + ); + expect(props.canonical.payload.toolInput?.value).toEqual({ a: 1 }); + // The payload shares the frozen snapshot's values rather than copying the envelope. + expect(props.canonical.payload.toolInput?.value).toBe(props.native.tool_input); +}); + +/** The JSON mirror in the runtime table's shape (`decode` stays the JSON's string; equality checks it). */ +const normalizedMirror = ( + payload: NonNullable, +): Readonly> => Object.fromEntries( + Object.entries(payload).map(([field, mapping]) => [ + field, + typeof mapping === 'string' + ? { nativeKey: mapping } + : { ...(mapping.decode === undefined ? {} : { decode: mapping.decode }), nativeKey: mapping.nativeKey }, + ]), +); + +it('mirrors the runtime mapping table in every pinned capability table, field for field', () => { + for (const host of hosts) { + const table = hostTables[host]; + for (const event of canonicalAgentEvents) { + const row = table[event]; + const mapping = agentEventPayloadNativeKeys[host][event]; + expect(row, `${host} ${event} row`).toBeDefined(); + if (row!.state !== 'supported') { + expect(mapping, `${host} ${event} is unsupported and maps no payload`).toBeUndefined(); + expect(row!.payload, `${host} ${event} carries no payload mirror`).toBeUndefined(); + continue; + } + expect(mapping, `${host} ${event} is supported and maps a payload`).toBeDefined(); + expect(row!.payload, `${host} ${event} mirrors its payload mapping`).toBeDefined(); + expect(normalizedMirror(row!.payload!)).toEqual(mapping); + } + } +}); + +it('admits a family field only when at least two supporting hosts report it, or the family has one host', () => { + for (const event of canonicalAgentEvents) { + const supporting = hosts.filter((host) => agentEventPayloadNativeKeys[host][event] !== undefined); + expect(supporting.length, `${event} is supported somewhere`).toBeGreaterThan(0); + const admitted: readonly AgentEventPayloadFieldName[] = agentEventPayloadFields[event]; + for (const field of admitted) { + const reporters = supporting.filter((host) => agentEventPayloadNativeKeys[host][event]![field] !== undefined); + expect(reporters.length, `${event}.${field} is reported by ${reporters.join(', ') || 'no host'}`) + .toBeGreaterThanOrEqual(Math.min(2, supporting.length)); + } + for (const host of supporting) { + for (const field of Object.keys(agentEventPayloadNativeKeys[host][event]!)) { + expect(admitted, `${host} ${event} maps only admitted fields`).toContain(field); + } + } + } + // Every vocabulary field is used by at least one family. + const used = new Set(Object.values(agentEventPayloadFields).flat()); + for (const field of Object.keys(agentEventPayloadFieldKinds)) { + expect(used.has(field as AgentEventPayloadFieldName), `${field} belongs to a family`).toBe(true); + } +}); diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index d69cca860..c118f627e 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -947,7 +947,13 @@ it('renders one tool/after event route through two native thin clients', { retry 'export default async function AfterTool({ canonical, native }) {', ' const context = await agent();', ' const requestValue = context.providers.requestValue as { kind: string };', - ' const tool = typeof native.tool_name === "string" ? native.tool_name : "unknown";', + ' // The canonical payload names the tool through the host key it came from and, on Cursor, the', + ' // parsed tool_output; `native` keeps the raw string (#466).', + ' const payloadTool = canonical.payload.toolName;', + ' const response = canonical.payload.toolResponse;', + ' const tool = payloadTool === undefined || typeof native.tool_name !== "string" || native.tool_name !== payloadTool.value', + ' ? "unknown"', + ' : `${payloadTool.value}@${payloadTool.nativeKey}/${response?.nativeKey ?? "none"}=${JSON.stringify(response?.value)}/${typeof native[response?.nativeKey ?? ""]}`;', " const actor = context.actor.state === 'unavailable' ? `unavailable:${context.actor.reason}` : `available:${context.actor.value.id}`;", " const host = context.host.state === 'unavailable' ? `unavailable:${context.host.reason}` : `available:${context.host.source}:${context.host.value.name}`;", " const session = context.session.state === 'unavailable' ? `unavailable:${context.session.reason}` : `available:${context.session.source}:${context.session.value.sessionId}`;", @@ -1028,10 +1034,10 @@ it('renders one tool/after event route through two native thin clients', { retry }; const response = await runHook(hook.output, native); expect(response).toEqual(target === 'cursor' - ? { additional_context: `cursor:Write:event:true:host:available:native:cursor:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided` } + ? { additional_context: `cursor:Write@tool_name/tool_output={"ok":true}/string:event:true:host:available:native:cursor:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided` } : { hookSpecificOutput: { - additionalContext: `claude:Write:event:true:host:available:native:claude:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided`, + additionalContext: `claude:Write@tool_name/tool_response={"ok":true}/object:event:true:host:available:native:claude:session:available:native:session-1:workspace:available:native:${root}:actor:unavailable:not-provided`, hookEventName: 'PostToolUse', }, }); diff --git a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts index 5a05386b4..226c1a89e 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-routes.test.ts @@ -64,6 +64,7 @@ class RecordingService implements LifecycleReplayRouteService { event: 'tool/after', idempotencyKey: 'key', observedAt: '2026-09-02T00:00:00.000Z', + payload: Object.freeze({ toolName: Object.freeze({ nativeKey: 'tool_name', value: 'Write' }) }), provenance: Object.freeze({ host: 'claude', hostContractRevision: '2.1.250', diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts index 7a7aa287e..97690be41 100644 --- a/packages/agent-bundle/tests/route-register-typegen.test.ts +++ b/packages/agent-bundle/tests/route-register-typegen.test.ts @@ -124,13 +124,17 @@ it('types every route-aware public surface from the generated route registration 'export default async function Report() { return { lines: 1 }; }', '', ].join('\n')), + // Typed to its family, so `canonical.payload` narrows to the tool/after fields (#466). writeProjectFile(root, 'src/events/tool/after.ts', [ "import type { AgentEventRouteProps } from 'agent-bundle';", - 'export default async function ToolAfter(props: AgentEventRouteProps) { return props.canonical.event; }', + "export default async function ToolAfter(props: AgentEventRouteProps<'tool/after'>) {", + ' const name: string | undefined = props.canonical.payload.toolName?.value;', + ' return name ?? props.canonical.event;', + '}', '', ].join('\n')), writeProjectFile(root, 'assertions.ts', [ - "import type { AgentEventCanonicalIdentity, AgentEventNativePayload } from 'agent-bundle';", + "import type { AgentEventCanonicalIdentity, AgentEventNativePayload, AgentEventPayload } from 'agent-bundle';", "import { expectMcpCall, expectNoMcpCall } from 'agent-bundle/eval';", 'import type {', ' RegisteredMcpRouteId,', @@ -141,6 +145,7 @@ it('types every route-aware public surface from the generated route registration ' RegisteredRouteResult,', "} from '@agent-bundle/runtime';", 'import {', + ' createEventRouteInput,', ' getMcpPrompt,', ' invokeCli,', ' invokeMcpTool,', @@ -165,7 +170,12 @@ it('types every route-aware public surface from the generated route registration '// An event route registers the harness payload — the component props without the `signal` the harness', '// injects — and no result, since event modules export no resultSchema.', "export type EventInput = Assert, 'canonical' | 'native'>>;", - "export type EventCanonical = Assert['canonical'], AgentEventCanonicalIdentity>>;", + "export type EventCanonical = Assert['canonical'], AgentEventCanonicalIdentity<'tool/after'>>>;", + '// The route\'s family narrows `canonical.payload`: tool/after fields are present, stop-only fields are not.', + "export type EventPayload = Assert['canonical']['payload'], AgentEventPayload<'tool/after'>>>;", + "export type EventPayloadTool = Assert['toolResponse']>['nativeKey'], string>>;", + "export type EventPayloadNoReentry = Assert ? true : false, false>>;", + "export type EventPayloadStop = Assert ? true : false, true>>;", "export type EventNative = Assert['native'], AgentEventNativePayload>>;", "export type EventResult = Assert, undefined>>;", '// The MCP server and protocol names a registered id encodes (TanStack\'s `RoutesByPath` shape).', @@ -178,7 +188,7 @@ it('types every route-aware public surface from the generated route registration "export type ShelfFindWireInput = Assert, { isbn: string }>>;", "export type DynamicWireInput = Assert, unknown>>;", '', - 'export const typed = async (canonical: AgentEventCanonicalIdentity, native: AgentEventNativePayload): Promise => {', + "export const typed = async (canonical: AgentEventCanonicalIdentity<'tool/after'>, native: AgentEventNativePayload): Promise => {", " const found = await renderRoute('tool:curator/find', { input: { query: 'dune' } });", ' // `result` is the route\'s own resultSchema output, no cast.', ' const hits: number | undefined = found.result?.hits;', @@ -187,6 +197,12 @@ it('types every route-aware public surface from the generated route registration ' // A valid event-route call carries exactly `{ canonical, native }`; the harness supplies the signal.', " const after = await renderRoute('event:tool/after', { input: { canonical, native } });", ' const none: undefined = after.result;', + ' // `createEventRouteInput` builds that pair from a host envelope, narrowed to the family.', + " const built = createEventRouteInput('tool/after', { hook_event_name: 'PostToolUse', tool_name: 'Write' }, { host: 'claude', validate: false });", + ' const builtName: string | undefined = built.canonical.payload.toolName?.value;', + " await renderRoute('event:tool/after', { input: built });", + " const stopInput = createEventRouteInput('stop', { hook_event_name: 'Stop' }, { host: 'codex', validate: false });", + ' const reentry: boolean | undefined = stopInput.canonical.payload.reentry?.value;', ' // A value typed string stays legal for dynamic lookups and observes unknown.', " const dynamic: string = ['tool:curator/status'].join('');", ' const loose = await renderRoute(dynamic);', @@ -234,7 +250,7 @@ it('types every route-aware public surface from the generated route registration " const script = await loadRouteModule('script:anything');", ' const scriptParsed: unknown = script.resultSchema?.parse({});', ' void hits; void status; void none; void anything; void packed; void executed; void isReport;', - ' void parsedQuery; void parsedHits; void looseParsed; void scriptParsed;', + ' void parsedQuery; void parsedHits; void looseParsed; void scriptParsed; void builtName; void reentry;', '};', '', ].join('\n')), @@ -298,6 +314,17 @@ it('types every route-aware public surface from the generated route registration "export const mistyped = renderRoute('event:tool/after', { input: { canonical: 'tool/after', native: {} } });", '', ].join('\n')), + writeProjectFile(root, 'wrong-event-payload.ts', [ + "import { createEventRouteInput } from 'agent-bundle/test';", + "export const stopOnly = createEventRouteInput('tool/after', { hook_event_name: 'PostToolUse' }, { host: 'claude', validate: false }).canonical.payload.reentry;", + '', + ].join('\n')), + writeProjectFile(root, 'wrong-event-family.ts', [ + "import { renderRoute } from 'agent-bundle/test';", + "import { createEventRouteInput } from 'agent-bundle/test';", + "export const mismatched = renderRoute('event:tool/after', { input: createEventRouteInput('stop', { hook_event_name: 'Stop' }, { host: 'claude', validate: false }) });", + '', + ].join('\n')), writeProjectFile(root, 'wrong-id.ts', [ "import { renderRoute } from 'agent-bundle/test';", "export const missing = renderRoute('tool:curator/missing');", @@ -361,7 +388,15 @@ it('types every route-aware public surface from the generated route registration expect(wrongInput[0]).toContain("Type 'number' is not assignable to type 'string'"); const wrongEventInput = typecheck(root, 'wrong-event-input.ts', true); expect(wrongEventInput).toHaveLength(1); - expect(wrongEventInput[0]).toContain("Type 'string' is not assignable to type 'AgentEventCanonicalIdentity'"); + expect(wrongEventInput[0]).toContain("Type 'string' is not assignable to type 'AgentEventCanonicalIdentity<\"tool/after\">'"); + // The payload is narrowed by family: a stop-only field is rejected on a tool/after route, and a + // stop envelope's input is rejected for a tool/after route. + const wrongEventPayload = typecheck(root, 'wrong-event-payload.ts', true); + expect(wrongEventPayload).toHaveLength(1); + expect(wrongEventPayload[0]).toContain("Property 'reentry' does not exist on type 'AgentEventPayload<\"tool/after\">'"); + const wrongEventFamily = typecheck(root, 'wrong-event-family.ts', true); + expect(wrongEventFamily).toHaveLength(1); + expect(wrongEventFamily[0]).toContain("Type '\"stop\"' is not assignable to type '\"tool/after\"'"); const wrongResult = typecheck(root, 'wrong-result.ts', true); expect(wrongResult).toHaveLength(1); expect(wrongResult[0]).toContain("Type 'number | undefined' is not assignable to type 'string | undefined'"); diff --git a/packages/workbench/src/lifecycles/lifecycle-client.ts b/packages/workbench/src/lifecycles/lifecycle-client.ts index 287baf012..a9d193836 100644 --- a/packages/workbench/src/lifecycles/lifecycle-client.ts +++ b/packages/workbench/src/lifecycles/lifecycle-client.ts @@ -105,10 +105,18 @@ const bindingSchema = z.strictObject({ routeId: textSchema, target: textSchema, }); +// The canonical payload: each mapped field carries its value beside the host +// key it was read from (#466). Field names are the framework's vocabulary and +// are not re-enumerated here so a new family field never invalidates a replay. +const payloadFieldSchema = z.strictObject({ + nativeKey: textSchema, + value: z.json(), +}); const canonicalSchema = z.strictObject({ event: canonicalEventSchema, idempotencyKey: textSchema, observedAt: textSchema, + payload: z.record(z.string(), payloadFieldSchema), provenance: z.strictObject({ host: textSchema, hostContractRevision: textSchema, diff --git a/packages/workbench/src/lifecycles/lifecycles-model.ts b/packages/workbench/src/lifecycles/lifecycles-model.ts index 07a7a69c5..152e95562 100644 --- a/packages/workbench/src/lifecycles/lifecycles-model.ts +++ b/packages/workbench/src/lifecycles/lifecycles-model.ts @@ -52,6 +52,7 @@ export interface LifecyclesView { readonly canonicalRows: readonly LifecycleDetailRow[]; readonly listDiagnostics: readonly LifecycleDiagnostic[]; readonly options: readonly LifecycleOption[]; + readonly payloadRows: readonly LifecycleDetailRow[]; readonly replay: LifecycleReplay | undefined; readonly replayDiagnostics: readonly LifecycleDiagnostic[]; readonly requestRows: readonly LifecycleDetailRow[]; @@ -122,6 +123,25 @@ export const canonicalRowsFor = (replay: LifecycleReplay): readonly LifecycleDet row('Host contract revision', replay.canonical.provenance.hostContractRevision), ]); +const payloadValueText = (value: unknown): string => { + if (typeof value === 'string') return value; + return JSON.stringify(value) ?? String(value); +}; + +/** + * The canonical payload the route received (#466): one row per mapped field, + * showing the value beside the host key it was read from, so the evidence + * panel makes the mapped-versus-missing distinction visible. An empty payload + * is one row saying so rather than an absent section. + */ +export const payloadRowsFor = (replay: LifecycleReplay): readonly LifecycleDetailRow[] => { + const entries = Object.entries(replay.canonical.payload) + .sort(([left], [right]) => left.localeCompare(right)); + if (entries.length === 0) return Object.freeze([row('Payload', 'No canonical field mapped from this envelope')]); + return Object.freeze(entries.map(([field, mapped]) => + row(field, `${payloadValueText(mapped.value)} · ${mapped.nativeKey}`))); +}; + export const requestRowsFor = (replay: LifecycleReplay): readonly LifecycleDetailRow[] => Object.freeze([ row('Invocation kind', replay.requestContext.invocation.kind), optionalRow('Operation ID', replay.requestContext.invocation.operationId), @@ -222,6 +242,7 @@ export const lifecyclesViewFor = (options: LifecyclesViewOptions): LifecyclesVie canonicalRows: replay === undefined ? noRows : canonicalRowsFor(replay), listDiagnostics, options: lifecycleOptions, + payloadRows: replay === undefined ? noRows : payloadRowsFor(replay), replay, replayDiagnostics, requestRows: replay === undefined ? noRows : requestRowsFor(replay), diff --git a/packages/workbench/src/lifecycles/lifecycles-page.tsx b/packages/workbench/src/lifecycles/lifecycles-page.tsx index 520b2cd59..7a6c5010d 100644 --- a/packages/workbench/src/lifecycles/lifecycles-page.tsx +++ b/packages/workbench/src/lifecycles/lifecycles-page.tsx @@ -148,6 +148,7 @@ export const LifecycleReplayView = ({ view }: LifecycleReplayViewProps) => { + { event: 'tool/after', idempotencyKey: `${request.binding.target}-receipt`, observedAt: '2026-09-01T12:00:00.000Z', + payload: { toolName: { nativeKey: 'tool_name', value: 'Write' } }, provenance: { host: request.binding.target, hostContractRevision: target.hostContractRevision, diff --git a/packages/workbench/tests/lifecycle-client.test.ts b/packages/workbench/tests/lifecycle-client.test.ts index b66d221cf..8cd761926 100644 --- a/packages/workbench/tests/lifecycle-client.test.ts +++ b/packages/workbench/tests/lifecycle-client.test.ts @@ -30,6 +30,7 @@ const replay = { event: 'tool/after', idempotencyKey: 'receipt-a', observedAt: '2026-09-01T12:00:00.000Z', + payload: { toolName: { nativeKey: 'tool_name', value: 'Write' } }, provenance: { host: 'claude', hostContractRevision: 'claude-hooks@1', @@ -186,6 +187,9 @@ it('rejects surplus fields at every lifecycle response boundary', async () => { }, { replay: { ...replay, version: 1 } }, { replay: { ...replay, canonical: { ...replay.canonical, version: 1 } } }, + // A payload field is exactly `{ nativeKey, value }`; a stray member is rejected like any other drift. + { replay: { ...replay, canonical: { ...replay.canonical, payload: { toolName: { nativeKey: 'tool_name', value: 'Write', version: 1 } } } } }, + { replay: { ...replay, canonical: { ...replay.canonical, payload: { toolName: { value: 'Write' } } } } }, { replay: { ...replay, requestContext: { ...replay.requestContext, version: 1 } } }, { replay: { diff --git a/packages/workbench/tests/lifecycles-model.test.ts b/packages/workbench/tests/lifecycles-model.test.ts index 23d4bfd8b..a11cae439 100644 --- a/packages/workbench/tests/lifecycles-model.test.ts +++ b/packages/workbench/tests/lifecycles-model.test.ts @@ -56,6 +56,7 @@ const replay: LifecycleReplay = { event: 'tool/after', idempotencyKey: 'receipt-a', observedAt: '2026-09-01T12:00:00.000Z', + payload: { toolName: { nativeKey: 'tool_name', value: 'Write' } }, provenance: { host: 'claude', hostContractRevision: 'claude-hooks@1', @@ -136,6 +137,10 @@ it('derives one correlated replay view with identity, context, and diagnostics', { label: 'Native event', value: 'PostToolUse' }, { label: 'Host contract revision', value: 'claude-hooks@1' }, ]); + // The canonical payload shows each mapped field beside the host key it came from (#466). + expect(view.payloadRows).toEqual([ + { label: 'toolName', value: 'Write · tool_name' }, + ]); expect(view.requestRows).toEqual([ { label: 'Invocation kind', value: 'event' }, { label: 'Operation ID', value: 'event:tool/after' }, diff --git a/packages/workbench/tests/lifecycles-page.test.ts b/packages/workbench/tests/lifecycles-page.test.ts index 69706bced..75b169e2c 100644 --- a/packages/workbench/tests/lifecycles-page.test.ts +++ b/packages/workbench/tests/lifecycles-page.test.ts @@ -46,6 +46,7 @@ const replay: LifecycleReplay = { event: 'tool/after', idempotencyKey: 'receipt-a', observedAt: '2026-09-01T12:00:00.000Z', + payload: { toolName: { nativeKey: 'tool_name', value: 'Write' } }, provenance: { host: 'claude', hostContractRevision: 'claude-hooks@1', diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index e4d5089d0..123cb974a 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -195,21 +195,57 @@ export const config = { tools: ['file.write'], // canonical selector -> per-host native matcher } satisfies AgentEventRouteConfig; -export default async function AfterFileEdit({ canonical, native, signal }: AgentEventRouteProps) { +export default async function AfterFileEdit({ canonical, native, signal }: AgentEventRouteProps<'tool/after'>) { // canonical.provenance = { host, hostContractRevision, nativeEvent, source: 'native' } + // canonical.payload.toolName = { value: 'Write', nativeKey: 'tool_name' } on every host + const tool = canonical.payload.toolName?.value ?? 'a tool'; return ( - {`Recorded an edit reported by ${canonical.provenance.host}.`} + {`Recorded an edit by ${tool}, reported by ${canonical.provenance.host}.`} ); } ``` `canonical` is the cross-host identity the framework derives — `event`, an `idempotencyKey` -hashed from the event, target, and native payload, `observedAt`, a `sequence`, and the -`provenance` naming the host and the native event that fired. `native` is a frozen snapshot of -the validated host envelope. Nothing in `canonical` is fabricated: a host that does not report an -axis leaves it unavailable. +hashed from the event, target, and native payload, `observedAt`, a `sequence`, the +`provenance` naming the host and the native event that fired, and the family's canonical +`payload`. `native` is a frozen snapshot of the validated host envelope. Nothing in +`canonical` is fabricated: a host that does not report a field leaves it absent. + +### The canonical payload + +`canonical.payload` is the cross-host reading of the envelope for the route's family: the +fields at least two hosts report under their own names — `toolName`, `toolInput`, and +`toolResponse` on the tool families, `sessionId` (Claude and Codex `session_id`, Cursor +`conversation_id`), `transcriptPath`, `cwd`, `prompt`, `agentId` and `agentType`, `reentry` (Claude +and Codex `stop_hook_active`, Cursor `loop_count > 0`), and so on. Each field arrives as +`{ value, nativeKey }`: the decoded value beside the host key it was read from, so a route can tell +a mapped field from a missing one and still name the host's spelling in its own output. A field +the host did not send is `undefined`, never defaulted. Two readings go beyond the key: Cursor's +`tool_output` JSON string is parsed into `toolResponse` (and kept as the string when it is not +valid JSON), and Cursor's `loop_count` counter becomes the `reentry` boolean. + +Type the route to its family — `AgentEventRouteProps<'tool/after'>` — and `payload` narrows to +that family's fields, so reading `payload.reentry` on a tool route is a compile error; the +generated `.agent-bundle/routes.d.ts` carries the same narrowing into `renderRoute`. A route +typed with the bare `AgentEventRouteProps` sees every field as optional. Anything the payload does +not model — Cursor's `attachments`, Claude's `background_tasks`, a host-only `duration` — is still +on `native`. The per-family, per-host table (field × host → native key) is the +[Canonical payload fields](../../reference/events.md#canonical-payload-fields) section of the +generated events reference, rendered from the pinned capability tables; the runtime reads the same +table, exported as `agentEventPayloadFields` and `agentEventPayloadNativeKeys`. + +Route-unit tests build the same props from a host envelope with `createEventRouteInput` from +`agent-bundle/test`, so a fixture exercises `payload` rather than hand-written identity: + +```ts +import { createEventRouteInput, renderRoute } from 'agent-bundle/test'; + +const rendered = await renderRoute('event:tool/after', { + input: createEventRouteInput('tool/after', claudePostToolUseEnvelope, { host: 'claude' }), +}); +``` The route answers through its rendered document. `Agent.Context` text becomes the host's additional-context channel, and `Agent.Result`'s `value` may carry diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index c15f5839c..4dc859ab8 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -75,7 +75,11 @@ const chapters: number | undefined = result?.chapters; // no cast An event route registers what the harness actually takes and gives back: `input` is the `{ canonical, native }` payload (the harness supplies `signal` itself), and `result` is -`undefined`, since event modules export no `resultSchema`. +`undefined`, since event modules export no `resultSchema`. Build that input from a host envelope +with `createEventRouteInput('tool/after', envelope, { host: 'claude' })` rather than by hand: it +validates the envelope per host and event and projects `canonical.payload` through the same table +the artifact's wrapper uses, and a route typed `AgentEventRouteProps<'tool/after'>` narrows the +registered `input` to that family, so passing a `stop` envelope's input to it is a type error. Nothing about it is required. A target typed `string` rather than a literal stays legal for dynamic lookups, a directly imported module target is unaffected, and a project that does not diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 4a3c7d698..73c08ed75 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -179,19 +179,49 @@ export const config = { tools: ['file.write'], // 规范选择器 -> 各宿主的原生匹配器 } satisfies AgentEventRouteConfig; -export default async function AfterFileEdit({ canonical, native, signal }: AgentEventRouteProps) { +export default async function AfterFileEdit({ canonical, native, signal }: AgentEventRouteProps<'tool/after'>) { // canonical.provenance = { host, hostContractRevision, nativeEvent, source: 'native' } + // 在每个宿主上,canonical.payload.toolName = { value: 'Write', nativeKey: 'tool_name' } + const tool = canonical.payload.toolName?.value ?? 'a tool'; return ( - {`Recorded an edit reported by ${canonical.provenance.host}.`} + {`Recorded an edit by ${tool}, reported by ${canonical.provenance.host}.`} ); } ``` `canonical` 是框架推导出的跨宿主身份——`event`、由事件、target 与原生载荷哈希而来的 `idempotencyKey`、 -`observedAt`、一个 `sequence`,以及记录触发宿主与原生事件名的 `provenance`。`native` 是经过校验的宿主 -信封的冻结快照。`canonical` 中没有任何伪造:宿主未报告的轴保持不可用。 +`observedAt`、一个 `sequence`、记录触发宿主与原生事件名的 `provenance`,以及该事件族的规范 `payload`。 +`native` 是经过校验的宿主信封的冻结快照。`canonical` 中没有任何伪造:宿主未报告的字段保持缺失。 + +### 规范载荷 + +`canonical.payload` 是该路由事件族对信封的跨宿主读法:至少两个宿主以各自名字报告的那些字段——工具族上的 +`toolName`、`toolInput`、`toolResponse`,`sessionId`(Claude 与 Codex 的 `session_id`、Cursor 的 +`conversation_id`)、`transcriptPath`、`cwd`、`prompt`、`agentId` 与 `agentType`、`reentry`(Claude 与 Codex 的 +`stop_hook_active`、Cursor 的 `loop_count > 0`)等等。每个字段以 `{ value, nativeKey }` 的形式到达:解码后的值 +旁边是它读取自的宿主键,因此路由既能区分「已映射」与「缺失」,也能在自己的输出里说出宿主的拼法。宿主未发送的 +字段是 `undefined`,绝不补默认值。只有两处读法超出「按键取值」:Cursor 的 `tool_output` JSON 字符串会被解析为 +`toolResponse`(不是合法 JSON 时保留为字符串),Cursor 的 `loop_count` 计数变为 `reentry` 布尔值。 + +把路由类型标注到它的事件族——`AgentEventRouteProps<'tool/after'>`——`payload` 就收窄为该族的字段,于是在工具 +路由上读取 `payload.reentry` 是编译错误;生成的 `.agent-bundle/routes.d.ts` 把同样的收窄带进 `renderRoute`。 +用裸的 `AgentEventRouteProps` 标注的路由把所有字段都视为可选。载荷未建模的一切——Cursor 的 `attachments`、 +Claude 的 `background_tasks`、只有一个宿主才有的 `duration`——仍在 `native` 上。按事件族、按宿主的表(字段 × +宿主 → 原生键)是生成的事件参考中的[规范载荷字段](../../reference/events.md#规范载荷字段)一节,由固定的 +能力表渲染而来;运行时读取的是同一张表,以 `agentEventPayloadFields` 与 `agentEventPayloadNativeKeys` 导出。 + +路由单元测试用 `agent-bundle/test` 的 `createEventRouteInput` 从宿主信封构造同样的 props,这样 fixture 练的是 +`payload`,而不是手写的身份: + +```ts +import { createEventRouteInput, renderRoute } from 'agent-bundle/test'; + +const rendered = await renderRoute('event:tool/after', { + input: createEventRouteInput('tool/after', claudePostToolUseEnvelope, { host: 'claude' }), +}); +``` 路由通过它渲染出的文档作答。`Agent.Context` 文本成为宿主的附加上下文通道,`Agent.Result` 的 `value` 可以 携带 `{ outcome: 'continue' | 'allow' | 'ask' | 'deny', reason?, updatedInput? }`。`continue`——或完全不给 diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index 44c6182e1..af6343381 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -65,7 +65,11 @@ const chapters: number | undefined = result?.chapters; // 无需强制类型转 ``` 事件路由注册的是测试工具实际接受与返回的内容:`input` 是 `{ canonical, native }` 载荷(`signal` 由测试 -工具自行提供),而 `result` 为 `undefined`,因为事件模块不导出 `resultSchema`。 +工具自行提供),而 `result` 为 `undefined`,因为事件模块不导出 `resultSchema`。请用 +`createEventRouteInput('tool/after', envelope, { host: 'claude' })` 从宿主信封构造这个输入,而不要手写:它按 +宿主与事件校验信封,并通过与制品包装器相同的表投影出 `canonical.payload`;标注为 +`AgentEventRouteProps<'tool/after'>` 的路由会把注册的 `input` 收窄到该事件族,因此把 `stop` 信封的输入传给它是 +类型错误。 这一切都不是必需的。目标若类型为 `string` 而非字面量,仍然合法,可用于动态查找;直接导入模块作为目标 不受影响;而没有把生成文件纳入程序的项目——或尚未运行过一次构建或 `agent-bundle dev` 的项目——看到的 diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index be1051865..6fa5e8aaa 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -24,6 +24,8 @@ interface CapabilityRow { readonly nativeEvent?: string; readonly evidence?: readonly string[]; readonly availability?: Readonly>; + /** Canonical payload field → host key (or `{ nativeKey, decode }`), on supported event-route rows. */ + readonly payload?: JsonObject; } interface HostCapabilityTable { @@ -164,6 +166,7 @@ const messages = { streamableHttp: 'Streamable HTTP', tokenFields: 'Fields accepting path tokens', canonicalEvent: 'Canonical event', + payloadField: 'Payload field', configKey: 'Config key', selector: 'Selector', nativeEvent: 'Native event', @@ -183,6 +186,10 @@ const messages = { eventRoutesIntro: 'Rows are the canonical event families a `src/events//*.tsx` route may declare; columns are the pinned hosts. A cell names the native event the route lowers to.', unavailableRoutes: 'Why a route is unavailable', + payloadFields: 'Canonical payload fields', + payloadFieldsIntro: + 'The `canonical.payload` an event route receives (`AgentEventRouteProps`): one table per family, one row per canonical field, one column per host that supports the family. A cell names the host key the field is read from — the field arrives as `{ value, nativeKey }` with exactly that key as its provenance — and `—` means the host never sends it, so the field is `undefined` there rather than fabricated. A key marked `→ json-string` is a JSON-encoded string the framework parses (kept as the string when it is not valid JSON); `→ positive-count` is a counter read as a boolean. Fields no host shares stay on `native`.', + payloadDecode: (decode: string) => `→ ${decode}`, configHookEvents: 'Config-declared hook events', configHookEventsIntro: 'The `hooks` block of `agent-bundle.config.ts` is keyed by these canonical names; each maps to the native event a target registers.', @@ -260,6 +267,7 @@ const messages = { streamableHttp: 'Streamable HTTP', tokenFields: '接受路径令牌的字段', canonicalEvent: '规范事件', + payloadField: '载荷字段', configKey: '配置键', selector: '选择器', nativeEvent: '宿主原生事件', @@ -279,6 +287,10 @@ const messages = { eventRoutesIntro: '行是 `src/events//*.tsx` 路由可以声明的规范事件族;列是固定宿主。单元格给出该路由降级到的原生事件。', unavailableRoutes: '路由不可用的原因', + payloadFields: '规范载荷字段', + payloadFieldsIntro: + '事件路由收到的 `canonical.payload`(`AgentEventRouteProps`):每个事件族一张表,每个规范字段一行,每个支持该事件族的宿主一列。单元格给出该字段读取自的宿主键——字段以 `{ value, nativeKey }` 的形式到达,`nativeKey` 恰为该键——`—` 表示宿主从不发送该字段,因此它在那里是 `undefined`,而非被伪造。标有 `→ json-string` 的键是框架会解析的 JSON 编码字符串(不是合法 JSON 时保留为字符串);`→ positive-count` 表示把计数读作布尔值。没有任何两个宿主共有的字段留在 `native` 上。', + payloadDecode: (decode: string) => `→ ${decode}`, configHookEvents: '配置声明的钩子事件', configHookEventsIntro: '`agent-bundle.config.ts` 的 `hooks` 块以这些规范名称为键;每个键映射到目标注册的原生事件。', @@ -375,6 +387,18 @@ const stateCell = (row: CapabilityRow | undefined, m: Messages): string => { return row.state ?? m.unavailable; }; +/** One payload-mapping cell: the host key, plus the transformation the framework applies when one is named. */ +const payloadCell = (mapping: JsonValue | undefined, m: Messages): string => { + if (typeof mapping === 'string') { + return code(mapping); + } + if (isObject(mapping) && typeof mapping.nativeKey === 'string') { + const decode = asString(mapping.decode); + return decode === undefined ? code(mapping.nativeKey) : `${code(mapping.nativeKey)} ${m.payloadDecode(decode)}`; + } + return m.notApplicable; +}; + const unionKeys = (hosts: readonly HostCapabilityTable[], select: (data: JsonObject) => JsonObject): string[] => [...new Set(hosts.flatMap(host => Object.keys(select(host.data))))].sort(); @@ -625,6 +649,26 @@ function renderEvents(hosts: readonly HostCapabilityTable[], m: Messages): strin sections.push(bullets.join('\n')); } + sections.push(`## ${m.payloadFields}\n`); + sections.push(m.payloadFieldsIntro); + for (const eventKey of eventKeys) { + const supporting = hosts.filter(host => capabilityRow(eventRoutesOf(host.data)[eventKey])?.payload !== undefined); + if (supporting.length === 0) { + continue; + } + const fieldKeys = unionKeys(supporting, data => asObject(capabilityRow(eventRoutesOf(data)[eventKey])?.payload)); + sections.push(`### ${code(eventKey)}\n`); + sections.push( + table( + [m.headers.payloadField, ...supporting.map(hostHeader)], + fieldKeys.map(field => [ + code(field), + ...supporting.map(host => payloadCell(asObject(capabilityRow(eventRoutesOf(host.data)[eventKey])?.payload)[field], m)), + ]), + ), + ); + } + sections.push(`## ${m.configHookEvents}\n`); sections.push(m.configHookEventsIntro); const hookHosts = hosts.filter(host => Object.keys(asObject(asObject(host.data.hooks).events)).length > 0); From b0ff2a6f217f32c81e3757f738da51f077fb5b92 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:30:15 +0000 Subject: [PATCH 2/3] fix(events): freeze the exported payload tables through and mark the required canonical.payload a minor bump (review) --- .changeset/466-canonical-event-payload.md | 4 ++-- packages/agent-bundle/src/routes/events.ts | 9 ++++++--- packages/agent-bundle/tests/event-payload.test.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.changeset/466-canonical-event-payload.md b/.changeset/466-canonical-event-payload.md index 93d35d4a7..a832bc222 100644 --- a/.changeset/466-canonical-event-payload.md +++ b/.changeset/466-canonical-event-payload.md @@ -1,5 +1,5 @@ --- -"agent-bundle": patch +"agent-bundle": minor --- -Give every event route a canonical, per-family `canonical.payload` beside the raw `native` envelope (`AgentEventRouteProps`): the fields at least two hosts report — `toolName`, `toolInput`, `toolUseId`, `toolResponse`, `sessionId` (Claude and Codex `session_id`, Cursor `conversation_id`), `transcriptPath`, `cwd`, `model`, `permissionMode`, `agentId`/`agentType`, `agentTranscriptPath`, `prompt`, `reason`, `source`, `trigger`, `error`/`isInterrupt`, `lastAssistantMessage`, and `reentry` (Claude and Codex `stop_hook_active`, Cursor `loop_count > 0`) — each delivered as `{ value, nativeKey }` naming the host key it was read from, and absent when the host did not send it, never fabricated. Cursor's `tool_output` JSON string is parsed into `toolResponse` (kept as the string when it is not valid JSON). Type a route to its family (`AgentEventRouteProps<'tool/after'>`) and `payload` narrows to that family's fields, in the route and in the generated `.agent-bundle/routes.d.ts` that `renderRoute` reads; `AgentEventCanonicalIdentity` gains the same parameter. The per-family table ships as `agentEventPayloadFields`, the per-host key table as `agentEventPayloadNativeKeys` (with `AgentEventPayload`, `AgentEventPayloadField`, `AgentEventPayloadFieldName`, `AgentEventPayloadNativeKey`, and `agentEventPayloadFieldKinds`), and each pinned capability table mirrors its host's mapping under `hooks.eventRoutes..payload`, so the generated events reference documents field × host → native key per family. `agent-bundle/test` gains `createEventRouteInput(event, native, { host })`, which validates a host envelope and builds the `{ canonical, native }` input the harness takes, payload included; the Workbench Lifecycles view lists the mapped payload beside the canonical identity. Additive: `native` is unchanged, `idempotencyKey` still hashes only the envelope, and the bare `AgentEventRouteProps` keeps working with every field optional; only code that constructs `AgentEventCanonicalIdentity` by hand must add `payload`. (#466) +Give every event route a canonical, per-family `canonical.payload` beside the raw `native` envelope (`AgentEventRouteProps`): the fields at least two hosts report — `toolName`, `toolInput`, `toolUseId`, `toolResponse`, `sessionId` (Claude and Codex `session_id`, Cursor `conversation_id`), `transcriptPath`, `cwd`, `model`, `permissionMode`, `agentId`/`agentType`, `agentTranscriptPath`, `prompt`, `reason`, `source`, `trigger`, `error`/`isInterrupt`, `lastAssistantMessage`, and `reentry` (Claude and Codex `stop_hook_active`, Cursor `loop_count > 0`) — each delivered as `{ value, nativeKey }` naming the host key it was read from, and absent when the host did not send it, never fabricated. Cursor's `tool_output` JSON string is parsed into `toolResponse` (kept as the string when it is not valid JSON). Type a route to its family (`AgentEventRouteProps<'tool/after'>`) and `payload` narrows to that family's fields, in the route and in the generated `.agent-bundle/routes.d.ts` that `renderRoute` reads; `AgentEventCanonicalIdentity` gains the same parameter. The per-family table ships as `agentEventPayloadFields`, the per-host key table as `agentEventPayloadNativeKeys` (with `AgentEventPayload`, `AgentEventPayloadField`, `AgentEventPayloadFieldName`, `AgentEventPayloadNativeKey`, and `agentEventPayloadFieldKinds`), and each pinned capability table mirrors its host's mapping under `hooks.eventRoutes..payload`, so the generated events reference documents field × host → native key per family. `agent-bundle/test` gains `createEventRouteInput(event, native, { host })`, which validates a host envelope and builds the `{ canonical, native }` input the harness takes, payload included; the Workbench Lifecycles view lists the mapped payload beside the canonical identity. `native` is unchanged, `idempotencyKey` still hashes only the envelope, and a route typed with the bare `AgentEventRouteProps` keeps working with every field optional. Breaking for one shape of consumer code: `payload` is a required property of `AgentEventCanonicalIdentity`, so a test or harness that constructs the identity by hand no longer compiles until it adds one — build the input with `createEventRouteInput` instead. (#466) diff --git a/packages/agent-bundle/src/routes/events.ts b/packages/agent-bundle/src/routes/events.ts index 678b1c553..a028840c0 100644 --- a/packages/agent-bundle/src/routes/events.ts +++ b/packages/agent-bundle/src/routes/events.ts @@ -1,3 +1,4 @@ +import { deepFreeze } from '../core/freeze.ts'; import type { JsonValue } from '../core/strict-json.ts'; /** The event-route families admitted by the recorded #97 v1/G10 decision. */ @@ -103,7 +104,7 @@ export type AgentEventPayloadFieldKind = | 'string-array' | 'trigger'; -export const agentEventPayloadFieldKinds = Object.freeze({ +export const agentEventPayloadFieldKinds = deepFreeze({ agentId: 'string', agentTranscriptPath: 'nullable-string', agentType: 'string', @@ -153,8 +154,10 @@ const modelSwitchFields = [...sessionFields, 'fromModel', 'toModel', 'requestedM * The canonical payload fields of every event-route family, in the order the * payload object carries them. This is the one per-family table; the types * ({@link AgentEventPayload}) and the runtime projection derive from it. + * Frozen through (the family arrays share instances), so a consumer holding + * the export cannot change what later invocations project. */ -export const agentEventPayloadFields = Object.freeze({ +export const agentEventPayloadFields = deepFreeze({ 'agent/idle': [...sessionFields, 'teammateName', 'teamName'], 'agent/start': threeHostFields, 'agent/stop': [...threeHostFields, 'agentTranscriptPath', 'reentry', 'lastAssistantMessage'], @@ -301,7 +304,7 @@ const cursorTool = [...cursorSession, 'cwd', 'toolName', 'toolInput', 'toolUseId */ export const agentEventPayloadNativeKeys: Readonly< Record>>> -> = Object.freeze({ +> = deepFreeze({ claude: Object.freeze({ 'agent/idle': pick(standardKeys, [...claudeSession, 'teammateName', 'teamName']), 'agent/start': pick(standardKeys, claudeSession), diff --git a/packages/agent-bundle/tests/event-payload.test.ts b/packages/agent-bundle/tests/event-payload.test.ts index df80a8528..f2c768795 100644 --- a/packages/agent-bundle/tests/event-payload.test.ts +++ b/packages/agent-bundle/tests/event-payload.test.ts @@ -302,3 +302,17 @@ it('admits a family field only when at least two supporting hosts report it, or expect(used.has(field as AgentEventPayloadFieldName), `${field} belongs to a family`).toBe(true); } }); + +it('exports the tables frozen through, so a consumer cannot change what later invocations project', () => { + for (const fields of Object.values(agentEventPayloadFields)) { + expect(Object.isFrozen(fields)).toBe(true); + } + for (const host of hosts) { + for (const mapping of Object.values(agentEventPayloadNativeKeys[host])) { + expect(Object.isFrozen(mapping)).toBe(true); + for (const entry of Object.values(mapping)) expect(Object.isFrozen(entry)).toBe(true); + } + } + expect(() => (agentEventPayloadFields['tool/before'] as unknown as string[]).push('reentry')).toThrow(TypeError); + expect(Object.isFrozen(agentEventPayloadFieldKinds)).toBe(true); +}); From bb7db6b291b85dce4d02ec9cd57c1c67194fe852 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 17:32:24 +0000 Subject: [PATCH 3/3] feat(events): admit the 2.1.260 model-switch families to the canonical payload and pin the HookEvent field overlap (rebase over #542, #533) --- .changeset/466-canonical-event-payload.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/466-canonical-event-payload.md b/.changeset/466-canonical-event-payload.md index a832bc222..5060e4c65 100644 --- a/.changeset/466-canonical-event-payload.md +++ b/.changeset/466-canonical-event-payload.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Give every event route a canonical, per-family `canonical.payload` beside the raw `native` envelope (`AgentEventRouteProps`): the fields at least two hosts report — `toolName`, `toolInput`, `toolUseId`, `toolResponse`, `sessionId` (Claude and Codex `session_id`, Cursor `conversation_id`), `transcriptPath`, `cwd`, `model`, `permissionMode`, `agentId`/`agentType`, `agentTranscriptPath`, `prompt`, `reason`, `source`, `trigger`, `error`/`isInterrupt`, `lastAssistantMessage`, and `reentry` (Claude and Codex `stop_hook_active`, Cursor `loop_count > 0`) — each delivered as `{ value, nativeKey }` naming the host key it was read from, and absent when the host did not send it, never fabricated. Cursor's `tool_output` JSON string is parsed into `toolResponse` (kept as the string when it is not valid JSON). Type a route to its family (`AgentEventRouteProps<'tool/after'>`) and `payload` narrows to that family's fields, in the route and in the generated `.agent-bundle/routes.d.ts` that `renderRoute` reads; `AgentEventCanonicalIdentity` gains the same parameter. The per-family table ships as `agentEventPayloadFields`, the per-host key table as `agentEventPayloadNativeKeys` (with `AgentEventPayload`, `AgentEventPayloadField`, `AgentEventPayloadFieldName`, `AgentEventPayloadNativeKey`, and `agentEventPayloadFieldKinds`), and each pinned capability table mirrors its host's mapping under `hooks.eventRoutes..payload`, so the generated events reference documents field × host → native key per family. `agent-bundle/test` gains `createEventRouteInput(event, native, { host })`, which validates a host envelope and builds the `{ canonical, native }` input the harness takes, payload included; the Workbench Lifecycles view lists the mapped payload beside the canonical identity. `native` is unchanged, `idempotencyKey` still hashes only the envelope, and a route typed with the bare `AgentEventRouteProps` keeps working with every field optional. Breaking for one shape of consumer code: `payload` is a required property of `AgentEventCanonicalIdentity`, so a test or harness that constructs the identity by hand no longer compiles until it adds one — build the input with `createEventRouteInput` instead. (#466) +Give every event route a canonical, per-family `canonical.payload` beside the raw `native` envelope (`AgentEventRouteProps`): the fields at least two hosts report — `toolName`, `toolInput`, `toolUseId`, `toolResponse`, `sessionId` (Claude and Codex `session_id`, Cursor `conversation_id`), `transcriptPath`, `cwd`, `model`, `permissionMode`, `agentId`/`agentType`, `agentTranscriptPath`, `prompt`, `reason`, `source`, `trigger`, `error`/`isInterrupt`, `lastAssistantMessage`, and `reentry` (Claude and Codex `stop_hook_active`, Cursor `loop_count > 0`) — each delivered as `{ value, nativeKey }` naming the host key it was read from, and absent when the host did not send it, never fabricated. Cursor's `tool_output` JSON string is parsed into `toolResponse` (kept as the string when it is not valid JSON); the Claude-only `model-switch/before` and `model-switch/after` families admitted by the 2.1.260 re-pin carry `fromModel`, `toModel`, `requestedModel`, and `source`. Where a family is also a config hook handler event, `canonical.payload` uses the same field names as `HookEvent` (`reentry` is the one renaming of `stopHookActive`). Type a route to its family (`AgentEventRouteProps<'tool/after'>`) and `payload` narrows to that family's fields, in the route and in the generated `.agent-bundle/routes.d.ts` that `renderRoute` reads; `AgentEventCanonicalIdentity` gains the same parameter. The per-family table ships as `agentEventPayloadFields`, the per-host key table as `agentEventPayloadNativeKeys` (with `AgentEventPayload`, `AgentEventPayloadField`, `AgentEventPayloadFieldName`, `AgentEventPayloadNativeKey`, and `agentEventPayloadFieldKinds`), and each pinned capability table mirrors its host's mapping under `hooks.eventRoutes..payload`, so the generated events reference documents field × host → native key per family. `agent-bundle/test` gains `createEventRouteInput(event, native, { host })`, which validates a host envelope and builds the `{ canonical, native }` input the harness takes, payload included; the Workbench Lifecycles view lists the mapped payload beside the canonical identity. `native` is unchanged, `idempotencyKey` still hashes only the envelope, and a route typed with the bare `AgentEventRouteProps` keeps working with every field optional. Breaking for one shape of consumer code: `payload` is a required property of `AgentEventCanonicalIdentity`, so a test or harness that constructs the identity by hand no longer compiles until it adds one — build the input with `createEventRouteInput` instead. (#466)