chatccc 0.2.3 â 0.2.4
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 +6 -3
- package/src/__tests__/cards.test.ts +297 -0
- package/src/__tests__/session.test.ts +151 -0
- package/src/cards.ts +21 -19
- package/src/index.ts +5 -5
- package/src/session.ts +34 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"demo:create-group": "tsx --env-file=.env src/index.ts",
|
|
24
24
|
"demo:create-group:local": "tsx --env-file=.env src/index.ts --local",
|
|
25
25
|
"demo:permission-check": "tsx --env-file=.env demo/permission_check.ts",
|
|
26
|
-
"demo:claude-hi": "tsx --env-file=.env demo/claude_say_hi.ts"
|
|
26
|
+
"demo:claude-hi": "tsx --env-file=.env demo/claude_say_hi.ts",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest"
|
|
27
29
|
},
|
|
28
30
|
"dependencies": {
|
|
29
31
|
"@anthropic-ai/claude-agent-sdk": "0.2.133",
|
|
@@ -34,7 +36,8 @@
|
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"@types/node": "^20.0.0",
|
|
36
38
|
"@types/ws": "^8.18.1",
|
|
37
|
-
"typescript": "^5.0.0"
|
|
39
|
+
"typescript": "^5.0.0",
|
|
40
|
+
"vitest": "^4.1.5"
|
|
38
41
|
},
|
|
39
42
|
"engines": {
|
|
40
43
|
"node": ">=20"
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildProgressCard,
|
|
4
|
+
buildHelpCard,
|
|
5
|
+
buildCdContent,
|
|
6
|
+
buildSessionsCard,
|
|
7
|
+
buildStatusCard,
|
|
8
|
+
buildButtons,
|
|
9
|
+
truncateContent,
|
|
10
|
+
getToolEmoji,
|
|
11
|
+
} from "../cards.ts";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// truncateContent
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe("truncateContent", () => {
|
|
18
|
+
it("returns original text when under limits", () => {
|
|
19
|
+
expect(truncateContent("hello")).toBe("hello");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("truncates lines when exceeding maxLines (default 20)", () => {
|
|
23
|
+
const lines = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`);
|
|
24
|
+
const text = lines.join("\n");
|
|
25
|
+
const result = truncateContent(text);
|
|
26
|
+
const resultLines = result.split("\n");
|
|
27
|
+
expect(resultLines.length).toBe(21); // 1 first + 1 "..." + 19 last
|
|
28
|
+
expect(resultLines[0]).toBe("line 1");
|
|
29
|
+
expect(resultLines[1]).toBe("...");
|
|
30
|
+
expect(resultLines[2]).toBe("line 7");
|
|
31
|
+
expect(resultLines[20]).toBe("line 25");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("truncates chars when exceeding maxChars", () => {
|
|
35
|
+
const long = "x".repeat(9000);
|
|
36
|
+
const result = truncateContent(long, 100, 500);
|
|
37
|
+
expect(result.length).toBeLessThanOrEqual(503); // "..." + 500 chars
|
|
38
|
+
expect(result.startsWith("...")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns empty string for empty input", () => {
|
|
42
|
+
expect(truncateContent("")).toBe("");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("respects custom maxLines and maxChars", () => {
|
|
46
|
+
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
|
|
47
|
+
const text = lines.join("\n");
|
|
48
|
+
const result = truncateContent(text, 5, 1000);
|
|
49
|
+
const resultLines = result.split("\n");
|
|
50
|
+
expect(resultLines[0]).toBe("line 1");
|
|
51
|
+
expect(resultLines[1]).toBe("...");
|
|
52
|
+
expect(resultLines[resultLines.length - 1]).toBe("line 10");
|
|
53
|
+
expect(resultLines.length).toBe(6); // 1 first + "..." + 4 last
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// getToolEmoji
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe("getToolEmoji", () => {
|
|
62
|
+
it("returns correct emoji for each known tool name", () => {
|
|
63
|
+
expect(getToolEmoji("Read")).toBe("\u{1F4D6}"); // đ
|
|
64
|
+
expect(getToolEmoji("Write")).toBe("\u{270D}\u{FE0F}"); // âī¸
|
|
65
|
+
expect(getToolEmoji("Edit")).toBe("\u{270F}\u{FE0F}"); // âī¸
|
|
66
|
+
expect(getToolEmoji("Grep")).toBe("\u{1F50E}"); // đ
|
|
67
|
+
expect(getToolEmoji("Glob")).toBe("\u{1F4C2}"); // đ
|
|
68
|
+
expect(getToolEmoji("Bash")).toBe("\u{1F5A5}\u{FE0F}"); // đĨī¸
|
|
69
|
+
expect(getToolEmoji("WebSearch")).toBe("\u{1F310}"); // đ
|
|
70
|
+
expect(getToolEmoji("WebFetch")).toBe("\u{1F4E5}"); // đĨ
|
|
71
|
+
expect(getToolEmoji("TodoWrite")).toBe("\u{2705}"); // â
|
|
72
|
+
expect(getToolEmoji("Agent")).toBe("\u{1F916}"); // đ¤
|
|
73
|
+
expect(getToolEmoji("NotebookEdit")).toBe("\u{1F4D3}"); // đ
|
|
74
|
+
expect(getToolEmoji("AskUserQuestion")).toBe("\u{2753}");// â
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns wrench for unknown tool names", () => {
|
|
78
|
+
expect(getToolEmoji("UnknownTool")).toBe("\u{1F527}");
|
|
79
|
+
expect(getToolEmoji("cat")).toBe("\u{1F527}");
|
|
80
|
+
expect(getToolEmoji("")).toBe("\u{1F527}");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// buildProgressCard
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe("buildProgressCard", () => {
|
|
89
|
+
it("returns valid JSON with correct schema", () => {
|
|
90
|
+
const card = buildProgressCard("test content");
|
|
91
|
+
const parsed = JSON.parse(card);
|
|
92
|
+
expect(parsed.schema).toBe("2.0");
|
|
93
|
+
expect(parsed.config.update_multi).toBe(true);
|
|
94
|
+
expect(parsed.config.streaming_mode).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses default header title 'įæä¸...'", () => {
|
|
98
|
+
const card = buildProgressCard("hello");
|
|
99
|
+
const parsed = JSON.parse(card);
|
|
100
|
+
expect(parsed.header.title.content).toBe("įæä¸...");
|
|
101
|
+
expect(parsed.header.template).toBe("blue");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("includes stop button by default", () => {
|
|
105
|
+
const card = buildProgressCard("hello");
|
|
106
|
+
const parsed = JSON.parse(card);
|
|
107
|
+
const buttons = parsed.body.elements.filter((e: any) => e.tag === "button");
|
|
108
|
+
expect(buttons).toHaveLength(2);
|
|
109
|
+
expect(buttons[0].text.content).toBe("æĨįįļæīŧ/statusīŧ");
|
|
110
|
+
expect(buttons[1].text.content).toBe("åæĸįæīŧ/stopīŧ");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("hides stop button when showStop is false", () => {
|
|
114
|
+
const card = buildProgressCard("hello", { showStop: false });
|
|
115
|
+
const parsed = JSON.parse(card);
|
|
116
|
+
const buttons = parsed.body.elements.filter((e: any) => e.tag === "button");
|
|
117
|
+
expect(buttons).toHaveLength(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses custom header title and template", () => {
|
|
121
|
+
const card = buildProgressCard("hello", { headerTitle: "åˇ˛åŽæ", headerTemplate: "green" });
|
|
122
|
+
const parsed = JSON.parse(card);
|
|
123
|
+
expect(parsed.header.title.content).toBe("åˇ˛åŽæ");
|
|
124
|
+
expect(parsed.header.template).toBe("green");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("includes markdown element with truncated content", () => {
|
|
128
|
+
const card = buildProgressCard("markdown text");
|
|
129
|
+
const parsed = JSON.parse(card);
|
|
130
|
+
const md = parsed.body.elements.find((e: any) => e.tag === "markdown");
|
|
131
|
+
expect(md).toBeDefined();
|
|
132
|
+
expect(md.element_id).toBe("main_content");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// buildHelpCard
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe("buildHelpCard", () => {
|
|
141
|
+
it("returns valid JSON with user text", () => {
|
|
142
|
+
const card = buildHelpCard("äŊ åĨŊ");
|
|
143
|
+
const parsed = JSON.parse(card);
|
|
144
|
+
expect(parsed.header.title.content).toBe("ChatCCC");
|
|
145
|
+
expect(parsed.elements[0].text.content).toContain("äŊ åĨŊ");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("includes action buttons", () => {
|
|
149
|
+
const card = buildHelpCard("test");
|
|
150
|
+
const parsed = JSON.parse(card);
|
|
151
|
+
const action = parsed.elements[1];
|
|
152
|
+
expect(action.tag).toBe("action");
|
|
153
|
+
expect(action.actions).toHaveLength(3);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// buildCdContent
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe("buildCdContent", () => {
|
|
162
|
+
const entries = [
|
|
163
|
+
{ name: "src", isDir: true },
|
|
164
|
+
{ name: "README.md", isDir: false },
|
|
165
|
+
{ name: "package.json", isDir: false },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
it("returns markdown with path and listing", () => {
|
|
169
|
+
const content = buildCdContent("/home/user/project", entries, false);
|
|
170
|
+
expect(content).toContain("/home/user/project");
|
|
171
|
+
expect(content).toContain("đ src/");
|
|
172
|
+
expect(content).toContain("đ README.md");
|
|
173
|
+
expect(content).toContain("đ package.json");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("shows 厞åæĸ when isUpdate is true", () => {
|
|
177
|
+
const content = buildCdContent("/home/user/project", entries, true);
|
|
178
|
+
expect(content).toContain("īŧ厞åæĸīŧ");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("shows currentCwd when provided", () => {
|
|
182
|
+
const content = buildCdContent("/new/path", entries, false, "/old/path");
|
|
183
|
+
expect(content).toContain("åŊåäŧč¯åˇĨäŊ莝åž");
|
|
184
|
+
expect(content).toContain("/old/path");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("omits currentCwd line when not provided", () => {
|
|
188
|
+
const content = buildCdContent("/new/path", entries, false);
|
|
189
|
+
expect(content).not.toContain("åŊåäŧč¯åˇĨäŊ莝åž");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("caps listing at maxFiles", () => {
|
|
193
|
+
const many = Array.from({ length: 150 }, (_, i) => ({
|
|
194
|
+
name: `file${i}.txt`,
|
|
195
|
+
isDir: false,
|
|
196
|
+
}));
|
|
197
|
+
const content = buildCdContent("/path", many, false);
|
|
198
|
+
expect(content).toContain("äģ
æžį¤ēå 100 ä¸Ē");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// buildSessionsCard
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("buildSessionsCard", () => {
|
|
207
|
+
it("returns valid JSON for empty sessions", () => {
|
|
208
|
+
const card = buildSessionsCard([]);
|
|
209
|
+
const parsed = JSON.parse(card);
|
|
210
|
+
expect(parsed.elements[0].text.content).toContain("æ˛Ąæäŧč¯čްåŊ");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns valid JSON with session listing", () => {
|
|
214
|
+
const card = buildSessionsCard([
|
|
215
|
+
{ sessionId: "abc123", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7" },
|
|
216
|
+
]);
|
|
217
|
+
const parsed = JSON.parse(card);
|
|
218
|
+
expect(parsed.elements[0].text.content).toContain("å
ą **1** ä¸Ēäŧč¯");
|
|
219
|
+
expect(parsed.elements[0].text.content).toContain("abc123");
|
|
220
|
+
expect(parsed.elements[0].text.content).toContain("đĸ æ´ģčˇ");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("shows idle status for inactive sessions", () => {
|
|
224
|
+
const card = buildSessionsCard([
|
|
225
|
+
{ sessionId: "xyz", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6" },
|
|
226
|
+
]);
|
|
227
|
+
const parsed = JSON.parse(card);
|
|
228
|
+
expect(parsed.elements[0].text.content).toContain("âĒ įŠēé˛");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("shows elapsed time for active sessions", () => {
|
|
232
|
+
const card = buildSessionsCard([
|
|
233
|
+
{ sessionId: "active123", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7" },
|
|
234
|
+
]);
|
|
235
|
+
const parsed = JSON.parse(card);
|
|
236
|
+
expect(parsed.elements[0].text.content).toContain("1å35į§");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("includes close button", () => {
|
|
240
|
+
const card = buildSessionsCard([]);
|
|
241
|
+
const parsed = JSON.parse(card);
|
|
242
|
+
const action = parsed.elements[2];
|
|
243
|
+
expect(action.actions[0].text.content).toBe("æļčĩˇ");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// buildStatusCard
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
describe("buildStatusCard", () => {
|
|
252
|
+
it("returns valid JSON with status text", () => {
|
|
253
|
+
const card = buildStatusCard("ä¸åæŖå¸¸");
|
|
254
|
+
const parsed = JSON.parse(card);
|
|
255
|
+
expect(parsed.header.title.content).toBe("äŧč¯įļæ");
|
|
256
|
+
expect(parsed.elements[0].text.content).toBe("ä¸åæŖå¸¸");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("uses custom template color", () => {
|
|
260
|
+
const card = buildStatusCard("čĻå", "red");
|
|
261
|
+
const parsed = JSON.parse(card);
|
|
262
|
+
expect(parsed.header.template).toBe("red");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("includes close button", () => {
|
|
266
|
+
const card = buildStatusCard("test");
|
|
267
|
+
const parsed = JSON.parse(card);
|
|
268
|
+
const action = parsed.elements[2];
|
|
269
|
+
expect(action.actions[0].text.content).toBe("æļčĩˇ");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// buildButtons
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
describe("buildButtons", () => {
|
|
278
|
+
it("returns action with buttons", () => {
|
|
279
|
+
const result = buildButtons([
|
|
280
|
+
{ text: "įĄŽčŽ¤", value: "ok", type: "primary" },
|
|
281
|
+
]);
|
|
282
|
+
const obj = result as any;
|
|
283
|
+
expect(obj.tag).toBe("action");
|
|
284
|
+
expect(obj.actions).toHaveLength(1);
|
|
285
|
+
expect(obj.actions[0].text.content).toBe("įĄŽčŽ¤");
|
|
286
|
+
expect(obj.actions[0].type).toBe("primary");
|
|
287
|
+
expect(obj.actions[0].value).toBe("ok");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("defaults button type to primary", () => {
|
|
291
|
+
const result = buildButtons([
|
|
292
|
+
{ text: "åæļ", value: "cancel" },
|
|
293
|
+
]);
|
|
294
|
+
const obj = result as any;
|
|
295
|
+
expect(obj.actions[0].type).toBe("primary");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
} from "../session.ts";
|
|
11
|
+
|
|
12
|
+
// Helper to create a mock active session entry
|
|
13
|
+
function mockActiveSession(chatId: string, overrides: Partial<{
|
|
14
|
+
accumulatedContent: string;
|
|
15
|
+
finalText: string;
|
|
16
|
+
stopped: boolean;
|
|
17
|
+
}> = {}) {
|
|
18
|
+
chatSessionMap.set(chatId, {
|
|
19
|
+
gen: 1,
|
|
20
|
+
close: () => {},
|
|
21
|
+
cardId: null,
|
|
22
|
+
stopped: overrides.stopped ?? false,
|
|
23
|
+
accumulatedContent: overrides.accumulatedContent ?? "thinking...",
|
|
24
|
+
finalText: overrides.finalText ?? "",
|
|
25
|
+
spinnerTimer: null,
|
|
26
|
+
msgTimestamp: Date.now(),
|
|
27
|
+
sequence: 0,
|
|
28
|
+
cardBusy: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mockSessionInfo(chatId: string, overrides: Partial<{
|
|
33
|
+
sessionId: string;
|
|
34
|
+
turnCount: number;
|
|
35
|
+
lastContextTokens: number;
|
|
36
|
+
startTime: number;
|
|
37
|
+
}> = {}) {
|
|
38
|
+
sessionInfoMap.set(chatId, {
|
|
39
|
+
sessionId: overrides.sessionId ?? "test-session-id",
|
|
40
|
+
turnCount: overrides.turnCount ?? 3,
|
|
41
|
+
lastContextTokens: overrides.lastContextTokens ?? 50000,
|
|
42
|
+
startTime: overrides.startTime ?? Date.now(),
|
|
43
|
+
model: "Claude Opus 4.7",
|
|
44
|
+
effort: "high",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("resetState", () => {
|
|
49
|
+
it("clears all maps and sets", () => {
|
|
50
|
+
chatSessionMap.set("chat1", {
|
|
51
|
+
gen: 1, close: () => {}, cardId: null, stopped: false,
|
|
52
|
+
accumulatedContent: "", finalText: "", spinnerTimer: null,
|
|
53
|
+
msgTimestamp: 0, sequence: 0, cardBusy: false,
|
|
54
|
+
});
|
|
55
|
+
sessionInfoMap.set("chat1", {
|
|
56
|
+
sessionId: "s1", turnCount: 1, lastContextTokens: 0,
|
|
57
|
+
startTime: 0, model: "", effort: "",
|
|
58
|
+
});
|
|
59
|
+
processedMessages.add("msg1");
|
|
60
|
+
|
|
61
|
+
resetState();
|
|
62
|
+
|
|
63
|
+
expect(chatSessionMap.size).toBe(0);
|
|
64
|
+
expect(sessionInfoMap.size).toBe(0);
|
|
65
|
+
expect(processedMessages.size).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("getSessionStatus", () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
chatSessionMap.clear();
|
|
72
|
+
sessionInfoMap.clear();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns null for unknown chatId", () => {
|
|
76
|
+
expect(getSessionStatus("nonexistent")).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns status for idle session (info exists, no active session)", () => {
|
|
80
|
+
mockSessionInfo("chat1");
|
|
81
|
+
const status = getSessionStatus("chat1");
|
|
82
|
+
expect(status).not.toBeNull();
|
|
83
|
+
expect(status!.sessionId).toBe("test-session-id");
|
|
84
|
+
expect(status!.running).toBe(false);
|
|
85
|
+
expect(status!.turnCount).toBe(3);
|
|
86
|
+
expect(status!.accumulatedLength).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns running=true for active session", () => {
|
|
90
|
+
mockSessionInfo("chat1");
|
|
91
|
+
mockActiveSession("chat1", { accumulatedContent: "thinking...", finalText: "reply" });
|
|
92
|
+
const status = getSessionStatus("chat1");
|
|
93
|
+
expect(status!.running).toBe(true);
|
|
94
|
+
expect(status!.accumulatedLength).toBe(16); // "thinking..."(11) + "reply"(5)
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns running=false for stopped session", () => {
|
|
98
|
+
mockSessionInfo("chat1");
|
|
99
|
+
mockActiveSession("chat1", { stopped: true });
|
|
100
|
+
const status = getSessionStatus("chat1");
|
|
101
|
+
expect(status!.running).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns correct turnCount and other info fields", () => {
|
|
105
|
+
mockSessionInfo("chat1", { turnCount: 7, lastContextTokens: 100000 });
|
|
106
|
+
const status = getSessionStatus("chat1");
|
|
107
|
+
expect(status!.turnCount).toBe(7);
|
|
108
|
+
expect(status!.lastContextTokens).toBe(100000);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("getAllSessionsStatus", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
chatSessionMap.clear();
|
|
115
|
+
sessionInfoMap.clear();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns empty array when no sessions", () => {
|
|
119
|
+
expect(getAllSessionsStatus()).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns statuses for all recorded sessions", () => {
|
|
123
|
+
mockSessionInfo("chat1", { sessionId: "s1" });
|
|
124
|
+
mockSessionInfo("chat2", { sessionId: "s2" });
|
|
125
|
+
mockActiveSession("chat1");
|
|
126
|
+
const result = getAllSessionsStatus();
|
|
127
|
+
expect(result).toHaveLength(2);
|
|
128
|
+
expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
|
|
129
|
+
expect(result.find(r => r.chatId === "chat2")!.active).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("marks stopped sessions as not active", () => {
|
|
133
|
+
mockSessionInfo("chat1");
|
|
134
|
+
mockActiveSession("chat1", { stopped: true });
|
|
135
|
+
const result = getAllSessionsStatus();
|
|
136
|
+
expect(result[0].active).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("processedMessages dedup", () => {
|
|
141
|
+
it("supports add/has semantics", () => {
|
|
142
|
+
processedMessages.clear();
|
|
143
|
+
processedMessages.add("msg_001");
|
|
144
|
+
expect(processedMessages.has("msg_001")).toBe(true);
|
|
145
|
+
expect(processedMessages.has("msg_002")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("MAX_PROCESSED is defined", () => {
|
|
149
|
+
expect(MAX_PROCESSED).toBe(5000);
|
|
150
|
+
});
|
|
151
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -42,35 +42,37 @@ export function truncateContent(text: string, maxLines = 20, maxChars = 8000): s
|
|
|
42
42
|
return displayText;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
const TOOL_EMOJI_MAP: Record<string, string> = {
|
|
46
|
+
Read: "\u{1F4D6}", // đ
|
|
47
|
+
Write: "\u{270D}\u{FE0F}", // âī¸
|
|
48
|
+
Edit: "\u{270F}\u{FE0F}", // âī¸
|
|
49
|
+
Grep: "\u{1F50E}", // đ
|
|
50
|
+
Glob: "\u{1F4C2}", // đ
|
|
51
|
+
Bash: "\u{1F5A5}\u{FE0F}", // đĨī¸
|
|
52
|
+
WebSearch: "\u{1F310}", // đ
|
|
53
|
+
WebFetch: "\u{1F4E5}", // đĨ
|
|
54
|
+
TodoWrite: "\u{2705}", // â
|
|
55
|
+
Agent: "\u{1F916}", // đ¤
|
|
56
|
+
NotebookEdit: "\u{1F4D3}", // đ
|
|
57
|
+
AskUserQuestion: "\u{2753}",// â
|
|
58
|
+
};
|
|
59
|
+
|
|
45
60
|
export function getToolEmoji(name: string): string {
|
|
46
|
-
|
|
47
|
-
if (n.includes("read") || n.includes("cat")) return "\u{1F4D6}"; // đ
|
|
48
|
-
if (n.includes("write")) return "\u{270D}\u{FE0F}"; // âī¸
|
|
49
|
-
if (n.includes("edit")) return "\u{270F}\u{FE0F}"; // âī¸
|
|
50
|
-
if (n.includes("grep") || n.includes("search")) return "\u{1F50E}"; // đ
|
|
51
|
-
if (n.includes("glob") || n.includes("find") || n.includes("ls")) return "\u{1F4C2}"; // đ
|
|
52
|
-
if (n.includes("bash") || n.includes("shell") || n.includes("exec")) return "\u{1F5A5}\u{FE0F}"; // đĨī¸
|
|
53
|
-
if (n.includes("websearch") || n.includes("web_search")) return "\u{1F310}"; // đ
|
|
54
|
-
if (n.includes("webfetch") || n.includes("web_fetch") || n.includes("fetch")) return "\u{1F4E5}"; // đĨ
|
|
55
|
-
if (n.includes("todo") || n.includes("task")) return "\u{2705}"; // â
|
|
56
|
-
if (n.includes("agent")) return "\u{1F916}"; // đ¤
|
|
57
|
-
if (n.includes("notebook")) return "\u{1F4D3}"; // đ
|
|
58
|
-
if (n.includes("ask") || n.includes("question")) return "\u{2753}"; // â
|
|
59
|
-
return "\u{1F527}"; // đ§
|
|
61
|
+
return TOOL_EMOJI_MAP[name] ?? "\u{1F527}"; // đ§
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
// ---------------------------------------------------------------------------
|
|
63
65
|
// Card builders
|
|
64
66
|
// ---------------------------------------------------------------------------
|
|
65
67
|
|
|
66
|
-
// CardKit schema 2.0
|
|
67
|
-
export function
|
|
68
|
-
|
|
68
|
+
// CardKit schema 2.0 čŋåēĻåĄįīŧå¸ĻåæĸæéŽīŧæ¯ææĩåŧæ´æ°īŧ
|
|
69
|
+
export function buildProgressCard(
|
|
70
|
+
text: string,
|
|
69
71
|
opts: { showStop?: boolean; headerTitle?: string; headerTemplate?: string } = {}
|
|
70
72
|
): string {
|
|
71
|
-
const { showStop = true, headerTitle = "
|
|
73
|
+
const { showStop = true, headerTitle = "įæä¸...", headerTemplate = "blue" } = opts;
|
|
72
74
|
const elements: object[] = [
|
|
73
|
-
{ tag: "markdown", content: truncateContent(
|
|
75
|
+
{ tag: "markdown", content: truncateContent(text), element_id: "main_content" },
|
|
74
76
|
];
|
|
75
77
|
if (showStop) {
|
|
76
78
|
elements.push({ tag: "hr" });
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* session ID, resumes the session via SDK, sends the user's text, and
|
|
14
14
|
* streams the response to the session's jsonl file.
|
|
15
15
|
*
|
|
16
|
-
* Buttons:
|
|
16
|
+
* Buttons: progress cards have a åæĸ button; help messages have /new and /restart buttons.
|
|
17
17
|
*
|
|
18
18
|
* Usage:
|
|
19
19
|
* npm run dev
|
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
verifyAllPermissions,
|
|
68
68
|
reportPermissionResults,
|
|
69
69
|
} from "./feishu-api.ts";
|
|
70
|
-
import { buildHelpCard, buildStatusCard,
|
|
70
|
+
import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildSessionsCard } from "./cards.ts";
|
|
71
71
|
import { updateCardKitCard } from "./cardkit.ts";
|
|
72
72
|
import {
|
|
73
73
|
MAX_PROCESSED,
|
|
@@ -420,9 +420,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
420
420
|
await new Promise(r => setTimeout(r, 20));
|
|
421
421
|
}
|
|
422
422
|
const cardId = existing.cardId;
|
|
423
|
-
const
|
|
424
|
-
const interruptedCard =
|
|
425
|
-
|
|
423
|
+
const currentContent = existing.accumulatedContent;
|
|
424
|
+
const interruptedCard = buildProgressCard(
|
|
425
|
+
currentContent || "æ°éŽéĸ厞æäē¤īŧåŊååå¤åˇ˛ä¸æã",
|
|
426
426
|
{ showStop: false, headerTitle: "厞䏿", headerTemplate: "yellow" }
|
|
427
427
|
);
|
|
428
428
|
let nextSeq = existing.sequence + 1;
|
package/src/session.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
isSdkAnthropicDefault,
|
|
10
10
|
ts,
|
|
11
11
|
} from "./config.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { buildProgressCard, getToolEmoji, truncateContent } from "./cards.ts";
|
|
13
13
|
import {
|
|
14
14
|
createCardKitCard,
|
|
15
15
|
sendCardKitMessage,
|
|
@@ -30,7 +30,7 @@ export const chatSessionMap = new Map<string, {
|
|
|
30
30
|
close: () => void;
|
|
31
31
|
cardId: string | null;
|
|
32
32
|
stopped: boolean;
|
|
33
|
-
|
|
33
|
+
accumulatedContent: string;
|
|
34
34
|
finalText: string;
|
|
35
35
|
spinnerTimer: ReturnType<typeof setInterval> | null;
|
|
36
36
|
msgTimestamp: number;
|
|
@@ -132,7 +132,7 @@ export async function resumeAndPrompt(
|
|
|
132
132
|
close: () => session.close(),
|
|
133
133
|
cardId: null,
|
|
134
134
|
stopped: false,
|
|
135
|
-
|
|
135
|
+
accumulatedContent: "",
|
|
136
136
|
finalText: "",
|
|
137
137
|
spinnerTimer: null,
|
|
138
138
|
msgTimestamp,
|
|
@@ -155,7 +155,7 @@ export async function resumeAndPrompt(
|
|
|
155
155
|
|
|
156
156
|
await session.send(userText);
|
|
157
157
|
|
|
158
|
-
cardId = await createCardKitCard(token,
|
|
158
|
+
cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "įæä¸..." })).catch((err) => {
|
|
159
159
|
console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
160
160
|
fileLog.flush();
|
|
161
161
|
sendTextReply(token, chatId, "â ī¸ æĩåŧåĄįååģēå¤ąč´Ĩīŧå¯čŊå éæĩīŧīŧå°äŊŋ፿æŦåå¤ã").catch(() => {});
|
|
@@ -178,8 +178,8 @@ export async function resumeAndPrompt(
|
|
|
178
178
|
|
|
179
179
|
const stream = session.stream();
|
|
180
180
|
|
|
181
|
-
let
|
|
182
|
-
let
|
|
181
|
+
let chunkCount = 0;
|
|
182
|
+
let accumulatedContent = "";
|
|
183
183
|
let finalText = "";
|
|
184
184
|
|
|
185
185
|
let cardCreatedAt = Date.now();
|
|
@@ -201,11 +201,11 @@ export async function resumeAndPrompt(
|
|
|
201
201
|
try {
|
|
202
202
|
// 1. į¨åŊåᴝ᧝å
åŽšæ´æ°æ§åĄįä¸ēéæįæŦ
|
|
203
203
|
const oldSeqBase = cEntry.sequence;
|
|
204
|
-
const oldDisplay = truncateContent(
|
|
205
|
-
const oldCard =
|
|
204
|
+
const oldDisplay = truncateContent(accumulatedContent + finalText) || "å¤įä¸...";
|
|
205
|
+
const oldCard = buildProgressCard(oldDisplay, { showStop: false, headerTitle: "įæä¸...īŧä¸čŊŽīŧ" });
|
|
206
206
|
await updateCardKitCard(token, oldCardId!, oldCard, oldSeqBase + 1).catch(() => {});
|
|
207
207
|
// 2. ååģēæ°åĄįåšļåé
|
|
208
|
-
const newCardId = await createCardKitCard(token,
|
|
208
|
+
const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "įæä¸..." }));
|
|
209
209
|
if (!newCardId) throw new Error("createCardKitCard returned empty");
|
|
210
210
|
await sendCardKitMessage(token, chatId, newCardId);
|
|
211
211
|
// 3. åæĸå°æ°åĄį
|
|
@@ -225,21 +225,21 @@ export async function resumeAndPrompt(
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
dotCount = (dotCount % 9) + 1;
|
|
228
|
-
const content = truncateContent(
|
|
228
|
+
const content = truncateContent(accumulatedContent + finalText + "\n" + "ã".repeat(dotCount));
|
|
229
229
|
if (content === lastSentContent) return;
|
|
230
230
|
|
|
231
231
|
lastSentContent = content;
|
|
232
232
|
cEntry.cardBusy = true;
|
|
233
233
|
const mySeq = cEntry.sequence + 1;
|
|
234
234
|
try {
|
|
235
|
-
const card =
|
|
235
|
+
const card = buildProgressCard(content, { showStop: true, headerTitle: "įæä¸..." });
|
|
236
236
|
await updateCardKitCard(token, cardId!, card, mySeq);
|
|
237
237
|
cEntry.sequence = mySeq;
|
|
238
|
-
cEntry.
|
|
238
|
+
cEntry.accumulatedContent = accumulatedContent;
|
|
239
239
|
streamErrorNotified = false;
|
|
240
240
|
healthLogTicks++;
|
|
241
241
|
if (healthLogTicks % 10 === 0) {
|
|
242
|
-
console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq}
|
|
242
|
+
console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq} content=${accumulatedContent.length}chars text=${finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
|
|
243
243
|
}
|
|
244
244
|
} catch (err) {
|
|
245
245
|
console.error(`[${ts()}] CardKit update error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
|
|
@@ -266,14 +266,14 @@ export async function resumeAndPrompt(
|
|
|
266
266
|
for (const block of sdkMsg.message.content) {
|
|
267
267
|
|
|
268
268
|
if (block.type === "thinking" && block.thinking) {
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
chunkCount++;
|
|
270
|
+
accumulatedContent += block.thinking;
|
|
271
271
|
} else if (block.type === "tool_use") {
|
|
272
272
|
const toolName = (block as { name?: string }).name ?? "unknown";
|
|
273
273
|
const toolInput = (block as { input?: unknown }).input;
|
|
274
274
|
const inputStr = typeof toolInput === "object" ? JSON.stringify(toolInput) : String(toolInput ?? "");
|
|
275
275
|
const shortInput = inputStr.length > 300 ? inputStr.slice(0, 300) + "..." : inputStr;
|
|
276
|
-
|
|
276
|
+
accumulatedContent += `\n\n${getToolEmoji(toolName)} **${toolName}**\n\`${shortInput}\`\n`;
|
|
277
277
|
} else if (block.type === "tool_result") {
|
|
278
278
|
const toolUseId = (block as { tool_use_id?: string }).tool_use_id ?? "";
|
|
279
279
|
const resultContent = (block as { content?: unknown }).content;
|
|
@@ -288,12 +288,12 @@ export async function resumeAndPrompt(
|
|
|
288
288
|
const shortResult = resultStr.length > 200 ? resultStr.slice(0, 200) + "..." : resultStr;
|
|
289
289
|
const isError = (block as { is_error?: boolean }).is_error;
|
|
290
290
|
const icon = isError ? "â" : "â
";
|
|
291
|
-
|
|
291
|
+
accumulatedContent += `${icon} *${toolUseId.slice(-6)}*: ${shortResult}\n`;
|
|
292
292
|
} else if (block.type === "redacted_thinking") {
|
|
293
|
-
|
|
293
|
+
accumulatedContent += "\n\nâ ī¸ å
厚čĸĢåŽå
¨čŋæģ¤\n";
|
|
294
294
|
} else if (block.type === "search_result") {
|
|
295
295
|
const searchQuery = (block as { query?: string }).query ?? "";
|
|
296
|
-
|
|
296
|
+
accumulatedContent += `\n\nđ čįŊæį´ĸ: **${searchQuery}**\n`;
|
|
297
297
|
} else if (block.type === "text" && block.text) {
|
|
298
298
|
finalText += block.text;
|
|
299
299
|
const entry = chatSessionMap.get(chatId);
|
|
@@ -304,7 +304,7 @@ export async function resumeAndPrompt(
|
|
|
304
304
|
const compactMeta = (sdkMsg as { compact_metadata?: { trigger?: string; pre_tokens?: number; post_tokens?: number } }).compact_metadata;
|
|
305
305
|
if (compactMeta) {
|
|
306
306
|
const triggerLabel = compactMeta.trigger === "manual" ? "æå¨" : "čĒå¨";
|
|
307
|
-
|
|
307
|
+
accumulatedContent += `\n\nđ ä¸ä¸æåįŧŠ(${triggerLabel}): **${compactMeta.pre_tokens}** â **${compactMeta.post_tokens}** tokens\n`;
|
|
308
308
|
// æ´æ°æäš
åä¸ä¸æ token æ°
|
|
309
309
|
if (compactMeta.post_tokens) {
|
|
310
310
|
const info = sessionInfoMap.get(chatId);
|
|
@@ -325,19 +325,19 @@ export async function resumeAndPrompt(
|
|
|
325
325
|
const wasStopped = cEntry.stopped;
|
|
326
326
|
chatSessionMap.delete(chatId);
|
|
327
327
|
|
|
328
|
-
if (cardId &&
|
|
328
|
+
if (cardId && accumulatedContent) {
|
|
329
329
|
while (cEntry.cardBusy) {
|
|
330
330
|
await new Promise(r => setTimeout(r, 20));
|
|
331
331
|
}
|
|
332
332
|
const nextSeq = cEntry.sequence + 1;
|
|
333
333
|
if (wasStopped) {
|
|
334
|
-
const stopCard =
|
|
334
|
+
const stopCard = buildProgressCard(accumulatedContent || "厞åæĸ", { showStop: false, headerTitle: "厞åæĸ", headerTemplate: "red" });
|
|
335
335
|
await updateCardKitCard(token, cardId, stopCard, nextSeq).catch((err) => {
|
|
336
336
|
console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
|
|
337
337
|
fileLog.flush();
|
|
338
338
|
});
|
|
339
339
|
} else {
|
|
340
|
-
const doneCard =
|
|
340
|
+
const doneCard = buildProgressCard(accumulatedContent, { showStop: false, headerTitle: "åŽæ" });
|
|
341
341
|
await updateCardKitCard(token, cardId, doneCard, nextSeq).catch((err) => {
|
|
342
342
|
console.error(`[${ts()}] CardKit finalize: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
|
|
343
343
|
fileLog.flush();
|
|
@@ -353,16 +353,16 @@ export async function resumeAndPrompt(
|
|
|
353
353
|
console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
|
|
354
354
|
);
|
|
355
355
|
}
|
|
356
|
-
console.log(`[${ts()}] Session ${sessionId} stopped by user (
|
|
356
|
+
console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${chunkCount})`);
|
|
357
357
|
return;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
if (streamErrorNotified) {
|
|
361
361
|
// CardKit streaming failed â send everything as text to ensure user sees the result
|
|
362
|
-
if (
|
|
363
|
-
const
|
|
364
|
-
await sendTextReply(token, chatId, `[
|
|
365
|
-
console.error(`[${ts()}] Failed to send
|
|
362
|
+
if (accumulatedContent.trim()) {
|
|
363
|
+
const shortContent = truncateContent(accumulatedContent, 30, 4000);
|
|
364
|
+
await sendTextReply(token, chatId, `[įæčŋį¨]\n${shortContent}`).catch((err) =>
|
|
365
|
+
console.error(`[${ts()}] Failed to send content fallback: ${(err as Error).message}`)
|
|
366
366
|
);
|
|
367
367
|
}
|
|
368
368
|
if (finalText.trim()) {
|
|
@@ -376,15 +376,15 @@ export async function resumeAndPrompt(
|
|
|
376
376
|
await sendTextReply(token, chatId, finalText.trim()).catch((err) =>
|
|
377
377
|
console.error(`[${ts()}] Failed to send final text: ${(err as Error).message}`)
|
|
378
378
|
);
|
|
379
|
-
} else if (!cardId &&
|
|
380
|
-
const
|
|
381
|
-
await sendTextReply(token, chatId, `[
|
|
382
|
-
console.error(`[${ts()}] Failed to send
|
|
379
|
+
} else if (!cardId && accumulatedContent.trim()) {
|
|
380
|
+
const shortContent = truncateContent(accumulatedContent, 30, 4000);
|
|
381
|
+
await sendTextReply(token, chatId, `[įæčŋį¨]\n${shortContent}`).catch((err) =>
|
|
382
|
+
console.error(`[${ts()}] Failed to send content text: ${(err as Error).message}`)
|
|
383
383
|
);
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
console.log(`[${ts()}] Session ${sessionId} stream complete (
|
|
387
|
+
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${chunkCount})`);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
// ---------------------------------------------------------------------------
|
|
@@ -415,7 +415,7 @@ export function getSessionStatus(chatId: string): SessionStatus | null {
|
|
|
415
415
|
startTime: info.startTime,
|
|
416
416
|
model: anthropicConfigDisplay(info.model),
|
|
417
417
|
effort: anthropicConfigDisplay(info.effort),
|
|
418
|
-
accumulatedLength: active ? active.
|
|
418
|
+
accumulatedLength: active ? active.accumulatedContent.length + active.finalText.length : 0,
|
|
419
419
|
};
|
|
420
420
|
}
|
|
421
421
|
|