From 47f2fb4ef994c3154146627423a2aa3103adcef9 Mon Sep 17 00:00:00 2001 From: icesixgod Date: Sun, 13 Sep 2026 21:18:40 +0800 Subject: [PATCH] fix: initialize native trajectory viewer and receive delayed results --- CHANGELOG.md | 4 + README.md | 2 + README.zh-CN.md | 2 + .../codex-trajectory/assets/trajectory.html | 18 +++ plugins/codex-trajectory/tests/test_ui.py | 106 +++++++++++++++++- 5 files changed, 131 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65beaf5..92c866f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] +### Fixed + +- Initialize the native MCP Apps viewer before waiting for host results and render delayed legacy `toolOutput` updates, preventing the viewer from remaining on its loading screen. Failed initialization shows a recoverable error without replacing an already rendered trajectory. + ### Changed - Restrict the viewer, acceptance coverage, and packaged screenshots to supported Codex desktop surfaces. diff --git a/README.md b/README.md index 74c9cf8..d9c42ca 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Full details are opt-in through `detailLevel: "full"` or the viewer's confirmati ## Tools +The native viewer initializes its MCP Apps connection before handshake-dependent hosts deliver results. It also accepts tool results injected later through the legacy `openai:set_globals` bridge. If initialization fails before data arrives, the viewer shows a loading error and can still recover when a result arrives; a successful tool call alone does not confirm that the host displayed the viewer. + | Tool | Purpose | | --- | --- | | `list_codex_sessions` | List recent task metadata without transcript bodies. | diff --git a/README.zh-CN.md b/README.zh-CN.md index ea10d28..7dda005 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,6 +33,8 @@ codex plugin add codex-trajectory@icesixgod ## 工具 +原生查看器会先初始化 MCP Apps 连接,以便接收需要握手后才下发的工具结果;也支持通过旧版 `openai:set_globals` 桥接延迟注入结果。若初始化失败且尚无数据,查看器会显示加载错误,后续收到结果仍可恢复。工具调用成功本身不代表宿主已显示界面。 + | 工具 | 用途 | | --- | --- | | `list_codex_sessions` | 列出最近任务的元数据,不返回对话正文。 | diff --git a/plugins/codex-trajectory/assets/trajectory.html b/plugins/codex-trajectory/assets/trajectory.html index 45d5080..24e7934 100644 --- a/plugins/codex-trajectory/assets/trajectory.html +++ b/plugins/codex-trajectory/assets/trajectory.html @@ -3175,6 +3175,7 @@ window.addEventListener("openai:set_globals", event => { const globals = event?.detail?.globals || event?.detail || {}; applyCodexTheme({ scheme: globals.theme }); + if (globals.toolOutput?.session) render(globals.toolOutput); const nextMode = globals.displayMode; if (nextMode !== "inline" && nextMode !== "fullscreen") return; displayMode = nextMode; @@ -3214,6 +3215,23 @@ const initial = window.openai?.toolOutput; if (initial?.session) render(initial); + // Some hosts withhold tool results until the app completes this handshake. + // Keep legacy toolOutput and direct-notification hosts working independently. + if (window.parent !== window) { + request("ui/initialize", { + appInfo: { name: "Codex Trajectory", version: "0.4.0" }, + appCapabilities: {}, + protocolVersion: "2026-01-26", + }).then(result => { + if (result?.protocolVersion !== "2026-01-26") throw new Error(labels.loadError); + applyCodexTheme({ scheme: result.hostContext?.theme }); + window.parent.postMessage({ + jsonrpc: "2.0", method: "ui/notifications/initialized", params: {}, + }, "*"); + }).catch(() => { + if (!data) app.innerHTML = ``; + }); + } })(); diff --git a/plugins/codex-trajectory/tests/test_ui.py b/plugins/codex-trajectory/tests/test_ui.py index bffb67a..c66427f 100644 --- a/plugins/codex-trajectory/tests/test_ui.py +++ b/plugins/codex-trajectory/tests/test_ui.py @@ -12,7 +12,7 @@ import pytest from codex_trajectory.browser_view import THEME_COLOR_KEYS, BrowserViewServer, injection_source from playwright.sync_api import FrameLocator, Page, expect, sync_playwright -from ui_harness import demo_trajectories, start_server +from ui_harness import app_resource_html, demo_trajectories, start_server pytestmark = [ pytest.mark.ui, @@ -143,6 +143,110 @@ def test_long_title_keeps_header_actions_reachable( page.set_viewport_size({"width": 1280, "height": 900}) +@pytest.mark.parametrize( + "delivery", + [ + "handshake", + "globals", + "initial", + "initial-rejected", + "notification", + "rejected", + "unsupported", + ], +) +def test_viewer_receives_initial_data_from_host(page: Page, delivery: str) -> None: + """Real hosts may wait for initialization or inject globals after page startup.""" + page.set_content('') + page.evaluate( + """({delivery, payload}) => { + window.startupMessages = []; + const viewer = document.getElementById('viewer'); + const send = message => viewer.contentWindow.postMessage(message, '*'); + const notify = () => send({jsonrpc: '2.0', + method: 'ui/notifications/tool-result', params: {structuredContent: payload}}); + window.addEventListener('message', event => { + if (event.source !== viewer.contentWindow) return; + const message = event.data; + window.startupMessages.push(message); + if (message.method === 'ui/initialize') { + if (delivery === 'rejected' || delivery === 'initial-rejected') { + send({jsonrpc: '2.0', id: message.id, + error: {code: -32601, message: 'Unsupported initialization'}}); + return; + } + send({jsonrpc: '2.0', id: message.id, result: { + protocolVersion: delivery === 'unsupported' ? 'unknown' : '2026-01-26', + hostInfo: {name: 'test-host', version: '1'}, + hostCapabilities: {}, hostContext: {theme: 'dark'}}}); + } else if (message.method === 'ui/notifications/initialized') { + if (delivery === 'handshake') notify(); + } else if (message.method === 'tools/call') { + send({jsonrpc: '2.0', id: message.id, result: {structuredContent: {}}}); + } + }); + if (delivery === 'notification') viewer.addEventListener('load', notify); + }""", + {"delivery": delivery, "payload": demo_trajectories()["session-alpha"]}, + ) + html = app_resource_html() + if delivery in {"initial", "initial-rejected"}: + payload = json.dumps(demo_trajectories()["session-alpha"]).replace("<", "\\u003c") + html = html.replace( + "", f"" + ) + page.locator("iframe").evaluate("(el, html) => el.srcdoc = html", html) + frame = page.frame_locator("iframe") + if delivery in {"rejected", "unsupported"}: + expect(frame.get_by_role("alert")).to_be_visible() + assert "ui/notifications/initialized" not in page.evaluate( + "startupMessages.map(message => message.method)" + ) + # A legacy host can still deliver a result after rejecting the shared handshake. + page.evaluate( + """payload => document.querySelector('iframe').contentWindow.postMessage({ + jsonrpc: '2.0', method: 'ui/notifications/tool-result', + params: {structuredContent: payload}}, '*')""", + demo_trajectories()["session-alpha"], + ) + if delivery == "globals": + expect(frame.locator(".loading")).to_be_visible() + frame.locator("#app").evaluate( + """(el, payload) => window.dispatchEvent(new CustomEvent('openai:set_globals', + {detail: {globals: {toolOutput: payload}}}))""", + demo_trajectories()["session-alpha"], + ) + expect(frame.locator("#sessionSelect")).to_be_visible(timeout=3000) + expect(frame.locator(".loading")).to_have_count(0) + expect(frame.get_by_role("alert")).to_have_count(0) + if delivery == "handshake": + messages = page.evaluate("startupMessages") + methods = [message.get("method") for message in messages] + assert methods[:2] == ["ui/initialize", "ui/notifications/initialized"] + assert methods.count("ui/initialize") == 1 + assert messages[0]["params"]["protocolVersion"] == "2026-01-26" + assert messages[0]["params"]["appCapabilities"] == {} + + +def test_viewer_initialization_timeout_recovers_when_data_arrives(page: Page) -> None: + with page.context.new_page() as isolated: + isolated.clock.install() + isolated.set_content('') + isolated.locator("iframe").evaluate("(el, html) => el.srcdoc = html", app_resource_html()) + frame = isolated.frame_locator("iframe") + expect(frame.locator(".loading")).to_be_visible() + isolated.clock.fast_forward(60_001) + expect(frame.get_by_role("alert")).to_be_visible() + isolated.evaluate( + """payload => document.querySelector('iframe').contentWindow.postMessage({ + jsonrpc: '2.0', method: 'ui/notifications/tool-result', + params: {structuredContent: payload}}, '*')""", + demo_trajectories()["session-alpha"], + ) + expect(frame.locator("#sessionSelect")).to_be_visible() + expect(frame.get_by_role("alert")).to_have_count(0) + + def test_cdp_injection_places_safe_entry_after_full_access(page: Page, harness_url: str) -> None: page.goto(f"{harness_url}/toolbar-fixture") viewer_url = "http://127.0.0.1:43123/private-token/"