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
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions apps/presentation/dashboard/smoke/chat-route-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "")
);
}

Expand Down
10 changes: 10 additions & 0 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/飞书双向送达仍分别验收。

同一对话界面在用户停留底部时跟随输出,上翻阅读后保持位置,并提供回到最新消息的入口。
窗口或时间线尺寸变化(包括图片布局与展开回复)后仍应跟随最新回复,不必等待下一条消息。
用户上翻阅读时,布局变化保留当前位置;既有回到最新消息按钮恢复跟随。该行为留在共享
Expand Down
15 changes: 15 additions & 0 deletions docs/assets/personal-workspace/conversation-history/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
});
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
Expand Down Expand Up @@ -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();
}
Expand Down
Loading