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
7 changes: 5 additions & 2 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,11 @@ document contract under a layout exactly as it does without one.
The generated worker resolves the route's element **before** the layout chain
renders, then wraps it. That keeps failure semantics identical with and
without a layout — a route that throws rejects the whole render (CLI exit 1
with the route's message, MCP transport failure) instead of being downgraded
to a represented `boundary` error beneath the layout's shell. The trade-off is
with the route's message; on MCP the SDK's default `isError` tool result or a
JSON-RPC error, with no layout `_meta` — see
[What happens when a route throws](framework-mode.md#what-happens-when-a-route-throws))
instead of being downgraded to a represented `boundary` error beneath the
layout's shell. The trade-off is
deliberate: a layout cannot stream a `Suspense` fallback around `children`
while the route is still running, because the route is never a lazily
resolved Flight chunk under the layout. A `Suspense` boundary a layout places
Expand Down
30 changes: 30 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,36 @@ The final Agent Document of a tool route lowers to one `CallToolResult`:
| `Agent.Error code message` | `isError: true` plus one text block `[<code>] <message>`. The wire has no error-code field, so the code is deliberately kept in the text (the routed CLI prints the same `**[code]** message` form); choose codes that read well to the model. |
| `resultSchema` | `outputSchema` in `tools/list` **only when the schema describes an object** (`z.object`, `z.record`, a discriminated union of objects). The MCP specification requires every result of a tool that declares `outputSchema` to carry `structuredContent`, so a text-only route declares `resultSchema = z.undefined()` (or any non-object schema), advertises no `outputSchema`, and returns no `structuredContent`. An object schema keeps the SDK's fail-closed output validation on every call. |

### What happens when a route throws

`Agent.Error` is the supported error path: the error is data inside the
document, so the layout shell, `_meta`, `structuredContent`, and the `[code]`
text all survive. A route that **throws** instead (or rejects, before any
document exists) is not projected by agent-bundle at all — there is no document
to project — and each surface's own default applies. These are decisions, pinned
by `tests/route-unit/thrown-route-error.test.ts`, `tests/projection/mcp-in-memory.test.ts`,
`tests/projection/cli-dispatch-rendered.test.ts`, `tests/generated-route-server.test.ts`,
`tests/hooks.test.ts`, and `tests/event-ipc.test.ts` (#492):

| Surface | A route whose default export throws | A nested `Suspense` boundary that rejects |
| --- | --- | --- |
| MCP `tools/call` | The MCP SDK's default tool error: `{ content: [{ type: 'text', text: <error.message> }], isError: true }`. No `_meta` (the layout never rendered), no `structuredContent`, no `[code]` prefix. The session stays usable. | A represented error: the reconciler folds the rejected boundary into the streamed document as an error node with code `boundary`, so the result is exactly what `<Agent.Error code="boundary">` would produce — layout `_meta` kept, `structuredContent` from the route's `Agent.Result value`, `isError: true`, and a `[boundary] <message>` text block after the content that had already rendered. |
| MCP `prompts/get`, `resources/read` | A JSON-RPC error response carrying the message; the client call rejects. These surfaces have no `isError` channel. | The same represented document; the generated server returns the route's `resultSchema`-parsed value, so the prompt or resource result is whatever the route's value said. |
| Rendered CLI command / rendered script | The message on stderr, exit 1. Nothing on stdout: no Markdown, no `--json` value. | The `error` render event is written to stderr as `[boundary] message` as it happens; the final document then prints as usual — Markdown (TTY or piped) with `**[boundary]** message` beside the content that had rendered, or under `--json` the route's value alone, with the boundary message **not** in the JSON. `--ndjson` carries the `error` event itself. Exit 1, because any non-`success` document status exits 1 regardless of the command's `exitCode` policy. |
| Plain CLI command / plain script | Routed command: the message on stderr, exit 1 (only usage and input errors exit 2). Plain script: the rejection escapes through Node's top-level failure path — stack on stderr, exit 1. | n/a (no renderer). |
| Event route (hook) | The generated wrapper writes to stderr, nothing to stdout, and exits 1. What stderr says depends on the runtime mode: a `runtime: 'standalone'` route and a config-declared handler write the thrown message; a route in the default **shared** runtime writes `Event route rendering failed.` — the shared runtime answers the wrapper with the generic `runtime-failed` error (`events/ipc.ts`) and the original message never leaves the runtime process. Every supported host documents exit 1 as a **non-blocking** error (Claude Code and Codex show the stderr; Cursor treats it as fail-open), so the pending action proceeds exactly as a pass-through would — a thrown `tool/before` does **not** deny. | The hook projection reads `Agent.Context` and the result value only; the error node contributes nothing, so the host receives the surviving context and decision as a normal response. |
| `renderRoute` / `renderRouteEvents` (route-unit) | `AgentTestError('render-failed')` naming the route and the cause; no document, no events. | Resolves: `document.status === 'represented-error'`, an `error` node with code `boundary`, events `shell → error(boundaryId) → complete`. |

Why the projector does not wrap a root throw into the `Agent.Error` shape: the
layout shell is a property of a document, and a root throw has none — giving it
`_meta` would mean rendering the layout around a synthetic child, which is the
layout-level `error` prop the #492 discussion reserves for a consumer who asks
for it. A `[code]` prefix would also create a third error shape beside the
SDK default (which every other handler failure — a `resultSchema` rejection, an
`McpProjectionError` — already uses) and the represented one. Both surveyed real
apps keep failures as data for exactly this reason; a route that wants a
code, a shell, or structured content renders `Agent.Error`.

Everything else is power-tier reference: custom/remote server modes and
collision recovery are in [Entry conventions](entry-conventions.md); accepted
static metadata, generated `.agent-bundle/routes.d.ts`, and diagnostics are in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Agent } from '@agent-bundle/runtime';
import { Suspense } from 'react';
import { z } from 'zod';

export const config = {
annotations: { readOnlyHint: true },
description: 'Throws from the route or from a nested Suspense boundary, for thrown-error projection proof.',
};

export const inputSchema = z.object({
mode: z.enum(['ok', 'throw', 'reject-boundary']).default('ok'),
});

export const resultSchema = z.object({ mode: z.string(), settled: z.literal(true) });

/** Rejects after the shell has streamed, so the failure is a settled boundary rather than the root. */
const Rejecting = async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1);
});
throw new Error('fault: boundary rejected');
};

/**
* The route-harness fixture for #492: a thrown (not represented) error. `throw`
* rejects the route's own default export before any document exists;
* `reject-boundary` streams a shell whose nested Suspense child rejects.
* Neither renders `Agent.Error` — that represented path is `unavailable.tsx`.
*/
export default async function Fault({ input }: { readonly input: z.infer<typeof inputSchema> }) {
if (input.mode === 'throw') throw new Error('fault: route threw');
return (
<Agent.Result value={{ mode: input.mode, settled: true }}>
<Agent.Text>{`fault: ${input.mode}`}</Agent.Text>
{input.mode === 'reject-boundary'
? (
<Suspense fallback={<Agent.Progress completed={0} message="faulting" />}>
<Rejecting />
</Suspense>
)
: null}
</Agent.Result>
);
}
128 changes: 128 additions & 0 deletions packages/agent-bundle/tests/generated-route-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,78 @@ it('emits MCP progress notifications only when a progress token is supplied', {
}
});

/**
* #492: what a thrown (not represented) route error is on each MCP surface of
* a real generated stdio server. A tool throw is the SDK's default tool error;
* a prompt or resource throw is a JSON-RPC error the client rejects with. The
* layout shell never wraps either, because no document exists.
*/
it('projects thrown route errors as the SDK tool error or a JSON-RPC error, never as a layout-wrapped document', { retry: 2, timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-thrown-'));
roots.push(root);
const throwing = (kind: string) => [
"import { z } from 'zod';",
`export const config = ${kind === 'resource' ? "{ mimeType: 'text/plain', uri: 'curator://broken' }" : "{ description: 'Throws.' }"};`,
`export const inputSchema = ${kind === 'resource' ? 'z.object({ uri: z.string() })' : 'z.object({}).strict()'};`,
'export const resultSchema = z.object({}).passthrough();',
`export default async function Broken() { throw new Error('${kind} route threw'); }`,
'',
].join('\n');
await writeGeneratedProject(root, {
// A layout that would stamp `_meta` on every document, to show it is absent when the route throws.
'src/layout.tsx': [
"import { Agent } from '@agent-bundle/runtime';",
"import { createElement } from 'react';",
"export default function Layout({ children, route }) { return createElement(Agent.Result, { metadata: { route: route.id } }, children); }",
'',
].join('\n'),
'src/mcp/curator/prompts/broken.ts': throwing('prompt'),
'src/mcp/curator/resources/broken.ts': throwing('resource'),
'src/mcp/curator/tools/broken.ts': throwing('tool'),
'src/mcp/curator/tools/fine.tsx': [
"import { Agent } from '@agent-bundle/runtime';",
"import { createElement } from 'react';",
"import { z } from 'zod';",
"export const config = { description: 'Renders.' };",
'export const inputSchema = z.object({}).strict();',
'export const resultSchema = z.object({ ok: z.literal(true) }).strict();',
"export default async function Fine() { return createElement(Agent.Result, { value: { ok: true } }, createElement(Agent.Text, null, 'fine')); }",
'',
].join('\n'),
});
const session = await connectGeneratedServer(root);
try {
// The layout is live: a rendering tool carries its `_meta`.
await expect(session.client.callTool({ arguments: {}, name: 'fine' }, { signal: AbortSignal.timeout(10_000) })).resolves.toEqual({
_meta: { route: 'tool:curator/fine' },
content: [{ text: 'fine', type: 'text' }],
structuredContent: { ok: true },
});

// tools/call: @modelcontextprotocol/server `createToolError` — the thrown
// message as one text block plus isError. No `_meta`, no
// `structuredContent`, no `[code]` prefix: agent-bundle projected nothing.
await expect(session.client.callTool({ arguments: {}, name: 'broken' }, { signal: AbortSignal.timeout(10_000) })).resolves.toEqual({
content: [{ text: 'tool route threw', type: 'text' }],
isError: true,
});

// prompts/get and resources/read have no isError channel: the throw is a
// JSON-RPC error response and the client call rejects.
await expect(session.client.getPrompt({ arguments: {}, name: 'broken' }, { signal: AbortSignal.timeout(10_000) }))
.rejects.toMatchObject({ message: expect.stringContaining('prompt route threw') });
await expect(session.client.readResource({ uri: 'curator://broken' }, { signal: AbortSignal.timeout(10_000) }))
.rejects.toMatchObject({ message: expect.stringContaining('resource route threw') });

// The server survived all three: the next call renders normally.
await expect(session.client.callTool({ arguments: {}, name: 'fine' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({
structuredContent: { ok: true },
});
} finally {
await session.close();
}
});

it('maps notifications/cancelled into the renderer AbortSignal', { retry: 2, timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-cancel-'));
roots.push(root);
Expand Down Expand Up @@ -1185,6 +1257,62 @@ it('runs an explicitly standalone event route without a shared runtime', { timeo
expect(response).toEqual({ additional_context: 'standalone:Write' });
});

/**
* #492: a thrown event route never reaches `projectEventDocument`. The
* generated wrapper writes the message to stderr, nothing to stdout, and exits
* 1 — which every supported host documents as a non-blocking error, so the
* pending action proceeds exactly as a pass-through would.
*/
it('exits 1 with the message on stderr and no stdout when a standalone event route throws', { timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-thrown-event-'));
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:*', react: '19.2.8' },
name: 'thrown-event-fixture',
type: 'module',
version: '1.0.0',
})),
writeProjectFile(root, 'agent-bundle.config.ts', [
"import { defineConfig } from 'agent-bundle/config';",
"export default defineConfig({ plugin: { name: 'thrown-event-fixture', version: '1.0.0' }, targets: ['cursor'] });",
'',
].join('\n')),
writeProjectFile(root, 'src/events/tool/before.tsx', [
"export const config = { runtime: 'standalone', targets: ['cursor'] };",
"export default async function BeforeTool() { throw new Error('before-tool route exploded'); }",
'',
].join('\n')),
]);

const compiled = await build({ output: join(root, 'artifact'), root, targets: ['cursor'] });
const hook = compiled.build.compiledHooks.find((entry) => entry.event === 'beforeTool');
expect(hook).toBeDefined();
const run = await new Promise<{ readonly code: number | null; readonly stderr: string; readonly stdout: string }>((resolvePromise, reject) => {
const child = spawn(process.execPath, [hook!.output], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
child.once('error', reject);
child.once('close', (code) => { resolvePromise({ code, stderr, stdout }); });
child.stdin.end(JSON.stringify({
conversation_id: 'conversation-1',
cwd: root,
hook_event_name: 'preToolUse',
session_id: 'session-1',
tool_input: { command: 'ls' },
tool_name: 'Shell',
tool_use_id: 'tool-1',
}));
});

expect(run.code).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain('before-tool route exploded');
});

it('replays Claude and Codex subagent fixtures through standalone event-route wrappers', { timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-events-'));
roots.push(root);
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-bundle/tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@ it('rejects malformed native hook input, exports, and handler results concisely'
{ ...base.hooks[0]!, id: 'hook:session-start:valid:00000001', name: 'valid-00000001', source: join(sourceRoot, 'valid.ts'), targets: ['codex'] },
{ ...base.hooks[0]!, id: 'hook:session-start:export:00000002', name: 'export-00000002', source: join(sourceRoot, 'no-default.ts'), targets: ['codex'] },
{ ...base.hooks[0]!, id: 'hook:session-start:result:00000003', name: 'result-00000003', source: join(sourceRoot, 'bad-result.ts'), targets: ['codex'] },
{ ...base.hooks[0]!, id: 'hook:session-start:throws:00000004', name: 'throws-00000004', source: join(sourceRoot, 'throws.ts'), targets: ['codex'] },
],
targets: [base.targets[0]!],
};
Expand All @@ -1535,6 +1536,7 @@ it('rejects malformed native hook input, exports, and handler results concisely'
writeFile(join(sourceRoot, 'valid.ts'), 'export default () => undefined;\n'),
writeFile(join(sourceRoot, 'no-default.ts'), 'export const value = true;\n'),
writeFile(join(sourceRoot, 'bad-result.ts'), "export default () => 'not a result';\n"),
writeFile(join(sourceRoot, 'throws.ts'), "export default () => { throw new Error('handler exploded'); };\n"),
]);
await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() });

Expand All @@ -1555,6 +1557,16 @@ it('rejects malformed native hook input, exports, and handler results concisely'
stderr: 'Agent Bundle hook error: handler must return void or a result object\n',
stdout: '',
});
// #492: a handler that throws is the same wire outcome as a malformed one —
// the message on stderr, nothing on stdout, exit 1 (a non-blocking error on
// every supported host, so the pending action proceeds).
await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'throws-00000004.mjs'), JSON.stringify({
cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json',
}))).resolves.toEqual({
code: 1,
stderr: 'handler exploded\n',
stdout: '',
});
} finally {
await rm(root, { force: true, recursive: true });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ it('serves compiled routes and durable state across packed process restarts', as
'catalog',
'context',
'echo',
'fault',
'journal',
'layout-probe',
'lifecycle',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ describe('rendered commands at the CLI dispatch level', () => {
expect(run.stdout.endsWith('# Report: books\n\nGenerated for books.\n\nitems: 2\n')).toBe(true);
});

describe('a projected MCP command whose route throws (#492)', () => {
it('reports a root throw on stderr with exit 1 and nothing on stdout', async () => {
const run = await invokeCli(['harness', 'fault', '--input', '{"mode":"throw"}']);

expect(run.exitCode).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toBe('fault: route threw\n');
expect(run.value).toBeUndefined();
});

it('prints a rejected boundary as a represented error in Markdown, on stderr as the event, and never inside the --json value', async () => {
const markdown = await invokeCli(['harness', 'fault', '--input', '{"mode":"reject-boundary"}']);
expect(markdown.exitCode).toBe(1);
expect(markdown.stderr).toBe('[boundary] fault: boundary rejected\n');
expect(markdown.stdout).toBe('fault: reject-boundary\n\n**[boundary]** fault: boundary rejected\n');

const json = await invokeCli(['harness', 'fault', '--input', '{"mode":"reject-boundary"}', '--json']);
expect(json.exitCode).toBe(1);
expect(json.stderr).toBe('[boundary] fault: boundary rejected\n');
// The value is the route's own result; the boundary message is not in it.
expect(cliJson(json)).toEqual({ mode: 'reject-boundary', settled: true });
expect(json.stdout).not.toContain('boundary rejected');
});
});

it('projects a rendered command to one canonical JSON line', async () => {
const run = await invokeCli(['report', 'books', '--json']);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('the CLI dispatch level', () => {
'harness catalog',
'harness context',
'harness echo',
'harness fault',
'harness journal',
'harness layout-probe',
'harness lifecycle',
Expand Down
Loading
Loading