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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ codex plugin add codex-trajectory@icesixgod

## 工具

原生查看器会先初始化 MCP Apps 连接,以便接收需要握手后才下发的工具结果;也支持通过旧版 `openai:set_globals` 桥接延迟注入结果。若初始化失败且尚无数据,查看器会显示加载错误,后续收到结果仍可恢复。工具调用成功本身不代表宿主已显示界面。

| 工具 | 用途 |
| --- | --- |
| `list_codex_sessions` | 列出最近任务的元数据,不返回对话正文。 |
Expand Down
18 changes: 18 additions & 0 deletions plugins/codex-trajectory/assets/trajectory.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = `<div class="notice error" role="alert">${labels.loadError}</div>`;
});
}
})();
</script>
</body>
Expand Down
106 changes: 105 additions & 1 deletion plugins/codex-trajectory/tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('<iframe id="viewer" sandbox="allow-scripts"></iframe>')
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(
"<head>", f"<head><script>window.openai={{toolOutput:{payload}}}</script>"
)
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('<iframe sandbox="allow-scripts"></iframe>')
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/"
Expand Down