Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/rendered-cli-dispatch-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"agent-bundle": minor
---

Exercise rendered CLI command routes through the public `cli-dispatch` test
harness. `invokeCli` now mirrors the generated executable's render session,
supports explicit TTY projection through `tty`, and exposes `cliNdjson` for
asserting ordered rendered event streams.
14 changes: 11 additions & 3 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,22 +274,30 @@ is never a receipt for another.
| --- | --- | --- |
| `route-unit` | `renderRoute`, `renderRouteEvents` | a route module renders to the document (and render-event stream) it claims |
| `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface` | the real generated MCP server's protocol contract, over the SDK's in-memory transport |
| `cli-dispatch` | `invokeCli`, `cliJson` | an argv vector resolved and run through the routed CLI's own shell, in-process |
| `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a plain or rendered argv vector resolved and run through the routed CLI's own shell, including rendered Markdown, explicit TTY, JSON, and NDJSON modes, in-process |
| `packed-stdio` | `openPackedMcpServer` | a built artifact's generated entry running as a real process over stdio |
| `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })` | the packed stdio process still runs after project source and configuration are removed and verified absent |
| `host-install` | repository real-host install proof | a built bundle installed into an isolated real host home through the public install path, with registration observed through the host's own CLI |

```ts
import { cliJson, expectEvents, invokeCli, invokeMcpTool } from 'agent-bundle/test';
import { cliJson, cliNdjson, expectEvents, invokeCli, invokeMcpTool } from 'agent-bundle/test';

// mcp-in-memory: the generated server projects the document to protocol content.
const call = await invokeMcpTool('summarize', { input: { title: 'Dune' } });
expect(call.structuredContent).toEqual({ chapters: 24 });

// cli-dispatch: the routed CLI resolves the command, parses argv, and maps the exit code.
// cli-dispatch, plain .ts route: resolve argv, execute, and map the exit code.
const run = await invokeCli(['library', 'audit', './books', '--max-files', '8']);
expect(run.exitCode).toBe(0);
expect(cliJson(run)).toMatchObject({ scanned: 8 });

// cli-dispatch, rendered .tsx route: exercise the shell's rendered output modes.
const rendered = await invokeCli(['library', 'report', './books', '--ndjson']);
const events = cliNdjson(rendered);
expect(events.at(-1)?.type).toBe('complete');

const tty = await invokeCli(['library', 'report', './books'], { tty: true });
expect(tty.stdout).toContain('\r\u001B[2K');
```

`expectEvents` asserts over a render-event stream. `toContainSequence` is
Expand Down
51 changes: 51 additions & 0 deletions packages/agent-bundle/fixtures/route-harness/src/cli/report.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Agent, agent } from '@agent-bundle/runtime';
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

export const config = {
description: 'Renders a harness report.',
positionals: ['topic'],
} satisfies CliRouteConfig;

export const inputSchema = z.object({
mode: z.enum(['success', 'render-error', 'invalid-result', 'wait-for-abort']).default('success'),
topic: z.string().min(1),
}).strict();

export const resultSchema = z.object({
count: z.number().int().nonnegative(),
stateMounted: z.literal(true),
status: z.literal('ready'),
topic: z.string(),
}).strict();

export default async function Report({ input, signal }: CliRouteProps<typeof inputSchema>) {
const context = await agent();
await context.progress.report({ completed: 1, message: 'preparing report', total: 2 });

if (input.mode === 'render-error') {
throw new Error('report render exploded');
}
if (input.mode === 'wait-for-abort') {
await new Promise<void>((_resolve, reject) => {
const rejectAborted = () => reject(new DOMException('Report render aborted', 'AbortError'));
if (signal.aborted) {
rejectAborted();
return;
}
signal.addEventListener('abort', rejectAborted, { once: true });
});
}

await context.progress.report({ completed: 2, message: 'report ready', total: 2 });
const value = input.mode === 'invalid-result'
? { count: 'two', stateMounted: context.state !== undefined, status: 'ready', topic: input.topic }
: { count: 2, stateMounted: context.state !== undefined, status: 'ready', topic: input.topic };

return (
<Agent.Result value={value}>
<Agent.Markdown>{`# Report: ${input.topic}\n\nGenerated for ${input.topic}.`}</Agent.Markdown>
<Agent.Text>items: 2</Agent.Text>
</Agent.Result>
);
}
243 changes: 171 additions & 72 deletions packages/agent-bundle/src/test/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,38 @@
* `invokeCli` runs one argv vector through the routed CLI's own shell
* (`runGeneratedCliEntry`, #102 stage 2) over the compiled command graph the
* manifest carries, in this process. Command resolution, the argv projection,
* help, `--version`, and the exit-code mapping are the product's; the only
* thing the harness supplies is the `execute` bridge that runs the matched
* route module — and that mirrors the generated executable's, so a command
* that passes here fails in the same place a shipped binary would.
* help, output-mode selection, and exit-code mapping are the product's. Plain
* commands run through an in-process `execute` bridge; rendered commands run
* through an in-process render session that shares the route-unit harness's
* dispatcher and Flight renderer.
*
* It does **not** spawn the generated binary: no shebang, no executable bit,
* no process framing. That is the `packed-stdio` level's business.
*
* Rendered (`.tsx`) command routes compile no command until #102 stage 3, so
* this level dispatches plain command routes only; a rendered command is a
* compiler error (`AB4816`) long before it reaches a test.
* worker thread, process framing, or chunk-by-chunk Flight streaming timing.
* The packed CLI route suite owns that evidence.
*/
import type * as AgentRuntime from '@agent-bundle/runtime';

import { CliInputError, runGeneratedCliEntry } from '../cli-entry.ts';
import type { CliRenderedEvent } from '../cli-entry.ts';
import type { CompiledCliCommand } from '../routes/types.ts';
import { AgentTestError, captured } from './errors.ts';
import { CLI_DISPATCH_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts';
import { registeredRouteLoader, testManifest } from './registry.ts';
import type { RenderRouteContext } from './render.ts';
import { prepareCliRenderHost, type RenderRouteContext } from './render.ts';
import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts';

export type { CliRenderedEvent };

export interface InvokeCliOptions {
/** Request-scope overrides for the dispatched command, over the runtime's request contract. */
readonly context?: RenderRouteContext;
readonly manifest?: AgentBundleTestManifest;
readonly signal?: AbortSignal;
/**
* Selects interactive rendered output explicitly. Generated binaries use
* `process.stdout.isTTY`; the in-process harness defaults to piped output.
*/
readonly tty?: boolean;
}

export interface CliInvocation {
Expand All @@ -48,9 +53,9 @@ export interface CliInvocation {
readonly routeId?: string;
/** Everything the shell wrote to its diagnostic stream. */
readonly stderr: string;
/** Everything the shell wrote to its output stream: one canonical JSON line, or help text. */
/** Everything the shell wrote to stdout, including rendered Markdown, TTY, JSON, or NDJSON output. */
readonly stdout: string;
/** The validated result the command returned; absent unless a command executed. */
/** The validated plain result or rendered document value; absent unless a command completed validation. */
readonly value?: unknown;
}

Expand Down Expand Up @@ -79,7 +84,7 @@ const noCommands = (manifest: AgentBundleTestManifest): AgentTestError => new Ag
? []
: [`compiler: ${String(manifest.diagnostics.length)} diagnostic(s), first ${manifest.diagnostics[0]!.code}: ${manifest.diagnostics[0]!.message}`]),
],
recovery: 'Add a plain command route under src/cli/ exporting inputSchema, resultSchema, and an async default function.',
recovery: 'Add a command route under src/cli/ exporting inputSchema, resultSchema, and an async default function or component.',
},
);

Expand Down Expand Up @@ -148,71 +153,115 @@ export const invokeCli = async (
const provenance = provenanceOf(manifest);
const runtime = await loadRuntime();
const context = options.context ?? {};
const signal = options.signal ?? new AbortController().signal;
const renderedCommands = manifest.cliCommands.filter((command) => command.rendered);

let executed: CompiledCliCommand | undefined;
let value: unknown;
let out = '';
let err = '';

const exitCode = await runGeneratedCliEntry({
argv,
commands: manifest.cliCommands,
// The bridge the generated executable inlines: the module's own schemas
// stay the validation boundary, an input rejection is a usage failure,
// and the command body runs inside the typed request scope.
execute: async (command, input, execution) => {
executed = command;
const module = await moduleFor(manifest, command.routeId, provenance);
const component = (module as { default?: unknown }).default;
if (typeof component !== 'function') {
throw new AgentTestError('invalid-route-module', `Command route ${command.routeId} must default-export an async function.`, {
details: [`received: default export of type ${typeof component}`],
recovery: 'Export the command function as the module default.',
});
}
if (module.inputSchema === undefined || module.resultSchema === undefined) {
throw new AgentTestError('invalid-route-module', `Command route ${command.routeId} must export inputSchema and resultSchema.`, {
recovery: 'Export both zod schemas from the command module; the routed CLI validates argv through them.',
});
}
let parsed: unknown;
try {
parsed = module.inputSchema.parse(input);
} catch (error) {
throw new CliInputError(error instanceof Error ? error.message : String(error));
}
const root = process.cwd();
const result = await runtime.runAgentRequest({
capabilities: {
command: runtime.unavailable(),
filesystem: runtime.unavailable(),
network: runtime.unavailable(),
projectRoot: runtime.available({ root }, 'derived'),
},
host: runtime.unavailable('unsupported-surface'),
workspace: runtime.available({ root }, 'derived'),
...context,
invocation: {
kind: 'cli',
operationId: command.routeId,
surface: commandPath(command),
...context.invocation,
},
...(context.progress === undefined ? {} : { progress: context.progress }),
signal: execution.signal,
}, async () => (component as (props: unknown) => Promise<unknown>)({
input: parsed,
signal: execution.signal,
}));
value = module.resultSchema.parse(result);
return value;
},
name: manifest.plugin.name,
version: manifest.plugin.version,
...(options.signal === undefined ? {} : { signal: options.signal }),
writeErr: (text) => { err += text; },
writeOut: (text) => { out += text; },
});
const renderedModules = new Map<string, AgentRouteModule>();
for (const command of renderedCommands) {
renderedModules.set(command.routeId, await moduleFor(manifest, command.routeId, provenance));
}
const firstRendered = renderedCommands[0];
const firstDescriptor = firstRendered === undefined ? undefined : manifest.routes[firstRendered.routeId];
const renderHost = firstRendered === undefined
? undefined
: await prepareCliRenderHost({
context,
manifest,
modules: renderedModules,
onValidated: (validated) => { value = validated; },
provenance: {
kind: 'cli',
manifestDigest: manifest.digest,
...(firstDescriptor === undefined
? {}
: {
modulePath: firstDescriptor.source,
relativePath: firstDescriptor.relativePath,
Comment on lines +183 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive provenance from the executed rendered command

In a project with multiple rendered commands, this host permanently takes modulePath and relativePath from the first rendered command. When a later command has an invalid default export or missing schema, prepareCliRenderHost replaces only routeId, so the resulting diagnostic names the invoked route but points to the first route's source file; construct provenance from the current command's manifest descriptor instead.

Useful? React with 👍 / 👎.

}),
projectRoot: manifest.projectRoot,
proofLevel: CLI_DISPATCH_PROOF_LEVEL,
routeId: firstRendered.routeId,
source: 'manifest',
targets: manifest.targets,
},
signal,
});

let exitCode: number;
try {
exitCode = await runGeneratedCliEntry({
argv,
commands: manifest.cliCommands,
execute: async (command, input, execution) => {
executed = command;
const module = await moduleFor(manifest, command.routeId, provenance);
const component = (module as { default?: unknown }).default;
if (typeof component !== 'function') {
throw new AgentTestError('invalid-route-module', `Command route ${command.routeId} must default-export an async function.`, {
details: [`received: default export of type ${typeof component}`],
recovery: 'Export the command function as the module default.',
});
}
if (module.inputSchema === undefined || module.resultSchema === undefined) {
throw new AgentTestError('invalid-route-module', `Command route ${command.routeId} must export inputSchema and resultSchema.`, {
recovery: 'Export both zod schemas from the command module; the routed CLI validates argv through them.',
});
}
let parsed: unknown;
try {
parsed = module.inputSchema.parse(input);
} catch (error) {
throw new CliInputError(error instanceof Error ? error.message : String(error));
}
const root = process.cwd();
const result = await runtime.runAgentRequest({
capabilities: {
command: runtime.unavailable(),
filesystem: runtime.unavailable(),
network: runtime.unavailable(),
projectRoot: runtime.available({ root }, 'derived'),
},
host: runtime.unavailable('unsupported-surface'),
workspace: runtime.available({ root }, 'derived'),
...context,
invocation: {
kind: 'cli',
operationId: command.routeId,
surface: commandPath(command),
...context.invocation,
},
...(context.progress === undefined ? {} : { progress: context.progress }),
signal: execution.signal,
}, async () => (component as (props: unknown) => Promise<unknown>)({
input: parsed,
signal: execution.signal,
}));
value = module.resultSchema.parse(result);
return value;
},
isTty: () => options.tty === true,
name: manifest.plugin.name,
...(renderHost === undefined
? {}
: {
render: (command, input, execution) => {
executed = command;
return renderHost.render(command, input, execution);
},
}),
signal,
version: manifest.plugin.version,
writeErr: (text) => { err += text; },
writeOut: (text) => { out += text; },
});
} finally {
await renderHost?.close();
}

return Object.freeze({
argv: Object.freeze([...argv]),
Expand Down Expand Up @@ -248,3 +297,53 @@ export const cliJson = (invocation: CliInvocation): unknown => {
});
}
};

/** The ordered render events a successful `--ndjson` invocation wrote to stdout. */
export const cliNdjson = (invocation: CliInvocation): readonly CliRenderedEvent[] => {
try {
const lines = invocation.stdout.endsWith('\n')
? invocation.stdout.slice(0, -1).split('\n')
: invocation.stdout.split('\n');
if (lines.length === 0 || lines.some((line) => line.trim() === '')) {
throw new SyntaxError('NDJSON output must contain one non-empty JSON object per line.');
}
return Object.freeze(lines.map((line) => {
const event = JSON.parse(line) as unknown;
if (typeof event !== 'object' || event === null || Array.isArray(event)) {
throw new SyntaxError('NDJSON output lines must be JSON objects.');
}
const record = event as Record<string, unknown>;
if (!Number.isInteger(record['sequence'])) {
throw new SyntaxError('NDJSON render events must carry an integer sequence.');
}
switch (record['type']) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate NDJSON event payloads before casting

When stdout contains an object such as {"sequence":0,"type":"complete"}—for example from a plain command or a truncated stream—this switch accepts it and casts it to CliRenderedEvent even though the required document is absent. Consumers can then trust the exported union type and fail while accessing its fields; validate the required payload for each discriminator before returning the events.

Useful? React with 👍 / 👎.

case 'shell':
case 'progress':
case 'replace':
case 'error':
case 'complete':
break;
default:
throw new SyntaxError('NDJSON output contains an unknown render-event type.');
}
return event as CliRenderedEvent;
}));
} catch (error) {
throw new AgentTestError('projection-failed', 'The dispatched command did not write one JSON object per line to stdout.', {
cause: error,
details: [
`exit code: ${String(invocation.exitCode)}`,
`stdout: ${captured(invocation.stdout)}`,
...(invocation.stderr === '' ? [] : [`stderr: ${captured(invocation.stderr)}`]),
],
provenance: {
...invocation.provenance,
kind: 'cli',
routeId: invocation.routeId ?? '(no command executed)',
source: 'manifest',
targets: [],
},
recovery: 'Call cliNdjson() only for a rendered invocation that passed --ndjson and wrote a complete event stream.',
});
}
};
Loading
Loading