chatccc 0.2.5 → 0.2.7

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.
@@ -1,296 +1,476 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import {
3
- chatSessionMap,
4
- sessionInfoMap,
5
- processedMessages,
6
- MAX_PROCESSED,
7
- resetState,
8
- getSessionStatus,
9
- getAllSessionsStatus,
10
- accumulateBlockContent,
11
- } from "../session.ts";
12
- import type { AccumulatorState } from "../session.ts";
13
- import type { UnifiedBlock } from "../adapters/adapter-interface.ts";
14
-
15
- // Helper to create a mock active session entry
16
- function mockActiveSession(chatId: string, overrides: Partial<{
17
- accumulatedContent: string;
18
- finalText: string;
19
- stopped: boolean;
20
- }> = {}) {
21
- chatSessionMap.set(chatId, {
22
- gen: 1,
23
- close: () => {},
24
- cardId: null,
25
- stopped: overrides.stopped ?? false,
26
- accumulatedContent: overrides.accumulatedContent ?? "thinking...",
27
- finalText: overrides.finalText ?? "",
28
- spinnerTimer: null,
29
- msgTimestamp: Date.now(),
30
- sequence: 0,
31
- cardBusy: false,
32
- });
33
- }
34
-
35
- function mockSessionInfo(chatId: string, overrides: Partial<{
36
- sessionId: string;
37
- turnCount: number;
38
- lastContextTokens: number;
39
- startTime: number;
40
- }> = {}) {
41
- sessionInfoMap.set(chatId, {
42
- sessionId: overrides.sessionId ?? "test-session-id",
43
- turnCount: overrides.turnCount ?? 3,
44
- lastContextTokens: overrides.lastContextTokens ?? 50000,
45
- startTime: overrides.startTime ?? Date.now(),
46
- model: "Claude Opus 4.7",
47
- effort: "high",
48
- });
49
- }
50
-
51
- describe("resetState", () => {
52
- it("clears all maps and sets", () => {
53
- chatSessionMap.set("chat1", {
54
- gen: 1, close: () => {}, cardId: null, stopped: false,
55
- accumulatedContent: "", finalText: "", spinnerTimer: null,
56
- msgTimestamp: 0, sequence: 0, cardBusy: false,
57
- });
58
- sessionInfoMap.set("chat1", {
59
- sessionId: "s1", turnCount: 1, lastContextTokens: 0,
60
- startTime: 0, model: "", effort: "",
61
- });
62
- processedMessages.add("msg1");
63
-
64
- resetState();
65
-
66
- expect(chatSessionMap.size).toBe(0);
67
- expect(sessionInfoMap.size).toBe(0);
68
- expect(processedMessages.size).toBe(0);
69
- });
70
- });
71
-
72
- describe("getSessionStatus", () => {
73
- beforeEach(() => {
74
- chatSessionMap.clear();
75
- sessionInfoMap.clear();
76
- });
77
-
78
- it("returns null for unknown chatId", () => {
79
- expect(getSessionStatus("nonexistent")).toBeNull();
80
- });
81
-
82
- it("returns status for idle session (info exists, no active session)", () => {
83
- mockSessionInfo("chat1");
84
- const status = getSessionStatus("chat1");
85
- expect(status).not.toBeNull();
86
- expect(status!.sessionId).toBe("test-session-id");
87
- expect(status!.running).toBe(false);
88
- expect(status!.turnCount).toBe(3);
89
- expect(status!.accumulatedLength).toBe(0);
90
- });
91
-
92
- it("returns running=true for active session", () => {
93
- mockSessionInfo("chat1");
94
- mockActiveSession("chat1", { accumulatedContent: "thinking...", finalText: "reply" });
95
- const status = getSessionStatus("chat1");
96
- expect(status!.running).toBe(true);
97
- expect(status!.accumulatedLength).toBe(16); // "thinking..."(11) + "reply"(5)
98
- });
99
-
100
- it("returns running=false for stopped session", () => {
101
- mockSessionInfo("chat1");
102
- mockActiveSession("chat1", { stopped: true });
103
- const status = getSessionStatus("chat1");
104
- expect(status!.running).toBe(false);
105
- });
106
-
107
- it("returns correct turnCount and other info fields", () => {
108
- mockSessionInfo("chat1", { turnCount: 7, lastContextTokens: 100000 });
109
- const status = getSessionStatus("chat1");
110
- expect(status!.turnCount).toBe(7);
111
- expect(status!.lastContextTokens).toBe(100000);
112
- });
113
- });
114
-
115
- describe("getAllSessionsStatus", () => {
116
- beforeEach(() => {
117
- chatSessionMap.clear();
118
- sessionInfoMap.clear();
119
- });
120
-
121
- it("returns empty array when no sessions", () => {
122
- expect(getAllSessionsStatus()).toEqual([]);
123
- });
124
-
125
- it("returns statuses for all recorded sessions", () => {
126
- mockSessionInfo("chat1", { sessionId: "s1" });
127
- mockSessionInfo("chat2", { sessionId: "s2" });
128
- mockActiveSession("chat1");
129
- const result = getAllSessionsStatus();
130
- expect(result).toHaveLength(2);
131
- expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
132
- expect(result.find(r => r.chatId === "chat2")!.active).toBe(false);
133
- });
134
-
135
- it("marks stopped sessions as not active", () => {
136
- mockSessionInfo("chat1");
137
- mockActiveSession("chat1", { stopped: true });
138
- const result = getAllSessionsStatus();
139
- expect(result[0].active).toBe(false);
140
- });
141
- });
142
-
143
- describe("processedMessages dedup", () => {
144
- it("supports add/has semantics", () => {
145
- processedMessages.clear();
146
- processedMessages.add("msg_001");
147
- expect(processedMessages.has("msg_001")).toBe(true);
148
- expect(processedMessages.has("msg_002")).toBe(false);
149
- });
150
-
151
- it("MAX_PROCESSED is defined", () => {
152
- expect(MAX_PROCESSED).toBe(5000);
153
- });
154
- });
155
-
156
- // ---------------------------------------------------------------------------
157
- // accumulateBlockContent — 统一消息块累积测试
158
- // ---------------------------------------------------------------------------
159
-
160
- function freshState(): AccumulatorState {
161
- return { accumulatedContent: "", finalText: "", chunkCount: 0 };
162
- }
163
-
164
- describe("accumulateBlockContent", () => {
165
- it("accumulates thinking block into accumulatedContent", () => {
166
- const s = freshState();
167
- accumulateBlockContent({ type: "thinking", thinking: "Let me think..." }, s);
168
- expect(s.accumulatedContent).toBe("Let me think...");
169
- expect(s.chunkCount).toBe(1);
170
- expect(s.finalText).toBe("");
171
- });
172
-
173
- it("accumulates text block into finalText", () => {
174
- const s = freshState();
175
- accumulateBlockContent({ type: "text", text: "Hello world" }, s);
176
- expect(s.finalText).toBe("Hello world");
177
- expect(s.accumulatedContent).toBe("");
178
- });
179
-
180
- it("accumulates tool_use block with formatted name and input", () => {
181
- const s = freshState();
182
- accumulateBlockContent(
183
- { type: "tool_use", name: "Read", input: { file_path: "/tmp/test.txt" } },
184
- s,
185
- );
186
- expect(s.accumulatedContent).toContain("📖"); // 📖
187
- expect(s.accumulatedContent).toContain("**Read**");
188
- expect(s.accumulatedContent).toContain("file_path");
189
- });
190
-
191
- it("accumulates tool_use block with long input truncated", () => {
192
- const s = freshState();
193
- const longInput = "x".repeat(500);
194
- accumulateBlockContent(
195
- { type: "tool_use", name: "Bash", input: longInput },
196
- s,
197
- );
198
- expect(s.accumulatedContent).toContain("...");
199
- // Should be truncated to 300 + "..."
200
- const inputPart = s.accumulatedContent.split("`")[1];
201
- expect(inputPart.length).toBeLessThanOrEqual(304); // 300 + "..."
202
- });
203
-
204
- it("accumulates tool_result block with success icon ()", () => {
205
- const s = freshState();
206
- accumulateBlockContent(
207
- { type: "tool_result", tool_use_id: "tool_abc123", content: "done", is_error: false },
208
- s,
209
- );
210
- expect(s.accumulatedContent).toContain("✅"); // ✅
211
- expect(s.accumulatedContent).toContain("abc123");
212
- expect(s.accumulatedContent).toContain("done");
213
- });
214
-
215
- it("accumulates tool_result block with error icon (❌)", () => {
216
- const s = freshState();
217
- accumulateBlockContent(
218
- { type: "tool_result", tool_use_id: "tool_err456", content: "failed", is_error: true },
219
- s,
220
- );
221
- expect(s.accumulatedContent).toContain("❌"); // ❌
222
- });
223
-
224
- it("accumulates tool_result with array content (text blocks)", () => {
225
- const s = freshState();
226
- const content = [{ type: "text", text: "line1" }, { type: "text", text: "line2" }];
227
- accumulateBlockContent(
228
- { type: "tool_result", tool_use_id: "tool_arr", content },
229
- s,
230
- );
231
- expect(s.accumulatedContent).toContain("line1line2");
232
- });
233
-
234
- it("accumulates tool_result with object content (JSON stringified)", () => {
235
- const s = freshState();
236
- accumulateBlockContent(
237
- { type: "tool_result", tool_use_id: "tool_obj", content: { key: "val" } },
238
- s,
239
- );
240
- expect(s.accumulatedContent).toContain('{"key":"val"}');
241
- });
242
-
243
- it("accumulates redacted_thinking block with safety notice", () => {
244
- const s = freshState();
245
- accumulateBlockContent({ type: "redacted_thinking" }, s);
246
- expect(s.accumulatedContent).toContain("内容被安全过滤"); // 内容被安全过滤
247
- });
248
-
249
- it("accumulates search_result block with query", () => {
250
- const s = freshState();
251
- accumulateBlockContent(
252
- { type: "search_result", query: "TypeScript docs" },
253
- s,
254
- );
255
- expect(s.accumulatedContent).toContain("🔍"); // 🔍
256
- expect(s.accumulatedContent).toContain("TypeScript docs");
257
- });
258
-
259
- it("accumulates compact_boundary block with trigger label", () => {
260
- const s = freshState();
261
- accumulateBlockContent(
262
- { type: "compact_boundary", trigger: "auto", pre_tokens: 15000, post_tokens: 8000 },
263
- s,
264
- );
265
- expect(s.accumulatedContent).toContain("🔄"); // 🔄
266
- expect(s.accumulatedContent).toContain("自动");
267
- expect(s.accumulatedContent).toContain("15000");
268
- expect(s.accumulatedContent).toContain("8000");
269
- });
270
-
271
- it("accumulates compact_boundary with manual trigger label", () => {
272
- const s = freshState();
273
- accumulateBlockContent(
274
- { type: "compact_boundary", trigger: "manual", pre_tokens: 20000 },
275
- s,
276
- );
277
- expect(s.accumulatedContent).toContain("手动");
278
- });
279
-
280
- it("accumulates multiple blocks in sequence correctly", () => {
281
- const s = freshState();
282
- accumulateBlockContent({ type: "thinking", thinking: "Hmm..." }, s);
283
- accumulateBlockContent({ type: "tool_use", name: "Grep", input: { pattern: "foo" } }, s);
284
- accumulateBlockContent(
285
- { type: "tool_result", tool_use_id: "abc123", content: "found 3 matches", is_error: false },
286
- s,
287
- );
288
- accumulateBlockContent({ type: "text", text: "I found the results." }, s);
289
-
290
- expect(s.accumulatedContent).toContain("Hmm...");
291
- expect(s.accumulatedContent).toContain("Grep");
292
- expect(s.accumulatedContent).toContain("found 3 matches");
293
- expect(s.finalText).toBe("I found the results.");
294
- expect(s.chunkCount).toBe(1); // Only thinking increments chunkCount
295
- });
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ chatSessionMap,
4
+ sessionInfoMap,
5
+ processedMessages,
6
+ MAX_PROCESSED,
7
+ resetState,
8
+ getSessionStatus,
9
+ getAllSessionsStatus,
10
+ accumulateBlockContent,
11
+ pickFinalReply,
12
+ UNKNOWN_MODEL_PLACEHOLDER,
13
+ _setAdapterForToolForTest,
14
+ _clearAdapterCacheForTest,
15
+ } from "../session.ts";
16
+ import type { AccumulatorState } from "../session.ts";
17
+ import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
18
+
19
+ // Helper to create a mock active session entry
20
+ function mockActiveSession(chatId: string, overrides: Partial<{
21
+ accumulatedContent: string;
22
+ finalText: string;
23
+ stopped: boolean;
24
+ }> = {}) {
25
+ chatSessionMap.set(chatId, {
26
+ gen: 1,
27
+ close: () => {},
28
+ cardId: null,
29
+ stopped: overrides.stopped ?? false,
30
+ accumulatedContent: overrides.accumulatedContent ?? "thinking...",
31
+ finalText: overrides.finalText ?? "",
32
+ spinnerTimer: null,
33
+ msgTimestamp: Date.now(),
34
+ sequence: 0,
35
+ cardBusy: false,
36
+ });
37
+ }
38
+
39
+ function mockSessionInfo(chatId: string, overrides: Partial<{
40
+ sessionId: string;
41
+ turnCount: number;
42
+ lastContextTokens: number;
43
+ startTime: number;
44
+ tool: string;
45
+ }> = {}) {
46
+ sessionInfoMap.set(chatId, {
47
+ sessionId: overrides.sessionId ?? "test-session-id",
48
+ turnCount: overrides.turnCount ?? 3,
49
+ lastContextTokens: overrides.lastContextTokens ?? 50000,
50
+ startTime: overrides.startTime ?? Date.now(),
51
+ tool: overrides.tool ?? "claude",
52
+ });
53
+ }
54
+
55
+ /**
56
+ * 简易 mock adapter:getSessionInfo 返回固定 SessionInfo,其他方法不实现
57
+ * (仅 /status、/sessions 路径会触发 getSessionInfo,无需完整接口)。
58
+ */
59
+ function mockAdapter(getInfo: (sid: string) => SessionInfo | undefined): ToolAdapter {
60
+ return {
61
+ displayName: "MockTool",
62
+ sessionDescPrefix: "Mock Session:",
63
+ createSession: async () => ({ sessionId: "" }),
64
+ prompt: async function* () {},
65
+ getSessionInfo: async (sid) => getInfo(sid),
66
+ closeSession: async () => {},
67
+ };
68
+ }
69
+
70
+ describe("resetState", () => {
71
+ it("clears all maps and sets", () => {
72
+ chatSessionMap.set("chat1", {
73
+ gen: 1, close: () => {}, cardId: null, stopped: false,
74
+ accumulatedContent: "", finalText: "", spinnerTimer: null,
75
+ msgTimestamp: 0, sequence: 0, cardBusy: false,
76
+ });
77
+ sessionInfoMap.set("chat1", {
78
+ sessionId: "s1", turnCount: 1, lastContextTokens: 0,
79
+ startTime: 0, tool: "claude",
80
+ });
81
+ processedMessages.add("msg1");
82
+
83
+ resetState();
84
+
85
+ expect(chatSessionMap.size).toBe(0);
86
+ expect(sessionInfoMap.size).toBe(0);
87
+ expect(processedMessages.size).toBe(0);
88
+ });
89
+ });
90
+
91
+ describe("getSessionStatus", () => {
92
+ beforeEach(() => {
93
+ chatSessionMap.clear();
94
+ sessionInfoMap.clear();
95
+ });
96
+
97
+ afterEach(() => {
98
+ _clearAdapterCacheForTest();
99
+ });
100
+
101
+ it("returns null for unknown chatId", async () => {
102
+ await expect(getSessionStatus("nonexistent")).resolves.toBeNull();
103
+ });
104
+
105
+ it("returns status for idle session (info exists, no active session)", async () => {
106
+ mockSessionInfo("chat1");
107
+ const status = await getSessionStatus("chat1");
108
+ expect(status).not.toBeNull();
109
+ expect(status!.sessionId).toBe("test-session-id");
110
+ expect(status!.running).toBe(false);
111
+ expect(status!.turnCount).toBe(3);
112
+ expect(status!.accumulatedLength).toBe(0);
113
+ });
114
+
115
+ it("returns running=true for active session", async () => {
116
+ mockSessionInfo("chat1");
117
+ mockActiveSession("chat1", { accumulatedContent: "thinking...", finalText: "reply" });
118
+ const status = await getSessionStatus("chat1");
119
+ expect(status!.running).toBe(true);
120
+ expect(status!.accumulatedLength).toBe(16); // "thinking..."(11) + "reply"(5)
121
+ });
122
+
123
+ it("returns running=false for stopped session", async () => {
124
+ mockSessionInfo("chat1");
125
+ mockActiveSession("chat1", { stopped: true });
126
+ const status = await getSessionStatus("chat1");
127
+ expect(status!.running).toBe(false);
128
+ });
129
+
130
+ it("returns correct turnCount and other info fields", async () => {
131
+ mockSessionInfo("chat1", { turnCount: 7, lastContextTokens: 100000 });
132
+ const status = await getSessionStatus("chat1");
133
+ expect(status!.turnCount).toBe(7);
134
+ expect(status!.lastContextTokens).toBe(100000);
135
+ });
136
+
137
+ // -------------------------------------------------------------------------
138
+ // model/effort 来源:按 tool 分支(核心契约——决定 /status 显示是否真实)
139
+ // -------------------------------------------------------------------------
140
+
141
+ it("Claude 会话:effort 非 null(始终显示该行);model 来自全局配置", async () => {
142
+ mockSessionInfo("chat-claude", { tool: "claude" });
143
+ const status = await getSessionStatus("chat-claude");
144
+ expect(status!.effort).not.toBeNull();
145
+ // model 必为非空字符串(默认 'default' 或环境变量值);不应是占位符
146
+ expect(typeof status!.model).toBe("string");
147
+ expect(status!.model.length).toBeGreaterThan(0);
148
+ });
149
+
150
+ it("Cursor 会话:effort 恒为 null(卡片渲染时隐藏该行,避免显示无意义的 effort)", async () => {
151
+ mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
152
+ _setAdapterForToolForTest(
153
+ "cursor",
154
+ mockAdapter(() => ({ sessionId: "sid-cur", model: "Composer 2 Fast" })),
155
+ );
156
+ const status = await getSessionStatus("chat-cursor");
157
+ expect(status!.effort).toBeNull();
158
+ });
159
+
160
+ it("Cursor 会话:model 来自 adapter.getSessionInfo(真实模型,不是 ChatCCC 配置)", async () => {
161
+ mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
162
+ _setAdapterForToolForTest(
163
+ "cursor",
164
+ mockAdapter((sid) =>
165
+ sid === "sid-cur"
166
+ ? { sessionId: sid, cwd: "/tmp", model: "Composer 2 Fast" }
167
+ : undefined,
168
+ ),
169
+ );
170
+ const status = await getSessionStatus("chat-cursor");
171
+ expect(status!.model).toBe("Composer 2 Fast");
172
+ });
173
+
174
+ it("Cursor 会话:adapter 没返回 model 时使用占位符(区别于硬塞 'default')", async () => {
175
+ mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
176
+ _setAdapterForToolForTest(
177
+ "cursor",
178
+ mockAdapter(() => ({ sessionId: "sid-cur" /* 无 model */ })),
179
+ );
180
+ const status = await getSessionStatus("chat-cursor");
181
+ expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
182
+ });
183
+
184
+ it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /status)", async () => {
185
+ mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
186
+ _setAdapterForToolForTest(
187
+ "cursor",
188
+ mockAdapter(() => {
189
+ throw new Error("simulated adapter failure");
190
+ }),
191
+ );
192
+ const status = await getSessionStatus("chat-cursor");
193
+ expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
194
+ expect(status!.effort).toBeNull();
195
+ });
196
+ });
197
+
198
+ describe("getAllSessionsStatus", () => {
199
+ beforeEach(() => {
200
+ chatSessionMap.clear();
201
+ sessionInfoMap.clear();
202
+ });
203
+
204
+ afterEach(() => {
205
+ _clearAdapterCacheForTest();
206
+ });
207
+
208
+ it("returns empty array when no sessions", async () => {
209
+ await expect(getAllSessionsStatus()).resolves.toEqual([]);
210
+ });
211
+
212
+ it("returns statuses for all recorded sessions", async () => {
213
+ mockSessionInfo("chat1", { sessionId: "s1" });
214
+ mockSessionInfo("chat2", { sessionId: "s2" });
215
+ mockActiveSession("chat1");
216
+ const result = await getAllSessionsStatus();
217
+ expect(result).toHaveLength(2);
218
+ expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
219
+ expect(result.find(r => r.chatId === "chat2")!.active).toBe(false);
220
+ });
221
+
222
+ it("marks stopped sessions as not active", async () => {
223
+ mockSessionInfo("chat1");
224
+ mockActiveSession("chat1", { stopped: true });
225
+ const result = await getAllSessionsStatus();
226
+ expect(result[0].active).toBe(false);
227
+ });
228
+
229
+ it("混合 claude + cursor 会话:各自取自己来源的 model/effort", async () => {
230
+ mockSessionInfo("chat-c", { sessionId: "sid-c", tool: "claude" });
231
+ mockSessionInfo("chat-x", { sessionId: "sid-x", tool: "cursor" });
232
+ _setAdapterForToolForTest(
233
+ "cursor",
234
+ mockAdapter((sid) =>
235
+ sid === "sid-x"
236
+ ? { sessionId: sid, cwd: "/tmp", model: "Composer 2 Fast" }
237
+ : undefined,
238
+ ),
239
+ );
240
+
241
+ const result = await getAllSessionsStatus();
242
+ const claude = result.find((r) => r.tool === "claude")!;
243
+ const cursor = result.find((r) => r.tool === "cursor")!;
244
+
245
+ expect(claude.effort).not.toBeNull();
246
+ expect(claude.model.length).toBeGreaterThan(0);
247
+
248
+ expect(cursor.effort).toBeNull();
249
+ expect(cursor.model).toBe("Composer 2 Fast");
250
+ });
251
+ });
252
+
253
+ describe("processedMessages dedup", () => {
254
+ it("supports add/has semantics", () => {
255
+ processedMessages.clear();
256
+ processedMessages.add("msg_001");
257
+ expect(processedMessages.has("msg_001")).toBe(true);
258
+ expect(processedMessages.has("msg_002")).toBe(false);
259
+ });
260
+
261
+ it("MAX_PROCESSED is defined", () => {
262
+ expect(MAX_PROCESSED).toBe(5000);
263
+ });
264
+ });
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // accumulateBlockContent — 统一消息块累积测试
268
+ // ---------------------------------------------------------------------------
269
+
270
+ function freshState(): AccumulatorState {
271
+ return { accumulatedContent: "", finalText: "", finalCompleteText: "", chunkCount: 0 };
272
+ }
273
+
274
+ describe("accumulateBlockContent", () => {
275
+ it("accumulates thinking block into accumulatedContent", () => {
276
+ const s = freshState();
277
+ accumulateBlockContent({ type: "thinking", thinking: "Let me think..." }, s);
278
+ expect(s.accumulatedContent).toBe("Let me think...");
279
+ expect(s.chunkCount).toBe(1);
280
+ expect(s.finalText).toBe("");
281
+ });
282
+
283
+ it("accumulates text block into finalText", () => {
284
+ const s = freshState();
285
+ accumulateBlockContent({ type: "text", text: "Hello world" }, s);
286
+ expect(s.finalText).toBe("Hello world");
287
+ expect(s.accumulatedContent).toBe("");
288
+ });
289
+
290
+ it("accumulates tool_use block with formatted name and input", () => {
291
+ const s = freshState();
292
+ accumulateBlockContent(
293
+ { type: "tool_use", name: "Read", input: { file_path: "/tmp/test.txt" } },
294
+ s,
295
+ );
296
+ expect(s.accumulatedContent).toContain("📖"); // 📖
297
+ expect(s.accumulatedContent).toContain("**Read**");
298
+ expect(s.accumulatedContent).toContain("file_path");
299
+ });
300
+
301
+ it("accumulates tool_use block with long input truncated", () => {
302
+ const s = freshState();
303
+ const longInput = "x".repeat(500);
304
+ accumulateBlockContent(
305
+ { type: "tool_use", name: "Bash", input: longInput },
306
+ s,
307
+ );
308
+ expect(s.accumulatedContent).toContain("...");
309
+ // Should be truncated to 300 + "..."
310
+ const inputPart = s.accumulatedContent.split("`")[1];
311
+ expect(inputPart.length).toBeLessThanOrEqual(304); // 300 + "..."
312
+ });
313
+
314
+ it("accumulates tool_result block with success icon (✅)", () => {
315
+ const s = freshState();
316
+ accumulateBlockContent(
317
+ { type: "tool_result", tool_use_id: "tool_abc123", content: "done", is_error: false },
318
+ s,
319
+ );
320
+ expect(s.accumulatedContent).toContain("✅"); // ✅
321
+ expect(s.accumulatedContent).toContain("abc123");
322
+ expect(s.accumulatedContent).toContain("done");
323
+ });
324
+
325
+ it("accumulates tool_result block with error icon (❌)", () => {
326
+ const s = freshState();
327
+ accumulateBlockContent(
328
+ { type: "tool_result", tool_use_id: "tool_err456", content: "failed", is_error: true },
329
+ s,
330
+ );
331
+ expect(s.accumulatedContent).toContain("❌"); // ❌
332
+ });
333
+
334
+ it("accumulates tool_result with array content (text blocks)", () => {
335
+ const s = freshState();
336
+ const content = [{ type: "text", text: "line1" }, { type: "text", text: "line2" }];
337
+ accumulateBlockContent(
338
+ { type: "tool_result", tool_use_id: "tool_arr", content },
339
+ s,
340
+ );
341
+ expect(s.accumulatedContent).toContain("line1line2");
342
+ });
343
+
344
+ it("accumulates tool_result with object content (JSON stringified)", () => {
345
+ const s = freshState();
346
+ accumulateBlockContent(
347
+ { type: "tool_result", tool_use_id: "tool_obj", content: { key: "val" } },
348
+ s,
349
+ );
350
+ expect(s.accumulatedContent).toContain('{"key":"val"}');
351
+ });
352
+
353
+ it("accumulates redacted_thinking block with safety notice", () => {
354
+ const s = freshState();
355
+ accumulateBlockContent({ type: "redacted_thinking" }, s);
356
+ expect(s.accumulatedContent).toContain("内容被安全过滤"); // 内容被安全过滤
357
+ });
358
+
359
+ it("accumulates search_result block with query", () => {
360
+ const s = freshState();
361
+ accumulateBlockContent(
362
+ { type: "search_result", query: "TypeScript docs" },
363
+ s,
364
+ );
365
+ expect(s.accumulatedContent).toContain("🔍"); // 🔍
366
+ expect(s.accumulatedContent).toContain("TypeScript docs");
367
+ });
368
+
369
+ it("accumulates compact_boundary block with trigger label", () => {
370
+ const s = freshState();
371
+ accumulateBlockContent(
372
+ { type: "compact_boundary", trigger: "auto", pre_tokens: 15000, post_tokens: 8000 },
373
+ s,
374
+ );
375
+ expect(s.accumulatedContent).toContain("🔄"); // 🔄
376
+ expect(s.accumulatedContent).toContain("自动");
377
+ expect(s.accumulatedContent).toContain("15000");
378
+ expect(s.accumulatedContent).toContain("8000");
379
+ });
380
+
381
+ it("accumulates compact_boundary with manual trigger label", () => {
382
+ const s = freshState();
383
+ accumulateBlockContent(
384
+ { type: "compact_boundary", trigger: "manual", pre_tokens: 20000 },
385
+ s,
386
+ );
387
+ expect(s.accumulatedContent).toContain("手动");
388
+ });
389
+
390
+ it("accumulates multiple blocks in sequence correctly", () => {
391
+ const s = freshState();
392
+ accumulateBlockContent({ type: "thinking", thinking: "Hmm..." }, s);
393
+ accumulateBlockContent({ type: "tool_use", name: "Grep", input: { pattern: "foo" } }, s);
394
+ accumulateBlockContent(
395
+ { type: "tool_result", tool_use_id: "abc123", content: "found 3 matches", is_error: false },
396
+ s,
397
+ );
398
+ accumulateBlockContent({ type: "text", text: "I found the results." }, s);
399
+
400
+ expect(s.accumulatedContent).toContain("Hmm...");
401
+ expect(s.accumulatedContent).toContain("Grep");
402
+ expect(s.accumulatedContent).toContain("found 3 matches");
403
+ expect(s.finalText).toBe("I found the results.");
404
+ expect(s.chunkCount).toBe(1); // Only thinking increments chunkCount
405
+ });
406
+
407
+ // -------------------------------------------------------------------------
408
+ // text_final:来自 Cursor CLI 的"完整最终文本"消息
409
+ // 行为:覆盖(不是追加)finalCompleteText,避免与 partial 累加重复
410
+ // -------------------------------------------------------------------------
411
+
412
+ it("accumulates text_final into finalCompleteText (覆盖语义)", () => {
413
+ const s = freshState();
414
+ accumulateBlockContent({ type: "text_final", text: "完整最终文本" } as UnifiedBlock, s);
415
+ expect(s.finalCompleteText).toBe("完整最终文本");
416
+ expect(s.finalText).toBe("");
417
+ });
418
+
419
+ it("text_final 多次到达时以最新一次为准(覆盖而非追加)", () => {
420
+ const s = freshState();
421
+ accumulateBlockContent({ type: "text_final", text: "first" } as UnifiedBlock, s);
422
+ accumulateBlockContent({ type: "text_final", text: "second" } as UnifiedBlock, s);
423
+ expect(s.finalCompleteText).toBe("second");
424
+ });
425
+ });
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // pickFinalReply — 在 partial 累加 vs final 完整文本之间挑选最终回复
429
+ // ---------------------------------------------------------------------------
430
+
431
+ describe("pickFinalReply", () => {
432
+ // 新规则:有 finalCompleteText 永远优先(来自 cursor result.result,官方权威);
433
+ // 无则回退到 partial 累加 finalText。
434
+ // 不再做长度比较——因为带 buffered flush 重复时 partial 可能"虚高",长度比较会选错。
435
+
436
+ it("finalCompleteText 非空时永远优先(即便等长)", () => {
437
+ const reply = pickFinalReply({
438
+ accumulatedContent: "",
439
+ finalText: "你好世界",
440
+ finalCompleteText: "你好世界",
441
+ chunkCount: 0,
442
+ });
443
+ expect(reply).toBe("你好世界");
444
+ });
445
+
446
+ it("finalCompleteText 非空时优先(即便 partial 累加更长——可能被 buffered flush 污染)", () => {
447
+ const reply = pickFinalReply({
448
+ accumulatedContent: "",
449
+ finalText: "你好世界你好世界", // 工具调用前 buffered flush 让 partial 累加翻倍
450
+ finalCompleteText: "你好世界",
451
+ chunkCount: 0,
452
+ });
453
+ expect(reply).toBe("你好世界");
454
+ });
455
+
456
+ it("无 finalCompleteText 时回退到 partial 累加 (finalText)", () => {
457
+ const reply = pickFinalReply({
458
+ accumulatedContent: "",
459
+ finalText: "仅有 partial 累加",
460
+ finalCompleteText: "",
461
+ chunkCount: 0,
462
+ });
463
+ expect(reply).toBe("仅有 partial 累加");
464
+ });
465
+
466
+ it("两者都为空时返回空串", () => {
467
+ expect(
468
+ pickFinalReply({
469
+ accumulatedContent: "",
470
+ finalText: "",
471
+ finalCompleteText: "",
472
+ chunkCount: 0,
473
+ }),
474
+ ).toBe("");
475
+ });
296
476
  });