chatccc 0.2.205 → 0.2.207
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/README.md +2 -2
- package/package.json +1 -1
- package/src/__tests__/cards.test.ts +3 -2
- package/src/__tests__/orchestrator.test.ts +144 -2
- package/src/__tests__/response-stall.test.ts +49 -0
- package/src/__tests__/session.test.ts +261 -27
- package/src/cards.ts +2 -2
- package/src/orchestrator.ts +178 -13
- package/src/response-stall.ts +28 -0
- package/src/session-chat-binding.ts +33 -12
- package/src/session.ts +276 -101
- package/src/stream-state.ts +5 -3
package/README.md
CHANGED
|
@@ -318,7 +318,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
318
318
|
| `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
|
|
319
319
|
| `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
|
|
320
320
|
| `*.enabled` | 是否启用对应 AI Agent |
|
|
321
|
-
| `*.defaultAgent` | `/new` 未指定 Agent
|
|
321
|
+
| `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具;飞书私聊会在下一条普通消息到达时跟随变化并创建新的空会话 |
|
|
322
322
|
| `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
|
|
323
323
|
| `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
|
|
324
324
|
| `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
|
|
@@ -330,7 +330,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
330
330
|
|
|
331
331
|
### 5. 开始使用
|
|
332
332
|
|
|
333
|
-
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC
|
|
333
|
+
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new codex`,机器人会另外创建会话群。
|
|
334
334
|
|
|
335
335
|
**微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
|
|
336
336
|
|
package/package.json
CHANGED
|
@@ -435,12 +435,13 @@ describe("buildSessionsCard", () => {
|
|
|
435
435
|
expect(parsed.elements[2].text.content).toContain("/session 数字");
|
|
436
436
|
});
|
|
437
437
|
|
|
438
|
-
it("explains
|
|
438
|
+
it("explains Feishu private-session default Agent following without advertising /session switching", () => {
|
|
439
439
|
const card = buildSessionsCard([
|
|
440
440
|
{ sessionId: "abc123", chatName: "飞书私聊", chatId: "ou_private", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
|
|
441
441
|
], { fixedPrivateSession: true });
|
|
442
442
|
const parsed = JSON.parse(card);
|
|
443
|
-
expect(parsed.elements[2].text.content).toContain("
|
|
443
|
+
expect(parsed.elements[2].text.content).toContain("下一条普通消息");
|
|
444
|
+
expect(parsed.elements[2].text.content).toContain("新空会话");
|
|
444
445
|
expect(parsed.elements[2].text.content).toContain("不支持 **/session** 切换");
|
|
445
446
|
expect(parsed.elements[2].text.content).not.toContain("/session 数字");
|
|
446
447
|
});
|
|
@@ -87,7 +87,7 @@ import {
|
|
|
87
87
|
resetState,
|
|
88
88
|
sessionInfoMap,
|
|
89
89
|
} from "../session.ts";
|
|
90
|
-
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
90
|
+
import { activePrompts, dequeueMessage, resetBindingState } from "../session-chat-binding.ts";
|
|
91
91
|
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
92
92
|
import { config } from "../config.ts";
|
|
93
93
|
|
|
@@ -342,6 +342,148 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
342
342
|
expect(registry["feishu-p2p"]?.sessionId).toBe("sid-feishu-private");
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
+
it("switches an idle Feishu p2p chat to a fresh session when the default Agent changes", async () => {
|
|
346
|
+
const platform = mockPlatform("feishu");
|
|
347
|
+
const oldPrompt = vi.fn(async function* () {
|
|
348
|
+
yield { type: "assistant" as const, blocks: [{ type: "text" as const, text: "old" }] };
|
|
349
|
+
});
|
|
350
|
+
_setAdapterForToolForTest("claude", {
|
|
351
|
+
...mockAdapter("sid-old-claude"),
|
|
352
|
+
prompt: oldPrompt,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const createCursorSession = vi.fn(async () => ({ sessionId: "sid-new-cursor" }));
|
|
356
|
+
const cursorPrompt = vi.fn(async function* () {
|
|
357
|
+
yield { type: "assistant" as const, blocks: [{ type: "text" as const, text: "new" }] };
|
|
358
|
+
});
|
|
359
|
+
_setAdapterForToolForTest("cursor", {
|
|
360
|
+
...mockAdapter("sid-new-cursor"),
|
|
361
|
+
displayName: "Cursor",
|
|
362
|
+
sessionDescPrefix: "Cursor Session:",
|
|
363
|
+
createSession: createCursorSession,
|
|
364
|
+
prompt: cursorPrompt,
|
|
365
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({ sessionId, cwd: homedir() }),
|
|
366
|
+
});
|
|
367
|
+
await recordSessionRegistry({
|
|
368
|
+
chatId: "feishu-p2p",
|
|
369
|
+
sessionId: "sid-old-claude",
|
|
370
|
+
tool: "claude",
|
|
371
|
+
chatType: "p2p",
|
|
372
|
+
chatName: "飞书私聊",
|
|
373
|
+
running: false,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const originalCursorEnabled = config.cursor.enabled;
|
|
377
|
+
try {
|
|
378
|
+
config.cursor.enabled = true;
|
|
379
|
+
config.claude.defaultAgent = false;
|
|
380
|
+
config.cursor.defaultAgent = true;
|
|
381
|
+
|
|
382
|
+
await handleCommand(platform, "/state", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
383
|
+
expect(createCursorSession).not.toHaveBeenCalled();
|
|
384
|
+
expect((await loadSessionRegistryForBinding())["feishu-p2p"]?.sessionId).toBe("sid-old-claude");
|
|
385
|
+
|
|
386
|
+
await handleCommand(platform, "使用新的默认 Agent", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
387
|
+
|
|
388
|
+
expect(createCursorSession).toHaveBeenCalledWith(homedir());
|
|
389
|
+
expect(oldPrompt).not.toHaveBeenCalled();
|
|
390
|
+
expect(cursorPrompt).toHaveBeenCalledWith(
|
|
391
|
+
"sid-new-cursor",
|
|
392
|
+
expect.stringContaining("使用新的默认 Agent"),
|
|
393
|
+
homedir(),
|
|
394
|
+
expect.any(AbortSignal),
|
|
395
|
+
expect.any(Object),
|
|
396
|
+
);
|
|
397
|
+
expect(platform.updateChatInfo).not.toHaveBeenCalled();
|
|
398
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
399
|
+
"feishu-p2p",
|
|
400
|
+
"默认 Agent 已切换",
|
|
401
|
+
expect.stringContaining("Claude Code → Cursor"),
|
|
402
|
+
"green",
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const registry = await loadSessionRegistryForBinding();
|
|
406
|
+
expect(registry["feishu-p2p"]).toMatchObject({
|
|
407
|
+
sessionId: "sid-new-cursor",
|
|
408
|
+
tool: "cursor",
|
|
409
|
+
chatType: "p2p",
|
|
410
|
+
turnCount: 1,
|
|
411
|
+
});
|
|
412
|
+
} finally {
|
|
413
|
+
config.cursor.enabled = originalCursorEnabled;
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("waits for a running Feishu p2p Agent before switching the queued message to the new default", async () => {
|
|
418
|
+
const platform = mockPlatform("feishu");
|
|
419
|
+
const createCursorSession = vi.fn(async () => ({ sessionId: "sid-cursor-after-wait" }));
|
|
420
|
+
const cursorPrompt = vi.fn(async function* () {
|
|
421
|
+
yield { type: "assistant" as const, blocks: [{ type: "text" as const, text: "new" }] };
|
|
422
|
+
});
|
|
423
|
+
_setAdapterForToolForTest("cursor", {
|
|
424
|
+
...mockAdapter("sid-cursor-after-wait"),
|
|
425
|
+
displayName: "Cursor",
|
|
426
|
+
sessionDescPrefix: "Cursor Session:",
|
|
427
|
+
createSession: createCursorSession,
|
|
428
|
+
prompt: cursorPrompt,
|
|
429
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({ sessionId, cwd: homedir() }),
|
|
430
|
+
});
|
|
431
|
+
await recordSessionRegistry({
|
|
432
|
+
chatId: "feishu-p2p-wait",
|
|
433
|
+
sessionId: "sid-running-claude",
|
|
434
|
+
tool: "claude",
|
|
435
|
+
chatType: "p2p",
|
|
436
|
+
chatName: "飞书私聊",
|
|
437
|
+
running: true,
|
|
438
|
+
});
|
|
439
|
+
activePrompts.set("sid-running-claude", {
|
|
440
|
+
controller: new AbortController(),
|
|
441
|
+
stopped: false,
|
|
442
|
+
startTime: Date.now(),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const originalCursorEnabled = config.cursor.enabled;
|
|
446
|
+
try {
|
|
447
|
+
config.cursor.enabled = true;
|
|
448
|
+
config.claude.defaultAgent = false;
|
|
449
|
+
config.cursor.defaultAgent = true;
|
|
450
|
+
|
|
451
|
+
await handleCommand(platform, "等当前回复完成后处理", "feishu-p2p-wait", "ou-user", Date.now(), "p2p");
|
|
452
|
+
|
|
453
|
+
expect(createCursorSession).not.toHaveBeenCalled();
|
|
454
|
+
const queued = dequeueMessage("sid-running-claude");
|
|
455
|
+
expect(queued?.text).toContain("等当前回复完成后处理");
|
|
456
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
457
|
+
"feishu-p2p-wait",
|
|
458
|
+
"Agent 切换等待中",
|
|
459
|
+
expect.stringContaining("完成后会切换到 Cursor"),
|
|
460
|
+
"blue",
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
activePrompts.delete("sid-running-claude");
|
|
464
|
+
await handleCommand(
|
|
465
|
+
platform,
|
|
466
|
+
queued!.text,
|
|
467
|
+
queued!.chatId,
|
|
468
|
+
queued!.openId,
|
|
469
|
+
queued!.msgTimestamp,
|
|
470
|
+
queued!.chatType,
|
|
471
|
+
queued!.traceId,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
expect(createCursorSession).toHaveBeenCalledTimes(1);
|
|
475
|
+
expect(cursorPrompt).toHaveBeenCalledWith(
|
|
476
|
+
"sid-cursor-after-wait",
|
|
477
|
+
expect.stringContaining("等当前回复完成后处理"),
|
|
478
|
+
homedir(),
|
|
479
|
+
expect.any(AbortSignal),
|
|
480
|
+
expect.any(Object),
|
|
481
|
+
);
|
|
482
|
+
} finally {
|
|
483
|
+
config.cursor.enabled = originalCursorEnabled;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
345
487
|
it("creates the first Feishu p2p session in the OS user directory and sends the first prompt in place", async () => {
|
|
346
488
|
const platform = mockPlatform("feishu");
|
|
347
489
|
const createSession = vi.fn(async () => ({ sessionId: "sid-feishu-private" }));
|
|
@@ -551,7 +693,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
551
693
|
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
552
694
|
"feishu-p2p",
|
|
553
695
|
"/session",
|
|
554
|
-
expect.stringContaining("
|
|
696
|
+
expect.stringContaining("下一条普通消息时跟随默认 Agent"),
|
|
555
697
|
"yellow",
|
|
556
698
|
);
|
|
557
699
|
expect(platform.updateChatInfo).not.toHaveBeenCalled();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
hasResponseStalled,
|
|
5
|
+
observeResponseProgress,
|
|
6
|
+
} from "../response-stall.ts";
|
|
7
|
+
|
|
8
|
+
describe("response stall detection", () => {
|
|
9
|
+
it("starts tracking while responding even when the total character count is zero", () => {
|
|
10
|
+
const observation = observeResponseProgress(undefined, true, 0, 1_000);
|
|
11
|
+
|
|
12
|
+
expect(observation).toEqual({
|
|
13
|
+
totalChars: 0,
|
|
14
|
+
unchangedSince: 1_000,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("auto-ends only after the same character count has lasted three minutes", () => {
|
|
19
|
+
const first = observeResponseProgress(undefined, true, 12, 1_000);
|
|
20
|
+
const unchanged = observeResponseProgress(first, true, 12, 90_000);
|
|
21
|
+
|
|
22
|
+
expect(unchanged).toBe(first);
|
|
23
|
+
expect(hasResponseStalled(unchanged, 180_999, 180_000)).toBe(false);
|
|
24
|
+
expect(hasResponseStalled(unchanged, 181_000, 180_000)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("restarts the timer whenever the total character count changes", () => {
|
|
28
|
+
const first = observeResponseProgress(undefined, true, 12, 1_000);
|
|
29
|
+
const changed = observeResponseProgress(first, true, 13, 150_000);
|
|
30
|
+
|
|
31
|
+
expect(changed).toEqual({
|
|
32
|
+
totalChars: 13,
|
|
33
|
+
unchangedSince: 150_000,
|
|
34
|
+
});
|
|
35
|
+
expect(hasResponseStalled(changed, 181_000, 180_000)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clears tracking outside responding and starts a fresh window after returning", () => {
|
|
39
|
+
const first = observeResponseProgress(undefined, true, 0, 1_000);
|
|
40
|
+
const cleared = observeResponseProgress(first, false, 0, 100_000);
|
|
41
|
+
const resumed = observeResponseProgress(cleared, true, 0, 200_000);
|
|
42
|
+
|
|
43
|
+
expect(cleared).toBeUndefined();
|
|
44
|
+
expect(resumed).toEqual({
|
|
45
|
+
totalChars: 0,
|
|
46
|
+
unchangedSince: 200_000,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const killProcessTreeMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
7
|
+
vi.mock("../adapters/proc-tree-kill.ts", () => ({
|
|
8
|
+
killProcessTree: killProcessTreeMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// mock stream-state 以支持在测试中控制累积长度
|
|
7
12
|
const mockStreamStates = new Map<string, {
|
|
8
13
|
accumulatedContent: string;
|
|
9
14
|
finalReply: string;
|
|
10
15
|
activity?: {
|
|
11
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
|
|
16
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
12
17
|
startedAt: number;
|
|
13
18
|
toolName?: string;
|
|
14
19
|
toolCount?: number;
|
|
15
20
|
};
|
|
16
|
-
status?: "running" | "done" | "stopped" | "error";
|
|
21
|
+
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
22
|
+
autoEndedAt?: number;
|
|
17
23
|
turnCount?: number;
|
|
18
24
|
finalReplySentTurn?: number;
|
|
19
25
|
finalReplySentAt?: number;
|
|
@@ -27,8 +33,9 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
27
33
|
accumulatedContent: state.accumulatedContent,
|
|
28
34
|
finalReply: state.finalReply,
|
|
29
35
|
activity: state.activity,
|
|
30
|
-
finalReplySentTurn: state.finalReplySentTurn,
|
|
31
|
-
finalReplySentAt: state.finalReplySentAt,
|
|
36
|
+
finalReplySentTurn: state.finalReplySentTurn,
|
|
37
|
+
finalReplySentAt: state.finalReplySentAt,
|
|
38
|
+
autoEndedAt: state.autoEndedAt,
|
|
32
39
|
status: state.status ?? "running",
|
|
33
40
|
chunkCount: 0,
|
|
34
41
|
turnCount: state.turnCount ?? 0,
|
|
@@ -43,12 +50,13 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
43
50
|
accumulatedContent: string;
|
|
44
51
|
finalReply: string;
|
|
45
52
|
activity?: {
|
|
46
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
|
|
53
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
47
54
|
startedAt: number;
|
|
48
55
|
toolName?: string;
|
|
49
56
|
toolCount?: number;
|
|
50
57
|
};
|
|
51
|
-
status?: "running" | "done" | "stopped" | "error";
|
|
58
|
+
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
59
|
+
autoEndedAt?: number;
|
|
52
60
|
turnCount?: number;
|
|
53
61
|
finalReplySentTurn?: number;
|
|
54
62
|
finalReplySentAt?: number;
|
|
@@ -57,7 +65,8 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
57
65
|
accumulatedContent: state.accumulatedContent,
|
|
58
66
|
finalReply: state.finalReply,
|
|
59
67
|
activity: state.activity,
|
|
60
|
-
status: state.status,
|
|
68
|
+
status: state.status,
|
|
69
|
+
autoEndedAt: state.autoEndedAt,
|
|
61
70
|
turnCount: state.turnCount,
|
|
62
71
|
finalReplySentTurn: state.finalReplySentTurn,
|
|
63
72
|
finalReplySentAt: state.finalReplySentAt,
|
|
@@ -109,6 +118,10 @@ import {
|
|
|
109
118
|
_resetProcessAliveForTest,
|
|
110
119
|
_setProcessMonitorIntervalForTest,
|
|
111
120
|
_resetProcessMonitorIntervalForTest,
|
|
121
|
+
_setResponseStallTimeoutForTest,
|
|
122
|
+
_resetResponseStallTimeoutForTest,
|
|
123
|
+
_setResponseStallCheckIntervalForTest,
|
|
124
|
+
_resetResponseStallCheckIntervalForTest,
|
|
112
125
|
setSessionEffortOverride,
|
|
113
126
|
} from "../session.ts";
|
|
114
127
|
import {
|
|
@@ -119,9 +132,12 @@ import {
|
|
|
119
132
|
getLastActiveChat,
|
|
120
133
|
pickDisplayChat,
|
|
121
134
|
resetBindingState,
|
|
122
|
-
getChatsForSession,
|
|
123
|
-
displayCards,
|
|
124
|
-
|
|
135
|
+
getChatsForSession,
|
|
136
|
+
displayCards,
|
|
137
|
+
enqueueMessage,
|
|
138
|
+
setQueueConsumer,
|
|
139
|
+
isSessionRunning,
|
|
140
|
+
} from "../session-chat-binding.ts";
|
|
125
141
|
import type { AccumulatorState } from "../session.ts";
|
|
126
142
|
import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
127
143
|
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
@@ -349,13 +365,15 @@ describe("runAgentSession process monitor", () => {
|
|
|
349
365
|
_resetSessionRegistryFileForTest();
|
|
350
366
|
_resetSessionToolsFileForTest();
|
|
351
367
|
_clearAdapterCacheForTest();
|
|
352
|
-
_resetProcessAliveForTest();
|
|
353
|
-
_resetProcessMonitorIntervalForTest();
|
|
368
|
+
_resetProcessAliveForTest();
|
|
369
|
+
_resetProcessMonitorIntervalForTest();
|
|
370
|
+
_resetResponseStallTimeoutForTest();
|
|
371
|
+
_resetResponseStallCheckIntervalForTest();
|
|
354
372
|
resetBindingState();
|
|
355
373
|
vi.useRealTimers();
|
|
356
374
|
});
|
|
357
375
|
|
|
358
|
-
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
376
|
+
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
359
377
|
const platform = mockPlatform("feishu");
|
|
360
378
|
setSessionPlatform(platform);
|
|
361
379
|
bindChatToSession("sid-process", "chat-process");
|
|
@@ -450,7 +468,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
450
468
|
);
|
|
451
469
|
});
|
|
452
470
|
|
|
453
|
-
it("does not register an invisible progress card and sends the final text fallback", async () => {
|
|
471
|
+
it("does not register an invisible progress card and sends the final text fallback", async () => {
|
|
454
472
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
455
473
|
const platform = mockPlatform("feishu");
|
|
456
474
|
platform.cardCreate = vi.fn()
|
|
@@ -483,10 +501,77 @@ describe("runAgentSession process monitor", () => {
|
|
|
483
501
|
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
484
502
|
);
|
|
485
503
|
expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
|
|
486
|
-
expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
it("
|
|
504
|
+
expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("consumes a queued message only after the previous turn finishes final delivery", async () => {
|
|
508
|
+
const platform = mockPlatform("feishu");
|
|
509
|
+
setSessionPlatform(platform);
|
|
510
|
+
bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
|
|
511
|
+
recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
|
|
512
|
+
|
|
513
|
+
let releaseFinalDelivery: (() => void) | undefined;
|
|
514
|
+
const finalDeliveryGate = new Promise<void>((resolve) => {
|
|
515
|
+
releaseFinalDelivery = resolve;
|
|
516
|
+
});
|
|
517
|
+
platform.sendText = vi.fn(async (_chatId, content) => {
|
|
518
|
+
if (content === "final answer") await finalDeliveryGate;
|
|
519
|
+
return true;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const adapter: ToolAdapter = {
|
|
523
|
+
displayName: "Claude Code",
|
|
524
|
+
sessionDescPrefix: "Claude Code Session:",
|
|
525
|
+
createSession: async () => ({ sessionId: "sid-queue-finalize" }),
|
|
526
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
527
|
+
closeSession: async () => {},
|
|
528
|
+
prompt: async function* () {
|
|
529
|
+
// 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
|
|
530
|
+
displayCards.delete("chat-queue-finalize");
|
|
531
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
535
|
+
|
|
536
|
+
enqueueMessage("sid-queue-finalize", {
|
|
537
|
+
text: "queued prompt",
|
|
538
|
+
chatId: "chat-queue-finalize",
|
|
539
|
+
openId: "open-user",
|
|
540
|
+
msgTimestamp: Date.now(),
|
|
541
|
+
chatType: "p2p",
|
|
542
|
+
});
|
|
543
|
+
const consumeQueued = vi.fn();
|
|
544
|
+
setQueueConsumer(consumeQueued);
|
|
545
|
+
|
|
546
|
+
const runPromise = runAgentSession(
|
|
547
|
+
"sid-queue-finalize",
|
|
548
|
+
"first prompt",
|
|
549
|
+
platform,
|
|
550
|
+
"chat-queue-finalize",
|
|
551
|
+
Date.now(),
|
|
552
|
+
"claude",
|
|
553
|
+
);
|
|
554
|
+
await vi.waitFor(() => {
|
|
555
|
+
expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
|
|
556
|
+
});
|
|
557
|
+
expect(isSessionRunning("sid-queue-finalize")).toBe(true);
|
|
558
|
+
|
|
559
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
560
|
+
expect(consumeQueued).not.toHaveBeenCalled();
|
|
561
|
+
|
|
562
|
+
releaseFinalDelivery?.();
|
|
563
|
+
await runPromise;
|
|
564
|
+
expect(isSessionRunning("sid-queue-finalize")).toBe(false);
|
|
565
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
566
|
+
expect(consumeQueued).toHaveBeenCalledTimes(1);
|
|
567
|
+
expect(consumeQueued).toHaveBeenCalledWith(
|
|
568
|
+
platform,
|
|
569
|
+
expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
|
|
570
|
+
);
|
|
571
|
+
setQueueConsumer(() => {});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("sends the stopped notice only after the prompt generator exits", async () => {
|
|
490
575
|
const platform = mockPlatform("feishu");
|
|
491
576
|
setSessionPlatform(platform);
|
|
492
577
|
bindChatToSession("sid-stop-notice", "chat-stop-notice");
|
|
@@ -583,8 +668,108 @@ describe("runAgentSession process monitor", () => {
|
|
|
583
668
|
});
|
|
584
669
|
});
|
|
585
670
|
|
|
671
|
+
describe("runAgentSession response stall watchdog", () => {
|
|
672
|
+
let tempDir = "";
|
|
673
|
+
|
|
674
|
+
beforeEach(async () => {
|
|
675
|
+
vi.useFakeTimers();
|
|
676
|
+
resetState();
|
|
677
|
+
resetBindingState();
|
|
678
|
+
mockStreamStates.clear();
|
|
679
|
+
killProcessTreeMock.mockClear();
|
|
680
|
+
tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
|
|
681
|
+
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
682
|
+
_setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
afterEach(async () => {
|
|
686
|
+
_resetSessionRegistryFileForTest();
|
|
687
|
+
_resetSessionToolsFileForTest();
|
|
688
|
+
_clearAdapterCacheForTest();
|
|
689
|
+
_resetProcessAliveForTest();
|
|
690
|
+
_resetResponseStallTimeoutForTest();
|
|
691
|
+
_resetResponseStallCheckIntervalForTest();
|
|
692
|
+
resetBindingState();
|
|
693
|
+
vi.useRealTimers();
|
|
694
|
+
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
|
|
698
|
+
vi.setSystemTime(0);
|
|
699
|
+
_setResponseStallTimeoutForTest(180_000);
|
|
700
|
+
_setResponseStallCheckIntervalForTest(1_000);
|
|
701
|
+
_setProcessAliveForTest(() => true);
|
|
702
|
+
|
|
703
|
+
const platform = mockPlatform("feishu");
|
|
704
|
+
setSessionPlatform(platform);
|
|
705
|
+
bindChatToSession("sid-response-stall", "chat-response-stall");
|
|
706
|
+
recordLastActiveChat("sid-response-stall", "chat-response-stall");
|
|
707
|
+
|
|
708
|
+
const closeSession = vi.fn();
|
|
709
|
+
const adapter: ToolAdapter = {
|
|
710
|
+
displayName: "Any Agent",
|
|
711
|
+
sessionDescPrefix: "Agent Session:",
|
|
712
|
+
createSession: async () => ({ sessionId: "sid-response-stall" }),
|
|
713
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
714
|
+
closeSession: async () => {},
|
|
715
|
+
prompt: async function* (
|
|
716
|
+
_sid: string,
|
|
717
|
+
_text: string,
|
|
718
|
+
_cwd: string,
|
|
719
|
+
signal?: AbortSignal,
|
|
720
|
+
options?: ToolPromptOptions,
|
|
721
|
+
) {
|
|
722
|
+
options?.onSessionCreated?.(closeSession);
|
|
723
|
+
options?.onProcessStart?.({ pid: 4242 });
|
|
724
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
725
|
+
await new Promise<void>((resolve) => {
|
|
726
|
+
if (signal?.aborted) {
|
|
727
|
+
resolve();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
735
|
+
|
|
736
|
+
const runPromise = runAgentSession(
|
|
737
|
+
"sid-response-stall",
|
|
738
|
+
"prompt",
|
|
739
|
+
platform,
|
|
740
|
+
"chat-response-stall",
|
|
741
|
+
Date.now(),
|
|
742
|
+
"claude",
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
await vi.waitFor(() => {
|
|
746
|
+
expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
|
|
747
|
+
totalChars: 0,
|
|
748
|
+
unchangedSince: expect.any(Number),
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
|
|
753
|
+
const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
|
|
754
|
+
await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
|
|
755
|
+
expect(activePrompts.has("sid-response-stall")).toBe(true);
|
|
756
|
+
|
|
757
|
+
await vi.advanceTimersByTimeAsync(1_001);
|
|
758
|
+
await runPromise;
|
|
759
|
+
|
|
760
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
761
|
+
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
|
762
|
+
expect(activePrompts.has("sid-response-stall")).toBe(false);
|
|
763
|
+
expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
|
|
764
|
+
status: "auto_ended",
|
|
765
|
+
finalReply: "",
|
|
766
|
+
autoEndedAt: expect.any(Number),
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
586
771
|
describe("unified display loop WeChat delta", () => {
|
|
587
|
-
beforeEach(() => {
|
|
772
|
+
beforeEach(() => {
|
|
588
773
|
vi.useFakeTimers();
|
|
589
774
|
resetState();
|
|
590
775
|
resetBindingState();
|
|
@@ -651,6 +836,7 @@ describe("unified display loop WeChat delta", () => {
|
|
|
651
836
|
"tool output",
|
|
652
837
|
);
|
|
653
838
|
});
|
|
839
|
+
|
|
654
840
|
});
|
|
655
841
|
|
|
656
842
|
describe("unified display loop activity status", () => {
|
|
@@ -727,13 +913,61 @@ describe("unified display loop terminal card update", () => {
|
|
|
727
913
|
mockStreamStates.clear();
|
|
728
914
|
});
|
|
729
915
|
|
|
730
|
-
afterEach(() => {
|
|
916
|
+
afterEach(() => {
|
|
731
917
|
stopUnifiedDisplayLoop();
|
|
732
918
|
resetBindingState();
|
|
733
|
-
vi.useRealTimers();
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it("
|
|
919
|
+
vi.useRealTimers();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
|
|
923
|
+
const platform = mockPlatform("feishu");
|
|
924
|
+
setSessionPlatform(platform);
|
|
925
|
+
|
|
926
|
+
bindChatToSession("sid-auto-ended", "chat-auto-ended");
|
|
927
|
+
recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
|
|
928
|
+
sessionInfoMap.set("chat-auto-ended", {
|
|
929
|
+
sessionId: "sid-auto-ended",
|
|
930
|
+
turnCount: 1,
|
|
931
|
+
lastContextTokens: 0,
|
|
932
|
+
startTime: 0,
|
|
933
|
+
tool: "cursor",
|
|
934
|
+
});
|
|
935
|
+
displayCards.set("chat-auto-ended", {
|
|
936
|
+
cardId: "card-auto-ended",
|
|
937
|
+
sequence: 1,
|
|
938
|
+
cardBusy: false,
|
|
939
|
+
cardCreatedAt: Date.now(),
|
|
940
|
+
lastSentContent: "",
|
|
941
|
+
streamErrorNotified: false,
|
|
942
|
+
sessionId: "sid-auto-ended",
|
|
943
|
+
turnCount: 1,
|
|
944
|
+
dotCount: 0,
|
|
945
|
+
});
|
|
946
|
+
mockStreamStates.set("sid-auto-ended", {
|
|
947
|
+
accumulatedContent: "",
|
|
948
|
+
finalReply: "",
|
|
949
|
+
status: "auto_ended",
|
|
950
|
+
turnCount: 1,
|
|
951
|
+
autoEndedAt: Date.now(),
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
startUnifiedDisplayLoop();
|
|
955
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
956
|
+
|
|
957
|
+
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
958
|
+
const card = JSON.parse(payload as string) as {
|
|
959
|
+
header: { title: { content: string }; template?: string };
|
|
960
|
+
};
|
|
961
|
+
expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
|
|
962
|
+
expect(card.header.template).toBe("orange");
|
|
963
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
964
|
+
"chat-auto-ended",
|
|
965
|
+
"⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。本轮没有可发送的回复内容。",
|
|
966
|
+
);
|
|
967
|
+
expect(displayCards.has("chat-auto-ended")).toBe(false);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
|
|
737
971
|
const platform = mockPlatform("feishu");
|
|
738
972
|
platform.cardUpdate = vi.fn(async () => {
|
|
739
973
|
throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
|
package/src/cards.ts
CHANGED
|
@@ -344,7 +344,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
344
344
|
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
345
345
|
elements: [
|
|
346
346
|
{ tag: "div", text: { tag: "lark_md", content: fixedPrivateSession
|
|
347
|
-
? `当前没有会话记录。\n\n直接发送普通消息即可创建飞书私聊专属 ${defaultToolLabel}
|
|
347
|
+
? `当前没有会话记录。\n\n直接发送普通消息即可创建飞书私聊专属 ${defaultToolLabel} 会话;默认 Agent 变化后,下一条普通消息会创建对应 Agent 的新空会话。发送 **/new**、**/new claude**、**/new cursor** 或 **/new codex** 会另外创建会话群。`
|
|
348
348
|
: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
|
|
349
349
|
{ tag: "hr" },
|
|
350
350
|
{ tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
|
|
@@ -387,7 +387,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
387
387
|
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
388
388
|
{ tag: "hr" },
|
|
389
389
|
{ tag: "div", text: { tag: "lark_md", content: fixedPrivateSession
|
|
390
|
-
? "
|
|
390
|
+
? "当前飞书私聊使用专属会话;默认 Agent 变化后,下一条普通消息会自动创建对应 Agent 的新空会话。发送 **/newh** 可在私聊中原地重置。群聊会话请回到对应群聊继续,私聊不支持 **/session** 切换。"
|
|
391
391
|
: "在会话群内发送 **/newh** 可重置当前会话(创建新 Session,保留工作目录和群聊)。\n发送 **/session 数字**(如 `/session 1`)可将当前群聊切换到列表中对应编号的会话。" } },
|
|
392
392
|
{ tag: "hr" },
|
|
393
393
|
{
|