diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index a0e52fa86..8cdc4fb16 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -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 diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 39e2fdf21..be90032cf 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -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 `[] `. 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: }], 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 `` would produce — layout `_meta` kept, `structuredContent` from the route's `Agent.Result value`, `isError: true`, and a `[boundary] ` 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 diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx new file mode 100644 index 000000000..3398dc3dc --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/fault.tsx @@ -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((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 }) { + if (input.mode === 'throw') throw new Error('fault: route threw'); + return ( + + {`fault: ${input.mode}`} + {input.mode === 'reject-boundary' + ? ( + }> + + + ) + : null} + + ); +} diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index e7a36ace4..d69cca860 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -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); @@ -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); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 24dd828a6..ad2cf0bad 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -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]!], }; @@ -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() }); @@ -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 }); } diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 25ecf40b1..fbcd8cafa 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -120,6 +120,7 @@ it('serves compiled routes and durable state across packed process restarts', as 'catalog', 'context', 'echo', + 'fault', 'journal', 'layout-probe', 'lifecycle', diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-rendered.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-rendered.test.ts index de8dbb37c..17d52c76f 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-rendered.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-rendered.test.ts @@ -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']); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 714007fa9..1b3bbdffc 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -30,6 +30,7 @@ describe('the CLI dispatch level', () => { 'harness catalog', 'harness context', 'harness echo', + 'harness fault', 'harness journal', 'harness layout-probe', 'harness lifecycle', diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 41b8fd4e1..9d6be60ec 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -33,7 +33,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); + expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'fault', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -44,6 +44,7 @@ describe('the in-memory MCP projection level', () => { 'tool:harness/catalog', 'tool:harness/context', 'tool:harness/echo', + 'tool:harness/fault', 'tool:harness/journal', 'tool:harness/layout-probe', 'tool:harness/lifecycle', @@ -152,6 +153,45 @@ describe('the in-memory MCP projection level', () => { expect(invocation.structuredContent).toEqual({ available: false }); }); + describe('a tool route that throws instead of rendering Agent.Error (#492)', () => { + it('reaches the wire as the SDK default tool error: message text, isError, no _meta, no structuredContent', async () => { + await using session = await openInMemoryMcpServer(); + + // No document exists, so nothing agent-bundle projects — layout `_meta`, + // `structuredContent`, a `[code]` prefix — can be present. The result is + // exactly what @modelcontextprotocol/server's `createToolError` builds + // from the thrown error's message. + const result = await session.client.callTool({ arguments: { mode: 'throw' }, name: 'fault' }); + expect(result).toEqual({ + content: [{ text: 'fault: route threw', type: 'text' }], + isError: true, + }); + expect(result).not.toHaveProperty('_meta'); + expect(result).not.toHaveProperty('structuredContent'); + + // The session is still usable: the failure was a result, not a transport error. + const ok = await session.client.callTool({ arguments: { mode: 'ok' }, name: 'fault' }); + expect(ok).toMatchObject({ structuredContent: { mode: 'ok', settled: true } }); + expect(ok).not.toHaveProperty('isError'); + }); + + it('keeps the layout shell when only a nested Suspense boundary rejects: represented error with code "boundary"', async () => { + const invocation = await invokeMcpTool('fault', { input: { mode: 'reject-boundary' } }); + + // The reconciler folds the rejected boundary into the streamed document + // as an error node, so this is the same wire shape `` would produce: layout `_meta` survives, the route's + // value is still `structuredContent`, and the text carries the code. + expect(invocation.isError).toBe(true); + expect(invocation._meta).toMatchObject({ layout: 'harness', route: 'tool:harness/fault', server: 'mcp:harness' }); + expect(invocation.content).toEqual([ + { text: 'fault: reject-boundary', type: 'text' }, + { text: '[boundary] fault: boundary rejected', type: 'text' }, + ]); + expect(invocation.structuredContent).toEqual({ mode: 'reject-boundary', settled: true }); + }); + }); + it('resolves a suspended boundary before the server projects the result', async () => { const invocation = await invokeMcpTool('catalog', { input: { genre: 'mystery' } }); diff --git a/packages/agent-bundle/tests/route-unit/thrown-route-error.test.ts b/packages/agent-bundle/tests/route-unit/thrown-route-error.test.ts new file mode 100644 index 000000000..8258e7226 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/thrown-route-error.test.ts @@ -0,0 +1,128 @@ +import { Agent } from '@agent-bundle/runtime'; +import { describe, expect, it } from '@rstest/core'; +import { createElement, Suspense } from 'react'; + +import { createCanonicalEventProps, projectEventDocument } from '../../src/events/project.ts'; +import { AgentTestError } from '../../src/test/errors.ts'; +import { expectDocument } from '../../src/test/matchers.ts'; +import { renderRoute, renderRouteEvents } from '../../src/test/render.ts'; + +const rejection = async (render: Promise): Promise => { + try { + await render; + } catch (thrown: unknown) { + return thrown as AgentTestError; + } + throw new Error('The render resolved, so no harness diagnostic was produced.'); +}; + +/** + * Pins what a thrown — not represented — route error is at the route-unit + * level (#492). `Agent.Error` is the supported error path; these tests exist + * so the thrown paths are decisions rather than omissions. + */ +describe('a route whose default export throws', () => { + it('fails the render with no document, so nothing downstream can project it', async () => { + const error = await rejection(renderRoute('tool:harness/fault', { input: { mode: 'throw' } })); + + expect(error).toBeInstanceOf(AgentTestError); + expect(error.code).toBe('render-failed'); + expect(error.message).toContain('cause: Error: fault: route threw'); + expect(error.message).toContain('route: tool:harness/fault (tool)'); + }); + + it('produces no render events either: the layout chain never ran, so there is no shell to stream', async () => { + const error = await rejection(renderRouteEvents('tool:harness/fault', { input: { mode: 'throw' } })); + + expect(error.code).toBe('render-failed'); + expect(error.message).toContain('fault: route threw'); + }); +}); + +describe('a route whose nested Suspense boundary rejects', () => { + it('completes as a represented error with code "boundary" inside the surviving layout shell', async () => { + const rendered = await renderRoute('tool:harness/fault', { input: { mode: 'reject-boundary' } }); + + // The reconciler folds the rejected boundary into the document as an error + // node, so the outcome is the represented-error shape a route would get + // from rendering itself. + expectDocument(rendered) + .toHaveStatus('represented-error') + .toContainText('fault: reject-boundary') + .toHaveError('boundary'); + expect(rendered.document.root.kind).toBe('result'); + if (rendered.document.root.kind !== 'result') throw new Error('unreachable'); + expect(rendered.document.root.children).toContainEqual({ + code: 'boundary', + kind: 'error', + message: 'fault: boundary rejected', + }); + // The layouts composed before the boundary settled keep their metadata, + // and the route's own result value is still the document value. + expect(rendered.document.root.metadata).toMatchObject({ layout: 'harness', route: 'tool:harness/fault' }); + expect(rendered.result).toEqual({ mode: 'reject-boundary', settled: true }); + }); + + it('streams shell → error(boundaryId) → complete rather than failing the render', async () => { + const rendered = await renderRouteEvents('tool:harness/fault', { input: { mode: 'reject-boundary' } }); + + expect(rendered.events.map((event) => event.type)).toEqual(['shell', 'error', 'complete']); + const errorEvent = rendered.events[1]; + if (errorEvent?.type !== 'error') throw new Error('expected the second event to be the boundary error'); + expect(errorEvent.boundaryId).toBeDefined(); + expect(errorEvent.error).toEqual({ code: 'boundary', message: 'fault: boundary rejected' }); + expect(rendered.document.status).toBe('represented-error'); + }); +}); + +describe('an event route that throws', () => { + const props = () => createCanonicalEventProps( + 'tool/after', + { hook_event_name: 'PostToolUse', tool_name: 'Write' }, + 'claude', + 'PostToolUse', + '2.1.250', + new AbortController().signal, + ); + + it('fails the render before any hook output exists — the wrapper then exits 1 with the message on stderr', async () => { + const { canonical, native } = props(); + const error = await rejection(renderRoute({ + default: async () => { + throw new Error('event route exploded'); + }, + }, { input: { canonical, native }, kind: 'event-route', routeId: 'event:tool/after' })); + + expect(error.code).toBe('render-failed'); + expect(error.message).toContain('event route exploded'); + // There is no document, so `projectEventDocument` is never reached: the + // generated wrapper writes the message to stderr, nothing to stdout, and + // exits 1 (see hooks.test.ts and generated-route-server.test.ts). + }); + + it('projects a rejected boundary as if the error node were absent: only context and value reach the host', async () => { + const { canonical, native } = props(); + const Rejecting = async () => { + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + throw new Error('event boundary rejected'); + }; + const rendered = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'kept context'), + createElement(Suspense, { fallback: createElement(Agent.Context, null, 'loading') }, createElement(Rejecting)), + ), + }, { input: { canonical, native }, kind: 'event-route', routeId: 'event:tool/after' }); + + expect(rendered.document.status).toBe('represented-error'); + // The hook projection reads Agent.Context and the result value only; a + // represented or boundary error node contributes nothing to the host + // output, so the host sees a normal pass-through with the surviving context. + expect(projectEventDocument(rendered.document, 'tool/after', 'claude', 'PostToolUse')).toEqual({ + hookSpecificOutput: { additionalContext: 'kept context', hookEventName: 'PostToolUse' }, + }); + }); +}); diff --git a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts index 0c0d36adb..cda7d6af6 100644 --- a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts +++ b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts @@ -87,6 +87,7 @@ export const routeHarnessContractFixtures = (): Record { 'tool:harness/catalog', 'tool:harness/context', 'tool:harness/echo', + 'tool:harness/fault', 'tool:harness/journal', 'tool:harness/layout-probe', 'tool:harness/lifecycle', @@ -321,6 +322,7 @@ describe('the compiled test manifest', () => { projected('catalog', 'Streams the harness catalog behind one Suspense boundary.', true), projected('context', 'Returns the request identity axes observed by this route.', true), projected('echo', 'Echoes one message back with the observed workspace root.', false), + projected('fault', 'Throws from the route or from a nested Suspense boundary, for thrown-error projection proof.', false), projected('journal', 'Records and reads durable route-harness journal entries.', true), projected('layout-probe', 'Renders a bare valued result so the layout chain around it is observable.', false), projected('lifecycle', 'Replays a deterministic durable lifecycle through mounted state.', true), diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 8f51d6290..bab756c3d 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -219,6 +219,15 @@ Event routes reach the twenty canonical families (`session/end`, `prompt/submit` `compact/before`, `permission/request`, …); config-declared `hooks` cover only the seven listed above. +A route or handler that **throws** produces no document and therefore no decision: the generated +wrapper writes to stderr, nothing to stdout, and exits `1`, which every supported host documents as +a non-blocking error — the pending action proceeds as it would after a pass-through. The stderr +text is the thrown message for a `runtime: 'standalone'` route or a config-declared handler; a route +in the default shared runtime reports `Event route rendering failed.`, because the runtime answers +the wrapper with a generic `runtime-failed` error and keeps the original message in its own process. +A thrown `tool/before` does not deny; return `outcome: 'deny'` for that. The per-surface table is +in [What happens when a route throws](./mcp.mdx#what-happens-when-a-route-throws). + ### What is on the wire Both shapes share the emitted `hooks/hooks.json` wiring, and both compile into a wrapper the host diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index d5099c215..9a6d1a481 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -127,9 +127,10 @@ compile-time identity (`id`, `kind`, `name`, `serverId` for MCP kinds), `signal` signal, and `await agent()` works inside a layout exactly as in a route. The route's element resolves **before** the layout chain renders, so a throwing route still fails -the whole render (CLI exit `1`, MCP transport failure) rather than being downgraded to a boundary -error beneath the layout's shell; the trade-off is that a layout cannot stream a `Suspense` -fallback around `children`. A layout whose default export is not a function, or that exports the +the whole render (CLI exit `1`; on MCP the SDK's default `isError` result or a JSON-RPC error with +no layout `_meta` — see [What happens when a route throws](#what-happens-when-a-route-throws)) +rather than being downgraded to a boundary error beneath the layout's shell; the trade-off is that +a layout cannot stream a `Suspense` fallback around `children`. A layout whose default export is not a function, or that exports the route-only `config`/`inputSchema`/`resultSchema`, is `AB4830`; `.ts` and `.tsx` siblings for one scope are `AB4831`; a server layout whose server declares no tool, resource, or prompt routes is `AB4832`, while a server pinned to `custom`, `command`, or `remote` skips its layout entirely. The @@ -137,6 +138,43 @@ route-unit and projection test levels compose the same chain, so `renderRoute('t `invokeMcpTool(...)` prove the composed document; a module passed directly to `renderRoute()` composes no layout. +## 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] message` text all survive, and the wire +carries `isError: true`: + +```tsx +export default async function Lookup({ input }: ToolRouteProps) { + const hit = await catalog.find(input.id); + if (hit === undefined) { + return ( + + {`No entry is named ${input.id}.`} + + ); + } + // … +} +``` + +A route that **throws** instead is not projected by agent-bundle at all — there is no document to +project — and each surface's own default applies. The layout shell never wraps it. Per surface: + +| Surface | The route's default export throws | A nested `Suspense` boundary rejects | +| --- | --- | --- | +| MCP `tools/call` | The MCP SDK's default tool error: `{ content: [{ type: 'text', text: }], isError: true }` — no `_meta`, no `structuredContent`, no `[code]` prefix. The session stays usable. | A represented error: the reconciler folds the rejected boundary into the document as an error node with code `boundary`, so the result is what `` would produce — layout `_meta` kept, `structuredContent` from the route's value, `isError: true`, and a `[boundary] ` text block beside 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 generated server returns the route's `resultSchema`-parsed value from the same represented document. | +| Rendered CLI command / rendered script | The message on stderr, exit `1`; nothing on stdout. | `[boundary] message` on stderr as the event happens; then the document prints as usual — Markdown with `**[boundary]** message`, or under `--json` the route's value alone (the boundary message is not in the JSON; `--ndjson` carries the `error` event). Exit `1`, as for any non-`success` document. | +| Plain CLI command / plain script | Routed command: message on stderr, exit `1` (usage and input errors exit `2`). Plain script: Node's top-level failure path — stack on stderr, exit `1`. | n/a | +| Event route (hook) | The generated wrapper writes to stderr, nothing to stdout, and exits `1`. A `runtime: 'standalone'` route or a config-declared handler writes the thrown message; a route in the default **shared** runtime writes `Event route rendering failed.` — the runtime answers with the generic `runtime-failed` error and the original message stays inside the runtime process. Every supported host documents exit `1` as a **non-blocking** error, so the pending action proceeds exactly as a pass-through would — a thrown `tool/before` does **not** deny. Return `outcome: 'deny'` to block. | The hook projection reads `Agent.Context` and the result value only; the error node contributes nothing. | +| `renderRoute` / `renderRouteEvents` | `AgentTestError('render-failed')` naming the route and the cause; no document, no events. | Resolves with `document.status === 'represented-error'`, an `error` node with code `boundary`, and events `shell → error → complete`. | + +The projector deliberately 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. A route that wants a code, a shell, +or structured content on failure renders `Agent.Error`; a route that wants a nested failure to +keep the rest of the document places it behind `Suspense`. + ## Handwritten stdio entries A server declared in config with no `entry`, `command`, or `url` picks up the conventional diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 2e142374e..6d9c0f58d 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -198,6 +198,14 @@ export default async function AfterFileEdit({ canonical, native, signal }: Agent 事件路由可以触达全部二十个规范事件族(`session/end`、`prompt/submit`、`compact/before`、 `permission/request`……);配置声明的 `hooks` 只覆盖上面列出的七个。 +**抛出**异常的路由或处理器不会产生文档,因此也没有决定:生成的包装器写到 stderr,stdout 无输出, +退出码为 `1`——所有受支持的宿主都把它记作非阻塞错误,待执行的动作会像 pass-through 之后一样继续进行。 +对于 `runtime: 'standalone'` 的路由或配置声明的处理器,stderr 文本是抛出的消息;默认共享运行时中的路由 +报告 `Event route rendering failed.`,因为运行时以通用的 `runtime-failed` 错误应答包装器,并把原始消息 +留在自己的进程中。 +抛错的 `tool/before` 不会拒绝;要拒绝请返回 `outcome: 'deny'`。按表面分列的表格见 +[路由抛错时会发生什么](./mcp.mdx#路由抛错时会发生什么)。 + ### 线上到底传了什么 两种形态共享输出的 `hooks/hooks.json` 接线,并且都编译成宿主以 diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index efa369ffc..fa40e8430 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -114,14 +114,51 @@ export default function Layout({ children, route }: AgentLayoutProps) { `kind`、`name`,MCP 类型还有 `serverId`),`signal` 是请求中止信号,`await agent()` 在布局中的用法与在 路由中完全一致。 -路由的元素在布局链渲染**之前**解析,因此抛错的路由仍然让整次渲染失败(CLI 退出码 `1`、MCP 传输失败), -而不是被降级为布局外壳之下的 boundary 错误;代价是布局无法在 `children` 周围流式输出 `Suspense` 回退。 +路由的元素在布局链渲染**之前**解析,因此抛错的路由仍然让整次渲染失败(CLI 退出码 `1`;在 MCP 上是 SDK +默认的 `isError` 结果或一个 JSON-RPC 错误,且没有布局 `_meta`——见 +[路由抛错时会发生什么](#路由抛错时会发生什么)),而不是被降级为布局外壳之下的 boundary 错误;代价是 +布局无法在 `children` 周围流式输出 `Suspense` 回退。 默认导出不是函数、或导出了仅属于路由的 `config`/`inputSchema`/`resultSchema` 的布局是 `AB4830`;同一作用域 下 `.ts` 与 `.tsx` 并存是 `AB4831`;其服务器没有声明任何工具、资源或提示路由的服务器布局是 `AB4832`,而固定为 `custom`、`command` 或 `remote` 的服务器会完全跳过其布局。route-unit 与 projection 两个测试层级组合同一条 布局链,因此 `renderRoute('tool:...')` 与 `invokeMcpTool(...)` 证明的是组合后的文档;直接传给 `renderRoute()` 的模块不会组合任何布局。 +## 路由抛错时会发生什么 + +`Agent.Error` 是受支持的错误路径。错误是文档内部的数据,因此布局外壳、`_meta`、`structuredContent` 与 +`[code] message` 文本都得以保留,线上结果携带 `isError: true`: + +```tsx +export default async function Lookup({ input }: ToolRouteProps) { + const hit = await catalog.find(input.id); + if (hit === undefined) { + return ( + + {`No entry is named ${input.id}.`} + + ); + } + // … +} +``` + +改为**抛出**异常的路由完全不会被 agent-bundle 投影——没有文档可投影——各个表面自己的默认行为生效。布局外壳 +永远不会包裹它。按表面分列: + +| 表面 | 路由的默认导出抛错 | 嵌套的 `Suspense` boundary 被拒绝 | +| --- | --- | --- | +| MCP `tools/call` | MCP SDK 的默认工具错误:`{ content: [{ type: 'text', text: }], isError: true }`——没有 `_meta`、没有 `structuredContent`、没有 `[code]` 前缀。会话仍然可用。 | 一个已表示的错误:reconciler 把被拒绝的 boundary 折叠进文档,成为 code 为 `boundary` 的错误节点,因此结果与 `` 所产生的一致——保留布局 `_meta`,`structuredContent` 来自路由的值,`isError: true`,并在已渲染的内容旁多一个 `[boundary] ` 文本块。 | +| MCP `prompts/get`、`resources/read` | 一个携带该消息的 JSON-RPC 错误响应;客户端调用被拒绝。这些表面没有 `isError` 通道。 | 生成的服务器从同一份已表示文档中返回路由经 `resultSchema` 解析后的值。 | +| 渲染式 CLI 命令 / 渲染式脚本 | 消息写到 stderr,退出码 `1`;stdout 无输出。 | 事件发生时 `[boundary] message` 写到 stderr;随后文档照常打印——Markdown 带有 `**[boundary]** message`,而在 `--json` 下只输出路由的值(boundary 消息不在 JSON 中;`--ndjson` 携带 `error` 事件本身)。退出码 `1`,与任何非 `success` 文档相同。 | +| 普通 CLI 命令 / 普通脚本 | 路由式命令:消息写到 stderr,退出码 `1`(用法与输入错误退出码为 `2`)。普通脚本:走 Node 的顶层失败路径——堆栈写到 stderr,退出码 `1`。 | 不适用 | +| 事件路由(钩子) | 生成的包装器写到 stderr,stdout 无输出,退出码 `1`。`runtime: 'standalone'` 的路由或配置声明的处理器写出抛出的消息;默认**共享**运行时中的路由写出 `Event route rendering failed.`——运行时以通用的 `runtime-failed` 错误应答,原始消息留在运行时进程内部。所有受支持的宿主都把退出码 `1` 记作**非阻塞**错误,因此待执行的动作会像 pass-through 一样继续进行——抛错的 `tool/before` **不会**拒绝。要阻止,请返回 `outcome: 'deny'`。 | 钩子投影只读取 `Agent.Context` 与结果值;错误节点不贡献任何内容。 | +| `renderRoute` / `renderRouteEvents` | `AgentTestError('render-failed')`,指明路由与原因;没有文档,也没有事件。 | 正常返回:`document.status === 'represented-error'`,一个 code 为 `boundary` 的 `error` 节点,事件序列为 `shell → error → complete`。 | + +投影器有意不把根级抛错包装成 `Agent.Error` 的形状:布局外壳是文档的属性,而根级抛错没有文档。希望在失败时 +带有 code、外壳或结构化内容的路由应渲染 `Agent.Error`;希望嵌套失败不影响文档其余部分的路由应把它放到 +`Suspense` 之后。 + ## 手写 stdio 入口 在配置中声明、但未指定 `entry`、`command` 或 `url` 的服务器,会识别约定的 `src/mcp/.ts`