chatccc 0.2.41 → 0.2.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,222 +1,222 @@
1
- /**
2
- * OpenILink WeChat Echo Probe
3
- * ===========================
4
- * Minimal scan-login and text echo demo for the WeChat iLink channel.
5
- *
6
- * Usage:
7
- * npm run demo:ilink-echo
8
- *
9
- * Behavior:
10
- * When a user sends text to the logged-in WeChat account, this demo replies
11
- * with "收到:" plus the received text.
12
- */
13
-
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { createRequire } from "node:module";
16
- import { dirname, join } from "node:path";
17
- import { fileURLToPath } from "node:url";
18
-
19
- import {
20
- Client as OpenIlinkWire,
21
- extractText,
22
- type GetUpdatesResponse,
23
- type WeixinMessage,
24
- } from "@openilink/openilink-sdk-node";
25
-
26
- import { setupFileLogging } from "../src/shared.ts";
27
-
28
- interface TerminalQrRenderer {
29
- generate(
30
- input: string,
31
- opts: { small: boolean },
32
- callback: (qrcode: string) => void,
33
- ): void;
34
- }
35
-
36
- interface EchoProbeSnapshot {
37
- token?: string;
38
- baseUrl?: string;
39
- pollCursor?: string;
40
- botId?: string;
41
- userId?: string;
42
- lastSeenAt?: string;
43
- }
44
-
45
- const __dirname = dirname(fileURLToPath(import.meta.url));
46
- const PROJECT_ROOT = join(__dirname, "..");
47
- const SNAPSHOT_PATH = join(PROJECT_ROOT, "state", "ilink-echo-probe.json");
48
- const LOG_DIR = join(__dirname, "logs");
49
- const requireFromDemo = createRequire(import.meta.url);
50
- const terminalQr = requireFromDemo("qrcode-terminal") as TerminalQrRenderer;
51
-
52
- setupFileLogging(LOG_DIR, "ilink-echo-probe");
53
-
54
- let acceptingEvents = true;
55
-
56
- function ensureParentDir(filePath: string): void {
57
- mkdirSync(dirname(filePath), { recursive: true });
58
- }
59
-
60
- function readSnapshot(): EchoProbeSnapshot {
61
- if (!existsSync(SNAPSHOT_PATH)) {
62
- return {};
63
- }
64
-
65
- try {
66
- return JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")) as EchoProbeSnapshot;
67
- } catch (error) {
68
- console.warn(`Cannot read saved iLink probe state: ${(error as Error).message}`);
69
- return {};
70
- }
71
- }
72
-
73
- function writeSnapshot(snapshot: EchoProbeSnapshot): void {
74
- ensureParentDir(SNAPSHOT_PATH);
75
- writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`);
76
- }
77
-
78
- function updateSnapshot(patch: Partial<EchoProbeSnapshot>): EchoProbeSnapshot {
79
- const next = {
80
- ...readSnapshot(),
81
- ...patch,
82
- lastSeenAt: new Date().toISOString(),
83
- };
84
- writeSnapshot(next);
85
- return next;
86
- }
87
-
88
- function peerKeyOf(message: WeixinMessage): string {
89
- return String(message.from_user_id ?? "");
90
- }
91
-
92
- function textOf(message: WeixinMessage): string {
93
- return extractText(message).trim();
94
- }
95
-
96
- function printScanMaterial(content: string): void {
97
- console.log("\n========== iLink scan content ==========");
98
- console.log(content);
99
- console.log("========== iLink terminal QR ==========");
100
- terminalQr.generate(content, { small: true }, (renderedQr) => {
101
- console.log(renderedQr);
102
- });
103
- console.log("========== end iLink QR ==========\n");
104
- }
105
-
106
- async function prepareProbeWire(saved: EchoProbeSnapshot): Promise<OpenIlinkWire> {
107
- const ilinkWire = new OpenIlinkWire("", {
108
- base_url: saved.baseUrl,
109
- });
110
-
111
- console.log("Starting iLink QR login. The QR is shown on every run.");
112
- const loginResult = await ilinkWire.loginWithQr({
113
- on_qrcode: (content) => {
114
- printScanMaterial(content);
115
- },
116
- on_scanned: () => {
117
- console.log("QR scanned. Confirm login in WeChat.");
118
- },
119
- on_expired: (attempt, maxAttempts) => {
120
- console.log(`QR expired, refreshing (${attempt}/${maxAttempts}).`);
121
- },
122
- });
123
-
124
- if (!loginResult.connected) {
125
- throw new Error(`iLink QR login failed: ${loginResult.message}`);
126
- }
127
-
128
- updateSnapshot({
129
- token: loginResult.bot_token ?? ilinkWire.token,
130
- baseUrl: loginResult.base_url ?? ilinkWire.baseUrl,
131
- botId: loginResult.bot_id,
132
- userId: loginResult.user_id,
133
- pollCursor: "",
134
- });
135
-
136
- console.log(`iLink login ready. BotID=${loginResult.bot_id ?? ""}`);
137
- return ilinkWire;
138
- }
139
-
140
- async function mirrorIncomingLine(
141
- ilinkWire: OpenIlinkWire,
142
- message: WeixinMessage,
143
- ): Promise<void> {
144
- const peerKey = peerKeyOf(message);
145
- if (!peerKey) {
146
- console.warn("Skip message without from_user_id.");
147
- return;
148
- }
149
-
150
- const incomingText = textOf(message);
151
- const outgoingText = incomingText ? `收到:${incomingText}` : "收到:[non-text message]";
152
- const contextToken = message.context_token;
153
-
154
- if (contextToken) {
155
- await ilinkWire.sendText(peerKey, outgoingText, contextToken);
156
- } else {
157
- await ilinkWire.push(peerKey, outgoingText);
158
- }
159
-
160
- console.log(`Echoed to ${peerKey}: ${outgoingText.slice(0, 120)}`);
161
- }
162
-
163
- function rememberPollingCursor(response: GetUpdatesResponse): void {
164
- const pollCursor = response.sync_buf ?? response.get_updates_buf;
165
- if (!pollCursor) {
166
- return;
167
- }
168
- updateSnapshot({ pollCursor });
169
- }
170
-
171
- function installStopHooks(): void {
172
- const stop = (): void => {
173
- if (!acceptingEvents) {
174
- return;
175
- }
176
- acceptingEvents = false;
177
- console.log("Stopping iLink echo probe...");
178
- };
179
-
180
- process.once("SIGINT", stop);
181
- process.once("SIGTERM", stop);
182
- }
183
-
184
- async function runProbe(): Promise<void> {
185
- installStopHooks();
186
-
187
- const saved = readSnapshot();
188
- const ilinkWire = await prepareProbeWire(saved);
189
- const latest = readSnapshot();
190
-
191
- console.log("Listening for WeChat messages. Send text to the small account.");
192
- console.log(`State file: ${SNAPSHOT_PATH}`);
193
- console.log(`Log dir: ${LOG_DIR}`);
194
-
195
- await ilinkWire.monitor(
196
- async (message) => {
197
- try {
198
- await mirrorIncomingLine(ilinkWire, message);
199
- } catch (error) {
200
- console.error(`Echo failed: ${(error as Error).stack ?? (error as Error).message}`);
201
- }
202
- },
203
- {
204
- initial_buf: latest.pollCursor ?? "",
205
- on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
206
- on_response: rememberPollingCursor,
207
- on_error: (error) => {
208
- console.error(`iLink monitor error: ${error.stack ?? error.message}`);
209
- },
210
- on_session_expired: () => {
211
- console.error("iLink session expired. Restart with --fresh to scan again.");
212
- acceptingEvents = false;
213
- },
214
- should_continue: () => acceptingEvents,
215
- },
216
- );
217
- }
218
-
219
- runProbe().catch((error: Error) => {
220
- console.error(`Fatal iLink echo probe error: ${error.stack ?? error.message}`);
221
- process.exitCode = 1;
222
- });
1
+ /**
2
+ * OpenILink WeChat Echo Probe
3
+ * ===========================
4
+ * Minimal scan-login and text echo demo for the WeChat iLink channel.
5
+ *
6
+ * Usage:
7
+ * npm run demo:ilink-echo
8
+ *
9
+ * Behavior:
10
+ * When a user sends text to the logged-in WeChat account, this demo replies
11
+ * with "收到:" plus the received text.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { createRequire } from "node:module";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import {
20
+ Client as OpenIlinkWire,
21
+ extractText,
22
+ type GetUpdatesResponse,
23
+ type WeixinMessage,
24
+ } from "@openilink/openilink-sdk-node";
25
+
26
+ import { setupFileLogging } from "../src/shared.ts";
27
+
28
+ interface TerminalQrRenderer {
29
+ generate(
30
+ input: string,
31
+ opts: { small: boolean },
32
+ callback: (qrcode: string) => void,
33
+ ): void;
34
+ }
35
+
36
+ interface EchoProbeSnapshot {
37
+ token?: string;
38
+ baseUrl?: string;
39
+ pollCursor?: string;
40
+ botId?: string;
41
+ userId?: string;
42
+ lastSeenAt?: string;
43
+ }
44
+
45
+ const __dirname = dirname(fileURLToPath(import.meta.url));
46
+ const PROJECT_ROOT = join(__dirname, "..");
47
+ const SNAPSHOT_PATH = join(PROJECT_ROOT, "state", "ilink-echo-probe.json");
48
+ const LOG_DIR = join(__dirname, "logs");
49
+ const requireFromDemo = createRequire(import.meta.url);
50
+ const terminalQr = requireFromDemo("qrcode-terminal") as TerminalQrRenderer;
51
+
52
+ setupFileLogging(LOG_DIR, "ilink-echo-probe");
53
+
54
+ let acceptingEvents = true;
55
+
56
+ function ensureParentDir(filePath: string): void {
57
+ mkdirSync(dirname(filePath), { recursive: true });
58
+ }
59
+
60
+ function readSnapshot(): EchoProbeSnapshot {
61
+ if (!existsSync(SNAPSHOT_PATH)) {
62
+ return {};
63
+ }
64
+
65
+ try {
66
+ return JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")) as EchoProbeSnapshot;
67
+ } catch (error) {
68
+ console.warn(`Cannot read saved iLink probe state: ${(error as Error).message}`);
69
+ return {};
70
+ }
71
+ }
72
+
73
+ function writeSnapshot(snapshot: EchoProbeSnapshot): void {
74
+ ensureParentDir(SNAPSHOT_PATH);
75
+ writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`);
76
+ }
77
+
78
+ function updateSnapshot(patch: Partial<EchoProbeSnapshot>): EchoProbeSnapshot {
79
+ const next = {
80
+ ...readSnapshot(),
81
+ ...patch,
82
+ lastSeenAt: new Date().toISOString(),
83
+ };
84
+ writeSnapshot(next);
85
+ return next;
86
+ }
87
+
88
+ function peerKeyOf(message: WeixinMessage): string {
89
+ return String(message.from_user_id ?? "");
90
+ }
91
+
92
+ function textOf(message: WeixinMessage): string {
93
+ return extractText(message).trim();
94
+ }
95
+
96
+ function printScanMaterial(content: string): void {
97
+ console.log("\n========== iLink scan content ==========");
98
+ console.log(content);
99
+ console.log("========== iLink terminal QR ==========");
100
+ terminalQr.generate(content, { small: true }, (renderedQr) => {
101
+ console.log(renderedQr);
102
+ });
103
+ console.log("========== end iLink QR ==========\n");
104
+ }
105
+
106
+ async function prepareProbeWire(saved: EchoProbeSnapshot): Promise<OpenIlinkWire> {
107
+ const ilinkWire = new OpenIlinkWire("", {
108
+ base_url: saved.baseUrl,
109
+ });
110
+
111
+ console.log("Starting iLink QR login. The QR is shown on every run.");
112
+ const loginResult = await ilinkWire.loginWithQr({
113
+ on_qrcode: (content) => {
114
+ printScanMaterial(content);
115
+ },
116
+ on_scanned: () => {
117
+ console.log("QR scanned. Confirm login in WeChat.");
118
+ },
119
+ on_expired: (attempt, maxAttempts) => {
120
+ console.log(`QR expired, refreshing (${attempt}/${maxAttempts}).`);
121
+ },
122
+ });
123
+
124
+ if (!loginResult.connected) {
125
+ throw new Error(`iLink QR login failed: ${loginResult.message}`);
126
+ }
127
+
128
+ updateSnapshot({
129
+ token: loginResult.bot_token ?? ilinkWire.token,
130
+ baseUrl: loginResult.base_url ?? ilinkWire.baseUrl,
131
+ botId: loginResult.bot_id,
132
+ userId: loginResult.user_id,
133
+ pollCursor: "",
134
+ });
135
+
136
+ console.log(`iLink login ready. BotID=${loginResult.bot_id ?? ""}`);
137
+ return ilinkWire;
138
+ }
139
+
140
+ async function mirrorIncomingLine(
141
+ ilinkWire: OpenIlinkWire,
142
+ message: WeixinMessage,
143
+ ): Promise<void> {
144
+ const peerKey = peerKeyOf(message);
145
+ if (!peerKey) {
146
+ console.warn("Skip message without from_user_id.");
147
+ return;
148
+ }
149
+
150
+ const incomingText = textOf(message);
151
+ const outgoingText = incomingText ? `收到:${incomingText}` : "收到:[non-text message]";
152
+ const contextToken = message.context_token;
153
+
154
+ if (contextToken) {
155
+ await ilinkWire.sendText(peerKey, outgoingText, contextToken);
156
+ } else {
157
+ await ilinkWire.push(peerKey, outgoingText);
158
+ }
159
+
160
+ console.log(`Echoed to ${peerKey}: ${outgoingText.slice(0, 120)}`);
161
+ }
162
+
163
+ function rememberPollingCursor(response: GetUpdatesResponse): void {
164
+ const pollCursor = response.sync_buf ?? response.get_updates_buf;
165
+ if (!pollCursor) {
166
+ return;
167
+ }
168
+ updateSnapshot({ pollCursor });
169
+ }
170
+
171
+ function installStopHooks(): void {
172
+ const stop = (): void => {
173
+ if (!acceptingEvents) {
174
+ return;
175
+ }
176
+ acceptingEvents = false;
177
+ console.log("Stopping iLink echo probe...");
178
+ };
179
+
180
+ process.once("SIGINT", stop);
181
+ process.once("SIGTERM", stop);
182
+ }
183
+
184
+ async function runProbe(): Promise<void> {
185
+ installStopHooks();
186
+
187
+ const saved = readSnapshot();
188
+ const ilinkWire = await prepareProbeWire(saved);
189
+ const latest = readSnapshot();
190
+
191
+ console.log("Listening for WeChat messages. Send text to the small account.");
192
+ console.log(`State file: ${SNAPSHOT_PATH}`);
193
+ console.log(`Log dir: ${LOG_DIR}`);
194
+
195
+ await ilinkWire.monitor(
196
+ async (message) => {
197
+ try {
198
+ await mirrorIncomingLine(ilinkWire, message);
199
+ } catch (error) {
200
+ console.error(`Echo failed: ${(error as Error).stack ?? (error as Error).message}`);
201
+ }
202
+ },
203
+ {
204
+ initial_buf: latest.pollCursor ?? "",
205
+ on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
206
+ on_response: rememberPollingCursor,
207
+ on_error: (error) => {
208
+ console.error(`iLink monitor error: ${error.stack ?? error.message}`);
209
+ },
210
+ on_session_expired: () => {
211
+ console.error("iLink session expired. Restart with --fresh to scan again.");
212
+ acceptingEvents = false;
213
+ },
214
+ should_continue: () => acceptingEvents,
215
+ },
216
+ );
217
+ }
218
+
219
+ runProbe().catch((error: Error) => {
220
+ console.error(`Fatal iLink echo probe error: ${error.stack ?? error.message}`);
221
+ process.exitCode = 1;
222
+ });
package/package.json CHANGED
@@ -1,59 +1,59 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.2.41",
4
- "description": "Feishu bot bridge for Claude Code",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "main": "./src/index.ts",
8
- "bin": {
9
- "chatccc": "bin/chatccc.mjs"
10
- },
11
- "files": [
12
- "src/",
13
- "bin/",
14
- "demo/ilink_echo_probe.ts",
15
- "im-skills/",
16
- "images/img_readme_*.jpg",
17
- "images/img_readme_*.png",
18
- "images/avatars/status_*.png",
19
- "images/avatars/badges/",
20
- "package.json",
21
- "README.md",
22
- "config.sample.json"
23
- ],
24
- "scripts": {
25
- "dev": "tsx src/index.ts",
26
- "chatccc": "tsx src/index.ts",
27
- "start": "tsx src/index.ts",
28
- "demo:bot-test": "tsx demo/bot_test.ts",
29
- "demo:bot-test:local": "tsx demo/bot_test.ts --local",
30
- "demo:create-group": "tsx src/index.ts",
31
- "demo:create-group:local": "tsx src/index.ts --local",
32
- "demo:permission-check": "tsx demo/permission_check.ts",
33
- "demo:claude-hi": "tsx demo/claude_say_hi.ts",
34
- "demo:codex-hi": "tsx demo/codex_say_hi.ts",
35
- "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
36
- "test": "vitest run",
37
- "test:watch": "vitest"
38
- },
39
- "dependencies": {
40
- "@anthropic-ai/claude-agent-sdk": "0.2.133",
41
- "@larksuiteoapi/node-sdk": "^1.59.0",
42
- "@openilink/openilink-sdk-node": "^0.6.0",
43
- "nodemailer": "^8.0.7",
44
- "qrcode-terminal": "^0.12.0",
45
- "sharp": "^0.34.5",
46
- "tsx": "^4.0.0",
47
- "ws": "^8.18.0"
48
- },
49
- "devDependencies": {
50
- "@types/node": "^20.0.0",
51
- "@types/qrcode-terminal": "^0.12.2",
52
- "@types/ws": "^8.18.1",
53
- "typescript": "^5.0.0",
54
- "vitest": "^4.1.5"
55
- },
56
- "engines": {
57
- "node": ">=20"
58
- }
59
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.2.42",
4
+ "description": "Feishu bot bridge for Claude Code",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "bin": {
9
+ "chatccc": "bin/chatccc.mjs"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "bin/",
14
+ "demo/ilink_echo_probe.ts",
15
+ "im-skills/",
16
+ "images/img_readme_*.jpg",
17
+ "images/img_readme_*.png",
18
+ "images/avatars/status_*.png",
19
+ "images/avatars/badges/",
20
+ "package.json",
21
+ "README.md",
22
+ "config.sample.json"
23
+ ],
24
+ "scripts": {
25
+ "dev": "tsx src/index.ts",
26
+ "chatccc": "tsx src/index.ts",
27
+ "start": "tsx src/index.ts",
28
+ "demo:bot-test": "tsx demo/bot_test.ts",
29
+ "demo:bot-test:local": "tsx demo/bot_test.ts --local",
30
+ "demo:create-group": "tsx src/index.ts",
31
+ "demo:create-group:local": "tsx src/index.ts --local",
32
+ "demo:permission-check": "tsx demo/permission_check.ts",
33
+ "demo:claude-hi": "tsx demo/claude_say_hi.ts",
34
+ "demo:codex-hi": "tsx demo/codex_say_hi.ts",
35
+ "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ },
39
+ "dependencies": {
40
+ "@anthropic-ai/claude-agent-sdk": "0.2.133",
41
+ "@larksuiteoapi/node-sdk": "^1.59.0",
42
+ "@openilink/openilink-sdk-node": "^0.6.0",
43
+ "nodemailer": "^8.0.7",
44
+ "qrcode-terminal": "^0.12.0",
45
+ "sharp": "^0.34.5",
46
+ "tsx": "^4.0.0",
47
+ "ws": "^8.18.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.0.0",
51
+ "@types/qrcode-terminal": "^0.12.2",
52
+ "@types/ws": "^8.18.1",
53
+ "typescript": "^5.0.0",
54
+ "vitest": "^4.1.5"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }
@@ -311,7 +311,7 @@ describe("buildSessionsCard", () => {
311
311
 
312
312
  it("returns valid JSON with session listing", () => {
313
313
  const card = buildSessionsCard([
314
- { sessionId: "abc123", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
314
+ { sessionId: "abc123", chatName: "test-group", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
315
315
  ]);
316
316
  const parsed = JSON.parse(card);
317
317
  expect(parsed.elements[0].text.content).toContain("共 **1** 个会话");
@@ -322,7 +322,7 @@ describe("buildSessionsCard", () => {
322
322
 
323
323
  it("shows idle status for inactive sessions", () => {
324
324
  const card = buildSessionsCard([
325
- { sessionId: "xyz", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
325
+ { sessionId: "xyz", chatName: "", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
326
326
  ]);
327
327
  const parsed = JSON.parse(card);
328
328
  expect(parsed.elements[0].text.content).toContain("⚪ 空闲");
@@ -330,7 +330,7 @@ describe("buildSessionsCard", () => {
330
330
 
331
331
  it("shows elapsed time for active sessions", () => {
332
332
  const card = buildSessionsCard([
333
- { sessionId: "active123", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
333
+ { sessionId: "active123", chatName: "", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
334
334
  ]);
335
335
  const parsed = JSON.parse(card);
336
336
  expect(parsed.elements[0].text.content).toContain("1分35秒");
@@ -338,8 +338,8 @@ describe("buildSessionsCard", () => {
338
338
 
339
339
  it("separates Claude Code and Cursor sessions", () => {
340
340
  const card = buildSessionsCard([
341
- { sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
342
- { sessionId: "c2", active: false, turnCount: 2, elapsedSeconds: null, model: "claude-opus-4-7-max", tool: "cursor" },
341
+ { sessionId: "c1", chatName: "", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
342
+ { sessionId: "c2", chatName: "", active: false, turnCount: 2, elapsedSeconds: null, model: "claude-opus-4-7-max", tool: "cursor" },
343
343
  ]);
344
344
  const parsed = JSON.parse(card);
345
345
  const content: string = parsed.elements[0].text.content;
@@ -349,7 +349,7 @@ describe("buildSessionsCard", () => {
349
349
 
350
350
  it("omits Cursor section when no Cursor sessions", () => {
351
351
  const card = buildSessionsCard([
352
- { sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
352
+ { sessionId: "c1", chatName: "", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
353
353
  ]);
354
354
  const parsed = JSON.parse(card);
355
355
  const content: string = parsed.elements[0].text.content;
@@ -357,6 +357,22 @@ describe("buildSessionsCard", () => {
357
357
  expect(content).not.toContain("Cursor 会话");
358
358
  });
359
359
 
360
+ it("displays chatName when provided", () => {
361
+ const card = buildSessionsCard([
362
+ { sessionId: "abc123", chatName: "帮我写代码-src", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
363
+ ]);
364
+ const parsed = JSON.parse(card);
365
+ expect(parsed.elements[0].text.content).toContain("帮我写代码-src");
366
+ });
367
+
368
+ it("includes /session help text in non-empty card", () => {
369
+ const card = buildSessionsCard([
370
+ { sessionId: "abc123", chatName: "", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
371
+ ]);
372
+ const parsed = JSON.parse(card);
373
+ expect(parsed.elements[2].text.content).toContain("/session 数字");
374
+ });
375
+
360
376
  it("includes close button", () => {
361
377
  const card = buildSessionsCard([]);
362
378
  const parsed = JSON.parse(card);