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/.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..1c6eee9f3 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -398,14 +398,18 @@ 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, 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"', @@ -415,15 +419,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 +441,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);', '};', ] : []), @@ -743,6 +802,7 @@ export const planHooks = ( model: NormalizedPlugin, target: string, contract: TargetHookContract, + concreteEventTarget?: string, ): HookPlan => { const diagnostics: Diagnostic[] = []; const selected = model.hooks @@ -811,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/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/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/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/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/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..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); @@ -629,11 +780,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 +797,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 +806,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..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'; @@ -314,8 +315,40 @@ 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('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', () => {