diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css index 69f81040e2..8bb9a1ccfa 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -10,7 +10,7 @@ .attached-conversation-content textarea {min-height: 88px; width: 100%; resize: vertical;} .attached-conversation-content button {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} .attached-conversation-content button:disabled, .attached-conversation-content button[aria-disabled="true"] {opacity: .5; cursor: default;} -.attached-conversation-content .attached-conversation-open {border: 0; padding: 0; border-radius: 0; background: transparent; text-decoration: underline; text-underline-offset: 3px;} +.attached-conversation-content .attached-conversation-open {border: 0; padding: 0; border-radius: 0; background: transparent;} .attached-session-selection {display: flex; align-items: center; gap: 12px; margin-block: 12px 4px; min-width: 0;} .attached-session-selection > span {flex-shrink: 0;} .attached-session-selection select {min-width: 0; max-width: 100%; min-height: 44px; font: inherit; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text);} @@ -59,9 +59,10 @@ .goal-agent-work-page form button[type="submit"] {background: var(--pw-text); color: var(--pw-card); min-height: 44px;} @media (max-width: 640px) {.agent-work-navigation {width: 100%;} .agent-work-navigation select {flex: 1; min-width: 0;} .goal-agent-work-page .attached-conversation-messages {min-height: 120px;}} -.attached-connection-status {display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; padding-block: 8px;} +.attached-connection-status {display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 16px; padding-block: 8px; padding-inline-end: 4px;} .attached-connection-status p {margin: 0;} -.attached-connection-status .attached-conversation-open {min-height: 44px; flex-shrink: 0;} +.attached-connection-status .attached-conversation-open {display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; border-radius: 6px; color: var(--pw-muted);} +.attached-connection-status .attached-conversation-open:hover {background: var(--pw-hover); color: var(--pw-text);} @media (max-width: 640px) {.goal-agent-work-page .attached-conversation-heading h2 {font-size: var(--text-lead);} .agent-work-navigation {width: auto; flex: 1;} .agent-work-navigation select {max-width: 140px;} .attached-conversation-heading button {padding-inline: 8px;}} .attached-conversation-messages summary > strong {font-weight: 500;} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx index 21b3c0d726..c79f83af37 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -1,5 +1,5 @@ import {useEffect, useId, useLayoutEffect, useRef, useState} from "react"; -import {Keyboard} from "lucide-react"; +import {ExternalLink, Keyboard} from "lucide-react"; import {AttachedConversationTimeline, type ConversationRecord, type ContentPage} from "./attached-conversation-record"; import {composerKeyAction} from "./composer-keyboard"; import {ContextHelp} from "./context-help"; @@ -411,7 +411,8 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: { {recoveryError ?

{recoveryError}

: null} {sendNotice ?

{sendNotice}

: null}
Codex App

{zh ? "已保存消息与工具记录自动更新;实时内容来自原宿主。" : "Saved messages and tools update automatically; live content comes from the original host."}

{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}

-
+ {openRequested ?

{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}

: null} {openError ?

{openError}

: null}
{loadingOlder ? (zh ? "正在加载消息…" : "Loading messages…") : readingHistory ? {zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""} : null}
diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index 96fadec0c8..e7fed54250 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -3,6 +3,7 @@ import {spawn} from 'node:child_process'; import {once} from 'node:events'; import {resolve} from 'node:path'; import {createInterface} from 'node:readline'; +import {readFile} from 'node:fs/promises'; import {repoRoot, outputDir} from './fixture.mjs'; import {openWorkspacePage} from './scenario-context.mjs'; import {testPythonCommand} from '../../scripts/test-python.mjs'; @@ -556,20 +557,52 @@ async function runConversation(browser, uncertain) { assert.equal(new Set(sentOperations).size,6); assert.equal(await direct.getByRole('textbox',{name:'回复 worker',exact:true}).inputValue(),''); await page.screenshot({path:resolve(outputDir,'attached-codex-owner-conversation.png'),animations:'disabled'}); + // Keep the real display state intact through the production attached + // reader, HTTP detail pages and rendered tool record. Host execution is a + // disposable protocol peer, not the original benchmark/model conversation. + const benchmarkReference = JSON.parse(await readFile(resolve(repoRoot, + 'apps/presentation/dashboard/src/data/fixtures/edgebench-grid-context-on-20261103-history.json'), 'utf8')); await page.request.post(new URL('/fixture/work-records',url).href); await direct.getByText('工作结果已返回。',{exact:true}).waitFor(); const toolResult = direct.locator('article[data-role="tool"]').filter({hasText:'工具结果'}); await expandActivity(toolResult); await toolResult.locator('summary').click(); - assert.equal(await toolResult.getByText('COMPLETE TOOL RESULT',{exact:false}).count(),0); - await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).click(); - await toolResult.getByText('COMPLETE TOOL RESULT',{exact:false}).waitFor(); + const recordId = await toolResult.getAttribute('data-record-id'); + let detailPages = 0; + while (await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).count()) { + const before = await toolResult.locator('pre').textContent(); + await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).click(); + await page.waitForFunction(({id, previous}) => { + const text = document.querySelector(`article[data-record-id="${CSS.escape(id)}"] pre`)?.textContent; + return text != null && text !== previous; + }, {id: recordId, previous: before}); + await toolResult.getByRole('button',{name:'正在读取…',exact:true}).waitFor({state:'hidden'}); + assert.ok(++detailPages <= 32, 'The bounded reference must finish reading'); + } + assert.ok(detailPages > 0, 'The genuine reference crosses a detail-page boundary'); assert.equal(await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).count(),0); // Unicode detail offsets must count source characters, not JS UTF-16 units. - assert.equal(await toolResult.locator('pre').textContent(),'Validation output\n' + '🧭验证结果\n'.repeat(6000) + 'COMPLETE TOOL RESULT'); + const body = await toolResult.locator('pre').textContent(); + assert.ok(body.startsWith('🧭\n')); + assert.deepEqual(JSON.parse(body.slice('🧭\n'.length)), benchmarkReference, + 'Every real observation, original wording, negative/unknown result and provenance survives native paging'); + await page.screenshot({path:resolve(outputDir,'attached-codex-benchmark-detail-desktop.png'),animations:'disabled'}); + await page.setViewportSize({width:390,height:844}); + assert.ok(await page.locator('body').evaluate(node => node.scrollWidth <= innerWidth + 1)); + await page.screenshot({path:resolve(outputDir,'attached-codex-benchmark-detail-narrow.png'),animations:'disabled'}); + await page.setViewportSize({width:1512,height:982}); await toolResult.locator('summary').click(); await toolResult.locator('xpath=ancestor::details[contains(@class,"attached-record-group")]').locator(':scope > summary').click(); await page.screenshot({path:resolve(outputDir,'attached-codex-work-timeline.png'),animations:'disabled'}); + await page.setViewportSize({width:390,height:844}); + const connection = direct.locator('.attached-connection-status'); + assert.ok(await connection.getByRole('status').isVisible(), 'The native reply or unknown state stays visible'); + await connection.getByRole('button',{name:'在 Codex App 中打开',exact:true}).focus(); + assert.ok(await connection.getByRole('button',{name:'在 Codex App 中打开',exact:true}) + .evaluate(node => document.activeElement === node), 'The compact original-App action remains keyboard reachable'); + assert.ok(await page.locator('body').evaluate(node => node.scrollWidth <= innerWidth + 1)); + await page.screenshot({path:resolve(outputDir,'attached-codex-work-timeline-narrow.png'),animations:'disabled'}); + await page.setViewportSize({width:1512,height:982}); await scrollHistory(page, direct, 1); await direct.getByText('工作结果已返回。',{exact:true}).waitFor(); assert.equal(await direct.getByRole('button',{name:/^(读取更早的消息|较新记录|回到最新)$/}).count(),0,'History needs no pagination controls'); diff --git a/examples/personal-workspace-browser/codex_attached_fixture.py b/examples/personal-workspace-browser/codex_attached_fixture.py index 07d782ab99..d6c934fa0b 100644 --- a/examples/personal-workspace-browser/codex_attached_fixture.py +++ b/examples/personal-workspace-browser/codex_attached_fixture.py @@ -118,12 +118,20 @@ def do_POST(self): self._send_json({"ok": True}) return if self.path == "/fixture/work-records": + # Genuine, already public-safe display data; only the host + # protocol and carrier messages are synthetic. Keep one + # non-BMP prefix to exercise character-based detail offsets. + reference = ( + Path(__file__).resolve().parents[2] + / "apps/presentation/dashboard/src/data/fixtures" + / "edgebench-grid-context-on-20261103-history.json" + ) records = [ { "type": "function_call", - "name": "validate", + "name": "read_file", "call_id": "work-1", - "arguments": "Check the release artifact", + "arguments": json.dumps({"path": reference.name}), }, { "type": "function_call_output", @@ -131,9 +139,7 @@ def do_POST(self): "output": [ { "type": "text", - "text": "Validation output\n" - + "🧭验证结果\n" * 6000 - + "COMPLETE TOOL RESULT", + "text": "🧭\n" + reference.read_text().strip(), } ], }, diff --git a/examples/personal-workspace-browser/codex_attached_peer.py b/examples/personal-workspace-browser/codex_attached_peer.py index 2b9a4ff1b9..c4e76edf0d 100644 --- a/examples/personal-workspace-browser/codex_attached_peer.py +++ b/examples/personal-workspace-browser/codex_attached_peer.py @@ -124,6 +124,7 @@ def fixture(tmp_path, *, uncertain=False, goal_id="sample-goal"): registry.write_text( json.dumps( { + "common_runtime_root": str(tmp_path / "runtime"), "goals": [ { "id": goal_id, diff --git a/examples/personal-workspace-browser/research-history.md b/examples/personal-workspace-browser/research-history.md index 00b77d207e..acab3de700 100644 --- a/examples/personal-workspace-browser/research-history.md +++ b/examples/personal-workspace-browser/research-history.md @@ -47,6 +47,23 @@ original JSONL digests and sizes. The two source byte totals are 80,992 and 20,575; their exact original serialization can be reconstructed as described below. No model or solver is called by this scenario. +The attached work-conversation scenario also uses the complete Grid context-on +fixture as a tool result, replacing repeated invented validation lines. The +production attached reader and HTTP detail pages must deliver the entire JSON +object into the packaged conversation, including every original observation and +its diagnostic/unknown boundaries, at desktop and 390px widths. A single synthetic +Unicode prefix retains the character-offset regression. The disposable host peer, +tool call and carrier messages are synthetic; this does not reconstruct a real +benchmark conversation or qualify model, installed App or original-host adoption. +The peer registry pins its disposable runtime instead of consulting the operator's +implicit default roots; conflicting defaults are covered by the HTTP regression. +The separate artificial paging, transport-failure and heavy-tool stress cases +remain boundary tests. +The narrow work view keeps the native source, reply/unknown state and original-App +action in one status band. Its icon retains the full accessible action name, +keyboard activation and native failure/recovery path; the original-App action is +never a substitute for reading the complete record in LoopX. + The same core scenario now includes two record-rich histories from different tasks, with all **349 original events** retained: diff --git a/tests/presentation/test_codex_attached_conversation.py b/tests/presentation/test_codex_attached_conversation.py index 11ecac4734..42f2e2f82f 100644 --- a/tests/presentation/test_codex_attached_conversation.py +++ b/tests/presentation/test_codex_attached_conversation.py @@ -45,6 +45,31 @@ def body(binding, text="Owner follow-up"): } +def test_protocol_fixture_ignores_conflicting_ambient_runtime_roots(tmp_path, monkeypatch): + from loopx import paths + + current, legacy = tmp_path / "current", tmp_path / "legacy" + monkeypatch.setattr(paths, "DEFAULT_RUNTIME_ROOT", current) + monkeypatch.setattr(paths, "LEGACY_RUNTIME_ROOT", legacy) + ambient = [] + for root in (current, legacy): + root.mkdir() + registry = paths.global_registry_path(root) + registry.write_text(json.dumps({"goals": []})) + ambient.append(registry) + assert paths.default_runtime_route()["status"] == "conflict" + before = {path: path.read_bytes() for path in ambient} + workspace = tmp_path / "isolated" + workspace.mkdir() + with fixture(workspace) as (request, _, _, inputs, _, _): + _, query = target(request) + status, result = request(query=query) + assert status == 200 + assert any(row["text"] == "Original result" for row in result["messages"]) + assert not inputs + assert {path: path.read_bytes() for path in ambient} == before + + def test_managed_directory_keeps_registered_identity_and_original_sessions(tmp_path): with fixture(tmp_path) as (request, _, methods, inputs, registry, _): store = request.server.chat_store