返回 DeepSeek-Reasonix
bridgeHistoryFixtures.ts
根目录 / desktop / frontend / src / lib / bridgeHistoryFixtures.ts
1 import type {
2 HistoryContentRef,
3 HistoryEntry,
4 HistoryMessage,
5 HistorySlice,
6 HistorySliceRequest,
7 } from "./types";
8
9 export function mockHistorySlice(
10 tabID: string,
11 messages: HistoryMessage[],
12 req: HistorySliceRequest,
13 benchMock: boolean,
14 ): HistorySlice {
15 const turnsOf: number[] = [];
16 let turn = 0;
17 for (const message of messages) {
18 if (message.role === "user") turn += 1;
19 turnsOf.push(turn);
20 }
21 let before = messages.length;
22 if (req.cursor) {
23 try {
24 const decoded = JSON.parse(atob(req.cursor)) as { before?: number };
25 if (typeof decoded.before === "number" && decoded.before >= 0 && decoded.before < before) before = decoded.before;
26 } catch { /* unknown cursor: serve the latest page */ }
27 }
28 const empty: HistorySlice = { entries: [], nextCursor: "", hasOlder: false, totalTurns: turn, startTurn: 0, endTurn: 0, stale: false, revision: 0 };
29 if (before <= 0 || messages.length === 0) return empty;
30 const windowedTranscriptContract = messages.length === 2_000
31 && messages.some((message) => message.content?.includes("Windowed turn 1000"));
32 const turns = windowedTranscriptContract
33 ? Math.max(120, Math.floor(req.turns || 0))
34 : Math.max(1, Math.floor(req.turns || 12));
35 const newestTurn = turnsOf[before - 1];
36 const oldestTurn = newestTurn > 0 ? Math.max(newestTurn - turns + 1, 1) : 0;
37 let lo = 0;
38 if (oldestTurn > 1) {
39 lo = before;
40 for (let index = 0; index < before; index += 1) {
41 if (turnsOf[index] >= oldestTurn) {
42 lo = index;
43 break;
44 }
45 }
46 }
47 // The geometry-contract fixture is deliberately a single completed turn.
48 // Serve all of it in the first slice so its first-visit traversal measures
49 // row geometry, not an unrelated history-prepend transaction. Prepend is
50 // covered by the dedicated history pagination scenario below.
51 const geometryContract = messages.some((message) => message.content?.includes("Geometry contract fixture complete."));
52 // The browser selection contract spans 20+ turns in the 3.2k-message
53 // tool-dense fixture. A production request is turn-budgeted, but the dev
54 // mock's generic 120-entry fallback would expose barely two such turns and
55 // make the test depend on a long chain of incidental prepend timings.
56 const toolDenseSelectionContract = messages.length > 1_500
57 && messages.some((message) => message.content?.startsWith("bench turn 38:"));
58 const maxEntries = geometryContract
59 ? messages.length
60 : toolDenseSelectionContract
61 ? Math.max(1_000, Math.floor(req.entries || 0))
62 : windowedTranscriptContract
63 ? Math.max(240, Math.floor(req.entries || 0))
64 : Math.max(1, Math.floor(req.entries || 120));
65 if (before - lo > maxEntries) lo = before - maxEntries;
66 const entries = messages.slice(lo, before).map((message, index) => {
67 const entryId = `smock-${tabID}:r0:m${lo + index}:o0`;
68 const content = message.content ?? "";
69 const reasoning = message.reasoning ?? "";
70 const lazyContent = benchMock && content.includes("ASYNC LAYOUT EXPANSION COMPLETE");
71 const stormContent = benchMock && content.includes("BENCH STORM HYDRATION RESOLVED");
72 const stormReasoning = benchMock && reasoning.includes("BENCH STORM HYDRATION RESOLVED");
73 const refs: HistoryEntry["refs"] = [];
74 if (lazyContent || stormContent) {
75 refs.push({ entryId, field: "content", size: content.length, chunks: 1, revision: 0, revKnown: false, digest: "" });
76 }
77 if (stormReasoning) {
78 refs.push({ entryId, field: "reasoning", size: reasoning.length, chunks: 1, revision: 0, revKnown: false, digest: "" });
79 }
80 return {
81 entryId,
82 turn: turnsOf[lo + index],
83 order: lo + index,
84 message: refs.length > 0 ? {
85 ...message,
86 ...(lazyContent || stormContent ? { content: content.slice(0, 4 * 1024) } : {}),
87 ...(stormReasoning ? { reasoning: reasoning.slice(0, 4 * 1024) } : {}),
88 } : message,
89 refs,
90 };
91 });
92 const visibleTurns = entries.map((entry) => entry.turn).filter((value) => value > 0);
93 return {
94 entries,
95 nextCursor: lo > 0 ? btoa(JSON.stringify({ v: 1, before: lo })) : "",
96 hasOlder: lo > 0,
97 totalTurns: turn,
98 startTurn: visibleTurns.length > 0 ? Math.min(...visibleTurns) : 0,
99 endTurn: visibleTurns.length > 0 ? Math.max(...visibleTurns) : 0,
100 stale: false,
101 revision: 0,
102 };
103 }
104
105 export function mockHistoryContentField(message: HistoryMessage, ref: HistoryContentRef): string {
106 switch (ref.field) {
107 case "content": return message.content ?? "";
108 case "reasoning": return message.reasoning ?? "";
109 case "submitText": return message.submitText ?? "";
110 case "detail": return message.detail ?? "";
111 case "code": return message.code ?? "";
112 case "summary": return message.summary ?? "";
113 case "archive": return message.archive ?? "";
114 case "toolResultError": return message.toolResultError ?? "";
115 case "toolArguments": return (message.toolCalls ?? []).find((toolCall) => toolCall.id === ref.toolCallId)?.arguments ?? "";
116 case "toolSubject": return (message.toolCalls ?? []).find((toolCall) => toolCall.id === ref.toolCallId)?.subject ?? "";
117 case "toolSummary": return (message.toolCalls ?? []).find((toolCall) => toolCall.id === ref.toolCallId)?.summary ?? "";
118 case "toolDiff": return (message.toolCalls ?? []).find((toolCall) => toolCall.id === ref.toolCallId)?.diff ?? "";
119 default: return "";
120 }
121 }
122
123 // The dev mock's topic history: one fixture per mock topic, addressed the way the
124 // transcript addresses it. The epoch is a parameter so a session's timestamps stay
125 // stable across every read of the same mock app.
126 function mockLongTranscriptHistory(t0: number): HistoryMessage[] {
127 const out: HistoryMessage[] = [];
128 for (let i = 1; i <= 18; i++) {
129 out.push({
130 role: "user",
131 content: `第 ${i} 轮:检查聊天滚动定位,切换会话后应该自动停在最新消息底部。`,
132 createdAt: t0 - (19 - i) * 15 * 60_000,
133 });
134 if (i === 4) {
135 out.push({ role: "phase", content: "复现切换会话后的滚动位置" });
136 }
137 if (i === 8) {
138 const toolID = "mock-scroll-layout-check";
139 out.push({
140 role: "assistant",
141 content: "我会先读取滚动容器尺寸,再确认是否存在动态高度变化导致的底部偏移。",
142 reasoning: "旧实现只重置 stick 标志,没有主动等待布局稳定;AskCard、Approval、Todo 这类卡片可能在下一帧改变高度。",
143 toolCalls: [{ id: toolID, name: "bash", arguments: JSON.stringify({ command: "npm run check:css && pnpm typecheck" }) }],
144 });
145 out.push({
146 role: "tool",
147 toolCallId: toolID,
148 toolName: "bash",
149 content: "CSS syntax check passed\nz-index token check passed\ntsc --noEmit passed\n",
150 });
151 continue;
152 }
153 if (i === 13) {
154 out.push({ role: "notice", level: "info", content: "模拟提示:用户向上查看历史后,右下角应出现跳到底部按钮。" });
155 }
156 out.push({
157 role: "assistant",
158 content: [
159 `第 ${i} 轮结果:当前滚动契约会在切换会话或 reveal 信号到达后执行强制贴底。`,
160 "它会先立即设置 scrollTop 到 scrollHeight,再连续几个 animation frame 复查,避免动态内容把底部再次推走。",
161 "如果用户主动向上滚动,普通 streaming 不会强行拉回;只有点击跳到底部按钮或显式切换会话才会重新贴底。",
162 ].join("\n\n"),
163 });
164 }
165 out.push({
166 role: "compaction",
167 content: "",
168 trigger: "manual",
169 messages: 36,
170 summary: "Mock 长会话用于验证桌面端 Transcript 自动贴底、多帧布局修正和跳到底部按钮。",
171 archive: "mock-scroll-preview",
172 });
173 out.push({
174 role: "assistant",
175 content: "最终状态:这条消息应该位于真实底部。向上滚动后,右下角会显示跳到底部按钮;点击按钮后应回到这里。",
176 });
177 return out;
178 }
179
180 export function mockTopicHistory(t0: number, topicId: string): HistoryMessage[] {
181 switch (topicId) {
182 case "topic_product":
183 return [
184 {
185 role: "user",
186 content: [
187 "[[reasonix-im]]",
188 "provider=lark",
189 "label=Feishu / Lark",
190 "sender=ou_mock_user_001",
191 "chat=p2p 会话",
192 "[[/reasonix-im]]",
193 "你可以做什么",
194 ].join("\n"),
195 },
196 {
197 role: "assistant",
198 content: "这是 Global 范围下的 IM 会话。我可以先处理不依赖项目文件的问答、计划和信息整理;需要进入项目时,再由桌面端显式绑定或迁移到项目话题。",
199 },
200 ];
201 case "topic_ai":
202 return [
203 {
204 role: "user",
205 content: [
206 "[[reasonix-im]]",
207 "provider=weixin",
208 "label=微信",
209 "sender=wxid_mock_user_001",
210 "chat=单聊",
211 "[[/reasonix-im]]",
212 "帮我整理一下今天要做的事",
213 ].join("\n"),
214 },
215 {
216 role: "assistant",
217 content: "可以。我会先在 Global 范围里整理任务清单;如果某条任务需要读取项目文件,再切到你授权的项目话题处理。",
218 },
219 ];
220 case "topic_dev_standard":
221 return mockLongTranscriptHistory(t0);
222 case "topic_p3b_pd":
223 return [
224 { role: "user", content: "把 p3b P&D 的范围和风险重新整理成可执行计划。" },
225 { role: "phase", content: "分析需求范围" },
226 ];
227 case "topic_p3a_pd":
228 return [
229 { role: "user", content: "复盘 p3a 的技术方案,先不要写文件,先说明你的判断。" },
230 ];
231 case "topic_hotfix":
232 return [
233 { role: "user", content: "检查 post-p3-hotfix 的回归风险,重点看最近的 shell 输出和 git 改动。" },
234 { role: "assistant", content: "", reasoning: "我先定位最近一次 hotfix 的上下文,然后用只读命令检查状态;左侧保持“思考中”,工具细节在这里展开。" },
235 ];
236 case "topic_sys_coord":
237 return [
238 { role: "user", content: "准备执行 joyquant-sys 的同步脚本,但需要我确认后再运行。" },
239 { role: "assistant", content: "", reasoning: "这个动作会运行脚本并可能刷新本地缓存,所以需要先等用户确认。" },
240 ];
241 case "topic_sys_standard":
242 return [
243 { role: "user", content: "继续制定 SYS 项目开发规范,先停在当前检查点。" },
244 { role: "assistant", content: "已暂停在规范整理阶段。当前保留了目录约定、分支策略和待确认的发布检查项;继续时可以从这里恢复。" },
245 { role: "notice", level: "info", content: "会话已暂停:未继续执行命令,等待用户恢复或切换任务。" },
246 ];
247 case "topic_sys_exception":
248 return [
249 { role: "user", content: "演练异常处理流程,看看失败时界面怎么提示。" },
250 { role: "assistant", content: "我尝试校验恢复脚本时遇到异常,已停止继续执行。" },
251 { role: "notice", level: "warn", content: "运行异常:恢复脚本缺少必要环境变量 JOYQUANT_SYS_TOKEN。请补齐配置后重试。" },
252 ];
253 default:
254 return [];
255 }
256 }
257
257 lines TYPESCRIPT