From 0d2561d3f754ef9b74dfab0cb435a83b14350032 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 06:07:14 +0000 Subject: [PATCH 1/2] fix(events): render standalone event routes through a local Flight worker Standalone and fallback event-route wrappers no longer evaluate route components with a manual element walker: the bundle emits one shared hooks-flight.mjs react-server worker per target, and the wrapper dispatches the same kind:'event' render request the warm runtime uses, then projects the resulting Agent Document into the unchanged native hook envelope. --- .changeset/standalone-event-flight.md | 6 + .../src/adapters/hook-contract.ts | 72 ++++++++- packages/agent-bundle/src/build/build.ts | 14 +- packages/agent-bundle/src/build/entries.ts | 89 +++++++++-- packages/agent-bundle/src/build/provenance.ts | 2 + packages/agent-bundle/src/events/project.ts | 29 ---- .../agent-bundle/tests/event-project.test.ts | 123 --------------- .../tests/generated-route-server.test.ts | 11 +- .../agent-bundle/tests/provenance.test.ts | 1 + .../tests/route-unit/event-project.test.ts | 149 ++++++++++++++++++ .../tests/target-hook-contract.test.ts | 6 +- 11 files changed, 319 insertions(+), 183 deletions(-) create mode 100644 .changeset/standalone-event-flight.md create mode 100644 packages/agent-bundle/tests/route-unit/event-project.test.ts diff --git a/.changeset/standalone-event-flight.md b/.changeset/standalone-event-flight.md new file mode 100644 index 000000000..657749885 --- /dev/null +++ b/.changeset/standalone-event-flight.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": patch +--- + +Render standalone event routes through a local react-server Flight worker while +preserving each host's native hook input and output envelopes. diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index ad556dce9..62739030b 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -398,6 +398,7 @@ export const encodeCursorPlaygroundOutput = ( export const eventIpcRuntimeSpecifier = 'agent-bundle/event-ipc'; export const eventProjectRuntimeSpecifier = 'agent-bundle/event-project'; export const eventArtifactEpochToken = '__AGENT_BUNDLE_EVENT_ARTIFACT_EPOCH__'; +export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFACT_EPOCH__'; const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, @@ -415,15 +416,15 @@ const eventRouteHookWrapperSource = ( : ['const target = artifactTarget;']; return [ "import { dirname, resolve } from 'node:path';", - `import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, - `import { ${standalone ? 'createCanonicalEventProps, projectEventDocument, renderStandaloneEventRoute, ' : ''}validateNativeEventEnvelope } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, + ...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []), ...(standalone - ? [ - `import * as routeModule from ${JSON.stringify(entry.hook.source)};`, - ] + ? ["import { agent, available, createAgentRenderDispatcher, runAgentRequest } from '@agent-bundle/runtime';"] : []), + `import { EventRuntimeTransportError, requestEventRuntime } from ${JSON.stringify(eventIpcRuntimeSpecifier)};`, + `import { ${standalone ? 'createCanonicalEventProps, projectEventDocument, ' : ''}validateNativeEventEnvelope } from ${JSON.stringify(eventProjectRuntimeSpecifier)};`, '', `const artifactEpoch = ${JSON.stringify(eventArtifactEpochToken)};`, + ...(standalone ? [`const flightArtifactEpoch = ${JSON.stringify(eventFlightArtifactEpochToken)};`] : []), `const canonicalEvent = ${JSON.stringify(route.event)};`, `const capabilityRevision = ${JSON.stringify(hostContractRevision)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, @@ -437,11 +438,66 @@ const eventRouteHookWrapperSource = ( 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', ...(standalone ? [ + 'const renderStandalone = async (invocation, signal) => {', + ' const worker = new Worker(new URL("./hooks-flight.mjs", import.meta.url), { stderr: true, stdout: true });', + " worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));", + " worker.stderr?.on('data', (chunk) => process.stderr.write(chunk));", + ' let sequence = 0;', + ' const host = Object.freeze({', + ' execute: async (dispatch) => {', + ' const context = await agent();', + ' const id = ++sequence;', + ' return new Promise((resolvePromise, rejectPromise) => {', + ' const abort = () => { worker.postMessage({ id, type: "cancel" }); rejectPromise(new DOMException("Agent render was aborted", "AbortError")); };', + ' const cleanup = () => { dispatch.signal.removeEventListener("abort", abort); worker.off("error", onError); worker.off("exit", onExit); worker.off("message", onMessage); };', + ' const reject = (error) => { cleanup(); rejectPromise(error); };', + ' const onError = (error) => { reject(error); };', + ' const onExit = (code) => { reject(new Error(`Generated hook Flight worker exited with code ${String(code)}.`)); };', + ' const onMessage = (message) => {', + ' if (message.id !== id) return;', + ' if (message.type === "progress") { Promise.resolve(dispatch.progress?.report(message.update)).catch(reject); return; }', + ' if (message.type === "error") { reject(new Error(message.message)); return; }', + ' if (message.type !== "complete") return;', + ' cleanup();', + ' resolvePromise(new ReadableStream({ start(controller) { controller.enqueue(message.bytes); controller.close(); } }));', + ' };', + ' worker.on("error", onError);', + ' worker.on("exit", onExit);', + ' worker.on("message", onMessage);', + ' dispatch.signal.addEventListener("abort", abort, { once: true });', + ' if (dispatch.signal.aborted) { abort(); return; }', + ' worker.postMessage({', + ' actor: context.actor,', + ' artifactEpoch: flightArtifactEpoch,', + ' host: context.host,', + ' id,', + ' invocation: dispatch.invocation,', + ' requestInvocation: context.invocation,', + ' session: context.session,', + ' type: "render",', + ' workspace: context.workspace,', + ' });', + ' });', + ' },', + ' });', + ' try {', + ' return await createAgentRenderDispatcher(host).dispatch({ invocation, signal });', + ' } finally {', + ' await worker.terminate();', + ' }', + '};', 'const runStandalone = async (native, signal) => {', - ' const component = Reflect.get(routeModule, "default");', - ' if (typeof component !== "function") fail("default export must be an async Server Component");', ' const props = createCanonicalEventProps(canonicalEvent, native, target, nativeEvent, capabilityRevision, signal);', - ' return projectEventDocument(await renderStandaloneEventRoute(component, props), canonicalEvent, target, nativeEvent);', + ' const sessionId = typeof native.session_id === "string" ? native.session_id : typeof native.conversation_id === "string" ? native.conversation_id : undefined;', + ' const workspaceRoot = typeof native.cwd === "string" ? native.cwd : undefined;', + ' const document = await runAgentRequest({', + ' host: available({ name: target }, "native"),', + ' invocation: { artifactEpoch, hostContractRevision: capabilityRevision, kind: "event", operationId: `event:${canonicalEvent}`, surface: canonicalEvent },', + ' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),', + ' signal,', + ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', + ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: props.canonical, native: props.native } } }, signal));', + ' return projectEventDocument(document, canonicalEvent, target, nativeEvent);', '};', ] : []), diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index e0c17f608..7b20045b9 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -194,7 +194,7 @@ const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[ resolveArtifactDestination(target.root, entry.relativePath), ), ...target.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledHooks.map((entry) => entry.output), + ...target.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ...target.compiledMcpApps.map((entry) => entry.output), ...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ]); @@ -235,11 +235,15 @@ const outputCandidatesFor = (options: { path: entry.workerOutput, sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, }])]), - ...options.compiledHooks.map((entry) => ({ + ...options.compiledHooks.flatMap((entry) => [{ kind: 'bundle' as const, path: entry.output, sourceInputs: entry.sourceInputs, - })), + }, ...(entry.workerOutput === undefined ? [] : [{ + kind: 'bundle' as const, + path: entry.workerOutput, + sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, + }])]), ...options.compiledMcpApps.map((entry) => ({ kind: 'bundle' as const, path: entry.output, @@ -377,6 +381,9 @@ export const build = async (options: BuildOptions): Promise => { cwd: options.projectRoot, meta, outDir: target.root, + plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), ...tools, }))); compiledMcpEntries.push(...(await compileMcpEntries(options.model.mcpServers, { @@ -462,6 +469,7 @@ export const build = async (options: BuildOptions): Promise => { compiledHooks: Object.freeze(compiledHooks.map((entry) => Object.freeze({ ...entry, output: publishedOutput(entry), + ...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }), }))), compiledMcpApps: Object.freeze(compiledMcpApps.map((entry) => Object.freeze({ ...entry, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 886850345..40bbfa8a1 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { eventArtifactEpochToken, + eventFlightArtifactEpochToken, eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier, type TargetHookEntry, @@ -471,18 +472,32 @@ export const compileMcpEntries = async ( export const planCompiledHooks = ( entries: readonly TargetHookEntry[], options: { readonly outDir: string }, -): readonly CompiledHookEntry[] => deepFreeze(entries.map((entry) => ({ - event: entry.event, - id: entry.hook.id, - ...(entry.indexed === false ? { indexed: false as const } : {}), - name: entry.hook.name, - output: resolveArtifactDestination(options.outDir, entry.relativePath), - outputKind: 'bundle', - source: entry.hook.source, - sourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]), - target: entry.target, - ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), -}))); +): readonly CompiledHookEntry[] => { + const workerOwner = entries.findIndex((entry) => + entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone'); + const workerSourceInputs = Object.freeze([...new Set(entries + .filter((entry) => + entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') + .flatMap((entry) => [entry.hook.provenance.sourcePath, entry.hook.source]))]); + return deepFreeze(entries.map((entry, index) => ({ + event: entry.event, + id: entry.hook.id, + ...(entry.indexed === false ? { indexed: false as const } : {}), + name: entry.hook.name, + output: resolveArtifactDestination(options.outDir, entry.relativePath), + outputKind: 'bundle', + source: entry.hook.source, + sourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]), + target: entry.target, + ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), + ...(index === workerOwner + ? { + workerOutput: resolveArtifactDestination(resolve(options.outDir, 'hooks'), 'hooks-flight.mjs'), + workerSourceInputs, + } + : {}), + }))); +}; export const compileHooks = async ( entries: readonly TargetHookEntry[], @@ -491,21 +506,58 @@ export const compileHooks = async ( readonly cwd: string; readonly meta: AgentBundleMeta; readonly outDir: string; + readonly plugin: { readonly name: string; readonly version: string }; + readonly providers?: readonly CompiledProvider[]; + readonly state?: NormalizedStateDefinition; readonly tools?: AgentBundleToolsConfig; }, ): Promise => { const compiled = planCompiledHooks(entries, options); const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined); + const standaloneEventRoutes = [...new Map(routeEntries + .filter((entry) => + entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') + .map((entry) => [entry.hook.id, entry.hook])).values()]; + const workerArtifactEpoch = generatedRouteArtifactEpoch(options.plugin); const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc'); const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project'); + const workerEntry = standaloneEventRoutes.length === 0 + ? undefined + : { + name: 'hooks-flight', + outputRelativePath: 'hooks/hooks-flight.mjs', + reactServer: true as const, + rscManifest: true as const, + source: standaloneEventRoutes[0]!.source, + sourceInputs: Object.freeze([ + ...new Set([ + ...standaloneEventRoutes.flatMap((hook) => [hook.provenance.sourcePath, hook.source]), + ...(options.providers ?? []).map((provider) => provider.source), + ...(options.state === undefined ? [] : [options.state.provenance.sourcePath, options.state.source]), + ]), + ]), + virtualSource: generatedRouteFlightWorkerSource({ + artifactEpoch: workerArtifactEpoch, + eventRoutes: standaloneEventRoutes, + providers: options.providers ?? [], + routes: [], + serverName: 'hooks', + ...(options.state === undefined ? {} : { state: options.state }), + }), + }; const evidence = await buildWithRslib({ cwd: options.cwd, - entries: compiled.map((entry, index) => ({ + entries: [ + ...compiled.map((entry, index) => ({ // One hook can compile into several host wrappers (for example a shared // Claude/Codex wrapper plus a Cursor-codec wrapper), so the bundler // library id derives from the unique output path, not the hook name. name: entries[index]!.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), outputRelativePath: entries[index]!.relativePath, + ...(entries[index]!.hook.eventRoute?.runtime === 'standalone' + || entries[index]!.hook.eventRoute?.fallback === 'standalone' + ? { rscManifest: true as const } + : {}), ...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined ? {} : { @@ -516,8 +568,12 @@ export const compileHooks = async ( }), source: entry.source, sourceInputs: entry.sourceInputs, - virtualSource: entries[index]!.virtualSource.replaceAll(eventArtifactEpochToken, options.artifactEpoch), - })), + virtualSource: entries[index]!.virtualSource + .replaceAll(eventArtifactEpochToken, options.artifactEpoch) + .replaceAll(eventFlightArtifactEpochToken, workerArtifactEpoch), + })), + ...(workerEntry === undefined ? [] : [workerEntry]), + ], ...(eventIpcRuntime === undefined ? {} : { @@ -531,5 +587,8 @@ export const compileHooks = async ( return Object.freeze(compiled.map((entry, index) => Object.freeze({ ...entry, sourceInputs: evidenceByPath.get(entries[index]!.relativePath) ?? (() => { throw new Error(`Missing bundled hook evidence for ${JSON.stringify(entry.name)}.`); })(), + ...(entry.workerOutput === undefined ? {} : { + workerSourceInputs: evidenceByPath.get('hooks/hooks-flight.mjs') ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), + }), }))); }; diff --git a/packages/agent-bundle/src/build/provenance.ts b/packages/agent-bundle/src/build/provenance.ts index fe33ef863..30f2e7e29 100644 --- a/packages/agent-bundle/src/build/provenance.ts +++ b/packages/agent-bundle/src/build/provenance.ts @@ -116,10 +116,12 @@ const isIgnoredModule = (path: string, ignoredSourcePaths: readonly string[]): b // identified by moduleType and externals by their readable identifier prefix // (an external's moduleType is "javascript/dynamic", never "external"). const isKnownNoAuthorSourceModule = (module: JsonRecord): boolean => { + const identifier = stringAt(module, 'identifier'); const moduleType = stringAt(module, 'moduleType'); const name = stringAt(module, 'name'); return recordsAt(module.modules).length > 0 || moduleType === 'runtime' || + identifier?.endsWith('|sync') === true || name?.startsWith('external ') === true; }; diff --git a/packages/agent-bundle/src/events/project.ts b/packages/agent-bundle/src/events/project.ts index 4883817c1..4e515d466 100644 --- a/packages/agent-bundle/src/events/project.ts +++ b/packages/agent-bundle/src/events/project.ts @@ -1,35 +1,6 @@ -import { Children, cloneElement, isValidElement, type ReactNode } from 'react'; - -import { decodeAgentDocument, type AgentDocument } from '@agent-bundle/runtime'; - -import type { AgentEventRouteProps } from '../routes/public.ts'; - 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)); - if (!isValidElement>(node)) return node; - const type: unknown = node.type; - if (type === Symbol.for('react.fragment')) { - return Promise.all(Children.toArray(node.props.children as ReactNode).map(resolveServerNode)); - } - if (typeof type === 'function') { - const component = type as (props: Record) => ReactNode | Promise; - return resolveServerNode(await component(node.props)); - } - if (typeof type !== 'string') { - throw new TypeError('Standalone event routes may render only Server Components and Agent protocol elements.'); - } - const children = await Promise.all(Children.toArray(node.props.children as ReactNode).map(resolveServerNode)); - return cloneElement(node, undefined, ...children); -}; - -export const renderStandaloneEventRoute = async ( - component: (props: AgentEventRouteProps) => ReactNode | Promise, - props: AgentEventRouteProps, -): Promise => decodeAgentDocument(await resolveServerNode(await component(props))); diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index 7eab5394a..6d2ac50f8 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -1,12 +1,7 @@ -import { Agent } from '@agent-bundle/runtime'; import { expect, it } from '@rstest/core'; -import { createElement } from 'react'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { - createCanonicalEventProps, - projectEventDocument, - renderStandaloneEventRoute, validateNativeEventEnvelope, } from '../src/events/project.ts'; import { canonicalAgentEvents } from '../src/routes/public.ts'; @@ -51,121 +46,3 @@ it('validates native event envelopes with the generated wrapper error contract', expect(() => validateNativeEventEnvelope([], options)) .toThrow('Agent Bundle event route error: stdin JSON value must be an object'); }); - -it('resolves nested Server Components in explicit standalone event routes', async () => { - const NestedContext = async () => createElement(Agent.Context, null, 'standalone'); - const Route = async () => createElement( - Agent.Result, - null, - createElement(NestedContext), - ); - const controller = new AbortController(); - const props = createCanonicalEventProps( - 'tool/after', - { hook_event_name: 'PostToolUse', tool_name: 'Write' }, - 'claude', - 'PostToolUse', - '2.1.250', - controller.signal, - ); - - const document = await renderStandaloneEventRoute(Route, props); - expect(projectEventDocument(document, 'tool/after', 'claude', 'PostToolUse')).toEqual({ - hookSpecificOutput: { - additionalContext: 'standalone', - hookEventName: 'PostToolUse', - }, - }); -}); - -it('projects subagent-start context without fabricating a blocking effect', async () => { - const document = await renderStandaloneEventRoute( - async () => createElement( - Agent.Result, - null, - createElement(Agent.Context, null, 'Review the repository test conventions first.'), - ), - createCanonicalEventProps( - 'agent/start', - { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' }, - 'codex', - 'SubagentStart', - '0.147.0', - new AbortController().signal, - ), - ); - - for (const target of ['claude', 'codex']) { - expect(projectEventDocument(document, 'agent/start', target, 'SubagentStart')).toEqual({ - hookSpecificOutput: { - additionalContext: 'Review the repository test conventions first.', - hookEventName: 'SubagentStart', - }, - }); - } - - const blocked = await renderStandaloneEventRoute( - async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Do not start.' } }), - createCanonicalEventProps( - 'agent/start', - { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' }, - 'codex', - 'SubagentStart', - '0.147.0', - new AbortController().signal, - ), - ); - expect(() => projectEventDocument(blocked, 'agent/start', 'codex', 'SubagentStart')) - .toThrow(/agent\/start cannot block subagent creation/u); -}); - -it('projects subagent-stop continuation only through supported host contracts', async () => { - const blocked = await renderStandaloneEventRoute( - async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Run one more focused pass.' } }), - createCanonicalEventProps( - 'agent/stop', - { - agent_id: 'agent-1', - agent_transcript_path: '/workspace/subagents/agent-1.jsonl', - agent_type: 'Explore', - hook_event_name: 'SubagentStop', - last_assistant_message: 'Done.', - session_id: 'parent-1', - stop_hook_active: false, - }, - 'codex', - 'SubagentStop', - '0.147.0', - new AbortController().signal, - ), - ); - - for (const target of ['claude', 'codex']) { - expect(projectEventDocument(blocked, 'agent/stop', target, 'SubagentStop')).toEqual({ - decision: 'block', - reason: 'Run one more focused pass.', - }); - } - - const feedback = await renderStandaloneEventRoute( - async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.')), - createCanonicalEventProps( - 'agent/stop', - { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStop', session_id: 'session-1' }, - 'claude', - 'SubagentStop', - '2.1.250', - new AbortController().signal, - ), - ); - expect(projectEventDocument(feedback, 'agent/stop', 'claude', 'SubagentStop')).toEqual({ - hookSpecificOutput: { - additionalContext: 'Check the final result.', - hookEventName: 'SubagentStop', - }, - }); - expect(() => projectEventDocument(feedback, 'agent/stop', 'codex', 'SubagentStop')) - .toThrow(/not supported by the Codex SubagentStop output schema/u); - expect(() => projectEventDocument(feedback, 'agent/stop', 'plugin', 'SubagentStop')) - .toThrow(/must resolve the invoking host/u); -}); diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 1198da76b..33da23eea 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -629,11 +629,13 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo ].join('\n')), writeProjectFile(root, 'src/events/tool/after.tsx', [ "import { Agent } from '@agent-bundle/runtime';", - "import { createElement } from 'react';", + "import { createElement, Suspense } from 'react';", "export const config = { runtime: 'standalone', targets: ['cursor'] };", + 'const wait = () => new Promise((resolve) => setTimeout(resolve, 5));', 'const Context = async ({ tool }) => createElement(Agent.Context, null, `standalone:${tool}`);', 'export default async function AfterTool({ native }) {', - ' return createElement(Agent.Result, null, createElement(Context, { tool: native.tool_name }));', + ' await wait();', + " return createElement(Agent.Result, null, createElement(Suspense, { fallback: createElement(Agent.Context, null, 'loading') }, createElement(Context, { tool: native.tool_name })));", '}', '', ].join('\n')), @@ -644,7 +646,7 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo expect(compiled.build.compiledMcpEntries).toHaveLength(0); const hook = compiled.build.compiledHooks.find((entry) => entry.event === 'afterTool'); expect(hook).toBeDefined(); - await expect(runHook(hook!.output, { + const response = await runHook(hook!.output, { conversation_id: 'conversation-1', cwd: root, hook_event_name: 'postToolUse', @@ -653,7 +655,8 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo tool_name: 'Write', tool_output: '{"ok":true}', tool_use_id: 'tool-1', - })).resolves.toEqual({ additional_context: 'standalone:Write' }); + }); + expect(response).toEqual({ additional_context: 'standalone:Write' }); }); it('replays Claude and Codex subagent fixtures through standalone event-route wrappers', { timeout: 60_000 }, async () => { diff --git a/packages/agent-bundle/tests/provenance.test.ts b/packages/agent-bundle/tests/provenance.test.ts index 2443c38a3..263893534 100644 --- a/packages/agent-bundle/tests/provenance.test.ts +++ b/packages/agent-bundle/tests/provenance.test.ts @@ -275,6 +275,7 @@ it('permits classified no-source modules and rejects anonymous or unknown select expect(collect([ { moduleType: 'runtime' }, { moduleType: 'external', name: 'external "node:fs"' }, + { identifier: '/work/project/node_modules/agent-bundle/dist|sync', moduleType: 'javascript/auto' }, { modules: [{ nameForCondition: '/work/project/src/greeting.ts' }] }, ])).toEqual([{ path: 'portable/scripts/greeting.mjs', diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts new file mode 100644 index 000000000..0b26c9a58 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -0,0 +1,149 @@ +import { Agent } from '@agent-bundle/runtime'; +import { expect, it } from '@rstest/core'; +import { createElement, Suspense } from 'react'; + +import { + createCanonicalEventProps, + projectEventDocument, +} from '../../src/events/project.ts'; +import { renderRoute } from '../../src/test/render.ts'; + +it('renders standalone event projection fixtures through real Flight', async () => { + const NestedContext = async () => createElement(Agent.Context, null, 'standalone'); + const Route = async () => createElement( + Agent.Result, + null, + createElement( + Suspense, + { fallback: createElement(Agent.Context, null, 'loading') }, + createElement(NestedContext), + ), + ); + const props = createCanonicalEventProps( + 'tool/after', + { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + 'claude', + 'PostToolUse', + '2.1.250', + new AbortController().signal, + ); + const rendered = await renderRoute({ default: Route }, { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:tool/after', + }); + + expect(projectEventDocument(rendered.document, 'tool/after', 'claude', 'PostToolUse')).toEqual({ + hookSpecificOutput: { + additionalContext: 'standalone', + hookEventName: 'PostToolUse', + }, + }); +}); + +it('projects subagent-start context without fabricating a blocking effect', async () => { + const props = createCanonicalEventProps( + 'agent/start', + { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStart', session_id: 'parent-1' }, + 'codex', + 'SubagentStart', + '0.147.0', + new AbortController().signal, + ); + const rendered = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Review the repository test conventions first.'), + ), + }, { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:agent/start', + }); + + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(rendered.document, 'agent/start', target, 'SubagentStart')).toEqual({ + hookSpecificOutput: { + additionalContext: 'Review the repository test conventions first.', + hookEventName: 'SubagentStart', + }, + }); + } + + const blocked = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Do not start.' } }), + }, { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:agent/start', + }); + expect(() => projectEventDocument(blocked.document, 'agent/start', 'codex', 'SubagentStart')) + .toThrow(/agent\/start cannot block subagent creation/u); +}); + +it('projects subagent-stop continuation only through supported host contracts', async () => { + const props = createCanonicalEventProps( + 'agent/stop', + { + agent_id: 'agent-1', + agent_transcript_path: '/workspace/subagents/agent-1.jsonl', + agent_type: 'Explore', + hook_event_name: 'SubagentStop', + last_assistant_message: 'Done.', + session_id: 'parent-1', + stop_hook_active: false, + }, + 'codex', + 'SubagentStop', + '0.147.0', + new AbortController().signal, + ); + const blocked = await renderRoute({ + default: async () => createElement( + Agent.Result, + { value: { outcome: 'deny', reason: 'Run one more focused pass.' } }, + ), + }, { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:agent/stop', + }); + + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(blocked.document, 'agent/stop', target, 'SubagentStop')).toEqual({ + decision: 'block', + reason: 'Run one more focused pass.', + }); + } + + const feedbackProps = createCanonicalEventProps( + 'agent/stop', + { agent_id: 'agent-1', agent_type: 'Explore', hook_event_name: 'SubagentStop', session_id: 'session-1' }, + 'claude', + 'SubagentStop', + '2.1.250', + new AbortController().signal, + ); + const feedback = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Check the final result.'), + ), + }, { + input: { canonical: feedbackProps.canonical, native: feedbackProps.native }, + kind: 'event-route', + routeId: 'event:agent/stop', + }); + expect(projectEventDocument(feedback.document, 'agent/stop', 'claude', 'SubagentStop')).toEqual({ + hookSpecificOutput: { + additionalContext: 'Check the final result.', + hookEventName: 'SubagentStop', + }, + }); + expect(() => projectEventDocument(feedback.document, 'agent/stop', 'codex', 'SubagentStop')) + .toThrow(/not supported by the Codex SubagentStop output schema/u); + expect(() => projectEventDocument(feedback.document, 'agent/stop', 'plugin', 'SubagentStop')) + .toThrow(/must resolve the invoking host/u); +}); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index e340c6637..9a2e30b57 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -314,8 +314,12 @@ it('plans a thin epoch-bound event-route client and keeps standalone execution e eventRoute: { event: 'tool/after', fallback: 'standalone', runtime: 'shared' }, }; const degradedSource = planHooks(planningModel([degraded]), 'synthetic', contract).hookEntries[0]!.virtualSource; - expect(degradedSource).toContain('import * as routeModule'); + expect(degradedSource).toContain('new URL("./hooks-flight.mjs", import.meta.url)'); + expect(degradedSource).toContain('createAgentRenderDispatcher'); + expect(degradedSource).toContain('projectEventDocument'); expect(degradedSource).toContain('error.code === "runtime-unavailable"'); + expect(degradedSource).not.toContain('import * as routeModule'); + expect(degradedSource).not.toContain('renderStandaloneEventRoute'); }); it('continues planning valid hooks after a prior hook mapping error', () => { From 0ca8e11e8d8699759208457cce7c77a96810193a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 06:18:58 +0000 Subject: [PATCH 2/2] fix(events): resolve the concrete invoking host for composite plugin event routes The composite plugin artifact's event runtime validated and projected with the literal 'plugin' target, which projectEventDocument rejects, and the shared wrapper's host detection had no Cursor branch. The Cursor wrapper variant now bakes its concrete target, the shared runtime validates request.target against the artifact's allowed hosts, and props, host identity, and projection all use the concrete host. --- .changeset/composite-plugin-event-hosts.md | 5 + .../src/adapters/hook-contract.ts | 8 +- packages/agent-bundle/src/adapters/plugin.ts | 2 +- .../agent-bundle/src/build/entry-shell.ts | 8 +- .../agent-bundle/src/mcp-server-runtime.ts | 13 +- .../tests/generated-route-server.test.ts | 153 +++++++++++++++++- .../tests/target-hook-contract.test.ts | 29 ++++ 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 .changeset/composite-plugin-event-hosts.md diff --git a/.changeset/composite-plugin-event-hosts.md b/.changeset/composite-plugin-event-hosts.md new file mode 100644 index 000000000..2363da656 --- /dev/null +++ b/.changeset/composite-plugin-event-hosts.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Resolve the concrete invoking host for composite plugin shared event routes so Claude, Codex, and Cursor wrappers validate and project their native envelopes correctly. diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 62739030b..1c6eee9f3 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -403,10 +403,13 @@ export const eventFlightArtifactEpochToken = '__AGENT_BUNDLE_EVENT_FLIGHT_ARTIFA const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, + concreteTarget?: string, ): string => { const route = entry.hook.eventRoute!; const standalone = route.runtime === 'standalone' || route.fallback === 'standalone'; - const targetSource = entry.target === 'plugin' + const targetSource = concreteTarget !== undefined + ? [`const target = ${JSON.stringify(concreteTarget)};`] + : entry.target === 'plugin' ? [ 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', 'const target = declaredHost === "claude" || declaredHost === "codex"', @@ -799,6 +802,7 @@ export const planHooks = ( model: NormalizedPlugin, target: string, contract: TargetHookContract, + concreteEventTarget?: string, ): HookPlan => { const diagnostics: Diagnostic[] = []; const selected = model.hooks @@ -867,7 +871,7 @@ export const planHooks = ( ...wrapper, virtualSource: hook.eventRoute === undefined ? contract.wrapperSource(wrapper) - : eventRouteHookWrapperSource(wrapper, contract.hostContractRevision ?? target), + : eventRouteHookWrapperSource(wrapper, contract.hostContractRevision ?? target, concreteEventTarget), }); } diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index c76584d2c..f6c7800bb 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -429,7 +429,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { // schema-collision guard when no hook lowers to Cursor. let cursorHooksDocument: Record = emptyCursorHooksDocument; if (emitCursorHooks) { - const cursorHooks = planHooks(model, pluginName, cursorBundleHookContract); + const cursorHooks = planHooks(model, pluginName, cursorBundleHookContract, 'cursor'); diagnostics.push(...cursorHooks.diagnostics); if (cursorHooks.document !== undefined) { const cursorHooksDocumentValid = cursorHooksValidator(cursorHooks.document); diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index f0ed7aa9a..b71d2fb5d 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -713,6 +713,10 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti assertRegistrableMcpRoutes(routes, options.state !== undefined); const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; + const eventTarget = options.target ?? 'unknown'; + const allowedEventTargets = eventTarget === 'plugin' + ? ['claude', 'codex', 'cursor'] + : [eventTarget]; return [ ...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []), `import { createFlightWorkerHost, createGeneratedRouteMcpServer } from ${JSON.stringify(mcpServerRuntimeSpecifier)};`, @@ -737,8 +741,10 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti // The endpoint identity is artifact-location dependent, so it stays // in the artifact rather than the shared runtime. `const EVENT_ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch ?? 'unknown')};`, - `const EVENT_TARGET = ${JSON.stringify(options.target ?? 'unknown')};`, + `const EVENT_TARGET = ${JSON.stringify(eventTarget)};`, + `const EVENT_ALLOWED_TARGETS = Object.freeze(${JSON.stringify(allowedEventTargets)});`, 'const events = Object.freeze({', + ' allowedTargets: EVENT_ALLOWED_TARGETS,', ' artifactEpoch: EVENT_ARTIFACT_EPOCH,', ' createCanonicalEventProps,', ' createEventRuntimeServer,', diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 7abe728e0..4bc1297cb 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -417,6 +417,7 @@ export type GeneratedRouteExecutionHost = WarmFlightHost; * harness. An artifact with no event routes passes nothing. */ export interface GeneratedEventRuntimeBinding { + readonly allowedTargets: readonly string[]; readonly artifactEpoch: string; readonly createCanonicalEventProps: typeof createCanonicalEventProps; readonly createEventRuntimeServer: typeof createEventRuntimeServer; @@ -468,11 +469,17 @@ const startEventRuntime = async ( endpointId: events.endpointId, handle: async (request, signal) => { const event = canonicalEvent(request.event); + const target = events.allowedTargets.find((candidate) => candidate === request.target); + if (target === undefined) { + throw new TypeError( + `Event runtime target ${JSON.stringify(request.target)} is not allowed by this artifact (${events.allowedTargets.map((candidate) => JSON.stringify(candidate)).join(', ')}).`, + ); + } const nativeEvent = nativeString(request.native, 'hook_event_name') ?? event; const props = events.createCanonicalEventProps( event, request.native, - events.target, + target, nativeEvent, request.hostContractRevision, signal, @@ -481,7 +488,7 @@ const startEventRuntime = async ( ?? nativeString(request.native, 'conversation_id'); const workspaceRoot = nativeString(request.native, 'cwd'); return runAgentRequest({ - host: available({ name: events.target }, 'native'), + host: available({ name: target }, 'native'), invocation: { artifactEpoch: events.artifactEpoch, hostContractRevision: request.hostContractRevision, @@ -505,7 +512,7 @@ const startEventRuntime = async ( signal, }), event, - events.target, + target, nativeEvent, )); }, diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 33da23eea..c450fd73c 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -8,7 +8,7 @@ import { dirname, join, resolve } from 'node:path'; import { afterEach, expect, it } from '@rstest/core'; import { build } from '../src/api.ts'; -import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; +import { eventRuntimeEndpoint, requestEventRuntime } from '../src/events/ipc.ts'; const roots: string[] = []; @@ -608,6 +608,157 @@ it('renders one tool/after event route through two native thin clients', { retry } }); +it('renders composite plugin events through each concrete host in one warm runtime', { retry: 2, timeout: 90_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-plugin-events-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'generated-plugin-events-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture', version: '1.0.0' }, targets: ['plugin'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { description: 'Keep the shared event runtime warm.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ok: z.literal(true) }).strict();', + 'export default async function Status() {', + " return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'ready'));", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/tool/after.tsx', [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { targets: ['plugin'], tools: ['file.write'] };", + 'export default async function AfterTool() {', + ' const context = await agent();', + ' const processLifetime = context.providers.processLifetime as { hits: number; instanceId: string };', + ' const host = context.host.state === "available" ? context.host.value.name : "unavailable";', + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${host}:tool/after:${String(processLifetime.hits)}:${processLifetime.instanceId}`));', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/events/session/start.tsx', [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { targets: ['plugin'] };", + 'export default async function SessionStart() {', + ' const context = await agent();', + ' const processLifetime = context.providers.processLifetime as { hits: number; instanceId: string };', + ' const host = context.host.state === "available" ? context.host.value.name : "unavailable";', + ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${host}:session/start:${String(processLifetime.hits)}:${processLifetime.instanceId}`));', + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['plugin'] }); + const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'plugin')!; + const sharedAfter = compiled.build.compiledHooks.find((entry) => + entry.event === 'afterTool' && !entry.output.endsWith('.cursor.mjs'))!; + const cursorAfter = compiled.build.compiledHooks.find((entry) => + entry.event === 'afterTool' && entry.output.endsWith('.cursor.mjs'))!; + const sharedSession = compiled.build.compiledHooks.find((entry) => + entry.event === 'sessionStart' && !entry.output.endsWith('.cursor.mjs'))!; + const client = new Client({ name: 'generated-event-plugin', version: '0.0.0' }); + const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); + await client.connect(transport); + try { + const endpointId = `${compiled.build.manifest.project.revision}:plugin:${dirname(dirname(resolve(mcp.output)))}`; + await expect(requestEventRuntime({ + artifactEpoch: compiled.build.manifest.project.revision, + endpointId, + event: 'tool/after', + hostContractRevision: 'test', + native: {}, + signal: AbortSignal.timeout(10_000), + target: 'portable', + timeoutMs: 10_000, + })).rejects.toMatchObject({ code: 'runtime-failed' }); + + const claude = await runHook(sharedAfter.output, { + cwd: root, + hook_event_name: 'PostToolUse', + session_id: 'session-claude', + tool_input: { file_path: 'demo.ts' }, + tool_name: 'Write', + tool_response: { ok: true }, + tool_use_id: 'tool-claude', + transcript_path: join(root, 'transcript.jsonl'), + }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }); + const firstContext = (claude as { hookSpecificOutput: { additionalContext: string } }) + .hookSpecificOutput.additionalContext; + const instanceId = firstContext.slice('claude:tool/after:1:'.length); + expect(instanceId).not.toBe(''); + expect(claude).toEqual({ + hookSpecificOutput: { + additionalContext: `claude:tool/after:1:${instanceId}`, + hookEventName: 'PostToolUse', + }, + }); + + await expect(runHook(sharedAfter.output, { + cwd: root, + hook_event_name: 'PostToolUse', + session_id: 'session-codex', + tool_input: { command: '*** Begin Patch\n*** End Patch' }, + tool_name: 'apply_patch', + tool_response: { ok: true }, + tool_use_id: 'tool-codex', + transcript_path: null, + }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output })).resolves.toEqual({ + hookSpecificOutput: { + additionalContext: `codex:tool/after:2:${instanceId}`, + hookEventName: 'PostToolUse', + }, + }); + + await expect(runHook(cursorAfter.output, { + conversation_id: 'conversation-cursor', + cwd: root, + hook_event_name: 'postToolUse', + session_id: 'session-cursor', + tool_input: { file_path: 'demo.ts' }, + tool_name: 'Write', + tool_output: '{"ok":true}', + tool_use_id: 'tool-cursor', + }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined })).resolves.toEqual({ + additional_context: `cursor:tool/after:3:${instanceId}`, + }); + + await expect(runHook(sharedSession.output, { + cwd: root, + hook_event_name: 'SessionStart', + session_id: 'session-codex', + source: 'startup', + transcript_path: null, + }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output })).resolves.toEqual({ + hookSpecificOutput: { + additionalContext: `codex:session/start:4:${instanceId}`, + hookEventName: 'SessionStart', + }, + }); + } finally { + await client.close(); + } +}); + it('runs an explicitly standalone event route without a shared runtime', { timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-standalone-event-')); roots.push(root); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 9a2e30b57..710f8e770 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -12,6 +12,7 @@ import { readTargetNativeHookCommands, type TargetHookContract, } from '../src/adapters/hook-contract.ts'; +import { pluginAdapter } from '../src/adapters/plugin.ts'; import { TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { normalizeProject, type NormalizationTargetRegistry } from '../src/config/index.ts'; @@ -322,6 +323,34 @@ it('plans a thin epoch-bound event-route client and keeps standalone execution e expect(degradedSource).not.toContain('renderStandaloneEventRoute'); }); +it('bakes the concrete Cursor target only into the plugin Cursor event wrapper', () => { + const hook: NormalizedHook = { + ...planningHook('afterTool', []), + eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, + targets: ['plugin'], + }; + const model: NormalizedPlugin = { + ...planningModel([hook]), + targets: [{ + id: 'target:plugin', + name: 'plugin', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], + }; + const plan = pluginAdapter.plan(model); + const hookEntries = plan.hookEntries ?? []; + const shared = hookEntries.find((entry) => !entry.relativePath.endsWith('.cursor.mjs')); + const cursor = hookEntries.find((entry) => entry.relativePath.endsWith('.cursor.mjs')); + + expect(shared?.virtualSource).toContain('const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;'); + expect(shared?.virtualSource).toContain('process.env.PLUGIN_ROOT === undefined ? "claude" : "codex"'); + expect(shared?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })'); + expect(cursor?.virtualSource).toContain('const target = "cursor";'); + expect(cursor?.virtualSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + expect(cursor?.virtualSource).not.toContain('process.env.PLUGIN_ROOT'); + expect(cursor?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })'); +}); + it('continues planning valid hooks after a prior hook mapping error', () => { const plan = planHooks(planningModel([ planningHook('beforeTool', ['shell']),