Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
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
15 changes: 11 additions & 4 deletions apps/desktop/src/renderer/components/chat/MessageStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1579,11 +1579,18 @@ function renderItemEndMs(item: RenderItem): number | null {
return startMs === null ? item.resultTsMs : Math.max(startMs, item.resultTsMs);
}
if (item.type === 'work_group') {
for (let i = item.children.length - 1; i >= 0; i--) {
const childMs = renderItemEndMs(item.children[i]);
if (childMs !== null) return childMs;
// 全量取 max,不是"最后一个 child":children 按**发起**时刻排列,并行的 Agent/Task 乱序完成时
// 真正的结束时刻可能落在更靠前的 child 上(先发起、更晚 settle)。取最后一个会低估组的结束
// 时间,于是空洞判定的锚点变小、把本来连续的 turn 误判成空洞切开 —— 与本函数 tool_segment
// 分支、以及 groupWorkRuns 里 prevEndMs 的 Math.max 是同一条理由(#676 review codex P1)。
// 手机端同款函数(maker-shared 的 itemEndTimestamp)已按此收敛,#1210 review 指出这里镜像存在。
let latest: number | null = null;
for (const child of item.children) {
const childMs = renderItemEndMs(child);
if (childMs === null) continue;
latest = latest === null ? childMs : Math.max(latest, childMs);
}
return null;
return latest;
}
// thinking 的 createdAt 是块**开始**的时刻,真正结束要加 thinkingDurationMs
// (与 workRunEndTs 同口径)。一个想了半小时以上的 thinking 块后面紧跟工具或正文时,
Expand Down
20 changes: 6 additions & 14 deletions apps/desktop/src/renderer/lib/historyGap.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
/**
* 历史窗口空洞的判定阈值 —— 单一来源
* 历史窗口空洞的判定阈值 —— 桌面侧入口,正本在 `@cindy/maker-shared/history-gap`
*
* 唯一消费方:`components/chat/MessageStream` —— tool_segment 按它切段、工作组按它切组。
*
* 为什么单独放在 lib 而不是埋在 MessageStream 里:它是一条产品级阈值(多久算"历史不
* 连续"),独立成文件便于查找与调整,也留出被 main / 其它 renderer 模块复用的位置而不必
* 反向依赖 component(见 docs/dev-rules/architecture-invariants.md 的依赖方向)。
* 桌面消费方:`components/chat/MessageStream` —— tool_segment 按它切段、工作组按它切组。
* makerChatStore 一度按它模拟切段来估算跳转补齐预算,现已改为按行数取保守上界
* (见 JUMP_BACKFILL_MAX_ITEMS),不再依赖本常量。
*
* 为什么是 30 分钟:跳转到历史消息时,目标附近的窗口与已加载的尾部窗口之间可能隔着
* 大段没加载的历史(补齐失败时)。渲染层看到的是两段"相邻"item,中间的 user 行(唯一的
* turn 边界)全部缺席,于是跨越空洞的所有动作被折成同一个「已工作 Xs」:实测出现过一条
* 组吞掉 47 小时、40 条 user 消息的会话,组时长也跟着谎报成 2820m。
*
* 单个 turn 内相邻动作(工具调用 / thinking)正常在秒级到分钟级,等长任务最多几十分钟;
* 真被误切也只是多出一个折叠条,代价远小于把不相干的两段并成一条并谎报时长。
* 为什么保留这层 re-export 而不让 MessageStream 直接引 shared:阈值原本是桌面常量,
* 手机端接入后成为两端共用的产品级阈值(见正本文件头的完整理由)。留住这个路径让桌面侧
* 既有引用与文档指向不必跟着改,同时保证两端逐字节同一把尺子。
*/
export const HISTORY_GAP_SPLIT_MS = 30 * 60 * 1000;
export { HISTORY_GAP_SPLIT_MS } from '@cindy/maker-shared/history-gap';
208 changes: 202 additions & 6 deletions apps/mobile/app/sessions/[sessionId].tsx

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions apps/mobile/src/__tests__/historyWindowBackfillWiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* 空洞补齐在会话屏幕上的接线守卫。
*
* 补齐算法本身在 `historyWindowGap.test.ts` 有行为测试;这里锁住屏幕侧那些"删掉也照样跑、但会
* 悄悄踩坑"的前置条件。#1210 的三轮 review 全部集中在这一层,所以先把不变量写清,再逐条对着断言
* ——每条不变量在代码里只允许有**一个**判据,所有对称路径复用它:
*
* 1. **一轮补齐的身份是单调的**:每次启动分配只增不减的 `runSeq`;"是否已被取代"、飞行标记的
* 清除、结论的写入,全都对着 seq 比。凡是"当前状态是否仍等于启动时状态"的判据都不可靠 ——
* 会话 id 会摆回来(A 在飞 → 切到 B → 快速切回 A),那种判据会把取消**撤销**掉,于是同一会话
* 并发翻页、旧轮收尾还误清新轮的标记,越滚越多。
* 2. **同一会话同一时刻最多一轮在飞**:互斥按 `inFlight.sid === sessionId`;别的会话残留的那一轮
* 不连坐当前会话(它自己会在下一次 isCancelled 上收手)。
* 3. **同步门槛按 session + 连接代判定**:屏实例会被原地复用,屏幕级 `lastSyncedAt` 在切会话后
* 仍是上一个会话的非空值,补齐会基于旧缓存快照动手。
* 4. **每个结局有独立的遗忘条件与预算归属**:contiguous(事实,永久跳过,不占翻页额度)/
* backfilled(真翻过页,占翻页额度)/ failed(绑 connectionEpoch,重连后可重试)/ cancelled(不记)。
* 5. **两道预算闸**:考察总次数(防海量正常停顿打出上百次探测)、翻页段数(防一路翻整场历史)。
* 6. **补齐永不写用户可见的加载态或错误**:它是静默自愈,失败由渲染层的空洞守卫兜底。
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';

describe('history window backfill wiring', () => {
const source = readFileSync(resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8');

it('不变量 1:一轮的身份是单调 seq,取消不可撤销', () => {
expect(source).toContain('const runSeq = backfillRunSeqRef.current + 1;');
expect(source).toContain('backfillLatestRunSeqRef.current = runSeq;');
// 取消判据对着 seq 比,不是"当前会话 id 是否仍等于启动时的"——后者会随切回来而摆回。
expect(source).toContain('isCancelled: () => backfillLatestRunSeqRef.current !== runSeq');
// 结论写入同样要求"我还是最新那一轮"。
expect(source).toContain('if (backfillLatestRunSeqRef.current !== runSeq) return;');
// 收尾按 seq 精确清标记:按 sid 比会把切回同一会话后新起那一轮的标记误清。
expect(source).toContain('setBackfillInFlightRun((current) => (current?.seq === runSeq ? null : current));');
// 作废收敛成一个函数,三个入口共用(单向,不启动新轮):
expect(source).toContain('const abandonInFlightBackfill = useCallback(() => {');
expect(source).toContain('backfillRunSeqRef.current += 1;');
// ①切会话 ②换连接代 —— 被动 effect 足够
expect(source).toContain('}, [abandonInFlightBackfill, sessionId, connectionEpoch]);');
// ③手动「加载更早」—— 必须在**同步路径**里作废:effect 是被动的,而 loadEarlierMessages 在
// setLoadingEarlier(true) 之后同步就发请求,自动补齐可能在 effect 跑之前返回并继续下一页,
// 两条分页流程短暂并发(#1210 review)。
const manualEntry = source.slice(
source.indexOf('const loadEarlierMessages = useCallback'),
source.indexOf('setLoadingEarlier(true);', source.indexOf('const loadEarlierMessages = useCallback')),
);
expect(manualEntry).toContain('abandonInFlightBackfill();');
// 已退役的可摆动判据不得回归。
expect(source).not.toContain('backfillSessionRef');
expect(source).not.toContain('backfillInFlightRef');
});

it('不变量 2:互斥只挡同一会话,且飞行标记是可观察 state', () => {
expect(source).toContain('if (loading || loadingEarlier || backfillInFlightRun?.sid === sessionId) return;');
expect(source).toContain('const [backfillInFlightRun, setBackfillInFlightRun] = useState<{ sid: string; seq: number } | null>(null);');
});

it('不变量 3:同步门槛按 session + 连接代,不用屏幕级 lastSyncedAt', () => {
expect(source).toContain('if (readAckSyncedKey !== `${sessionId}:${connectionEpoch}`) return;');
expect(source).not.toContain('|| lastSyncedAt === null) return;');
});

it('不变量 4:结局分三类,失败绑连接代,cancelled 不记,跳过表取并集', () => {
expect(source).toContain("if (outcome === 'contiguous') state.contiguous.add(gapKey);");
expect(source).toContain("else if (outcome === 'failed') state.failed.add(gapKey);");
expect(source).toContain("else if (outcome !== 'cancelled') state.backfilled.add(gapKey);");
// 断线那次不得把空洞永久钉死:换连接代只清 failed,重连后同一处可以再试。
expect(source).toContain('gapState.failed.clear();');
// 换会话时整体重置,否则上个会话的已考察集合会压住新会话的补齐。
expect(source).toContain('existingState?.sid === sessionId');
expect(source).toContain('state.sid !== sessionIdAtStart || state.epoch !== epochAtStart');
expect(source).toContain('findHistoryWindowGap(messages, consideredKeys)');
expect(source).toContain('...gapState.contiguous,');
expect(source).toContain('...gapState.backfilled,');
expect(source).toContain('...gapState.failed,');
});

it('不变量 5:两道预算闸都在,且额度只算翻过页的', () => {
expect(source).toContain('if (gapState.backfilled.size >= HISTORY_BACKFILL_MAX_GAPS_PER_VISIT) return;');
expect(source).toContain('if (consideredKeys.size >= HISTORY_GAP_MAX_CONSIDERED_PER_VISIT) return;');
// 硬编码的 3 不得回归:两道闸的语义与理由写在常量注释里。
expect(source).not.toContain('.backfilled.size >= 3');
});

it('不变量 6:补齐不写 error / loadingEarlier,锚点行消失即收手', () => {
expect(source).toContain('.some((row) => row.id === gap.newerId)');
const effectStart = source.indexOf('const backfillGapStateRef');
const effectEnd = source.indexOf('const selectSlashCommand', effectStart);
expect(effectStart).toBeGreaterThan(0);
expect(effectEnd).toBeGreaterThan(effectStart);
const effectSource = source.slice(effectStart, effectEnd);
expect(effectSource).not.toContain('setError(');
expect(effectSource).not.toContain('setLoadingEarlier(');
});

it('探测页不沿用默认降级阶梯(第一枪就满页则探测白花),翻页页保留降级重试', () => {
expect(source).toContain('limit === HISTORY_GAP_PROBE_LIMIT ? [HISTORY_GAP_PROBE_LIMIT] : undefined,');
});
});

/**
* 实时流「生效 / 中断」通知的接线守卫。
*
* 不变量:**store 只在该会话的实时行确实会送到本端时,才允许 push 续推覆盖区间的上界**(见
* remoteSessionStore 的 `sessionWindowCoverage.liveTailTrusted`;行为测试在
* `remoteSessionStore.test.ts`)。于是两侧都要接线:订阅被远端 ACK(生效)一处、断流三处。漏掉任何
* 一处都会让窗口凭空背书出一段没收到的历史,而这种孤岛在半小时内产生时连自动探测都发现不了
* (#1210 review)。
*/
describe('live stream interruption wiring', () => {
const source = readFileSync(resolve(process.cwd(), 'src/device-link/DeviceLinkContext.tsx'), 'utf8');

it('订阅被远端 ACK:按真正记进 ACK 表的 topic 生效', () => {
const sendSubscribe = source.slice(
source.indexOf('const sendTrackedSubscribe = useCallback'),
source.indexOf('const probeUnresponsiveDevice'),
);
// 传的是 markHeldRemoteTopicsSubscribed 的返回值(仍被持有的那些),不是原始 toSend ——
// 中途被释放的 topic 不算订阅生效。
expect(sendSubscribe).toContain('noteSessionLiveStreamsAcked(\n markHeldRemoteTopicsSubscribed(');
});

it('socket 掉线:整体失效(影响所有订阅)', () => {
const offlineBranch = source.slice(
source.indexOf("if (next !== 'online') {"),
source.indexOf('clearRehydrateRetry(true);', source.indexOf("if (next !== 'online') {")),
);
expect(offlineBranch).toContain('remoteSessionStore.noteLiveStreamInterrupted();');
});

it('退后台释放 session 订阅:按被释放的会话失效', () => {
const release = source.slice(
source.indexOf('const releaseHeavyTopics = ()'),
source.indexOf('return releases;'),
);
expect(release).toContain('noteSessionLiveStreamsInterrupted(heavy);');
});

it('离开会话取消订阅:按被释放的会话失效', () => {
const unsubscribe = source.slice(
source.indexOf('const unsubscribe = useCallback'),
source.indexOf('const value = useMemo'),
);
expect(unsubscribe).toContain('noteSessionLiveStreamsInterrupted(toSend);');
});
});
Loading
Loading