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
Original file line number Diff line number Diff line change
Expand Up @@ -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);}
Expand Down Expand Up @@ -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;}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -411,7 +411,8 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
{recoveryError ? <p role="alert">{recoveryError} <button type="button" disabled={sending} onClick={nextReply}>{zh ? "已在原 App 核对,清理本页回执" : "Checked in original App; clear this receipt"}</button></p> : null}
{sendNotice ? <p role="status">{sendNotice}</p> : null}
<div className="attached-connection-status"><div><div className="attached-conversation-source">Codex App <ContextHelp label={zh ? "原会话来源与更新" : "Original conversation source and updates"}><p>{zh ? "已保存消息与工具记录自动更新;实时内容来自原宿主。" : "Saved messages and tools update automatically; live content comes from the original host."}</p></ContextHelp></div><p role="status">{!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")}</p></div>
<button type="button" className="attached-conversation-open" aria-disabled={opening} aria-busy={opening} onClick={() => void openOriginal()}>{zh ? "在 Codex App 中打开" : "Open in Codex App"}</button></div>
<button type="button" className="attached-conversation-open" aria-label={zh ? "在 Codex App 中打开" : "Open in Codex App"}
title={zh ? "在 Codex App 中打开" : "Open in Codex App"} aria-disabled={opening} aria-busy={opening} onClick={() => void openOriginal()}><ExternalLink size={18} aria-hidden="true" /></button></div>
{openRequested ? <p role="status">{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}</p> : null}
{openError ? <p role="alert">{openError}</p> : null}
<div className="attached-history-status" role="status">{loadingOlder ? (zh ? "正在加载消息…" : "Loading messages…") : readingHistory ? <span>{zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""}</span> : null}</div>
Expand Down
41 changes: 37 additions & 4 deletions examples/personal-workspace-browser/codex-attached.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
16 changes: 11 additions & 5 deletions examples/personal-workspace-browser/codex_attached_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,22 +118,28 @@ 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",
"call_id": "work-1",
"output": [
{
"type": "text",
"text": "Validation output\n"
+ "🧭验证结果\n" * 6000
+ "COMPLETE TOOL RESULT",
"text": "🧭\n" + reference.read_text().strip(),
}
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions examples/personal-workspace-browser/research-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
25 changes: 25 additions & 0 deletions tests/presentation/test_codex_attached_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading