chatccc 0.2.41 → 0.2.43

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.
@@ -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);
@@ -1,9 +1,9 @@
1
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { describe, it, expect, vi } from "vitest";
5
-
6
- import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, it, expect, vi } from "vitest";
5
+
6
+ import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
7
7
 
8
8
  describe("buildCrashLoggingHandlers", () => {
9
9
  it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
@@ -144,7 +144,7 @@ describe("buildCrashLoggingHandlers", () => {
144
144
  });
145
145
  });
146
146
 
147
- describe("installCrashLogging", () => {
147
+ describe("installCrashLogging", () => {
148
148
  it("注册所有相关事件监听器", () => {
149
149
  const before = {
150
150
  uncaught: process.listenerCount("uncaughtException"),
@@ -209,58 +209,58 @@ describe("installCrashLogging", () => {
209
209
  } finally {
210
210
  cleanup();
211
211
  }
212
- });
213
- });
214
-
215
- describe("setupFileLogging", () => {
216
- it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
217
- const originalLog = console.log;
218
- const originalError = console.error;
219
- console.log = vi.fn() as never;
220
- console.error = vi.fn() as never;
221
- const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
222
-
223
- try {
224
- const fileLog = setupFileLogging(dir, "index");
225
-
226
- console.log("before flush");
227
- fileLog.flush();
228
-
229
- expect(() => console.error("after flush")).not.toThrow();
230
- fileLog.flush();
231
-
232
- const content = await readFile(fileLog.logPath, "utf8");
233
- expect(content).toContain("[LOG] before flush");
234
- expect(content).toContain("[ERR] after flush");
235
- } finally {
236
- console.log = originalLog;
237
- console.error = originalError;
238
- await rm(dir, { recursive: true, force: true });
239
- }
240
- });
241
-
242
- it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
243
- const originalLog = console.log;
244
- const originalError = console.error;
245
- console.log = vi.fn() as never;
246
- console.error = vi.fn() as never;
247
- const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
248
-
249
- try {
250
- const fileLog = setupFileLogging(dir, "index");
251
- const circular: Record<string, unknown> = { name: "root" };
252
- circular.self = circular;
253
-
254
- expect(() => console.log("circular", circular)).not.toThrow();
255
- fileLog.flush();
256
-
257
- const content = await readFile(fileLog.logPath, "utf8");
258
- expect(content).toContain("[LOG] circular");
259
- expect(content).toContain("self");
260
- } finally {
261
- console.log = originalLog;
262
- console.error = originalError;
263
- await rm(dir, { recursive: true, force: true });
264
- }
265
- });
266
- });
212
+ });
213
+ });
214
+
215
+ describe("setupFileLogging", () => {
216
+ it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
217
+ const originalLog = console.log;
218
+ const originalError = console.error;
219
+ console.log = vi.fn() as never;
220
+ console.error = vi.fn() as never;
221
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
222
+
223
+ try {
224
+ const fileLog = setupFileLogging(dir, "index");
225
+
226
+ console.log("before flush");
227
+ fileLog.flush();
228
+
229
+ expect(() => console.error("after flush")).not.toThrow();
230
+ fileLog.flush();
231
+
232
+ const content = await readFile(fileLog.logPath, "utf8");
233
+ expect(content).toContain("[LOG] before flush");
234
+ expect(content).toContain("[ERR] after flush");
235
+ } finally {
236
+ console.log = originalLog;
237
+ console.error = originalError;
238
+ await rm(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+
242
+ it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
243
+ const originalLog = console.log;
244
+ const originalError = console.error;
245
+ console.log = vi.fn() as never;
246
+ console.error = vi.fn() as never;
247
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
248
+
249
+ try {
250
+ const fileLog = setupFileLogging(dir, "index");
251
+ const circular: Record<string, unknown> = { name: "root" };
252
+ circular.self = circular;
253
+
254
+ expect(() => console.log("circular", circular)).not.toThrow();
255
+ fileLog.flush();
256
+
257
+ const content = await readFile(fileLog.logPath, "utf8");
258
+ expect(content).toContain("[LOG] circular");
259
+ expect(content).toContain("self");
260
+ } finally {
261
+ console.log = originalLog;
262
+ console.error = originalError;
263
+ await rm(dir, { recursive: true, force: true });
264
+ }
265
+ });
266
+ });
@@ -1,4 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
2
5
  import {
3
6
  chatSessionMap,
4
7
  sessionInfoMap,
@@ -7,9 +10,12 @@ import {
7
10
  resetState,
8
11
  getSessionStatus,
9
12
  getAllSessionsStatus,
13
+ recordSessionRegistry,
10
14
  accumulateBlockContent,
11
15
  pickFinalReply,
12
16
  UNKNOWN_MODEL_PLACEHOLDER,
17
+ _setSessionRegistryFileForTest,
18
+ _resetSessionRegistryFileForTest,
13
19
  _setAdapterForToolForTest,
14
20
  _clearAdapterCacheForTest,
15
21
  } from "../session.ts";
@@ -196,39 +202,146 @@ describe("getSessionStatus", () => {
196
202
  });
197
203
 
198
204
  describe("getAllSessionsStatus", () => {
199
- beforeEach(() => {
205
+ let registryFile = "";
206
+
207
+ beforeEach(async () => {
200
208
  chatSessionMap.clear();
201
209
  sessionInfoMap.clear();
210
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-session-registry-"));
211
+ registryFile = join(dir, "session-registry.json");
212
+ _setSessionRegistryFileForTest(registryFile);
202
213
  });
203
214
 
204
- afterEach(() => {
215
+ afterEach(async () => {
205
216
  _clearAdapterCacheForTest();
217
+ _resetSessionRegistryFileForTest();
218
+ if (registryFile) {
219
+ await rm(dirname(registryFile), { recursive: true, force: true });
220
+ }
206
221
  });
207
222
 
208
223
  it("returns empty array when no sessions", async () => {
209
224
  await expect(getAllSessionsStatus()).resolves.toEqual([]);
210
225
  });
211
226
 
212
- it("returns statuses for all recorded sessions", async () => {
227
+ it("does not read memory-only sessions", async () => {
213
228
  mockSessionInfo("chat1", { sessionId: "s1" });
214
229
  mockSessionInfo("chat2", { sessionId: "s2" });
215
230
  mockActiveSession("chat1");
231
+
232
+ const result = await getAllSessionsStatus();
233
+ expect(result).toEqual([]);
234
+ });
235
+
236
+ it("returns statuses from disk registry", async () => {
237
+ await recordSessionRegistry({
238
+ chatId: "chat1",
239
+ sessionId: "s1",
240
+ tool: "claude",
241
+ chatName: "test-chat-1",
242
+ turnCount: 2,
243
+ startTime: 1000,
244
+ updatedAt: 2000,
245
+ running: true,
246
+ });
247
+ await recordSessionRegistry({
248
+ chatId: "chat2",
249
+ sessionId: "s2",
250
+ tool: "claude",
251
+ chatName: "test-chat-2",
252
+ turnCount: 0,
253
+ startTime: 900,
254
+ updatedAt: 1900,
255
+ running: false,
256
+ });
257
+
216
258
  const result = await getAllSessionsStatus();
217
259
  expect(result).toHaveLength(2);
260
+ expect(result[0].chatId).toBe("chat1");
261
+ expect(result[0].active).toBe(true);
262
+ expect(result[0].turnCount).toBe(2);
263
+ expect(result[0].chatName).toBe("test-chat-1");
264
+ expect(result[1].chatId).toBe("chat2");
265
+ expect(result[1].active).toBe(false);
266
+ expect(result[1].chatName).toBe("test-chat-2");
267
+ });
268
+
269
+ it("returns recent disk sessions by updatedAt desc, limited to 20", async () => {
270
+ for (let i = 0; i < 25; i++) {
271
+ await recordSessionRegistry({
272
+ chatId: `chat-${i}`,
273
+ sessionId: `sid-${i}`,
274
+ tool: "claude",
275
+ startTime: i,
276
+ updatedAt: 1000 + i,
277
+ });
278
+ }
279
+
280
+ const result = await getAllSessionsStatus();
281
+ expect(result).toHaveLength(20);
282
+ expect(result[0].chatId).toBe("chat-24");
283
+ expect(result[19].chatId).toBe("chat-5");
284
+ expect(result.some((r) => r.chatId === "chat-4")).toBe(false);
285
+ });
286
+
287
+ it("marks disk-running sessions as active without checking chatSessionMap", async () => {
288
+ await recordSessionRegistry({
289
+ chatId: "chat1",
290
+ sessionId: "s1",
291
+ tool: "claude",
292
+ running: true,
293
+ updatedAt: 1000,
294
+ });
295
+
296
+ const result = await getAllSessionsStatus();
218
297
  expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
219
- expect(result.find(r => r.chatId === "chat2")!.active).toBe(false);
220
298
  });
221
299
 
222
- it("marks stopped sessions as not active", async () => {
223
- mockSessionInfo("chat1");
224
- mockActiveSession("chat1", { stopped: true });
300
+ it("persists chatName across updates and defaults to empty string when not set", async () => {
301
+ await recordSessionRegistry({
302
+ chatId: "chat-a",
303
+ sessionId: "sa",
304
+ tool: "claude",
305
+ chatName: "My Chat",
306
+ updatedAt: 100,
307
+ });
308
+ // Update without chatName — should keep existing
309
+ await recordSessionRegistry({
310
+ chatId: "chat-a",
311
+ sessionId: "sa",
312
+ tool: "claude",
313
+ updatedAt: 200,
314
+ });
315
+ const result = await getAllSessionsStatus();
316
+ expect(result.find(r => r.chatId === "chat-a")!.chatName).toBe("My Chat");
317
+ });
318
+
319
+ it("chatName defaults to empty string for sessions without it", async () => {
320
+ await recordSessionRegistry({
321
+ chatId: "chat-b",
322
+ sessionId: "sb",
323
+ tool: "claude",
324
+ updatedAt: 100,
325
+ });
225
326
  const result = await getAllSessionsStatus();
226
- expect(result[0].active).toBe(false);
327
+ expect(result.find(r => r.chatId === "chat-b")!.chatName).toBe("");
227
328
  });
228
329
 
229
330
  it("混合 claude + cursor 会话:各自取自己来源的 model/effort", async () => {
230
- mockSessionInfo("chat-c", { sessionId: "sid-c", tool: "claude" });
231
- mockSessionInfo("chat-x", { sessionId: "sid-x", tool: "cursor" });
331
+ await recordSessionRegistry({
332
+ chatId: "chat-c",
333
+ sessionId: "sid-c",
334
+ tool: "claude",
335
+ chatName: "claude-chat",
336
+ updatedAt: 100,
337
+ });
338
+ await recordSessionRegistry({
339
+ chatId: "chat-x",
340
+ sessionId: "sid-x",
341
+ tool: "cursor",
342
+ chatName: "cursor-chat",
343
+ updatedAt: 200,
344
+ });
232
345
  _setAdapterForToolForTest(
233
346
  "cursor",
234
347
  mockAdapter((sid) =>
@@ -473,4 +586,4 @@ describe("pickFinalReply", () => {
473
586
  }),
474
587
  ).toBe("");
475
588
  });
476
- });
589
+ });
package/src/cards.ts CHANGED
@@ -254,6 +254,7 @@ export function buildCdCard(
254
254
  // 所有会话列表卡片(Claude Code 优先,然后 Cursor)
255
255
  export function buildSessionsCard(sessions: Array<{
256
256
  sessionId: string;
257
+ chatName: string;
257
258
  active: boolean;
258
259
  turnCount: number;
259
260
  elapsedSeconds: number | null;
@@ -274,7 +275,7 @@ export function buildSessionsCard(sessions: Array<{
274
275
  config: { wide_screen_mode: true },
275
276
  header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
276
277
  elements: [
277
- { tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
278
+ { tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
278
279
  { tag: "hr" },
279
280
  { tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
280
281
  ],
@@ -291,7 +292,8 @@ export function buildSessionsCard(sessions: Array<{
291
292
  extra = ` | 本轮: ${mins}分${secs}秒`;
292
293
  }
293
294
  const toolLabel = s.tool === "cursor" ? "Cursor" : s.tool === "codex" ? "Codex" : "Claude Code";
294
- return `**${i + 1}.** \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
295
+ const namePart = s.chatName ? `**${s.chatName}** ` : "";
296
+ return `**${i + 1}.** ${namePart}\`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
295
297
  };
296
298
 
297
299
  const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
@@ -326,7 +328,7 @@ export function buildSessionsCard(sessions: Array<{
326
328
  elements: [
327
329
  { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
328
330
  { tag: "hr" },
329
- { tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/forget** 可重置当前会话(创建新 Session,保留工作目录和群聊)。" } },
331
+ { tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/forget** 可重置当前会话(创建新 Session,保留工作目录和群聊)。\n发送 **/session 数字**(如 `/session 1`)可将当前群聊切换到列表中对应编号的会话。" } },
330
332
  { tag: "hr" },
331
333
  {
332
334
  tag: "action",