diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index d9cda41712..7e125f4cbc 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -22,7 +22,7 @@ "smoke:benchmark-study-browser": "node ../../../examples/dashboard-benchmark-study-browser-smoke.mjs", "smoke:capability-configuration": "rm -rf node_modules/.cache/loopx-capability-configuration-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --types node --skipLibCheck --strict --outDir node_modules/.cache/loopx-capability-configuration-smoke smoke/capability-configuration-smoke.ts src/data/capability-configuration.ts && node node_modules/.cache/loopx-capability-configuration-smoke/smoke/capability-configuration-smoke.js", "smoke:chat-model-menu": "vite build --ssr smoke/chat-model-menu-smoke.mjs --outDir node_modules/.cache/chat-model-menu --emptyOutDir && node node_modules/.cache/chat-model-menu/chat-model-menu-smoke.js", - "smoke:chat-route": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-chat-route-smoke smoke/chat-route-smoke.ts src/data/chat-model.ts src/vite-env.d.ts && node node_modules/.cache/loopx-chat-route-smoke/apps/presentation/dashboard/smoke/chat-route-smoke.js", + "smoke:chat-route": "vite build --ssr smoke/chat-route-smoke.ts --outDir node_modules/.cache/loopx-chat-route-smoke --emptyOutDir && node node_modules/.cache/loopx-chat-route-smoke/chat-route-smoke.js", "smoke:chat-stream-stall": "vite build --ssr smoke/chat-stream-stall-smoke.ts --outDir node_modules/.cache/loopx-chat-stream-stall --emptyOutDir && node node_modules/.cache/loopx-chat-stream-stall/chat-stream-stall-smoke.js", "smoke:chat-turn-acceptance-retry": "vite build --ssr smoke/chat-turn-acceptance-retry-smoke.ts --outDir node_modules/.cache/loopx-chat-turn-acceptance-retry --emptyOutDir && node node_modules/.cache/loopx-chat-turn-acceptance-retry/chat-turn-acceptance-retry-smoke.js", "smoke:demo-readiness": "bash ../../../scripts/loopx-python.sh --exec ../../../examples/dashboard-demo-readiness-smoke.py", diff --git a/apps/presentation/dashboard/smoke/chat-route-smoke.ts b/apps/presentation/dashboard/smoke/chat-route-smoke.ts index e30b7f46ca..937501ba86 100644 --- a/apps/presentation/dashboard/smoke/chat-route-smoke.ts +++ b/apps/presentation/dashboard/smoke/chat-route-smoke.ts @@ -569,6 +569,24 @@ async function checkSessionHistoryRecoveryContract() { === "message-old,message-new", "messages from multiple local sessions should retain chronological order", ); + const tied = historySnapshot("session-tied", "ready", [ + { message_id: "z-request", turn_id: null, role: "user", text: "Original question", created_at: "2026-08-10T02:00:00Z" }, + { message_id: "a-answer", turn_id: null, role: "agent", text: "完整正文".repeat(3000), created_at: "2026-08-10T02:00:00Z" }, + { message_id: "m-repeat", turn_id: null, role: "user", text: "Original question", created_at: "2026-08-10T02:00:00Z" }, + ]); + const sameIdElsewhere = historySnapshot("session-other", "ready", [ + { ...tied.messages[0], text: "Other session" }, + ]); + const ordered = mergeChatSessionMessages([tied, sameIdElsewhere, tied]); + check(ordered.filter(row => row.session_id === "session-tied").map(row => row.message_id).join(",") + === "z-request,a-answer,m-repeat", "Timestamp ties must retain the original Session's append order, not random ID order"); + check(ordered.find(row => row.message_id === "a-answer")?.text === tied.messages[1].text, + "History ordering must retain the full long answer"); + check(ordered.length === 4 && ordered.filter(row => row.text === "Original question").length === 2, + "Deduplicate only exact stored identities; repeated bodies and colliding cross-session IDs remain"); + check(mergeChatSessionMessages([sameIdElsewhere, tied]).map(row => `${row.session_id}:${row.message_id}`).join("|") + === ordered.map(row => `${row.session_id}:${row.message_id}`).join("|"), "Equal-time cross-session ordering is stable across read completion order"); + } finally { globalThis.fetch = originalFetch; } diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 33fcd85cc9..05331962ca 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1025,9 +1025,12 @@ export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) { messages.set(`${snapshot.session.session_id}:${message.message_id}`, { ...message, session_id: snapshot.session.session_id }); } } + // Store rows are append-ordered; opaque IDs cannot break timestamp ties. + // Stable sort retains that order within a Session, with a deterministic tie + // across Sessions even when their snapshots finish in a different order. return [...messages.values()].sort((left, right) => left.created_at.localeCompare(right.created_at) - || left.message_id.localeCompare(right.message_id) + || (left.session_id ?? "").localeCompare(right.session_id ?? "") ); } 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 bf3a4d4e7d..1813940400 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -1288,6 +1288,16 @@ unreadable current Session blocks ordinary send until its exact record recovers; never infer an empty conversation or create a replacement driver from a read failure. A recovered read does not prove receiver adoption or native GQ02/GQ09. +Equal-timestamp messages retain the original Session's append order: opaque +message IDs are identity, not chronology. The shared history merge preserves +complete bodies and repeated text, deduplicates only the same Session/message +identity, and uses a deterministic Session tie across independent snapshots. +Browser recovery waits for asynchronous hydration and for the held stale reader +to settle, including an aborted request; it does not require a response from an +already timed-out fetch. [Synthetic source-order captures](../../assets/personal-workspace/conversation-history/README.md) +qualify this read/display boundary, not full history pagination, reply threading +or installed App/Lark bidirectional delivery. + 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 b60789ae40..f6effbe558 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 @@ -959,6 +959,13 @@ App 历史与重连读取同一条消息;managed 和 attached 完成存储均 [公开模型评测](../../../examples/evaluations/chat-intake.py) 仅在 release 候选版本验收时显式启用付费调用,默认及新增宣传的模型配置分别至少重复两次。平时 PR、每次提交和 heartbeat 只跑离线回归及受影响的浏览器场景,不运行付费评测。记录候选版本,保留失败;凭证不可用记为跳过,不视为通过。评测使用生产 prompt/parser 和固定公开上下文,覆盖新建、已有负责人、多候选、停止/无授权、纠偏、当前 Goal 追问、引用、否定、普通问答与中英文表达。冲突操作、协议缺失、生成截断均记录为失败。记录模型、prompt/用例哈希、请求参数、token 用量和重复次数,不泄露凭证。该层只验证模型解释已提供证据,不证明真实发现、派发、停止执行或完整 GQ01/GQ02;打包浏览器和实际委派传输测试分别覆盖其边界。 +时间戳相同的消息保留原 Session 的追加顺序;随机消息 ID 只标识身份,不表示时间。 +共享历史合并保留完整正文与重复文本,只按同一 Session/消息身份去重;独立快照时间 +相同时按 Session 确定显示顺序,避免读取完成先后影响排序。浏览器恢复检查等待异步 +回填及被挂起的旧读取结束,允许旧请求已经超时,不再强求已取消请求的响应。 +[合成顺序截图](../../assets/personal-workspace/conversation-history/README.md)只验收该读取/显示边界, +完整历史分页、回复线程和已安装 App/飞书双向送达仍分别验收。 + 同一对话界面在用户停留底部时跟随输出,上翻阅读后保持位置,并提供回到最新消息的入口。 窗口或时间线尺寸变化(包括图片布局与展开回复)后仍应跟随最新回复,不必等待下一条消息。 用户上翻阅读时,布局变化保留当前位置;既有回到最新消息按钮恢复跟随。该行为留在共享 diff --git a/docs/assets/personal-workspace/conversation-history/README.md b/docs/assets/personal-workspace/conversation-history/README.md new file mode 100644 index 0000000000..df2e1f36e3 --- /dev/null +++ b/docs/assets/personal-workspace/conversation-history/README.md @@ -0,0 +1,15 @@ +# Conversation history source order + +Synthetic local fixtures only; no private conversation, account or credentials. +The desktop captures use 1512 × 982; the narrow capture uses 390 × 844. + +- [Before](before-desktop.png): opaque message-ID sorting puts the answer before its same-second question. +- [After](after-desktop.png): the Session append order keeps the question before its answer. +- [Narrow](after-narrow.png): the same source order remains readable at 390 px. + +The existing conversation-history-recovery scenario also exercises failed older +reads, draft retention, current-session send blocking, projection rehydration, +retired observers and zero replay. The chat-route smoke checks complete long +bodies, repeated text, scoped identity and deterministic cross-session ties. +This qualifies source/display order and synthetic recovery, not complete +pagination, reply threading, native input or real App/Lark media delivery. diff --git a/docs/assets/personal-workspace/conversation-history/after-desktop.png b/docs/assets/personal-workspace/conversation-history/after-desktop.png new file mode 100644 index 0000000000..cf5e8edbc5 Binary files /dev/null and b/docs/assets/personal-workspace/conversation-history/after-desktop.png differ diff --git a/docs/assets/personal-workspace/conversation-history/after-narrow.png b/docs/assets/personal-workspace/conversation-history/after-narrow.png new file mode 100644 index 0000000000..44a1c714a1 Binary files /dev/null and b/docs/assets/personal-workspace/conversation-history/after-narrow.png differ diff --git a/docs/assets/personal-workspace/conversation-history/before-desktop.png b/docs/assets/personal-workspace/conversation-history/before-desktop.png new file mode 100644 index 0000000000..41d3bc5b2a Binary files /dev/null and b/docs/assets/personal-workspace/conversation-history/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 a5db023c38..12344c4bd0 100644 --- a/examples/personal-workspace-browser/conversation-history-recovery.mjs +++ b/examples/personal-workspace-browser/conversation-history-recovery.mjs @@ -5,7 +5,7 @@ import { openWorkspacePage } from "./scenario-context.mjs"; 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(), stream = Promise.withResolvers(); + const staleRead = Promise.withResolvers(), staleStarted = Promise.withResolvers(), staleSettled = Promise.withResolvers(), stream = Promise.withResolvers(); let oldReads = 0, freshReads = false; const context = await openWorkspacePage(browser, url, { gotoWaitUntil: "domcontentloaded", @@ -27,7 +27,10 @@ async function overlappingHistoryRefresh(browser, url) { if (oldReads === 1) return route.fulfill({ status: 503, json: { error: "Synthetic missing history" } }); if (!freshReads) { staleStarted.resolve(); await staleRead.promise; - return route.fulfill({ status: 503, json: { error: "Older request failed after recovery" } }).catch(() => {}); + try { + await route.fulfill({ status: 503, json: { error: "Older request failed after recovery" } }).catch(() => {}); + } finally { staleSettled.resolve(); } + return; } return route.fallback(); }); @@ -46,8 +49,9 @@ async function overlappingHistoryRefresh(browser, url) { await notice.waitFor({ state: "hidden" }); const composer = page.getByLabel("向 LoopX 发送消息"); await composer.fill("保留这条草稿,稍后继续。"); - const lateResponse = page.waitForResponse(response => new URL(response.url()).pathname === `/api/chat/sessions/${oldId}` && response.status() === 503); - staleRead.resolve(); await (await lateResponse).finished(); + // The request may already have timed out: no browser response exists then. + // Observe settlement of the held reader, then check the visible owner. + staleRead.resolve(); await staleSettled.promise; await page.evaluate(() => new Promise(resolveFrame => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)))); await page.screenshot({ path: resolve(outputDir, "conversation-history-read-order-desktop.png"), animations: "disabled" }); assert.equal(await notice.isVisible(), false, "An older read failure cannot reopen a recovered history warning"); @@ -234,6 +238,10 @@ export const conversationHistoryRecoveryScenario = { message_id: "answer", turn_id: `turn-${id}`, role: "agent", text, created_at: date, }]); } + page.__loopxRuntime.messages.get(currentId).push( + { message_id: "z-source-question", turn_id: "same-second", role: "user", text: "同一秒的原问题:请先核对公开资料。", created_at: "2026-08-13T01:00:01Z" }, + { message_id: "a-source-answer", turn_id: "same-second", role: "agent", text: "同一秒的原回答:核验后再返回结论。", created_at: "2026-08-13T01:00:01Z" }, + ); return page.route("**/api/chat/sessions/*", async (route) => { if (route.request().method() === "GET" && new URL(route.request().url()).pathname === `/api/chat/sessions/${unavailable}`) { failedReads += 1; @@ -247,6 +255,16 @@ export const conversationHistoryRecoveryScenario = { await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); await page.getByText("目前正在核对微软现金流。", { exact: true }).waitFor({ state: "visible", timeout: 5000 }); const notice = page.getByRole("status").filter({ hasText: "部分历史暂时无法读取" }); + const sourceQuestion = page.getByText("同一秒的原问题:请先核对公开资料。", { exact: true }); + await sourceQuestion.waitFor({ state: "visible" }); + await page.screenshot({ path: resolve(outputDir, "conversation-history-source-order-desktop.png"), animations: "disabled" }); + const historyText = await page.locator(".personal-channel-timeline").innerText(); + assert.ok(historyText.indexOf("同一秒的原问题") < historyText.indexOf("同一秒的原回答"), "The original request stays before its same-second reply"); + await page.setViewportSize({ width: 390, height: 844 }); + await sourceQuestion.scrollIntoViewIfNeeded(); + await page.screenshot({ path: resolve(outputDir, "conversation-history-source-order-mobile.png"), animations: "disabled" }); + await page.setViewportSize({ width: 1512, height: 982 }); + await notice.waitFor({ state: "visible" }); await page.screenshot({ path: resolve(outputDir, "conversation-history-partial-desktop.png"), animations: "disabled" }); await page.setViewportSize({ width: 390, height: 844 }); @@ -299,11 +317,12 @@ export const conversationHistoryRecoveryScenario = { await page.locator(".personal-goal-link").first().click(); await page.locator(".personal-manager-link").click(); await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await page.getByText("只看当前状态。", { exact: true }).waitFor({ state: "visible" }); assert.equal(await page.getByText("只看当前状态。", { exact: true }).count(), 1, "Projection-only messages retain their exact stored identities"); assert.equal(api.turnRequests.length, 1, "Projection history does not run an Agent"); await recoveryObserverHandover(browser, url); await retiredHistoryRead(browser, url); - return { coverageEntries: context.coverageEntries, note: "History read recovery retains the draft and exact session. Navigation retires its observer and history-read ownership; delayed projection cannot block the current conversation or erase a replacement Turn's interrupt target." }; + return { coverageEntries: context.coverageEntries, note: "History retains source order for timestamp ties, projection identity, the draft and exact session. Navigation retires its observer and history-read ownership; delayed projection cannot block the current conversation or erase a replacement Turn's interrupt target." }; } finally { await context.close(); }