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
5 changes: 5 additions & 0 deletions .changeset/browser-app-reinitialize-terminal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Replay a pending `mountBrowserApp` call's published result or cancellation after the App reinitializes. (#743)
12 changes: 10 additions & 2 deletions examples/mcp-app/tests/browser-app/status-panel.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,24 @@ it('mounts the compiled panel, initializes the bridge, and renders the published
});

it('merges caller host context with the derived opening tool information', async () => {
const app = await mountStatus({ host: { context: { locale: 'fr-FR', theme: 'dark' } } });
const app = await mountStatus({
host: { context: { displayMode: 'fullscreen', locale: 'fr-FR', theme: 'dark' } },
});

expect(initializeResult(app)).toMatchObject({
hostContext: {
displayMode: 'fullscreen',
locale: 'fr-FR',
platform: 'desktop',
theme: 'dark',
toolInfo: { tool: { name: 'show-status' } },
},
});
expect(initializeResult(app)).not.toMatchObject({
hostContext: { availableDisplayModes: ['inline'] },
});
expect(initializeResult(app)).not.toMatchObject({
hostContext: { platform: 'desktop' },
});
});

it('fills the default object input schema for a partial tool definition', async () => {
Expand Down
38 changes: 26 additions & 12 deletions packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,13 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr
let hostTrafficBlocked = false;
let inputQueued = false;
let terminalQueued = false;
let openingTerminal: McpAppBridgeMessage | undefined = options.deferInitialToolResult === true
? undefined
: Object.freeze({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: cloneJson(binding.result),
});
let closePromise: Promise<void> | undefined;
let releasePromise: Promise<void> | undefined;
let teardownId: McpAppBridgeRequestId | undefined;
Expand Down Expand Up @@ -1058,8 +1065,16 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr
const originalInput = jsonRecord(binding.input);
if (originalInput === undefined || !queueInput(originalInput)) return false;
}
const queued = emitHost(Object.freeze({ jsonrpc: '2.0', method: 'ui/notifications/tool-result', params: cloneJson(result) }));
if (queued) terminalQueued = true;
const message = Object.freeze({
jsonrpc: '2.0' as const,
method: 'ui/notifications/tool-result',
params: cloneJson(result),
});
const queued = emitHost(message);
if (queued) {
openingTerminal = message;
terminalQueued = true;
}
return queued;
};

Expand Down Expand Up @@ -1417,12 +1432,16 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr
if (originalInput === undefined || !queueInput(originalInput)) return false;
}
const params: McpAppBridgeJsonRecord = reason === undefined ? {} : { reason };
const queued = emitHost(Object.freeze({
const message = Object.freeze({
jsonrpc: '2.0',
method: 'ui/notifications/tool-cancelled',
params: Object.freeze(params),
}));
if (queued) terminalQueued = true;
});
const queued = emitHost(message);
if (queued) {
openingTerminal = message;
terminalQueued = true;
}
return queued;
},
publishToolInput(argumentsValue?: McpAppBridgeJsonRecord): boolean {
Expand Down Expand Up @@ -1482,13 +1501,8 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr
}
inputQueued = true;
}
if (!terminalQueued && options.deferInitialToolResult !== true) {
const resultMessage = Object.freeze({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: cloneJson(binding.result),
});
if (!enqueueHostMessage(resultMessage)) {
if (!terminalQueued && openingTerminal !== undefined) {
if (!enqueueHostMessage(openingTerminal)) {
lifecycle = 'closing';
void releaseBinding().catch(() => undefined);
return false;
Expand Down
9 changes: 5 additions & 4 deletions packages/agent-bundle/src/test/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,11 @@ export const mountBrowserApp = async (
serverTools: {},
},
context: {
availableDisplayModes: ['inline'],
displayMode: 'inline',
platform: 'desktop',
...userContext,
...(userContext ?? {
availableDisplayModes: ['inline'],
displayMode: 'inline',
platform: 'desktop',
}),
toolInfo: { ...suppliedToolInfo, tool: toolDefinition },
},
info: userHost?.info ?? { name: 'agent-bundle-browser-test', version: '1.0.0' },
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-bundle/tests/mcp-app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ it('leaves the opening result pending when automatic publication is deferred', a
method: 'ui/notifications/tool-cancelled',
params: { reason: 'user-dismissed' },
});

await bridge.receive(initialize('init:pending-rebound'));
await bridge.receive(initialized());

expect(fixture.sent.at(-3)?.id).toBe('init:pending-rebound');
expect(fixture.sent.slice(-2)).toEqual([
{ jsonrpc: '2.0', method: 'ui/notifications/tool-input', params: { arguments: { city: 'Paris', units: 'metric' } } },
{ jsonrpc: '2.0', method: 'ui/notifications/tool-cancelled', params: { reason: 'user-dismissed' } },
]);
});

it('accepts the stable parameterless initialized notification before flushing host traffic', async () => {
Expand Down
8 changes: 5 additions & 3 deletions website/docs/en/guide/development/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,14 @@ an isolated host-shaped root and spawned without a host-owned install, which is
`host-install`.

`mountBrowserApp` derives the opening call's `hostContext.toolInfo` from `toolName` and
`toolDefinition`, publishes `toolInput`, and merges any other `host.context` fields. Do not repeat
`toolInfo` in `host.context`; a conflicting opening tool name is rejected. Pass `toolResult` to
`toolDefinition`, publishes `toolInput`, and injects that tool information into either the caller's
`host.context` or the default inline desktop context. Do not repeat `toolInfo` in `host.context`;
a conflicting opening tool name is rejected. Pass `toolResult` to
publish an already-settled result during mount. Omit it when the opening call is still in flight,
then settle exactly once with `publishToolResult(...)` — including an `isError: true` result — or
`publishToolCancelled(reason?)`. Each publisher returns whether the bridge accepted that terminal
notification.
notification. If the App initializes again, the harness replays the opening input and the accepted
terminal notification.

```ts twoslash
import { cliJson, cliNdjson, invokeCli, invokeMcpTool } from 'agent-bundle/test';
Expand Down
8 changes: 5 additions & 3 deletions website/docs/zh/guide/development/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,13 @@ try {
`host-install` 更弱。

`mountBrowserApp` 会根据 `toolName` 与 `toolDefinition` 派生开场调用的
`hostContext.toolInfo`,发布 `toolInput`,并合并其他 `host.context` 字段。不要在
`host.context` 中重复提供 `toolInfo`;冲突的开场工具名会被拒绝。传入 `toolResult`
`hostContext.toolInfo`,发布 `toolInput`,并把该工具信息注入调用方的 `host.context`;
未提供该上下文时则注入默认的 inline desktop 上下文。不要在 `host.context` 中重复提供
`toolInfo`;冲突的开场工具名会被拒绝。传入 `toolResult`
会在挂载期间发布已经结算的结果。若开场调用仍在进行中,则省略它,之后再用
`publishToolResult(...)`(包括 `isError: true` 的结果)或 `publishToolCancelled(reason?)`
结算且只能结算一次。每个发布方法都会返回桥接层是否接受了该终态通知。
结算且只能结算一次。每个发布方法都会返回桥接层是否接受了该终态通知。如果 App 再次初始化,
harness 会重放开场输入以及已接受的终态通知。

```ts twoslash
import { cliJson, cliNdjson, invokeCli, invokeMcpTool } from 'agent-bundle/test';
Expand Down
Loading