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 @@ -974,7 +974,7 @@ describe('makerChatStore active view tracking', () => {
await makerChatStore.loadAroundMessage(sessionId, 'hit', { radius: 60 });
// 阶段一:窗口里只有孤岛 → 必须播种,游标为 null 会让下一次翻页从最新重开、把跳转位置顶掉。
expect(makerChatStore.getSnapshot(sessionId).oldestMessageId).toBe('older-hit-context');
expect(makerChatStore.getSnapshot(sessionId).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(sessionId).historyWindowIslands).toHaveLength(1);
expect(makerChatStore.getLightSnapshot(sessionId).historyWindowHasIsland).toBe(true);

resolveInitialList([
Expand All @@ -994,8 +994,8 @@ describe('makerChatStore active view tracking', () => {
]);
// 阶段二:最新页落地 → 游标交还给它的下沿,往上翻才会穿过孤岛与尾段之间的缺失区间。
expect(snapshot.oldestMessageId).toBe('latest-page-oldest');
// 洞还在,孤岛标记不清 —— 下一次跳转仍会尝试补齐。
expect(snapshot.historyWindowHasIsland).toBe(true);
// 洞还在,孤岛区间不清 —— 下一次跳转仍会尝试补齐。
expect(snapshot.historyWindowIslands).toHaveLength(1);
});

it('keeps loadOlder history chronological after thinking timestamps are backdated', async () => {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const state = (overrides: Partial<SessionChatState> = {}): SessionChatState => (
continuationInFlightProjectionCapability: 'unknown',
isLoadingMore: false,
hasMoreMessages: false,
historyWindowIslands: [],
isFirstMessage: false,
streamingClientId: null,
streamingText: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
expect(ids).toContain('long-thinking');
expect(ids).toContain('client-auth-b');
// 关键:按落库时间线它在权威范围之外 → 按孤岛处理。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0);
});

it('远程会话:同毫秒但没有 rowid 的 live push 保守按脱离处理', async () => {
Expand Down Expand Up @@ -1223,7 +1223,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
'client-same-ms-no-rowid',
);
// 关键:排不出先后 → 按孤岛处理,而不是当成连续。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0);
});

it('远程会话:同毫秒、rowid 更小的范围内晚到行不被误判成脱离', async () => {
Expand Down Expand Up @@ -1256,9 +1256,11 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
]);
await flushMany(REMOTE_RECONCILE_FLUSH_TICKS);

expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toContain('client-inside');
expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toContain(
'client-inside',
);
// 关键:范围内 → 不记孤岛。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(false);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBe(0);
});

it('远程会话:加性提交不能替一次无关的 rewind 背书,rewind 掉的尾部不得被补回', async () => {
Expand Down Expand Up @@ -1338,7 +1340,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
expect(ids).toContain('client-same-ms-later');
expect(ids).toContain('client-auth-1');
// 关键:同毫秒但 rowid 更大 → 落在权威范围之外 → 按孤岛处理。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0);
});

it('远程会话:权威重建保留了比权威窗口更新的晚到行时也记孤岛(推送有损)', async () => {
Expand Down Expand Up @@ -1370,7 +1372,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
expect(ids).toContain('client-last-of-burst');
expect(ids).toContain('client-auth-1');
// 关键:范围外的晚到行按孤岛处理,下一次跳转会尝试补连续。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0);
});

it('远程会话:purge 清掉对账次序簿,但旧代际的对账仍被代际守卫拦下', async () => {
Expand Down Expand Up @@ -1402,7 +1404,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
const s = sid();
makerChatStore.initGlobalListeners();
await openRemoteWithHistory(s, [dbMessage(s, 'seed', 'seed row', '2026-06-15T00:00:00.000Z')]);
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(false);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBe(0);

const pendingList = deferred<Message[]>();
remoteListResolver = () => pendingList.promise;
Expand All @@ -1426,7 +1428,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
expect(ids).toContain('client-far-older');
expect(ids).toContain('client-auth-1');
// 关键:保留了脱离新窗口的行 → 标记必须点亮,后续跳转才会尝试补连续。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0);
});

it('远程会话:分页期间转入 streaming 时,不 bump 代际也不抢别人的分页锁', async () => {
Expand Down Expand Up @@ -1492,7 +1494,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
// 先制造孤岛状态。
remoteAround = [dbMessage(s, 'island', 'island row', '2026-06-01T00:00:00.000Z')];
await makerChatStore.loadAroundMessageClientId(s, 'client-island', { radius: 60 });
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0);

// 无重叠对账 → 权威重建,期间没有任何 remote push 进来。
remoteListResolver = () => [
Expand All @@ -1501,9 +1503,11 @@ describe('makerChatStore.reconcileRemoteMessages', () => {
makerChatStore.reconcileRemoteMessages(s);
await flushMany(REMOTE_RECONCILE_FLUSH_TICKS);

expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toEqual(['client-auth-1']);
expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toEqual([
'client-auth-1',
]);
// 关键:窗口是完整重建出来的,标记必须清零。
expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(false);
expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBe(0);
});

it('远程会话:权威重建作废在飞行中的跳转补齐,并释放分页锁', async () => {
Expand Down
144 changes: 112 additions & 32 deletions apps/desktop/src/renderer/__tests__/searchJumpTargeting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,138 @@
* ---------------------------------------------------------------------------
* 回归:搜索 / 引用跳转的落点判定必须区分"目标在窗口里"与"窗口连续覆盖到目标"。
*
* 背景(#676 review):这个判定原先内联在 CCAgentSessionView 的 searchJump effect 里 ——
* 背景(#676):这个判定原先内联在 CCAgentSessionView 的 searchJump effect 里 ——
* 调用方在 messages 里看到目标就直接 focus 并 return,store 侧新加的孤岛感知补齐根本没有
* 机会运行。于是"补齐失败留下孤岛 → 重跳同一目标自愈"这条链在生产路径上是断的,而 store
* 级回归绕过了这个入口、看不出问题。判定抽成纯函数后由本文件直接覆盖。
*
* feat B:窗口模型从 boolean(historyWindowHasIsland)升级为显式孤岛区间
* (historyWindowIslands,每座孤岛 {oldestClientId, newestClientId})。"能否零成本
* focus"只看目标是否落在**主连续段**(最后一个孤岛最新边界行之后的所有行)里:
* 孤岛行与主段之间隔着没加载的历史,必须先交给 store 补齐。
*/

import { describe, it, expect } from 'vitest';
import { canFocusWithoutJumpLoad } from '@/lib/searchJumpTargeting';
import {
canFocusWithoutJumpLoad,
mainContiguousRunStartIndex,
isInsideMainContiguousRun,
type LoadedWindowIsland,
} from '@/lib/searchJumpTargeting';

const windowWith = (ids: string[], hasIsland?: boolean) => ({
const windowWith = (ids: string[], islands?: LoadedWindowIsland[]) => ({
messages: ids.map((clientId) => ({ clientId })),
...(hasIsland === undefined ? {} : { historyWindowHasIsland: hasIsland }),
...(islands === undefined ? {} : { historyWindowIslands: islands }),
});

describe('搜索跳转落点判定', () => {
it('窗口连续且目标在窗口里 → 直接 focus,不必再走 store', () => {
describe('搜索跳转落点判定 — 主连续段', () => {
it('无孤岛且目标在窗口里 → 直接 focus,不必再走 store', () => {
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c']), 'b')).toBe(true);
// historyWindowHasIsland 缺省(undefined)等于"无孤岛"
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b'], false), 'b')).toBe(true);
// 显式空孤岛列表与缺省同义
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b'], []), 'b')).toBe(true);
});

it('目标不在窗口里 → 必须走 store 加载', () => {
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b']), 'zzz')).toBe(false);
});

it('窗口有孤岛时即便目标在窗口里也要走 store,让补齐自愈', () => {
it('目标在孤岛上 → 即便在窗口里也要走 store,让补齐自愈', () => {
// 关键回归:目标"在 messages 里"可能只是先前失败的深跳留下的孤立片段。
expect(canFocusWithoutJumpLoad(windowWith(['island-target'], true), 'island-target')).toBe(
false,
);
expect(
canFocusWithoutJumpLoad(
windowWith(
['island-target'],
[{ oldestClientId: 'island-target', newestClientId: 'island-target' }],
),
'island-target',
),
).toBe(false);
});

it('目标在主段内(最后一座孤岛 newest 边界之后)→ 直接 focus', () => {
const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'b' }];
// 主段 = b 之后的所有行。
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'c')).toBe(true);
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'd')).toBe(true);
// 孤岛上的行仍不直接 focus。
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'a')).toBe(false);
expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'b')).toBe(false);
});

it('多孤岛时,目标落在两座孤岛之间 → 走 store;落在主段 → 直接 focus', () => {
const islands: LoadedWindowIsland[] = [
{ oldestClientId: 'a', newestClientId: 'b' },
{ oldestClientId: 'd', newestClientId: 'e' },
];
const window = windowWith(['a', 'b', 'c', 'd', 'e', 'f', 'g'], islands);
// c 在 b 与 d 之间:是"b → 最新"这段历史里的洞,不得直接 focus。
expect(canFocusWithoutJumpLoad(window, 'c')).toBe(false);
expect(canFocusWithoutJumpLoad(window, 'd')).toBe(false);
expect(canFocusWithoutJumpLoad(window, 'e')).toBe(false);
// 主段 = e 之后。
expect(canFocusWithoutJumpLoad(window, 'f')).toBe(true);
expect(canFocusWithoutJumpLoad(window, 'g')).toBe(true);
});

it('孤岛边界行不在窗口里(模型被破坏)→ 保守整窗不连续,一律走 store', () => {
// newest 边界行缺失:主段起点按"找不到边界"保守推到窗口末尾。
expect(
canFocusWithoutJumpLoad(
windowWith(['x', 'y'], [{ oldestClientId: 'ghost', newestClientId: 'y' }]),
'y',
),
).toBe(false);
// oldest 边界行缺失同样保守。
expect(
canFocusWithoutJumpLoad(
windowWith(['x', 'y'], [{ oldestClientId: 'x', newestClientId: 'ghost' }]),
'x',
),
).toBe(false);
});
});

describe('mainContiguousRunStartIndex', () => {
it('无孤岛时整窗都是主段', () => {
expect(mainContiguousRunStartIndex([{ clientId: 'a' }, { clientId: 'b' }], [])).toBe(0);
});

it('主段起点 = 最后一个孤岛 newest 边界行之后', () => {
const messages = [{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }];
const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'b' }];
expect(mainContiguousRunStartIndex(messages, islands)).toBe(2);
});

it('边界行缺失 → 主段为空(返回 messages.length)', () => {
const messages = [{ clientId: 'a' }, { clientId: 'b' }];
const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'nope' }];
expect(mainContiguousRunStartIndex(messages, islands)).toBe(messages.length);
});
});

describe('canFocusWithoutJumpLoad · 孤岛一律交回 store', () => {
it('有孤岛时即便已翻到历史起点也不直接 focus(around 仍可能捞回缺的邻居)', () => {
// review #676(codex P1):跳转不只走分页,它还发 around-client-id。远程权威重建可以同时
// 留下"孤岛 + hasMore=false"(翻到历史起点却保留了一条被有损推送落下的脱离行),那时
// around 恰好能把它周围缺的邻居捞回来。用 hasMore 短路会把这条修复通道永久关掉。
const state = {
messages: [{ clientId: 'a' }, { clientId: 'b' }],
historyWindowHasIsland: true,
hasMoreMessages: false,
};
expect(canFocusWithoutJumpLoad(state, 'b')).toBe(false);
});

it('有孤岛且还能继续翻页时同样交回 store 补齐', () => {
const state = {
messages: [{ clientId: 'a' }],
historyWindowHasIsland: true,
hasMoreMessages: true,
};
expect(canFocusWithoutJumpLoad(state, 'a')).toBe(false);
describe('isInsideMainContiguousRun(与 canFocusWithoutJumpLoad 共用同一把尺子)', () => {
it('目标在窗口外或下标早于主段起点 → false', () => {
const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'b' }];
expect(
isInsideMainContiguousRun(
[{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }],
islands,
'c',
),
).toBe(true);
expect(
isInsideMainContiguousRun(
[{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }],
islands,
'b',
),
).toBe(false);
expect(
isInsideMainContiguousRun(
[{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }],
islands,
'zzz',
),
).toBe(false);
});
});
5 changes: 3 additions & 2 deletions apps/desktop/src/renderer/hooks/useCCAgentChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {

import {
makerChatStore,
EMPTY_LIGHT_STATE,
EMPTY_SESSION_STATE,
EMPTY_TASK_UPDATES,
type AgentStatus,
Expand Down Expand Up @@ -357,8 +358,8 @@ function useHeavyChatSnapshot(
function useLiveChatLightState(sessionId: string | undefined): SessionChatLightState {
return useSyncExternalStore(
(cb) => (sessionId ? makerChatStore.subscribeLight(sessionId, cb) : NOOP_UNSUBSCRIBE),
() => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_SESSION_STATE),
() => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_SESSION_STATE),
() => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_LIGHT_STATE),
() => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_LIGHT_STATE),
);
}

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/lib/historyGap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* 桌面消费方:`components/chat/MessageStream` —— tool_segment 按它切段、工作组按它切组。
* makerChatStore 用同一把尺子识别裁剪后窗口里「孤岛 + 最新连续尾段」的缺口,好把
* 向上翻页的游标留在尾段下沿(见 oldestMessageOfNewestContiguousRun);跳转补齐预算
* 向上翻页的游标留在尾段下沿(见 makerChatStore 的 oldestMessageOfMainContiguousRun);跳转补齐预算
* 本身仍按行数取保守上界(JUMP_BACKFILL_MAX_ITEMS),不按本常量估算。
*
* 为什么保留这层 re-export 而不让 MessageStream 直接引 shared:阈值原本是桌面常量,
Expand Down
Loading