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
6 changes: 6 additions & 0 deletions .changeset/448-progress-fallback-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@agent-bundle/runtime': patch
'agent-bundle': patch
---

Project an `Agent.Progress` node streamed in a `Suspense` fallback (any `shell`/`replace` document) to `notifications/progress` when the MCP request carries `_meta.progressToken`, under the same monotonic `progress` rule as `progress.report()` so a re-streamed fallback or an explicit report of the same step is never duplicated; the rendered CLI's interactive TTY draws its in-place progress line from the same streamed node. A fallback alone is now enough on both surfaces; `announce()`-style shims that repeat the fallback message through `progress.report()` are unnecessary. Fixes #448. (#498)
1 change: 1 addition & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ The final Agent Document of a tool route lowers to one `CallToolResult`:
| `Agent.Image`, `Agent.Audio`, `Agent.Resource` | Native `image`, `audio`, and `resource_link` blocks; a host without that capability fails the projection closed unless a text fallback is selected. |
| `Agent.Result value` | `structuredContent` when the value is a JSON object; a non-object value emits none and is never wrapped. |
| `Agent.Result metadata` | `CallToolResult._meta`. It must be a JSON object (snapshotted through the same wire boundary as `structuredContent`); anything else fails the projection closed with `McpProjectionError('invalid-result-metadata')`. Listing-level `_meta` still comes from static `config._meta`, so the MCP Apps convention stamps `_meta.ui.resourceUri` on both halves. In `config._meta.ui.resourceUri`, reference the App route instead of repeating its `ui://` literal: `appResourceUri('dashboard')` from `agent-bundle/routes` resolves at compile time to that App route's `config.resourceUri`, and a `const` string literal imported from a relative sibling module (`import { DASHBOARD_URI } from '../constants'`) is accepted too and stays available at run time for the result half. |
| `Agent.Progress` | Never a `content` block. Streamed inside a `shell` or `replace` document — normally as a `Suspense` fallback — it projects to one `notifications/progress` (`progress` from `completed`, plus `message` and `total` when present) when the request carried `_meta.progressToken`; a request without a token gets none. The same monotonic rule applies as to `progress.report()`: each notification's `progress` must exceed the last, so a fallback re-streamed on the next chunk, or one an explicit report already announced with the same `completed`, is not repeated. A fallback alone is enough — an `announce()`-style helper that repeats the fallback message through `progress.report()` adds nothing (#448). A progress node in the final document is content only. The rendered CLI's interactive TTY draws its in-place progress line from the same streamed node (redrawn only when the fallback changes); piped Markdown, `--json`, and `--ndjson` never print it. |
| `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. |

Expand Down
15 changes: 8 additions & 7 deletions examples/audiobook-curator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,15 @@ structured shelf and render an explicit unavailable notice.

### Suspense becomes MCP progress

`audit_library` first reports progress through the request's
`context.progress`, then places the asynchronous `LibraryAnalysis` component
behind React `Suspense`. While that component re-stats duplicate candidates and
`audit_library` places the asynchronous `LibraryAnalysis` component behind
React `Suspense`. While that component re-stats duplicate candidates and
calculates reclaimable bytes, its fallback is an `Agent.Progress` document
node. The generated MCP projector streams the progress state and then replaces
it with the completed analysis without changing the final structured
`LibraryAuditReceipt`. The rendered `library-audit` CLI route composes the same
analysis and fallback.
node — and that node is the whole progress story: the generated MCP projector
turns the streamed fallback into `notifications/progress` for a client that
sent a progress token, then replaces it with the completed analysis without
changing the final structured `LibraryAuditReceipt`. No `progress.report()`
call repeats the fallback's message. The rendered `library-audit` CLI route
composes the same analysis and fallback.

### CLI routes have rendered and plain modes

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Agent, agent } from '@agent-bundle/runtime';
import { Agent } from '@agent-bundle/runtime';
import React, { Suspense } from 'react';
import type { ToolRouteProps } from 'agent-bundle';

Expand All @@ -20,12 +20,9 @@ export const resultSchema = operation.resultSchema;

export default async function Route({ input, signal }: ToolRouteProps<typeof inputSchema>) {
const receipt = await operation.handler(input, { signal }) as LibraryAuditReceipt;
const context = await agent();
await context.progress.report({
completed: 0,
message: 'Analyzing duplicate and multipart groups',
total: 1,
});
// The Suspense fallback is the progress surface: the MCP projector turns the
// streamed `Agent.Progress` node into `notifications/progress` for a client
// that sent a progress token, so no `progress.report()` repeats the message.

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 Restore CLI-visible progress for the projected command

Because agent-bundle.config.ts enables routes.mcpCommands, this MCP route is also exposed as the rendered curator audit_library CLI command. runRenderedInvocation in packages/agent-bundle/src/cli-entry.ts ignores shell/replace events and updates the interactive TTY line only for explicit progress events, so deleting this report makes the command appear idle while LibraryAnalysis suspends. Keep the explicit report for this dual-surface route; the new monotonic MCP filter will still deduplicate its matching fallback notification.

AGENTS.md reference: AGENTS.md:L59-L59

Useful? React with 👍 / 👎.

return (
<Agent.Result value={receipt}>
<Agent.Text>{libraryAuditHeadline(receipt)}</Agent.Text>
Expand Down
12 changes: 5 additions & 7 deletions examples/audiobook-curator/tests/route-unit/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ it('streams library analysis after the audit shell while preserving the canonica
summary: { files: 2 },
});

const completeIndex = rendered.events.findIndex((event) => event.type === 'complete');
const progressIndex = rendered.events.findIndex((event) => event.type === 'progress');
const projected = await projectTargetCapabilities(
rendered,
createTargetCapabilityFixture({
Expand All @@ -67,14 +65,14 @@ it('streams library analysis after the audit shell while preserving the canonica
}),
);

expect(progressIndex).toBeGreaterThanOrEqual(0);
expect(progressIndex).toBeLessThan(completeIndex);
expect(projected.progress.length).toBeGreaterThanOrEqual(1);
expect(projected.progress[0]).toMatchObject({
// The route never calls `progress.report()`: the streamed Suspense fallback
// alone is what the MCP projector announces (agent-bundle#448).
expect(rendered.events.some((event) => event.type === 'progress')).toBe(false);
expect(projected.progress).toEqual([{
message: 'Analyzing duplicate and multipart groups',
progress: 0,
progressToken: 'agent-bundle-target-capability-fixture',
});
}]);
expect(projected.structuredContent).toEqual(rendered.document.value);
} finally {
await rm(directory, { force: true, recursive: true });
Expand Down
58 changes: 53 additions & 5 deletions packages/agent-bundle/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,46 @@ export const projectCliDocumentToMarkdown = (document: CliRenderedDocument): str
return blocks.length === 0 ? '' : `${blocks.join('\n\n')}\n`;
};

const progressLine = (event: { readonly completed: number; readonly message?: string; readonly total?: number }): string => {
/** The progress fields a render `progress` event and an `Agent.Progress` document node share. */
interface CliProgressSource {
readonly completed: number;
readonly message?: string;
readonly total?: number;
}

const progressLine = (event: CliProgressSource): string => {
const counter = event.total === undefined ? String(event.completed) : `${String(event.completed)}/${String(event.total)}`;
return event.message === undefined ? counter : `${event.message} (${counter})`;
};

/**
* The `Agent.Progress` nodes of a streamed `shell`/`replace` document, in
* document order — a `Suspense` fallback rendered as `Agent.Progress` is the
* route's progress surface, so the interactive TTY shows it exactly as it
* shows an explicit `progress.report()` (#448).
*/
const progressNodes = (node: CliRenderedDocumentNode): readonly CliProgressSource[] => {
switch (node.kind) {
case 'result':
return node.children.flatMap(progressNodes);
case 'progress':
return [node];
case 'audio':
case 'context':
case 'error':
case 'image':
case 'json':
case 'markdown':
case 'resource':
case 'text':
return [];
default: {
const unreachable: never = node;
throw new TypeError(`Unsupported Agent Document node ${String((unreachable as { kind?: string }).kind)}.`);
}
}
};

const clearProgressLine = '\r\u001B[2K';

interface RenderedRunOptions {
Expand All @@ -509,6 +544,21 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise<numbe
const reader = options.session.events().getReader();
let complete: CliRenderedDocument | undefined;
let progressShown = false;
const showProgress = (source: CliProgressSource): void => {
if (mode !== 'tty') return;
writeOut(`${clearProgressLine}${progressLine(source)}`);
progressShown = true;
};
// A fallback is re-streamed with every chunk that leaves its boundary
// pending; the line is redrawn only when the fallback itself changed. A TTY
// has no monotonic constraint, so an explicit report always redraws.
const shownFallbacks = new Set<string>();
const showFallback = (node: CliProgressSource): void => {
const key = JSON.stringify([node.completed, node.message, node.total]);
if (shownFallbacks.has(key)) return;
shownFallbacks.add(key);
showProgress(node);
};
const clearProgress = (): void => {
if (progressShown) {
writeOut(clearProgressLine);
Expand All @@ -526,12 +576,10 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise<numbe
switch (event.type) {
case 'shell':
case 'replace':
for (const node of progressNodes(event.document.root)) showFallback(node);
break;
case 'progress':
if (mode === 'tty') {
writeOut(`${clearProgressLine}${progressLine(event)}`);
progressShown = true;
}
showProgress(event);
break;
case 'error':
if (mode !== 'ndjson') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ describe('rendered commands at the CLI dispatch level', () => {
expect(run.stdout.endsWith('# Report: books\n\nGenerated for books.\n\nitems: 2\n')).toBe(true);
});

it('shows a streamed Agent.Progress Suspense fallback on the TTY without an explicit report (#448)', async () => {
// The projected `harness catalog` command never calls `progress.report()`;
// its only progress surface is the `<Suspense fallback={<Agent.Progress …/>}>`.
const tty = await invokeCli(['harness', 'catalog', '--input', '{"genre":"mystery"}', '--yes'], { tty: true });

expect(tty.exitCode).toBe(0);
expect(tty.stderr).toBe('');
expect(tty.stdout).toContain('\r\u001B[2Kloading mystery (0/2)');
// Drawn once, although the shell and the replace both carried the node.
expect(tty.stdout.split('loading mystery (0/2)')).toHaveLength(2);
expect(tty.stdout.endsWith('catalog: mystery\n\n## mystery\n\n- Piranesi\n- Solaris\n')).toBe(true);

// Piped output is the final document only: the fallback never prints.
const piped = await invokeCli(['harness', 'catalog', '--input', '{"genre":"mystery"}', '--yes']);
expect(piped.stdout).toBe('catalog: mystery\n\n## mystery\n\n- Piranesi\n- Solaris\n');
expect(piped.stdout).not.toContain('loading mystery');
});

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"}']);
Expand Down
32 changes: 32 additions & 0 deletions packages/agent-bundle/tests/projection/mcp-in-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,38 @@ describe('the in-memory MCP projection level', () => {
expect(invocation.structuredContent).toEqual({ genre: 'mystery', titles: ['Piranesi', 'Solaris'] });
});

it('notifies the client of a streamed Agent.Progress fallback under its own progress token (#448)', async () => {
await using session = await openInMemoryMcpServer();
const notifications: unknown[] = [];
session.client.setNotificationHandler('notifications/progress', (notification) => {
notifications.push(notification.params);
});

// Without a token the same render produces no notification at all.
await session.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' });
expect(notifications).toEqual([]);

// The catalog route never calls `progress.report()`; the request's own
// `_meta.progressToken` is what turns its streamed fallback into the wire
// notification, exactly as for an explicit report.
const result = await session.client.callTool({
arguments: { genre: 'mystery' },
name: 'catalog',
_meta: { progressToken: 'tok-448' },
});

expect(notifications).toEqual([
{ message: 'loading mystery', progress: 0, progressToken: 'tok-448', total: 2 },
]);
expect(result).toMatchObject({
content: [
{ text: 'catalog: mystery', type: 'text' },
{ text: '## mystery\n\n- Piranesi\n- Solaris', type: 'text' },
],
structuredContent: { genre: 'mystery', titles: ['Piranesi', 'Solaris'] },
});
});

it('reads a compiled resource route by its configured URI', async () => {
const read = await readMcpResource('harness://notes');

Expand Down
23 changes: 23 additions & 0 deletions packages/agent-bundle/tests/projection/target-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ describe('route-unit target-capability projection', () => {
expect(projected.structuredContent).toEqual({ fixture: 'target-capabilities' });
});

it('projects an Agent.Progress Suspense fallback streamed in the shell to notifications/progress (#448)', async () => {
// The catalog route reports no progress itself: its only progress surface
// is the `<Suspense fallback={<Agent.Progress …/>}>` the shell streams.
const rendered = await renderRouteEvents('tool:harness/catalog', { input: { genre: 'mystery' } });
expect(rendered.events.some((event) => event.type === 'progress')).toBe(false);

const projected = await projectTargetCapabilities(rendered, fixture());
expect(projected.progress).toEqual([{
message: 'loading mystery',
progress: 0,
progressToken: 'agent-bundle-target-capability-fixture',
total: 2,
}]);
// The resolved boundary, not the fallback, is what the result carries.
expect(projected.content).toEqual([
{ text: 'catalog: mystery', type: 'text' },
{ text: '## mystery\n\n- Piranesi\n- Solaris', type: 'text' },
]);

const silent = await projectTargetCapabilities(rendered, fixture({ progress: false }));
expect(silent.progress).toEqual([]);
});

it('uses exact text fallbacks and leaks no denied rich block', async () => {
const projected = await projectTargetCapabilities(await renderRichContent(), fixture({
audio: false,
Expand Down
7 changes: 5 additions & 2 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ elapsed time are bounded on the reconciler.

Generated MCP tool calls project the live render-event stream through
`projectMcpRenderStream`: `notifications/progress` is emitted only when the
caller supplied a progress token, shell/replace stay internal, and the
request resolves to one final `CallToolResult`. Image, audio, and resource
caller supplied a progress token — for `progress.report()` events and for
`Agent.Progress` nodes streamed in a shell/replace document (a `Suspense`
fallback), under one monotonic `progress` rule so neither source duplicates
the other — shell/replace content stays internal, and the request resolves to
one final `CallToolResult`. Image, audio, and resource
blocks are capability-gated — unsupported rich content uses a declared
fallback or a typed `McpProjectionError`, never a silent drop. The existing
`lowerMcpResult` / `lowerHookResult` helpers remain synchronous compatibility
Expand Down
Loading
Loading