From 140bd3c9734229f31b9d9711f1f74e4f4e0acaf7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 05:09:59 +0000 Subject: [PATCH 1/2] fix(packed): restore optional runtime boundaries Keep event context probes within native hook output and prevent public API declarations from requiring the optional runtime peer. --- .changeset/packed-runtime-boundaries.md | 6 ++++ .../route-harness/src/events/tool/after.tsx | 11 +++---- packages/agent-bundle/src/api.ts | 26 ++++++++++++----- packages/agent-bundle/src/routes/public.ts | 26 +++++++++++++++-- .../agent-bundle/tests/inspect-state.test.ts | 12 ++------ .../tests/packed-stdio-projection.test.ts | 6 +++- .../tests/public-api-packed.test.ts | 29 +++++++++++++------ .../tests/route-unit/render-route.test.ts | 3 +- 8 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 .changeset/packed-runtime-boundaries.md diff --git a/.changeset/packed-runtime-boundaries.md b/.changeset/packed-runtime-boundaries.md new file mode 100644 index 000000000..c1ffdd853 --- /dev/null +++ b/.changeset/packed-runtime-boundaries.md @@ -0,0 +1,6 @@ +--- +'agent-bundle': patch +--- + +Keep the public API importable without the optional runtime peer, and keep the +packed event journey's request-context probe within native hook output fields. diff --git a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx index ea0ef5c2b..12467cae5 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx @@ -1,4 +1,4 @@ -import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { Agent, agent } from '@agent-bundle/runtime'; import type { AgentEventRouteProps } from 'agent-bundle'; export default async function AfterTool({ canonical }: AgentEventRouteProps) { @@ -8,12 +8,13 @@ export default async function AfterTool({ canonical }: AgentEventRouteProps) { id: notice.id, message: notice.content.root.kind === 'text' ? notice.content.root.text : '', })); - const actor: JsonValue = context.actor.state === 'available' - ? { source: context.actor.source, state: context.actor.state, value: { id: context.actor.value.id } } - : { reason: context.actor.reason, state: context.actor.state }; + const actorContext = context.actor.state === 'available' + ? `actor available:${context.actor.source}:${context.actor.value.id}` + : `actor unavailable:${context.actor.reason}`; return ( - + {`Observed ${canonical.event} from ${canonical.provenance.host}.`} + {actorContext} {notices.map((notice) => ( {`notice ${notice.id}: ${notice.message}`} ))} diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 2867b12a1..1bf0419e8 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1,11 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -import { - AGENT_STATE_DEFAULT_BUDGETS, - type AgentStateBudgets, -} from '@agent-bundle/runtime/state'; - import { capabilityIsSupported } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; @@ -264,6 +259,13 @@ export interface InspectOptions extends ProjectOptions { export type StateInspectionDriver = 'memory' | 'sqlite'; +export interface StateInspectionBudgets { + readonly maxCommitMs: number; + readonly maxEventBytes: number; + readonly maxRevisions: number; + readonly maxStateBytes: number; +} + export type StateInspection = | { readonly declared: false; @@ -271,7 +273,7 @@ export type StateInspection = | { readonly budgets: | { - readonly resolved: AgentStateBudgets; + readonly resolved: StateInspectionBudgets; readonly source: 'declared' | 'defaults'; } | { @@ -528,6 +530,16 @@ const durableStateLocation = const noticeLedgerInspection = 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; +// Keep static inspection independent of the optional runtime peer. The +// cross-package inspection test compares these policy defaults with the +// runtime export so the two package boundaries cannot drift silently. +const agentStateDefaultBudgets: StateInspectionBudgets = Object.freeze({ + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, +}); + const stateDriver = ( lifetime: NonNullable['lifetime'], ): StateInspectionDriver => { @@ -552,7 +564,7 @@ const inspectState = (model: NormalizedPlugin): StateInspection => { ? Object.freeze({ source: 'dynamic' }) : Object.freeze({ resolved: Object.freeze({ - ...AGENT_STATE_DEFAULT_BUDGETS, + ...agentStateDefaultBudgets, ...(definition.budgets?.declared ?? {}), }), source: definition.budgets === undefined ? 'defaults' : 'declared', diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 6b00f7645..e9c35079a 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -1,4 +1,4 @@ -import type { AgentRenderInvocation } from '@agent-bundle/runtime'; +import type { JsonValue } from '../core/strict-json.ts'; /** The structural schema surface route props infer without coupling to one schema library. */ export interface RouteSchema { @@ -54,9 +54,31 @@ export interface AgentEventRouteProps { readonly signal: AbortSignal; } +type AgentProviderInvocation = + | { + readonly kind: 'tool'; + readonly props: { readonly input: JsonValue; readonly operationId: string }; + } + | { + readonly kind: 'event'; + readonly props: { readonly event: string; readonly payload: JsonValue }; + } + | { + readonly kind: 'cli'; + readonly props: { readonly args: readonly string[]; readonly command: string }; + } + | { + readonly kind: 'script'; + readonly props: { readonly input?: JsonValue; readonly name: string }; + } + | { + readonly kind: 'workbench'; + readonly props: { readonly input?: JsonValue; readonly view: string }; + }; + /** Request-scoped inputs supplied to a conventional context provider factory. */ export interface AgentProviderContext { - readonly invocation: AgentRenderInvocation; + readonly invocation: AgentProviderInvocation; readonly signal: AbortSignal; } diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 61a6c7d08..83024e1ed 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { AGENT_STATE_DEFAULT_BUDGETS } from '@agent-bundle/runtime/state'; import { expect, it } from '@rstest/core'; import { runCli } from '../src/cli.ts'; @@ -57,12 +58,7 @@ it('inspects volatile and workspace-durable state without inventing runtime path selected: { state: { budgets: { - resolved: { - maxCommitMs: 5000, - maxEventBytes: 262144, - maxRevisions: 100000, - maxStateBytes: 1048576, - }, + resolved: AGENT_STATE_DEFAULT_BUDGETS, source: 'defaults', }, declared: true, @@ -93,9 +89,7 @@ it('inspects volatile and workspace-durable state without inventing runtime path state: { budgets: { resolved: { - maxCommitMs: 5000, - maxEventBytes: 262144, - maxRevisions: 100000, + ...AGENT_STATE_DEFAULT_BUDGETS, maxStateBytes: 2048, }, source: 'declared', diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 570feb239..a8f6f6b01 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -266,8 +266,12 @@ it('serves compiled routes and durable state across packed process restarts', as timeoutMs: 10_000, }); } catch (error) { - throw new Error(`Packed event route failed.\nserver stderr:\n${secondSession.stderr()}`, { cause: error }); + throw new Error( + `Packed event route failed: ${error instanceof Error ? error.message : String(error)}\nserver stderr:\n${secondSession.stderr()}`, + { cause: error }, + ); } + expect(JSON.stringify(eventResponse)).toContain('actor unavailable:not-provided'); expect(JSON.stringify(eventResponse)).toContain(noticeId); expect(JSON.stringify(eventResponse)).toContain('cross-process notice'); expect(secondSession.stderr()).not.toContain('"jsonrpc"'); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index f024eeffa..771c46372 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -131,15 +131,26 @@ it('imports the externalized config entry from a packed npm consumer', async () 'void [claudeHook, codexHook, portableConfig];', '', ].join('\n')); - await expect(execFile(join(workspaceRoot, 'node_modules', '.bin', 'tsc'), [ - '--module', 'nodenext', - '--moduleResolution', 'nodenext', - '--noEmit', - '--strict', - '--target', 'es2022', - '--types', 'node', - 'config.mts', - ], { cwd: consumerRoot, env: isolatedCommandEnvironment() })).resolves.toMatchObject({ stderr: '', stdout: '' }); + try { + const typecheck = await execFile(join(workspaceRoot, 'node_modules', '.bin', 'tsc'), [ + '--module', 'nodenext', + '--moduleResolution', 'nodenext', + '--noEmit', + '--strict', + '--target', 'es2022', + '--types', 'node', + 'config.mts', + ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }); + expect(typecheck).toMatchObject({ stderr: '', stdout: '' }); + } catch (error) { + const stdout = error !== null && typeof error === 'object' && 'stdout' in error + ? String(error.stdout) + : ''; + const stderr = error !== null && typeof error === 'object' && 'stderr' in error + ? String(error.stderr) + : ''; + throw new Error(`Packed config typecheck failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`, { cause: error }); + } } finally { await rm(consumerRoot, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index 49a491c53..534f171d2 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -257,7 +257,8 @@ describe('renderRoute through the real renderer', () => { expectDocument(rendered) .toHaveStatus('success') .toContainMarkdown('Observed tool/after from claude.') - .toHaveValue({ actor: notProvided }); + .toContainContext('actor unavailable:not-provided') + .toHaveValue(undefined); }); it('renders a route module handed in directly, without the compiled manifest', async () => { From aefbb8432af00888136d418d3db50ffe099e3157 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 05:14:52 +0000 Subject: [PATCH 2/2] fix(packed): keep lifecycle projection peer-free Separate React rendering from event envelope projection so importing the public API does not load optional React peers. --- .changeset/packed-runtime-boundaries.md | 5 +- .../playground/lifecycle-replay-service.ts | 2 +- packages/agent-bundle/src/events/project.ts | 288 +----------------- .../agent-bundle/src/events/projection.ts | 282 +++++++++++++++++ 4 files changed, 294 insertions(+), 283 deletions(-) create mode 100644 packages/agent-bundle/src/events/projection.ts diff --git a/.changeset/packed-runtime-boundaries.md b/.changeset/packed-runtime-boundaries.md index c1ffdd853..0f35d6c7d 100644 --- a/.changeset/packed-runtime-boundaries.md +++ b/.changeset/packed-runtime-boundaries.md @@ -2,5 +2,6 @@ 'agent-bundle': patch --- -Keep the public API importable without the optional runtime peer, and keep the -packed event journey's request-context probe within native hook output fields. +Keep the public API importable without optional runtime or React peers, and +keep the packed event journey's request-context probe within native hook +output fields. diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 12099ac42..bee2f91d9 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -24,7 +24,7 @@ import { createCanonicalEventProps, projectEventDocument, validateNativeEventEnvelope, -} from '../../events/project.ts'; +} from '../../events/projection.ts'; import { canonicalAgentEvents, type CanonicalAgentEvent, diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index 5d5c0ce5b..4883817c1 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -1,132 +1,15 @@ -import { createHash } from 'node:crypto'; - import { Children, cloneElement, isValidElement, type ReactNode } from 'react'; -import { z } from 'zod'; - -import { decodeAgentDocument, type AgentDocument, type AgentDocumentNode } from '@agent-bundle/runtime'; -import type { - AgentEventCanonicalIdentity, - AgentEventRouteProps, - CanonicalAgentEvent, -} from '../routes/public.ts'; -import { deepFreeze } from '../core/freeze.ts'; - - -const resultValueSchema = z.object({ - outcome: z.enum(['continue', 'deny']).optional(), - reason: z.string().min(1).optional(), - updatedInput: z.record(z.string(), z.unknown()).optional(), -}).strict(); -let eventSequence = 0; - -const snapshotNative = (native: Readonly>): Readonly> => - Object.freeze(structuredClone(native)); - -export interface NativeEventEnvelopeValidation { - readonly canonicalEvent: CanonicalAgentEvent; - readonly nativeEvent: string; - readonly target: string; -} - -const nativeEventError = (message: string): never => { - throw new Error(`Agent Bundle event route error: ${message}`); -}; +import { decodeAgentDocument, type AgentDocument } from '@agent-bundle/runtime'; -const requireNativeString = (input: Readonly>, field: string): void => { - const value = input[field]; - if (typeof value !== 'string' || value.trim() === '') { - nativeEventError(`native ${field} must be a nonempty string`); - } -}; +import type { AgentEventRouteProps } from '../routes/public.ts'; -/** - * Validates the host envelope shared by generated event wrappers and semantic - * lifecycle replay. The process-edge stdin byte limit remains wrapper-owned. - */ -export const validateNativeEventEnvelope = ( - input: unknown, - validation: NativeEventEnvelopeValidation, -): Readonly> => { - if (typeof input !== 'object' || input === null || Array.isArray(input)) { - return nativeEventError('stdin JSON value must be an object'); - } - const native = input as Readonly>; - const { canonicalEvent, nativeEvent, target } = validation; - if (native.hook_event_name !== nativeEvent) { - return nativeEventError(`native hook_event_name must equal ${nativeEvent}`); - } - if (target === 'cursor') { - if (typeof native.session_id !== 'string' && typeof native.conversation_id !== 'string') { - return nativeEventError('native session_id or conversation_id must be a string'); - } - if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { - requireNativeString(native, 'tool_name'); - if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { - return nativeEventError('native tool_input must be an object'); - } - requireNativeString(native, 'tool_use_id'); - if (canonicalEvent === 'tool/after') requireNativeString(native, 'tool_output'); - } - if (canonicalEvent === 'stop' && typeof native.loop_count !== 'number') { - return nativeEventError('native loop_count must be a number'); - } - return native; - } - requireNativeString(native, 'session_id'); - if (target === 'codex') { - if (native.transcript_path !== null && typeof native.transcript_path !== 'string') { - return nativeEventError('native transcript_path must be a string or null'); - } - } else { - requireNativeString(native, 'transcript_path'); - } - requireNativeString(native, 'cwd'); - if (canonicalEvent === 'session/start') requireNativeString(native, 'source'); - if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { - requireNativeString(native, 'tool_name'); - if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { - return nativeEventError('native tool_input must be an object'); - } - requireNativeString(native, 'tool_use_id'); - if ( - canonicalEvent === 'tool/after' - && (typeof native.tool_response !== 'object' || native.tool_response === null || Array.isArray(native.tool_response)) - ) { - return nativeEventError('native tool_response must be an object'); - } - } - if (canonicalEvent === 'agent/start' || canonicalEvent === 'agent/stop') { - requireNativeString(native, 'agent_id'); - requireNativeString(native, 'agent_type'); - if (target === 'codex') { - requireNativeString(native, 'turn_id'); - requireNativeString(native, 'model'); - requireNativeString(native, 'permission_mode'); - if (!['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'].includes(String(native.permission_mode))) { - return nativeEventError('native permission_mode is invalid'); - } - } - if (canonicalEvent === 'agent/stop') { - if (typeof native.stop_hook_active !== 'boolean') { - return nativeEventError('native stop_hook_active must be a boolean'); - } - if (native.agent_transcript_path !== null && typeof native.agent_transcript_path !== 'string') { - return nativeEventError('native agent_transcript_path must be a string or null'); - } - if (native.last_assistant_message !== null && typeof native.last_assistant_message !== 'string') { - return nativeEventError('native last_assistant_message must be a string or null'); - } - } - } - if (canonicalEvent === 'stop') { - if (typeof native.stop_hook_active !== 'boolean') { - return nativeEventError('native stop_hook_active must be a boolean'); - } - requireNativeString(native, 'last_assistant_message'); - } - return native; -}; +export { + createCanonicalEventProps, + projectEventDocument, + validateNativeEventEnvelope, + type NativeEventEnvelopeValidation, +} from './projection.ts'; const resolveServerNode = async (node: ReactNode): Promise => { if (Array.isArray(node)) return Promise.all(node.map(resolveServerNode)); @@ -150,158 +33,3 @@ export const renderStandaloneEventRoute = async ( component: (props: AgentEventRouteProps) => ReactNode | Promise, props: AgentEventRouteProps, ): Promise => decodeAgentDocument(await resolveServerNode(await component(props))); - -export const createCanonicalEventProps = ( - event: CanonicalAgentEvent, - nativeInput: Readonly>, - target: string, - nativeEvent: string, - hostContractRevision: string, - signal: AbortSignal, -): AgentEventRouteProps => { - const native = snapshotNative(nativeInput); - const canonical: AgentEventCanonicalIdentity = Object.freeze({ - event, - idempotencyKey: createHash('sha256') - .update(JSON.stringify({ event, native, target }), 'utf8') - .digest('hex'), - observedAt: new Date().toISOString(), - provenance: Object.freeze({ - host: target, - hostContractRevision, - nativeEvent, - source: 'native', - }), - sequence: ++eventSequence, - }); - return Object.freeze({ canonical, native, signal }); -}; - -const appendContext = (node: AgentDocumentNode, contexts: string[]): void => { - switch (node.kind) { - case 'result': - for (const child of node.children) appendContext(child, contexts); - break; - case 'context': - contexts.push(node.text); - break; - case 'audio': - case 'error': - case 'image': - case 'json': - case 'markdown': - case 'progress': - case 'resource': - case 'text': - break; - default: { - const exhaustive: never = node; - return exhaustive; - } - } -}; - -export const projectEventDocument = ( - document: AgentDocument, - event: CanonicalAgentEvent, - target: string, - nativeEvent: string, -): Readonly> | undefined => { - if (target === 'plugin') { - throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); - } - const contexts: string[] = []; - appendContext(document.root, contexts); - const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); - const parsedValue = document.value === undefined ? undefined : resultValueSchema.parse(document.value); - const requireDenyReason = (): string => { - if (parsedValue?.outcome !== 'deny') { - throw new TypeError(`${event} did not request a blocking outcome.`); - } - if (parsedValue.reason === undefined) { - throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`); - } - return parsedValue.reason; - }; - - if (event === 'stop') { - if (parsedValue?.outcome !== 'deny') return undefined; - return target === 'cursor' - ? Object.freeze({ followup_message: requireDenyReason() }) - : Object.freeze({ decision: 'block', reason: requireDenyReason() }); - } - if (event === 'agent/start') { - if (parsedValue?.outcome === 'deny') { - throw new TypeError('agent/start cannot block subagent creation on any supported host.'); - } - if (parsedValue?.updatedInput !== undefined) { - throw new TypeError('agent/start cannot replace native input.'); - } - if (additionalContext === undefined) return undefined; - return target === 'cursor' - ? Object.freeze({ additional_context: additionalContext }) - : deepFreeze({ - hookSpecificOutput: { - additionalContext, - hookEventName: nativeEvent, - }, - }); - } - if (event === 'agent/stop') { - if (parsedValue?.updatedInput !== undefined) { - throw new TypeError('agent/stop cannot replace native input.'); - } - if (parsedValue?.outcome === 'deny') { - if (target === 'cursor') { - throw new TypeError('agent/stop cannot block subagent completion on cursor.'); - } - return Object.freeze({ decision: 'block', reason: requireDenyReason() }); - } - if (additionalContext === undefined) return undefined; - if (target === 'codex') { - throw new TypeError('agent/stop additional context is not supported by the Codex SubagentStop output schema.'); - } - return target === 'cursor' - ? Object.freeze({ additional_context: additionalContext }) - : deepFreeze({ - hookSpecificOutput: { - additionalContext, - hookEventName: nativeEvent, - }, - }); - } - if (event === 'tool/before') { - if (target === 'cursor') { - if (parsedValue?.outcome === 'deny') { - return Object.freeze({ - agent_message: parsedValue.reason, - permission: 'deny', - user_message: parsedValue.reason, - }); - } - return parsedValue?.updatedInput === undefined - ? undefined - : Object.freeze({ permission: 'allow', updated_input: parsedValue.updatedInput }); - } - const output = { - ...(additionalContext === undefined ? {} : { additionalContext }), - hookEventName: nativeEvent, - permissionDecision: parsedValue?.outcome === 'deny' ? 'deny' : 'allow', - ...(parsedValue?.reason === undefined ? {} : { permissionDecisionReason: parsedValue.reason }), - ...(parsedValue?.updatedInput === undefined ? {} : { updatedInput: parsedValue.updatedInput }), - }; - return deepFreeze({ hookSpecificOutput: output }); - } - if (event === 'session/start' || event === 'tool/after') { - if (additionalContext === undefined) return undefined; - return target === 'cursor' - ? Object.freeze({ additional_context: additionalContext }) - : deepFreeze({ - hookSpecificOutput: { - additionalContext, - hookEventName: nativeEvent, - }, - }); - } - return undefined; -}; diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts new file mode 100644 index 000000000..d215b503c --- /dev/null +++ b/packages/agent-bundle/src/events/projection.ts @@ -0,0 +1,282 @@ +import { createHash } from 'node:crypto'; + +import type { AgentDocument, AgentDocumentNode } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +import { deepFreeze } from '../core/freeze.ts'; +import type { + AgentEventCanonicalIdentity, + AgentEventRouteProps, + CanonicalAgentEvent, +} from '../routes/public.ts'; + +const resultValueSchema = z.object({ + outcome: z.enum(['continue', 'deny']).optional(), + reason: z.string().min(1).optional(), + updatedInput: z.record(z.string(), z.unknown()).optional(), +}).strict(); + +let eventSequence = 0; + +const snapshotNative = (native: Readonly>): Readonly> => + Object.freeze(structuredClone(native)); + +export interface NativeEventEnvelopeValidation { + readonly canonicalEvent: CanonicalAgentEvent; + readonly nativeEvent: string; + readonly target: string; +} + +const nativeEventError = (message: string): never => { + throw new Error(`Agent Bundle event route error: ${message}`); +}; + +const requireNativeString = (input: Readonly>, field: string): void => { + const value = input[field]; + if (typeof value !== 'string' || value.trim() === '') { + nativeEventError(`native ${field} must be a nonempty string`); + } +}; + +/** + * Validates the host envelope shared by generated event wrappers and semantic + * lifecycle replay. The process-edge stdin byte limit remains wrapper-owned. + */ +export const validateNativeEventEnvelope = ( + input: unknown, + validation: NativeEventEnvelopeValidation, +): Readonly> => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return nativeEventError('stdin JSON value must be an object'); + } + const native = input as Readonly>; + const { canonicalEvent, nativeEvent, target } = validation; + if (native.hook_event_name !== nativeEvent) { + return nativeEventError(`native hook_event_name must equal ${nativeEvent}`); + } + if (target === 'cursor') { + if (typeof native.session_id !== 'string' && typeof native.conversation_id !== 'string') { + return nativeEventError('native session_id or conversation_id must be a string'); + } + if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + if (canonicalEvent === 'tool/after') requireNativeString(native, 'tool_output'); + } + if (canonicalEvent === 'stop' && typeof native.loop_count !== 'number') { + return nativeEventError('native loop_count must be a number'); + } + return native; + } + requireNativeString(native, 'session_id'); + if (target === 'codex') { + if (native.transcript_path !== null && typeof native.transcript_path !== 'string') { + return nativeEventError('native transcript_path must be a string or null'); + } + } else { + requireNativeString(native, 'transcript_path'); + } + requireNativeString(native, 'cwd'); + if (canonicalEvent === 'session/start') requireNativeString(native, 'source'); + if (canonicalEvent === 'tool/before' || canonicalEvent === 'tool/after') { + requireNativeString(native, 'tool_name'); + if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) { + return nativeEventError('native tool_input must be an object'); + } + requireNativeString(native, 'tool_use_id'); + if ( + canonicalEvent === 'tool/after' + && (typeof native.tool_response !== 'object' || native.tool_response === null || Array.isArray(native.tool_response)) + ) { + return nativeEventError('native tool_response must be an object'); + } + } + if (canonicalEvent === 'agent/start' || canonicalEvent === 'agent/stop') { + requireNativeString(native, 'agent_id'); + requireNativeString(native, 'agent_type'); + if (target === 'codex') { + requireNativeString(native, 'turn_id'); + requireNativeString(native, 'model'); + requireNativeString(native, 'permission_mode'); + if (!['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'].includes(String(native.permission_mode))) { + return nativeEventError('native permission_mode is invalid'); + } + } + if (canonicalEvent === 'agent/stop') { + if (typeof native.stop_hook_active !== 'boolean') { + return nativeEventError('native stop_hook_active must be a boolean'); + } + if (native.agent_transcript_path !== null && typeof native.agent_transcript_path !== 'string') { + return nativeEventError('native agent_transcript_path must be a string or null'); + } + if (native.last_assistant_message !== null && typeof native.last_assistant_message !== 'string') { + return nativeEventError('native last_assistant_message must be a string or null'); + } + } + } + if (canonicalEvent === 'stop') { + if (typeof native.stop_hook_active !== 'boolean') { + return nativeEventError('native stop_hook_active must be a boolean'); + } + requireNativeString(native, 'last_assistant_message'); + } + return native; +}; + +export const createCanonicalEventProps = ( + event: CanonicalAgentEvent, + nativeInput: Readonly>, + target: string, + nativeEvent: string, + hostContractRevision: string, + signal: AbortSignal, +): AgentEventRouteProps => { + const native = snapshotNative(nativeInput); + const canonical: AgentEventCanonicalIdentity = Object.freeze({ + event, + idempotencyKey: createHash('sha256') + .update(JSON.stringify({ event, native, target }), 'utf8') + .digest('hex'), + observedAt: new Date().toISOString(), + provenance: Object.freeze({ + host: target, + hostContractRevision, + nativeEvent, + source: 'native', + }), + sequence: ++eventSequence, + }); + return Object.freeze({ canonical, native, signal }); +}; + +const appendContext = (node: AgentDocumentNode, contexts: string[]): void => { + switch (node.kind) { + case 'result': + for (const child of node.children) appendContext(child, contexts); + break; + case 'context': + contexts.push(node.text); + break; + case 'audio': + case 'error': + case 'image': + case 'json': + case 'markdown': + case 'progress': + case 'resource': + case 'text': + break; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +export const projectEventDocument = ( + document: AgentDocument, + event: CanonicalAgentEvent, + target: string, + nativeEvent: string, +): Readonly> | undefined => { + if (target === 'plugin') { + throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); + } + const contexts: string[] = []; + appendContext(document.root, contexts); + const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); + const parsedValue = document.value === undefined ? undefined : resultValueSchema.parse(document.value); + const requireDenyReason = (): string => { + if (parsedValue?.outcome !== 'deny') { + throw new TypeError(`${event} did not request a blocking outcome.`); + } + if (parsedValue.reason === undefined) { + throw new TypeError(`${event} requires a nonempty reason when outcome is deny.`); + } + return parsedValue.reason; + }; + + if (event === 'stop') { + if (parsedValue?.outcome !== 'deny') return undefined; + return target === 'cursor' + ? Object.freeze({ followup_message: requireDenyReason() }) + : Object.freeze({ decision: 'block', reason: requireDenyReason() }); + } + if (event === 'agent/start') { + if (parsedValue?.outcome === 'deny') { + throw new TypeError('agent/start cannot block subagent creation on any supported host.'); + } + if (parsedValue?.updatedInput !== undefined) { + throw new TypeError('agent/start cannot replace native input.'); + } + if (additionalContext === undefined) return undefined; + return target === 'cursor' + ? Object.freeze({ additional_context: additionalContext }) + : deepFreeze({ + hookSpecificOutput: { + additionalContext, + hookEventName: nativeEvent, + }, + }); + } + if (event === 'agent/stop') { + if (parsedValue?.updatedInput !== undefined) { + throw new TypeError('agent/stop cannot replace native input.'); + } + if (parsedValue?.outcome === 'deny') { + if (target === 'cursor') { + throw new TypeError('agent/stop cannot block subagent completion on cursor.'); + } + return Object.freeze({ decision: 'block', reason: requireDenyReason() }); + } + if (additionalContext === undefined) return undefined; + if (target === 'codex') { + throw new TypeError('agent/stop additional context is not supported by the Codex SubagentStop output schema.'); + } + return target === 'cursor' + ? Object.freeze({ additional_context: additionalContext }) + : deepFreeze({ + hookSpecificOutput: { + additionalContext, + hookEventName: nativeEvent, + }, + }); + } + if (event === 'tool/before') { + if (target === 'cursor') { + if (parsedValue?.outcome === 'deny') { + return Object.freeze({ + agent_message: parsedValue.reason, + permission: 'deny', + user_message: parsedValue.reason, + }); + } + return parsedValue?.updatedInput === undefined + ? undefined + : Object.freeze({ permission: 'allow', updated_input: parsedValue.updatedInput }); + } + const output = { + ...(additionalContext === undefined ? {} : { additionalContext }), + hookEventName: nativeEvent, + permissionDecision: parsedValue?.outcome === 'deny' ? 'deny' : 'allow', + ...(parsedValue?.reason === undefined ? {} : { permissionDecisionReason: parsedValue.reason }), + ...(parsedValue?.updatedInput === undefined ? {} : { updatedInput: parsedValue.updatedInput }), + }; + return deepFreeze({ hookSpecificOutput: output }); + } + if (event === 'session/start' || event === 'tool/after') { + if (additionalContext === undefined) return undefined; + return target === 'cursor' + ? Object.freeze({ additional_context: additionalContext }) + : deepFreeze({ + hookSpecificOutput: { + additionalContext, + hookEventName: nativeEvent, + }, + }); + } + return undefined; +};