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 @@ -60,7 +60,7 @@
@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: 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 p {margin: 0; overflow-wrap: anywhere;}
.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;}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import "./attached-agent-conversations.css";

export type Binding = {agent_id: string; thread_id: string; binding_ref: string};
type Message = ConversationRecord;
type TurnModel = {model: string; reasoning_effort: string | null};
type Snapshot = {messages?: Message[]; before?: number | null; after?: number; has_more?: boolean; reset?: boolean; source_ref?: string; history_size?: number; revision: string; unchanged?: boolean; connected?: boolean; can_reply?: boolean; activity: {state: string}};
type TurnModelReadback = {revision: string; turn_model: TurnModel | null};
type Submission = {operation_id: string; text: string; status?: string};
type LiveRecords = {identity: string; records: Message[]};
type LiveObservation = {kind: "records"; revision: number; records: Message[]} |
Expand All @@ -18,7 +20,9 @@ class ConversationRequestError extends Error {
}

export async function request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {...options, signal: AbortSignal.timeout(6_000)});
const timeout = AbortSignal.timeout(6_000);
const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
const response = await fetch(url, {...options, signal});
// Read the body as text first: a service failure can answer with an HTML or
// empty body, and the caller still needs the HTTP status to decide whether
// the read is retryable. Parsing first would surface a SyntaxError instead.
Expand Down Expand Up @@ -88,6 +92,7 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
}) {
const [messages, setMessages] = useState<Message[]>([]);
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [turnModelReadback, setTurnModelReadback] = useState<{query: string; model: TurnModel | null} | null>(null);
const [before, setBefore] = useState<number | null>(null);
const [error, setError] = useState("");
const [bindingReadRejected, setBindingReadRejected] = useState(false);
Expand Down Expand Up @@ -137,6 +142,33 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
// Connection/registration alone is never an activity observation.
const liveActive = liveEnabled && !liveUnavailable && !readingHistory;
const query = new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref});
const activeRevision = snapshot?.activity.state === "turn_open" ? snapshot.revision : null;
const modelQuery = activeRevision === null ? null
: new URLSearchParams({...Object.fromEntries(query), turn_model: "1", revision: activeRevision}).toString();
useEffect(() => {
if (modelQuery === null || bindingReadRejected) return;
const controller = new AbortController();
let retry: ReturnType<typeof setTimeout> | undefined;
const read = async (attempt: number) => {
try {
const result = await request<TurnModelReadback>(
`/api/chat/codex-attached?${modelQuery}`, {signal: controller.signal},
);
if (!controller.signal.aborted && result.revision === activeRevision) {
setTurnModelReadback({query: modelQuery, model: result.turn_model});
}
} catch (failure) {
if (!controller.signal.aborted && attempt < 3 && retryableRead(failure)) {
retry = setTimeout(() => void read(attempt + 1), 1_000);
}
}
};
void read(1);
return () => {
controller.abort();
clearTimeout(retry);
};
}, [modelQuery, activeRevision, bindingReadRejected]);

function stopOnRejectedRead(failure: unknown): boolean {
if (!(failure instanceof ConversationRequestError) || ![401, 403, 409].includes(failure.httpStatus ?? 0)) return false;
Expand Down Expand Up @@ -433,6 +465,17 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: {
try {sessionStorage.removeItem(cacheKey); sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");}
catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");}
}
const turnModel = turnModelReadback?.query === modelQuery ? turnModelReadback.model : null;
const turnProfile = turnModel
? [`${zh ? "本轮模型" : "Turn model"} ${turnModel.model}`, turnModel.reasoning_effort].filter(Boolean).join(" · ")
: (zh ? "本轮模型未读取" : "Turn model unavailable");
const connectionStatus = !snapshot
? (zh ? "正在连接或连接不可用" : "Connecting or unavailable")
: snapshot.can_reply
? (zh ? "原会话可回复" : "Original conversation accepts replies")
: activeRevision !== null
? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App")
: (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed");
const connectionMessage = bindingReadRejected
? (zh ? "无法验证原会话的绑定或访问权限。" : "This conversation's binding or access could not be verified.")
: error;
Expand All @@ -441,7 +484,17 @@ 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>{!bindingReadRejected ? <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> : null}</div>
<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>
{!bindingReadRejected ? <p role="status">
<span>{connectionStatus}</span>
{activeRevision !== null ? <>{" · "}<span>{turnProfile}</span></> : null}
</p> : null}
</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}
Expand Down
40 changes: 40 additions & 0 deletions examples/personal-workspace-browser/codex-attached.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,45 @@ async function runConversation(browser, uncertain) {
await composer.fill('');
assert.equal(await inputCount(),0,'Reading and switching never start or replay host work');
const live = panel.getByLabel('原宿主实时记录',{exact:true});
const fixtureModel = 'codex-synthetic-attached-workspace-verification-model';
const recordedModel = panel.getByText(`本轮模型 ${fixtureModel} · max`, {exact:true});
const modelReads = [];
const modelUrl = url => url.pathname === '/api/chat/codex-attached' && url.searchParams.get('turn_model') === '1';
let releaseModelRead;
const modelReadAllowed = new Promise(resolve => {releaseModelRead = resolve;});
const transientModelFailure = async route => {
modelReads.push(new URL(route.request().url()).search);
if (modelReads.length === 1) {
await route.fulfill({status:503, json:{ok:false,error:'Temporary model observation outage'}});
return;
}
await modelReadAllowed;
const response = await route.fetch();
assert.equal(response.status(),200);
const result = await response.json();
assert.deepEqual(result.turn_model,{model:fixtureModel,reasoning_effort:'max'});
await route.fulfill({response});
};
await page.route(modelUrl, transientModelFailure);
await page.request.post(new URL('/fixture/live-start',url).href);
const desktopViewport = page.viewportSize();
try {
await panel.getByText('本轮模型未读取',{exact:true}).waitFor();
await panel.getByText('Original result',{exact:true}).waitFor();
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:resolve(outputDir,'attached-codex-model-unavailable-narrow.png'),animations:'disabled'});
releaseModelRead();
await recordedModel.waitFor({timeout:10_000});
assert.equal(modelReads.length,2,'A single temporary error retries once and then stops');
assert.equal(new Set(modelReads).size,1,'The retry reads the same binding and rollout revision');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),true);
await page.screenshot({path:resolve(outputDir,'attached-codex-model-recovered-narrow.png'),animations:'disabled'});
await page.setViewportSize(desktopViewport);
await page.screenshot({path:resolve(outputDir,'attached-codex-model-recovered-desktop.png'),animations:'disabled'});
} finally {
releaseModelRead();
await page.unroute(modelUrl, transientModelFailure);
}
await live.getByText('原宿主实时草稿',{exact:true}).waitFor();
const activeMarks = panel.getByRole('img',{name:'原宿主执行中',exact:true});
assert.equal(await activeMarks.count(),2,'Only the observed current message and tool show activity');
Expand All @@ -332,6 +370,7 @@ async function runConversation(browser, uncertain) {
await panel.getByRole('button',{name:'会话来源未接入',exact:true}).click();
await panel.getByText('来源:cursor。此宿主的工作记录与回复尚未接入。',{exact:true}).waitFor();
assert.equal(await live.count(),0,'Changing the Agent removes the old live records');
assert.equal(await recordedModel.count(),0,'Changing the Agent removes the old model observation');
await page.waitForFunction(async () => (await (await fetch('/fixture/following')).json()).events.at(-1) === false);
await panel.getByRole('combobox',{name:'查看 Agent'}).selectOption('worker');
await live.getByText('原宿主实时草稿,已持续更新。',{exact:true}).waitFor();
Expand All @@ -356,6 +395,7 @@ async function runConversation(browser, uncertain) {
assert.equal(await activeMarks.count(),0,'Completed work is never left animating');
await panel.locator('.attached-conversation-messages').getByText('原宿主实时草稿,已持续更新。',{exact:true}).waitFor();
await panel.getByText('原会话可回复',{exact:true}).waitFor();
assert.equal(await recordedModel.count(),0,'A finished Turn no longer has an active model label');
assert.equal(await inputCount(),0);
}
const sentOperations = [];
Expand Down
19 changes: 18 additions & 1 deletion examples/personal-workspace-browser/codex_attached_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,29 @@ def do_POST(self):
"payload": {
"type": "task_complete"
if finished
else "task_started"
else "task_started",
"turn_id": "live-workspace-fixture",
},
}
)
+ "\n"
)
if not finished:
stream.write(
json.dumps(
{
"type": "turn_context",
"timestamp": "2026-01-01T00:00:04Z",
"payload": {
"turn_id": "live-workspace-fixture",
"model": "codex-synthetic-attached-workspace-verification-model",
"model_provider": "openai",
"effort": "max",
},
}
)
+ "\n"
)
if finished:
append_message(rollout, "assistant", text)
request.publish_live(
Expand Down
50 changes: 50 additions & 0 deletions loopx/capabilities/native_chat/codex_attached.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

from contextlib import closing
from functools import lru_cache
import hashlib
import json
import os
Expand Down Expand Up @@ -275,5 +276,54 @@ def activity(home: Path, thread_id: str) -> dict[str, Any]:
return observe_codex_threads([thread_id], homes=[home])[thread_id].to_payload()


def _rollout_revision(stamp: os.stat_result) -> tuple[str, int]:
return history_revision(stamp), stamp.st_ctime_ns


@lru_cache(maxsize=32)
def _read_turn_model(
home: Path, path: Path, thread_id: str, revision: tuple[str, int]
) -> dict[str, str | None] | None:
from ...control_plane.effect_runtime import effect_runtime_result

# The shared owner validates the original thread and the matching open Turn.
# Cache by exact file revision so unchanged history reads stay inexpensive.
observation = effect_runtime_result(
"runtime.execution_identity.codex",
{"home": str(home), "path": str(path), "thread_id": thread_id},
timeout=1.0,
retry_safe=False,
)
if _rollout_revision(path.stat()) != revision:
raise ValueError("Original Turn changed during model readback")
if (
observation["status"] != "runtime_reported"
or observation["active_turn_verified"] is not True
):
return None
return {
"model": observation["model"],
"reasoning_effort": observation["reasoning_effort"],
}


def observed_turn_model(
home: Path, path: Path, thread_id: str, stamp: os.stat_result
) -> dict[str, str | None] | None:
revision = _rollout_revision(stamp)
try:
result = _read_turn_model(home, path, thread_id, revision)
if result is None:
return None
# The thread index can replace or archive a rollout without changing
# the previous file. Re-resolve the same source even on a cache hit.
current_home, current_path = locate_thread(thread_id)
if (current_home, current_path) != (home, path) or _rollout_revision(current_path.stat()) != revision:
return None
return result
except (OSError, RuntimeError, ValueError):
return None


def input_fingerprint(binding: str, text: str) -> str:
return hashlib.sha256((binding + "\0" + text).encode()).hexdigest()
28 changes: 28 additions & 0 deletions loopx/presentation/codex_attached_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
history_revision,
input_fingerprint,
locate_thread,
observed_turn_model,
open_conversation,
read_messages,
)
Expand Down Expand Up @@ -238,8 +239,15 @@ def _codex_attached_read(self) -> None:
"entry_id",
"content_offset",
"content_ref",
"turn_model",
} or any(len(v) != 1 for v in query.values()):
raise ValueError("Conversation query is invalid")
if "turn_model" in query and (
set(query)
!= {"goal_id", "agent_id", "binding_ref", "revision", "turn_model"}
or query["turn_model"] != ["1"]
):
raise ValueError("Model readback needs a single snapshot revision")
goal = self._codex_goal(query.get("goal_id", [""])[0])
if "agent_id" not in query:
http._send_json({"ok": True, "agents": bound_conversations(goal)})
Expand All @@ -262,6 +270,26 @@ def _codex_attached_read(self) -> None:
http._send_json(receipt_readback(receipt))
return
home, path = locate_thread(binding["thread_id"])
if "turn_model" in query:
# Model readback is optional and must not stall ordinary history.
stamp = path.stat()
revision = history_revision(stamp)
turn_model = None
thread_id = binding["thread_id"]
if (
query["revision"][0] == revision
and activity(home, thread_id)["state"] == "turn_open"
):
turn_model = observed_turn_model(home, path, thread_id, stamp)
current_binding(
self._codex_goal(query["goal_id"][0]),
query["agent_id"][0],
binding["binding_ref"],
)
http._send_json(
Comment thread
huangruiteng marked this conversation as resolved.
Resolved
{"ok": True, "revision": revision, "turn_model": turn_model}
)
return
if "entry_id" in query:
content = read_content(
path,
Expand Down
Loading
Loading