Skip to content
Open
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 @@ -5,6 +5,7 @@
"""
from __future__ import annotations

import base64
import json
import os
from pathlib import Path
Expand Down Expand Up @@ -155,12 +156,42 @@ def start(self, server):
if "--returned-files" in sys.argv:
returned_files, reply_evidence = self.seed_returned_files(server, original)
bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"])
history_images = {}
if "--history-images" in sys.argv:
def image(name, encoded):
return server.chat_store.append_message(sid, role="user", text=f"Original {name}", origin="lark",
attachments=[{"id": name, "name": name + ".png", "mime_type": "image/png",
"size": len(base64.b64decode(encoded)), "data_url": "data:image/png;base64," + encoded}])

old = image("archived-image", "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAIAAAA7ljmRAAAAEElEQVR4nGNQnLIKjhhwcgA6oBB17nqaVAAAAABJRU5ErkJggg==")
for index in range(75):
server.chat_store.append_message(sid, role="user", text=f"Original archived message {index:02d}.")
current = image("latest-image", "iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR4nGP4v0gdghjgLABwBAqxX1jWkgAAAABJRU5ErkJggg==")
server.chat_store.append_message(sid, role="agent", text="Latest original result after archived images.")
history_images = {"historyImages": {"old": old["message_id"], "current": current["message_id"]}}
original_image_handler = server.RequestHandlerClass._serve_chat_history_image
failed = False
failure_lock = threading.Lock()

def fail_first_image_read(handler, parts):
nonlocal failed
with failure_lock:
fail = parts[3] == sid and parts[5] == old["message_id"] and not failed
failed |= fail
if fail:
handler._send_error("Fixture image temporarily unavailable", status=503)
return True
return original_image_handler(handler, parts)

server.RequestHandlerClass._serve_chat_history_image = fail_first_image_read
journal = server.chat_store.sessions_root / sid / "messages.jsonl"
reply_evidence[journal] = journal.read_bytes()
other, _ = runtime.open_session(goal_id=None, agent_id="codex", objective="", mode="new",
work_dir=self.root / "project", project_ref=binding["project_ref"])
server.chat_store.append_message(other["session_id"], role="agent", text="Unrelated desktop conversation — must remain separate.")
print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}",
"bindingId": binding["binding_id"], "sessionId": sid, "ordinarySessionId": other["session_id"],
**returned_files}), flush=True)
**returned_files, **history_images}), flush=True)
settings_handler = server.RequestHandlerClass._private_conversations
history_handler = server.RequestHandlerClass._session_snapshot
sessions_handler = server.RequestHandlerClass._list_sessions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {launchBrowser, loadPlaywright} from "../../../../examples/dashboard-brow

const repo = resolve(import.meta.dirname, "../../../..");
const returnedFiles = process.argv.includes("--returned-files");
const historyImages = process.argv.includes("--history-images");
const output = resolve(process.env.LOOPX_REPLY_OUTPUT ?? resolve(repo, "output/playwright/conversation-reply"));
await mkdir(output, {recursive: true});
const child = spawn(resolveTestPython({repoRoot: repo}), ["-u",
"apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py", returnedFiles ? "--returned-files" : "--reply-context"],
"apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py", returnedFiles ? "--returned-files" : "--reply-context",
...(historyImages ? ["--history-images"] : [])],
{cwd: repo, env: {...process.env, LOOPX_USAGE_PING: "0"}, stdio: ["pipe", "pipe", "pipe"]});
const exited = once(child, "exit"), queue = [], waiting = [];
let stderr = "", browser;
Expand Down Expand Up @@ -99,6 +101,60 @@ async function checkReturnedFiles(ready, page, errors, writes) {
console.log(baseline ? "returned-files-browser: baseline reproduced (published files omitted from history and full reader; zero model Turns or repeat sends)"
: "returned-files-browser: passed (real publisher, bound source, original history/reader, byte-exact downloads, desktop/narrow, keyboard, English, zero model Turns or repeat sends)");
}

async function checkHistoryImages(ready, page, errors, writes) {
const {old, current} = ready.historyImages;
const requests = [];
const responses = [];
page.on("request", request => {
const url = new URL(request.url());
if (url.origin === ready.origin && url.pathname.includes("/images/")) requests.push(url.pathname);
});
page.on("response", response => {
const url = new URL(response.url());
if (url.origin === ready.origin && url.pathname.includes(`/messages/${old}/images/`))
responses.push({path: url.pathname, status: response.status()});
});
const requested = id => requests.filter(path => path.includes(`/messages/${id}/images/`)).length;
const timeline = page.locator(".personal-channel-timeline");
const image = name => timeline.locator(`img[alt="${name}.png"]`);
await page.goto(ready.origin + "/chat/");
await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
await timeline.getByText("Latest original result after archived images.", {exact: true}).waitFor();
await page.waitForFunction(() => document.querySelector('img[alt="latest-image.png"]')?.naturalWidth === 3);
assert.equal(await image("archived-image").evaluate(node => node.naturalWidth), 0);
assert.equal(requested(old), 0, "Opening the latest conversation must not request the offscreen image");
assert.equal(requested(current), 1);
await image("archived-image").scrollIntoViewIfNeeded();
const oldMessage = timeline.locator(`article[data-message-id="${old}"]`);
const retry = oldMessage.getByRole("button", {name: "重试图片"});
await retry.waitFor();
assert.equal(requested(old), 1, "Scrolling requests only the selected original image");
assert.deepEqual(responses.map(response => response.status), [503]);
await retry.click();
await page.waitForFunction(() => document.querySelector('img[alt="archived-image.png"]')?.naturalWidth === 4);
assert.equal(requested(old), 2, "An inline retry reads the same original image without leaving the conversation");
assert.deepEqual(responses.map(response => response.status), [503, 200]);
assert.equal(responses[0].path, responses[1].path, "Retry must preserve the original message and content digest");

requests.length = 0;
const focus = new URL(ready.origin + "/chat/");
focus.search = new URLSearchParams({view: "conversation", conversationBinding: ready.bindingId,
conversationSession: ready.sessionId, messageSession: ready.sessionId, message: old});
await page.goto(focus.href);
await page.waitForFunction(id => document.activeElement?.getAttribute("data-message-id") === id
&& document.querySelector('img[alt="archived-image.png"]')?.naturalWidth === 4, old);
assert.equal(requested(old), 1, "Opening an original message link positions and loads that image");
assert.equal(requested(current), 0, "An unrelated offscreen image stays deferred when reading a link");
assert.deepEqual(errors, []);
assert.ok(!writes.some(path => /\/turns(?:\/|$)/u.test(path)), "Reading history cannot submit a Turn");
child.stdin.write("inspect\n");
const inspection = await next();
assert.equal(inspection.model_turns, 0);
assert.equal(inspection.reply_evidence_unchanged, true);
console.log("history-images-browser: passed (latest, scroll, inline retry and original message link; zero model Turns)");
}

try {
const ready = await next();
browser = await launchBrowser(loadPlaywright().chromium);
Expand All @@ -107,7 +163,9 @@ try {
const errors = [], writes = [];
page.on("pageerror", error => errors.push(error.message));
page.on("request", request => {if (request.method() !== "GET") writes.push(new URL(request.url()).pathname);});
if (returnedFiles) {
if (historyImages) {
await checkHistoryImages(ready, page, errors, writes);
} else if (returnedFiles) {
await checkReturnedFiles(ready, page, errors, writes);
} else {
const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json();
Expand Down
21 changes: 15 additions & 6 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,8 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri
}

async function readChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string,
progress?: ChatHistoryReadProgress, checkpoint?: (progress: ChatHistoryReadProgress) => void): Promise<ChatSessionSnapshot> {
progress?: ChatHistoryReadProgress, checkpoint?: (progress: ChatHistoryReadProgress) => void,
linkImages = false): Promise<ChatSessionSnapshot> {
const sessionId = typeof session === "string" ? session : session.session_id;
// A delegation has the original Session ID; pin its first verified page's
// scope. History discovery callers keep validating against their own source.
Expand All @@ -1113,7 +1114,8 @@ async function readChatSessionHistory(session: ChatSessionSummary | string, sign
if (cursor !== null) query.set("message_cursor", cursor);
const snapshot = await requestJson<ChatSessionSnapshot>(
`/api/chat/sessions/${encodeURIComponent(sessionId)}?${query}`,
{signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)]) : AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)});
{signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)]) : AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS),
headers: historyImageHeaders(linkImages)});
signal?.throwIfAborted();
const expected = expectedSession;
const sameSource = snapshot.session?.session_id === sessionId && (!expected || sameHistorySource(snapshot.session, expected));
Expand Down Expand Up @@ -1167,15 +1169,21 @@ function historyBasisMatches(basis: string, count: number) {
&& match[2].length === Math.ceil(count / 200) * 64;
}

async function tryResumeChatSessionHistory(session: ChatSessionSummary, saved: ChatSessionSnapshot) {
function historyImageHeaders(linkImages: boolean): HeadersInit | undefined {
// The linked bytes are same-origin resources. A separately configured Chat
// server still uses the compatible inline representation.
return linkImages && !configuredChatOrigin ? {"X-LoopX-History-Images": "linked"} : undefined;
}

async function tryResumeChatSessionHistory(session: ChatSessionSummary, saved: ChatSessionSnapshot, linkImages: boolean) {
const basis = saved.verifiedHistoryBasis;
if (!basis || !sameHistorySource(saved.session, session) || !historyBasisMatches(basis, saved.messages.length)) return null;
const query = new URLSearchParams({message_limit: "200", known_history: basis});
let snapshot: ChatSessionSnapshot & {history_resume?: unknown};
try {
snapshot = await requestJson<typeof snapshot>(
"/api/chat/sessions/" + encodeURIComponent(session.session_id) + "?" + query,
{signal: AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)});
{signal: AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS), headers: historyImageHeaders(linkImages)});
} catch (error) {
// A service rollback may no longer accept this optional argument.
if (error instanceof ChatApiError && error.payload.http_status === 400) return null;
Expand Down Expand Up @@ -1351,9 +1359,10 @@ export async function fetchChatHistory(options: {
const saved = options.bound && !progress && previous?.snapshots.find(row => row.session.session_id === session.session_id);
let snapshot: ChatSessionSnapshot;
try {
snapshot = (saved ? await tryResumeChatSessionHistory(session, saved) : null)
const linkImages = Boolean(options.bound);
snapshot = (saved ? await tryResumeChatSessionHistory(session, saved, linkImages) : null)
?? await readChatSessionHistory(session, undefined, undefined, progress,
checkpoint => pendingReads.set(session.session_id, checkpoint));
checkpoint => pendingReads.set(session.session_id, checkpoint), linkImages);
} catch (error) {
// Reuse the hook's existing retry schedule only for a lost transport.
// Revoked sources, invalid pages and stale cursors must start a fresh read.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {DocumentAttachment, isDocumentAttachment} from "./document-attachment";
import { MessageActivity } from "./message-activity";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import {Fragment, useLayoutEffect, useRef, useState} from "react";
import {Fragment, useEffect, useLayoutEffect, useRef, useState} from "react";
import { CollaborationCards } from "./collaboration-card";
import {ConversationReplyContext} from "./conversation-requests";
import { Activity, Bot, Sparkles } from "lucide-react";
Expand Down Expand Up @@ -30,6 +30,28 @@ function answerLink(sessionId: string, messageId: string) {
return url.toString();
}

function LinkedHistoryImage({name, source, ready, zh}: {name: string; source: string; ready: boolean; zh: boolean}) {
const ref = useRef<HTMLImageElement>(null);
const [imageRead, setImageRead] = useState<{source: string; failed?: boolean}>();
const requested = imageRead?.source === source;
useEffect(() => {
const image = ref.current;
if (!image || !ready || requested) return;
const observer = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting) return;
observer.disconnect();
setImageRead({source});
}, {root: image.closest(".personal-channel-scroll"), rootMargin: "64px 0px"});
observer.observe(image);
return () => observer.disconnect();
}, [ready, requested, source]);
if (requested && imageRead.failed) return <div className="personal-history-notice">
<div role="status"><span>{name}</span><span>{zh ? "图片无法显示" : "Could not load image"}</span></div>
<button type="button" onClick={() => setImageRead({source})}>{zh ? "重试图片" : "Retry image"}</button>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 刷新后使用已验证的新图片地址。 在真实打包界面保持同一原会话、消息和附件槽,只替换隔离样本中的一张图。旧分页证明返回 restart,新地址返回 200 和 57,803 字节。点击「刷新状态」完成历史读取后,这里的重试仍发出同一旧地址并返回 404。重开原消息后可见新图。现有 reconcileConversationReturns 保留旧附件,即使同源历史已读回新链接。请只从同一原 Session 和消息的已验证历史更新附件引用,保留旧摘要 404、原位 503 重试及活跃消息,补一个实际浏览器回归。

</div>;
return <img ref={ref} alt={name} src={requested ? source : undefined}
onError={() => setImageRead(current => current?.source === source ? {source, failed: true} : current)}/>;
}

export function ChannelTimeline({
emptyState,
Expand All @@ -46,13 +68,15 @@ export function ChannelTimeline({
onCancelPreparation,
messageFocus,
historyPhase = "ready",
historyPositionReady = true,
onReadMessage,
onRetryHistory,
queuedTurnKeys,
}: {
emptyState?: { title: string; description: string };
messageFocus?: {sessionId: string; messageId: string};
historyPhase?: ConversationHistoryStatus["phase"];
historyPositionReady?: boolean;
onReadMessage?: () => void;
onRetryHistory?: () => void;
queuedTurnKeys?: ReadonlySet<string>;
Expand Down Expand Up @@ -90,23 +114,24 @@ export function ChannelTimeline({
&& !item.message.pending && item.message.sourceSessionId && item.message.sourceMessageId).map(item => messageKey(item.message))));
}
const focusTarget = useRef<HTMLElement>(null);
const restoredFocus = useRef<string | null>(null);
const [restoredFocus, setRestoredFocus] = useState<string | null>(null);
const focusKey = messageFocus ? JSON.stringify([messageFocus.sessionId, messageFocus.messageId]) : null;
const focusPresent = Boolean(messageFocus && items.some(item => item.kind === "message"
&& item.message.sourceSessionId === messageFocus.sessionId && item.message.sourceMessageId === messageFocus.messageId));
useLayoutEffect(() => {
if (!focusKey) {restoredFocus.current = null; return;}
if (!focusPresent || restoredFocus.current === focusKey || !focusTarget.current) return;
if (!focusKey) {if (restoredFocus !== null) setRestoredFocus(null); return;}
if (!focusPresent || restoredFocus === focusKey || !focusTarget.current) return;
onReadMessage?.();
const frame = window.requestAnimationFrame(() => {
if (!focusTarget.current) return;
onReadMessage?.();
focusTarget.current.scrollIntoView({block: "start"});
focusTarget.current.focus({preventScroll: true});
restoredFocus.current = focusKey;
setRestoredFocus(focusKey);
});
return () => window.cancelAnimationFrame(frame);
}, [focusKey, focusPresent, onReadMessage]);
}, [focusKey, focusPresent, onReadMessage, restoredFocus]);
const loadHistoryImages = historyPositionReady && (!focusPresent || restoredFocus === focusKey);
const focusNotice = messageFocus && !focusPresent ? <div className="personal-message-focus-notice" role="status">
<span>{historyPhase === "loading" ? (locale === "zh-CN" ? "正在定位原消息…" : "Finding the original message…")
: historyPhase === "ready" ? (locale === "zh-CN" ? "原消息不在当前已读取的对话中。" : "The original message is not in the loaded conversation.")
Expand Down Expand Up @@ -210,7 +235,13 @@ export function ChannelTimeline({
<header><strong>{item.message.role === "user" ? item.message.agentLabel ?? t("common.you") : item.message.agentLabel ?? t("header.manager")}</strong>{item.message.time ? <time>{item.message.time}</time> : null}</header>
<ReturnDeliveryStatus delivery={item.message.returnDelivery} />
{item.message.queueInputCancelled ? <span>{locale === "zh-CN" ? "已取消排队" : "Cancelled before starting"}</span> : null}
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => isDocumentAttachment(attachment.mimeType) ? <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => {
if (isDocumentAttachment(attachment.mimeType)) return <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/>;
const linked = attachment.dataUrl.startsWith("/api/chat/sessions/") && attachment.dataUrl.includes("/images/");
return linked ? <LinkedHistoryImage key={attachment.id} name={attachment.name} source={attachment.dataUrl}
ready={loadHistoryImages} zh={locale === "zh-CN"}/>
: <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} loading="lazy"/>;
})}</div> : null}
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <HistoricalMessageText text={item.message.text}
initiallyCollapsed={Boolean(historical?.has(messageKey(item.message)))}
focused={Boolean(messageFocus && messageKey(item.message) === focusKey)} zh={locale === "zh-CN"} onRead={onReadMessage}/> : null}
Expand Down
Loading
Loading