chatccc 0.2.237 → 0.2.239

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.237",
3
+ "version": "0.2.239",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -56,6 +56,42 @@ afterEach(() => {
56
56
  });
57
57
 
58
58
  describe("ChatSession context management", () => {
59
+ it("keeps the generalized evidence gate in the stable system prompt prefix", async () => {
60
+ const { ChatSession } = await import("../builtin/index.ts");
61
+ const dir = await mkdtemp(join(tmpdir(), "deepccc-session-evidence-gate-"));
62
+ await writeFile(join(dir, "AGENTS.md"), "PROJECT GUIDANCE MARKER", "utf-8");
63
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
64
+
65
+ const session = new ChatSession(
66
+ { apiKey: "sk-test" },
67
+ {
68
+ cwd: dir,
69
+ sessionId: "evidence-gate",
70
+ systemPrompt: "CUSTOM PROMPT MARKER",
71
+ },
72
+ );
73
+ await collect(session.chat("diagnose a consequential problem"));
74
+
75
+ const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
76
+ expect(system).toContain("## Evidence-Gated Conclusions");
77
+ expect(system).toContain("source of truth");
78
+ expect(system).toContain("direct observations from inferences");
79
+ expect(system).toContain("plausible alternative explanations");
80
+ expect(system).toContain("runtime behavior for runtime claims");
81
+ expect(system).toContain("state uncertainty");
82
+ expect(system).toContain("Do not repeat checks once decisive evidence exists");
83
+ expect(system).not.toContain("CodesForUnity");
84
+ expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
85
+ system.indexOf("PROJECT GUIDANCE MARKER"),
86
+ );
87
+ expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
88
+ system.indexOf("Current working directory"),
89
+ );
90
+ expect(system.indexOf("## Evidence-Gated Conclusions")).toBeLessThan(
91
+ system.indexOf("CUSTOM PROMPT MARKER"),
92
+ );
93
+ });
94
+
59
95
  it("injects cwd project instruction files before runtime workspace details", async () => {
60
96
  const { ChatSession } = await import("../builtin/index.ts");
61
97
  const dir = await mkdtemp(join(tmpdir(), "deepccc-session-instructions-"));
@@ -131,9 +131,11 @@ import {
131
131
  stopUnifiedDisplayLoop,
132
132
  _setProcessAliveForTest,
133
133
  _resetProcessAliveForTest,
134
- _setProcessMonitorIntervalForTest,
135
- _resetProcessMonitorIntervalForTest,
136
- _setResponseStallTimeoutForTest,
134
+ _setProcessMonitorIntervalForTest,
135
+ _resetProcessMonitorIntervalForTest,
136
+ _setAvatarRefreshIntervalForTest,
137
+ _resetAvatarRefreshIntervalForTest,
138
+ _setResponseStallTimeoutForTest,
137
139
  _resetResponseStallTimeoutForTest,
138
140
  _setResponseStallCheckIntervalForTest,
139
141
  _resetResponseStallCheckIntervalForTest,
@@ -436,7 +438,7 @@ describe("runAgentSession previous final delivery guard", () => {
436
438
  });
437
439
  });
438
440
 
439
- describe("runAgentSession process monitor", () => {
441
+ describe("runAgentSession process monitor", () => {
440
442
  let registryFile = "";
441
443
  let toolsFile = "";
442
444
 
@@ -804,7 +806,99 @@ describe("runAgentSession process monitor", () => {
804
806
  expect(sentTexts[0]).toContain("first prompt");
805
807
  expect(sentTexts[1]).toContain("second prompt");
806
808
  });
807
- });
809
+ });
810
+
811
+ describe("runAgentSession periodic avatar refresh", () => {
812
+ let registryFile = "";
813
+ let toolsFile = "";
814
+
815
+ beforeEach(async () => {
816
+ vi.useFakeTimers();
817
+ resetState();
818
+ resetBindingState();
819
+ mockStreamStates.clear();
820
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-avatar-refresh-"));
821
+ registryFile = join(dir, "session-registry.json");
822
+ toolsFile = join(dir, "session-tools.json");
823
+ _setSessionRegistryFileForTest(registryFile);
824
+ _setSessionToolsFileForTest(toolsFile);
825
+ _setAvatarRefreshIntervalForTest(50);
826
+ });
827
+
828
+ afterEach(async () => {
829
+ _resetSessionRegistryFileForTest();
830
+ _resetSessionToolsFileForTest();
831
+ _resetAvatarRefreshIntervalForTest();
832
+ _clearAdapterCacheForTest();
833
+ resetState();
834
+ resetBindingState();
835
+ vi.useRealTimers();
836
+ if (registryFile) await rm(dirname(registryFile), { recursive: true, force: true });
837
+ });
838
+
839
+ it.each(["codex", "cursor", "claude", "ccc"])(
840
+ "refreshes the busy avatar for %s every interval and stops after completion",
841
+ async (tool) => {
842
+ const sessionId = `sid-avatar-${tool}`;
843
+ const chatId = `chat-avatar-${tool}`;
844
+ const platform = mockPlatform("feishu");
845
+ setSessionPlatform(platform);
846
+ bindChatToSession(sessionId, chatId);
847
+ recordLastActiveChat(sessionId, chatId);
848
+
849
+ let finishPrompt: (() => void) | undefined;
850
+ const adapter: ToolAdapter = {
851
+ displayName: tool,
852
+ sessionDescPrefix: `${tool} Session:`,
853
+ createSession: async () => ({ sessionId }),
854
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "/tmp" }),
855
+ closeSession: async () => {},
856
+ prompt: async function* () {
857
+ yield { type: "assistant", blocks: [{ type: "text", text: "working" }] };
858
+ await new Promise<void>((resolve) => {
859
+ finishPrompt = resolve;
860
+ });
861
+ yield { type: "assistant", blocks: [{ type: "text", text: "done" }] };
862
+ },
863
+ };
864
+ _setAdapterForToolForTest(tool, adapter);
865
+
866
+ const runPromise = runAgentSession(
867
+ sessionId,
868
+ "prompt",
869
+ platform,
870
+ chatId,
871
+ Date.now(),
872
+ tool,
873
+ );
874
+
875
+ await vi.waitFor(() => {
876
+ expect(vi.mocked(platform.setChatAvatar).mock.calls.some(
877
+ ([calledChatId, calledTool, status]) =>
878
+ calledChatId === chatId && calledTool === tool && status === "busy",
879
+ )).toBe(true);
880
+ expect(finishPrompt).toBeTypeOf("function");
881
+ });
882
+ const initialBusyCalls = vi.mocked(platform.setChatAvatar).mock.calls.filter(
883
+ ([calledChatId, calledTool, status]) =>
884
+ calledChatId === chatId && calledTool === tool && status === "busy",
885
+ ).length;
886
+
887
+ await vi.advanceTimersByTimeAsync(51);
888
+ expect(vi.mocked(platform.setChatAvatar).mock.calls.filter(
889
+ ([calledChatId, calledTool, status]) =>
890
+ calledChatId === chatId && calledTool === tool && status === "busy",
891
+ )).toHaveLength(initialBusyCalls + 1);
892
+
893
+ finishPrompt?.();
894
+ await runPromise;
895
+ const callsAfterCompletion = vi.mocked(platform.setChatAvatar).mock.calls.length;
896
+
897
+ await vi.advanceTimersByTimeAsync(100);
898
+ expect(platform.setChatAvatar).toHaveBeenCalledTimes(callsAfterCompletion);
899
+ },
900
+ );
901
+ });
808
902
 
809
903
  describe("runAgentSession response stall watchdog", () => {
810
904
  let tempDir = "";
@@ -42,9 +42,17 @@ const SYSTEM_PROMPT = [
42
42
  "- Respond in the user's language unless they ask otherwise.",
43
43
  "- Prefer direct, usable answers and concrete actions over long explanations.",
44
44
  "- For code tasks, inspect the relevant files before editing and verify with tests or checks when practical.",
45
- "- Preserve user work. Do not overwrite concurrent changes unless the user explicitly asks.",
46
- "- Keep immutable platform rules above project guidance and runtime details.",
47
- ].join("\n");
45
+ "- Preserve user work. Do not overwrite concurrent changes unless the user explicitly asks.",
46
+ "- Keep immutable platform rules above project guidance and runtime details.",
47
+ "",
48
+ "## Evidence-Gated Conclusions",
49
+ "- Apply this gate before consequential claims or actions that could affect code, data, deployments, or user decisions, and whenever the available evidence is indirect.",
50
+ "- Identify the claim and its authoritative source of truth. Separate direct observations from inferences, and test plausible alternative explanations before choosing one.",
51
+ "- Use the strongest practical decisive check at the same semantic level as the claim: runtime behavior for runtime claims, effective configuration for configuration claims, deployed state for deployment claims, and transformed output for transformation claims.",
52
+ "- Do not treat proxy signals such as names, timestamps, file sizes, line counts, partial samples, or a clean command exit as decisive when a direct check is practical.",
53
+ "- Use definitive language only after the evidence closes the loop. Otherwise state uncertainty, identify the missing evidence, and name the next check.",
54
+ "- Do not repeat checks once decisive evidence exists.",
55
+ ].join("\n");
48
56
 
49
57
  const SUMMARY_SYSTEM_PROMPT = [
50
58
  "You are DeepCCC's context compactor.",
@@ -111,6 +111,7 @@ export interface ActivePrompt {
111
111
  processPid?: number;
112
112
  processMonitor?: ReturnType<typeof setInterval>;
113
113
  responseStallMonitor?: ReturnType<typeof setInterval>;
114
+ avatarRefreshTimer?: ReturnType<typeof setInterval>;
114
115
  /** Grace timer that force-closes a stream which stays open after its authoritative final event. */
115
116
  finalResponseCloseTimer?: ReturnType<typeof setTimeout>;
116
117
  /** Character-count progress observed only while the activity is "responding". */
package/src/session.ts CHANGED
@@ -174,8 +174,9 @@ function platformForChat(chatId: string): PlatformAdapter | null {
174
174
  return chatPlatformMap.get(chatId) ?? platformRef;
175
175
  }
176
176
 
177
- const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
178
- const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
177
+ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
178
+ const DEFAULT_AVATAR_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
179
+ const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
179
180
  const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
180
181
  const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
181
182
  export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
@@ -184,8 +185,9 @@ export const RESPONSE_STALL_RECOVERY_NOTICE =
184
185
  export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
185
186
  "⚠️ 自动续跑仍连续 3 分钟没有生成新回复,本次不再自动继续。";
186
187
  const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
187
- let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
188
- let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
188
+ let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
189
+ let avatarRefreshIntervalMs = DEFAULT_AVATAR_REFRESH_INTERVAL_MS;
190
+ let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
189
191
  let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
190
192
  let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
191
193
  let isProcessAliveImpl = (pid: number): boolean => {
@@ -216,9 +218,17 @@ export function _setProcessMonitorIntervalForTest(ms: number): void {
216
218
  processMonitorIntervalMs = ms;
217
219
  }
218
220
 
219
- export function _resetProcessMonitorIntervalForTest(): void {
220
- processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
221
- }
221
+ export function _resetProcessMonitorIntervalForTest(): void {
222
+ processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
223
+ }
224
+
225
+ export function _setAvatarRefreshIntervalForTest(ms: number): void {
226
+ avatarRefreshIntervalMs = ms;
227
+ }
228
+
229
+ export function _resetAvatarRefreshIntervalForTest(): void {
230
+ avatarRefreshIntervalMs = DEFAULT_AVATAR_REFRESH_INTERVAL_MS;
231
+ }
222
232
 
223
233
  export function _setResponseStallTimeoutForTest(ms: number): void {
224
234
  responseStallTimeoutMs = ms;
@@ -251,14 +261,21 @@ function clearPromptProcessMonitor(sessionId: string): void {
251
261
  prompt.processMonitor = undefined;
252
262
  }
253
263
 
254
- function clearPromptResponseStallMonitor(sessionId: string): void {
255
- const prompt = activePrompts.get(sessionId);
256
- if (!prompt?.responseStallMonitor) return;
257
- clearInterval(prompt.responseStallMonitor);
258
- prompt.responseStallMonitor = undefined;
259
- }
260
-
261
- function clearPromptFinalResponseCloseTimer(sessionId: string): void {
264
+ function clearPromptResponseStallMonitor(sessionId: string): void {
265
+ const prompt = activePrompts.get(sessionId);
266
+ if (!prompt?.responseStallMonitor) return;
267
+ clearInterval(prompt.responseStallMonitor);
268
+ prompt.responseStallMonitor = undefined;
269
+ }
270
+
271
+ function clearPromptAvatarRefreshTimer(sessionId: string): void {
272
+ const prompt = activePrompts.get(sessionId);
273
+ if (!prompt?.avatarRefreshTimer) return;
274
+ clearInterval(prompt.avatarRefreshTimer);
275
+ prompt.avatarRefreshTimer = undefined;
276
+ }
277
+
278
+ function clearPromptFinalResponseCloseTimer(sessionId: string): void {
262
279
  const prompt = activePrompts.get(sessionId);
263
280
  if (!prompt?.finalResponseCloseTimer) return;
264
281
  clearTimeout(prompt.finalResponseCloseTimer);
@@ -513,10 +530,11 @@ export function resetState(): void {
513
530
  clearFeishuMessageLedgerMemory();
514
531
  lastMsgTimestamps.clear();
515
532
  chatPlatformMap.clear();
516
- for (const prompt of activePrompts.values()) {
517
- if (prompt.processMonitor) clearInterval(prompt.processMonitor);
518
- if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
519
- if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
533
+ for (const prompt of activePrompts.values()) {
534
+ if (prompt.processMonitor) clearInterval(prompt.processMonitor);
535
+ if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
536
+ if (prompt.avatarRefreshTimer) clearInterval(prompt.avatarRefreshTimer);
537
+ if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
520
538
  }
521
539
  activePrompts.clear();
522
540
  displayCards.clear();
@@ -582,7 +600,7 @@ export function getEffectiveFastModeForTool(tool: string, sessionId?: string): b
582
600
  return config.codex.fastMode;
583
601
  }
584
602
 
585
- function setSessionChatAvatar(
603
+ function setSessionChatAvatar(
586
604
  platform: PlatformAdapter,
587
605
  chatId: string,
588
606
  tool: string,
@@ -591,8 +609,59 @@ function setSessionChatAvatar(
591
609
  ): Promise<void> {
592
610
  return getEffectiveFastModeForTool(tool, sessionId)
593
611
  ? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
594
- : platform.setChatAvatar(chatId, tool, status);
595
- }
612
+ : platform.setChatAvatar(chatId, tool, status);
613
+ }
614
+
615
+ async function refreshBusySessionAvatar(
616
+ sessionId: string,
617
+ tool: string,
618
+ fallbackPlatform: PlatformAdapter,
619
+ ): Promise<void> {
620
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
621
+ if (!chatId) return;
622
+ const platform = platformForChat(chatId) ?? fallbackPlatform;
623
+ await setSessionChatAvatar(platform, chatId, tool, "busy", sessionId);
624
+ }
625
+
626
+ function startPromptAvatarRefresh(
627
+ sessionId: string,
628
+ tool: string,
629
+ fallbackPlatform: PlatformAdapter,
630
+ runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
631
+ ): void {
632
+ let refreshInFlight = false;
633
+ const timer = setInterval(() => {
634
+ const current = activePrompts.get(sessionId);
635
+ if (!current || current !== runningPrompt) {
636
+ clearInterval(timer);
637
+ if (runningPrompt.avatarRefreshTimer === timer) {
638
+ runningPrompt.avatarRefreshTimer = undefined;
639
+ }
640
+ return;
641
+ }
642
+ if (
643
+ refreshInFlight
644
+ || current.stopped
645
+ || current.abnormalExit
646
+ || current.resourceStuck
647
+ || current.autoEnded
648
+ || current.finalResponseObserved
649
+ ) {
650
+ return;
651
+ }
652
+
653
+ refreshInFlight = true;
654
+ void refreshBusySessionAvatar(sessionId, tool, fallbackPlatform)
655
+ .catch((err) => {
656
+ console.warn(`[${ts()}] [AVATAR] Periodic refresh failed for ${sessionId}: ${(err as Error).message}`);
657
+ })
658
+ .finally(() => {
659
+ refreshInFlight = false;
660
+ });
661
+ }, avatarRefreshIntervalMs);
662
+ timer.unref?.();
663
+ runningPrompt.avatarRefreshTimer = timer;
664
+ }
596
665
 
597
666
  /** 为指定 session 设置模型覆盖(/model <name>) */
598
667
  export function setSessionModelOverride(sessionId: string, model: string): void {
@@ -1435,10 +1504,7 @@ export async function runAgentSession(
1435
1504
  }
1436
1505
 
1437
1506
  // 设置最后活跃群头像为 busy
1438
- const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1439
- if (activeCid) {
1440
- setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1441
- }
1507
+ refreshBusySessionAvatar(sessionId, tool, platform).catch(() => {});
1442
1508
 
1443
1509
  const state: AccumulatorState = {
1444
1510
  accumulatedContent: "",
@@ -1456,6 +1522,8 @@ export async function runAgentSession(
1456
1522
 
1457
1523
  const runningPrompt = activePrompts.get(sessionId);
1458
1524
  if (runningPrompt) {
1525
+ startPromptAvatarRefresh(sessionId, tool, platform, runningPrompt);
1526
+
1459
1527
  // 在消费第一个事件前建立阶段感知的零字符基线;启动阶段不计时,只有后续
1460
1528
  // 收到明确的 responding 状态后才会启动三分钟回复停滞保护。
1461
1529
  runningPrompt.responseProgress = observeResponseProgress(
@@ -1642,9 +1710,10 @@ export async function runAgentSession(
1642
1710
  const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1643
1711
  const wasAutoRecovery = prompt?.autoRecovery ?? false;
1644
1712
  const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1645
- clearPromptResponseStallMonitor(sessionId);
1646
- clearPromptProcessMonitor(sessionId);
1647
- clearPromptFinalResponseCloseTimer(sessionId);
1713
+ clearPromptResponseStallMonitor(sessionId);
1714
+ clearPromptProcessMonitor(sessionId);
1715
+ clearPromptAvatarRefreshTimer(sessionId);
1716
+ clearPromptFinalResponseCloseTimer(sessionId);
1648
1717
  markSessionFinalizing(sessionId);
1649
1718
  activePrompts.delete(sessionId);
1650
1719
 
@@ -2351,10 +2420,11 @@ export function stopSession(sessionId: string): boolean {
2351
2420
  }
2352
2421
  return false;
2353
2422
  }
2354
- prompt.stopped = true;
2355
- clearPromptResponseStallMonitor(sessionId);
2356
- clearPromptProcessMonitor(sessionId);
2357
- clearPromptFinalResponseCloseTimer(sessionId);
2423
+ prompt.stopped = true;
2424
+ clearPromptResponseStallMonitor(sessionId);
2425
+ clearPromptProcessMonitor(sessionId);
2426
+ clearPromptAvatarRefreshTimer(sessionId);
2427
+ clearPromptFinalResponseCloseTimer(sessionId);
2358
2428
  cancelQueuedMessage(sessionId);
2359
2429
 
2360
2430
  // 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由