diff --git a/.changeset/661-preflight-data.md b/.changeset/661-preflight-data.md new file mode 100644 index 000000000..1c359185c --- /dev/null +++ b/.changeset/661-preflight-data.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Pass typed JSON data from an event preflight gate to its rendered route through `AgentEventRouteProps.preflight` (#664) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index ef73058aa..959d5d0d5 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -790,7 +790,7 @@ const eventRouteHookWrapperSource = ( '};', ] : []), - 'const runStandalone = async (native, signal, observation) => {', + 'const runStandalone = async (native, signal, observation, preflight) => {', ' const resolved = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal, observation);', ...(retiresLineage ? [' await retireLineage(native, resolved.canonical.idempotencyKey, resolved.canonical.observedAt);'] : []), ' const sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;', @@ -808,7 +808,7 @@ const eventRouteHookWrapperSource = ( // A hook's stdout is its host envelope: no terminal, never probed (#511). ' terminal: available({ hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } }, "derived"),', ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', - ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native } } }, signal));', + ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: resolved.canonical, native: resolved.native, ...(preflight === undefined ? {} : { preflight }) } } }, signal));', ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', '};', ] @@ -826,29 +826,30 @@ const eventRouteHookWrapperSource = ( ...(deferredExecution ? [ ' if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) fail("deferred input must be an object");', - ' const { native: nativeInput, observedAt, sequence } = parsed;', + ' const { native: nativeInput, observedAt, preflight, sequence } = parsed;', ' if (typeof observedAt !== "string" || !Number.isInteger(sequence) || sequence < 1) fail("deferred input has an invalid canonical observation");', ' const observation = { observedAt, sequence };', ] : [ ' const nativeInput = parsed;', ' const observation = undefined;', + ' const preflight = undefined;', ]), ' const native = validateNativeEventEnvelope(nativeInput, { canonicalEvent, nativeEvent, target });', ' const controller = new AbortController();', ' let output;', ' if (runtimeMode === "standalone") {', ...(standalone - ? [' output = await runStandalone(native, controller.signal, observation);'] + ? [' output = await runStandalone(native, controller.signal, observation, preflight);'] : [' fail("standalone runtime was not compiled");']), ' } else {', ' try {', - ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });', + ' output = await requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, observedAt: observation?.observedAt, preflight, sequence: observation?.sequence, signal: controller.signal, target, timeoutMs });', ' } catch (error) {', ...(standalone ? [ ' if (!(fallbackMode === "standalone" && error instanceof EventRuntimeTransportError && error.code === "runtime-unavailable")) throw error;', - ' output = await runStandalone(native, controller.signal, observation);', + ' output = await runStandalone(native, controller.signal, observation, preflight);', ] : [' throw error;']), ' }', @@ -910,7 +911,7 @@ const eventRoutePreflightWrapperSource = ( ' signal,', ' terminal: { hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } },', ' }, trace);', - ' const projected = gate === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', + ' const projected = gate === "execute" || gate.outcome === "execute" ? undefined : projectEventPreflightResult(gate, canonicalEvent, target, nativeEvent, native);', ' return Object.freeze({ gate, native, projected, props, runtime: runtimeMode, trace });', '};', 'const runExecutor = (input, signal) => new Promise((resolve, reject) => {', @@ -943,12 +944,12 @@ const eventRoutePreflightWrapperSource = ( ' const controller = new AbortController();', ' const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);', ' const { gate, native, projected, props, trace } = await prepareRouteInvocation(parsed, signal);', - ' if (gate !== "execute") {', + ' if (gate !== "execute" && gate.outcome !== "execute") {', ' if (projected !== undefined) process.stdout.write(JSON.stringify(projected));', ' return;', ' }', ' trace.executeStart(runtimeMode);', - ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, sequence: props.canonical.sequence }));', + ' const executionInput = Buffer.from(JSON.stringify({ native, observedAt: props.canonical.observedAt, ...(gate === "execute" ? {} : { preflight: gate.data }), sequence: props.canonical.sequence }));', ' const terminationSignals = ["SIGHUP", "SIGINT", "SIGTERM"];', ' const terminate = () => controller.abort();', ' for (const terminationSignal of terminationSignals) process.once(terminationSignal, terminate);', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 9021ba25b..7c6d57a5b 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -1186,7 +1186,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ' }, async () => {', ' let validationError;', " const props = message.invocation.kind === 'event'", - ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), signal: controller.signal })', + ' ? Object.freeze({ canonical: Object.freeze(message.invocation.props.payload.canonical), native: Object.freeze(message.invocation.props.payload.native), ...(message.invocation.props.payload.preflight === undefined ? {} : { preflight: Object.freeze(message.invocation.props.payload.preflight) }), signal: controller.signal })', // The MCP server hands the worker input the SDK already validated; only // the Workbench, which bypasses the SDK, asks the worker to validate. ' : message.validateInput !== true ? { input: message.invocation.props.input, signal: controller.signal } : (() => {', diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts index cce484cb0..c82a1b7d9 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-production.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-production.ts @@ -15,6 +15,7 @@ import { } from '@agent-bundle/runtime'; import { renderedDocumentExitCode } from '../../cli-entry.ts'; +import type { EventPreflightResult } from '../../events/preflight.ts'; import type { EventTraceEvent, EventTraceObserver, EventTracer } from '../../events/trace.ts'; import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; import { pluginRootEnvAnchor, pluginStateRootEnvAnchor } from '../../core/types.ts'; @@ -38,7 +39,7 @@ interface CompiledCliInvocationModule { } interface CompiledEventPreflight { - readonly gate: 'execute' | Readonly<{ readonly outcome: 'continue' | 'deny'; readonly reason?: string }>; + readonly gate: EventPreflightResult; readonly native: JsonObject; readonly projected?: JsonObject; readonly props: Readonly<{ readonly canonical: JsonObject }>; @@ -169,7 +170,13 @@ const prepareInput = async ( const native = (request.input as { readonly native?: JsonObject }).native ?? {}; const preflight = await wrapper.prepareRouteInvocation(native, signal, (event) => traceEvents.push(event)); return { - input: { canonical: preflight.props.canonical, native: preflight.native }, + input: { + canonical: preflight.props.canonical, + native: preflight.native, + ...(preflight.gate !== 'execute' && preflight.gate.outcome === 'execute' + ? { preflight: preflight.gate.data } + : {}), + }, preflight, }; }; @@ -415,6 +422,9 @@ const routeProps = (request: ProductionRequest, input: JsonValue): Readonly>; readonly observedAt?: string; + readonly preflight?: JsonValue; readonly sequence?: number; readonly target: string; } @@ -342,6 +345,7 @@ const handleConnection = Effect.fnUntraced(function*( hostContractRevision: parsed.data.hostContractRevision, native: parsed.data.native, observedAt: parsed.data.observedAt, + ...(parsed.data.preflight === undefined ? {} : { preflight: snapshotStrictJsonValue(parsed.data.preflight) }), sequence: parsed.data.sequence, target: parsed.data.target, }, signal)).pipe(Effect.exit); @@ -1143,6 +1147,7 @@ const requestProgram = ( hostContractRevision: options.hostContractRevision, native: options.native, observedAt: options.observedAt, + preflight: options.preflight, protocolVersion: EVENT_RUNTIME_PROTOCOL_VERSION, sequence: options.sequence, target: options.target, diff --git a/packages/agent-bundle/src/events/preflight.ts b/packages/agent-bundle/src/events/preflight.ts index f067e2751..e238214e7 100644 --- a/packages/agent-bundle/src/events/preflight.ts +++ b/packages/agent-bundle/src/events/preflight.ts @@ -1,5 +1,5 @@ import { settleBeforeAbort } from '../core/abort.ts'; -import { isRecord } from '../core/strict-json.ts'; +import { isRecord, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts'; import type { CanonicalAgentEvent } from '../routes/events.ts'; import type { AgentEventCanonicalIdentity } from '../routes/public.ts'; import type { AgentTerminal } from '../terminal-capability.ts'; @@ -7,12 +7,13 @@ import type { EventTracer } from './trace.ts'; /** * The gate result a conventional event route's re-exported preflight may return (#595). - * `execute` is the only value that loads the rendered route; `continue` is a - * pass-through with no host decision; `deny` blocks through the existing - * canonical event outcome projection and always carries a nonempty reason. + * The bare `execute` value or an `execute` object carrying JSON data loads the + * rendered route; `continue` passes through with no host decision; `deny` + * blocks through the existing canonical event outcome projection. */ -export type EventPreflightResult = +export type EventPreflightResult = | 'execute' + | { readonly outcome: 'execute'; readonly data: Data } | { readonly outcome: 'continue' } | { readonly outcome: 'deny'; readonly reason: string }; @@ -29,14 +30,17 @@ export interface EventPreflightContext = ( +export type EventPreflight< + E extends CanonicalAgentEvent = CanonicalAgentEvent, + Data extends JsonValue = JsonValue, +> = ( context: EventPreflightContext, -) => EventPreflightResult | Promise; +) => EventPreflightResult | Promise>; -type PreflightObjectOutcome = 'continue' | 'deny'; +type PreflightObjectOutcome = 'continue' | 'deny' | 'execute'; const isPreflightObjectOutcome = (value: unknown): value is PreflightObjectOutcome => - value === 'continue' || value === 'deny'; + value === 'continue' || value === 'deny' || value === 'execute'; const unsupportedResult = (detail: string): never => { throw new TypeError(`Event preflight result ${detail}`); @@ -100,13 +104,16 @@ export const validateEventPreflightResult = ( ): EventPreflightResult => { if (value === 'execute') return 'execute'; if (!isRecord(value)) { - return unsupportedResult('must be "execute" or a continue/deny object.'); + return unsupportedResult('must be "execute" or an execute/continue/deny object.'); } const outcome = value.outcome; if (!isPreflightObjectOutcome(outcome)) { return unsupportedResult(`outcome ${JSON.stringify(outcome)} is not supported.`); } switch (outcome) { + case 'execute': + unexpectedFields(value, new Set(['outcome', 'data'])); + return Object.freeze({ data: snapshotStrictJsonValue(value.data), outcome: 'execute' }); case 'continue': unexpectedFields(value, new Set(['outcome'])); return Object.freeze({ outcome: 'continue' }); @@ -131,11 +138,14 @@ export const validateEventPreflightResult = ( * Runs the gate inside the common event kernel and validates its result before * any caller projects host output or loads the rendered route runtime. */ -export const executeEventPreflight = async ( - preflight: EventPreflight, +export const executeEventPreflight = async < + E extends CanonicalAgentEvent, + Data extends JsonValue = JsonValue, +>( + preflight: EventPreflight, context: EventPreflightContext, trace?: EventTracer, -): Promise => { +): Promise> => { trace?.preflightStart(); try { context.signal.throwIfAborted(); @@ -147,7 +157,7 @@ export const executeEventPreflight = async ( }); const value = await settleBeforeAbort(Promise.resolve().then(() => preflight(frozenContext)), context.signal); context.signal.throwIfAborted(); - const result = validateEventPreflightResult(value, context.canonical.event); + const result = validateEventPreflightResult(value, context.canonical.event) as EventPreflightResult; trace?.preflightOutcome(result); return result; } catch (error) { diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index b72db4a5a..4efb258f1 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -920,7 +920,7 @@ export const projectEventDocument = ( * decision rules as a rendered Agent.Result, without loading the renderer. */ export const projectEventPreflightResult = ( - result: Exclude, + result: Extract, event: CanonicalAgentEvent, target: string, nativeEvent: string, diff --git a/packages/agent-bundle/src/events/trace.ts b/packages/agent-bundle/src/events/trace.ts index 919e026ad..f3429b250 100644 --- a/packages/agent-bundle/src/events/trace.ts +++ b/packages/agent-bundle/src/events/trace.ts @@ -236,7 +236,7 @@ export const summarizeEventTraceError = (error: unknown): EventTraceErrorSummary }; const preflightOutcomeOf = (result: EventPreflightResult): EventTracePreflightOutcome => { - if (result === 'execute') return 'execute'; + if (result === 'execute' || result.outcome === 'execute') return 'execute'; switch (result.outcome) { case 'continue': return 'continue'; diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 478724f16..e5943e508 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -1007,7 +1007,14 @@ const startEventRuntime = async ( kind: 'event', // The event payload crosses the render boundary as data; the route // props type is what gives it shape on the other side. - props: { event, payload: { canonical: props.canonical, native: props.native } as never }, + props: { + event, + payload: { + canonical: props.canonical, + native: props.native, + ...(request.preflight === undefined ? {} : { preflight: request.preflight }), + } as never, + }, }, signal, }), diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index dbaccc388..2b0edc756 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -67,7 +67,8 @@ 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. + * host-specific fields the payload does not model; `preflight` is strict JSON + * returned by the route's gate with an execute outcome. * * Read transport-owned request context with `await agent()` from * `@agent-bundle/runtime`. The invocation, host, session, actor, workspace, @@ -78,9 +79,13 @@ 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 { +export interface AgentEventRouteProps< + E extends CanonicalAgentEvent = CanonicalAgentEvent, + Preflight extends JsonValue = JsonValue, +> { readonly canonical: AgentEventCanonicalIdentity; readonly native: AgentEventNativePayload; + readonly preflight?: Preflight; readonly signal: AbortSignal; } diff --git a/packages/agent-bundle/src/test/event-input.ts b/packages/agent-bundle/src/test/event-input.ts index 021153b44..22b8bdc0c 100644 --- a/packages/agent-bundle/src/test/event-input.ts +++ b/packages/agent-bundle/src/test/event-input.ts @@ -1,4 +1,5 @@ import { createCanonicalEventProps, validateNativeEventEnvelope } from '../events/project.ts'; +import type { JsonValue } from '../core/strict-json.ts'; import type { AgentEventCanonicalIdentity, AgentEventNativePayload, CanonicalAgentEvent } from '../routes/public.ts'; import { AgentTestError, captured } from './errors.ts'; @@ -20,10 +21,11 @@ export interface CreateEventRouteInputOptions { readonly validate?: boolean; } -/** The `{ canonical, native }` half of `AgentEventRouteProps`; the harness supplies `signal`. */ +/** The `{ canonical, native, preflight? }` half of `AgentEventRouteProps`; the harness supplies `signal`. */ export interface AgentEventRouteInput { readonly canonical: AgentEventCanonicalIdentity; readonly native: AgentEventNativePayload; + readonly preflight?: JsonValue; } /** diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index c53569de1..487e3938a 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -396,9 +396,14 @@ const componentProps = ( // The public event-route contract is `{ canonical, native, signal }`, // and the generated Flight worker unwraps the payload into exactly that. const payload = (invocation.props as { - readonly payload?: { readonly canonical?: unknown; readonly native?: unknown }; + readonly payload?: { readonly canonical?: unknown; readonly native?: unknown; readonly preflight?: unknown }; }).payload ?? {}; - return { canonical: payload.canonical, native: payload.native, signal }; + return { + canonical: payload.canonical, + native: payload.native, + ...(payload.preflight === undefined ? {} : { preflight: payload.preflight }), + signal, + }; } case 'cli': return { input: options.input ?? {}, signal }; diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 5854a8a9a..9bc24effa 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -695,11 +695,12 @@ it('generates the warm react-server Flight worker separately from the MCP dispat ); expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); + expect(source).toContain('preflight: Object.freeze(message.invocation.props.payload.preflight)'); expect(source).toContain('route.module.inputSchema.parse(message.invocation.props.input)'); expect(source).toContain('message.validateInput !== true ? { input: message.invocation.props.input'); expect(source).toContain("createElement(Agent.Error, { code: 'invalid-input' }"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '9780b027d8d5fef12aa0843ba9eb5ab6bd0336ef137ec1bfa552a2ff19daa217', + '68b593d21fdf4aaa5c51d99cffb1a106773e50ef1837072bfb0774699719f98c', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts index 0215a4093..5f10e57aa 100644 --- a/packages/agent-bundle/tests/event-ipc.test.ts +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -179,6 +179,8 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so echoed: request.native, event: request.event, observedAt: request.observedAt, + preflight: request.preflight, + preflightFrozen: Object.isFrozen(request.preflight), sequence: request.sequence, }), })), @@ -196,6 +198,7 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so hostContractRevision: '2.1.250', native: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, observedAt: '2026-09-05T09:00:00.000Z', + preflight: { tickets: ['cc-7'] }, sequence: 42, signal: new AbortController().signal, target: 'claude', @@ -205,6 +208,8 @@ it.live('round-trips a bounded event envelope through the epoch-bound runtime so echoed: { hook_event_name: 'PostToolUse', tool_name: 'Write' }, event: 'tool/after', observedAt: '2026-09-05T09:00:00.000Z', + preflight: { tickets: ['cc-7'] }, + preflightFrozen: true, sequence: 42, }); })); diff --git a/packages/agent-bundle/tests/event-preflight.test.ts b/packages/agent-bundle/tests/event-preflight.test.ts index aadf6def9..df77b76ce 100644 --- a/packages/agent-bundle/tests/event-preflight.test.ts +++ b/packages/agent-bundle/tests/event-preflight.test.ts @@ -68,6 +68,10 @@ it('classifies deny legality for every canonical event family', () => { it('validates execute and continue results without a host decision', () => { expect(validateEventPreflightResult('execute', 'tool/before')).toBe('execute'); + expect(validateEventPreflightResult( + { data: { tickets: ['cc-7'] }, outcome: 'execute' }, + 'tool/after', + )).toEqual({ data: { tickets: ['cc-7'] }, outcome: 'execute' }); expect(validateEventPreflightResult({ outcome: 'continue' }, 'tool/after')).toEqual({ outcome: 'continue', }); @@ -104,7 +108,9 @@ it('rejects unsupported preflight fields and results', () => { expect(() => validateEventPreflightResult({ outcome: 'ask' }, 'tool/before')) .toThrow(/not supported/u); expect(() => validateEventPreflightResult({ outcome: 'execute' }, 'tool/before')) - .toThrow(/not supported/u); + .toThrow(/JSON values/u); + expect(() => validateEventPreflightResult({ data: new Date(), outcome: 'execute' }, 'tool/before')) + .toThrow(/JSON objects must be plain objects/u); expect(() => validateEventPreflightResult({ outcome: 'continue', reason: 'x' }, 'tool/before')) .toThrow(/unsupported field/u); expect(() => validateEventPreflightResult( @@ -178,8 +184,11 @@ it('re-exports the preflight contract through the public production path', () => expect(rootValidateEventPreflightResult).toBe(validateEventPreflightResult); expect(rootEventFamilyAllowsPreflightDeny).toBe(eventFamilyAllowsPreflightDeny); const result: PublicEventPreflightResult = publicValidateEventPreflightResult('execute', 'tool/before'); - const context: PublicEventPreflightContext = {} as EventPreflightContext; - const authoring: EventPreflight = () => result; + const context: PublicEventPreflightContext<'tool/before'> = {} as EventPreflightContext<'tool/before'>; + const authoring: EventPreflight<'tool/before', { readonly ticket: string }> = () => ({ + data: { ticket: 'cc-7' }, + outcome: 'execute', + }); expect(result).toBe('execute'); - expect(authoring(context)).toBe('execute'); + expect(authoring(context)).toEqual({ data: { ticket: 'cc-7' }, outcome: 'execute' }); }); diff --git a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts index c41934c3e..d039cb4b5 100644 --- a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -64,7 +64,7 @@ const projectFiles: Readonly> = { 'export default ({ canonical }: { readonly canonical: { readonly payload?: Record } }) => {', " const tool = canonical.payload?.['toolInput'] as { readonly value?: { readonly command?: unknown } } | undefined;", " const command = typeof tool?.value?.command === 'string' ? tool.value.command : '';", - " if (mentionsCargo(command)) return 'execute';", + " if (mentionsCargo(command)) return { outcome: 'execute', data: { ticket: 'cc-7' } };", " return command === 'blocked' ? { outcome: 'deny', reason: PREFLIGHT_LEAF_SENTINEL } : { outcome: 'continue' };", '};', '', @@ -73,11 +73,12 @@ const projectFiles: Readonly> = { // artifact, with a declared provider and a deliberately heavy import graph. 'src/events/tool/before.tsx': [ "import { Agent } from '@agent-bundle/runtime';", + "import type { AgentEventRouteProps } from 'agent-bundle';", "import { RENDERED_ROUTE_SENTINEL } from '../../heavy/rendered-route.js';", "export { default as preflight } from './before.preflight.js';", "export const config = { providers: ['daemonProbe'], runtime: 'standalone' };", - 'export default async function ToolBefore({ canonical }) {', - " return {canonical.event};", + "export default async function ToolBefore({ canonical, preflight }: AgentEventRouteProps<'tool/before', { readonly ticket: string }>) {", + " return {canonical.event};", '}', '', ].join('\n'), @@ -303,20 +304,20 @@ describe('preflight artifact graph (#595)', () => { })); }); - it('runs continue, deny, and deferred execute outcomes through the published hook process', async () => { - const invoke = (command: string) => runNodeScript({ - args: [join(artifactRoot, entryPath)], - input: JSON.stringify({ - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-1', - tool_input: { command }, - tool_name: 'Bash', - tool_use_id: 'use-1', - transcript_path: join(root, 'transcript.json'), - }), - }); + const invoke = (command: string) => runNodeScript({ + args: [join(artifactRoot, entryPath)], + input: JSON.stringify({ + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-1', + tool_input: { command }, + tool_name: 'Bash', + tool_use_id: 'use-1', + transcript_path: join(root, 'transcript.json'), + }), + }); + it('short-circuits continue and deny outcomes before deferred execution', async () => { await expect(invoke('echo hello')).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); const denied = await invoke('blocked'); expect(denied.code).toBe(0); @@ -327,13 +328,16 @@ describe('preflight artifact graph (#595)', () => { permissionDecisionReason: sentinels.preflightLeaf, }, }); + }); + + it('passes computed preflight data to the deferred route', async () => { const executed = await invoke('cargo check'); expect(executed.code).toBe(0); expect(executed.stderr).toBe(''); expect(JSON.parse(executed.stdout)).toMatchObject({ hookSpecificOutput: { permissionDecision: 'deny', - permissionDecisionReason: sentinels.renderedRoute, + permissionDecisionReason: `${sentinels.renderedRoute}:cc-7`, }, }); }); diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts index a9e46ff9e..9aeceba2a 100644 --- a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -89,7 +89,7 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', 'export default () => {', " appendFileSync(join(process.cwd(), '.agent-bundle', 'defer-gate.marker'), 'gate\\n');", - " return 'execute';", + " return { outcome: 'execute', data: { ticket: 'cc-7' } };", '};', '', ].join('\n'), @@ -102,10 +102,10 @@ it('invokes compiled tool and event routes through the foreground server', { tim '', "export const config = { providers: ['clock'], runtime: 'standalone' };", '', - 'export default async function AfterTool({ canonical }) {', + 'export default async function AfterTool({ canonical, preflight }) {', ' const context = await agent();', " appendFileSync(join(process.cwd(), '.agent-bundle', 'defer-handler.marker'), 'run\\n');", - " const value = { outcome: 'defer', providers: Object.keys(context.providers).sort() };", + " const value = { outcome: 'defer', providers: Object.keys(context.providers).sort(), ticket: preflight.ticket };", " return createElement(Agent.Result, { value }, createElement(Agent.Context, null, `Observed ${canonical.payload.toolName}.`));", '}', '', @@ -368,7 +368,11 @@ it('invokes compiled tool and event routes through the foreground server', { tim expect(event.invocation.outcome).toEqual({ kind: 'success' }); expect(event.invocation.events.at(-1)?.type).toBe('complete'); expect(event.invocation.document).toBeDefined(); - expect(event.invocation.result).toEqual({ outcome: 'defer', providers: ['clock', 'processLifetime'] }); + expect(event.invocation.result).toEqual({ + outcome: 'defer', + providers: ['clock', 'processLifetime'], + ticket: 'cc-7', + }); expect(await readFile(join(project.root, '.agent-bundle', 'defer-gate.marker'), 'utf8')).toBe('gate\n'); expect(await readFile(join(project.root, '.agent-bundle', 'defer-handler.marker'), 'utf8')).toBe('run\n'); expect(event.invocation.projection.hosts?.[0]).toMatchObject({ host: 'claude' }); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 8671bccb7..b902419dc 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -420,9 +420,9 @@ it('runs event-route preflight in the per-host wrapper before shared IPC', () => expect(source).toContain('AbortSignal.timeout(timeoutMs)'); expect(source).toContain('process.once(terminationSignal, terminate)'); expect(source).toContain('process.off(terminationSignal, terminate)'); - expect(source).toContain('observedAt: props.canonical.observedAt, sequence: props.canonical.sequence'); + expect(source).toContain('...(gate === "execute" ? {} : { preflight: gate.data })'); expect(entry.executeVirtualSource).toContain('const observation = { observedAt, sequence };'); - expect(entry.executeVirtualSource).toContain('observedAt: observation?.observedAt, sequence: observation?.sequence'); + expect(entry.executeVirtualSource).toContain('observedAt: observation?.observedAt, preflight, sequence: observation?.sequence'); expect(source).not.toContain('AGENT_BUNDLE_HOOK_HOST'); expect(source).not.toContain('createAgentRenderDispatcher'); expect(source).not.toContain('import * as routeModule'); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 379e629ad..245580d55 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -279,18 +279,21 @@ an object literal, a string, a class, or an identifier the module imports rather function). The route compiles without a gate beside the error, and because the diagnostic is an error the build fails rather than silently taking the expensive rendered path. -Preflight may be synchronous or asynchronous and has exactly three results +Preflight may be synchronous or asynchronous and has exactly four results (`EventPreflightResult`): | Result | Meaning | | --- | --- | | `'execute'` | Load the rendered route entry, resolve its declared providers, and render it. | +| `{ outcome: 'execute', data }` | Load and render the route with the strict-JSON `data` available as its `preflight` prop. | | `{ outcome: 'continue' }` | Pass through: nothing is projected and no host decision is written, so the host's normal flow applies — on `tool/before`, its own permission prompt. | | `{ outcome: 'deny', reason }` | Deny with a non-empty reason, projected per host by the same rules as a rendered route's `deny` (`hookSpecificOutput.permissionDecision: 'deny'` with `permissionDecisionReason` on a Claude `tool/before`, for example). | +When the rendered route needs a value the gate already computed, type both sides with the same JSON shape — `EventPreflight<'tool/after', CompletedTickets>` and `AgentEventRouteProps<'tool/after', CompletedTickets>` — then return `{ outcome: 'execute', data }`; the route receives that value as the optional `preflight` prop (it is absent after bare `'execute'` or when no gate exists). The kernel snapshots `data` as strict JSON before it crosses the existing deferred executor and shared-runtime boundaries, so functions, class instances, cycles, accessors, and non-finite numbers fail closed instead of reaching the route. + `execute` is the only result that loads the rendered route runtime. Validation (`validateEventPreflightResult`, exported from `agent-bundle`) fails closed on everything else: -`undefined`, the bare string `'continue'`, an outcome other than `continue` or `deny` — a gate has +`undefined`, the bare string `'continue'`, an outcome other than `execute`, `continue`, or `deny` — a gate has no `allow`, `ask`, or `updatedInput`; return `'execute'` and let the rendered route make those decisions — any extra field (`reason` beside `continue`, `updatedInput` beside `deny`), an empty or whitespace-only reason, and `deny` on a family where no host projects a blocking denial. diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 470e84c09..1a326ab2b 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -241,14 +241,17 @@ provider,实际开销并不会降低。通过裸包名导入、形成循环、 `preflight`,都会导致构建错误;编译器不会悄悄退回高开销的路由路径。 这些无效形式由 `inspect`、`validate`、`build` 和 `dev` 以 `AB4840` 报告。 -`preflight` 可以是同步或异步的,并且恰好只有三种结果: +`preflight` 可以是同步或异步的,并且恰好只有四种结果: | 结果 | 含义 | | --- | --- | | `'execute'` | 加载渲染式路由入口,解析其声明的 provider,并渲染它。 | +| `{ outcome: 'execute', data }` | 加载并渲染路由,同时把严格 JSON 的 `data` 作为其 `preflight` prop。 | | `{ outcome: 'continue' }` | 返回放行输出,不表达任何宿主决定。 | | `{ outcome: 'deny', reason: string }` | 按该事件既有的规范结果规则投影一次拒绝。 | +当渲染式路由需要门控已经算出的值时,请用同一种 JSON 形状标注两端——`EventPreflight<'tool/after', CompletedTickets>` 与 `AgentEventRouteProps<'tool/after', CompletedTickets>`——再返回 `{ outcome: 'execute', data }`;路由会从可选的 `preflight` prop 收到该值(返回裸 `'execute'` 或没有门控时此字段缺失)。内核会先把 `data` 快照为严格 JSON,再让它跨过既有的延迟执行器与共享运行时边界,因此函数、类实例、循环引用、访问器与非有限数字都会失败即关闭,而不会到达路由。 + `execute` 是唯一会加载渲染式路由运行时的结果。返回 `undefined`、未知字段或未知结果,或给出空的拒绝 原因,都会无法通过框架校验并终止执行。仅用于观察的事件不能拒绝。