Skip to content

Commit ace2fa1

Browse files
fix(cli): draw the TTY progress line from a streamed Agent.Progress fallback too (#448)
1 parent a06d759 commit ace2fa1

6 files changed

Lines changed: 88 additions & 9 deletions

File tree

‎.changeset/448-progress-fallback-notifications.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
'agent-bundle': patch
44
---
55

6-
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. A fallback alone is now enough; `announce()`-style shims that repeat the fallback message through `progress.report()` are unnecessary. Fixes #448. (#498)
6+
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)

‎docs/framework-mode.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ The final Agent Document of a tool route lowers to one `CallToolResult`:
208208
| `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. |
209209
| `Agent.Result value` | `structuredContent` when the value is a JSON object; a non-object value emits none and is never wrapped. |
210210
| `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. |
211-
| `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. |
211+
| `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. |
212212
| `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. |
213213
| `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. |
214214

‎packages/agent-bundle/src/cli-entry.ts‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,11 +482,46 @@ export const projectCliDocumentToMarkdown = (document: CliRenderedDocument): str
482482
return blocks.length === 0 ? '' : `${blocks.join('\n\n')}\n`;
483483
};
484484

485-
const progressLine = (event: { readonly completed: number; readonly message?: string; readonly total?: number }): string => {
485+
/** The progress fields a render `progress` event and an `Agent.Progress` document node share. */
486+
interface CliProgressSource {
487+
readonly completed: number;
488+
readonly message?: string;
489+
readonly total?: number;
490+
}
491+
492+
const progressLine = (event: CliProgressSource): string => {
486493
const counter = event.total === undefined ? String(event.completed) : `${String(event.completed)}/${String(event.total)}`;
487494
return event.message === undefined ? counter : `${event.message} (${counter})`;
488495
};
489496

497+
/**
498+
* The `Agent.Progress` nodes of a streamed `shell`/`replace` document, in
499+
* document order — a `Suspense` fallback rendered as `Agent.Progress` is the
500+
* route's progress surface, so the interactive TTY shows it exactly as it
501+
* shows an explicit `progress.report()` (#448).
502+
*/
503+
const progressNodes = (node: CliRenderedDocumentNode): readonly CliProgressSource[] => {
504+
switch (node.kind) {
505+
case 'result':
506+
return node.children.flatMap(progressNodes);
507+
case 'progress':
508+
return [node];
509+
case 'audio':
510+
case 'context':
511+
case 'error':
512+
case 'image':
513+
case 'json':
514+
case 'markdown':
515+
case 'resource':
516+
case 'text':
517+
return [];
518+
default: {
519+
const unreachable: never = node;
520+
throw new TypeError(`Unsupported Agent Document node ${String((unreachable as { kind?: string }).kind)}.`);
521+
}
522+
}
523+
};
524+
490525
const clearProgressLine = '\r\u001B[2K';
491526

492527
interface RenderedRunOptions {
@@ -509,6 +544,21 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise<numbe
509544
const reader = options.session.events().getReader();
510545
let complete: CliRenderedDocument | undefined;
511546
let progressShown = false;
547+
const showProgress = (source: CliProgressSource): void => {
548+
if (mode !== 'tty') return;
549+
writeOut(`${clearProgressLine}${progressLine(source)}`);
550+
progressShown = true;
551+
};
552+
// A fallback is re-streamed with every chunk that leaves its boundary
553+
// pending; the line is redrawn only when the fallback itself changed. A TTY
554+
// has no monotonic constraint, so an explicit report always redraws.
555+
const shownFallbacks = new Set<string>();
556+
const showFallback = (node: CliProgressSource): void => {
557+
const key = JSON.stringify([node.completed, node.message, node.total]);
558+
if (shownFallbacks.has(key)) return;
559+
shownFallbacks.add(key);
560+
showProgress(node);
561+
};
512562
const clearProgress = (): void => {
513563
if (progressShown) {
514564
writeOut(clearProgressLine);
@@ -526,12 +576,10 @@ const runRenderedInvocation = async (options: RenderedRunOptions): Promise<numbe
526576
switch (event.type) {
527577
case 'shell':
528578
case 'replace':
579+
for (const node of progressNodes(event.document.root)) showFallback(node);
529580
break;
530581
case 'progress':
531-
if (mode === 'tty') {
532-
writeOut(`${clearProgressLine}${progressLine(event)}`);
533-
progressShown = true;
534-
}
582+
showProgress(event);
535583
break;
536584
case 'error':
537585
if (mode !== 'ndjson') {

‎packages/agent-bundle/tests/projection/cli-dispatch-rendered.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ describe('rendered commands at the CLI dispatch level', () => {
2424
expect(run.stdout.endsWith('# Report: books\n\nGenerated for books.\n\nitems: 2\n')).toBe(true);
2525
});
2626

27+
it('shows a streamed Agent.Progress Suspense fallback on the TTY without an explicit report (#448)', async () => {
28+
// The projected `harness catalog` command never calls `progress.report()`;
29+
// its only progress surface is the `<Suspense fallback={<Agent.Progress …/>}>`.
30+
const tty = await invokeCli(['harness', 'catalog', '--input', '{"genre":"mystery"}', '--yes'], { tty: true });
31+
32+
expect(tty.exitCode).toBe(0);
33+
expect(tty.stderr).toBe('');
34+
expect(tty.stdout).toContain('\r\u001B[2Kloading mystery (0/2)');
35+
// Drawn once, although the shell and the replace both carried the node.
36+
expect(tty.stdout.split('loading mystery (0/2)')).toHaveLength(2);
37+
expect(tty.stdout.endsWith('catalog: mystery\n\n## mystery\n\n- Piranesi\n- Solaris\n')).toBe(true);
38+
39+
// Piped output is the final document only: the fallback never prints.
40+
const piped = await invokeCli(['harness', 'catalog', '--input', '{"genre":"mystery"}', '--yes']);
41+
expect(piped.stdout).toBe('catalog: mystery\n\n## mystery\n\n- Piranesi\n- Solaris\n');
42+
expect(piped.stdout).not.toContain('loading mystery');
43+
});
44+
2745
describe('a projected MCP command whose route throws (#492)', () => {
2846
it('reports a root throw on stderr with exit 1 and nothing on stdout', async () => {
2947
const run = await invokeCli(['harness', 'fault', '--input', '{"mode":"throw"}']);

‎website/docs/en/guide/authoring/mcp.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ framework never reads or surfaces who the human behind a host session is.
9797
9898
A route streams by rendering React `Suspense`: the shell goes out first with the fallback in
9999
place, and each resolved boundary replaces it. `Agent.Progress` is the fallback to use — it is the
100-
framework's streaming progress surface on every host:
100+
framework's streaming progress surface on MCP, in the rendered CLI, and in the Workbench:
101101
102102
```tsx
103103
// src/mcp/curator/tools/search.tsx
@@ -127,6 +127,13 @@ re-sent, and a later fallback that advances `completed` is. Give nested boundari
127127
`completed` values when each stage should reach the client. A progress node never becomes a
128128
`content` block, and one left in the final document is content only.
129129
130+
The same route exposed as a rendered CLI command (`routes.mcpCommands`) or written as a rendered
131+
`src/cli/**` command shows the fallback on an interactive terminal too: the in-place progress line
132+
is drawn from the streamed `Agent.Progress` node exactly as from an explicit report, redrawn only
133+
when the fallback changes, and never printed to piped Markdown, `--json`, or `--ndjson` output
134+
(where the node stays a document event). The Workbench renders the node as document content.
135+
Hooks have no progress channel.
136+
130137
The route-unit and `mcp-in-memory` test levels prove this without a host:
131138
`projectTargetCapabilities(await renderRouteEvents('tool:curator/search', { input }), fixture)`
132139
returns the notifications the fixture's token produced, and an `openInMemoryMcpServer()` client

‎website/docs/zh/guide/authoring/mcp.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ start 匹配到最新一个未被认领的 spawn 调用)、`confirmed`(宿
8888
## 流式输出与进度
8989

9090
路由通过渲染 React `Suspense` 实现流式输出:外壳(shell)先带着回退内容发出,之后每个解析完成的
91-
boundary 替换掉对应的回退。回退内容应使用 `Agent.Progress`——它是框架在所有宿主上的流式进度表面:
91+
boundary 替换掉对应的回退。回退内容应使用 `Agent.Progress`——它是框架在 MCP、渲染式 CLI 与 Workbench 上的
92+
流式进度表面:
9293

9394
```tsx
9495
// src/mcp/curator/tools/search.tsx
@@ -115,6 +116,11 @@ export default async function Search({ input, signal }: ToolRouteProps<typeof in
115116
boundary 都应到达客户端时,请给它们递增的 `completed` 值。进度节点永远不会成为 `content` 块;留在最终
116117
文档中的进度节点只是内容。
117118

119+
同一条路由以渲染式 CLI 命令形式暴露(`routes.mcpCommands`)或作为渲染式 `src/cli/**` 命令编写时,交互式
120+
终端同样会显示这个回退:就地更新的进度行会像来自显式报告一样从流式传输的 `Agent.Progress` 节点绘制,
121+
仅在回退变化时重绘,并且永远不会打印到管道 Markdown、`--json` 或 `--ndjson` 输出中(在那里该节点只是一个
122+
文档事件)。Workbench 把该节点渲染为文档内容。钩子没有进度通道。
123+
118124
route-unit 与 `mcp-in-memory` 两个测试层级无需宿主即可证明这一点:
119125
`projectTargetCapabilities(await renderRouteEvents('tool:curator/search', { input }), fixture)`
120126
返回 fixture 的 token 产生的通知;而在 `callTool` 上设置了 `_meta.progressToken` 的

0 commit comments

Comments
 (0)