chatccc 0.2.221 → 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 +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +40 -0
- package/src/__tests__/builtin-skills.test.ts +141 -0
- package/src/__tests__/card-plain-text.test.ts +7 -6
- package/src/__tests__/cards.test.ts +179 -178
- package/src/__tests__/progress-reducer.test.ts +110 -0
- package/src/__tests__/terminal-renderer.test.ts +143 -0
- package/src/builtin/cli.ts +38 -1
- package/src/builtin/index.ts +12 -0
- package/src/builtin/skills.ts +108 -0
- package/src/cards.ts +280 -275
- package/src/progress/reducer.ts +108 -0
- package/src/progress/terminal-renderer.ts +190 -0
- package/src/progress/view.ts +77 -0
- package/src/session.ts +935 -937
|
@@ -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",
|
|
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",
|
|
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
|
+
});
|