diff --git a/apps/presentation/dashboard/smoke/chat-route-smoke.ts b/apps/presentation/dashboard/smoke/chat-route-smoke.ts index 937501ba86..7fa384a7c8 100644 --- a/apps/presentation/dashboard/smoke/chat-route-smoke.ts +++ b/apps/presentation/dashboard/smoke/chat-route-smoke.ts @@ -540,13 +540,13 @@ async function checkSessionHistoryRecoveryContract() { globalThis.fetch = (async (input) => { const url = String(input); requests.push(url); - const body = url.includes("?") + const body = new URL(url, "http://localhost").pathname === "/api/chat/sessions" ? { ok: true, schema_version: "loopx_chat_session_list_v1", sessions: [failed.session, ready.session], } - : url.endsWith("session-failed") + : new URL(url, "http://localhost").pathname.endsWith("session-failed") ? failed : ready; return new Response(JSON.stringify(body), { @@ -676,8 +676,66 @@ async function checkActiveTurnStreamRecoveryContract() { } } +async function checkPagedHistoryContract() { + const originalFetch = globalThis.fetch; + const rows = Array.from({length: 451}, (_, index) => ({ + message_id: `original-${index}`, turn_id: null, role: index % 2 ? "agent" : "user", + text: index === 201 ? "完整正文".repeat(3000) : "Deliberately repeated text", + created_at: "2026-08-10T02:00:00Z", + ...(index === 201 ? {attachments: [{id: "original-image", name: "original.png", mime_type: "image/png", size: 1, data_url: "data:image/png;base64,AA=="}]} : {}), + })); + const original = historySnapshot("paged-original", "ready", rows); + let failure = "", reads: string[] = [], sourceReads = 0; + const bindingId = "a".repeat(24); + globalThis.fetch = (async (input, init) => { + check(!init?.method || init.method === "GET", "History paging must not execute work"); + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/chat/sessions") { + sourceReads += 1; + return Response.json({ok: true, schema_version: "loopx_chat_session_list_v1", + conversation_binding_id: failure === "revoked" && sourceReads > 1 ? "b".repeat(24) : bindingId, sessions: [original.session]}); + } + const cursor = url.searchParams.get("message_cursor"); + reads.push(cursor ?? "first"); + const offset = cursor === "page-200" ? 200 : cursor === "page-400" ? 400 : 0; + if (offset && failure === "transport") return Response.json({error: "interrupted page"}, {status: 503}); + const messages = rows.slice(offset, offset + 200); + const next = offset === 0 ? "page-200" : offset === 200 ? "page-400" : null; + return Response.json({...original, messages, + session: {...original.session, ...(offset && failure === "identity" ? {channel_id: "another-channel"} : {})}, + message_page: {schema_version: "loopx_chat_message_page_v1", offset: offset && failure === "offset" ? 0 : offset, + total: offset && failure === "total" ? 452 : rows.length, + snapshot_sha256: offset && failure === "digest" ? "b".repeat(64) : "a".repeat(64), + next_cursor: offset && failure === "cursor" ? cursor : next}}); + }) as typeof fetch; + try { + const history = await fetchChatHistory({channelId: "manager"}); + check(history.messages.length === rows.length && history.unavailableSessionIds.length === 0, + "The default history owner must drain all original pages before claiming a complete snapshot"); + check(reads.join(",") === "first,page-200,page-400", "Read the original advancing cursors exactly once"); + check(JSON.stringify(history.snapshots[0].messages) === JSON.stringify(rows), + "Page boundaries must retain append order, repeated bodies, full long text and attachments"); + for (failure of ["transport", "identity", "offset", "total", "digest", "cursor"]) { + reads = []; + const incomplete = await fetchChatHistory({channelId: "manager"}); + check(incomplete.messages.length === 0 && incomplete.unavailableSessionIds[0] === original.session.session_id, + `An interrupted or inconsistent ${failure} page must not become a complete history`); + check(reads.length === 2, "A failed cursor must stop the read, without a loop or replay"); + } + failure = ""; + check((await fetchChatHistory({channelId: "manager"})).messages.length === rows.length, + "Retry must recover the full original snapshot after a partial-page failure"); + failure = "revoked"; sourceReads = 0; + let refused = false; + try { await fetchChatHistory({channelId: "manager", bound: {bindingId, sessionId: original.session.session_id}}); } + catch { refused = true; } + check(refused && sourceReads === 2, "Recheck the original source after all pages; a revoked binding cannot publish the old read"); + } finally { globalThis.fetch = originalFetch; } +} + void checkSessionCloseContract() .then(checkSessionHistoryRecoveryContract) + .then(checkPagedHistoryContract) .then(checkActiveTurnStreamRecoveryContract) .then(() => console.log("chat-route-smoke: ok")) .catch((error) => { diff --git a/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py b/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py index d16577cc23..2dbfbb3d9c 100644 --- a/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py +++ b/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py @@ -6,6 +6,7 @@ import sys import tempfile import threading +from urllib.parse import parse_qs, urlparse sys.path.insert(0, str(Path(__file__).resolve().parents[4])) @@ -16,7 +17,9 @@ class HistoryReadFault(ChatRequestHandler): def _session_snapshot(self, session_id: str) -> None: - if session_id == self.server.unavailable_session_id: + query = parse_qs(urlparse(self.path).query) + if (session_id == self.server.unavailable_session_id + or (session_id == "old" and self.server.unavailable_message_pages and "message_cursor" in query)): self._send_error("Synthetic history read unavailable", status=503) else: super()._session_snapshot(session_id) @@ -50,6 +53,7 @@ def main() -> None: server.chat_store = store server.runtime_controller = runtime server.unavailable_session_id = "old" + server.unavailable_message_pages = False thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() print(json.dumps({"origin": f"http://127.0.0.1:{server.server_port}"}), flush=True) @@ -58,7 +62,19 @@ def main() -> None: command = command.strip() if command == "recover": server.unavailable_session_id = "" + server.unavailable_message_pages = False print(json.dumps({"recovered": True}), flush=True) + elif command == "history": + for index in range(450): + store.append_message("old", role="agent", message_id=f"history-{index}", + text="Original full body. " * 600 if index == 201 else f"Paged original row {index}") + before = {str(path.relative_to(store.sessions_root)): path.read_bytes() + for path in store.sessions_root.rglob("*") if path.is_file()} + print(json.dumps({"history_rows": len(store.messages("old"))}), flush=True) + elif command == "page-fault": + server.unavailable_session_id = "" + server.unavailable_message_pages = True + print(json.dumps({"unavailable_message_pages": True}), flush=True) elif command == "fault": server.unavailable_session_id = "old" print(json.dumps({"unavailable": "old"}), flush=True) diff --git a/apps/presentation/dashboard/smoke/conversation-return-browser-smoke.mjs b/apps/presentation/dashboard/smoke/conversation-return-browser-smoke.mjs index 3e998646f4..1456c0b7f6 100644 --- a/apps/presentation/dashboard/smoke/conversation-return-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/conversation-return-browser-smoke.mjs @@ -26,14 +26,16 @@ async function next() { } async function command(value) { fixture.stdin.write(`${value}\n`); return next(); } let server, browser, workspace; +const reads = []; try { const { origin } = await next(); await command("recover"); + assert.deepEqual(await command("history"), {history_rows: 451}); + await command("page-fault"); server = await startServer(); const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`; await waitForHttp(url); browser = await launchBrowser(loadPlaywright().chromium); - const reads = []; workspace = await openWorkspacePage({ newPage: options => browser.newPage({ locale: "zh-CN", ...options }) }, url, { beforeGoto(_api, page) { return page.route("**/api/chat/sessions**", async route => { @@ -53,7 +55,9 @@ try { } assert.equal(route.request().method(), "GET", "Observation cannot start work"); const response = await route.fetch({ url: new URL(path + new URL(route.request().url()).search, origin).href }); - reads.push({ path, status: response.status() }); + const snapshot = response.status() === 200 ? await response.json() : null; + reads.push({ path, query: new URL(route.request().url()).search, status: response.status(), offset: snapshot?.message_page?.offset, + sessions: snapshot?.sessions?.map(row => ({id: row.session_id, revision: row.transcript_revision})) }); await route.fulfill({ response }); }); }, @@ -61,7 +65,33 @@ try { const { page, api } = workspace; const chat = () => page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); await chat(); + const notice = page.getByRole("status").filter({hasText: "部分历史暂时无法读取"}); + await notice.waitFor({state: "visible"}); + assert.ok(reads.some(row => row.path === "/api/chat/sessions/old" && row.status === 503)); + assert.equal(await page.getByText("Earlier public report", {exact: true}).count(), 0, + "The first real HTTP page must not masquerade as a complete history"); + const composer = page.getByLabel("向 LoopX 发送消息"); + await composer.fill("Keep this draft while original history recovers."); + await mkdir(outputDir, {recursive: true}); + await page.screenshot({path: resolve(outputDir, "conversation-pagination-before-desktop.png"), animations: "disabled"}); + await command("recover"); + await notice.getByRole("button", {name: "重试读取", exact: true}).click(); await page.getByText("Earlier public report", { exact: true }).waitFor({ state: "visible" }); + await page.getByText("Paged original row 449", {exact: true}).waitFor({state: "visible"}); + assert.equal(await page.getByText("Original full body. ".repeat(600).trim(), {exact: true}).count(), 1); + await notice.waitFor({state: "hidden"}); + assert.deepEqual(reads.filter(row => row.path === "/api/chat/sessions/old" && row.status === 200 && typeof row.offset === "number").slice(-3).map(row => row.offset), [0, 200, 400]); + assert.equal(await composer.inputValue(), "Keep this draft while original history recovers."); + await page.screenshot({path: resolve(outputDir, "conversation-pagination-after-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await page.locator("body").evaluate(body => body.scrollWidth <= innerWidth + 1)); + await page.screenshot({path: resolve(outputDir, "conversation-pagination-after-narrow.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 982}); + if (process.argv.includes("--pagination-only")) { + assert.equal(api.turnRequests.length, 0); + assert.deepEqual(await command("inspect"), {store_unchanged: true, turn_count: 0}); + console.log("conversation-pagination-browser: passed (packaged UI, real HTTP/store, 451 complete rows, second-page failure/retry, full body, draft and zero Turns)"); + } else { await command("result"); await page.getByText("First checked result", { exact: true }).waitFor({ state: "visible", timeout: 12_000 }); const firstLink = page.locator('a[href="https://example.org/forms/result"]'); @@ -73,9 +103,9 @@ try { await page.waitForTimeout(6500); assert.equal(oldReadCount(), settledReads, "Quiet completed transcripts must not be downloaded repeatedly"); await page.locator(".personal-goal-link").first().click(); + const failedRead = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions/old" && response.status() === 503); await command("fault"); await command("revision"); - const failedRead = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions/old" && response.status() === 503); await failedRead; const recoveredRead = page.waitForResponse(response => new URL(response.url()).pathname === "/api/chat/sessions/old" && response.status() === 200); await command("recover"); @@ -96,6 +126,11 @@ try { assert.ok(await page.locator("body").evaluate(body => body.scrollWidth <= innerWidth + 1)); await page.screenshot({ path: resolve(outputDir, "conversation-return-revisions-mobile.png"), animations: "disabled" }); console.log("conversation-return-browser: passed (packaged UI, real HTTP/store, quiet reads, background revision, read recovery, no duplicate or model Turn)"); + } +} catch (error) { + console.error(JSON.stringify({recent_history_reads: reads.filter(row => row.path !== "/api/chat/sessions").slice(-10), + recent_indexes: reads.filter(row => row.path === "/api/chat/sessions").slice(-8)}, null, 2)); + throw error; } finally { await workspace?.page.unrouteAll({ behavior: "wait" }); await workspace?.close(); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 05331962ca..bf3d0e8f07 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -940,12 +940,69 @@ export type ChatSessionSnapshot = { session: ChatSessionSummary; messages: ChatVisibleMessage[]; active_turn: Record | null; + message_page?: { + schema_version: "loopx_chat_message_page_v1"; + offset: number; total: number; snapshot_sha256: string; next_cursor: string | null; + }; }; export async function fetchChatSession(sessionId: string, signal?: AbortSignal) { return requestJson(`/api/chat/sessions/${encodeURIComponent(sessionId)}`, { signal }); } +const chatMessagePageSchema = z.object({ + schema_version: z.literal("loopx_chat_message_page_v1"), + offset: z.number().int().nonnegative(), total: z.number().int().nonnegative(), + snapshot_sha256: z.string().regex(/^[a-f0-9]{64}$/), next_cursor: z.string().min(1).max(1024).nullable(), +}); + +/** Hydrate one complete original prefix; partial pages never become a snapshot. */ +async function fetchChatSessionHistory(session: ChatSessionSummary): Promise { + let first: ChatSessionSnapshot | undefined, cursor: string | null = null; + let total: number | undefined, digest: string | undefined; + const messages: ChatVisibleMessage[] = [], cursors = new Set(), ids = new Set(); + do { + const query = new URLSearchParams({message_limit: "200"}); + if (cursor !== null) query.set("message_cursor", cursor); + const snapshot = await requestJson( + `/api/chat/sessions/${encodeURIComponent(session.session_id)}?${query}`, {signal: AbortSignal.timeout(5000)}); + const sameSource = snapshot.session?.session_id === session.session_id && + (["agent_id", "goal_id", "channel_id", "managed_agent_id", "goal_instance_id", "project_ref"] as const) + .every(key => (snapshot.session[key] ?? null) === (session[key] ?? null)) && + (snapshot.session.session_mode ?? "managed_runtime") === (session.session_mode ?? "managed_runtime"); + if (snapshot.ok !== true || snapshot.schema_version !== "loopx_chat_store_v1" || !sameSource || !Array.isArray(snapshot.messages)) { + throw new Error("The original Session scope changed during history read."); + } + // Older services return their complete v1 snapshot for this read. A later + // page cannot silently lose the cursor contract and masquerade as complete. + if (!Object.hasOwn(snapshot, "message_page")) { + if (cursor !== null) throw new Error("The history page contract changed during read."); + return snapshot; + } + const parsed = chatMessagePageSchema.safeParse(snapshot.message_page); + if (!parsed.success) throw new Error("The original history page could not be verified."); + const page = parsed.data, end = messages.length + snapshot.messages.length; + if (page.offset !== messages.length || snapshot.messages.length > 200 || end > page.total + || (total !== undefined && total !== page.total) || (digest !== undefined && digest !== page.snapshot_sha256) + || (page.next_cursor !== null) !== (end < page.total) + || (page.next_cursor !== null && (!snapshot.messages.length || cursors.has(page.next_cursor)))) { + throw new Error("The original history cursor did not advance consistently."); + } + for (const message of snapshot.messages) { + if (typeof message.message_id !== "string" || !message.message_id || ids.has(message.message_id)) { + throw new Error("The original history contains conflicting message identities."); + } + ids.add(message.message_id); messages.push(message); + } + first ??= snapshot; + total = page.total; digest = page.snapshot_sha256; cursor = page.next_cursor; + if (cursor !== null) cursors.add(cursor); + } while (cursor !== null); + // Keep the first read's execution/revision hint. An append between pages is + // intentionally outside this prefix and will trigger the normal next refresh. + return {...first!, messages, message_page: undefined}; +} + /** Read-only discovery through the existing Core Goal/Agent/Session identity. */ export async function fetchManagedAgentSessions(goalId: string, agentId: string, signal?: AbortSignal) { if (!/^[A-Za-z0-9._-]{1,160}$/.test(goalId) || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(agentId)) throw new Error("Invalid registered Agent address."); @@ -1067,7 +1124,7 @@ export async function fetchChatHistory(options: { }); if (exact) for (const session of missing) known.delete(session.session_id); const results = await Promise.allSettled(missing.map(async (session) => { - const snapshot = await fetchChatSession(session.session_id, AbortSignal.timeout(5000)); + const snapshot = await fetchChatSessionHistory(session); if (snapshot.session.session_id !== session.session_id || snapshot.session.agent_id !== session.agent_id) { throw new Error("The original Session identity changed during history read."); } @@ -1076,6 +1133,12 @@ export async function fetchChatHistory(options: { || snapshot.session.session_mode !== "managed_runtime")) throw new Error("The original managed Session scope changed during history read."); return snapshot; })); + // A long page sequence must not publish against a connection/Goal removed + // while it was in flight. Reuse the original source owner, never a fallback. + if (missing.length && results.some(result => result.status === "fulfilled")) { + if (options.bound) await resolveBoundChatSession(options.bound, AbortSignal.timeout(5000)); + if (options.managed) await resolveManagedChatSession(options.managed, AbortSignal.timeout(5000)); + } for (const result of results) { if (result.status === "fulfilled") known.set(result.value.session.session_id, result.value); } diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 1813940400..93a2c67bed 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -1298,6 +1298,27 @@ already timed-out fetch. [Synthetic source-order captures](../../assets/personal qualify this read/display boundary, not full history pagination, reply threading or installed App/Lark bidirectional delivery. +The same Session snapshot endpoint now supports opt-in `message_limit` (1–200) +and `message_cursor`. Its additive `loopx_chat_message_page_v1` read metadata +binds an offset to the original Session, frozen row count and raw-prefix digest. +Appending during a read cannot move that boundary; rewriting/truncating it or +an unreadable journal refuses the page. Complete bodies and attachments are not +trimmed. Collaboration projections choose the carrier from the whole frozen +context, then project the selected page and its related return labels; crossing +a page does not create another carrier or reload every off-page Turn. The TS +history owner drains and validates every page before publishing a complete +snapshot, then rechecks an exact connection/Goal source. Interrupted, repeated, +mis-scoped or inconsistent cursors remain unreadable and retry through the +existing recovery path, without model replay. Unpaged v1 callers remain +compatible. This is a stateless read hint, not a new store, consumer, execution +state or grant. Response row count is bounded; server-side journal reads and +prefix hashing still scale with full history and no latency improvement is +claimed. HTTP/store and source/packaged browser checks qualify this bounded +history path; installed native input, reply threading and live App/Lark media +remain separate acceptance. The scoped packaged `--pagination-only` journey +does not certify the default return scenario's background-read wait, which also +times out with the unchanged baseline production code; that gap remains open. + Following the newest reply also survives a resized viewport or timeline, including image layout and expanded replies, without waiting for another message. Scrolling up keeps the reader in history across layout changes; the existing latest-message diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index f6effbe558..40379c32b4 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -966,6 +966,20 @@ App 历史与重连读取同一条消息;managed 和 attached 完成存储均 [合成顺序截图](../../assets/personal-workspace/conversation-history/README.md)只验收该读取/显示边界, 完整历史分页、回复线程和已安装 App/飞书双向送达仍分别验收。 +同一 Session 快照接口支持显式 `message_limit`(1–200)和 `message_cursor`。 +新增的 `loopx_chat_message_page_v1` 只读元数据把偏移绑定到原 Session、冻结记录数 +和原始前缀摘要。读取期间追加消息不移动该边界;前缀被改写、截短或 journal 无法 +完整读取时明确拒绝,正文与附件不裁剪。协作投影从完整冻结上下文选择原卡片载体, +只投影本页及相关回传标签;跨页不重复挂载卡片,也不重读全部页外 Turn。 +TS 历史 owner 校验并读完全部页面,随后复核 +确切连接/Goal 来源,才发布完整快照;断线、重复、来源不符或不一致的游标保留为 +不可读,通过既有恢复路径重读,不重放模型。未分页 v1 调用保持兼容。游标是无状态 +读取提示,不增加 store、consumer、执行状态或 grant。响应记录数有界,但服务端 +journal 读取和前缀摘要仍随完整历史增长,不宣称延迟改善。HTTP/store 与源码、 +打包浏览器检查验收该历史路径;实际原生输入、回复线程和真实 App/飞书媒体仍需 +分别验收。打包 `--pagination-only` 有界旅程不代表默认回传场景的后台读取等待已通过; +该等待在未修改的基线生产代码上同样超时,缺口继续保留。 + 同一对话界面在用户停留底部时跟随输出,上翻阅读后保持位置,并提供回到最新消息的入口。 窗口或时间线尺寸变化(包括图片布局与展开回复)后仍应跟随最新回复,不必等待下一条消息。 用户上翻阅读时,布局变化保留当前位置;既有回到最新消息按钮恢复跟随。该行为留在共享 diff --git a/docs/assets/personal-workspace/conversation-pagination/README.md b/docs/assets/personal-workspace/conversation-pagination/README.md new file mode 100644 index 0000000000..46578f7b81 --- /dev/null +++ b/docs/assets/personal-workspace/conversation-pagination/README.md @@ -0,0 +1,34 @@ +# Original history pagination: failure and recovery + +These are packaged browser captures of the production Chat HTTP/store with a +disposable synthetic runtime. The workspace navigation is synthetic; no personal +conversation, credentials, live Goal, provider or model is used. + +- `before-desktop.png` (1512×982): the second page is deliberately unavailable. + The current report remains visible, the incomplete-history notice offers retry, + and the draft stays in the composer. This is a fault state, not a baseline-version screenshot. +- `after-desktop.png` (1512×982): the same owner recovered all 451 original rows + through offsets 0, 200 and 400; the viewport shows the tail and retained draft. +- `after-narrow.png` (390×844): the recovered tail and draft remain usable without + horizontal overflow. The original long body is checked in the DOM, not inferred + from this tail screenshot. + +Reproduce after `npm run build:chat` in `apps/presentation/dashboard`: + +```sh +node apps/presentation/dashboard/smoke/conversation-return-browser-smoke.mjs --pagination-only +``` + +The scoped scenario checks unchanged stored bytes and zero Turns after observation. +The default full return scenario also checks later results/revisions and quiet reads; +its background-read wait times out on both the unchanged baseline production code +(`2de490ed6c632e0d641453fd2f10ed3dde69fc48`) and this branch. That retained gap +is not qualified by the pagination-only result. Cross-page original +commitment/return metadata is checked independently against the real projection +in `tests/test_chat_conversation_bindings.py`. Source and packaged recovery +scenarios retain full text and drafts without replay. + +All pixels and PNG chunk metadata were inspected; no textual metadata is present. +These captures belong to the code in this PR. The fixture's unrelated delegation +read is unavailable; these images do not qualify delegation completion, live Lark +transport, actual native App input, reply threading or installed media delivery. diff --git a/docs/assets/personal-workspace/conversation-pagination/after-desktop.png b/docs/assets/personal-workspace/conversation-pagination/after-desktop.png new file mode 100644 index 0000000000..a6a1311798 Binary files /dev/null and b/docs/assets/personal-workspace/conversation-pagination/after-desktop.png differ diff --git a/docs/assets/personal-workspace/conversation-pagination/after-narrow.png b/docs/assets/personal-workspace/conversation-pagination/after-narrow.png new file mode 100644 index 0000000000..c9506ead26 Binary files /dev/null and b/docs/assets/personal-workspace/conversation-pagination/after-narrow.png differ diff --git a/docs/assets/personal-workspace/conversation-pagination/before-desktop.png b/docs/assets/personal-workspace/conversation-pagination/before-desktop.png new file mode 100644 index 0000000000..8acdee571e Binary files /dev/null and b/docs/assets/personal-workspace/conversation-pagination/before-desktop.png differ diff --git a/examples/personal-workspace-browser/conversation-history-recovery.mjs b/examples/personal-workspace-browser/conversation-history-recovery.mjs index 12344c4bd0..67ad3bc8d4 100644 --- a/examples/personal-workspace-browser/conversation-history-recovery.mjs +++ b/examples/personal-workspace-browser/conversation-history-recovery.mjs @@ -3,6 +3,61 @@ import { resolve } from "node:path"; import { outputDir } from "./fixture.mjs"; import { openWorkspacePage } from "./scenario-context.mjs"; +async function pagedHistoryRecovery(browser, url) { + const id = "session-manager-loopx-manager-codex"; + const secondStarted = Promise.withResolvers(), releaseSecond = Promise.withResolvers(); + let failSecond = true, held = false; + const pages = []; + const rows = Array.from({length: 451}, (_, index) => ({message_id: `paged-${index}`, turn_id: null, + role: index % 2 ? "agent" : "user", text: index === 201 ? "原始长文。".repeat(2400) : `原始历史第 ${index} 条。`, + created_at: "2026-08-13T01:00:00Z"})); + const context = await openWorkspacePage(browser, url, { + async beforeGoto(_api, page) { + const runtime = page.__loopxRuntime; + runtime.sessions.clear(); + const session = {session_id: id, goal_id: "loopx-manager", agent_id: "codex", adapter_kind: "codex_app_server", + channel_id: "manager", status: "ready", active_turn_id: null, resumable: true, + created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:00Z", last_activity_at: "2026-08-13T01:00:00Z", last_error_code: null}; + runtime.sessions.set(id, session); runtime.messages.set(id, rows); + await page.route("**/api/chat/sessions/*", async route => { + const parsed = new URL(route.request().url()); + if (route.request().method() !== "GET" || parsed.pathname !== `/api/chat/sessions/${id}` || !parsed.searchParams.has("message_limit")) return route.fallback(); + const cursor = parsed.searchParams.get("message_cursor"), offset = cursor === "page-200" ? 200 : cursor === "page-400" ? 400 : 0; + pages.push(offset); + if (offset === 200 && failSecond) { + if (!held) { held = true; secondStarted.resolve(); await releaseSecond.promise; } + return route.fulfill({status: 503, json: {error: "Synthetic interrupted second page"}}); + } + await route.fulfill({json: {ok: true, schema_version: "loopx_chat_store_v1", session, active_turn: null, + messages: rows.slice(offset, offset + 200), message_page: {schema_version: "loopx_chat_message_page_v1", + offset, total: rows.length, snapshot_sha256: "a".repeat(64), next_cursor: offset === 0 ? "page-200" : offset === 200 ? "page-400" : null}}}); + }); + }, + }); + const {page, api} = context; + try { + await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click(); + await Promise.race([secondStarted.promise, new Promise((_, reject) => setTimeout(() => reject(new Error("The second original history page was not read")), 5000))]); + const composer = page.getByLabel("向 LoopX 发送消息"); + await composer.fill("分页恢复期间保留这条草稿。"); + assert.equal(await page.getByRole("button", {name: "发送", exact: true}).isEnabled(), false); + releaseSecond.resolve(); + await page.getByText("正在恢复当前会话,恢复后可以继续发送。", {exact: true}).waitFor({state: "visible"}); + assert.equal(await page.getByText(rows[0].text, {exact: true}).count(), 0, "A partial first page cannot masquerade as complete history"); + failSecond = false; + await page.getByRole("button", {name: "重试读取", exact: true}).click(); + await page.getByText(rows[450].text, {exact: true}).waitFor({state: "visible"}); + assert.equal(await page.getByText(rows[0].text, {exact: true}).count(), 1); + assert.equal(await page.getByText(rows[201].text, {exact: true}).count(), 1, "Keep the entire original long body across pages"); + assert.equal(await composer.inputValue(), "分页恢复期间保留这条草稿。"); + await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled); + assert.deepEqual(pages.slice(-3), [0, 200, 400]); + assert.equal(api.turnRequests.length, 0, "History paging and retry cannot replay or dispatch business"); + await page.setViewportSize({width: 390, height: 844}); + assert.ok(await page.locator("body").evaluate(body => body.scrollWidth <= innerWidth + 1)); + } finally { releaseSecond.resolve(); await context.close(); } +} + async function overlappingHistoryRefresh(browser, url) { const currentId = "session-manager-loopx-manager-codex", oldId = "older-overlap-session", turnId = "overlap-recovered-turn"; const staleRead = Promise.withResolvers(), staleStarted = Promise.withResolvers(), staleSettled = Promise.withResolvers(), stream = Promise.withResolvers(); @@ -215,6 +270,7 @@ async function recoveryObserverHandover(browser, url) { export const conversationHistoryRecoveryScenario = { id: "conversation-history-recovery", async run({ browser, collectCoverage, url }) { + await pagedHistoryRecovery(browser, url); await overlappingHistoryRefresh(browser, url); const currentId = "session-manager-loopx-manager-codex"; const oldId = "previous-worker-session"; diff --git a/loopx/capabilities/manager_context/presentation.py b/loopx/capabilities/manager_context/presentation.py index acc6bd9f45..b296bf9761 100644 --- a/loopx/capabilities/manager_context/presentation.py +++ b/loopx/capabilities/manager_context/presentation.py @@ -13,20 +13,32 @@ from ...control_plane.collaboration.inbox import controlled_request_readback -def project_collaboration(store, root, session_id, messages, *, registry): +def project_collaboration(store, root, session_id, messages, *, registry, context_messages=None): session = store.load_session(session_id) if not session or not conversation_scope(session)["private_conversation"]: return messages # Prefer the saved answer; when it was lost, keep the commitment on the # original user message. Never append a synthetic answer or mutate Chat. carriers, turns = {}, {} - for message in messages: + for message in messages if context_messages is None else context_messages: tid = message.get("turn_id") if not tid or message.get("origin") == "manager_followup": continue if message.get("role") in {"agent", "assistant"} or tid not in carriers: carriers[tid] = message.get("message_id") - if tid not in turns: + visible_ids = {message.get("message_id") for message in messages} + if context_messages is not None: + # A returned result may be on a later page than its commitment. Read + # that original carrier for revision labels, without rendering it twice. + returned_turns = {message.get("turn_id") for message in messages + if isinstance(message.get("return_delivery"), dict)} + messages = [message for message in context_messages + if message.get("turn_id") in returned_turns + and carriers.get(message.get("turn_id")) == message.get("message_id") + and message.get("message_id") not in visible_ids] + messages + for message in messages: + tid = message.get("turn_id") + if tid and carriers.get(tid) == message.get("message_id") and tid not in turns: turns[tid] = store.load_turn(session_id, tid) or {} missing_clients = { turn.get("client_turn_id") for turn in turns.values() @@ -147,4 +159,4 @@ def safe(value): return [{**message, "return_delivery": {**message["return_delivery"], "request_revision": revision_labels[message["message_id"]]}} if message.get("message_id") in revision_labels and isinstance(message.get("return_delivery"), dict) - else message for message in result] + else message for message in result if message.get("message_id") in visible_ids] diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py index c2886c4bea..e3232fd7c3 100644 --- a/loopx/capabilities/manager_context/roundtrip.py +++ b/loopx/capabilities/manager_context/roundtrip.py @@ -282,15 +282,30 @@ def project_chat_return_deliveries(root, session_id, messages): ] -def project_chat_session_snapshot(root, store, session_id, *, registry): +def project_chat_session_snapshot(root, store, session_id, *, registry, + message_limit=None, message_cursor=None): """Project return delivery state into one existing Chat snapshot.""" - snapshot = store.session_snapshot(session_id) + snapshot = (store.session_snapshot(session_id, strict_messages=True) if message_limit is not None + else store.session_snapshot(session_id)) + from ...chat_history import page_chat_messages + page = (page_chat_messages(session_id, snapshot["messages"], limit=message_limit, + cursor=message_cursor) if message_limit is not None else None) + context_messages = snapshot["messages"] + if page is not None: + context_messages = context_messages[:page["message_page"]["total"]] + snapshot["messages"] = page["messages"] snapshot["messages"] = project_chat_return_deliveries( root, session_id, snapshot["messages"] ) from .presentation import project_collaboration - snapshot["messages"] = project_collaboration(store, root, session_id, snapshot["messages"], registry=registry) + # Use the frozen context to choose the carrier, projecting only this page. + # A question and answer can straddle pages without duplicating a commitment + # or re-reading every off-page Turn for each page. + snapshot["messages"] = project_collaboration(store, root, session_id, snapshot["messages"], + registry=registry, context_messages=context_messages) + if page is not None: + snapshot["message_page"] = page["message_page"] return snapshot diff --git a/loopx/chat_history.py b/loopx/chat_history.py new file mode 100644 index 0000000000..18322330fc --- /dev/null +++ b/loopx/chat_history.py @@ -0,0 +1,42 @@ +"""Stateless paging of one original Chat transcript; no storage or execution owner.""" +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from typing import Any + + +def page_chat_messages(session_id: str, messages: list[dict[str, Any]], *, + limit: int, cursor: str | None = None) -> dict[str, Any]: + """Page an append-ordered frozen prefix; a cursor is a read hint, not authority.""" + if type(limit) is not int or not 1 <= limit <= 200: + raise ValueError("message_limit must be between 1 and 200") + offset, total = 0, len(messages) + expected_digest = None + if cursor is not None: + try: + if not isinstance(cursor, str) or not 1 <= len(cursor) <= 1024: + raise ValueError() + decoded = json.loads(base64.b64decode(cursor + "=" * (-len(cursor) % 4), + altchars=b"-_", validate=True)) + source, offset, total, expected_digest = decoded + if (source != session_id or type(offset) is not int or type(total) is not int + or not 0 < offset < total <= len(messages) + or not isinstance(expected_digest, str) + or not re.fullmatch(r"[a-f0-9]{64}", expected_digest)): + raise ValueError() + except (ValueError, TypeError, UnicodeDecodeError) as exc: + raise ValueError("invalid history cursor") from exc + digest = hashlib.sha256(json.dumps(messages[:total], sort_keys=True, + ensure_ascii=False, separators=(",", ":")).encode("utf-8")).hexdigest() + if expected_digest is not None and expected_digest != digest: + raise ValueError("history cursor source changed; restart the read") + end = min(offset + limit, total) + next_cursor = base64.urlsafe_b64encode(json.dumps([session_id, end, total, digest], + separators=(",", ":")).encode("utf-8")).decode("ascii").rstrip("=") if end < total else None + return {"messages": messages[offset:end], "message_page": { + "schema_version": "loopx_chat_message_page_v1", "offset": offset, + "total": total, "snapshot_sha256": digest, "next_cursor": next_cursor, + }} diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 8a8a04e975..c8bb754eec 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -923,13 +923,26 @@ def _session_turn(self, session_id: str) -> None: def _session_snapshot(self, session_id: str) -> None: try: + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + if (set(query) - {"message_limit", "message_cursor"} + or any(len(values) != 1 or not values[0] for values in query.values()) + or ("message_cursor" in query and "message_limit" not in query)): + raise ValueError("invalid history page arguments") + message_limit = int(query["message_limit"][0]) if "message_limit" in query else None + if message_limit is not None and not 1 <= message_limit <= 200: + raise ValueError("message_limit must be between 1 and 200") self.server.runtime_controller.recover_exited_turn(session_id) snapshot = cast(Callable[..., dict[str, object]], project_chat_session_snapshot) self._send_json(snapshot( self.server.runtime_root, self.server.chat_store, session_id, - registry=self.server.registry_path)) + registry=self.server.registry_path, message_limit=message_limit, + message_cursor=query.get("message_cursor", [None])[0])) except KeyError: self._send_error("chat session was not found", status=404) + except ValueError as exc: + self._send_error(str(exc), status=400) + except OSError: + self._send_error("Chat history is temporarily unavailable", status=503) def _managed_native_turn(self, session_id: str, *, execute: bool = False) -> None: from .capabilities.native_chat.steward_managed_mode import ManagedSessionTurn diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 42a81fdf5a..b7faee7508 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -846,8 +846,9 @@ def append_message( _append_jsonl(path, payload) return payload - def messages(self, session_id: str) -> list[dict[str, Any]]: - return _read_jsonl(self._session_dir(session_id) / "messages.jsonl") + def messages(self, session_id: str, *, strict: bool = False) -> list[dict[str, Any]]: + return _read_jsonl(self._session_dir(session_id) / "messages.jsonl", + raise_on_error=strict, strict=strict) def prepared_managed_turn_request( self, @@ -1973,7 +1974,7 @@ def public_session(self, payload: dict[str, Any]) -> dict[str, Any]: and payload.get("status") in RESUMABLE_SESSION_STATES, } - def session_snapshot(self, session_id: str) -> dict[str, Any]: + def session_snapshot(self, session_id: str, *, strict_messages: bool = False) -> dict[str, Any]: payload = self.load_session(session_id) if payload is None: raise KeyError("chat session was not found") @@ -1990,6 +1991,7 @@ def session_snapshot(self, session_id: str) -> dict[str, Any]: "ok": True, "schema_version": CHAT_STORE_SCHEMA_VERSION, "session": self.public_session(payload), - "messages": self.messages(session_id), + "messages": (self.messages(session_id, strict=True) if strict_messages + else self.messages(session_id)), "active_turn": active_turn, } diff --git a/tests/test_chat_conversation_bindings.py b/tests/test_chat_conversation_bindings.py index 20d938dc6b..610d0da04e 100644 --- a/tests/test_chat_conversation_bindings.py +++ b/tests/test_chat_conversation_bindings.py @@ -6,10 +6,111 @@ from test_chat_ordinary_project import ordinary # noqa: F401, F811 from test_native_steward_private import steward # noqa: F401 +from test_manager_context_roundtrip import flow # noqa: F401 from loopx.capabilities.native_chat.conversation_bindings import ChatConversationBindings from loopx.extensions.lark.conversation_identity import identity_ref +def _history_page_brief(): + return {"schema_version": "collaboration_brief_v0", "purpose": "Inspect original history", + "context": "Keep the original conversation", "constraints": ["Do not publish"], + "inputs": [], "acceptance": ["Return the original conclusion"], "return_requirement": "Return here"} + + +def test_history_page_keeps_one_original_collaboration_carrier(flow, monkeypatch): + from loopx.capabilities.manager_context.roundtrip import project_chat_session_snapshot + + root, registry, store, create = flow + session, turn, _ = create(brief=_history_page_brief()) + sid, tid = session["session_id"], turn["turn_id"] + for i in range(200 - len(store.messages(sid))): + store.append_message(sid, role="user", text=f"Independent history row {i}") + answer = store.append_message(sid, role="agent", text="Original answer", turn_id=tid) + load_turn, reads = store.load_turn, [] + def observed(*args): + reads.append(args) + return load_turn(*args) + monkeypatch.setattr(store, "load_turn", observed) + first = project_chat_session_snapshot(root, store, sid, registry=registry, message_limit=200) + assert reads == [], "A page without the original carrier must not project off-page Turns" + assert not any(row.get("collaboration") for row in first["messages"]) + store.append_message(sid, role="agent", text="Later answer outside frozen prefix", turn_id=tid) + second = project_chat_session_snapshot(root, store, sid, registry=registry, + message_limit=200, message_cursor=first["message_page"]["next_cursor"]) + assert len(second["messages"]) == 1 and second["message_page"]["next_cursor"] is None + assert second["messages"][0]["message_id"] == answer["message_id"] + assert second["messages"][0]["collaboration"]["agent_id"] == "worker" + assert reads == [(sid, tid)] + + +def test_return_delivery_readback_survives_a_separate_history_page(flow): + from loopx.capabilities.manager_context import acknowledge + from loopx.capabilities.manager_context.roundtrip import drain, report, project_chat_session_snapshot + + root, registry, store, create = flow + session, turn, receipt = create(brief=_history_page_brief()) + sid = session["session_id"] + store.append_message(sid, role="agent", text="Original commitment", turn_id=turn["turn_id"]) + acknowledge(root, "research", "worker", receipt["request_id"], "adopt", "Keep original audience") + report(root, "research", "worker", receipt["request_id"], "conclusion", "Original conclusion") + drain(root, registry, store, lambda *_: pytest.fail("A private App return must not send to a provider")) + expected = project_chat_session_snapshot(root, store, sid, registry=registry)["messages"] + assert len(expected) == 4 and expected[-1].get("return_delivery") + cursor, rows = None, [] + while True: + page = project_chat_session_snapshot(root, store, sid, registry=registry, + message_limit=1, message_cursor=cursor) + rows.extend(page["messages"]) + cursor = page["message_page"]["next_cursor"] + if cursor is None: + break + assert rows == expected + assert sum(bool(row.get("collaboration")) for row in rows) == 1 + + +def test_original_history_pages_freeze_append_boundary_and_keep_full_rows(ordinary, monkeypatch): + from urllib.parse import urlencode + from loopx.chat_server import ChatHTTPServer + + store, runtime, contexts, request, capture, _, _ = ordinary + monkeypatch.setattr(ChatHTTPServer, "runtime_root", store.root.parent, raising=False) + monkeypatch.setattr(ChatHTTPServer, "registry_path", runtime.registry_path, raising=False) + status, opened = request("/api/chat/sessions", { + "context_kind": "project", "project_ref": contexts.available()[0]["project_ref"], + }) + assert status == 201 + sid = opened["session_id"] + rows = [store.append_message(sid, role="user" if i % 2 == 0 else "agent", + text="完整正文" * 3000 if i == 201 else f"Original message {i}", + turn_id=None, attachments=[{"name": "original.png", "data_url": "data:image/png;base64,AA=="}] if i == 201 else None) + for i in range(451)] + native_before = capture.read_bytes() + path = f"/api/chat/sessions/{sid}" + code, first = request(path + "?message_limit=200") + assert code == 200 and len(first["messages"]) == 200 + assert first["message_page"]["total"] == 451 + cursor = first["message_page"]["next_cursor"] + store.append_message(sid, role="agent", text="Appended during history read") + code, second = request(path + "?" + urlencode({"message_limit": 200, "message_cursor": cursor})) + assert code == 200 and len(second["messages"]) == 200 + code, last = request(path + "?" + urlencode({"message_limit": 200, + "message_cursor": second["message_page"]["next_cursor"]})) + assert code == 200 and last["message_page"]["next_cursor"] is None + assert first["messages"] + second["messages"] + last["messages"] == rows + assert request(path)[1]["messages"][-1]["text"] == "Appended during history read" + assert request(path + "?message_limit=200")[1]["message_page"]["total"] == 452 + assert capture.read_bytes() == native_before, "History reads must not execute or replay a Turn" + for query in ["message_limit=0", "message_limit=201", "message_limit=2&message_limit=3", + "message_cursor=invalid", "message_limit=2&message_cursor=invalid"]: + assert request(path + "?" + query)[0] == 400 + def unreadable(*_, **__): + raise PermissionError("private journal path") + with monkeypatch.context() as patch: + patch.setattr(store, "messages", unreadable) + code, unavailable = request(path + "?message_limit=200") + assert code == 503 and "private journal path" not in json.dumps(unavailable) + + def test_app_steward_corrupt_legacy_configuration_never_falls_back(tmp_path): bindings = ChatConversationBindings(root=tmp_path, project_contexts=None, observe=lambda _: pytest.fail("an unselected entry must not probe a provider")) diff --git a/tests/test_chat_store_input_validation.py b/tests/test_chat_store_input_validation.py index 499b79467c..60480881f2 100644 --- a/tests/test_chat_store_input_validation.py +++ b/tests/test_chat_store_input_validation.py @@ -7,6 +7,7 @@ import loopx.chat_store as chat_store from loopx.chat_store import CHAT_SESSION_SCHEMA_VERSION, ChatSessionStore +from loopx.chat_history import page_chat_messages def test_agent_model_value_keeps_provider_protocol_characters() -> None: @@ -51,6 +52,33 @@ def _dedup_store(tmp_path: Path) -> tuple[ChatSessionStore, str]: return store, session_id +def test_history_cursor_rejects_other_session_and_rewritten_prefix(tmp_path: Path) -> None: + store, sid = _dedup_store(tmp_path) + for i in range(5): + store.append_message(sid, role="user", text=f"row {i}") + page = page_chat_messages(sid, store.messages(sid, strict=True), limit=2) + cursor = page["message_page"]["next_cursor"] + other = store.create_session(goal_id="goal-one", agent_id="codex", + adapter_kind="codex_app_server", upstream_thread_id="other", upstream_mode="chat")["session_id"] + with pytest.raises(ValueError, match="cursor"): + page_chat_messages(other, store.messages(other, strict=True), limit=2, cursor=cursor) + path = store._session_dir(sid) / "messages.jsonl" + original = path.read_bytes() + rows = store.messages(sid) + rows[0]["text"] = "Rewritten source" + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + with pytest.raises(ValueError, match="cursor"): + page_chat_messages(sid, store.messages(sid, strict=True), limit=2, cursor=cursor) + path.write_bytes(original + b"{broken record}\n") + with pytest.raises(ValueError, match="journal record"): + page_chat_messages(sid, store.messages(sid, strict=True), limit=2) + path.write_bytes(original) + assert page_chat_messages(sid, store.messages(sid, strict=True), limit=2, cursor=cursor)["messages"] == store.messages(sid)[2:4] + path.write_text("\n".join(path.read_text().splitlines()[:3]) + "\n") + with pytest.raises(ValueError, match="cursor"): + page_chat_messages(sid, store.messages(sid, strict=True), limit=2, cursor=cursor) + + def test_same_message_id_same_payload_returns_existing_row(tmp_path: Path) -> None: store, session_id = _dedup_store(tmp_path) first = store.append_message(