diff --git a/.changeset/plain-cli-context-providers.md b/.changeset/plain-cli-context-providers.md new file mode 100644 index 000000000..705e5a891 --- /dev/null +++ b/.changeset/plain-cli-context-providers.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Mount conventional request context providers for plain `.ts` routed CLI commands, so `(await agent()).providers` carries the same values on every generated request scope (MCP, events, rendered CLI, rendered scripts, and now plain CLI) with identical ordering, cancellation, and fail-closed semantics; the plain execute context also exposes the consumed `args`. The rendered-session bridge now forwards the invocation to its react-server worker, so providers behind rendered CLI commands and rendered scripts observe the real `invocation.kind` instead of `undefined`. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index d53a037b2..297fac67c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -81,7 +81,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | -| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | +| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. @@ -141,13 +141,17 @@ with the contract `(context: { invocation, signal }) => value | Promise`, where `invocation` is the current route invocation and `signal` is its request abort signal. -The generated shared Flight worker executes providers once per request, -sequentially in deterministic key order, before entering `runAgentRequest`. -The returned values join the request's provider map. A thrown or rejected -factory fails the request closed; expected degradation should return an honest -unavailable-shaped value instead of throwing. `processLifetime` is reserved -for the framework-owned process identity and hit counter, so provider filenames -must not derive that key. +Every generated request scope — the shared Flight worker behind generated MCP +and event routes, the react-server worker behind rendered routed CLI commands +and rendered scripts, and the routed-CLI executable itself for plain `.ts` +commands — executes providers once per request, sequentially in deterministic +key order, before entering `runAgentRequest`. The returned values join the +request's provider map. A thrown or rejected factory fails the request closed; +expected degradation should return an honest unavailable-shaped value instead +of throwing. `invocation.kind` stays surface-specific (`tool`, `event`, `cli`, +`script`), so a provider can branch on the entry surface deliberately. +`processLifetime` is reserved for the framework-owned process identity and hit +counter, so provider filenames must not derive that key. ### Handler request context diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 10e923a14..410afb912 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -130,6 +130,8 @@ export const generatedInstallBinEntrySource = (options: { export interface GeneratedCliBinEntryOptions { readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; + /** Conventional request context providers, mounted for plain commands in this process (#313). */ + readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly state?: NormalizedStateDefinition; /** The sibling react-server worker bundle; required when any command is rendered. */ @@ -220,7 +222,9 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [ ' pending.set(id, entry);', " dispatch.signal.addEventListener('abort', entry.abort, { once: true });", ' if (dispatch.signal.aborted) { entry.abort(); return stream; }', - " worker.postMessage({ id, props, request, routeId, type: 'render' });", + // The invocation rides to the worker so conventional providers observe the + // real surface (`cli`, `script`, `tool`) instead of an undefined invocation. + " worker.postMessage({ id, invocation, props, request, routeId, type: 'render' });", ' return stream;', ' },', ' });', @@ -250,6 +254,8 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) if (rendered && options.workerFile === undefined) { throw new Error('A generated CLI with rendered commands requires a worker file.'); } + const providers = orderedProviders(options.providers ?? []); + const plainIndent = options.state === undefined ? ' ' : ' '; return [ `import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered @@ -258,8 +264,11 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), ...generatedStateImports(options.state, 'cwd'), ...routeImports(commandRoutes), + ...providerImports(providers), '', ...generatedStateOwner(options.state, 'cwd'), + 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', + ...providerRegistrySource(providers), 'const routes = Object.freeze({', ...commandRoutes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), @@ -275,15 +284,24 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ' }', '};', '', + // Plain commands mount the same conventional providers as every other + // generated request scope (#313): once per request, in deterministic key + // order, fail-closed, before the typed Agent request context opens. 'const execute = async (command, input, context) => {', ' const route = routes[command.routeId];', " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');", ' const parsed = parseInput(route, input);', ' const cwd = process.cwd();', + ' processLifetime.hits += 1;', ...(options.state === undefined ? [] : [' const bindings = await runtimeState.requestBindings({ signal: context.signal });', ' try {']), - `${options.state === undefined ? ' ' : ' '}const result = await runAgentRequest({`, + ...providerExecutionSource(providers, { + indent: plainIndent, + invocation: "{ kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", + signal: 'context.signal', + }), + `${plainIndent}const result = await runAgentRequest({`, ' capabilities: {', ' command: unavailable(),', ' filesystem: unavailable(),', @@ -293,6 +311,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) " host: unavailable('unsupported-surface'),", " invocation: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), + ` providers: ${providerValuesExpression(providers)},`, ' signal: context.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), " workspace: available({ root: cwd }, 'derived'),", @@ -380,13 +399,7 @@ export const generatedRenderedRouteWorkerSource = ( '// Machine output owns the parent stdout; anything a route logs goes to stderr.', 'process.stdout.write = process.stderr.write.bind(process.stderr);', 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', - ...(providers.length === 0 - ? [] - : [ - 'const providers = Object.freeze([', - ...providerRecords(providers), - ']);', - ]), + ...providerRegistrySource(providers), 'const routes = Object.freeze({', ...options.routes.map((route, index) => ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), @@ -405,21 +418,7 @@ export const generatedRenderedRouteWorkerSource = ( ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']), ...(options.state === undefined ? [] : [' try {']), - ...(providers.length === 0 - ? [] - : [ - ' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };', - ' for (const provider of providers) {', - ' if (typeof provider.module.default !== \'function\') {', - ' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);', - ' }', - ' try {', - ' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });', - ' } catch (error) {', - ' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });', - ' }', - ' }', - ]), + ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', signal: 'controller.signal' }), ' await runAgentRequest({', ' capabilities: {', ' command: unavailable(),', @@ -431,9 +430,7 @@ export const generatedRenderedRouteWorkerSource = ( ' invocation: message.request,', ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", - ...(providers.length === 0 - ? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },'] - : [' providers: providerValues,']), + ` providers: ${providerValuesExpression(providers)},`, ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), " workspace: available({ root: cwd }, 'derived'),", @@ -577,6 +574,47 @@ const providerRecords = (providers: readonly CompiledProvider[]): readonly strin providers.map((provider, index) => ` Object.freeze({ key: ${JSON.stringify(providerKeyFromName(provider.name))}, module: provider${String(index)}, source: ${JSON.stringify(provider.provenance.relativePath)} }),`); +/** The frozen provider registry a generated request scope iterates; empty when the project declares none. */ +const providerRegistrySource = (providers: readonly CompiledProvider[]): readonly string[] => + providers.length === 0 + ? [] + : ['const providers = Object.freeze([', ...providerRecords(providers), ']);']; + +const processLifetimeValueSource = + '{ hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid }'; + +/** + * Per-request provider execution shared by every generated request scope + * (shared Flight worker, rendered CLI/script worker, plain routed CLI): once + * per request, sequentially in deterministic key order, fail-closed on a + * missing factory or a thrown/rejected factory, with the framework-owned + * `processLifetime` value seeded first. + */ +const providerExecutionSource = ( + providers: readonly CompiledProvider[], + expressions: { readonly indent: string; readonly invocation: string; readonly signal: string }, +): readonly string[] => { + if (providers.length === 0) return []; + const { indent, invocation, signal } = expressions; + return [ + `${indent}const providerValues = { processLifetime: ${processLifetimeValueSource} };`, + `${indent}for (const provider of providers) {`, + `${indent} if (typeof provider.module.default !== 'function') {`, + `${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`, + `${indent} }`, + `${indent} try {`, + `${indent} providerValues[provider.key] = await provider.module.default({ invocation: ${invocation}, signal: ${signal} });`, + `${indent} } catch (error) {`, + `${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`, + `${indent} }`, + `${indent}}`, + ]; +}; + +/** The `providers` request-scope value: the executed map, or only the framework-owned process identity. */ +const providerValuesExpression = (providers: readonly CompiledProvider[]): string => + providers.length === 0 ? `{ processLifetime: ${processLifetimeValueSource} }` : 'providerValues'; + /** The long-lived react-server worker used by one generated MCP process. */ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => { const routes = executableMcpRoutes(options.routes); @@ -600,13 +638,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo `const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`, 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...generatedStateOwner(options.state, 'artifact'), - ...(providers.length === 0 - ? [] - : [ - 'const providers = Object.freeze([', - ...providerRecords(providers), - ']);', - ]), + ...providerRegistrySource(providers), 'const routes = Object.freeze({', ...routeRecords(routes), ...noticeInboxRecord(options.state), @@ -629,30 +661,14 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ...(options.state === undefined ? [] : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']), - ...(providers.length === 0 - ? [] - : [ - ' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };', - ' for (const provider of providers) {', - ' if (typeof provider.module.default !== \'function\') {', - ' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);', - ' }', - ' try {', - ' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });', - ' } catch (error) {', - ' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });', - ' }', - ' }', - ]), + ...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', signal: 'controller.signal' }), ' const bytes = await runAgentRequest({', ' ...(message.actor === undefined ? {} : { actor: message.actor }),', ' ...(message.host === undefined ? {} : { host: message.host }),', ' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },', ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), ' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },', - ...(providers.length === 0 - ? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },'] - : [' providers: providerValues,']), + ` providers: ${providerValuesExpression(providers)},`, ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 18f3e11a3..0f3c1b768 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -140,6 +140,7 @@ export const planPackageEntries = async ( name: model.metadata.name, version: model.metadata.version, }, + providers: model.providers ?? [], routes: bin.generatedCli.routes, ...(model.state === undefined ? {} : { state: model.state }), ...(rendered ? { workerFile } : {}), diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index 61866e520..1370ec4ff 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -89,6 +89,8 @@ export class CliInputError extends Error { } export interface GeneratedCliExecuteContext { + /** The raw argv the command consumed, for the provider invocation's `args`. */ + readonly args: readonly string[]; /** True when `--json` was passed; plain commands already emit canonical JSON. */ readonly json: boolean; readonly signal: AbortSignal; @@ -669,7 +671,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro } } if (parsed.ndjson) throw new CliUsageError('--ndjson requires a rendered command.'); - const result = await options.execute(command, parsed.input, { json: parsed.json, signal }); + const result = await options.execute(command, parsed.input, { args: rest, json: parsed.json, signal }); signal.throwIfAborted(); const exitCode = resultExitCode(command.exitCode, result); writeOut(`${stableJson(result === undefined ? null : result)}\n`); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 8d6b9e530..6a0aec090 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -68,6 +68,30 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 '}', '', ].join('\n')), + // A conventional request context provider (#313): every generated request + // scope — plain CLI, rendered CLI, rendered script — mounts the same value. + writeProjectFile(root, 'src/providers/library-tooling.ts', [ + 'export default async function libraryTooling({ invocation, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + " return { kind: invocation.kind, tool: 'ffprobe 6.1' };", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/cli/tooling.ts', [ + "import { agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { description: 'Report the mounted request providers.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({', + ' hits: z.number().int().min(1),', + " libraryTooling: z.object({ kind: z.literal('cli'), tool: z.string() }).strict(),", + '}).strict();', + 'export default async function tooling() {', + ' const context = await agent();', + ' return { hits: context.providers.processLifetime.hits, libraryTooling: context.providers.libraryTooling };', + '}', + '', + ].join('\n')), writeProjectFile(root, 'src/cli/library/audit.ts', [ "import { z } from 'zod';", "export const config = { description: 'Audit sources.', exitCode: 'result', positionals: ['sources'] };", @@ -91,12 +115,12 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { description: 'Render a library report.', positionals: ['root'] };", 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', - 'export const resultSchema = z.object({ books: z.number(), root: z.string() }).strict();', + 'export const resultSchema = z.object({ books: z.number(), root: z.string(), tooling: z.string() }).strict();', 'export default async function Report({ input, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'scanning', total: 2 });", - ' const result = { books: 2, root: input.root };', + ' const result = { books: 2, root: input.root, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', ' return (', ' ', ' {`Found **2** books under ${input.root}.`}', @@ -152,10 +176,11 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 ].join('\n')), writeProjectFile(root, 'src/scripts/summarize.tsx', [ "import React from 'react';", - "import { Agent } from '@agent-bundle/runtime';", + "import { Agent, agent } from '@agent-bundle/runtime';", 'export default async function Summarize({ argv, signal }) {', " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", - ' const result = { arguments: argv.length };', + ' const context = await agent();', + ' const result = { arguments: argv.length, tooling: `${context.providers.libraryTooling.kind}:${context.providers.libraryTooling.tool}` };', ' return (', ' ', ' {`Summarized ${String(argv.length)} arguments.`}', @@ -180,8 +205,10 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(binEvidence?.sourceInputs).toEqual(expect.arrayContaining([ 'src/cli/doctor.ts', 'src/cli/library/audit.ts', + 'src/cli/tooling.ts', 'src/mcp/harness/tools/apply.tsx', 'src/mcp/harness/tools/lookup.tsx', + 'src/providers/library-tooling.ts', ])); // Help and version come from the compiled command graph. @@ -200,6 +227,10 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(JSON.parse(doctor.stdout)).toEqual({ invocation: 'cli', status: 'ready', surface: 'doctor' }); const aliased = await execFile(binPath, ['health', '--verbose', '--json']); expect(JSON.parse(aliased.stdout)).toEqual({ invocation: 'cli', status: 'ready', surface: 'doctor (verbose)' }); + // Plain .ts commands mount conventional providers once per request (#313), + // with the framework-owned processLifetime value beside them. + const tooling = await execFile(binPath, ['tooling']); + expect(JSON.parse(tooling.stdout)).toEqual({ hits: 1, libraryTooling: { kind: 'cli', tool: 'ffprobe 6.1' } }); // Nested commands parse positionals/options and honor the result exit-code policy. const audit = await execFile(binPath, ['library', 'audit', 'a', 'b']); @@ -220,9 +251,10 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // Piped output is exactly one final Markdown document, no partial fallbacks. const piped = await execFile(binPath, ['report', '/library']); expect(piped.stdout).toBe('Found **2** books under /library.\n'); - // --json returns the canonical validated final value. + // --json returns the canonical validated final value; the rendered command + // observed the same conventional provider as the plain command (#313). const reportJson = await execFile(binPath, ['report', '/library', '--json']); - expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library' }); + expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library', tooling: 'cli:ffprobe 6.1' }); // --ndjson exposes the sequence-numbered render-event stream, including // the progress the component reported through the request context. const reportEvents = await execFile(binPath, ['report', '/library', '--ndjson']); @@ -282,8 +314,9 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 await expect(stat(join(root, 'artifact', 'portable', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); + // The rendered script's provider sees `invocation.kind === 'script'` (#313). const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); - expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1 }); + expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1, tooling: 'script:ffprobe 6.1' }); // #102 acceptance: one build ships custom, MCP-generated, plain, and rendered commands/scripts. const plainScriptPath = join(root, 'artifact', 'portable', 'scripts', 'checksum.mjs'); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 9a43f1d5b..0e81e1ce4 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -453,6 +453,73 @@ it('generates deterministic per-request provider execution in the shared Flight expect(source).toContain('providers: providerValues'); }); +it('mounts deterministic per-request providers for plain routed CLI commands (#313)', () => { + const route = { + config: {}, + id: 'cli:doctor', + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/doctor.ts' }, + source: '/project/src/cli/doctor.ts', + }; + const command = { + aliases: [], + exitCode: 'zero' as const, + options: [], + path: ['doctor'], + rendered: false, + routeId: route.id, + }; + const withProviders = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'route-fixture', version: '1.2.3' }, + providers: [ + { + id: 'provider:zeta', + name: 'zeta', + provenance: { kind: 'conventional', relativePath: 'src/providers/zeta.ts' }, + source: '/project/src/providers/zeta.ts', + }, + { + id: 'provider:alpha-value', + name: 'alpha-value', + provenance: { kind: 'conventional', relativePath: 'src/providers/alpha-value.ts' }, + source: '/project/src/providers/alpha-value.ts', + }, + ], + routes: [route], + }); + + // Same registry, ordering, invocation contract, and fail-closed wrapping as the Flight workers. + expect(withProviders).toContain('import * as provider0 from "/project/src/providers/alpha-value.ts"'); + expect(withProviders).toContain('import * as provider1 from "/project/src/providers/zeta.ts"'); + expect(withProviders.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( + withProviders.indexOf('/project/src/providers/zeta.ts'), + ); + expect(withProviders).toContain('key: "alphaValue"'); + expect(withProviders).toContain( + "await provider.module.default({ invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }, signal: context.signal })", + ); + expect(withProviders).toContain('must default-export a factory.'); + expect(withProviders).toContain('failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error })'); + expect(withProviders).toContain('providers: providerValues,'); + // Providers run once per request, before the request scope opens. + expect(withProviders.indexOf('for (const provider of providers)')).toBeLessThan( + withProviders.indexOf('const result = await runAgentRequest({'), + ); + + // A project without providers still mounts only the framework-owned process identity. + const withoutProviders = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [route], + }); + expect(withoutProviders).not.toContain('const providers = Object.freeze(['); + expect(withoutProviders).toContain( + 'providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },', + ); + expect(withoutProviders).not.toContain('import * as provider0'); +}); + it('mounts deterministic per-request providers in rendered route workers', () => { const source = entryShellModule.generatedRenderedRouteWorkerSource({ providers: [ @@ -486,6 +553,15 @@ it('mounts deterministic per-request providers in rendered route workers', () => expect(source).toContain('await provider.module.default({ invocation: message.invocation, signal: controller.signal })'); expect(source).toContain('providers: providerValues'); expect(source).toContain('processLifetime'); + + // The rendered-session bridge must post the invocation the worker's + // providers read; without it every rendered provider saw `undefined` (#313). + const bridge = entryShellModule.generatedRenderedScriptEntrySource({ + name: 'report', + routeId: 'script:report', + workerFile: 'report-flight.mjs', + }); + expect(bridge).toContain("worker.postMessage({ id, invocation, props, request, routeId, type: 'render' })"); }); it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 65eb2043f..26d197042 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -361,6 +361,8 @@ codexPluginIt( 'defaultPrompt', 'developerName', 'displayName', + // The fixture declares `plugin.logo`; Codex projects it as `interface.logo` (#246 / #364). + 'logo', 'longDescription', 'shortDescription', ],