chatccc 0.2.171 → 0.2.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.171",
3
+ "version": "0.2.173",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -169,6 +169,26 @@ describe("handleCommand WeChat processing ack", () => {
169
169
  expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
170
170
  });
171
171
 
172
+ it("does not send the stopped success text until the running prompt really exits", async () => {
173
+ const platform = mockPlatform();
174
+ await recordSessionRegistry({
175
+ chatId: "wx-chat",
176
+ sessionId: "sid-wechat",
177
+ tool: "claude",
178
+ chatName: "busy-session",
179
+ running: true,
180
+ });
181
+ activePrompts.set("sid-wechat", {
182
+ controller: new AbortController(),
183
+ stopped: false,
184
+ startTime: Date.now(),
185
+ });
186
+
187
+ await handleCommand(platform, "/stop", "wx-chat", "wx-user", Date.now(), "p2p");
188
+
189
+ expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "会话已停止。");
190
+ });
191
+
172
192
  it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
173
193
  const platform = mockPlatform("feishu");
174
194
  const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
@@ -177,7 +197,7 @@ describe("handleCommand WeChat processing ack", () => {
177
197
  blocks: [{ type: "text" as const, text: `收到: ${userText}` }],
178
198
  };
179
199
  });
180
- _setAdapterForToolForTest("claude", {
200
+ _setAdapterForToolForTest("claude", {
181
201
  displayName: "Claude",
182
202
  sessionDescPrefix: "Claude Session:",
183
203
  createSession: vi.fn(async () => ({ sessionId: "sid-feishu-new" })),
@@ -88,6 +88,7 @@ import {
88
88
  recordChatPlatform,
89
89
  _getPlatformForChatForTest,
90
90
  runAgentSession,
91
+ stopSession,
91
92
  startUnifiedDisplayLoop,
92
93
  stopUnifiedDisplayLoop,
93
94
  _setProcessAliveForTest,
@@ -396,6 +397,139 @@ describe("runAgentSession process monitor", () => {
396
397
  expect.stringContaining("进程异常结束"),
397
398
  );
398
399
  });
400
+
401
+ it("recreates the progress card when the initial CardKit send fails", async () => {
402
+ vi.spyOn(console, "error").mockImplementation(() => {});
403
+ const platform = mockPlatform("feishu");
404
+ platform.cardCreate = vi.fn()
405
+ .mockResolvedValueOnce("bad-card")
406
+ .mockResolvedValueOnce("good-card");
407
+ platform.cardSend = vi.fn()
408
+ .mockRejectedValueOnce(new Error("cardid is invalid"))
409
+ .mockResolvedValueOnce("message-good");
410
+ setSessionPlatform(platform);
411
+ bindChatToSession("sid-card-retry", "chat-card-retry");
412
+ recordLastActiveChat("sid-card-retry", "chat-card-retry");
413
+
414
+ const adapter: ToolAdapter = {
415
+ displayName: "Claude Code",
416
+ sessionDescPrefix: "Claude Code Session:",
417
+ createSession: async () => ({ sessionId: "sid-card-retry" }),
418
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
419
+ closeSession: async () => {},
420
+ prompt: async function* () {
421
+ yield { type: "assistant", blocks: [{ type: "text", text: "done" }] };
422
+ },
423
+ };
424
+ _setAdapterForToolForTest("claude", adapter);
425
+
426
+ await runAgentSession("sid-card-retry", "prompt", platform, "chat-card-retry", Date.now(), "claude");
427
+
428
+ expect(platform.cardCreate).toHaveBeenCalledTimes(2);
429
+ expect(platform.cardSend).toHaveBeenNthCalledWith(1, "chat-card-retry", "bad-card");
430
+ expect(platform.cardSend).toHaveBeenNthCalledWith(2, "chat-card-retry", "good-card");
431
+ expect(displayCards.get("chat-card-retry")?.cardId).toBe("good-card");
432
+ expect(platform.sendText).not.toHaveBeenCalledWith(
433
+ "chat-card-retry",
434
+ "生成中卡片发送失败,结果将以文本形式发送。",
435
+ );
436
+ });
437
+
438
+ it("does not register an invisible progress card and sends the final text fallback", async () => {
439
+ vi.spyOn(console, "error").mockImplementation(() => {});
440
+ const platform = mockPlatform("feishu");
441
+ platform.cardCreate = vi.fn()
442
+ .mockResolvedValueOnce("bad-card-1")
443
+ .mockResolvedValueOnce("bad-card-2");
444
+ platform.cardSend = vi.fn()
445
+ .mockRejectedValueOnce(new Error("cardid is invalid"))
446
+ .mockRejectedValueOnce(new Error("cardid is invalid again"));
447
+ setSessionPlatform(platform);
448
+ bindChatToSession("sid-card-fallback", "chat-card-fallback");
449
+ recordLastActiveChat("sid-card-fallback", "chat-card-fallback");
450
+
451
+ const adapter: ToolAdapter = {
452
+ displayName: "Claude Code",
453
+ sessionDescPrefix: "Claude Code Session:",
454
+ createSession: async () => ({ sessionId: "sid-card-fallback" }),
455
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
456
+ closeSession: async () => {},
457
+ prompt: async function* () {
458
+ yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
459
+ },
460
+ };
461
+ _setAdapterForToolForTest("claude", adapter);
462
+
463
+ await runAgentSession("sid-card-fallback", "prompt", platform, "chat-card-fallback", Date.now(), "claude");
464
+
465
+ expect(displayCards.has("chat-card-fallback")).toBe(false);
466
+ expect(platform.sendText).toHaveBeenCalledWith(
467
+ "chat-card-fallback",
468
+ "生成中卡片发送失败,结果将以文本形式发送。",
469
+ );
470
+ expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
471
+ expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
472
+ });
473
+
474
+ it("sends the stopped notice only after the prompt generator exits", async () => {
475
+ const platform = mockPlatform("feishu");
476
+ setSessionPlatform(platform);
477
+ bindChatToSession("sid-stop-notice", "chat-stop-notice");
478
+ recordLastActiveChat("sid-stop-notice", "chat-stop-notice");
479
+
480
+ const closeSession = vi.fn();
481
+ let releasePrompt: (() => void) | undefined;
482
+ const adapter: ToolAdapter = {
483
+ displayName: "Claude Code",
484
+ sessionDescPrefix: "Claude Code Session:",
485
+ createSession: async () => ({ sessionId: "sid-stop-notice" }),
486
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
487
+ closeSession: async () => {},
488
+ prompt: async function* (
489
+ _sid: string,
490
+ _text: string,
491
+ _cwd: string,
492
+ signal?: AbortSignal,
493
+ options?: ToolPromptOptions,
494
+ ) {
495
+ const waitForStop = new Promise<void>((resolve) => {
496
+ releasePrompt = resolve;
497
+ signal?.addEventListener("abort", () => resolve(), { once: true });
498
+ });
499
+ options?.onSessionCreated?.(() => {
500
+ closeSession();
501
+ releasePrompt?.();
502
+ });
503
+ yield { type: "assistant", blocks: [{ type: "text", text: "partial answer" }] };
504
+ await waitForStop;
505
+ },
506
+ };
507
+ _setAdapterForToolForTest("claude", adapter);
508
+
509
+ const runPromise = runAgentSession(
510
+ "sid-stop-notice",
511
+ "prompt",
512
+ platform,
513
+ "chat-stop-notice",
514
+ Date.now(),
515
+ "claude",
516
+ );
517
+
518
+ await vi.waitFor(() => {
519
+ expect(activePrompts.get("sid-stop-notice")?.closeSession).toBeTypeOf("function");
520
+ });
521
+ expect(platform.sendText).not.toHaveBeenCalledWith("chat-stop-notice", "会话已停止。");
522
+
523
+ const ok = stopSession("sid-stop-notice");
524
+ expect(ok).toBe(true);
525
+ expect(closeSession).toHaveBeenCalledTimes(1);
526
+
527
+ await runPromise;
528
+
529
+ expect(platform.sendText).toHaveBeenCalledWith("chat-stop-notice", "会话已停止。");
530
+ expect(activePrompts.has("sid-stop-notice")).toBe(false);
531
+ });
532
+
399
533
  it("re-injects IM skill capabilities for each resumed Claude prompt", async () => {
400
534
  const platform = mockPlatform("feishu");
401
535
  setSessionPlatform(platform);
@@ -41,9 +41,13 @@ vi.mock("../stream-state.ts", () => ({
41
41
  import { stopSession } from "../session.ts";
42
42
  import { activePrompts } from "../session-chat-binding.ts";
43
43
 
44
- function seedRunningSession(sid: string, accumulated = "partial output"): AbortController {
44
+ function seedRunningSession(
45
+ sid: string,
46
+ accumulated = "partial output",
47
+ closeSession?: () => void,
48
+ ): AbortController {
45
49
  const controller = new AbortController();
46
- activePrompts.set(sid, { controller, stopped: false, startTime: Date.now() });
50
+ activePrompts.set(sid, { controller, stopped: false, startTime: Date.now(), closeSession });
47
51
  stateStore.set(sid, {
48
52
  sessionId: sid,
49
53
  status: "running",
@@ -123,4 +127,14 @@ describe("stopSession 行为护栏", () => {
123
127
  stopSession("sid-C");
124
128
  expect(activePrompts.get("sid-C")?.stopped).toBe(true);
125
129
  });
130
+
131
+ it("调用 adapter 提供的 closeSession 以主动关闭底层 SDK session", () => {
132
+ const closeSession = vi.fn();
133
+ seedRunningSession("sid-D", "partial output", closeSession);
134
+
135
+ const ok = stopSession("sid-D");
136
+
137
+ expect(ok).toBe(true);
138
+ expect(closeSession).toHaveBeenCalledTimes(1);
139
+ });
126
140
  });
@@ -778,8 +778,7 @@ export async function handleCommand(
778
778
  logTrace(tid, "BRANCH", { cmd: "/stop" });
779
779
  if (stopSession(sessionId)) {
780
780
  console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
781
- await platform.sendText(chatId, "会话已停止。").catch(() => {});
782
- logTrace(tid, "DONE", { outcome: "stopped" });
781
+ logTrace(tid, "DONE", { outcome: "stop_requested" });
783
782
  } else {
784
783
  await platform
785
784
  .sendText(chatId, "当前没有正在进行的会话。")
package/src/session.ts CHANGED
@@ -81,6 +81,36 @@ async function sendFinalReplyTextOnce(
81
81
  return sent;
82
82
  }
83
83
 
84
+ async function createVisibleProgressCard(
85
+ platform: PlatformAdapter,
86
+ chatId: string,
87
+ sessionId: string,
88
+ turnCount: number,
89
+ notifyFailureText?: string,
90
+ ): Promise<string | null> {
91
+ for (let attempt = 1; attempt <= 2; attempt++) {
92
+ let cardId: string | null = null;
93
+ try {
94
+ cardId = await platform.cardCreate(
95
+ buildProgressCard("", { showStop: true, headerTitle: "生成中..." }),
96
+ );
97
+ if (!cardId) throw new Error("empty card id");
98
+ await platform.cardSend(chatId, cardId);
99
+ await addCardToTurn(sessionId, turnCount, cardId);
100
+ return cardId;
101
+ } catch (err) {
102
+ console.error(
103
+ `[${ts()}] [DISPLAY] progress card send attempt ${attempt} failed: chatId=${chatId} cardId=${cardId || "(none)"} ${(err as Error).message}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ if (notifyFailureText) {
109
+ await platform.sendText(chatId, notifyFailureText).catch(() => {});
110
+ }
111
+ return null;
112
+ }
113
+
84
114
  // ---------------------------------------------------------------------------
85
115
  // Shared state (imported by index.ts)
86
116
  // ---------------------------------------------------------------------------
@@ -1053,11 +1083,14 @@ export async function runAgentSession(
1053
1083
  if (displayChatIdForNew) {
1054
1084
  const ppNew = platformForChat(displayChatIdForNew);
1055
1085
  if (ppNew && ppNew.kind !== "wechat") {
1056
- const cardId = await ppNew.cardCreate(
1057
- buildProgressCard("", { showStop: true, headerTitle: "生成中..." }),
1058
- ).catch(() => null);
1086
+ const cardId = await createVisibleProgressCard(
1087
+ ppNew,
1088
+ displayChatIdForNew,
1089
+ sessionId,
1090
+ nextTurnCount,
1091
+ "生成中卡片发送失败,结果将以文本形式发送。",
1092
+ );
1059
1093
  if (cardId) {
1060
- await ppNew.cardSend(displayChatIdForNew, cardId).catch(() => null);
1061
1094
  displayCards.set(displayChatIdForNew, {
1062
1095
  cardId,
1063
1096
  sequence: 1,
@@ -1069,7 +1102,6 @@ export async function runAgentSession(
1069
1102
  turnCount: nextTurnCount,
1070
1103
  dotCount: 0,
1071
1104
  });
1072
- addCardToTurn(sessionId, nextTurnCount, cardId).catch(() => {});
1073
1105
  }
1074
1106
  } else if (ppNew && ppNew.kind === "wechat") {
1075
1107
  // WeChat: 无卡片,但需要 display entry 追踪已发送内容
@@ -1247,7 +1279,10 @@ export async function runAgentSession(
1247
1279
  });
1248
1280
  }
1249
1281
  const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1250
- if (active1) platform.setChatAvatar(active1, tool, "idle").catch(() => {});
1282
+ if (active1) {
1283
+ await platform.sendText(active1, "会话已停止。").catch(() => {});
1284
+ platform.setChatAvatar(active1, tool, "idle").catch(() => {});
1285
+ }
1251
1286
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1252
1287
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1253
1288
  } else if (wasAbnormalExit) {
@@ -1281,7 +1316,14 @@ export async function runAgentSession(
1281
1316
  });
1282
1317
  }
1283
1318
  const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1284
- if (active2) platform.setChatAvatar(active2, tool, "idle").catch(() => {});
1319
+ if (active2) {
1320
+ const terminalState = await readStreamState(sessionId);
1321
+ if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1322
+ const pp = platformForChat(active2) ?? platform;
1323
+ await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
1324
+ }
1325
+ platform.setChatAvatar(active2, tool, "idle").catch(() => {});
1326
+ }
1285
1327
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1286
1328
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1287
1329
  }
@@ -1479,28 +1521,33 @@ export function startUnifiedDisplayLoop(): void {
1479
1521
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1480
1522
  display.cardBusy = true;
1481
1523
  try {
1524
+ const newCardId = await createVisibleProgressCard(
1525
+ p,
1526
+ chatId,
1527
+ sessionId,
1528
+ display.turnCount,
1529
+ display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
1530
+ );
1531
+ if (!newCardId) {
1532
+ display.streamErrorNotified = true;
1533
+ continue;
1534
+ }
1482
1535
  const oldSeqBase = display.sequence;
1483
1536
  const oldContent = state.accumulatedContent + state.finalReply;
1484
1537
  const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
1485
- await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(err => {
1538
+ await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
1539
+ display.sequence = oldSeqBase + 1;
1540
+ }).catch(err => {
1486
1541
  console.error(`[${ts()}] [DISPLAY] rotation old cardUpdate failed: ${(err as Error).message}`);
1487
1542
  });
1488
1543
  markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
1489
- const newCardId = await p.cardCreate(buildProgressCard(
1490
- "",
1491
- { showStop: true, headerTitle: "生成中..." },
1492
- ));
1493
- if (newCardId) {
1494
- await p.cardSend(chatId, newCardId);
1495
- addCardToTurn(sessionId, display.turnCount, newCardId).catch(() => {});
1496
- display.cardId = newCardId;
1497
- display.sequence = 1;
1498
- display.cardCreatedAt = Date.now();
1499
- display.rotationAccLen = state.accumulatedContent.length;
1500
- display.rotationFinalReply = state.finalReply;
1501
- display.lastSentContent = "";
1502
- display.streamErrorNotified = false;
1503
- }
1544
+ display.cardId = newCardId;
1545
+ display.sequence = 1;
1546
+ display.cardCreatedAt = Date.now();
1547
+ display.rotationAccLen = state.accumulatedContent.length;
1548
+ display.rotationFinalReply = state.finalReply;
1549
+ display.lastSentContent = "";
1550
+ display.streamErrorNotified = false;
1504
1551
  } catch (err) {
1505
1552
  console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
1506
1553
  } finally {
@@ -1623,6 +1670,11 @@ export function stopSession(sessionId: string): boolean {
1623
1670
  prompt.stopped = true;
1624
1671
  clearPromptProcessMonitor(sessionId);
1625
1672
  cancelQueuedMessage(sessionId);
1673
+ try {
1674
+ prompt.closeSession?.();
1675
+ } catch (err) {
1676
+ console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${(err as Error).message}`);
1677
+ }
1626
1678
  prompt.controller.abort();
1627
1679
 
1628
1680
  // 强制杀死 CLI 子进程。controller.abort() 只在 for-await 收到下一条