chatccc 0.2.153 → 0.2.155

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.153",
3
+ "version": "0.2.155",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -398,10 +398,10 @@ describe("runAgentSession process monitor", () => {
398
398
  });
399
399
  });
400
400
 
401
- describe("unified display loop WeChat delta", () => {
402
- beforeEach(() => {
403
- vi.useFakeTimers();
404
- resetState();
401
+ describe("unified display loop WeChat delta", () => {
402
+ beforeEach(() => {
403
+ vi.useFakeTimers();
404
+ resetState();
405
405
  resetBindingState();
406
406
  mockStreamStates.clear();
407
407
  });
@@ -464,12 +464,79 @@ describe("unified display loop WeChat delta", () => {
464
464
  2,
465
465
  "chat-wechat",
466
466
  "tool output",
467
- );
468
- });
469
- });
470
-
471
- describe("rebuildBindingsFromRegistry", () => {
472
- let registryFile = "";
467
+ );
468
+ });
469
+ });
470
+
471
+ describe("unified display loop terminal card update", () => {
472
+ beforeEach(() => {
473
+ vi.useFakeTimers();
474
+ resetState();
475
+ resetBindingState();
476
+ mockStreamStates.clear();
477
+ });
478
+
479
+ afterEach(() => {
480
+ stopUnifiedDisplayLoop();
481
+ resetBindingState();
482
+ vi.useRealTimers();
483
+ });
484
+
485
+ it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
486
+ const platform = mockPlatform("feishu");
487
+ platform.cardUpdate = vi.fn(async () => {
488
+ throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
489
+ });
490
+ setSessionPlatform(platform);
491
+
492
+ bindChatToSession("sid-terminal", "chat-terminal");
493
+ recordLastActiveChat("sid-terminal", "chat-terminal");
494
+ sessionInfoMap.set("chat-terminal", {
495
+ sessionId: "sid-terminal",
496
+ turnCount: 1,
497
+ lastContextTokens: 0,
498
+ startTime: 0,
499
+ tool: "claude",
500
+ });
501
+ activePrompts.set("sid-terminal", {
502
+ controller: new AbortController(),
503
+ stopped: false,
504
+ startTime: Date.now(),
505
+ });
506
+ displayCards.set("chat-terminal", {
507
+ cardId: "card-terminal",
508
+ sequence: 109,
509
+ cardBusy: false,
510
+ cardCreatedAt: Date.now(),
511
+ lastSentContent: "",
512
+ streamErrorNotified: false,
513
+ sessionId: "sid-terminal",
514
+ turnCount: 1,
515
+ dotCount: 0,
516
+ });
517
+ mockStreamStates.set("sid-terminal", {
518
+ accumulatedContent: "partial tool output",
519
+ finalReply: "",
520
+ status: "stopped",
521
+ turnCount: 1,
522
+ });
523
+
524
+ startUnifiedDisplayLoop();
525
+ await vi.advanceTimersByTimeAsync(3000);
526
+ await vi.advanceTimersByTimeAsync(3000);
527
+
528
+ expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
529
+ expect(platform.cardUpdate).toHaveBeenCalledWith(
530
+ "card-terminal",
531
+ expect.any(String),
532
+ 110,
533
+ );
534
+ expect(displayCards.get("chat-terminal")?.sequence).toBe(110);
535
+ });
536
+ });
537
+
538
+ describe("rebuildBindingsFromRegistry", () => {
539
+ let registryFile = "";
473
540
 
474
541
  beforeEach(async () => {
475
542
  chatSessionMap.clear();
@@ -10,9 +10,6 @@ export const AGENT_STOP_STUCK_PATH = "/api/agent/stop-stuck-loop";
10
10
 
11
11
  const MAX_REQUEST_BYTES = 8 * 1024;
12
12
 
13
- /** 已处理过 stop-stuck-loop 的 session,防止 agent 循环中反复调用 */
14
- const processedSessions = new Set<string>();
15
-
16
13
  function jsonReply(res: ServerResponse, status: number, data: unknown): void {
17
14
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
18
15
  res.end(JSON.stringify(data));
@@ -68,19 +65,10 @@ export async function handleAgentStopStuckRequest(
68
65
 
69
66
  const prompt = activePrompts.get(sessionId);
70
67
  if (!prompt) {
71
- // session 可能已被清理,清理去重记录
72
- processedSessions.delete(sessionId);
73
68
  jsonReply(res, 404, { error: "Session not found or not running" });
74
69
  return true;
75
70
  }
76
71
 
77
- // 去重:同一 session 只处理一次,防止 agent 循环中反复调用
78
- if (processedSessions.has(sessionId)) {
79
- jsonReply(res, 200, { ok: true, deduplicated: true });
80
- return true;
81
- }
82
- processedSessions.add(sessionId);
83
-
84
72
  // 先发"卡住"提示消息给所有绑定的群聊
85
73
  const chats = getChatsForSession(sessionId);
86
74
  for (const chatId of chats) {
@@ -106,6 +94,7 @@ export async function handleAgentStopStuckRequest(
106
94
  await writeStreamState({
107
95
  ...current,
108
96
  status: "done",
97
+ stuckAt: Date.now(),
109
98
  finalReply: finalReply ? current.finalReply + "\n\n" + finalReply : current.finalReply,
110
99
  updatedAt: Date.now(),
111
100
  });
@@ -78,6 +78,7 @@ import {
78
78
  cancelQueuedMessage,
79
79
  } from "./session-chat-binding.ts";
80
80
  import { getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
81
+ import { readStreamState } from "./stream-state.ts";
81
82
  export { type PlatformAdapter } from "./platform-adapter.ts";
82
83
  import type { PlatformAdapter } from "./platform-adapter.ts";
83
84
 
@@ -593,10 +594,12 @@ export async function handleCommand(
593
594
 
594
595
  if (sessionId && descriptionTool && toolLabel) {
595
596
  // 有会话上下文 — 路由到命令处理或 prompt
596
- logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
597
- console.log(
598
- `[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`,
599
- );
597
+ logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
598
+ const routeKind = textLower.startsWith("/") ? "command" : "prompt";
599
+ const chatKind = chatType === "p2p" ? "p2p chat" : "session group";
600
+ console.log(
601
+ `[${ts()}] [ROUTE] ${toolLabel} ${chatKind} ${routeKind} detected, session=${sessionId} tool=${descriptionTool}`,
602
+ );
600
603
 
601
604
  if (
602
605
  chatType !== "p2p" &&
@@ -1228,6 +1231,27 @@ export async function handleCommand(
1228
1231
  await platform.sendText(chatId, "生成中...").catch(() => {});
1229
1232
  }
1230
1233
 
1234
+ // 检查 session 是否被 stop-stuck-loop 标记为卡住,是则创建新 session
1235
+ const stuckState = await readStreamState(sessionId);
1236
+ if (stuckState?.stuckAt) {
1237
+ try {
1238
+ const init = await initClaudeSession(descriptionTool, undefined, chatId);
1239
+ const newSessionId = init.sessionId;
1240
+ unbindChatFromSession(sessionId, chatId);
1241
+ bindChatToSession(newSessionId, chatId);
1242
+ await recordSessionRegistry({
1243
+ chatId,
1244
+ sessionId: newSessionId,
1245
+ tool: descriptionTool,
1246
+ chatName: sessionChatName(text.slice(0, 10), init.cwd),
1247
+ }).catch(() => {});
1248
+ console.log(`[${ts()}] [STUCK-REDIRECT] Session ${sessionId} was stuck, created new session ${newSessionId}`);
1249
+ sessionId = newSessionId;
1250
+ } catch (err) {
1251
+ console.warn(`[${ts()}] [STUCK-REDIRECT] Failed to create new session: ${(err as Error).message}`);
1252
+ }
1253
+ }
1254
+
1231
1255
  try {
1232
1256
  logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
1233
1257
  await resumeAndPrompt(
@@ -1235,10 +1259,10 @@ export async function handleCommand(
1235
1259
  text,
1236
1260
  platform,
1237
1261
  chatId,
1238
- msgTimestamp,
1239
- descriptionTool,
1240
- tid,
1241
- );
1262
+ msgTimestamp,
1263
+ descriptionTool,
1264
+ tid,
1265
+ );
1242
1266
  logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
1243
1267
  console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
1244
1268
  } catch (err) {
@@ -1434,10 +1458,10 @@ export async function handleCommand(
1434
1458
  text,
1435
1459
  platform,
1436
1460
  newChatId,
1437
- msgTimestamp,
1438
- tool,
1439
- tid,
1440
- );
1461
+ msgTimestamp,
1462
+ tool,
1463
+ tid,
1464
+ );
1441
1465
  console.log(`[${ts()}] [AUTO-P2P 5/5] First prompt sent → OK`);
1442
1466
  logTrace(tid, "DONE", { outcome: "auto_new_p2p_prompt_done", newChatId, sessionId, tool });
1443
1467
  } catch (err) {
package/src/session.ts CHANGED
@@ -167,11 +167,15 @@ function formatTerminalHeader(status: "running" | "done" | "stopped" | "error"):
167
167
  return { title: "完成" };
168
168
  }
169
169
 
170
- function turnFinalStatus(status: "running" | "done" | "stopped" | "error"): "done" | "stopped" {
171
- return status === "stopped" || status === "error" ? "stopped" : "done";
172
- }
173
-
174
- function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): void {
170
+ function turnFinalStatus(status: "running" | "done" | "stopped" | "error"): "done" | "stopped" {
171
+ return status === "stopped" || status === "error" ? "stopped" : "done";
172
+ }
173
+
174
+ function isCardKitSequenceConflict(err: unknown): boolean {
175
+ return err instanceof Error && err.message.includes("300317");
176
+ }
177
+
178
+ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): void {
175
179
  const prompt = activePrompts.get(sessionId);
176
180
  if (!prompt) return;
177
181
  prompt.processPid = info.pid;
@@ -1356,24 +1360,42 @@ export function startUnifiedDisplayLoop(): void {
1356
1360
  displayCards.delete(chatId);
1357
1361
  } else {
1358
1362
  // 发送最终结果(卡片平台)
1359
- while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
1360
- const nextSeq = display.sequence + 1;
1363
+ while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
1364
+ const promptStillActive = activePrompts.has(sessionId);
1365
+ if (
1366
+ promptStillActive &&
1367
+ display.lastSentAccLen === state.accumulatedContent.length &&
1368
+ display.lastSentFinalReply === state.finalReply
1369
+ ) {
1370
+ continue;
1371
+ }
1372
+ const nextSeq = display.sequence + 1;
1361
1373
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
1362
1374
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1363
1375
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1364
- await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
1365
- console.error(`[${ts()}] [DISPLAY] terminal cardUpdate failed: ${(err as Error).message}`);
1366
- });
1376
+ let terminalCardUpdateAccepted = false;
1377
+ await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
1378
+ display.sequence = nextSeq;
1379
+ terminalCardUpdateAccepted = true;
1380
+ }).catch(err => {
1381
+ console.error(`[${ts()}] [DISPLAY] terminal cardUpdate failed: ${(err as Error).message}`);
1382
+ if (isCardKitSequenceConflict(err)) {
1383
+ display.sequence = nextSeq;
1384
+ terminalCardUpdateAccepted = true;
1385
+ }
1386
+ });
1367
1387
 
1368
1388
  // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1369
1389
  // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1370
1390
  // 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
1371
1391
  // 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
1372
- if (activePrompts.has(sessionId)) {
1373
- display.lastSentAccLen = state.accumulatedContent.length;
1374
- display.lastSentFinalReply = state.finalReply;
1375
- continue;
1376
- }
1392
+ if (promptStillActive) {
1393
+ if (terminalCardUpdateAccepted) {
1394
+ display.lastSentAccLen = state.accumulatedContent.length;
1395
+ display.lastSentFinalReply = state.finalReply;
1396
+ }
1397
+ continue;
1398
+ }
1377
1399
 
1378
1400
  const finalSt = turnFinalStatus(state.status);
1379
1401
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
@@ -26,6 +26,9 @@ export interface StreamState {
26
26
  updatedAt: number;
27
27
  cwd: string;
28
28
  tool: string;
29
+ /** Set by stop-stuck-loop to prevent the session from being resumed.
30
+ * The orchestrator checks this before resuming and creates a new session instead. */
31
+ stuckAt?: number;
29
32
  }
30
33
 
31
34
  function getStreamStatePath(sessionId: string): string {