diff --git a/.changeset/browser-app-reinitialize-terminal.md b/.changeset/browser-app-reinitialize-terminal.md new file mode 100644 index 000000000..f0aa89bb4 --- /dev/null +++ b/.changeset/browser-app-reinitialize-terminal.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Replay a pending `mountBrowserApp` call's published result or cancellation after the App reinitializes. (#743) diff --git a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts index 87d387817..9f9a89246 100644 --- a/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts +++ b/examples/mcp-app/tests/browser-app/status-panel.browser.test.ts @@ -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 () => { diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts index e29215091..3eb33f028 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts @@ -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 | undefined; let releasePromise: Promise | undefined; let teardownId: McpAppBridgeRequestId | undefined; @@ -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; }; @@ -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 { @@ -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; diff --git a/packages/agent-bundle/src/test/browser.ts b/packages/agent-bundle/src/test/browser.ts index b168f4ff0..7172c7e7e 100644 --- a/packages/agent-bundle/src/test/browser.ts +++ b/packages/agent-bundle/src/test/browser.ts @@ -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' }, diff --git a/packages/agent-bundle/tests/mcp-app-bridge.test.ts b/packages/agent-bundle/tests/mcp-app-bridge.test.ts index b74deba9b..4f009af18 100644 --- a/packages/agent-bundle/tests/mcp-app-bridge.test.ts +++ b/packages/agent-bundle/tests/mcp-app-bridge.test.ts @@ -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 () => { diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index e37aad9a5..ec1f71452 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -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'; diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index fc9dc1a01..462b1c87d 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -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';