chatccc 0.2.222 → 0.2.223

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.222",
3
+ "version": "0.2.223",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { cardJsonToPlainText } from "../card-plain-text.ts";
4
+ import { progressView } from "../progress/view.ts";
4
5
  import {
5
6
  buildHelpCard,
6
7
  buildProgressCard,
@@ -16,11 +17,11 @@ describe("cardJsonToPlainText", () => {
16
17
  expect(text).toContain("/new");
17
18
  expect(text).toContain("/new cursor");
18
19
  expect(text).toContain("/new codex");
19
- expect(text).toContain("/restart");
20
- expect(text).toContain("/update");
21
- expect(text).toContain("/cd");
22
- expect(text).toContain("/abd");
23
- });
20
+ expect(text).toContain("/restart");
21
+ expect(text).toContain("/update");
22
+ expect(text).toContain("/cd");
23
+ expect(text).toContain("/abd");
24
+ });
24
25
 
25
26
  it("converts status cards from v1 card format", () => {
26
27
  const text = cardJsonToPlainText(buildStatusCard("status body", "green"));
@@ -30,7 +31,7 @@ describe("cardJsonToPlainText", () => {
30
31
  });
31
32
 
32
33
  it("converts schema 2.0 progress cards", () => {
33
- const text = cardJsonToPlainText(buildProgressCard("stream body"));
34
+ const text = cardJsonToPlainText(buildProgressCard(progressView({ text: "stream body" })));
34
35
 
35
36
  expect(text).toContain("# 生成中...");
36
37
  expect(text).toContain("stream body");
@@ -1,27 +1,28 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import {
3
- buildProgressCard,
4
- buildHelpCard,
2
+ import {
3
+ buildProgressCard,
4
+ buildHelpCard,
5
5
  buildCdContent,
6
6
  buildCdCard,
7
7
  buildSessionsCard,
8
- buildStatusCard,
9
- buildCodexUsageCard,
10
- buildCodexResetConfirmCard,
11
- buildFastModeCard,
12
- buildModelCard,
13
- buildButtons,
14
- truncateContent,
15
- getToolEmoji,
16
- normalizeToolName,
17
- } from "../cards.ts";
18
- import { ABD_HELP_LINE } from "../shared-prefix.ts";
8
+ buildStatusCard,
9
+ buildCodexUsageCard,
10
+ buildCodexResetConfirmCard,
11
+ buildFastModeCard,
12
+ buildModelCard,
13
+ buildButtons,
14
+ truncateContent,
15
+ getToolEmoji,
16
+ normalizeToolName,
17
+ } from "../cards.ts";
18
+ import { ABD_HELP_LINE } from "../shared-prefix.ts";
19
+ import { progressView } from "../progress/view.ts";
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // truncateContent
22
23
  // ---------------------------------------------------------------------------
23
24
 
24
- describe("truncateContent", () => {
25
+ describe("truncateContent", () => {
25
26
  it("returns original text when under limits", () => {
26
27
  expect(truncateContent("hello")).toBe("hello");
27
28
  });
@@ -116,7 +117,7 @@ describe("getToolEmoji", () => {
116
117
 
117
118
  describe("buildProgressCard", () => {
118
119
  it("returns valid JSON with correct schema", () => {
119
- const card = buildProgressCard("test content");
120
+ const card = buildProgressCard(progressView({ text: "test content" }));
120
121
  const parsed = JSON.parse(card);
121
122
  expect(parsed.schema).toBe("2.0");
122
123
  expect(parsed.config.update_multi).toBe(true);
@@ -124,14 +125,14 @@ describe("buildProgressCard", () => {
124
125
  });
125
126
 
126
127
  it("uses default header title '生成中...'", () => {
127
- const card = buildProgressCard("hello");
128
+ const card = buildProgressCard(progressView({ text: "hello" }));
128
129
  const parsed = JSON.parse(card);
129
130
  expect(parsed.header.title.content).toBe("生成中...");
130
131
  expect(parsed.header.template).toBe("blue");
131
132
  });
132
133
 
133
134
  it("includes stop button by default", () => {
134
- const card = buildProgressCard("hello");
135
+ const card = buildProgressCard(progressView({ text: "hello" }));
135
136
  const parsed = JSON.parse(card);
136
137
  const buttons = parsed.body.elements.filter((e: any) => e.tag === "button");
137
138
  expect(buttons).toHaveLength(2);
@@ -142,21 +143,21 @@ describe("buildProgressCard", () => {
142
143
  });
143
144
 
144
145
  it("hides stop button when showStop is false", () => {
145
- const card = buildProgressCard("hello", { showStop: false });
146
+ const card = buildProgressCard(progressView({ text: "hello", showStop: false }));
146
147
  const parsed = JSON.parse(card);
147
148
  const buttons = parsed.body.elements.filter((e: any) => e.tag === "button");
148
149
  expect(buttons).toHaveLength(0);
149
150
  });
150
151
 
151
152
  it("uses custom header title and template", () => {
152
- const card = buildProgressCard("hello", { headerTitle: "已完成", headerTemplate: "green" });
153
+ const card = buildProgressCard(progressView({ text: "hello", headerTitle: "已完成", headerTemplate: "green" }));
153
154
  const parsed = JSON.parse(card);
154
155
  expect(parsed.header.title.content).toBe("已完成");
155
156
  expect(parsed.header.template).toBe("green");
156
157
  });
157
158
 
158
159
  it("includes markdown element with truncated content", () => {
159
- const card = buildProgressCard("markdown text");
160
+ const card = buildProgressCard(progressView({ text: "markdown text" }));
160
161
  const parsed = JSON.parse(card);
161
162
  const md = parsed.body.elements.find((e: any) => e.tag === "markdown");
162
163
  expect(md).toBeDefined();
@@ -176,66 +177,66 @@ describe("buildHelpCard", () => {
176
177
  expect(parsed.elements[0].text.content).toContain("你好");
177
178
  });
178
179
 
179
- it("includes action buttons", () => {
180
- const card = buildHelpCard("test");
181
- const parsed = JSON.parse(card);
182
- const action = parsed.elements[2];
183
- expect(action.tag).toBe("action");
184
- expect(action.actions).toHaveLength(8);
185
- });
186
-
187
- it("adds ABD prefix help as the final help line", () => {
188
- const card = buildHelpCard("test");
189
- const parsed = JSON.parse(card);
190
- const lines = parsed.elements[1].text.content.split("\n");
191
-
192
- expect(lines).toContain("发送 **/usage** 查看当前 Agent 的用量或余额");
193
- expect(lines.at(-1)).toBe(ABD_HELP_LINE);
194
- });
195
-
196
- it("advertises CCC Agent as a normal session option", () => {
197
- const card = buildHelpCard("test");
198
- const parsed = JSON.parse(card);
199
- const text = JSON.stringify(parsed);
200
-
201
- expect(text).toContain("/new ccc");
202
- expect(text).toContain("CCC Agent");
203
- });
204
- });
205
-
206
- describe("Codex Fast mode cards", () => {
207
- it("shows the current mode and exposes ON/OFF commands", () => {
208
- const card = JSON.parse(buildFastModeCard(false)) as {
209
- header: { title: { content: string } };
210
- elements: Array<{
211
- tag: string;
212
- text?: { content: string };
213
- actions?: Array<{ text: { content: string }; value: string }>;
214
- }>;
215
- };
216
-
217
- expect(card.header.title.content).toBe("Codex Fast 模式");
218
- expect(card.elements.find((element) => element.tag === "div")?.text?.content).toContain("OFF");
219
- const actions = card.elements.find((element) => element.tag === "action")?.actions ?? [];
220
- expect(actions.map((action) => JSON.parse(action.value).cmd)).toEqual([
221
- "/fast on",
222
- "/fast off",
223
- ]);
224
- });
225
-
226
- it("places /fast help immediately after /model help for Codex", () => {
227
- const card = JSON.parse(buildModelCard("gpt-5", ["gpt-5"], "codex")) as {
228
- elements: Array<{ tag: string; text?: { content: string } }>;
229
- };
230
- const content = card.elements.find((element) => element.tag === "div")?.text?.content ?? "";
231
- const modelHelpIndex = content.indexOf("/model");
232
- const fastHelpIndex = content.indexOf("/fast");
233
-
234
- expect(modelHelpIndex).toBeGreaterThanOrEqual(0);
235
- expect(fastHelpIndex).toBeGreaterThan(modelHelpIndex);
236
- expect(content.slice(modelHelpIndex, fastHelpIndex).split("\n")).toHaveLength(2);
237
- });
238
- });
180
+ it("includes action buttons", () => {
181
+ const card = buildHelpCard("test");
182
+ const parsed = JSON.parse(card);
183
+ const action = parsed.elements[2];
184
+ expect(action.tag).toBe("action");
185
+ expect(action.actions).toHaveLength(8);
186
+ });
187
+
188
+ it("adds ABD prefix help as the final help line", () => {
189
+ const card = buildHelpCard("test");
190
+ const parsed = JSON.parse(card);
191
+ const lines = parsed.elements[1].text.content.split("\n");
192
+
193
+ expect(lines).toContain("发送 **/usage** 查看当前 Agent 的用量或余额");
194
+ expect(lines.at(-1)).toBe(ABD_HELP_LINE);
195
+ });
196
+
197
+ it("advertises CCC Agent as a normal session option", () => {
198
+ const card = buildHelpCard("test");
199
+ const parsed = JSON.parse(card);
200
+ const text = JSON.stringify(parsed);
201
+
202
+ expect(text).toContain("/new ccc");
203
+ expect(text).toContain("CCC Agent");
204
+ });
205
+ });
206
+
207
+ describe("Codex Fast mode cards", () => {
208
+ it("shows the current mode and exposes ON/OFF commands", () => {
209
+ const card = JSON.parse(buildFastModeCard(false)) as {
210
+ header: { title: { content: string } };
211
+ elements: Array<{
212
+ tag: string;
213
+ text?: { content: string };
214
+ actions?: Array<{ text: { content: string }; value: string }>;
215
+ }>;
216
+ };
217
+
218
+ expect(card.header.title.content).toBe("Codex Fast 模式");
219
+ expect(card.elements.find((element) => element.tag === "div")?.text?.content).toContain("OFF");
220
+ const actions = card.elements.find((element) => element.tag === "action")?.actions ?? [];
221
+ expect(actions.map((action) => JSON.parse(action.value).cmd)).toEqual([
222
+ "/fast on",
223
+ "/fast off",
224
+ ]);
225
+ });
226
+
227
+ it("places /fast help immediately after /model help for Codex", () => {
228
+ const card = JSON.parse(buildModelCard("gpt-5", ["gpt-5"], "codex")) as {
229
+ elements: Array<{ tag: string; text?: { content: string } }>;
230
+ };
231
+ const content = card.elements.find((element) => element.tag === "div")?.text?.content ?? "";
232
+ const modelHelpIndex = content.indexOf("/model");
233
+ const fastHelpIndex = content.indexOf("/fast");
234
+
235
+ expect(modelHelpIndex).toBeGreaterThanOrEqual(0);
236
+ expect(fastHelpIndex).toBeGreaterThan(modelHelpIndex);
237
+ expect(content.slice(modelHelpIndex, fastHelpIndex).split("\n")).toHaveLength(2);
238
+ });
239
+ });
239
240
 
240
241
  // ---------------------------------------------------------------------------
241
242
  // buildCdContent
@@ -314,7 +315,7 @@ describe("buildCdCard", () => {
314
315
  it("shows current working directory in markdown", () => {
315
316
  const card = buildCdCard("/home/project", entries, []);
316
317
  const parsed = JSON.parse(card);
317
- const cwdContent = mdContents(parsed).find((c) => c.includes("后续新建会话默认工作路径"));
318
+ const cwdContent = mdContents(parsed).find((c) => c.includes("后续新建会话默认工作路径"));
318
319
  expect(cwdContent).toBeDefined();
319
320
  expect(cwdContent).toContain("/home/project");
320
321
  });
@@ -429,29 +430,29 @@ describe("buildSessionsCard", () => {
429
430
  expect(content).toContain("Cursor 会话");
430
431
  });
431
432
 
432
- it("omits Cursor section when no Cursor sessions", () => {
433
- const card = buildSessionsCard([
434
- { sessionId: "c1", chatName: "", chatId: "oc_c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
435
- ]);
436
- const parsed = JSON.parse(card);
437
- const content: string = parsed.elements[0].text.content;
438
- expect(content).toContain("Claude Code 会话");
439
- expect(content).not.toContain("Cursor 会话");
440
- });
441
-
442
- it("labels CCC sessions without grouping them as Claude Code", () => {
443
- const card = buildSessionsCard([
444
- { sessionId: "session-20260702-121530-a1b2c3", chatName: "", chatId: "oc_ccc", active: false, turnCount: 1, elapsedSeconds: null, model: "deepseek-v4-pro", tool: "ccc" },
445
- ]);
446
- const parsed = JSON.parse(card);
447
- const content: string = parsed.elements[0].text.content;
448
-
449
- expect(content).toContain("CCC Agent 会话");
450
- expect(content).toContain("工具: CCC Agent");
451
- expect(content).not.toContain("Claude Code 会话");
452
- });
453
-
454
- it("displays chatName when provided", () => {
433
+ it("omits Cursor section when no Cursor sessions", () => {
434
+ const card = buildSessionsCard([
435
+ { sessionId: "c1", chatName: "", chatId: "oc_c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
436
+ ]);
437
+ const parsed = JSON.parse(card);
438
+ const content: string = parsed.elements[0].text.content;
439
+ expect(content).toContain("Claude Code 会话");
440
+ expect(content).not.toContain("Cursor 会话");
441
+ });
442
+
443
+ it("labels CCC sessions without grouping them as Claude Code", () => {
444
+ const card = buildSessionsCard([
445
+ { sessionId: "session-20260702-121530-a1b2c3", chatName: "", chatId: "oc_ccc", active: false, turnCount: 1, elapsedSeconds: null, model: "deepseek-v4-pro", tool: "ccc" },
446
+ ]);
447
+ const parsed = JSON.parse(card);
448
+ const content: string = parsed.elements[0].text.content;
449
+
450
+ expect(content).toContain("CCC Agent 会话");
451
+ expect(content).toContain("工具: CCC Agent");
452
+ expect(content).not.toContain("Claude Code 会话");
453
+ });
454
+
455
+ it("displays chatName when provided", () => {
455
456
  const card = buildSessionsCard([
456
457
  { sessionId: "abc123", chatName: "帮我写代码-src", chatId: "oc_abc123", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
457
458
  ]);
@@ -459,20 +460,20 @@ describe("buildSessionsCard", () => {
459
460
  expect(parsed.elements[0].text.content).toContain("帮我写代码-src");
460
461
  });
461
462
 
462
- it("shows (群聊) tag for group chat sessions and not for private chats", () => {
463
- const card = buildSessionsCard([
464
- { sessionId: "g1", chatName: "group-chat", chatId: "oc_group1", chatType: "group", active: false, turnCount: 1, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
465
- { sessionId: "p1", chatName: "private-chat", chatId: "oc_private1", chatType: "p2p", active: false, turnCount: 1, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
466
- ]);
467
- const content: string = JSON.parse(card).elements[0].text.content;
468
- expect(content).toContain("(群聊)");
469
- expect(content).toContain("(私聊)");
463
+ it("shows (群聊) tag for group chat sessions and not for private chats", () => {
464
+ const card = buildSessionsCard([
465
+ { sessionId: "g1", chatName: "group-chat", chatId: "oc_group1", chatType: "group", active: false, turnCount: 1, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
466
+ { sessionId: "p1", chatName: "private-chat", chatId: "oc_private1", chatType: "p2p", active: false, turnCount: 1, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
467
+ ]);
468
+ const content: string = JSON.parse(card).elements[0].text.content;
469
+ expect(content).toContain("(群聊)");
470
+ expect(content).toContain("(私聊)");
470
471
  // 群聊会话包含 (群聊)
471
472
  expect(content).toMatch(/group-chat.*\(群聊\)/);
472
473
  // 私聊会话不包含 (群聊)
473
474
  expect(content).toMatch(/private-chat/);
474
- const afterPrivateChat = content.split("private-chat")[1];
475
- expect(afterPrivateChat).not.toContain("(群聊)");
475
+ const afterPrivateChat = content.split("private-chat")[1];
476
+ expect(afterPrivateChat).not.toContain("(群聊)");
476
477
  });
477
478
 
478
479
  it("shows chat id missing for sessions without a chat binding", () => {
@@ -483,24 +484,24 @@ describe("buildSessionsCard", () => {
483
484
  expect(content).toContain("chat id缺失");
484
485
  });
485
486
 
486
- it("includes /session help text in non-empty card", () => {
487
+ it("includes /session help text in non-empty card", () => {
487
488
  const card = buildSessionsCard([
488
489
  { sessionId: "abc123", chatName: "", chatId: "oc_abc123", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
489
490
  ]);
490
491
  const parsed = JSON.parse(card);
491
- expect(parsed.elements[2].text.content).toContain("/session 数字");
492
- });
493
-
494
- it("explains Feishu private-session default Agent following without advertising /session switching", () => {
495
- const card = buildSessionsCard([
496
- { sessionId: "abc123", chatName: "飞书私聊", chatId: "ou_private", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
497
- ], { fixedPrivateSession: true });
498
- const parsed = JSON.parse(card);
499
- expect(parsed.elements[2].text.content).toContain("下一条普通消息");
500
- expect(parsed.elements[2].text.content).toContain("新空会话");
501
- expect(parsed.elements[2].text.content).toContain("不支持 **/session** 切换");
502
- expect(parsed.elements[2].text.content).not.toContain("/session 数字");
503
- });
492
+ expect(parsed.elements[2].text.content).toContain("/session 数字");
493
+ });
494
+
495
+ it("explains Feishu private-session default Agent following without advertising /session switching", () => {
496
+ const card = buildSessionsCard([
497
+ { sessionId: "abc123", chatName: "飞书私聊", chatId: "ou_private", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
498
+ ], { fixedPrivateSession: true });
499
+ const parsed = JSON.parse(card);
500
+ expect(parsed.elements[2].text.content).toContain("下一条普通消息");
501
+ expect(parsed.elements[2].text.content).toContain("新空会话");
502
+ expect(parsed.elements[2].text.content).toContain("不支持 **/session** 切换");
503
+ expect(parsed.elements[2].text.content).not.toContain("/session 数字");
504
+ });
504
505
 
505
506
  it("includes close button", () => {
506
507
  const card = buildSessionsCard([]);
@@ -514,7 +515,7 @@ describe("buildSessionsCard", () => {
514
515
  // buildStatusCard
515
516
  // ---------------------------------------------------------------------------
516
517
 
517
- describe("buildStatusCard", () => {
518
+ describe("buildStatusCard", () => {
518
519
  it("returns valid JSON with status text", () => {
519
520
  const card = buildStatusCard("一切正常");
520
521
  const parsed = JSON.parse(card);
@@ -534,54 +535,54 @@ describe("buildStatusCard", () => {
534
535
  const action = parsed.elements[2];
535
536
  expect(action.actions[0].text.content).toBe("收起");
536
537
  });
537
- });
538
-
539
- // ---------------------------------------------------------------------------
540
- // buildCodexUsageCard / buildCodexResetConfirmCard
541
- // ---------------------------------------------------------------------------
542
-
543
- describe("Codex usage reset cards", () => {
544
- it("shows the reset button only when reset credits are available", () => {
545
- const card = buildCodexUsageCard("Codex 用量", 2);
546
- const parsed = JSON.parse(card);
547
- const action = parsed.elements.find((element: any) => element.tag === "action");
548
- expect(action.actions[0].text.content).toBe("发起重置");
549
- expect(action.actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
550
-
551
- const noCreditCard = buildCodexUsageCard("Codex 用量", 0);
552
- const noCreditParsed = JSON.parse(noCreditCard);
553
- expect(noCreditParsed.elements.some((element: any) => element.tag === "action")).toBe(false);
554
- });
555
-
556
- it("builds yes/no confirmation buttons tied to the parent usage card", () => {
557
- const card = buildCodexResetConfirmCard({
558
- availableCount: 2,
559
- parentMessageId: "usage-message",
560
- requestId: "request-1",
561
- });
562
- const parsed = JSON.parse(card);
563
- const action = parsed.elements.find((element: any) => element.tag === "action");
564
-
565
- expect(action.actions[0].text.content).toBe("是,发起重置");
566
- expect(action.actions[0].value).toEqual({
567
- action: "codex_reset_confirm",
568
- decision: "yes",
569
- parentMessageId: "usage-message",
570
- requestId: "request-1",
571
- });
572
- expect(action.actions[1].text.content).toBe("否");
573
- expect(action.actions[1].value).toEqual({
574
- action: "codex_reset_confirm",
575
- decision: "no",
576
- parentMessageId: "usage-message",
577
- requestId: "request-1",
578
- });
579
- });
580
- });
581
-
582
- // ---------------------------------------------------------------------------
583
- // buildButtons
584
- // ---------------------------------------------------------------------------
538
+ });
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // buildCodexUsageCard / buildCodexResetConfirmCard
542
+ // ---------------------------------------------------------------------------
543
+
544
+ describe("Codex usage reset cards", () => {
545
+ it("shows the reset button only when reset credits are available", () => {
546
+ const card = buildCodexUsageCard("Codex 用量", 2);
547
+ const parsed = JSON.parse(card);
548
+ const action = parsed.elements.find((element: any) => element.tag === "action");
549
+ expect(action.actions[0].text.content).toBe("发起重置");
550
+ expect(action.actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
551
+
552
+ const noCreditCard = buildCodexUsageCard("Codex 用量", 0);
553
+ const noCreditParsed = JSON.parse(noCreditCard);
554
+ expect(noCreditParsed.elements.some((element: any) => element.tag === "action")).toBe(false);
555
+ });
556
+
557
+ it("builds yes/no confirmation buttons tied to the parent usage card", () => {
558
+ const card = buildCodexResetConfirmCard({
559
+ availableCount: 2,
560
+ parentMessageId: "usage-message",
561
+ requestId: "request-1",
562
+ });
563
+ const parsed = JSON.parse(card);
564
+ const action = parsed.elements.find((element: any) => element.tag === "action");
565
+
566
+ expect(action.actions[0].text.content).toBe("是,发起重置");
567
+ expect(action.actions[0].value).toEqual({
568
+ action: "codex_reset_confirm",
569
+ decision: "yes",
570
+ parentMessageId: "usage-message",
571
+ requestId: "request-1",
572
+ });
573
+ expect(action.actions[1].text.content).toBe("否");
574
+ expect(action.actions[1].value).toEqual({
575
+ action: "codex_reset_confirm",
576
+ decision: "no",
577
+ parentMessageId: "usage-message",
578
+ requestId: "request-1",
579
+ });
580
+ });
581
+ });
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // buildButtons
585
+ // ---------------------------------------------------------------------------
585
586
 
586
587
  describe("buildButtons", () => {
587
588
  it("returns action with buttons", () => {
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { ChatEvent } from "../builtin/index.ts";
4
+ import { reduceProgress, summarizeToolInput, summarizeToolResult } from "../progress/reducer.ts";
5
+ import { progressView } from "../progress/view.ts";
6
+
7
+ function feed(events: ChatEvent[]) {
8
+ return events.reduce(reduceProgress, progressView({ headerTitle: "生成中..." }));
9
+ }
10
+
11
+ describe("reduceProgress", () => {
12
+ it("accumulates text via accumulated field", () => {
13
+ const view = feed([
14
+ { type: "text", text: "Hello", accumulated: "Hello" },
15
+ { type: "text", text: " world", accumulated: "Hello world" },
16
+ ]);
17
+ expect(view.text).toBe("Hello world");
18
+ expect(view.status).toBe("generating");
19
+ expect(view.showStop).toBe(true);
20
+ });
21
+
22
+ it("appends tool_use as running and resolves status on tool_result", () => {
23
+ const view = feed([
24
+ { type: "tool_use", id: "t1", name: "edit_file", input: { path: "a.ts" } },
25
+ { type: "tool_result", tool_use_id: "t1", name: "edit_file", content: "ok", is_error: false },
26
+ ]);
27
+ expect(view.tools).toHaveLength(1);
28
+ expect(view.tools[0]).toMatchObject({
29
+ id: "t1",
30
+ name: "edit_file",
31
+ status: "ok",
32
+ summary: "ok",
33
+ });
34
+ });
35
+
36
+ it("marks tool as error when is_error is true", () => {
37
+ const view = feed([
38
+ { type: "tool_use", id: "t2", name: "run_command", input: { command: "npm test" } },
39
+ { type: "tool_result", tool_use_id: "t2", name: "run_command", content: "boom", is_error: true },
40
+ ]);
41
+ expect(view.tools[0].status).toBe("error");
42
+ expect(view.tools[0].summary).toBe("boom");
43
+ });
44
+
45
+ it("falls back to last running tool when tool_use_id mismatches", () => {
46
+ const view = feed([
47
+ { type: "tool_use", id: undefined, name: "list_dir", input: {} },
48
+ { type: "tool_result", tool_use_id: "unknown-id", name: "list_dir", content: "ok", is_error: false },
49
+ ]);
50
+ expect(view.tools[0].status).toBe("ok");
51
+ });
52
+
53
+ it("keeps multiple tool calls independently", () => {
54
+ const view = feed([
55
+ { type: "tool_use", id: "a", name: "read_file", input: { path: "a" } },
56
+ { type: "tool_use", id: "b", name: "read_file", input: { path: "b" } },
57
+ { type: "tool_result", tool_use_id: "b", name: "read_file", content: "bb", is_error: false },
58
+ ]);
59
+ expect(view.tools[0].status).toBe("running");
60
+ expect(view.tools[1].status).toBe("ok");
61
+ });
62
+
63
+ it("marks done: status done, showStop false, text finalized", () => {
64
+ const view = feed([
65
+ { type: "text", text: "part", accumulated: "part" },
66
+ { type: "done", text: "final answer" },
67
+ ]);
68
+ expect(view.status).toBe("done");
69
+ expect(view.showStop).toBe(false);
70
+ expect(view.text).toBe("final answer");
71
+ });
72
+
73
+ it("marks error status and keeps text", () => {
74
+ const view = feed([
75
+ { type: "text", text: "x", accumulated: "x" },
76
+ { type: "error", message: "boom" },
77
+ ]);
78
+ expect(view.status).toBe("error");
79
+ expect(view.showStop).toBe(false);
80
+ expect(view.text).toBe("x");
81
+ });
82
+
83
+ it("ignores compact events without changing the view identity", () => {
84
+ const base = progressView({ headerTitle: "生成中..." });
85
+ const result = reduceProgress(base, { type: "compact", compactedMessages: 5 });
86
+ expect(result).toBe(base);
87
+ });
88
+
89
+ it("does not mutate the previous view (immutable updates)", () => {
90
+ const base = progressView({ headerTitle: "生成中..." });
91
+ const next = reduceProgress(base, { type: "text", text: "hi", accumulated: "hi" });
92
+ expect(base.text).toBe("");
93
+ expect(next.text).toBe("hi");
94
+ expect(next).not.toBe(base);
95
+ });
96
+ });
97
+
98
+ describe("summarizeToolInput / summarizeToolResult", () => {
99
+ it("flattens multiline input to one line", () => {
100
+ const s = summarizeToolInput({ path: "a\nb", big: "x".repeat(200) });
101
+ expect(s).not.toContain("\n");
102
+ expect(s.length).toBeLessThanOrEqual(121);
103
+ expect(s.endsWith("…")).toBe(true);
104
+ });
105
+
106
+ it("takes first line of result content", () => {
107
+ expect(summarizeToolResult("line1\nline2\n")).toBe("line1");
108
+ expect(summarizeToolResult({ ok: true })).toBe('{"ok":true}');
109
+ });
110
+ });