paseo-plugin-chat-view 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.compact-agent-activity +21 -0
  3. package/LICENSE.mermaid +21 -0
  4. package/README.md +428 -2
  5. package/README_zh.md +429 -0
  6. package/client/activity/Activity.test.ts +220 -0
  7. package/client/activity/Activity.tsx +784 -0
  8. package/client/activity/detail/detail.test.ts +102 -0
  9. package/client/activity/detail/dispatch.tsx +94 -0
  10. package/client/activity/detail/file.tsx +93 -0
  11. package/client/activity/detail/index.tsx +39 -0
  12. package/client/activity/detail/parts.tsx +221 -0
  13. package/client/activity/detail/search.tsx +59 -0
  14. package/client/activity/detail/terminal.tsx +53 -0
  15. package/client/activity/detail/texts.tsx +42 -0
  16. package/client/activity/disclosure.ts +49 -0
  17. package/client/activity/dom.test.ts +52 -0
  18. package/client/activity/dom.ts +138 -0
  19. package/client/activity/group-store.test.ts +126 -0
  20. package/client/activity/group-store.ts +265 -0
  21. package/client/activity/highlight.test.ts +116 -0
  22. package/client/activity/highlight.ts +418 -0
  23. package/client/activity/history.test.ts +602 -0
  24. package/client/activity/history.ts +239 -0
  25. package/client/activity/index.ts +74 -0
  26. package/client/activity/latest-marker.test.ts +41 -0
  27. package/client/activity/latest-marker.ts +70 -0
  28. package/client/activity/styles.ts +535 -0
  29. package/client/activity/tools/child-agent.tsx +308 -0
  30. package/client/activity/tools/exa.tsx +99 -0
  31. package/client/activity/tools/github.tsx +501 -0
  32. package/client/activity/tools/list.tsx +102 -0
  33. package/client/activity/tools/lists.test.ts +193 -0
  34. package/client/activity/tools/paseo/agent.tsx +384 -0
  35. package/client/activity/tools/paseo/browser.tsx +322 -0
  36. package/client/activity/tools/paseo/index.tsx +79 -0
  37. package/client/activity/tools/paseo/parts.tsx +133 -0
  38. package/client/activity/tools/paseo/provider.tsx +220 -0
  39. package/client/activity/tools/paseo/schedule.tsx +246 -0
  40. package/client/activity/tools/paseo/speak.tsx +20 -0
  41. package/client/activity/tools/paseo/terminal.tsx +214 -0
  42. package/client/activity/tools/paseo/workspace.tsx +162 -0
  43. package/client/activity/tools/shared.tsx +431 -0
  44. package/client/activity/transform.test.ts +28 -0
  45. package/client/activity/transform.ts +43 -0
  46. package/client/agent-pills.ts +56 -0
  47. package/client/dom.ts +75 -0
  48. package/client/enhance/controller.ts +111 -0
  49. package/client/enhance/dom.test.ts +28 -0
  50. package/client/enhance/dom.ts +101 -0
  51. package/client/enhance/index.ts +95 -0
  52. package/client/enhance/pill.tsx +19 -0
  53. package/client/enhance/state.ts +58 -0
  54. package/client/feature.ts +22 -0
  55. package/client/features.test.ts +149 -0
  56. package/client/features.ts +83 -0
  57. package/client/locale.ts +45 -0
  58. package/client/mermaid/diagram.tsx +438 -0
  59. package/client/mermaid/index.ts +27 -0
  60. package/client/mermaid/item.tsx +28 -0
  61. package/client/mermaid/markdown.tsx +217 -0
  62. package/client/mermaid/registration.ts +32 -0
  63. package/client/mermaid/toolbar.tsx +175 -0
  64. package/client/mermaid/viewer.tsx +314 -0
  65. package/client/readonly/dom.test.ts +66 -0
  66. package/client/readonly/dom.ts +32 -0
  67. package/client/readonly/index.ts +27 -0
  68. package/client/registration.test.ts +243 -0
  69. package/client/settings.tsx +438 -0
  70. package/client/sticky/StickyBar.tsx +144 -0
  71. package/client/sticky/dom.test.ts +230 -0
  72. package/client/sticky/dom.ts +517 -0
  73. package/client/sticky/index.ts +38 -0
  74. package/client/sticky/pill.tsx +70 -0
  75. package/client/timeline-pager.test.ts +121 -0
  76. package/client/timeline-pager.ts +123 -0
  77. package/index.client.tsx +44 -0
  78. package/index.server.ts +12 -0
  79. package/package.json +42 -4
  80. package/paseo-plugin.json +6 -0
  81. package/server/enhance/index.ts +63 -0
  82. package/server/enhance/manifest.ts +176 -0
  83. package/server/enhance/probe.test.ts +215 -0
  84. package/server/enhance/probe.ts +118 -0
  85. package/server/enhance/provider.ts +149 -0
  86. package/shared/activity/child-agent.test.ts +27 -0
  87. package/shared/activity/child-agent.ts +26 -0
  88. package/shared/activity/diff.test.ts +74 -0
  89. package/shared/activity/diff.ts +207 -0
  90. package/shared/activity/exa.test.ts +66 -0
  91. package/shared/activity/exa.ts +183 -0
  92. package/shared/activity/file-kind.test.ts +13 -0
  93. package/shared/activity/file-kind.ts +112 -0
  94. package/shared/activity/github.test.ts +56 -0
  95. package/shared/activity/github.ts +174 -0
  96. package/shared/activity/palette.test.ts +26 -0
  97. package/shared/activity/palette.ts +106 -0
  98. package/shared/activity/parse.test.ts +39 -0
  99. package/shared/activity/parse.ts +115 -0
  100. package/shared/activity/paseo-tools.test.ts +163 -0
  101. package/shared/activity/paseo-tools.ts +497 -0
  102. package/shared/activity/text.test.ts +85 -0
  103. package/shared/activity/text.ts +202 -0
  104. package/shared/activity/timeline.test.ts +26 -0
  105. package/shared/activity/timeline.ts +108 -0
  106. package/shared/activity/tool-presentation.test.ts +154 -0
  107. package/shared/activity/tool-presentation.ts +352 -0
  108. package/shared/enhance/contract.ts +105 -0
  109. package/shared/enhance/decide.ts +97 -0
  110. package/shared/enhance/enhance.test.ts +188 -0
  111. package/shared/enhance/failure.ts +40 -0
  112. package/shared/enhance/index.ts +8 -0
  113. package/shared/enhance/normalize.ts +16 -0
  114. package/shared/enhance/parse.ts +79 -0
  115. package/shared/enhance/policy.ts +48 -0
  116. package/shared/enhance/prompt.ts +134 -0
  117. package/shared/enhance/run.ts +84 -0
  118. package/shared/i18n.test.ts +81 -0
  119. package/shared/i18n.ts +300 -0
  120. package/shared/mermaid/flowchart.test.ts +169 -0
  121. package/shared/mermaid/flowchart.ts +271 -0
  122. package/shared/mermaid/layout.test.ts +305 -0
  123. package/shared/mermaid/layout.ts +496 -0
  124. package/shared/mermaid/parse.test.ts +110 -0
  125. package/shared/mermaid/parse.ts +193 -0
  126. package/shared/mermaid/segment.test.ts +55 -0
  127. package/shared/mermaid/segment.ts +95 -0
  128. package/shared/mermaid/sequence.test.ts +68 -0
  129. package/shared/mermaid/sequence.ts +130 -0
  130. package/shared/mermaid/transform.test.ts +52 -0
  131. package/shared/mermaid/transform.ts +45 -0
  132. package/shared/mermaid/zoom.test.ts +134 -0
  133. package/shared/mermaid/zoom.ts +114 -0
  134. package/shared/settings.ts +41 -0
  135. package/shared/sticky/history.test.ts +91 -0
  136. package/shared/sticky/history.ts +86 -0
@@ -0,0 +1,602 @@
1
+ import { afterEach, expect, it, vi } from "vitest";
2
+ import { createToolCallData } from "../../shared/activity/timeline";
3
+ import { cleanupActivityHistory, mergeResolvedGroups, useActivityGroup } from "./history";
4
+
5
+ const hooks = vi.hoisted(() => ({
6
+ effects: [] as Array<() => (() => void) | void>,
7
+ paseo: undefined as unknown,
8
+ ref: { current: undefined as unknown },
9
+ }));
10
+ vi.mock("react", () => ({
11
+ useMemo: (factory: () => unknown) => factory(),
12
+ useRef: () => hooks.ref,
13
+ useEffect: (effect: () => (() => void) | void) => hooks.effects.push(effect),
14
+ useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(),
15
+ }));
16
+ vi.mock("@getpaseo/plugin/client", () => ({ usePaseo: () => hooks.paseo }));
17
+
18
+ function commit() {
19
+ const cleanups = hooks.effects.splice(0).map((effect) => effect());
20
+ return () => cleanups.forEach((cleanup) => cleanup?.());
21
+ }
22
+ afterEach(() => {
23
+ hooks.effects.length = 0;
24
+ hooks.ref.current = undefined;
25
+ cleanupActivityHistory();
26
+ });
27
+
28
+ it("keeps reasoning folded while streamed text is ahead of the history fetch", async () => {
29
+ const refetch = vi.fn(async () => ({
30
+ entries: [
31
+ {
32
+ item: { type: "reasoning", text: "thinking" },
33
+ seqStart: 1,
34
+ seqEnd: 1,
35
+ timestamp: new Date(1000).toISOString(),
36
+ },
37
+ ],
38
+ epoch: "one",
39
+ startCursor: null,
40
+ hasOlder: false,
41
+ }));
42
+ hooks.paseo = {
43
+ agents: {
44
+ ref: () => ({
45
+ timeline: {
46
+ refetch,
47
+ subscribe: () => Object.assign(vi.fn(), { ready: Promise.resolve() }),
48
+ },
49
+ }),
50
+ },
51
+ };
52
+ const render = (text: string) =>
53
+ useActivityGroup("agent", new Date(1000), { text, phase: "streaming" }, true);
54
+ render("thinking");
55
+ commit();
56
+ await vi.waitFor(() => expect(render("thinking")?.isLeader).toBe(true));
57
+ expect(refetch).toHaveBeenCalledOnce();
58
+ hooks.effects.length = 0;
59
+ expect(render("thinking more streamed tokens")?.isLeader).toBe(true);
60
+ expect(render("different thought at the same timestamp")).toBeUndefined();
61
+ });
62
+
63
+ it("continues loading older activity when the tail contains only visible messages", async () => {
64
+ const cursor = { epoch: "one", seq: 2 };
65
+ const refetch = vi
66
+ .fn()
67
+ .mockResolvedValueOnce({
68
+ entries: [
69
+ {
70
+ item: { type: "assistant_message", text: "answer" },
71
+ seqStart: 2,
72
+ seqEnd: 2,
73
+ timestamp: new Date(2000).toISOString(),
74
+ },
75
+ ],
76
+ epoch: "one",
77
+ startCursor: cursor,
78
+ hasOlder: true,
79
+ })
80
+ .mockResolvedValueOnce({
81
+ entries: [
82
+ {
83
+ item: { type: "reasoning", text: "thinking" },
84
+ seqStart: 1,
85
+ seqEnd: 1,
86
+ timestamp: new Date(1000).toISOString(),
87
+ },
88
+ ],
89
+ epoch: "one",
90
+ startCursor: null,
91
+ hasOlder: false,
92
+ });
93
+ hooks.paseo = {
94
+ agents: {
95
+ ref: () => ({
96
+ timeline: {
97
+ refetch,
98
+ subscribe: () => Object.assign(vi.fn(), { ready: Promise.resolve() }),
99
+ },
100
+ }),
101
+ },
102
+ };
103
+ const render = () =>
104
+ useActivityGroup("agent", new Date(1000), { text: "thinking", phase: "complete" }, true);
105
+ render();
106
+ commit();
107
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
108
+ hooks.effects.length = 0;
109
+ expect(render()).toBeUndefined();
110
+ commit();
111
+ await vi.waitFor(() => expect(render()?.isLeader).toBe(true));
112
+ expect(refetch).toHaveBeenNthCalledWith(2, {
113
+ direction: "before",
114
+ cursor,
115
+ projection: "projected",
116
+ limit: 200,
117
+ });
118
+ });
119
+
120
+ it("keeps folded row geometry when virtualized activity rows unmount and remount", async () => {
121
+ const tool = {
122
+ type: "tool_call" as const,
123
+ callId: "shell-1",
124
+ name: "exec_command",
125
+ status: "completed" as const,
126
+ error: null,
127
+ detail: { type: "shell" as const, command: "pwd", cwd: "/tmp" },
128
+ };
129
+ const refetch = vi.fn(async () => ({
130
+ entries: [
131
+ {
132
+ item: { type: "reasoning", text: "thinking" },
133
+ seqStart: 1,
134
+ seqEnd: 1,
135
+ timestamp: new Date(1000).toISOString(),
136
+ },
137
+ { item: tool, seqStart: 2, seqEnd: 2, timestamp: new Date(2000).toISOString() },
138
+ ],
139
+ epoch: "one",
140
+ startCursor: null,
141
+ hasOlder: false,
142
+ }));
143
+ const unsubscribe = vi.fn();
144
+ const subscribe = vi.fn(() => Object.assign(unsubscribe, { ready: Promise.resolve() }));
145
+ hooks.paseo = { agents: { ref: () => ({ timeline: { refetch, subscribe } }) } };
146
+ const render = () => useActivityGroup("agent", new Date(2000), createToolCallData(tool), true);
147
+ render();
148
+ const unmount = commit();
149
+ await vi.waitFor(() => expect(render()?.isLeader).toBe(false));
150
+ hooks.effects.length = 0;
151
+ unmount();
152
+ await Promise.resolve();
153
+ expect(unsubscribe).toHaveBeenCalledOnce();
154
+ // This row must stay hidden immediately, before the next asynchronous history read.
155
+ expect(render()?.isLeader).toBe(false);
156
+ const cached = render();
157
+ const fetchCount = refetch.mock.calls.length;
158
+ const unmountAgain = commit();
159
+ await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2));
160
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(fetchCount + 1));
161
+ expect(render()).toBe(cached);
162
+ unmountAgain();
163
+ });
164
+
165
+ it.each(["unchanged", "new output", "new epoch", "replacement"])(
166
+ "refreshes %s without prematurely clearing folded geometry",
167
+ async (scenario) => {
168
+ const page = {
169
+ entries: [
170
+ {
171
+ item: { type: "reasoning", text: "thinking" },
172
+ seqStart: 1,
173
+ seqEnd: 1,
174
+ timestamp: new Date(1000).toISOString(),
175
+ },
176
+ ],
177
+ epoch: "one",
178
+ startCursor: null,
179
+ hasOlder: false,
180
+ };
181
+ let recover!: () => void;
182
+ let finish!: (value: typeof page) => void;
183
+ const refetch = vi
184
+ .fn()
185
+ .mockResolvedValueOnce(page)
186
+ .mockImplementationOnce(
187
+ () =>
188
+ new Promise<typeof page>((resolve) => {
189
+ finish = resolve;
190
+ }),
191
+ );
192
+ hooks.paseo = {
193
+ agents: {
194
+ ref: () => ({
195
+ timeline: {
196
+ refetch,
197
+ subscribe: (listener: (update: unknown) => void) => {
198
+ recover = () =>
199
+ listener({
200
+ event: {
201
+ type: scenario === "replacement" ? "replacement" : "subscription_restored",
202
+ },
203
+ });
204
+ return Object.assign(vi.fn(), { ready: Promise.resolve() });
205
+ },
206
+ },
207
+ }),
208
+ },
209
+ };
210
+ const render = () =>
211
+ useActivityGroup("agent", new Date(1000), { text: "thinking", phase: "complete" }, true);
212
+ render();
213
+ commit();
214
+ await vi.waitFor(() => expect(render()?.isLeader).toBe(true));
215
+ const cached = render();
216
+ recover();
217
+ if (scenario === "replacement") expect(render()).toBeUndefined();
218
+ else expect(render()).toBe(cached);
219
+ const refreshed = JSON.parse(JSON.stringify(page)) as typeof page;
220
+ if (scenario === "new output") refreshed.entries[0]!.item.text = "updated thinking";
221
+ if (scenario === "new epoch") {
222
+ refreshed.epoch = "two";
223
+ refreshed.entries = [];
224
+ }
225
+ finish(refreshed);
226
+ await Promise.resolve();
227
+ await Promise.resolve();
228
+ if (scenario === "unchanged") expect(render()).toBe(cached);
229
+ else if (scenario === "replacement")
230
+ await vi.waitFor(() => expect(render()?.isLeader).toBe(true));
231
+ else expect(render()).toBeUndefined();
232
+ if (scenario === "new output") {
233
+ expect(
234
+ useActivityGroup(
235
+ "agent",
236
+ new Date(1000),
237
+ { text: "updated thinking", phase: "complete" },
238
+ true,
239
+ )?.isLeader,
240
+ ).toBe(true);
241
+ }
242
+ },
243
+ );
244
+
245
+ it("matches a later thought to its own text within the same folded group", async () => {
246
+ const refetch = vi.fn(async () => ({
247
+ entries: [
248
+ {
249
+ item: { type: "reasoning", text: "first thought" },
250
+ seqStart: 1,
251
+ seqEnd: 1,
252
+ timestamp: new Date(1000).toISOString(),
253
+ },
254
+ {
255
+ item: { type: "reasoning", text: "second thought" },
256
+ seqStart: 2,
257
+ seqEnd: 2,
258
+ timestamp: new Date(2000).toISOString(),
259
+ },
260
+ ],
261
+ epoch: "one",
262
+ startCursor: null,
263
+ hasOlder: false,
264
+ }));
265
+ hooks.paseo = {
266
+ agents: {
267
+ ref: () => ({
268
+ timeline: {
269
+ refetch,
270
+ subscribe: () => Object.assign(vi.fn(), { ready: Promise.resolve() }),
271
+ },
272
+ }),
273
+ },
274
+ };
275
+ const render = () =>
276
+ useActivityGroup("agent", new Date(2000), { text: "second thought", phase: "complete" }, true);
277
+ render();
278
+ commit();
279
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
280
+ await Promise.resolve();
281
+ expect(render()?.isLeader).toBe(false);
282
+ });
283
+
284
+ it("folds distinct thoughts sharing a provider timestamp without conflating their text", async () => {
285
+ const refetch = vi.fn(async () => ({
286
+ entries: [
287
+ {
288
+ item: { type: "reasoning", text: "first thought" },
289
+ seqStart: 1,
290
+ seqEnd: 1,
291
+ timestamp: new Date(1000).toISOString(),
292
+ },
293
+ {
294
+ item: { type: "reasoning", text: "second thought" },
295
+ seqStart: 2,
296
+ seqEnd: 2,
297
+ timestamp: new Date(1000).toISOString(),
298
+ },
299
+ ],
300
+ epoch: "one",
301
+ startCursor: null,
302
+ hasOlder: false,
303
+ }));
304
+ hooks.paseo = {
305
+ agents: {
306
+ ref: () => ({
307
+ timeline: {
308
+ refetch,
309
+ subscribe: () => Object.assign(vi.fn(), { ready: Promise.resolve() }),
310
+ },
311
+ }),
312
+ },
313
+ };
314
+ const render = (text: string) =>
315
+ useActivityGroup("agent", new Date(1000), { text, phase: "complete" }, true);
316
+ render("first thought");
317
+ commit();
318
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
319
+ await Promise.resolve();
320
+ expect(render("first thought")?.isLeader).toBe(true);
321
+ expect(render("second thought")?.isLeader).toBe(false);
322
+ expect(render("unrelated thought")).toBeUndefined();
323
+ });
324
+
325
+ it("preserves follower state and avoids regressing to leader when entries are isolated", () => {
326
+ const target = new Map();
327
+ const leaderGroup = {
328
+ turnIndex: 1,
329
+ leaderId: "tool:1",
330
+ items: [
331
+ { id: "tool:1", type: "tool_call" as const, timestamp: 1000 },
332
+ { id: "tool:2", type: "tool_call" as const, timestamp: 2000 },
333
+ ],
334
+ hasReasoning: false,
335
+ commandCount: 2,
336
+ editCount: 0,
337
+ readCount: 0,
338
+ searchCount: 0,
339
+ otherToolCount: 0,
340
+ isRunning: false,
341
+ hasError: false,
342
+ startedAt: 1000,
343
+ };
344
+ target.set("tool:1", { group: leaderGroup, isLeader: true, itemId: "tool:1" });
345
+ target.set("tool:2", { group: leaderGroup, isLeader: false, itemId: "tool:2" });
346
+
347
+ const isolatedGroup = {
348
+ turnIndex: 2,
349
+ leaderId: "tool:2",
350
+ items: [{ id: "tool:2", type: "tool_call" as const, timestamp: 2000 }],
351
+ hasReasoning: false,
352
+ commandCount: 1,
353
+ editCount: 0,
354
+ readCount: 0,
355
+ searchCount: 0,
356
+ otherToolCount: 0,
357
+ isRunning: false,
358
+ hasError: false,
359
+ startedAt: 2000,
360
+ };
361
+ const fresh = new Map();
362
+ fresh.set("tool:2", { group: isolatedGroup, isLeader: true, itemId: "tool:2" });
363
+
364
+ mergeResolvedGroups(target, fresh);
365
+
366
+ expect(target.get("tool:2")?.isLeader).toBe(false);
367
+ expect(target.get("tool:2")?.group.items.length).toBe(2);
368
+ });
369
+
370
+ it("demotes false leader to follower when more complete history confirms it is a follower", () => {
371
+ const target = new Map();
372
+ // 模拟之前因断页误将 tool:2 记录为 Leader(包含 50 项)
373
+ const oldFalseLeaderGroup = {
374
+ turnIndex: 2,
375
+ leaderId: "tool:2",
376
+ items: Array.from({ length: 50 }).map((_, i) => ({
377
+ id: `tool:${i + 2}`,
378
+ type: "tool_call" as const,
379
+ timestamp: 2000 + i,
380
+ })),
381
+ hasReasoning: false,
382
+ commandCount: 50,
383
+ editCount: 0,
384
+ readCount: 0,
385
+ searchCount: 0,
386
+ otherToolCount: 0,
387
+ isRunning: false,
388
+ hasError: false,
389
+ startedAt: 2000,
390
+ };
391
+ target.set("tool:2", { group: oldFalseLeaderGroup, isLeader: true, itemId: "tool:2" });
392
+
393
+ // 后来拉回了更早的真实起点 tool:1,证实 tool:2 实际上是 tool:1 所在组的 Follower
394
+ const completeLeaderGroup = {
395
+ turnIndex: 1,
396
+ leaderId: "tool:1",
397
+ items: [
398
+ { id: "tool:1", type: "tool_call" as const, timestamp: 1000 },
399
+ { id: "tool:2", type: "tool_call" as const, timestamp: 2000 },
400
+ ],
401
+ hasReasoning: false,
402
+ commandCount: 55,
403
+ editCount: 0,
404
+ readCount: 0,
405
+ searchCount: 0,
406
+ otherToolCount: 0,
407
+ isRunning: false,
408
+ hasError: false,
409
+ startedAt: 1000,
410
+ };
411
+ const fresh = new Map();
412
+ fresh.set("tool:1", { group: completeLeaderGroup, isLeader: true, itemId: "tool:1" });
413
+ fresh.set("tool:2", { group: completeLeaderGroup, isLeader: false, itemId: "tool:2" });
414
+
415
+ mergeResolvedGroups(target, fresh);
416
+
417
+ // tool:2 必须被降级为 follower,绝不允许并存两个 Leader(消灭截图中 Ran 55 与 Ran 50 并存的现象)
418
+ expect(target.get("tool:2")?.isLeader).toBe(false);
419
+ expect(target.get("tool:1")?.isLeader).toBe(true);
420
+ });
421
+
422
+ it("retains resolved history memory across remounts even when new fetches only contain tail", async () => {
423
+ const toolOld = {
424
+ type: "tool_call" as const,
425
+ callId: "old-tool",
426
+ name: "exec_command",
427
+ status: "completed" as const,
428
+ error: null,
429
+ detail: { type: "shell" as const, command: "ls", cwd: "/tmp" },
430
+ };
431
+ const toolNew = {
432
+ type: "tool_call" as const,
433
+ callId: "new-tool",
434
+ name: "exec_command",
435
+ status: "completed" as const,
436
+ error: null,
437
+ detail: { type: "shell" as const, command: "pwd", cwd: "/tmp" },
438
+ };
439
+
440
+ const initialPage = {
441
+ entries: [
442
+ {
443
+ item: { type: "reasoning", text: "thinking" },
444
+ seqStart: 1,
445
+ seqEnd: 1,
446
+ timestamp: new Date(1000).toISOString(),
447
+ },
448
+ { item: toolOld, seqStart: 2, seqEnd: 2, timestamp: new Date(2000).toISOString() },
449
+ ],
450
+ epoch: "one",
451
+ startCursor: null,
452
+ hasOlder: false,
453
+ };
454
+
455
+ const refetch = vi.fn().mockResolvedValue(initialPage);
456
+ hooks.paseo = {
457
+ agents: {
458
+ ref: () => ({
459
+ timeline: {
460
+ refetch,
461
+ subscribe: () => Object.assign(vi.fn(), { ready: Promise.resolve() }),
462
+ },
463
+ }),
464
+ },
465
+ };
466
+
467
+ const renderOld = () =>
468
+ useActivityGroup("agent", new Date(2000), createToolCallData(toolOld), true);
469
+
470
+ renderOld();
471
+ const unmountOld = commit();
472
+ await vi.waitFor(() => expect(renderOld()?.isLeader).toBe(false));
473
+
474
+ // 虚拟列表卸载旧条目
475
+ unmountOld();
476
+ await Promise.resolve();
477
+
478
+ const tailOnlyPage = {
479
+ entries: [
480
+ {
481
+ item: { type: "assistant_message" as const, text: "done old turn" },
482
+ seqStart: 9,
483
+ seqEnd: 9,
484
+ timestamp: new Date(4000).toISOString(),
485
+ },
486
+ { item: toolNew, seqStart: 10, seqEnd: 10, timestamp: new Date(5000).toISOString() },
487
+ ],
488
+ epoch: "one",
489
+ startCursor: null,
490
+ hasOlder: false,
491
+ };
492
+ refetch.mockResolvedValue(tailOnlyPage);
493
+
494
+ // 挂载新条目,重新激活 history 并拉取新的 tail
495
+ const renderNew = () =>
496
+ useActivityGroup("agent", new Date(5000), createToolCallData(toolNew), true);
497
+ renderNew();
498
+ commit();
499
+ await vi.waitFor(() => expect(renderNew()?.isLeader).toBe(true));
500
+
501
+ // 虚拟列表重新滚动到旧命令:即使最新一次 fetch 不含旧命令,旧命令必须立即从记忆池命中且保持 follower!
502
+ expect(renderOld()?.isLeader).toBe(false);
503
+ expect(renderOld()?.group.commandCount).toBe(1);
504
+ });
505
+
506
+ it("queues concurrent older requests while a fetch is in-flight instead of dropping them", async () => {
507
+ const cursor1 = { epoch: "one", seq: 10 };
508
+ const cursor2 = { epoch: "one", seq: 5 };
509
+
510
+ let resolveSecondFetch!: (page: unknown) => void;
511
+ const secondFetchPromise = new Promise((resolve) => {
512
+ resolveSecondFetch = resolve;
513
+ });
514
+
515
+ const refetch = vi
516
+ .fn()
517
+ // 第一次 tail fetch:返回最新消息,有 older
518
+ .mockResolvedValueOnce({
519
+ entries: [
520
+ {
521
+ item: { type: "assistant_message", text: "latest" },
522
+ seqStart: 10,
523
+ seqEnd: 10,
524
+ timestamp: new Date(3000).toISOString(),
525
+ },
526
+ ],
527
+ epoch: "one",
528
+ startCursor: cursor1,
529
+ hasOlder: true,
530
+ })
531
+ // 第二次 before fetch:挂起中(in-flight)
532
+ .mockImplementationOnce(() => secondFetchPromise)
533
+ // 第三次 before fetch:返回最终思考
534
+ .mockResolvedValueOnce({
535
+ entries: [
536
+ {
537
+ item: { type: "reasoning", text: "thinking" },
538
+ seqStart: 1,
539
+ seqEnd: 1,
540
+ timestamp: new Date(1000).toISOString(),
541
+ },
542
+ ],
543
+ epoch: "one",
544
+ startCursor: null,
545
+ hasOlder: false,
546
+ });
547
+
548
+ hooks.paseo = {
549
+ agents: {
550
+ ref: () => ({
551
+ timeline: {
552
+ refetch,
553
+ subscribe: () => Object.assign(vi.fn(), { ready: Promise.resolve() }),
554
+ },
555
+ }),
556
+ },
557
+ };
558
+
559
+ const render = () =>
560
+ useActivityGroup("agent", new Date(1000), { text: "thinking", phase: "complete" }, true);
561
+
562
+ render();
563
+ commit();
564
+
565
+ // 1. 等待第一次 tail fetch 完成
566
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
567
+ hooks.effects.length = 0;
568
+
569
+ // 2. 第一次 fetch 结束后未匹配,重新 render 触发第二次 fetch(before,in-flight)
570
+ expect(render()).toBeUndefined();
571
+ commit();
572
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(2));
573
+ hooks.effects.length = 0;
574
+
575
+ // 3. 在第二次 fetch 正在进行中时(fetching === true),另一个未命中的组件触发了 older()
576
+ expect(render()).toBeUndefined();
577
+ commit();
578
+
579
+ // 4. 第二次 fetch resolve
580
+ resolveSecondFetch({
581
+ entries: [
582
+ {
583
+ item: { type: "assistant_message", text: "mid" },
584
+ seqStart: 5,
585
+ seqEnd: 5,
586
+ timestamp: new Date(2000).toISOString(),
587
+ },
588
+ ],
589
+ epoch: "one",
590
+ startCursor: cursor2,
591
+ hasOlder: true,
592
+ });
593
+
594
+ // 5. 验证排队的第三次 fetch 被自动执行了
595
+ await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(3));
596
+ expect(refetch).toHaveBeenNthCalledWith(3, {
597
+ direction: "before",
598
+ cursor: cursor2,
599
+ projection: "projected",
600
+ limit: 200,
601
+ });
602
+ });