chatccc 0.2.251 → 0.2.253
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 +1 -1
- package/bin/cccagent.mjs +12 -3
- package/bin/chatccc.mjs +15 -6
- package/deepccc-agent/package.json +1 -1
- package/dist/deepccc-agent/src/cli.js +634 -0
- package/dist/deepccc-agent/src/config.js +76 -0
- package/dist/deepccc-agent/src/context.js +348 -0
- package/dist/deepccc-agent/src/file-log.js +34 -0
- package/dist/deepccc-agent/src/file-tools.js +1208 -0
- package/dist/deepccc-agent/src/index.js +571 -0
- package/dist/deepccc-agent/src/permissions.js +170 -0
- package/dist/deepccc-agent/src/privacy.js +124 -0
- package/dist/deepccc-agent/src/proc-tree-kill.js +60 -0
- package/dist/deepccc-agent/src/progress/cards-helpers.js +70 -0
- package/dist/deepccc-agent/src/progress/reducer.js +102 -0
- package/dist/deepccc-agent/src/progress/terminal-renderer.js +264 -0
- package/dist/deepccc-agent/src/progress/view.js +30 -0
- package/dist/deepccc-agent/src/raw-stream-log.js +106 -0
- package/dist/deepccc-agent/src/session-search.js +276 -0
- package/dist/deepccc-agent/src/session-select.js +23 -0
- package/dist/deepccc-agent/src/sigint.js +26 -0
- package/dist/deepccc-agent/src/skills.js +178 -0
- package/dist/deepccc-agent/src/web-tools.js +246 -0
- package/dist/src/adapters/adapter-interface.js +19 -0
- package/dist/src/adapters/ccc-adapter.js +112 -0
- package/dist/src/adapters/claude-adapter.js +497 -0
- package/dist/src/adapters/claude-session-meta-store.js +92 -0
- package/dist/src/adapters/codex-adapter.js +279 -0
- package/dist/src/adapters/codex-session-meta-store.js +94 -0
- package/dist/src/adapters/cursor-adapter.js +491 -0
- package/dist/src/adapters/cursor-session-meta-store.js +116 -0
- package/dist/src/adapters/jsonl-stream.js +104 -0
- package/{src/adapters/proc-tree-kill.ts → dist/src/adapters/proc-tree-kill.js} +94 -97
- package/dist/src/adapters/raw-stream-log.js +106 -0
- package/dist/src/adapters/resource-monitor.js +113 -0
- package/dist/src/agent-activity.js +133 -0
- package/dist/src/agent-delegate-task-rpc.js +129 -0
- package/dist/src/agent-delegate-task.js +48 -0
- package/dist/src/agent-file-rpc.js +152 -0
- package/dist/src/agent-image-rpc.js +148 -0
- package/dist/src/agent-platform-routing.js +13 -0
- package/dist/src/agent-reload-config-rpc.js +23 -0
- package/dist/src/agent-rpc-body.js +87 -0
- package/dist/src/agent-stop-stuck.js +110 -0
- package/dist/src/card-action-routing.js +7 -0
- package/dist/src/card-plain-text.js +101 -0
- package/dist/src/cardkit.js +158 -0
- package/dist/src/cards.js +573 -0
- package/dist/src/chatgpt-subscription-rpc.js +18 -0
- package/dist/src/chatgpt-subscription.js +199 -0
- package/dist/src/chrome-devtools-guard.js +238 -0
- package/dist/src/claude-sdk-installer.js +249 -0
- package/dist/src/codex-reset-actions.js +143 -0
- package/dist/src/config-utils.js +149 -0
- package/dist/src/config.js +804 -0
- package/dist/src/cursor-usage.js +77 -0
- package/dist/src/exit-banner.js +28 -0
- package/dist/src/feishu-api.js +1404 -0
- package/dist/src/feishu-message-ingress.js +137 -0
- package/dist/src/feishu-platform.js +97 -0
- package/dist/src/format-message.js +252 -0
- package/dist/src/git-command.js +155 -0
- package/dist/src/im-skills.js +121 -0
- package/dist/src/index.js +833 -0
- package/dist/src/litellm-proxy.js +300 -0
- package/dist/src/orchestrator.js +2078 -0
- package/dist/src/package-root.js +26 -0
- package/dist/src/platform-adapter.js +7 -0
- package/dist/src/platform-startup.js +6 -0
- package/dist/src/privacy.js +100 -0
- package/dist/src/progress/reducer.js +102 -0
- package/dist/src/progress/terminal-renderer.js +264 -0
- package/dist/src/progress/view.js +30 -0
- package/dist/src/response-stall.js +14 -0
- package/dist/src/runtime-entry.js +13 -0
- package/dist/src/runtime-reload.js +19 -0
- package/dist/src/session-chat-binding.js +183 -0
- package/dist/src/session-name.js +7 -0
- package/dist/src/session.js +2144 -0
- package/dist/src/shared-prefix.js +16 -0
- package/dist/src/shared.js +493 -0
- package/dist/src/sim-agent.js +105 -0
- package/dist/src/sim-platform.js +142 -0
- package/dist/src/sim-store.js +231 -0
- package/dist/src/simplify.js +99 -0
- package/dist/src/startup-lifecycle.js +209 -0
- package/dist/src/stream-state.js +141 -0
- package/dist/src/terminal-error.js +100 -0
- package/dist/src/trace.js +50 -0
- package/dist/src/turn-cards.js +92 -0
- package/dist/src/update-command-guard.js +114 -0
- package/{src/web-ui.ts → dist/src/web-ui.js} +749 -823
- package/dist/src/wechat-platform.js +545 -0
- package/package.json +7 -5
- package/deepccc-agent/LICENSE +0 -201
- package/deepccc-agent/bin/deepccc.mjs +0 -26
- package/deepccc-agent/docs/cache-hit-rate-1.jpg +0 -0
- package/deepccc-agent/docs/cache-hit-rate-2.jpg +0 -0
- package/deepccc-agent/package-lock.json +0 -2027
- package/deepccc-agent/src/__tests__/chat-session.test.ts +0 -852
- package/deepccc-agent/src/__tests__/cli-json.test.ts +0 -49
- package/deepccc-agent/src/__tests__/config.test.ts +0 -34
- package/deepccc-agent/src/__tests__/context.test.ts +0 -341
- package/deepccc-agent/src/__tests__/file-tools.test.ts +0 -240
- package/deepccc-agent/src/__tests__/permissions.test.ts +0 -199
- package/deepccc-agent/src/__tests__/privacy.test.ts +0 -318
- package/deepccc-agent/src/__tests__/progress-reducer.test.ts +0 -121
- package/deepccc-agent/src/__tests__/session-search.test.ts +0 -262
- package/deepccc-agent/src/__tests__/session-select.test.ts +0 -116
- package/deepccc-agent/src/__tests__/sigint.test.ts +0 -56
- package/deepccc-agent/src/__tests__/skills.test.ts +0 -284
- package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +0 -247
- package/deepccc-agent/src/__tests__/web-tools.test.ts +0 -220
- package/deepccc-agent/src/cli.ts +0 -682
- package/deepccc-agent/src/config.ts +0 -101
- package/deepccc-agent/src/context.ts +0 -465
- package/deepccc-agent/src/file-log.ts +0 -38
- package/deepccc-agent/src/file-tools.ts +0 -1493
- package/deepccc-agent/src/index.ts +0 -676
- package/deepccc-agent/src/permissions.ts +0 -226
- package/deepccc-agent/src/privacy.ts +0 -141
- package/deepccc-agent/src/proc-tree-kill.ts +0 -61
- package/deepccc-agent/src/progress/cards-helpers.ts +0 -76
- package/deepccc-agent/src/progress/reducer.ts +0 -113
- package/deepccc-agent/src/progress/terminal-renderer.ts +0 -294
- package/deepccc-agent/src/progress/view.ts +0 -77
- package/deepccc-agent/src/raw-stream-log.ts +0 -124
- package/deepccc-agent/src/session-search.ts +0 -370
- package/deepccc-agent/src/session-select.ts +0 -48
- package/deepccc-agent/src/sigint.ts +0 -50
- package/deepccc-agent/src/skills.ts +0 -205
- package/deepccc-agent/src/web-tools.ts +0 -313
- package/deepccc-agent/tsconfig.build.json +0 -13
- package/deepccc-agent/tsconfig.json +0 -13
- package/deepccc-agent/vitest.config.ts +0 -7
- package/src/__tests__/adapter-interface.test.ts +0 -152
- package/src/__tests__/agent-activity.test.ts +0 -86
- package/src/__tests__/agent-delegate-task-rpc.test.ts +0 -165
- package/src/__tests__/agent-image-rpc.test.ts +0 -34
- package/src/__tests__/agent-platform-routing.test.ts +0 -26
- package/src/__tests__/agent-reload-config-rpc.test.ts +0 -99
- package/src/__tests__/agent-rpc-body.test.ts +0 -42
- package/src/__tests__/builtin-chat-session.test.ts +0 -532
- package/src/__tests__/builtin-cli-json.test.ts +0 -39
- package/src/__tests__/builtin-config.test.ts +0 -26
- package/src/__tests__/builtin-context.test.ts +0 -319
- package/src/__tests__/builtin-file-tools.test.ts +0 -240
- package/src/__tests__/builtin-permissions.test.ts +0 -219
- package/src/__tests__/builtin-session-search.test.ts +0 -262
- package/src/__tests__/builtin-session-select.test.ts +0 -116
- package/src/__tests__/builtin-sigint.test.ts +0 -56
- package/src/__tests__/builtin-skills.test.ts +0 -284
- package/src/__tests__/builtin-web-tools.test.ts +0 -220
- package/src/__tests__/card-action-routing.test.ts +0 -18
- package/src/__tests__/card-plain-text.test.ts +0 -45
- package/src/__tests__/cardkit.test.ts +0 -60
- package/src/__tests__/cards.test.ts +0 -607
- package/src/__tests__/ccc-adapter.test.ts +0 -194
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +0 -89
- package/src/__tests__/chatgpt-subscription.test.ts +0 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +0 -165
- package/src/__tests__/claude-adapter.test.ts +0 -614
- package/src/__tests__/claude-raw-stream-log.test.ts +0 -96
- package/src/__tests__/claude-sdk-installer.test.ts +0 -285
- package/src/__tests__/codex-adapter.test.ts +0 -331
- package/src/__tests__/codex-raw-stream-log.test.ts +0 -170
- package/src/__tests__/codex-reset-actions.test.ts +0 -146
- package/src/__tests__/config-reload.test.ts +0 -284
- package/src/__tests__/config-sample.test.ts +0 -97
- package/src/__tests__/config-utils.test.ts +0 -40
- package/src/__tests__/config.test.ts +0 -395
- package/src/__tests__/crash-logging.test.ts +0 -360
- package/src/__tests__/cursor-adapter.test.ts +0 -890
- package/src/__tests__/cursor-session-meta-store.test.ts +0 -212
- package/src/__tests__/feishu-api.test.ts +0 -60
- package/src/__tests__/feishu-avatar.test.ts +0 -504
- package/src/__tests__/feishu-message-ingress.test.ts +0 -138
- package/src/__tests__/feishu-platform.test.ts +0 -75
- package/src/__tests__/fixtures/codex_simple_text.jsonl +0 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +0 -6
- package/src/__tests__/fixtures/cursor_partial_only.jsonl +0 -5
- package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +0 -13
- package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +0 -12
- package/src/__tests__/format-message.test.ts +0 -316
- package/src/__tests__/git-command.test.ts +0 -288
- package/src/__tests__/im-skills.test.ts +0 -125
- package/src/__tests__/jsonl-stream.test.ts +0 -79
- package/src/__tests__/orchestrator.test.ts +0 -1268
- package/src/__tests__/package-files.test.ts +0 -24
- package/src/__tests__/platform-startup.test.ts +0 -19
- package/src/__tests__/privacy.test.ts +0 -198
- package/src/__tests__/proc-tree-kill.test.ts +0 -108
- package/src/__tests__/progress-reducer.test.ts +0 -121
- package/src/__tests__/raw-stream-log.test.ts +0 -106
- package/src/__tests__/response-stall.test.ts +0 -49
- package/src/__tests__/restart.test.ts +0 -232
- package/src/__tests__/session-ccc-config.test.ts +0 -66
- package/src/__tests__/session.test.ts +0 -3004
- package/src/__tests__/shared-prefix.test.ts +0 -36
- package/src/__tests__/sim-agent.test.ts +0 -174
- package/src/__tests__/sim-platform.test.ts +0 -93
- package/src/__tests__/sim-store.test.ts +0 -214
- package/src/__tests__/simplify.test.ts +0 -283
- package/src/__tests__/startup-lifecycle.test.ts +0 -231
- package/src/__tests__/stop-session.test.ts +0 -162
- package/src/__tests__/stream-state.test.ts +0 -164
- package/src/__tests__/terminal-error.test.ts +0 -54
- package/src/__tests__/terminal-renderer.test.ts +0 -247
- package/src/__tests__/update-command-guard.test.ts +0 -144
- package/src/__tests__/web-ui.test.ts +0 -438
- package/src/__tests__/wechat-platform.test.ts +0 -111
- package/src/adapters/adapter-interface.ts +0 -217
- package/src/adapters/ccc-adapter.ts +0 -150
- package/src/adapters/claude-adapter.ts +0 -673
- package/src/adapters/claude-session-meta-store.ts +0 -120
- package/src/adapters/codex-adapter.ts +0 -426
- package/src/adapters/codex-session-meta-store.ts +0 -131
- package/src/adapters/cursor-adapter.ts +0 -681
- package/src/adapters/cursor-session-meta-store.ts +0 -154
- package/src/adapters/jsonl-stream.ts +0 -157
- package/src/adapters/raw-stream-log.ts +0 -124
- package/src/adapters/resource-monitor.ts +0 -141
- package/src/agent-activity.ts +0 -175
- package/src/agent-delegate-task-rpc.ts +0 -153
- package/src/agent-delegate-task.ts +0 -91
- package/src/agent-file-rpc.ts +0 -172
- package/src/agent-image-rpc.ts +0 -168
- package/src/agent-platform-routing.ts +0 -28
- package/src/agent-reload-config-rpc.ts +0 -34
- package/src/agent-rpc-body.ts +0 -92
- package/src/agent-stop-stuck.ts +0 -129
- package/src/card-action-routing.ts +0 -14
- package/src/card-plain-text.ts +0 -108
- package/src/cardkit.ts +0 -179
- package/src/cards.ts +0 -684
- package/src/chatgpt-subscription-rpc.ts +0 -27
- package/src/chatgpt-subscription.ts +0 -299
- package/src/chrome-devtools-guard.ts +0 -318
- package/src/claude-sdk-installer.ts +0 -324
- package/src/codex-reset-actions.ts +0 -184
- package/src/config-utils.ts +0 -211
- package/src/config.ts +0 -1063
- package/src/cursor-usage.ts +0 -128
- package/src/exit-banner.ts +0 -33
- package/src/feishu-api.ts +0 -1616
- package/src/feishu-message-ingress.ts +0 -195
- package/src/feishu-platform.ts +0 -159
- package/src/format-message.ts +0 -293
- package/src/git-command.ts +0 -202
- package/src/im-skills.ts +0 -149
- package/src/index.ts +0 -1089
- package/src/litellm-proxy.ts +0 -374
- package/src/orchestrator.ts +0 -2543
- package/src/platform-adapter.ts +0 -70
- package/src/platform-startup.ts +0 -16
- package/src/privacy.ts +0 -118
- package/src/progress/reducer.ts +0 -113
- package/src/progress/terminal-renderer.ts +0 -294
- package/src/progress/view.ts +0 -77
- package/src/response-stall.ts +0 -28
- package/src/runtime-reload.ts +0 -34
- package/src/session-chat-binding.ts +0 -292
- package/src/session-name.ts +0 -8
- package/src/session.ts +0 -2659
- package/src/shared-prefix.ts +0 -29
- package/src/shared.ts +0 -552
- package/src/sim-agent.ts +0 -167
- package/src/sim-platform.ts +0 -177
- package/src/sim-store.ts +0 -317
- package/src/simplify.ts +0 -120
- package/src/startup-lifecycle.ts +0 -250
- package/src/stream-state.ts +0 -177
- package/src/terminal-error.ts +0 -129
- package/src/trace.ts +0 -51
- package/src/turn-cards.ts +0 -118
- package/src/update-command-guard.ts +0 -165
- package/src/wechat-platform.ts +0 -680
|
@@ -1,3004 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
const killProcessTreeMock = vi.hoisted(() => vi.fn(async (_pid?: number) => {}));
|
|
7
|
-
vi.mock("../adapters/proc-tree-kill.ts", () => ({
|
|
8
|
-
killProcessTree: killProcessTreeMock,
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
// mock stream-state 以支持在测试中控制累积长度
|
|
12
|
-
const mockStreamStates = new Map<string, {
|
|
13
|
-
accumulatedContent: string;
|
|
14
|
-
finalReply: string;
|
|
15
|
-
activity?: {
|
|
16
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
17
|
-
startedAt: number;
|
|
18
|
-
toolName?: string;
|
|
19
|
-
toolCount?: number;
|
|
20
|
-
};
|
|
21
|
-
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
22
|
-
autoEndedAt?: number;
|
|
23
|
-
turnCount?: number;
|
|
24
|
-
finalReplySentTurn?: number;
|
|
25
|
-
finalReplySentAt?: number;
|
|
26
|
-
terminalError?: {
|
|
27
|
-
kind: "network_timeout" | "network" | "authentication" | "rate_limit" | "provider" | "process" | "resource" | "unknown";
|
|
28
|
-
title: string;
|
|
29
|
-
message: string;
|
|
30
|
-
occurredAt: number;
|
|
31
|
-
};
|
|
32
|
-
}>();
|
|
33
|
-
vi.mock("../stream-state.ts", () => ({
|
|
34
|
-
readStreamState: async (sid: string) => {
|
|
35
|
-
const state = mockStreamStates.get(sid);
|
|
36
|
-
if (!state) return null;
|
|
37
|
-
return {
|
|
38
|
-
sessionId: sid,
|
|
39
|
-
accumulatedContent: state.accumulatedContent,
|
|
40
|
-
finalReply: state.finalReply,
|
|
41
|
-
activity: state.activity,
|
|
42
|
-
finalReplySentTurn: state.finalReplySentTurn,
|
|
43
|
-
finalReplySentAt: state.finalReplySentAt,
|
|
44
|
-
terminalError: state.terminalError,
|
|
45
|
-
autoEndedAt: state.autoEndedAt,
|
|
46
|
-
status: state.status ?? "running",
|
|
47
|
-
chunkCount: 0,
|
|
48
|
-
turnCount: state.turnCount ?? 0,
|
|
49
|
-
contextTokens: 0,
|
|
50
|
-
updatedAt: Date.now(),
|
|
51
|
-
cwd: "",
|
|
52
|
-
tool: "claude",
|
|
53
|
-
};
|
|
54
|
-
},
|
|
55
|
-
writeStreamState: async (state: {
|
|
56
|
-
sessionId: string;
|
|
57
|
-
accumulatedContent: string;
|
|
58
|
-
finalReply: string;
|
|
59
|
-
activity?: {
|
|
60
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
61
|
-
startedAt: number;
|
|
62
|
-
toolName?: string;
|
|
63
|
-
toolCount?: number;
|
|
64
|
-
};
|
|
65
|
-
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
66
|
-
autoEndedAt?: number;
|
|
67
|
-
turnCount?: number;
|
|
68
|
-
finalReplySentTurn?: number;
|
|
69
|
-
finalReplySentAt?: number;
|
|
70
|
-
terminalError?: {
|
|
71
|
-
kind: "network_timeout" | "network" | "authentication" | "rate_limit" | "provider" | "process" | "resource" | "unknown";
|
|
72
|
-
title: string;
|
|
73
|
-
message: string;
|
|
74
|
-
occurredAt: number;
|
|
75
|
-
};
|
|
76
|
-
}) => {
|
|
77
|
-
mockStreamStates.set(state.sessionId, {
|
|
78
|
-
accumulatedContent: state.accumulatedContent,
|
|
79
|
-
finalReply: state.finalReply,
|
|
80
|
-
activity: state.activity,
|
|
81
|
-
status: state.status,
|
|
82
|
-
autoEndedAt: state.autoEndedAt,
|
|
83
|
-
turnCount: state.turnCount,
|
|
84
|
-
finalReplySentTurn: state.finalReplySentTurn,
|
|
85
|
-
finalReplySentAt: state.finalReplySentAt,
|
|
86
|
-
terminalError: state.terminalError,
|
|
87
|
-
});
|
|
88
|
-
},
|
|
89
|
-
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
90
|
-
sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", activity: { kind: "starting" as const, startedAt: Date.now() }, chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
|
|
91
|
-
}),
|
|
92
|
-
isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
|
|
93
|
-
markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
|
|
94
|
-
const state = mockStreamStates.get(sid);
|
|
95
|
-
if (!state) return;
|
|
96
|
-
if ((state.turnCount ?? 0) !== turnCount) return;
|
|
97
|
-
if ((state.status ?? "running") === "running") return;
|
|
98
|
-
state.finalReplySentTurn = turnCount;
|
|
99
|
-
state.finalReplySentAt = sentAt;
|
|
100
|
-
},
|
|
101
|
-
fixStaleStreamStates: async () => {},
|
|
102
|
-
}));
|
|
103
|
-
import {
|
|
104
|
-
chatSessionMap,
|
|
105
|
-
sessionInfoMap,
|
|
106
|
-
processedMessages,
|
|
107
|
-
MAX_PROCESSED,
|
|
108
|
-
resetState,
|
|
109
|
-
getSessionStatus,
|
|
110
|
-
getAllSessionsStatus,
|
|
111
|
-
recordSessionRegistry,
|
|
112
|
-
saveSessionTool,
|
|
113
|
-
accumulateBlockContent,
|
|
114
|
-
pickFinalReply,
|
|
115
|
-
switchChatBinding,
|
|
116
|
-
rebuildBindingsFromRegistry,
|
|
117
|
-
UNKNOWN_MODEL_PLACEHOLDER,
|
|
118
|
-
_setSessionRegistryFileForTest,
|
|
119
|
-
_resetSessionRegistryFileForTest,
|
|
120
|
-
_setSessionToolsFileForTest,
|
|
121
|
-
_resetSessionToolsFileForTest,
|
|
122
|
-
_setAdapterForToolForTest,
|
|
123
|
-
_clearAdapterCacheForTest,
|
|
124
|
-
setSessionPlatform,
|
|
125
|
-
recordChatPlatform,
|
|
126
|
-
_getPlatformForChatForTest,
|
|
127
|
-
initClaudeSession,
|
|
128
|
-
runAgentSession,
|
|
129
|
-
stopSession,
|
|
130
|
-
startUnifiedDisplayLoop,
|
|
131
|
-
stopUnifiedDisplayLoop,
|
|
132
|
-
_setProcessAliveForTest,
|
|
133
|
-
_resetProcessAliveForTest,
|
|
134
|
-
_setProcessMonitorIntervalForTest,
|
|
135
|
-
_resetProcessMonitorIntervalForTest,
|
|
136
|
-
_setAvatarRefreshIntervalForTest,
|
|
137
|
-
_resetAvatarRefreshIntervalForTest,
|
|
138
|
-
_setResponseStallTimeoutForTest,
|
|
139
|
-
_resetResponseStallTimeoutForTest,
|
|
140
|
-
_setResponseStallCheckIntervalForTest,
|
|
141
|
-
_resetResponseStallCheckIntervalForTest,
|
|
142
|
-
_setFinalResponseCloseTimeoutForTest,
|
|
143
|
-
_resetFinalResponseCloseTimeoutForTest,
|
|
144
|
-
setSessionEffortOverride,
|
|
145
|
-
clearSessionEffortOverride,
|
|
146
|
-
getEffectiveEffortForTool,
|
|
147
|
-
getEffectiveFastModeForTool,
|
|
148
|
-
setSessionFastModeOverride,
|
|
149
|
-
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
150
|
-
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
151
|
-
} from "../session.ts";
|
|
152
|
-
import { config } from "../config.ts";
|
|
153
|
-
import {
|
|
154
|
-
activePrompts,
|
|
155
|
-
bindChatToSession,
|
|
156
|
-
unbindChatFromSession,
|
|
157
|
-
recordLastActiveChat,
|
|
158
|
-
getLastActiveChat,
|
|
159
|
-
pickDisplayChat,
|
|
160
|
-
resetBindingState,
|
|
161
|
-
getChatsForSession,
|
|
162
|
-
displayCards,
|
|
163
|
-
enqueueMessage,
|
|
164
|
-
setQueueConsumer,
|
|
165
|
-
isSessionRunning,
|
|
166
|
-
} from "../session-chat-binding.ts";
|
|
167
|
-
import type { AccumulatorState } from "../session.ts";
|
|
168
|
-
import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
169
|
-
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
170
|
-
|
|
171
|
-
describe("Codex Fast mode overrides", () => {
|
|
172
|
-
it("uses the global default until the current session overrides it", () => {
|
|
173
|
-
const previousFastMode = config.codex.fastMode;
|
|
174
|
-
config.codex.fastMode = false;
|
|
175
|
-
resetState();
|
|
176
|
-
|
|
177
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
178
|
-
setSessionFastModeOverride("sid-fast", true);
|
|
179
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(true);
|
|
180
|
-
setSessionFastModeOverride("sid-fast", false);
|
|
181
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
182
|
-
expect(getEffectiveFastModeForTool("claude", "sid-fast")).toBe(false);
|
|
183
|
-
|
|
184
|
-
resetState();
|
|
185
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
186
|
-
config.codex.fastMode = previousFastMode;
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// Helper to create a mock active session entry
|
|
191
|
-
function mockActiveSession(chatId: string, overrides: Partial<{
|
|
192
|
-
accumulatedContent: string;
|
|
193
|
-
finalText: string;
|
|
194
|
-
stopped: boolean;
|
|
195
|
-
}> = {}) {
|
|
196
|
-
const info = sessionInfoMap.get(chatId);
|
|
197
|
-
const sessionId = info?.sessionId ?? "test-session-id";
|
|
198
|
-
activePrompts.set(sessionId, {
|
|
199
|
-
controller: new AbortController(),
|
|
200
|
-
stopped: overrides.stopped ?? false,
|
|
201
|
-
startTime: Date.now(),
|
|
202
|
-
});
|
|
203
|
-
mockStreamStates.set(sessionId, {
|
|
204
|
-
accumulatedContent: overrides.accumulatedContent ?? "thinking...",
|
|
205
|
-
finalReply: overrides.finalText ?? "",
|
|
206
|
-
});
|
|
207
|
-
// 保留 chatSessionMap 兼容旧测试
|
|
208
|
-
chatSessionMap.set(chatId, {
|
|
209
|
-
gen: 1,
|
|
210
|
-
close: () => {},
|
|
211
|
-
cardId: null,
|
|
212
|
-
stopped: overrides.stopped ?? false,
|
|
213
|
-
accumulatedContent: overrides.accumulatedContent ?? "thinking...",
|
|
214
|
-
finalText: overrides.finalText ?? "",
|
|
215
|
-
spinnerTimer: null,
|
|
216
|
-
msgTimestamp: Date.now(),
|
|
217
|
-
sequence: 0,
|
|
218
|
-
cardBusy: false,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function mockSessionInfo(chatId: string, overrides: Partial<{
|
|
223
|
-
sessionId: string;
|
|
224
|
-
turnCount: number;
|
|
225
|
-
lastContextTokens: number;
|
|
226
|
-
startTime: number;
|
|
227
|
-
tool: string;
|
|
228
|
-
}> = {}) {
|
|
229
|
-
sessionInfoMap.set(chatId, {
|
|
230
|
-
sessionId: overrides.sessionId ?? "test-session-id",
|
|
231
|
-
turnCount: overrides.turnCount ?? 3,
|
|
232
|
-
lastContextTokens: overrides.lastContextTokens ?? 50000,
|
|
233
|
-
startTime: overrides.startTime ?? Date.now(),
|
|
234
|
-
tool: overrides.tool ?? "claude",
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* 简易 mock adapter:getSessionInfo 返回固定 SessionInfo,其他方法不实现
|
|
240
|
-
* (仅 /state、/sessions 路径会触发 getSessionInfo,无需完整接口)。
|
|
241
|
-
*/
|
|
242
|
-
function mockAdapter(getInfo: (sid: string) => SessionInfo | undefined): ToolAdapter {
|
|
243
|
-
return {
|
|
244
|
-
displayName: "MockTool",
|
|
245
|
-
sessionDescPrefix: "Mock Session:",
|
|
246
|
-
createSession: async () => ({ sessionId: "" }),
|
|
247
|
-
prompt: async function* () {},
|
|
248
|
-
getSessionInfo: async (sid) => getInfo(sid),
|
|
249
|
-
closeSession: async () => {},
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function mockPlatform(name: string): PlatformAdapter {
|
|
254
|
-
return {
|
|
255
|
-
sendText: vi.fn(async () => true),
|
|
256
|
-
sendCard: vi.fn(async () => true),
|
|
257
|
-
sendRawCard: vi.fn(async () => true),
|
|
258
|
-
createGroup: vi.fn(async () => `${name}-group`),
|
|
259
|
-
updateChatInfo: vi.fn(async () => {}),
|
|
260
|
-
getChatInfo: vi.fn(async () => ({ name, description: "" })),
|
|
261
|
-
disbandChat: vi.fn(async () => {}),
|
|
262
|
-
setChatAvatar: vi.fn(async () => {}),
|
|
263
|
-
extractSessionInfo: vi.fn(() => null),
|
|
264
|
-
cardCreate: vi.fn(async () => `${name}-card`),
|
|
265
|
-
cardSend: vi.fn(async () => `${name}-message`),
|
|
266
|
-
cardUpdate: vi.fn(async () => {}),
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
describe("resetState", () => {
|
|
271
|
-
it("clears all maps and sets", () => {
|
|
272
|
-
chatSessionMap.set("chat1", {
|
|
273
|
-
gen: 1, close: () => {}, cardId: null, stopped: false,
|
|
274
|
-
accumulatedContent: "", finalText: "", spinnerTimer: null,
|
|
275
|
-
msgTimestamp: 0, sequence: 0, cardBusy: false,
|
|
276
|
-
});
|
|
277
|
-
sessionInfoMap.set("chat1", {
|
|
278
|
-
sessionId: "s1", turnCount: 1, lastContextTokens: 0,
|
|
279
|
-
startTime: 0, tool: "claude",
|
|
280
|
-
});
|
|
281
|
-
processedMessages.add("msg1");
|
|
282
|
-
|
|
283
|
-
resetState();
|
|
284
|
-
|
|
285
|
-
expect(chatSessionMap.size).toBe(0);
|
|
286
|
-
expect(sessionInfoMap.size).toBe(0);
|
|
287
|
-
expect(processedMessages.size).toBe(0);
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
describe("initClaudeSession startup watchdog", () => {
|
|
292
|
-
afterEach(() => {
|
|
293
|
-
_clearAdapterCacheForTest();
|
|
294
|
-
_resetResponseStallTimeoutForTest();
|
|
295
|
-
vi.useRealTimers();
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it("aborts session creation when an Agent never emits its init event", async () => {
|
|
299
|
-
vi.useFakeTimers();
|
|
300
|
-
_setResponseStallTimeoutForTest(100);
|
|
301
|
-
|
|
302
|
-
let aborted = false;
|
|
303
|
-
const adapter: ToolAdapter = {
|
|
304
|
-
displayName: "Silent Cursor",
|
|
305
|
-
sessionDescPrefix: "Cursor Session:",
|
|
306
|
-
createSession: async (_cwd: string, signal?: AbortSignal) => {
|
|
307
|
-
await new Promise<void>((resolve) => {
|
|
308
|
-
if (signal?.aborted) {
|
|
309
|
-
aborted = true;
|
|
310
|
-
resolve();
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
signal?.addEventListener("abort", () => {
|
|
314
|
-
aborted = true;
|
|
315
|
-
resolve();
|
|
316
|
-
}, { once: true });
|
|
317
|
-
});
|
|
318
|
-
throw new Error("adapter aborted");
|
|
319
|
-
},
|
|
320
|
-
prompt: async function* () {},
|
|
321
|
-
getSessionInfo: async () => undefined,
|
|
322
|
-
closeSession: async () => {},
|
|
323
|
-
};
|
|
324
|
-
_setAdapterForToolForTest("cursor", adapter);
|
|
325
|
-
|
|
326
|
-
let outcome: unknown;
|
|
327
|
-
void initClaudeSession("cursor", "F:\\repo").then(
|
|
328
|
-
(value) => { outcome = value; },
|
|
329
|
-
(err) => { outcome = err; },
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
await vi.advanceTimersByTimeAsync(101);
|
|
333
|
-
|
|
334
|
-
expect(aborted).toBe(true);
|
|
335
|
-
expect(outcome).toBeInstanceOf(Error);
|
|
336
|
-
expect((outcome as Error).message).toContain("3 minutes");
|
|
337
|
-
});
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
describe("chat platform routing", () => {
|
|
341
|
-
beforeEach(() => {
|
|
342
|
-
resetState();
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it("uses the platform recorded for the chat before falling back to the default platform", () => {
|
|
346
|
-
const feishu = mockPlatform("feishu");
|
|
347
|
-
const wechat = mockPlatform("wechat");
|
|
348
|
-
|
|
349
|
-
setSessionPlatform(feishu);
|
|
350
|
-
recordChatPlatform("wx-chat", wechat);
|
|
351
|
-
|
|
352
|
-
expect(_getPlatformForChatForTest("wx-chat")).toBe(wechat);
|
|
353
|
-
expect(_getPlatformForChatForTest("feishu-chat")).toBe(feishu);
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
// rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
|
|
359
|
-
//
|
|
360
|
-
// 这是 onReady/onReconnected 应当调用的函数(替代之前错误调用的 resetState)。
|
|
361
|
-
// 关键不变量:重建映射后,原有的 activePrompts、sessionInfoMap、displayCards、
|
|
362
|
-
// processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
|
|
363
|
-
// ---------------------------------------------------------------------------
|
|
364
|
-
|
|
365
|
-
describe("runAgentSession previous final delivery guard", () => {
|
|
366
|
-
let registryFile = "";
|
|
367
|
-
let toolsFile = "";
|
|
368
|
-
|
|
369
|
-
beforeEach(async () => {
|
|
370
|
-
resetState();
|
|
371
|
-
resetBindingState();
|
|
372
|
-
mockStreamStates.clear();
|
|
373
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-final-guard-"));
|
|
374
|
-
registryFile = join(dir, "session-registry.json");
|
|
375
|
-
toolsFile = join(dir, "session-tools.json");
|
|
376
|
-
_setSessionRegistryFileForTest(registryFile);
|
|
377
|
-
_setSessionToolsFileForTest(toolsFile);
|
|
378
|
-
_setAdapterForToolForTest(
|
|
379
|
-
"claude",
|
|
380
|
-
mockAdapter((sid) => ({ sessionId: sid, cwd: "/tmp" })),
|
|
381
|
-
);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
afterEach(() => {
|
|
385
|
-
_resetSessionRegistryFileForTest();
|
|
386
|
-
_resetSessionToolsFileForTest();
|
|
387
|
-
_clearAdapterCacheForTest();
|
|
388
|
-
resetBindingState();
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it("does not resend the previous terminal final when the same turn is already marked sent", async () => {
|
|
392
|
-
const platform = mockPlatform("feishu");
|
|
393
|
-
setSessionPlatform(platform);
|
|
394
|
-
bindChatToSession("sid-final", "chat-final");
|
|
395
|
-
recordLastActiveChat("sid-final", "chat-final");
|
|
396
|
-
sessionInfoMap.set("chat-final", {
|
|
397
|
-
sessionId: "sid-final",
|
|
398
|
-
turnCount: 1,
|
|
399
|
-
lastContextTokens: 0,
|
|
400
|
-
startTime: 0,
|
|
401
|
-
tool: "claude",
|
|
402
|
-
});
|
|
403
|
-
mockStreamStates.set("sid-final", {
|
|
404
|
-
accumulatedContent: "",
|
|
405
|
-
finalReply: "old final",
|
|
406
|
-
status: "done",
|
|
407
|
-
turnCount: 1,
|
|
408
|
-
finalReplySentTurn: 1,
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
await runAgentSession("sid-final", "next prompt", platform, "chat-final", Date.now(), "claude");
|
|
412
|
-
|
|
413
|
-
expect(platform.sendText).not.toHaveBeenCalledWith("chat-final", "old final");
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it("resends the previous terminal final when there is no delivery marker", async () => {
|
|
417
|
-
const platform = mockPlatform("feishu");
|
|
418
|
-
setSessionPlatform(platform);
|
|
419
|
-
bindChatToSession("sid-unsent", "chat-unsent");
|
|
420
|
-
recordLastActiveChat("sid-unsent", "chat-unsent");
|
|
421
|
-
sessionInfoMap.set("chat-unsent", {
|
|
422
|
-
sessionId: "sid-unsent",
|
|
423
|
-
turnCount: 1,
|
|
424
|
-
lastContextTokens: 0,
|
|
425
|
-
startTime: 0,
|
|
426
|
-
tool: "claude",
|
|
427
|
-
});
|
|
428
|
-
mockStreamStates.set("sid-unsent", {
|
|
429
|
-
accumulatedContent: "",
|
|
430
|
-
finalReply: "old final",
|
|
431
|
-
status: "done",
|
|
432
|
-
turnCount: 1,
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
await runAgentSession("sid-unsent", "next prompt", platform, "chat-unsent", Date.now(), "claude");
|
|
436
|
-
|
|
437
|
-
expect(platform.sendText).toHaveBeenCalledWith("chat-unsent", "old final");
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
describe("runAgentSession process monitor", () => {
|
|
442
|
-
let registryFile = "";
|
|
443
|
-
let toolsFile = "";
|
|
444
|
-
|
|
445
|
-
beforeEach(async () => {
|
|
446
|
-
vi.useFakeTimers();
|
|
447
|
-
resetState();
|
|
448
|
-
resetBindingState();
|
|
449
|
-
mockStreamStates.clear();
|
|
450
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-process-monitor-"));
|
|
451
|
-
registryFile = join(dir, "session-registry.json");
|
|
452
|
-
toolsFile = join(dir, "session-tools.json");
|
|
453
|
-
_setSessionRegistryFileForTest(registryFile);
|
|
454
|
-
_setSessionToolsFileForTest(toolsFile);
|
|
455
|
-
_setProcessMonitorIntervalForTest(50);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
afterEach(() => {
|
|
459
|
-
_resetSessionRegistryFileForTest();
|
|
460
|
-
_resetSessionToolsFileForTest();
|
|
461
|
-
_clearAdapterCacheForTest();
|
|
462
|
-
_resetProcessAliveForTest();
|
|
463
|
-
_resetProcessMonitorIntervalForTest();
|
|
464
|
-
_resetResponseStallTimeoutForTest();
|
|
465
|
-
_resetResponseStallCheckIntervalForTest();
|
|
466
|
-
_resetFinalResponseCloseTimeoutForTest();
|
|
467
|
-
resetBindingState();
|
|
468
|
-
vi.useRealTimers();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
472
|
-
const platform = mockPlatform("feishu");
|
|
473
|
-
setSessionPlatform(platform);
|
|
474
|
-
bindChatToSession("sid-process", "chat-process");
|
|
475
|
-
recordLastActiveChat("sid-process", "chat-process");
|
|
476
|
-
_setProcessAliveForTest(() => false);
|
|
477
|
-
|
|
478
|
-
const adapter: ToolAdapter = {
|
|
479
|
-
displayName: "Cursor",
|
|
480
|
-
sessionDescPrefix: "Cursor Session:",
|
|
481
|
-
createSession: async () => ({ sessionId: "sid-process" }),
|
|
482
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "/tmp" }),
|
|
483
|
-
closeSession: async () => {},
|
|
484
|
-
prompt: async function* (
|
|
485
|
-
_sid: string,
|
|
486
|
-
_text: string,
|
|
487
|
-
_cwd: string,
|
|
488
|
-
signal?: AbortSignal,
|
|
489
|
-
options?: ToolPromptOptions,
|
|
490
|
-
) {
|
|
491
|
-
options?.onProcessStart?.({ pid: 12345 });
|
|
492
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "partial answer" }] };
|
|
493
|
-
await new Promise<void>((resolve) => {
|
|
494
|
-
if (signal?.aborted) {
|
|
495
|
-
resolve();
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
499
|
-
});
|
|
500
|
-
},
|
|
501
|
-
};
|
|
502
|
-
_setAdapterForToolForTest("cursor", adapter);
|
|
503
|
-
|
|
504
|
-
const runPromise = runAgentSession(
|
|
505
|
-
"sid-process",
|
|
506
|
-
"prompt",
|
|
507
|
-
platform,
|
|
508
|
-
"chat-process",
|
|
509
|
-
Date.now(),
|
|
510
|
-
"cursor",
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
await vi.waitFor(() => {
|
|
514
|
-
expect(activePrompts.get("sid-process")?.processPid).toBe(12345);
|
|
515
|
-
});
|
|
516
|
-
await vi.advanceTimersByTimeAsync(60);
|
|
517
|
-
await runPromise;
|
|
518
|
-
|
|
519
|
-
const state = mockStreamStates.get("sid-process");
|
|
520
|
-
expect(state?.status).toBe("error");
|
|
521
|
-
expect(state?.finalReply).toContain("partial answer");
|
|
522
|
-
expect(activePrompts.has("sid-process")).toBe(false);
|
|
523
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
524
|
-
"chat-process",
|
|
525
|
-
expect.stringContaining("进程异常结束"),
|
|
526
|
-
);
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
it("persists and sends the root cause when the agent stream fails before producing output", async () => {
|
|
530
|
-
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
531
|
-
const platform = mockPlatform("feishu");
|
|
532
|
-
platform.cardCreate = vi.fn(async () => {
|
|
533
|
-
throw new Error("CardKit unavailable");
|
|
534
|
-
});
|
|
535
|
-
setSessionPlatform(platform);
|
|
536
|
-
bindChatToSession("sid-stream-error", "chat-stream-error");
|
|
537
|
-
recordLastActiveChat("sid-stream-error", "chat-stream-error");
|
|
538
|
-
|
|
539
|
-
const adapter: ToolAdapter = {
|
|
540
|
-
displayName: "CCC Agent",
|
|
541
|
-
sessionDescPrefix: "CCC Session:",
|
|
542
|
-
createSession: async () => ({ sessionId: "sid-stream-error" }),
|
|
543
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "/tmp" }),
|
|
544
|
-
closeSession: async () => {},
|
|
545
|
-
prompt: async function* () {
|
|
546
|
-
throw new Error(
|
|
547
|
-
"Failed after 3 attempts. Last error: Cannot connect to API: " +
|
|
548
|
-
"Connect Timeout Error (timeout: 10000ms)",
|
|
549
|
-
);
|
|
550
|
-
},
|
|
551
|
-
};
|
|
552
|
-
_setAdapterForToolForTest("ccc", adapter);
|
|
553
|
-
|
|
554
|
-
const outcome = await runAgentSession(
|
|
555
|
-
"sid-stream-error",
|
|
556
|
-
"prompt",
|
|
557
|
-
platform,
|
|
558
|
-
"chat-stream-error",
|
|
559
|
-
Date.now(),
|
|
560
|
-
"ccc",
|
|
561
|
-
);
|
|
562
|
-
|
|
563
|
-
const state = mockStreamStates.get("sid-stream-error");
|
|
564
|
-
expect(outcome).toBe("error");
|
|
565
|
-
expect(state?.status).toBe("error");
|
|
566
|
-
expect(state?.terminalError?.kind).toBe("network_timeout");
|
|
567
|
-
expect(state?.terminalError?.message).toContain("已重试 3 次");
|
|
568
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
569
|
-
"chat-stream-error",
|
|
570
|
-
expect.stringContaining("网络连接超时"),
|
|
571
|
-
);
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it("recreates the progress card when the initial CardKit send fails", async () => {
|
|
575
|
-
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
576
|
-
const platform = mockPlatform("feishu");
|
|
577
|
-
platform.cardCreate = vi.fn()
|
|
578
|
-
.mockResolvedValueOnce("bad-card")
|
|
579
|
-
.mockResolvedValueOnce("good-card");
|
|
580
|
-
platform.cardSend = vi.fn()
|
|
581
|
-
.mockRejectedValueOnce(new Error("cardid is invalid"))
|
|
582
|
-
.mockResolvedValueOnce("message-good");
|
|
583
|
-
setSessionPlatform(platform);
|
|
584
|
-
bindChatToSession("sid-card-retry", "chat-card-retry");
|
|
585
|
-
recordLastActiveChat("sid-card-retry", "chat-card-retry");
|
|
586
|
-
|
|
587
|
-
const adapter: ToolAdapter = {
|
|
588
|
-
displayName: "Claude Code",
|
|
589
|
-
sessionDescPrefix: "Claude Code Session:",
|
|
590
|
-
createSession: async () => ({ sessionId: "sid-card-retry" }),
|
|
591
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
592
|
-
closeSession: async () => {},
|
|
593
|
-
prompt: async function* () {
|
|
594
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "done" }] };
|
|
595
|
-
},
|
|
596
|
-
};
|
|
597
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
598
|
-
|
|
599
|
-
await runAgentSession("sid-card-retry", "prompt", platform, "chat-card-retry", Date.now(), "claude");
|
|
600
|
-
|
|
601
|
-
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
602
|
-
expect(platform.cardSend).toHaveBeenNthCalledWith(1, "chat-card-retry", "bad-card");
|
|
603
|
-
expect(platform.cardSend).toHaveBeenNthCalledWith(2, "chat-card-retry", "good-card");
|
|
604
|
-
expect(displayCards.get("chat-card-retry")?.cardId).toBe("good-card");
|
|
605
|
-
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
606
|
-
"chat-card-retry",
|
|
607
|
-
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
608
|
-
);
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
it("does not register an invisible progress card and sends the final text fallback", async () => {
|
|
612
|
-
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
613
|
-
const platform = mockPlatform("feishu");
|
|
614
|
-
platform.cardCreate = vi.fn()
|
|
615
|
-
.mockResolvedValueOnce("bad-card-1")
|
|
616
|
-
.mockResolvedValueOnce("bad-card-2");
|
|
617
|
-
platform.cardSend = vi.fn()
|
|
618
|
-
.mockRejectedValueOnce(new Error("cardid is invalid"))
|
|
619
|
-
.mockRejectedValueOnce(new Error("cardid is invalid again"));
|
|
620
|
-
setSessionPlatform(platform);
|
|
621
|
-
bindChatToSession("sid-card-fallback", "chat-card-fallback");
|
|
622
|
-
recordLastActiveChat("sid-card-fallback", "chat-card-fallback");
|
|
623
|
-
|
|
624
|
-
const adapter: ToolAdapter = {
|
|
625
|
-
displayName: "Claude Code",
|
|
626
|
-
sessionDescPrefix: "Claude Code Session:",
|
|
627
|
-
createSession: async () => ({ sessionId: "sid-card-fallback" }),
|
|
628
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
629
|
-
closeSession: async () => {},
|
|
630
|
-
prompt: async function* () {
|
|
631
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
|
|
632
|
-
},
|
|
633
|
-
};
|
|
634
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
635
|
-
|
|
636
|
-
await runAgentSession("sid-card-fallback", "prompt", platform, "chat-card-fallback", Date.now(), "claude");
|
|
637
|
-
|
|
638
|
-
expect(displayCards.has("chat-card-fallback")).toBe(false);
|
|
639
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
640
|
-
"chat-card-fallback",
|
|
641
|
-
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
642
|
-
);
|
|
643
|
-
expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
|
|
644
|
-
expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
it("consumes a queued message only after the previous turn finishes final delivery", async () => {
|
|
648
|
-
const platform = mockPlatform("feishu");
|
|
649
|
-
setSessionPlatform(platform);
|
|
650
|
-
bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
|
|
651
|
-
recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
|
|
652
|
-
|
|
653
|
-
let releaseFinalDelivery: (() => void) | undefined;
|
|
654
|
-
const finalDeliveryGate = new Promise<void>((resolve) => {
|
|
655
|
-
releaseFinalDelivery = resolve;
|
|
656
|
-
});
|
|
657
|
-
platform.sendText = vi.fn(async (_chatId, content) => {
|
|
658
|
-
if (content === "final answer") await finalDeliveryGate;
|
|
659
|
-
return true;
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
const adapter: ToolAdapter = {
|
|
663
|
-
displayName: "Claude Code",
|
|
664
|
-
sessionDescPrefix: "Claude Code Session:",
|
|
665
|
-
createSession: async () => ({ sessionId: "sid-queue-finalize" }),
|
|
666
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
667
|
-
closeSession: async () => {},
|
|
668
|
-
prompt: async function* () {
|
|
669
|
-
// 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
|
|
670
|
-
displayCards.delete("chat-queue-finalize");
|
|
671
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
|
|
672
|
-
},
|
|
673
|
-
};
|
|
674
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
675
|
-
|
|
676
|
-
enqueueMessage("sid-queue-finalize", {
|
|
677
|
-
text: "queued prompt",
|
|
678
|
-
chatId: "chat-queue-finalize",
|
|
679
|
-
openId: "open-user",
|
|
680
|
-
msgTimestamp: Date.now(),
|
|
681
|
-
chatType: "p2p",
|
|
682
|
-
});
|
|
683
|
-
const consumeQueued = vi.fn();
|
|
684
|
-
setQueueConsumer(consumeQueued);
|
|
685
|
-
|
|
686
|
-
const runPromise = runAgentSession(
|
|
687
|
-
"sid-queue-finalize",
|
|
688
|
-
"first prompt",
|
|
689
|
-
platform,
|
|
690
|
-
"chat-queue-finalize",
|
|
691
|
-
Date.now(),
|
|
692
|
-
"claude",
|
|
693
|
-
);
|
|
694
|
-
await vi.waitFor(() => {
|
|
695
|
-
expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
|
|
696
|
-
});
|
|
697
|
-
expect(isSessionRunning("sid-queue-finalize")).toBe(true);
|
|
698
|
-
|
|
699
|
-
await vi.advanceTimersByTimeAsync(250);
|
|
700
|
-
expect(consumeQueued).not.toHaveBeenCalled();
|
|
701
|
-
|
|
702
|
-
releaseFinalDelivery?.();
|
|
703
|
-
await runPromise;
|
|
704
|
-
expect(isSessionRunning("sid-queue-finalize")).toBe(false);
|
|
705
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
706
|
-
expect(consumeQueued).toHaveBeenCalledTimes(1);
|
|
707
|
-
expect(consumeQueued).toHaveBeenCalledWith(
|
|
708
|
-
platform,
|
|
709
|
-
expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
|
|
710
|
-
);
|
|
711
|
-
setQueueConsumer(() => {});
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
it("sends the stopped notice only after the prompt generator exits", async () => {
|
|
715
|
-
const platform = mockPlatform("feishu");
|
|
716
|
-
setSessionPlatform(platform);
|
|
717
|
-
bindChatToSession("sid-stop-notice", "chat-stop-notice");
|
|
718
|
-
recordLastActiveChat("sid-stop-notice", "chat-stop-notice");
|
|
719
|
-
|
|
720
|
-
const closeSession = vi.fn();
|
|
721
|
-
let releasePrompt: (() => void) | undefined;
|
|
722
|
-
const adapter: ToolAdapter = {
|
|
723
|
-
displayName: "Claude Code",
|
|
724
|
-
sessionDescPrefix: "Claude Code Session:",
|
|
725
|
-
createSession: async () => ({ sessionId: "sid-stop-notice" }),
|
|
726
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
727
|
-
closeSession: async () => {},
|
|
728
|
-
prompt: async function* (
|
|
729
|
-
_sid: string,
|
|
730
|
-
_text: string,
|
|
731
|
-
_cwd: string,
|
|
732
|
-
signal?: AbortSignal,
|
|
733
|
-
options?: ToolPromptOptions,
|
|
734
|
-
) {
|
|
735
|
-
const waitForStop = new Promise<void>((resolve) => {
|
|
736
|
-
releasePrompt = resolve;
|
|
737
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
738
|
-
});
|
|
739
|
-
options?.onSessionCreated?.(() => {
|
|
740
|
-
closeSession();
|
|
741
|
-
releasePrompt?.();
|
|
742
|
-
});
|
|
743
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "partial answer" }] };
|
|
744
|
-
await waitForStop;
|
|
745
|
-
},
|
|
746
|
-
};
|
|
747
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
748
|
-
|
|
749
|
-
const runPromise = runAgentSession(
|
|
750
|
-
"sid-stop-notice",
|
|
751
|
-
"prompt",
|
|
752
|
-
platform,
|
|
753
|
-
"chat-stop-notice",
|
|
754
|
-
Date.now(),
|
|
755
|
-
"claude",
|
|
756
|
-
);
|
|
757
|
-
|
|
758
|
-
await vi.waitFor(() => {
|
|
759
|
-
expect(activePrompts.get("sid-stop-notice")?.closeSession).toBeTypeOf("function");
|
|
760
|
-
});
|
|
761
|
-
expect(platform.sendText).not.toHaveBeenCalledWith("chat-stop-notice", "会话已停止。");
|
|
762
|
-
|
|
763
|
-
const ok = stopSession("sid-stop-notice");
|
|
764
|
-
expect(ok).toBe(true);
|
|
765
|
-
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
766
|
-
|
|
767
|
-
await runPromise;
|
|
768
|
-
|
|
769
|
-
expect(platform.sendText).toHaveBeenCalledWith("chat-stop-notice", "会话已停止。");
|
|
770
|
-
expect(activePrompts.has("sid-stop-notice")).toBe(false);
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
it("re-injects IM skill capabilities for each resumed Claude prompt", async () => {
|
|
774
|
-
const platform = mockPlatform("feishu");
|
|
775
|
-
setSessionPlatform(platform);
|
|
776
|
-
bindChatToSession("sid-resume", "chat-resume");
|
|
777
|
-
recordLastActiveChat("sid-resume", "chat-resume");
|
|
778
|
-
|
|
779
|
-
const sentTexts: string[] = [];
|
|
780
|
-
const adapter: ToolAdapter = {
|
|
781
|
-
displayName: "Claude Code",
|
|
782
|
-
sessionDescPrefix: "Claude Code Session:",
|
|
783
|
-
createSession: async () => ({ sessionId: "sid-resume" }),
|
|
784
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
785
|
-
closeSession: async () => {},
|
|
786
|
-
prompt: async function* (_sid: string, text: string) {
|
|
787
|
-
sentTexts.push(text);
|
|
788
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "done" }] };
|
|
789
|
-
},
|
|
790
|
-
};
|
|
791
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
792
|
-
|
|
793
|
-
await runAgentSession("sid-resume", "first prompt", platform, "chat-resume", Date.now(), "claude");
|
|
794
|
-
await runAgentSession("sid-resume", "second prompt", platform, "chat-resume", Date.now(), "claude");
|
|
795
|
-
|
|
796
|
-
expect(sentTexts).toHaveLength(2);
|
|
797
|
-
for (const text of sentTexts) {
|
|
798
|
-
expect(text).toContain("[ChatCCC IM skill: feishu-skill]");
|
|
799
|
-
expect(text).toContain("[/ChatCCC IM skill: feishu-skill]");
|
|
800
|
-
expect(text).toContain('"session_id":"sid-resume"');
|
|
801
|
-
expect(text).toContain("http://127.0.0.1:");
|
|
802
|
-
expect(text).toContain("/api/agent/send-image");
|
|
803
|
-
expect(text).toContain("[User message]");
|
|
804
|
-
expect(text).toContain("[/User message]");
|
|
805
|
-
}
|
|
806
|
-
expect(sentTexts[0]).toContain("first prompt");
|
|
807
|
-
expect(sentTexts[1]).toContain("second prompt");
|
|
808
|
-
});
|
|
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
|
-
});
|
|
902
|
-
|
|
903
|
-
describe("runAgentSession response stall watchdog", () => {
|
|
904
|
-
let tempDir = "";
|
|
905
|
-
const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
|
|
906
|
-
|
|
907
|
-
beforeEach(async () => {
|
|
908
|
-
vi.useFakeTimers();
|
|
909
|
-
resetState();
|
|
910
|
-
resetBindingState();
|
|
911
|
-
mockStreamStates.clear();
|
|
912
|
-
killProcessTreeMock.mockClear();
|
|
913
|
-
tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
|
|
914
|
-
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
915
|
-
_setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
afterEach(async () => {
|
|
919
|
-
_resetSessionRegistryFileForTest();
|
|
920
|
-
_resetSessionToolsFileForTest();
|
|
921
|
-
_clearAdapterCacheForTest();
|
|
922
|
-
_resetProcessAliveForTest();
|
|
923
|
-
_resetResponseStallTimeoutForTest();
|
|
924
|
-
_resetResponseStallCheckIntervalForTest();
|
|
925
|
-
_resetFinalResponseCloseTimeoutForTest();
|
|
926
|
-
resetBindingState();
|
|
927
|
-
vi.useRealTimers();
|
|
928
|
-
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
it("auto-ends adapters with stall detection after three minutes of unchanged reply characters, including zero", async () => {
|
|
932
|
-
vi.setSystemTime(0);
|
|
933
|
-
_setResponseStallTimeoutForTest(180_000);
|
|
934
|
-
_setResponseStallCheckIntervalForTest(1_000);
|
|
935
|
-
_setProcessAliveForTest(() => true);
|
|
936
|
-
|
|
937
|
-
const platform = mockPlatform("feishu");
|
|
938
|
-
setSessionPlatform(platform);
|
|
939
|
-
bindChatToSession("sid-response-stall", "chat-response-stall");
|
|
940
|
-
recordLastActiveChat("sid-response-stall", "chat-response-stall");
|
|
941
|
-
|
|
942
|
-
const closeSession = vi.fn();
|
|
943
|
-
const adapter: ToolAdapter = {
|
|
944
|
-
displayName: "Any Agent",
|
|
945
|
-
sessionDescPrefix: "Agent Session:",
|
|
946
|
-
createSession: async () => ({ sessionId: "sid-response-stall" }),
|
|
947
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
948
|
-
closeSession: async () => {},
|
|
949
|
-
prompt: async function* (
|
|
950
|
-
_sid: string,
|
|
951
|
-
_text: string,
|
|
952
|
-
_cwd: string,
|
|
953
|
-
signal?: AbortSignal,
|
|
954
|
-
options?: ToolPromptOptions,
|
|
955
|
-
) {
|
|
956
|
-
options?.onSessionCreated?.(closeSession);
|
|
957
|
-
options?.onProcessStart?.({ pid: 4242 });
|
|
958
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
959
|
-
await new Promise<void>((resolve) => {
|
|
960
|
-
if (signal?.aborted) {
|
|
961
|
-
resolve();
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
965
|
-
});
|
|
966
|
-
},
|
|
967
|
-
};
|
|
968
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
969
|
-
|
|
970
|
-
const runPromise = runAgentSession(
|
|
971
|
-
"sid-response-stall",
|
|
972
|
-
"prompt",
|
|
973
|
-
platform,
|
|
974
|
-
"chat-response-stall",
|
|
975
|
-
Date.now(),
|
|
976
|
-
"claude",
|
|
977
|
-
);
|
|
978
|
-
|
|
979
|
-
await vi.waitFor(() => {
|
|
980
|
-
expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
|
|
981
|
-
totalChars: 0,
|
|
982
|
-
unchangedSince: expect.any(Number),
|
|
983
|
-
});
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
|
|
987
|
-
const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
|
|
988
|
-
await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
|
|
989
|
-
expect(activePrompts.has("sid-response-stall")).toBe(true);
|
|
990
|
-
|
|
991
|
-
await vi.advanceTimersByTimeAsync(1_001);
|
|
992
|
-
await runPromise;
|
|
993
|
-
|
|
994
|
-
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
995
|
-
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
|
996
|
-
expect(activePrompts.has("sid-response-stall")).toBe(false);
|
|
997
|
-
expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
|
|
998
|
-
status: "auto_ended",
|
|
999
|
-
finalReply: "",
|
|
1000
|
-
autoEndedAt: expect.any(Number),
|
|
1001
|
-
});
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
it("does not detect response stalls when the adapter disables streamed-output monitoring", async () => {
|
|
1005
|
-
vi.setSystemTime(0);
|
|
1006
|
-
_setResponseStallTimeoutForTest(180_000);
|
|
1007
|
-
_setResponseStallCheckIntervalForTest(1_000);
|
|
1008
|
-
_setProcessAliveForTest(() => true);
|
|
1009
|
-
|
|
1010
|
-
const platform = mockPlatform("feishu");
|
|
1011
|
-
setSessionPlatform(platform);
|
|
1012
|
-
bindChatToSession("sid-non-streaming", "chat-non-streaming");
|
|
1013
|
-
recordLastActiveChat("sid-non-streaming", "chat-non-streaming");
|
|
1014
|
-
|
|
1015
|
-
let finishPrompt: (() => void) | undefined;
|
|
1016
|
-
const closeSession = vi.fn();
|
|
1017
|
-
const adapter: ToolAdapter = {
|
|
1018
|
-
displayName: "Non-streaming DeepCCC",
|
|
1019
|
-
sessionDescPrefix: "CCC Session:",
|
|
1020
|
-
responseStallDetectionEnabled: false,
|
|
1021
|
-
createSession: async () => ({ sessionId: "sid-non-streaming" }),
|
|
1022
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1023
|
-
closeSession: async () => {},
|
|
1024
|
-
prompt: async function* (
|
|
1025
|
-
_sid: string,
|
|
1026
|
-
_text: string,
|
|
1027
|
-
_cwd: string,
|
|
1028
|
-
_signal?: AbortSignal,
|
|
1029
|
-
options?: ToolPromptOptions,
|
|
1030
|
-
) {
|
|
1031
|
-
options?.onSessionCreated?.(closeSession);
|
|
1032
|
-
options?.onProcessStart?.({ pid: 4545 });
|
|
1033
|
-
yield { type: "assistant", blocks: [{ type: "agent_status", status: "responding" }] };
|
|
1034
|
-
await new Promise<void>((resolve) => {
|
|
1035
|
-
finishPrompt = resolve;
|
|
1036
|
-
});
|
|
1037
|
-
},
|
|
1038
|
-
};
|
|
1039
|
-
_setAdapterForToolForTest("ccc", adapter);
|
|
1040
|
-
|
|
1041
|
-
const runPromise = runAgentSession(
|
|
1042
|
-
"sid-non-streaming",
|
|
1043
|
-
"prompt",
|
|
1044
|
-
platform,
|
|
1045
|
-
"chat-non-streaming",
|
|
1046
|
-
Date.now(),
|
|
1047
|
-
"ccc",
|
|
1048
|
-
);
|
|
1049
|
-
|
|
1050
|
-
await vi.waitFor(() => {
|
|
1051
|
-
expect(activePrompts.get("sid-non-streaming")?.processPid).toBe(4545);
|
|
1052
|
-
expect(finishPrompt).toBeTypeOf("function");
|
|
1053
|
-
});
|
|
1054
|
-
expect(activePrompts.get("sid-non-streaming")?.responseStallMonitor).toBeUndefined();
|
|
1055
|
-
|
|
1056
|
-
await vi.advanceTimersByTimeAsync(181_001);
|
|
1057
|
-
|
|
1058
|
-
expect(activePrompts.has("sid-non-streaming")).toBe(true);
|
|
1059
|
-
expect(closeSession).not.toHaveBeenCalled();
|
|
1060
|
-
expect(killProcessTreeMock).not.toHaveBeenCalledWith(4545);
|
|
1061
|
-
expect(mockStreamStates.get("sid-non-streaming")?.status).toBe("running");
|
|
1062
|
-
|
|
1063
|
-
finishPrompt?.();
|
|
1064
|
-
await runPromise;
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
it("does not apply the reply-stall timeout while an Agent is compacting context", async () => {
|
|
1068
|
-
vi.setSystemTime(0);
|
|
1069
|
-
_setResponseStallTimeoutForTest(180_000);
|
|
1070
|
-
_setResponseStallCheckIntervalForTest(1_000);
|
|
1071
|
-
_setProcessAliveForTest(() => true);
|
|
1072
|
-
|
|
1073
|
-
const platform = mockPlatform("feishu");
|
|
1074
|
-
setSessionPlatform(platform);
|
|
1075
|
-
bindChatToSession("sid-starting-stall", "chat-starting-stall");
|
|
1076
|
-
recordLastActiveChat("sid-starting-stall", "chat-starting-stall");
|
|
1077
|
-
|
|
1078
|
-
const closeSession = vi.fn();
|
|
1079
|
-
const adapter: ToolAdapter = {
|
|
1080
|
-
displayName: "Silent Agent",
|
|
1081
|
-
sessionDescPrefix: "Agent Session:",
|
|
1082
|
-
createSession: async () => ({ sessionId: "sid-starting-stall" }),
|
|
1083
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1084
|
-
closeSession: async () => {},
|
|
1085
|
-
prompt: async function* (
|
|
1086
|
-
_sid: string,
|
|
1087
|
-
_text: string,
|
|
1088
|
-
_cwd: string,
|
|
1089
|
-
signal?: AbortSignal,
|
|
1090
|
-
options?: ToolPromptOptions,
|
|
1091
|
-
) {
|
|
1092
|
-
options?.onSessionCreated?.(closeSession);
|
|
1093
|
-
options?.onProcessStart?.({ pid: 4343 });
|
|
1094
|
-
yield { type: "assistant", blocks: [{ type: "agent_status", status: "compacting" }] };
|
|
1095
|
-
await new Promise<void>((resolve) => {
|
|
1096
|
-
if (signal?.aborted) {
|
|
1097
|
-
resolve();
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1101
|
-
});
|
|
1102
|
-
if (false) yield { type: "assistant", blocks: [] };
|
|
1103
|
-
},
|
|
1104
|
-
};
|
|
1105
|
-
_setAdapterForToolForTest("codex", adapter);
|
|
1106
|
-
|
|
1107
|
-
const runPromise = runAgentSession(
|
|
1108
|
-
"sid-starting-stall",
|
|
1109
|
-
"prompt",
|
|
1110
|
-
platform,
|
|
1111
|
-
"chat-starting-stall",
|
|
1112
|
-
Date.now(),
|
|
1113
|
-
"codex",
|
|
1114
|
-
);
|
|
1115
|
-
|
|
1116
|
-
await vi.waitFor(() => {
|
|
1117
|
-
expect(activePrompts.get("sid-starting-stall")).toMatchObject({
|
|
1118
|
-
processPid: 4343,
|
|
1119
|
-
});
|
|
1120
|
-
expect(activePrompts.get("sid-starting-stall")?.responseProgress).toBeUndefined();
|
|
1121
|
-
expect(closeSession).toHaveBeenCalledTimes(0);
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
await vi.advanceTimersByTimeAsync(181_001);
|
|
1125
|
-
const stateAfterDeadline = mockStreamStates.get("sid-starting-stall");
|
|
1126
|
-
const closeCountAfterDeadline = closeSession.mock.calls.length;
|
|
1127
|
-
const treeKillCountAfterDeadline = killProcessTreeMock.mock.calls
|
|
1128
|
-
.filter(([pid]) => pid === 4343)
|
|
1129
|
-
.length;
|
|
1130
|
-
|
|
1131
|
-
// 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
|
|
1132
|
-
stopSession("sid-starting-stall");
|
|
1133
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
1134
|
-
await runPromise;
|
|
1135
|
-
|
|
1136
|
-
expect(stateAfterDeadline).toMatchObject({
|
|
1137
|
-
status: "running",
|
|
1138
|
-
activity: { kind: "compacting" },
|
|
1139
|
-
});
|
|
1140
|
-
expect(closeCountAfterDeadline).toBe(0);
|
|
1141
|
-
expect(treeKillCountAfterDeadline).toBe(0);
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
|
|
1145
|
-
vi.setSystemTime(0);
|
|
1146
|
-
_setResponseStallTimeoutForTest(100);
|
|
1147
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1148
|
-
_setProcessAliveForTest(() => true);
|
|
1149
|
-
|
|
1150
|
-
const platform = mockPlatform("feishu");
|
|
1151
|
-
setSessionPlatform(platform);
|
|
1152
|
-
|
|
1153
|
-
const receivedPrompts: string[] = [];
|
|
1154
|
-
const adapter: ToolAdapter = {
|
|
1155
|
-
displayName: "Any Agent",
|
|
1156
|
-
sessionDescPrefix: "Agent Session:",
|
|
1157
|
-
createSession: async () => ({ sessionId: "sid-binding-recovery" }),
|
|
1158
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1159
|
-
closeSession: async () => {},
|
|
1160
|
-
prompt: async function* (
|
|
1161
|
-
_sid: string,
|
|
1162
|
-
text: string,
|
|
1163
|
-
_cwd: string,
|
|
1164
|
-
signal?: AbortSignal,
|
|
1165
|
-
options?: ToolPromptOptions,
|
|
1166
|
-
) {
|
|
1167
|
-
receivedPrompts.push(text);
|
|
1168
|
-
options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
|
|
1169
|
-
if (receivedPrompts.length === 1) {
|
|
1170
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1171
|
-
await new Promise<void>((resolve) => {
|
|
1172
|
-
if (signal?.aborted) {
|
|
1173
|
-
resolve();
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1177
|
-
});
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
yield {
|
|
1181
|
-
type: "assistant",
|
|
1182
|
-
blocks: [{ type: "text", text: "recovery completed" }],
|
|
1183
|
-
isFinalResponse: true,
|
|
1184
|
-
};
|
|
1185
|
-
},
|
|
1186
|
-
};
|
|
1187
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1188
|
-
|
|
1189
|
-
const firstRun = runAgentSession(
|
|
1190
|
-
"sid-binding-recovery",
|
|
1191
|
-
"first prompt",
|
|
1192
|
-
platform,
|
|
1193
|
-
"chat-binding-recovery",
|
|
1194
|
-
0,
|
|
1195
|
-
"claude",
|
|
1196
|
-
);
|
|
1197
|
-
|
|
1198
|
-
await vi.waitFor(() => {
|
|
1199
|
-
expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
|
|
1200
|
-
});
|
|
1201
|
-
const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
|
|
1202
|
-
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1203
|
-
await firstRun;
|
|
1204
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1205
|
-
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1206
|
-
await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
|
|
1207
|
-
|
|
1208
|
-
expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
|
|
1209
|
-
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
1210
|
-
expect(platform.cardSend).toHaveBeenCalledTimes(2);
|
|
1211
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1212
|
-
"chat-binding-recovery",
|
|
1213
|
-
expect.stringContaining(recoveryPrompt),
|
|
1214
|
-
);
|
|
1215
|
-
|
|
1216
|
-
const registry = JSON.parse(
|
|
1217
|
-
await readFile(join(tempDir, "session-registry.json"), "utf8"),
|
|
1218
|
-
) as Record<string, { running?: boolean }>;
|
|
1219
|
-
expect(registry["chat-binding-recovery"]?.running).toBe(false);
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
it("treats an authoritative final response that races timeout cleanup as completed", async () => {
|
|
1223
|
-
vi.setSystemTime(0);
|
|
1224
|
-
_setResponseStallTimeoutForTest(100);
|
|
1225
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1226
|
-
_setProcessAliveForTest(() => true);
|
|
1227
|
-
|
|
1228
|
-
const platform = mockPlatform("feishu");
|
|
1229
|
-
setSessionPlatform(platform);
|
|
1230
|
-
bindChatToSession("sid-final-race", "chat-final-race");
|
|
1231
|
-
recordLastActiveChat("sid-final-race", "chat-final-race");
|
|
1232
|
-
|
|
1233
|
-
const receivedPrompts: string[] = [];
|
|
1234
|
-
const adapter: ToolAdapter = {
|
|
1235
|
-
displayName: "Any Agent",
|
|
1236
|
-
sessionDescPrefix: "Agent Session:",
|
|
1237
|
-
createSession: async () => ({ sessionId: "sid-final-race" }),
|
|
1238
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1239
|
-
closeSession: async () => {},
|
|
1240
|
-
prompt: async function* (
|
|
1241
|
-
_sid: string,
|
|
1242
|
-
text: string,
|
|
1243
|
-
_cwd: string,
|
|
1244
|
-
signal?: AbortSignal,
|
|
1245
|
-
options?: ToolPromptOptions,
|
|
1246
|
-
) {
|
|
1247
|
-
receivedPrompts.push(text);
|
|
1248
|
-
options?.onProcessStart?.({ pid: 5151 });
|
|
1249
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1250
|
-
await new Promise<void>((resolve) => {
|
|
1251
|
-
if (signal?.aborted) {
|
|
1252
|
-
resolve();
|
|
1253
|
-
return;
|
|
1254
|
-
}
|
|
1255
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1256
|
-
});
|
|
1257
|
-
yield {
|
|
1258
|
-
type: "assistant",
|
|
1259
|
-
blocks: [{ type: "text", text: "completed at the timeout boundary" }],
|
|
1260
|
-
isFinalResponse: true,
|
|
1261
|
-
};
|
|
1262
|
-
},
|
|
1263
|
-
};
|
|
1264
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1265
|
-
|
|
1266
|
-
const run = runAgentSession(
|
|
1267
|
-
"sid-final-race",
|
|
1268
|
-
"prompt",
|
|
1269
|
-
platform,
|
|
1270
|
-
"chat-final-race",
|
|
1271
|
-
0,
|
|
1272
|
-
"claude",
|
|
1273
|
-
);
|
|
1274
|
-
await vi.waitFor(() => {
|
|
1275
|
-
expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
|
|
1276
|
-
});
|
|
1277
|
-
const progress = activePrompts.get("sid-final-race")!.responseProgress!;
|
|
1278
|
-
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1279
|
-
await run;
|
|
1280
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
1281
|
-
|
|
1282
|
-
expect(receivedPrompts).toHaveLength(1);
|
|
1283
|
-
expect(mockStreamStates.get("sid-final-race")).toMatchObject({
|
|
1284
|
-
status: "done",
|
|
1285
|
-
finalReply: "completed at the timeout boundary",
|
|
1286
|
-
});
|
|
1287
|
-
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
1288
|
-
"chat-final-race",
|
|
1289
|
-
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
1290
|
-
);
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
|
|
1294
|
-
vi.setSystemTime(0);
|
|
1295
|
-
_setResponseStallTimeoutForTest(100);
|
|
1296
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1297
|
-
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1298
|
-
_setProcessAliveForTest(() => true);
|
|
1299
|
-
|
|
1300
|
-
const platform = mockPlatform("feishu");
|
|
1301
|
-
setSessionPlatform(platform);
|
|
1302
|
-
bindChatToSession("sid-final-close", "chat-final-close");
|
|
1303
|
-
recordLastActiveChat("sid-final-close", "chat-final-close");
|
|
1304
|
-
|
|
1305
|
-
const closeSession = vi.fn();
|
|
1306
|
-
const adapter: ToolAdapter = {
|
|
1307
|
-
displayName: "Any Agent",
|
|
1308
|
-
sessionDescPrefix: "Agent Session:",
|
|
1309
|
-
createSession: async () => ({ sessionId: "sid-final-close" }),
|
|
1310
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1311
|
-
closeSession: async () => {},
|
|
1312
|
-
prompt: async function* (
|
|
1313
|
-
_sid: string,
|
|
1314
|
-
_text: string,
|
|
1315
|
-
_cwd: string,
|
|
1316
|
-
signal?: AbortSignal,
|
|
1317
|
-
options?: ToolPromptOptions,
|
|
1318
|
-
) {
|
|
1319
|
-
options?.onSessionCreated?.(closeSession);
|
|
1320
|
-
options?.onProcessStart?.({ pid: 7171 });
|
|
1321
|
-
yield {
|
|
1322
|
-
type: "assistant",
|
|
1323
|
-
blocks: [{ type: "text", text: "authoritative answer" }],
|
|
1324
|
-
};
|
|
1325
|
-
yield {
|
|
1326
|
-
type: "assistant",
|
|
1327
|
-
blocks: [],
|
|
1328
|
-
isFinalResponse: true,
|
|
1329
|
-
};
|
|
1330
|
-
await new Promise<void>((resolve) => {
|
|
1331
|
-
if (signal?.aborted) {
|
|
1332
|
-
resolve();
|
|
1333
|
-
return;
|
|
1334
|
-
}
|
|
1335
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1336
|
-
});
|
|
1337
|
-
},
|
|
1338
|
-
};
|
|
1339
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1340
|
-
|
|
1341
|
-
const run = runAgentSession(
|
|
1342
|
-
"sid-final-close",
|
|
1343
|
-
"prompt",
|
|
1344
|
-
platform,
|
|
1345
|
-
"chat-final-close",
|
|
1346
|
-
0,
|
|
1347
|
-
"claude",
|
|
1348
|
-
);
|
|
1349
|
-
|
|
1350
|
-
await vi.waitFor(() => {
|
|
1351
|
-
expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
|
|
1352
|
-
});
|
|
1353
|
-
await vi.advanceTimersByTimeAsync(900);
|
|
1354
|
-
expect(activePrompts.has("sid-final-close")).toBe(true);
|
|
1355
|
-
|
|
1356
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
1357
|
-
await run;
|
|
1358
|
-
|
|
1359
|
-
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
1360
|
-
expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
|
|
1361
|
-
expect(mockStreamStates.get("sid-final-close")).toMatchObject({
|
|
1362
|
-
status: "done",
|
|
1363
|
-
finalReply: "authoritative answer",
|
|
1364
|
-
});
|
|
1365
|
-
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
1366
|
-
"chat-final-close",
|
|
1367
|
-
expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
|
|
1368
|
-
);
|
|
1369
|
-
});
|
|
1370
|
-
|
|
1371
|
-
it("cancels the final-response close guard when the stream exits normally", async () => {
|
|
1372
|
-
vi.setSystemTime(0);
|
|
1373
|
-
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1374
|
-
_setProcessAliveForTest(() => true);
|
|
1375
|
-
|
|
1376
|
-
const platform = mockPlatform("feishu");
|
|
1377
|
-
setSessionPlatform(platform);
|
|
1378
|
-
bindChatToSession("sid-final-normal", "chat-final-normal");
|
|
1379
|
-
recordLastActiveChat("sid-final-normal", "chat-final-normal");
|
|
1380
|
-
|
|
1381
|
-
const closeSession = vi.fn();
|
|
1382
|
-
const adapter: ToolAdapter = {
|
|
1383
|
-
displayName: "Any Agent",
|
|
1384
|
-
sessionDescPrefix: "Agent Session:",
|
|
1385
|
-
createSession: async () => ({ sessionId: "sid-final-normal" }),
|
|
1386
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1387
|
-
closeSession: async () => {},
|
|
1388
|
-
prompt: async function* (
|
|
1389
|
-
_sid: string,
|
|
1390
|
-
_text: string,
|
|
1391
|
-
_cwd: string,
|
|
1392
|
-
_signal?: AbortSignal,
|
|
1393
|
-
options?: ToolPromptOptions,
|
|
1394
|
-
) {
|
|
1395
|
-
options?.onSessionCreated?.(closeSession);
|
|
1396
|
-
options?.onProcessStart?.({ pid: 7272 });
|
|
1397
|
-
yield {
|
|
1398
|
-
type: "assistant",
|
|
1399
|
-
blocks: [{ type: "text", text: "normal answer" }],
|
|
1400
|
-
};
|
|
1401
|
-
yield {
|
|
1402
|
-
type: "assistant",
|
|
1403
|
-
blocks: [],
|
|
1404
|
-
isFinalResponse: true,
|
|
1405
|
-
};
|
|
1406
|
-
},
|
|
1407
|
-
};
|
|
1408
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1409
|
-
|
|
1410
|
-
await runAgentSession(
|
|
1411
|
-
"sid-final-normal",
|
|
1412
|
-
"prompt",
|
|
1413
|
-
platform,
|
|
1414
|
-
"chat-final-normal",
|
|
1415
|
-
0,
|
|
1416
|
-
"claude",
|
|
1417
|
-
);
|
|
1418
|
-
await vi.advanceTimersByTimeAsync(2_000);
|
|
1419
|
-
|
|
1420
|
-
expect(closeSession).not.toHaveBeenCalled();
|
|
1421
|
-
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
|
1422
|
-
expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
|
|
1423
|
-
status: "done",
|
|
1424
|
-
finalReply: "normal answer",
|
|
1425
|
-
});
|
|
1426
|
-
});
|
|
1427
|
-
|
|
1428
|
-
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
1429
|
-
vi.setSystemTime(0);
|
|
1430
|
-
_setResponseStallTimeoutForTest(100);
|
|
1431
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1432
|
-
_setProcessAliveForTest(() => true);
|
|
1433
|
-
|
|
1434
|
-
const platform = mockPlatform("feishu");
|
|
1435
|
-
setSessionPlatform(platform);
|
|
1436
|
-
bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
|
|
1437
|
-
recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
|
|
1438
|
-
|
|
1439
|
-
const receivedPrompts: string[] = [];
|
|
1440
|
-
const adapter: ToolAdapter = {
|
|
1441
|
-
displayName: "Any Agent",
|
|
1442
|
-
sessionDescPrefix: "Agent Session:",
|
|
1443
|
-
createSession: async () => ({ sessionId: "sid-recovery-priority" }),
|
|
1444
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1445
|
-
closeSession: async () => {},
|
|
1446
|
-
prompt: async function* (
|
|
1447
|
-
_sid: string,
|
|
1448
|
-
text: string,
|
|
1449
|
-
_cwd: string,
|
|
1450
|
-
signal?: AbortSignal,
|
|
1451
|
-
options?: ToolPromptOptions,
|
|
1452
|
-
) {
|
|
1453
|
-
receivedPrompts.push(text);
|
|
1454
|
-
options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
|
|
1455
|
-
if (receivedPrompts.length === 1) {
|
|
1456
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1457
|
-
await new Promise<void>((resolve) => {
|
|
1458
|
-
if (signal?.aborted) {
|
|
1459
|
-
resolve();
|
|
1460
|
-
return;
|
|
1461
|
-
}
|
|
1462
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1463
|
-
});
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
|
|
1467
|
-
},
|
|
1468
|
-
};
|
|
1469
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1470
|
-
|
|
1471
|
-
enqueueMessage("sid-recovery-priority", {
|
|
1472
|
-
text: "queued user prompt",
|
|
1473
|
-
chatId: "chat-recovery-priority",
|
|
1474
|
-
openId: "open-user",
|
|
1475
|
-
msgTimestamp: 1,
|
|
1476
|
-
chatType: "p2p",
|
|
1477
|
-
});
|
|
1478
|
-
const consumeQueued = vi.fn();
|
|
1479
|
-
setQueueConsumer(consumeQueued);
|
|
1480
|
-
|
|
1481
|
-
const firstRun = runAgentSession(
|
|
1482
|
-
"sid-recovery-priority",
|
|
1483
|
-
"first prompt",
|
|
1484
|
-
platform,
|
|
1485
|
-
"chat-recovery-priority",
|
|
1486
|
-
0,
|
|
1487
|
-
"claude",
|
|
1488
|
-
);
|
|
1489
|
-
|
|
1490
|
-
await vi.waitFor(() => {
|
|
1491
|
-
expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
|
|
1492
|
-
});
|
|
1493
|
-
const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
|
|
1494
|
-
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1495
|
-
await firstRun;
|
|
1496
|
-
|
|
1497
|
-
expect(receivedPrompts).toHaveLength(1);
|
|
1498
|
-
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1499
|
-
expect(isSessionRunning("sid-recovery-priority")).toBe(true);
|
|
1500
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1501
|
-
"chat-recovery-priority",
|
|
1502
|
-
expect.stringContaining(recoveryPrompt),
|
|
1503
|
-
);
|
|
1504
|
-
|
|
1505
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1506
|
-
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1507
|
-
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1508
|
-
expect(receivedPrompts[1]).not.toContain("queued user prompt");
|
|
1509
|
-
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1510
|
-
|
|
1511
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1512
|
-
await vi.waitFor(() => expect(consumeQueued).toHaveBeenCalledTimes(1));
|
|
1513
|
-
expect(consumeQueued).toHaveBeenCalledWith(
|
|
1514
|
-
platform,
|
|
1515
|
-
expect.objectContaining({ text: "queued user prompt" }),
|
|
1516
|
-
);
|
|
1517
|
-
setQueueConsumer(() => {});
|
|
1518
|
-
});
|
|
1519
|
-
|
|
1520
|
-
it("does not auto-recover a second consecutive response stall", async () => {
|
|
1521
|
-
vi.setSystemTime(0);
|
|
1522
|
-
_setResponseStallTimeoutForTest(100);
|
|
1523
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1524
|
-
_setProcessAliveForTest(() => true);
|
|
1525
|
-
|
|
1526
|
-
const platform = mockPlatform("feishu");
|
|
1527
|
-
setSessionPlatform(platform);
|
|
1528
|
-
bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
|
|
1529
|
-
recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
|
|
1530
|
-
|
|
1531
|
-
const receivedPrompts: string[] = [];
|
|
1532
|
-
const adapter: ToolAdapter = {
|
|
1533
|
-
displayName: "Any Agent",
|
|
1534
|
-
sessionDescPrefix: "Agent Session:",
|
|
1535
|
-
createSession: async () => ({ sessionId: "sid-recovery-limit" }),
|
|
1536
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1537
|
-
closeSession: async () => {},
|
|
1538
|
-
prompt: async function* (
|
|
1539
|
-
_sid: string,
|
|
1540
|
-
text: string,
|
|
1541
|
-
_cwd: string,
|
|
1542
|
-
signal?: AbortSignal,
|
|
1543
|
-
options?: ToolPromptOptions,
|
|
1544
|
-
) {
|
|
1545
|
-
receivedPrompts.push(text);
|
|
1546
|
-
options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
|
|
1547
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1548
|
-
await new Promise<void>((resolve) => {
|
|
1549
|
-
if (signal?.aborted) {
|
|
1550
|
-
resolve();
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1554
|
-
});
|
|
1555
|
-
},
|
|
1556
|
-
};
|
|
1557
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1558
|
-
|
|
1559
|
-
const firstRun = runAgentSession(
|
|
1560
|
-
"sid-recovery-limit",
|
|
1561
|
-
"first prompt",
|
|
1562
|
-
platform,
|
|
1563
|
-
"chat-recovery-limit",
|
|
1564
|
-
0,
|
|
1565
|
-
"claude",
|
|
1566
|
-
);
|
|
1567
|
-
await vi.waitFor(() => {
|
|
1568
|
-
expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
|
|
1569
|
-
});
|
|
1570
|
-
const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1571
|
-
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1572
|
-
await firstRun;
|
|
1573
|
-
|
|
1574
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1575
|
-
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1576
|
-
const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1577
|
-
await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
|
|
1578
|
-
await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
|
|
1579
|
-
|
|
1580
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
1581
|
-
expect(receivedPrompts).toHaveLength(2);
|
|
1582
|
-
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1583
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1584
|
-
"chat-recovery-limit",
|
|
1585
|
-
"⚠️ 自动续跑仍连续 3 分钟没有生成新回复,本次不再自动继续。",
|
|
1586
|
-
);
|
|
1587
|
-
});
|
|
1588
|
-
|
|
1589
|
-
it("lets /stop cancel a reserved recovery before it starts", async () => {
|
|
1590
|
-
vi.setSystemTime(0);
|
|
1591
|
-
_setResponseStallTimeoutForTest(100);
|
|
1592
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1593
|
-
_setProcessAliveForTest(() => true);
|
|
1594
|
-
|
|
1595
|
-
const platform = mockPlatform("feishu");
|
|
1596
|
-
setSessionPlatform(platform);
|
|
1597
|
-
bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
|
|
1598
|
-
recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
|
|
1599
|
-
|
|
1600
|
-
const receivedPrompts: string[] = [];
|
|
1601
|
-
const adapter: ToolAdapter = {
|
|
1602
|
-
displayName: "Any Agent",
|
|
1603
|
-
sessionDescPrefix: "Agent Session:",
|
|
1604
|
-
createSession: async () => ({ sessionId: "sid-recovery-stop" }),
|
|
1605
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1606
|
-
closeSession: async () => {},
|
|
1607
|
-
prompt: async function* (
|
|
1608
|
-
_sid: string,
|
|
1609
|
-
text: string,
|
|
1610
|
-
_cwd: string,
|
|
1611
|
-
signal?: AbortSignal,
|
|
1612
|
-
) {
|
|
1613
|
-
receivedPrompts.push(text);
|
|
1614
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1615
|
-
await new Promise<void>((resolve) => {
|
|
1616
|
-
if (signal?.aborted) {
|
|
1617
|
-
resolve();
|
|
1618
|
-
return;
|
|
1619
|
-
}
|
|
1620
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1621
|
-
});
|
|
1622
|
-
},
|
|
1623
|
-
};
|
|
1624
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1625
|
-
|
|
1626
|
-
const firstRun = runAgentSession(
|
|
1627
|
-
"sid-recovery-stop",
|
|
1628
|
-
"first prompt",
|
|
1629
|
-
platform,
|
|
1630
|
-
"chat-recovery-stop",
|
|
1631
|
-
0,
|
|
1632
|
-
"claude",
|
|
1633
|
-
);
|
|
1634
|
-
await vi.waitFor(() => {
|
|
1635
|
-
expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
|
|
1636
|
-
});
|
|
1637
|
-
const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
|
|
1638
|
-
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1639
|
-
await firstRun;
|
|
1640
|
-
|
|
1641
|
-
expect(isSessionRunning("sid-recovery-stop")).toBe(true);
|
|
1642
|
-
expect(stopSession("sid-recovery-stop")).toBe(true);
|
|
1643
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
1644
|
-
|
|
1645
|
-
expect(receivedPrompts).toHaveLength(1);
|
|
1646
|
-
expect(isSessionRunning("sid-recovery-stop")).toBe(false);
|
|
1647
|
-
});
|
|
1648
|
-
});
|
|
1649
|
-
|
|
1650
|
-
describe("unified display loop WeChat delta", () => {
|
|
1651
|
-
beforeEach(() => {
|
|
1652
|
-
vi.useFakeTimers();
|
|
1653
|
-
resetState();
|
|
1654
|
-
resetBindingState();
|
|
1655
|
-
mockStreamStates.clear();
|
|
1656
|
-
});
|
|
1657
|
-
|
|
1658
|
-
afterEach(() => {
|
|
1659
|
-
stopUnifiedDisplayLoop();
|
|
1660
|
-
resetBindingState();
|
|
1661
|
-
vi.useRealTimers();
|
|
1662
|
-
});
|
|
1663
|
-
|
|
1664
|
-
it("sends only new accumulated content when tool output arrives before an already-sent final reply", async () => {
|
|
1665
|
-
const platform = mockPlatform("wechat");
|
|
1666
|
-
platform.kind = "wechat";
|
|
1667
|
-
setSessionPlatform(platform);
|
|
1668
|
-
|
|
1669
|
-
bindChatToSession("sid-wechat", "chat-wechat");
|
|
1670
|
-
recordLastActiveChat("sid-wechat", "chat-wechat");
|
|
1671
|
-
sessionInfoMap.set("chat-wechat", {
|
|
1672
|
-
sessionId: "sid-wechat",
|
|
1673
|
-
turnCount: 1,
|
|
1674
|
-
lastContextTokens: 0,
|
|
1675
|
-
startTime: 0,
|
|
1676
|
-
tool: "claude",
|
|
1677
|
-
});
|
|
1678
|
-
|
|
1679
|
-
// 模拟 runAgentSession 创建的 WeChat display 条目
|
|
1680
|
-
displayCards.set("chat-wechat", {
|
|
1681
|
-
cardId: "",
|
|
1682
|
-
sequence: 0,
|
|
1683
|
-
cardBusy: false,
|
|
1684
|
-
cardCreatedAt: Date.now(),
|
|
1685
|
-
lastSentContent: "",
|
|
1686
|
-
streamErrorNotified: false,
|
|
1687
|
-
sessionId: "sid-wechat",
|
|
1688
|
-
turnCount: 1,
|
|
1689
|
-
dotCount: 0,
|
|
1690
|
-
});
|
|
1691
|
-
|
|
1692
|
-
mockStreamStates.set("sid-wechat", {
|
|
1693
|
-
accumulatedContent: "",
|
|
1694
|
-
finalReply: "partial reply",
|
|
1695
|
-
status: "running",
|
|
1696
|
-
});
|
|
1697
|
-
startUnifiedDisplayLoop();
|
|
1698
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
1699
|
-
|
|
1700
|
-
mockStreamStates.set("sid-wechat", {
|
|
1701
|
-
accumulatedContent: "tool output\n",
|
|
1702
|
-
finalReply: "partial reply",
|
|
1703
|
-
status: "running",
|
|
1704
|
-
});
|
|
1705
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
1706
|
-
|
|
1707
|
-
expect(platform.sendText).toHaveBeenNthCalledWith(
|
|
1708
|
-
1,
|
|
1709
|
-
"chat-wechat",
|
|
1710
|
-
"partial reply",
|
|
1711
|
-
);
|
|
1712
|
-
expect(platform.sendText).toHaveBeenNthCalledWith(
|
|
1713
|
-
2,
|
|
1714
|
-
"chat-wechat",
|
|
1715
|
-
"tool output",
|
|
1716
|
-
);
|
|
1717
|
-
});
|
|
1718
|
-
|
|
1719
|
-
});
|
|
1720
|
-
|
|
1721
|
-
describe("unified display loop activity status", () => {
|
|
1722
|
-
beforeEach(() => {
|
|
1723
|
-
vi.useFakeTimers();
|
|
1724
|
-
vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
|
|
1725
|
-
resetState();
|
|
1726
|
-
resetBindingState();
|
|
1727
|
-
mockStreamStates.clear();
|
|
1728
|
-
});
|
|
1729
|
-
|
|
1730
|
-
afterEach(() => {
|
|
1731
|
-
stopUnifiedDisplayLoop();
|
|
1732
|
-
resetBindingState();
|
|
1733
|
-
vi.useRealTimers();
|
|
1734
|
-
});
|
|
1735
|
-
|
|
1736
|
-
it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
|
|
1737
|
-
const platform = mockPlatform("feishu");
|
|
1738
|
-
setSessionPlatform(platform);
|
|
1739
|
-
|
|
1740
|
-
bindChatToSession("sid-activity", "chat-activity");
|
|
1741
|
-
recordLastActiveChat("sid-activity", "chat-activity");
|
|
1742
|
-
sessionInfoMap.set("chat-activity", {
|
|
1743
|
-
sessionId: "sid-activity",
|
|
1744
|
-
turnCount: 1,
|
|
1745
|
-
lastContextTokens: 0,
|
|
1746
|
-
startTime: Date.now() - 12_000,
|
|
1747
|
-
tool: "codex",
|
|
1748
|
-
});
|
|
1749
|
-
displayCards.set("chat-activity", {
|
|
1750
|
-
cardId: "card-activity",
|
|
1751
|
-
sequence: 1,
|
|
1752
|
-
cardBusy: false,
|
|
1753
|
-
cardCreatedAt: Date.now(),
|
|
1754
|
-
lastSentContent: "",
|
|
1755
|
-
streamErrorNotified: false,
|
|
1756
|
-
sessionId: "sid-activity",
|
|
1757
|
-
turnCount: 1,
|
|
1758
|
-
dotCount: 0,
|
|
1759
|
-
});
|
|
1760
|
-
mockStreamStates.set("sid-activity", {
|
|
1761
|
-
accumulatedContent: "正在检查日志",
|
|
1762
|
-
finalReply: "",
|
|
1763
|
-
activity: {
|
|
1764
|
-
kind: "tool",
|
|
1765
|
-
startedAt: Date.now() - 12_000,
|
|
1766
|
-
toolName: "Shell",
|
|
1767
|
-
toolCount: 1,
|
|
1768
|
-
},
|
|
1769
|
-
status: "running",
|
|
1770
|
-
turnCount: 1,
|
|
1771
|
-
});
|
|
1772
|
-
|
|
1773
|
-
startUnifiedDisplayLoop();
|
|
1774
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
1775
|
-
|
|
1776
|
-
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1777
|
-
expect(payload).toBeTypeOf("string");
|
|
1778
|
-
const card = JSON.parse(payload as string) as {
|
|
1779
|
-
header: { title: { content: string } };
|
|
1780
|
-
body: { elements: Array<{ tag: string; content?: string }> };
|
|
1781
|
-
};
|
|
1782
|
-
expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
|
|
1783
|
-
expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
|
|
1784
|
-
});
|
|
1785
|
-
});
|
|
1786
|
-
|
|
1787
|
-
describe("unified display loop terminal card update", () => {
|
|
1788
|
-
beforeEach(() => {
|
|
1789
|
-
vi.useFakeTimers();
|
|
1790
|
-
resetState();
|
|
1791
|
-
resetBindingState();
|
|
1792
|
-
mockStreamStates.clear();
|
|
1793
|
-
});
|
|
1794
|
-
|
|
1795
|
-
afterEach(() => {
|
|
1796
|
-
stopUnifiedDisplayLoop();
|
|
1797
|
-
resetBindingState();
|
|
1798
|
-
vi.useRealTimers();
|
|
1799
|
-
});
|
|
1800
|
-
|
|
1801
|
-
it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
|
|
1802
|
-
const platform = mockPlatform("feishu");
|
|
1803
|
-
setSessionPlatform(platform);
|
|
1804
|
-
|
|
1805
|
-
bindChatToSession("sid-auto-ended", "chat-auto-ended");
|
|
1806
|
-
recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
|
|
1807
|
-
sessionInfoMap.set("chat-auto-ended", {
|
|
1808
|
-
sessionId: "sid-auto-ended",
|
|
1809
|
-
turnCount: 1,
|
|
1810
|
-
lastContextTokens: 0,
|
|
1811
|
-
startTime: 0,
|
|
1812
|
-
tool: "cursor",
|
|
1813
|
-
});
|
|
1814
|
-
displayCards.set("chat-auto-ended", {
|
|
1815
|
-
cardId: "card-auto-ended",
|
|
1816
|
-
sequence: 1,
|
|
1817
|
-
cardBusy: false,
|
|
1818
|
-
cardCreatedAt: Date.now(),
|
|
1819
|
-
lastSentContent: "",
|
|
1820
|
-
streamErrorNotified: false,
|
|
1821
|
-
sessionId: "sid-auto-ended",
|
|
1822
|
-
turnCount: 1,
|
|
1823
|
-
dotCount: 0,
|
|
1824
|
-
});
|
|
1825
|
-
mockStreamStates.set("sid-auto-ended", {
|
|
1826
|
-
accumulatedContent: "",
|
|
1827
|
-
finalReply: "",
|
|
1828
|
-
status: "auto_ended",
|
|
1829
|
-
turnCount: 1,
|
|
1830
|
-
autoEndedAt: Date.now(),
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
startUnifiedDisplayLoop();
|
|
1834
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
1835
|
-
|
|
1836
|
-
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1837
|
-
const card = JSON.parse(payload as string) as {
|
|
1838
|
-
header: { title: { content: string }; template?: string };
|
|
1839
|
-
};
|
|
1840
|
-
expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
|
|
1841
|
-
expect(card.header.template).toBe("orange");
|
|
1842
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1843
|
-
"chat-auto-ended",
|
|
1844
|
-
"⚠️ 已自动结束:生成回复阶段连续 3 分钟没有字符变化。本轮没有可发送的回复内容。",
|
|
1845
|
-
);
|
|
1846
|
-
expect(displayCards.has("chat-auto-ended")).toBe(false);
|
|
1847
|
-
});
|
|
1848
|
-
|
|
1849
|
-
it("shows the stream root cause in the error card without a duplicate text notice", async () => {
|
|
1850
|
-
const platform = mockPlatform("feishu");
|
|
1851
|
-
setSessionPlatform(platform);
|
|
1852
|
-
|
|
1853
|
-
bindChatToSession("sid-network-error", "chat-network-error");
|
|
1854
|
-
recordLastActiveChat("sid-network-error", "chat-network-error");
|
|
1855
|
-
sessionInfoMap.set("chat-network-error", {
|
|
1856
|
-
sessionId: "sid-network-error",
|
|
1857
|
-
turnCount: 1,
|
|
1858
|
-
lastContextTokens: 0,
|
|
1859
|
-
startTime: 0,
|
|
1860
|
-
tool: "ccc",
|
|
1861
|
-
});
|
|
1862
|
-
displayCards.set("chat-network-error", {
|
|
1863
|
-
cardId: "card-network-error",
|
|
1864
|
-
sequence: 1,
|
|
1865
|
-
cardBusy: false,
|
|
1866
|
-
cardCreatedAt: Date.now(),
|
|
1867
|
-
lastSentContent: "",
|
|
1868
|
-
streamErrorNotified: false,
|
|
1869
|
-
sessionId: "sid-network-error",
|
|
1870
|
-
turnCount: 1,
|
|
1871
|
-
dotCount: 0,
|
|
1872
|
-
});
|
|
1873
|
-
mockStreamStates.set("sid-network-error", {
|
|
1874
|
-
accumulatedContent: "",
|
|
1875
|
-
finalReply: "",
|
|
1876
|
-
status: "error",
|
|
1877
|
-
turnCount: 1,
|
|
1878
|
-
terminalError: {
|
|
1879
|
-
kind: "network_timeout",
|
|
1880
|
-
title: "网络连接超时",
|
|
1881
|
-
message: "连接模型服务失败,已重试 3 次,单次连接等待 10 秒。请检查网络、VPN或模型服务状态后重试。",
|
|
1882
|
-
occurredAt: Date.now(),
|
|
1883
|
-
},
|
|
1884
|
-
});
|
|
1885
|
-
|
|
1886
|
-
startUnifiedDisplayLoop();
|
|
1887
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
1888
|
-
|
|
1889
|
-
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1890
|
-
const card = JSON.parse(payload as string) as {
|
|
1891
|
-
header: { title: { content: string }; template?: string };
|
|
1892
|
-
body: { elements: Array<{ content?: string }> };
|
|
1893
|
-
};
|
|
1894
|
-
expect(card.header.title.content).toBe("异常结束 · 网络连接超时");
|
|
1895
|
-
expect(card.header.template).toBe("red");
|
|
1896
|
-
expect(card.body.elements[0]?.content).toContain("已重试 3 次");
|
|
1897
|
-
expect(platform.sendText).not.toHaveBeenCalled();
|
|
1898
|
-
expect(mockStreamStates.get("sid-network-error")?.finalReplySentTurn).toBe(1);
|
|
1899
|
-
expect(displayCards.has("chat-network-error")).toBe(false);
|
|
1900
|
-
});
|
|
1901
|
-
|
|
1902
|
-
it("falls back to a root-cause text when the error card cannot be updated", async () => {
|
|
1903
|
-
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1904
|
-
const platform = mockPlatform("feishu");
|
|
1905
|
-
platform.cardUpdate = vi.fn(async () => {
|
|
1906
|
-
throw new Error("CardKit unavailable");
|
|
1907
|
-
});
|
|
1908
|
-
setSessionPlatform(platform);
|
|
1909
|
-
|
|
1910
|
-
bindChatToSession("sid-error-fallback", "chat-error-fallback");
|
|
1911
|
-
recordLastActiveChat("sid-error-fallback", "chat-error-fallback");
|
|
1912
|
-
sessionInfoMap.set("chat-error-fallback", {
|
|
1913
|
-
sessionId: "sid-error-fallback",
|
|
1914
|
-
turnCount: 1,
|
|
1915
|
-
lastContextTokens: 0,
|
|
1916
|
-
startTime: 0,
|
|
1917
|
-
tool: "ccc",
|
|
1918
|
-
});
|
|
1919
|
-
displayCards.set("chat-error-fallback", {
|
|
1920
|
-
cardId: "card-error-fallback",
|
|
1921
|
-
sequence: 1,
|
|
1922
|
-
cardBusy: false,
|
|
1923
|
-
cardCreatedAt: Date.now(),
|
|
1924
|
-
lastSentContent: "",
|
|
1925
|
-
streamErrorNotified: false,
|
|
1926
|
-
sessionId: "sid-error-fallback",
|
|
1927
|
-
turnCount: 1,
|
|
1928
|
-
dotCount: 0,
|
|
1929
|
-
});
|
|
1930
|
-
mockStreamStates.set("sid-error-fallback", {
|
|
1931
|
-
accumulatedContent: "",
|
|
1932
|
-
finalReply: "",
|
|
1933
|
-
status: "error",
|
|
1934
|
-
turnCount: 1,
|
|
1935
|
-
terminalError: {
|
|
1936
|
-
kind: "network_timeout",
|
|
1937
|
-
title: "网络连接超时",
|
|
1938
|
-
message: "连接模型服务失败,已重试 3 次。",
|
|
1939
|
-
occurredAt: Date.now(),
|
|
1940
|
-
},
|
|
1941
|
-
});
|
|
1942
|
-
|
|
1943
|
-
startUnifiedDisplayLoop();
|
|
1944
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
1945
|
-
|
|
1946
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1947
|
-
"chat-error-fallback",
|
|
1948
|
-
expect.stringContaining("异常结束:网络连接超时"),
|
|
1949
|
-
);
|
|
1950
|
-
expect(displayCards.has("chat-error-fallback")).toBe(false);
|
|
1951
|
-
});
|
|
1952
|
-
|
|
1953
|
-
it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
|
|
1954
|
-
const platform = mockPlatform("feishu");
|
|
1955
|
-
platform.cardUpdate = vi.fn(async () => {
|
|
1956
|
-
throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
|
|
1957
|
-
});
|
|
1958
|
-
setSessionPlatform(platform);
|
|
1959
|
-
|
|
1960
|
-
bindChatToSession("sid-terminal", "chat-terminal");
|
|
1961
|
-
recordLastActiveChat("sid-terminal", "chat-terminal");
|
|
1962
|
-
sessionInfoMap.set("chat-terminal", {
|
|
1963
|
-
sessionId: "sid-terminal",
|
|
1964
|
-
turnCount: 1,
|
|
1965
|
-
lastContextTokens: 0,
|
|
1966
|
-
startTime: 0,
|
|
1967
|
-
tool: "claude",
|
|
1968
|
-
});
|
|
1969
|
-
activePrompts.set("sid-terminal", {
|
|
1970
|
-
controller: new AbortController(),
|
|
1971
|
-
stopped: false,
|
|
1972
|
-
startTime: Date.now(),
|
|
1973
|
-
});
|
|
1974
|
-
displayCards.set("chat-terminal", {
|
|
1975
|
-
cardId: "card-terminal",
|
|
1976
|
-
sequence: 109,
|
|
1977
|
-
cardBusy: false,
|
|
1978
|
-
cardCreatedAt: Date.now(),
|
|
1979
|
-
lastSentContent: "",
|
|
1980
|
-
streamErrorNotified: false,
|
|
1981
|
-
sessionId: "sid-terminal",
|
|
1982
|
-
turnCount: 1,
|
|
1983
|
-
dotCount: 0,
|
|
1984
|
-
});
|
|
1985
|
-
mockStreamStates.set("sid-terminal", {
|
|
1986
|
-
accumulatedContent: "partial tool output",
|
|
1987
|
-
finalReply: "",
|
|
1988
|
-
status: "stopped",
|
|
1989
|
-
turnCount: 1,
|
|
1990
|
-
});
|
|
1991
|
-
|
|
1992
|
-
startUnifiedDisplayLoop();
|
|
1993
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
1994
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
1995
|
-
|
|
1996
|
-
expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
|
|
1997
|
-
expect(platform.cardUpdate).toHaveBeenCalledWith(
|
|
1998
|
-
"card-terminal",
|
|
1999
|
-
expect.any(String),
|
|
2000
|
-
110,
|
|
2001
|
-
);
|
|
2002
|
-
expect(displayCards.get("chat-terminal")?.sequence).toBe(110);
|
|
2003
|
-
});
|
|
2004
|
-
|
|
2005
|
-
it("keeps terminal display and retries final text when sending fails", async () => {
|
|
2006
|
-
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
2007
|
-
const platform = mockPlatform("feishu");
|
|
2008
|
-
platform.cardUpdate = vi.fn(async () => {});
|
|
2009
|
-
platform.sendText = vi.fn()
|
|
2010
|
-
.mockResolvedValueOnce(false)
|
|
2011
|
-
.mockResolvedValueOnce(true);
|
|
2012
|
-
setSessionPlatform(platform);
|
|
2013
|
-
|
|
2014
|
-
bindChatToSession("sid-terminal-retry", "chat-terminal-retry");
|
|
2015
|
-
recordLastActiveChat("sid-terminal-retry", "chat-terminal-retry");
|
|
2016
|
-
sessionInfoMap.set("chat-terminal-retry", {
|
|
2017
|
-
sessionId: "sid-terminal-retry",
|
|
2018
|
-
turnCount: 1,
|
|
2019
|
-
lastContextTokens: 0,
|
|
2020
|
-
startTime: 0,
|
|
2021
|
-
tool: "claude",
|
|
2022
|
-
});
|
|
2023
|
-
displayCards.set("chat-terminal-retry", {
|
|
2024
|
-
cardId: "card-terminal-retry",
|
|
2025
|
-
sequence: 4,
|
|
2026
|
-
cardBusy: false,
|
|
2027
|
-
cardCreatedAt: Date.now(),
|
|
2028
|
-
lastSentContent: "",
|
|
2029
|
-
streamErrorNotified: false,
|
|
2030
|
-
sessionId: "sid-terminal-retry",
|
|
2031
|
-
turnCount: 1,
|
|
2032
|
-
dotCount: 0,
|
|
2033
|
-
});
|
|
2034
|
-
mockStreamStates.set("sid-terminal-retry", {
|
|
2035
|
-
accumulatedContent: "work log",
|
|
2036
|
-
finalReply: "final answer",
|
|
2037
|
-
status: "done",
|
|
2038
|
-
turnCount: 1,
|
|
2039
|
-
});
|
|
2040
|
-
|
|
2041
|
-
startUnifiedDisplayLoop();
|
|
2042
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
2043
|
-
|
|
2044
|
-
expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
|
|
2045
|
-
expect(platform.sendText).toHaveBeenCalledTimes(1);
|
|
2046
|
-
expect(displayCards.has("chat-terminal-retry")).toBe(true);
|
|
2047
|
-
expect(mockStreamStates.get("sid-terminal-retry")?.finalReplySentTurn).toBeUndefined();
|
|
2048
|
-
|
|
2049
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
2050
|
-
|
|
2051
|
-
expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
|
|
2052
|
-
expect(platform.sendText).toHaveBeenCalledTimes(2);
|
|
2053
|
-
expect(displayCards.has("chat-terminal-retry")).toBe(false);
|
|
2054
|
-
expect(mockStreamStates.get("sid-terminal-retry")?.finalReplySentTurn).toBe(1);
|
|
2055
|
-
});
|
|
2056
|
-
});
|
|
2057
|
-
|
|
2058
|
-
describe("rebuildBindingsFromRegistry", () => {
|
|
2059
|
-
let registryFile = "";
|
|
2060
|
-
|
|
2061
|
-
beforeEach(async () => {
|
|
2062
|
-
chatSessionMap.clear();
|
|
2063
|
-
sessionInfoMap.clear();
|
|
2064
|
-
activePrompts.clear();
|
|
2065
|
-
processedMessages.clear();
|
|
2066
|
-
resetBindingState();
|
|
2067
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-rebuild-"));
|
|
2068
|
-
registryFile = join(dir, "session-registry.json");
|
|
2069
|
-
_setSessionRegistryFileForTest(registryFile);
|
|
2070
|
-
});
|
|
2071
|
-
|
|
2072
|
-
afterEach(async () => {
|
|
2073
|
-
_resetSessionRegistryFileForTest();
|
|
2074
|
-
if (registryFile) {
|
|
2075
|
-
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
2076
|
-
}
|
|
2077
|
-
});
|
|
2078
|
-
|
|
2079
|
-
it("不清空 activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃", async () => {
|
|
2080
|
-
// 模拟有一个后台 prompt 正在跑
|
|
2081
|
-
const controller = new AbortController();
|
|
2082
|
-
activePrompts.set("session-running", { controller, stopped: false, startTime: Date.now() });
|
|
2083
|
-
await recordSessionRegistry({
|
|
2084
|
-
chatId: "chat-A",
|
|
2085
|
-
sessionId: "session-running",
|
|
2086
|
-
tool: "claude",
|
|
2087
|
-
updatedAt: 100,
|
|
2088
|
-
});
|
|
2089
|
-
|
|
2090
|
-
await rebuildBindingsFromRegistry();
|
|
2091
|
-
|
|
2092
|
-
// 关键不变量:重连后 activePrompts 必须保留,否则后台 generator 会变孤儿
|
|
2093
|
-
expect(activePrompts.has("session-running")).toBe(true);
|
|
2094
|
-
expect(activePrompts.get("session-running")?.controller).toBe(controller);
|
|
2095
|
-
});
|
|
2096
|
-
|
|
2097
|
-
it("不清空 sessionInfoMap:轮数计数在重连后保留", async () => {
|
|
2098
|
-
sessionInfoMap.set("chat-A", {
|
|
2099
|
-
sessionId: "sid-A", turnCount: 7, lastContextTokens: 50000,
|
|
2100
|
-
startTime: 1000, tool: "claude",
|
|
2101
|
-
});
|
|
2102
|
-
await recordSessionRegistry({
|
|
2103
|
-
chatId: "chat-A", sessionId: "sid-A", tool: "claude", updatedAt: 100,
|
|
2104
|
-
});
|
|
2105
|
-
|
|
2106
|
-
await rebuildBindingsFromRegistry();
|
|
2107
|
-
|
|
2108
|
-
expect(sessionInfoMap.get("chat-A")?.turnCount).toBe(7);
|
|
2109
|
-
expect(sessionInfoMap.get("chat-A")?.lastContextTokens).toBe(50000);
|
|
2110
|
-
});
|
|
2111
|
-
|
|
2112
|
-
it("不清空 processedMessages:重连后 SDK 重推消息仍能去重", async () => {
|
|
2113
|
-
processedMessages.add("msg-id-1");
|
|
2114
|
-
processedMessages.add("msg-id-2");
|
|
2115
|
-
|
|
2116
|
-
await rebuildBindingsFromRegistry();
|
|
2117
|
-
|
|
2118
|
-
expect(processedMessages.has("msg-id-1")).toBe(true);
|
|
2119
|
-
expect(processedMessages.has("msg-id-2")).toBe(true);
|
|
2120
|
-
});
|
|
2121
|
-
|
|
2122
|
-
it("从 registry 重建 sessionId → chatId 映射(沿用 rebuildSessionChatsFromRegistry 行为)", async () => {
|
|
2123
|
-
await recordSessionRegistry({
|
|
2124
|
-
chatId: "chat-A", sessionId: "sid-X", tool: "claude", updatedAt: 100,
|
|
2125
|
-
});
|
|
2126
|
-
await recordSessionRegistry({
|
|
2127
|
-
chatId: "chat-B", sessionId: "sid-X", tool: "claude", updatedAt: 200,
|
|
2128
|
-
});
|
|
2129
|
-
|
|
2130
|
-
await rebuildBindingsFromRegistry();
|
|
2131
|
-
|
|
2132
|
-
// 同一 sessionId 被两个 chatId 共享时,两个都应在映射中
|
|
2133
|
-
bindChatToSession("sid-X", "chat-A"); // 验证幂等(再次调用不会出错)
|
|
2134
|
-
expect(true).toBe(true); // 真正的断言由 sessionChatsMap 通过 pickDisplayChat 等间接验证
|
|
2135
|
-
});
|
|
2136
|
-
});
|
|
2137
|
-
|
|
2138
|
-
describe("getSessionStatus", () => {
|
|
2139
|
-
beforeEach(() => {
|
|
2140
|
-
chatSessionMap.clear();
|
|
2141
|
-
sessionInfoMap.clear();
|
|
2142
|
-
activePrompts.clear();
|
|
2143
|
-
mockStreamStates.clear();
|
|
2144
|
-
});
|
|
2145
|
-
|
|
2146
|
-
afterEach(() => {
|
|
2147
|
-
_clearAdapterCacheForTest();
|
|
2148
|
-
});
|
|
2149
|
-
|
|
2150
|
-
it("returns null for unknown chatId", async () => {
|
|
2151
|
-
await expect(getSessionStatus("nonexistent")).resolves.toBeNull();
|
|
2152
|
-
});
|
|
2153
|
-
|
|
2154
|
-
it("returns status for idle session (info exists, no active session)", async () => {
|
|
2155
|
-
mockSessionInfo("chat1");
|
|
2156
|
-
const status = await getSessionStatus("chat1");
|
|
2157
|
-
expect(status).not.toBeNull();
|
|
2158
|
-
expect(status!.sessionId).toBe("test-session-id");
|
|
2159
|
-
expect(status!.running).toBe(false);
|
|
2160
|
-
expect(status!.turnCount).toBe(3);
|
|
2161
|
-
expect(status!.accumulatedLength).toBe(0);
|
|
2162
|
-
});
|
|
2163
|
-
|
|
2164
|
-
it("returns running=true for active session", async () => {
|
|
2165
|
-
mockSessionInfo("chat1");
|
|
2166
|
-
mockActiveSession("chat1", { accumulatedContent: "thinking...", finalText: "reply" });
|
|
2167
|
-
const status = await getSessionStatus("chat1");
|
|
2168
|
-
expect(status!.running).toBe(true);
|
|
2169
|
-
expect(status!.accumulatedLength).toBe(16); // "thinking..."(11) + "reply"(5)
|
|
2170
|
-
});
|
|
2171
|
-
|
|
2172
|
-
it("returns running=false for stopped session", async () => {
|
|
2173
|
-
mockSessionInfo("chat1");
|
|
2174
|
-
mockActiveSession("chat1", { stopped: true });
|
|
2175
|
-
const status = await getSessionStatus("chat1");
|
|
2176
|
-
expect(status!.running).toBe(false);
|
|
2177
|
-
});
|
|
2178
|
-
|
|
2179
|
-
it("returns correct turnCount and other info fields", async () => {
|
|
2180
|
-
mockSessionInfo("chat1", { turnCount: 7, lastContextTokens: 100000 });
|
|
2181
|
-
const status = await getSessionStatus("chat1");
|
|
2182
|
-
expect(status!.turnCount).toBe(7);
|
|
2183
|
-
expect(status!.lastContextTokens).toBe(100000);
|
|
2184
|
-
});
|
|
2185
|
-
|
|
2186
|
-
// -------------------------------------------------------------------------
|
|
2187
|
-
// model/effort 来源:按 tool 分支(核心契约——决定 /state 显示是否真实)
|
|
2188
|
-
// -------------------------------------------------------------------------
|
|
2189
|
-
|
|
2190
|
-
it("Claude 会话:effort 非 null(始终显示该行);model 来自全局配置", async () => {
|
|
2191
|
-
mockSessionInfo("chat-claude", { tool: "claude" });
|
|
2192
|
-
const status = await getSessionStatus("chat-claude");
|
|
2193
|
-
expect(status!.effort).not.toBeNull();
|
|
2194
|
-
// model 必为字符串(留空时显示 '(留空)',否则为环境变量值);不应是占位符
|
|
2195
|
-
expect(typeof status!.model).toBe("string");
|
|
2196
|
-
expect(status!.model.length).toBeGreaterThan(0);
|
|
2197
|
-
});
|
|
2198
|
-
|
|
2199
|
-
it("Codex session status uses the per-session effort override", async () => {
|
|
2200
|
-
mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
|
|
2201
|
-
setSessionEffortOverride("sid-codex-effort", "xhigh");
|
|
2202
|
-
|
|
2203
|
-
const status = await getSessionStatus("chat-codex");
|
|
2204
|
-
|
|
2205
|
-
expect(status!.effort).toBe("xhigh");
|
|
2206
|
-
});
|
|
2207
|
-
|
|
2208
|
-
it("CCC 会话:effort 默认取 config.ccc.effort,session override 优先", async () => {
|
|
2209
|
-
expect(getEffectiveEffortForTool("ccc")).toBe(config.ccc.effort);
|
|
2210
|
-
setSessionEffortOverride("sid-ccc-effort-default", "low");
|
|
2211
|
-
expect(getEffectiveEffortForTool("ccc", "sid-ccc-effort-default")).toBe("low");
|
|
2212
|
-
clearSessionEffortOverride("sid-ccc-effort-default");
|
|
2213
|
-
expect(getEffectiveEffortForTool("ccc", "sid-ccc-effort-default")).toBe(config.ccc.effort);
|
|
2214
|
-
});
|
|
2215
|
-
|
|
2216
|
-
it("Cursor 会话:effort 恒为 null(卡片渲染时隐藏该行,避免显示无意义的 effort)", async () => {
|
|
2217
|
-
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
2218
|
-
_setAdapterForToolForTest(
|
|
2219
|
-
"cursor",
|
|
2220
|
-
mockAdapter(() => ({ sessionId: "sid-cur", model: "Composer 2 Fast" })),
|
|
2221
|
-
);
|
|
2222
|
-
const status = await getSessionStatus("chat-cursor");
|
|
2223
|
-
expect(status!.effort).toBeNull();
|
|
2224
|
-
});
|
|
2225
|
-
|
|
2226
|
-
it("Cursor 会话:model 来自 adapter.getSessionInfo(真实模型,不是 ChatCCC 配置)", async () => {
|
|
2227
|
-
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
2228
|
-
_setAdapterForToolForTest(
|
|
2229
|
-
"cursor",
|
|
2230
|
-
mockAdapter((sid) =>
|
|
2231
|
-
sid === "sid-cur"
|
|
2232
|
-
? { sessionId: sid, cwd: "/tmp", model: "Composer 2 Fast" }
|
|
2233
|
-
: undefined,
|
|
2234
|
-
),
|
|
2235
|
-
);
|
|
2236
|
-
const status = await getSessionStatus("chat-cursor");
|
|
2237
|
-
expect(status!.model).toBe("Composer 2 Fast");
|
|
2238
|
-
});
|
|
2239
|
-
|
|
2240
|
-
it("Cursor 会话:adapter 没返回 model 时使用占位符(不应硬塞任何模型字面量)", async () => {
|
|
2241
|
-
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
2242
|
-
_setAdapterForToolForTest(
|
|
2243
|
-
"cursor",
|
|
2244
|
-
mockAdapter(() => ({ sessionId: "sid-cur" /* 无 model */ })),
|
|
2245
|
-
);
|
|
2246
|
-
const status = await getSessionStatus("chat-cursor");
|
|
2247
|
-
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
2248
|
-
});
|
|
2249
|
-
|
|
2250
|
-
it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
|
|
2251
|
-
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
2252
|
-
_setAdapterForToolForTest(
|
|
2253
|
-
"cursor",
|
|
2254
|
-
mockAdapter(() => {
|
|
2255
|
-
throw new Error("simulated adapter failure");
|
|
2256
|
-
}),
|
|
2257
|
-
);
|
|
2258
|
-
const status = await getSessionStatus("chat-cursor");
|
|
2259
|
-
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
2260
|
-
expect(status!.effort).toBeNull();
|
|
2261
|
-
});
|
|
2262
|
-
|
|
2263
|
-
it("CCC Agent 会话:model 来自 ccc 配置且 effort 恒为 null", async () => {
|
|
2264
|
-
mockSessionInfo("chat-ccc", { sessionId: "session-ccc", tool: "ccc" });
|
|
2265
|
-
|
|
2266
|
-
const status = await getSessionStatus("chat-ccc");
|
|
2267
|
-
|
|
2268
|
-
expect(status!.model).toBeTruthy();
|
|
2269
|
-
expect(status!.model).not.toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
2270
|
-
expect(status!.effort).toBeNull();
|
|
2271
|
-
});
|
|
2272
|
-
});
|
|
2273
|
-
|
|
2274
|
-
describe("getAllSessionsStatus", () => {
|
|
2275
|
-
let registryFile = "";
|
|
2276
|
-
let sessionsFile = "";
|
|
2277
|
-
|
|
2278
|
-
beforeEach(async () => {
|
|
2279
|
-
chatSessionMap.clear();
|
|
2280
|
-
sessionInfoMap.clear();
|
|
2281
|
-
activePrompts.clear();
|
|
2282
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-session-registry-"));
|
|
2283
|
-
registryFile = join(dir, "session-registry.json");
|
|
2284
|
-
sessionsFile = join(dir, "sessions.json");
|
|
2285
|
-
_setSessionRegistryFileForTest(registryFile);
|
|
2286
|
-
_setSessionToolsFileForTest(sessionsFile);
|
|
2287
|
-
});
|
|
2288
|
-
|
|
2289
|
-
afterEach(async () => {
|
|
2290
|
-
_clearAdapterCacheForTest();
|
|
2291
|
-
_resetSessionRegistryFileForTest();
|
|
2292
|
-
_resetSessionToolsFileForTest();
|
|
2293
|
-
if (registryFile) {
|
|
2294
|
-
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
2295
|
-
}
|
|
2296
|
-
});
|
|
2297
|
-
|
|
2298
|
-
it("returns empty array when no sessions", async () => {
|
|
2299
|
-
await expect(getAllSessionsStatus()).resolves.toEqual([]);
|
|
2300
|
-
});
|
|
2301
|
-
|
|
2302
|
-
it("does not read memory-only sessions", async () => {
|
|
2303
|
-
mockSessionInfo("chat1", { sessionId: "s1" });
|
|
2304
|
-
mockSessionInfo("chat2", { sessionId: "s2" });
|
|
2305
|
-
mockActiveSession("chat1");
|
|
2306
|
-
|
|
2307
|
-
const result = await getAllSessionsStatus();
|
|
2308
|
-
expect(result).toEqual([]);
|
|
2309
|
-
});
|
|
2310
|
-
|
|
2311
|
-
it("returns statuses from disk registry", async () => {
|
|
2312
|
-
await recordSessionRegistry({
|
|
2313
|
-
chatId: "chat1",
|
|
2314
|
-
sessionId: "s1",
|
|
2315
|
-
tool: "claude",
|
|
2316
|
-
chatType: "p2p",
|
|
2317
|
-
chatName: "test-chat-1",
|
|
2318
|
-
turnCount: 2,
|
|
2319
|
-
startTime: 1000,
|
|
2320
|
-
updatedAt: 2000,
|
|
2321
|
-
running: true,
|
|
2322
|
-
});
|
|
2323
|
-
await recordSessionRegistry({
|
|
2324
|
-
chatId: "chat2",
|
|
2325
|
-
sessionId: "s2",
|
|
2326
|
-
tool: "claude",
|
|
2327
|
-
chatName: "test-chat-2",
|
|
2328
|
-
turnCount: 0,
|
|
2329
|
-
startTime: 900,
|
|
2330
|
-
updatedAt: 1900,
|
|
2331
|
-
running: false,
|
|
2332
|
-
});
|
|
2333
|
-
|
|
2334
|
-
const result = await getAllSessionsStatus();
|
|
2335
|
-
expect(result).toHaveLength(2);
|
|
2336
|
-
expect(result[0].chatId).toBe("chat1");
|
|
2337
|
-
expect(result[0].chatType).toBe("p2p");
|
|
2338
|
-
// running=true in registry doesn't make it active — must be in activePrompts
|
|
2339
|
-
expect(result[0].active).toBe(false);
|
|
2340
|
-
expect(result[0].turnCount).toBe(2);
|
|
2341
|
-
expect(result[0].chatName).toBe("test-chat-1");
|
|
2342
|
-
expect(result[1].chatId).toBe("chat2");
|
|
2343
|
-
expect(result[1].active).toBe(false);
|
|
2344
|
-
expect(result[1].chatName).toBe("test-chat-2");
|
|
2345
|
-
});
|
|
2346
|
-
|
|
2347
|
-
it("includes recent sessions without a registry chat binding", async () => {
|
|
2348
|
-
await saveSessionTool("orphan-session", "claude");
|
|
2349
|
-
activePrompts.set("orphan-session", {
|
|
2350
|
-
controller: new AbortController(),
|
|
2351
|
-
stopped: false,
|
|
2352
|
-
startTime: 3000,
|
|
2353
|
-
});
|
|
2354
|
-
|
|
2355
|
-
const result = await getAllSessionsStatus();
|
|
2356
|
-
expect(result).toHaveLength(1);
|
|
2357
|
-
expect(result[0].sessionId).toBe("orphan-session");
|
|
2358
|
-
expect(result[0].chatId).toBe("");
|
|
2359
|
-
expect(result[0].active).toBe(true);
|
|
2360
|
-
expect(result[0].turnCount).toBe(0);
|
|
2361
|
-
});
|
|
2362
|
-
|
|
2363
|
-
it("orphan sessions preserve chatName from sessions.json", async () => {
|
|
2364
|
-
await saveSessionTool("orphan-with-name", "claude", "帮我写代码-src");
|
|
2365
|
-
const result = await getAllSessionsStatus();
|
|
2366
|
-
expect(result).toHaveLength(1);
|
|
2367
|
-
expect(result[0].sessionId).toBe("orphan-with-name");
|
|
2368
|
-
expect(result[0].chatId).toBe("");
|
|
2369
|
-
expect(result[0].chatName).toBe("帮我写代码-src");
|
|
2370
|
-
expect(result[0].active).toBe(false);
|
|
2371
|
-
});
|
|
2372
|
-
|
|
2373
|
-
it("returns recent disk sessions by updatedAt desc, limited to 20", async () => {
|
|
2374
|
-
for (let i = 0; i < 25; i++) {
|
|
2375
|
-
await recordSessionRegistry({
|
|
2376
|
-
chatId: `chat-${i}`,
|
|
2377
|
-
sessionId: `sid-${i}`,
|
|
2378
|
-
tool: "claude",
|
|
2379
|
-
startTime: i,
|
|
2380
|
-
updatedAt: 1000 + i,
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
const result = await getAllSessionsStatus();
|
|
2385
|
-
expect(result).toHaveLength(20);
|
|
2386
|
-
expect(result[0].chatId).toBe("chat-24");
|
|
2387
|
-
expect(result[19].chatId).toBe("chat-5");
|
|
2388
|
-
expect(result.some((r) => r.chatId === "chat-4")).toBe(false);
|
|
2389
|
-
});
|
|
2390
|
-
|
|
2391
|
-
it("shows session as inactive when not in activePrompts, regardless of registry running field", async () => {
|
|
2392
|
-
await recordSessionRegistry({
|
|
2393
|
-
chatId: "chat1",
|
|
2394
|
-
sessionId: "s1",
|
|
2395
|
-
tool: "claude",
|
|
2396
|
-
running: true,
|
|
2397
|
-
updatedAt: 1000,
|
|
2398
|
-
});
|
|
2399
|
-
|
|
2400
|
-
const result = await getAllSessionsStatus();
|
|
2401
|
-
// After restart, activePrompts is cleared; registry running=true should not show as active
|
|
2402
|
-
expect(result.find(r => r.chatId === "chat1")!.active).toBe(false);
|
|
2403
|
-
});
|
|
2404
|
-
|
|
2405
|
-
it("shows session as active when in activePrompts", async () => {
|
|
2406
|
-
await recordSessionRegistry({
|
|
2407
|
-
chatId: "chat1",
|
|
2408
|
-
sessionId: "s1",
|
|
2409
|
-
tool: "claude",
|
|
2410
|
-
running: false, // registry says false, but activePrompts wins
|
|
2411
|
-
updatedAt: 1000,
|
|
2412
|
-
});
|
|
2413
|
-
mockSessionInfo("chat1", { sessionId: "s1" });
|
|
2414
|
-
mockActiveSession("chat1");
|
|
2415
|
-
|
|
2416
|
-
const result = await getAllSessionsStatus();
|
|
2417
|
-
expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
|
|
2418
|
-
});
|
|
2419
|
-
|
|
2420
|
-
it("persists chatName across updates and defaults to empty string when not set", async () => {
|
|
2421
|
-
await recordSessionRegistry({
|
|
2422
|
-
chatId: "chat-a",
|
|
2423
|
-
sessionId: "sa",
|
|
2424
|
-
tool: "claude",
|
|
2425
|
-
chatName: "My Chat",
|
|
2426
|
-
updatedAt: 100,
|
|
2427
|
-
});
|
|
2428
|
-
// Update without chatName — should keep existing
|
|
2429
|
-
await recordSessionRegistry({
|
|
2430
|
-
chatId: "chat-a",
|
|
2431
|
-
sessionId: "sa",
|
|
2432
|
-
tool: "claude",
|
|
2433
|
-
updatedAt: 200,
|
|
2434
|
-
});
|
|
2435
|
-
const result = await getAllSessionsStatus();
|
|
2436
|
-
expect(result.find(r => r.chatId === "chat-a")!.chatName).toBe("My Chat");
|
|
2437
|
-
});
|
|
2438
|
-
|
|
2439
|
-
it("chatName defaults to empty string for sessions without it", async () => {
|
|
2440
|
-
await recordSessionRegistry({
|
|
2441
|
-
chatId: "chat-b",
|
|
2442
|
-
sessionId: "sb",
|
|
2443
|
-
tool: "claude",
|
|
2444
|
-
updatedAt: 100,
|
|
2445
|
-
});
|
|
2446
|
-
const result = await getAllSessionsStatus();
|
|
2447
|
-
expect(result.find(r => r.chatId === "chat-b")!.chatName).toBe("");
|
|
2448
|
-
});
|
|
2449
|
-
|
|
2450
|
-
it("混合 claude + cursor 会话:各自取自己来源的 model/effort", async () => {
|
|
2451
|
-
await recordSessionRegistry({
|
|
2452
|
-
chatId: "chat-c",
|
|
2453
|
-
sessionId: "sid-c",
|
|
2454
|
-
tool: "claude",
|
|
2455
|
-
chatName: "claude-chat",
|
|
2456
|
-
updatedAt: 100,
|
|
2457
|
-
});
|
|
2458
|
-
await recordSessionRegistry({
|
|
2459
|
-
chatId: "chat-x",
|
|
2460
|
-
sessionId: "sid-x",
|
|
2461
|
-
tool: "cursor",
|
|
2462
|
-
chatName: "cursor-chat",
|
|
2463
|
-
updatedAt: 200,
|
|
2464
|
-
});
|
|
2465
|
-
_setAdapterForToolForTest(
|
|
2466
|
-
"cursor",
|
|
2467
|
-
mockAdapter((sid) =>
|
|
2468
|
-
sid === "sid-x"
|
|
2469
|
-
? { sessionId: sid, cwd: "/tmp", model: "Composer 2 Fast" }
|
|
2470
|
-
: undefined,
|
|
2471
|
-
),
|
|
2472
|
-
);
|
|
2473
|
-
|
|
2474
|
-
const result = await getAllSessionsStatus();
|
|
2475
|
-
const claude = result.find((r) => r.tool === "claude")!;
|
|
2476
|
-
const cursor = result.find((r) => r.tool === "cursor")!;
|
|
2477
|
-
|
|
2478
|
-
expect(claude.effort).not.toBeNull();
|
|
2479
|
-
expect(claude.model.length).toBeGreaterThan(0);
|
|
2480
|
-
|
|
2481
|
-
expect(cursor.effort).toBeNull();
|
|
2482
|
-
expect(cursor.model).toBe("Composer 2 Fast");
|
|
2483
|
-
});
|
|
2484
|
-
});
|
|
2485
|
-
|
|
2486
|
-
describe("processedMessages dedup", () => {
|
|
2487
|
-
it("supports add/has semantics", () => {
|
|
2488
|
-
processedMessages.clear();
|
|
2489
|
-
processedMessages.add("msg_001");
|
|
2490
|
-
expect(processedMessages.has("msg_001")).toBe(true);
|
|
2491
|
-
expect(processedMessages.has("msg_002")).toBe(false);
|
|
2492
|
-
});
|
|
2493
|
-
|
|
2494
|
-
it("MAX_PROCESSED is defined", () => {
|
|
2495
|
-
expect(MAX_PROCESSED).toBe(5000);
|
|
2496
|
-
});
|
|
2497
|
-
});
|
|
2498
|
-
|
|
2499
|
-
// ---------------------------------------------------------------------------
|
|
2500
|
-
// accumulateBlockContent — 统一消息块累积测试
|
|
2501
|
-
// ---------------------------------------------------------------------------
|
|
2502
|
-
|
|
2503
|
-
function freshState(): AccumulatorState {
|
|
2504
|
-
return { accumulatedContent: "", finalText: "", finalCompleteText: "", chunkCount: 0 };
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
describe("accumulateBlockContent", () => {
|
|
2508
|
-
it("accumulates thinking block into accumulatedContent", () => {
|
|
2509
|
-
const s = freshState();
|
|
2510
|
-
accumulateBlockContent({ type: "thinking", thinking: "Let me think..." }, s);
|
|
2511
|
-
expect(s.accumulatedContent).toBe("\n> Let me think...\n");
|
|
2512
|
-
expect(s.chunkCount).toBe(1);
|
|
2513
|
-
expect(s.finalText).toBe("");
|
|
2514
|
-
});
|
|
2515
|
-
|
|
2516
|
-
it("accumulates text block into finalText", () => {
|
|
2517
|
-
const s = freshState();
|
|
2518
|
-
accumulateBlockContent({ type: "text", text: "Hello world" }, s);
|
|
2519
|
-
expect(s.finalText).toBe("Hello world");
|
|
2520
|
-
expect(s.accumulatedContent).toBe("");
|
|
2521
|
-
});
|
|
2522
|
-
|
|
2523
|
-
it("accumulates tool_use block with formatted name and input", () => {
|
|
2524
|
-
const s = freshState();
|
|
2525
|
-
accumulateBlockContent(
|
|
2526
|
-
{ type: "tool_use", name: "Read", input: { file_path: "/tmp/test.txt" } },
|
|
2527
|
-
s,
|
|
2528
|
-
);
|
|
2529
|
-
expect(s.accumulatedContent).toContain("📖"); // 📖
|
|
2530
|
-
expect(s.accumulatedContent).toContain("**Read**");
|
|
2531
|
-
expect(s.accumulatedContent).toContain("/tmp/test.txt");
|
|
2532
|
-
});
|
|
2533
|
-
|
|
2534
|
-
it("accumulates tool_use block with long input truncated", () => {
|
|
2535
|
-
const s = freshState();
|
|
2536
|
-
const longInput = "x".repeat(500);
|
|
2537
|
-
accumulateBlockContent(
|
|
2538
|
-
{ type: "tool_use", name: "Bash", input: { command: longInput } },
|
|
2539
|
-
s,
|
|
2540
|
-
);
|
|
2541
|
-
// 简化规则截断到 500 chars
|
|
2542
|
-
expect(s.accumulatedContent).toContain("🖥️");
|
|
2543
|
-
expect(s.accumulatedContent).toContain("**Bash**");
|
|
2544
|
-
// command 超过 maxLength=500 时会被截断
|
|
2545
|
-
const body = s.accumulatedContent.split("**Bash** ")[1]?.trim() ?? "";
|
|
2546
|
-
expect(body.length).toBeLessThanOrEqual(503); // 500 + "..."
|
|
2547
|
-
});
|
|
2548
|
-
|
|
2549
|
-
it("accumulates tool_result block with success icon (✅)", () => {
|
|
2550
|
-
const s = freshState();
|
|
2551
|
-
accumulateBlockContent(
|
|
2552
|
-
{ type: "tool_result", tool_use_id: "tool_abc123", content: "done", is_error: false },
|
|
2553
|
-
s,
|
|
2554
|
-
);
|
|
2555
|
-
expect(s.accumulatedContent).toContain("✅"); // ✅
|
|
2556
|
-
expect(s.accumulatedContent).toContain("abc123");
|
|
2557
|
-
expect(s.accumulatedContent).toContain("done");
|
|
2558
|
-
});
|
|
2559
|
-
|
|
2560
|
-
it("accumulates tool_result block with error icon (❌)", () => {
|
|
2561
|
-
const s = freshState();
|
|
2562
|
-
accumulateBlockContent(
|
|
2563
|
-
{ type: "tool_result", tool_use_id: "tool_err456", content: "failed", is_error: true },
|
|
2564
|
-
s,
|
|
2565
|
-
);
|
|
2566
|
-
expect(s.accumulatedContent).toContain("❌"); // ❌
|
|
2567
|
-
});
|
|
2568
|
-
|
|
2569
|
-
it("accumulates tool_result with array content (text blocks)", () => {
|
|
2570
|
-
const s = freshState();
|
|
2571
|
-
const content = [{ type: "text", text: "line1" }, { type: "text", text: "line2" }];
|
|
2572
|
-
accumulateBlockContent(
|
|
2573
|
-
{ type: "tool_result", tool_use_id: "tool_arr", content },
|
|
2574
|
-
s,
|
|
2575
|
-
);
|
|
2576
|
-
expect(s.accumulatedContent).toContain("line1line2");
|
|
2577
|
-
});
|
|
2578
|
-
|
|
2579
|
-
it("accumulates tool_result with object content (JSON stringified)", () => {
|
|
2580
|
-
const s = freshState();
|
|
2581
|
-
accumulateBlockContent(
|
|
2582
|
-
{ type: "tool_result", tool_use_id: "tool_obj", content: { key: "val" } },
|
|
2583
|
-
s,
|
|
2584
|
-
);
|
|
2585
|
-
expect(s.accumulatedContent).toContain('{"key":"val"}');
|
|
2586
|
-
});
|
|
2587
|
-
|
|
2588
|
-
it("accumulates redacted_thinking block with safety notice", () => {
|
|
2589
|
-
const s = freshState();
|
|
2590
|
-
accumulateBlockContent({ type: "redacted_thinking" }, s);
|
|
2591
|
-
expect(s.accumulatedContent).toContain("内容被安全过滤"); // 内容被安全过滤
|
|
2592
|
-
});
|
|
2593
|
-
|
|
2594
|
-
it("accumulates search_result block with query", () => {
|
|
2595
|
-
const s = freshState();
|
|
2596
|
-
accumulateBlockContent(
|
|
2597
|
-
{ type: "search_result", query: "TypeScript docs" },
|
|
2598
|
-
s,
|
|
2599
|
-
);
|
|
2600
|
-
expect(s.accumulatedContent).toContain("🔍"); // 🔍
|
|
2601
|
-
expect(s.accumulatedContent).toContain("TypeScript docs");
|
|
2602
|
-
});
|
|
2603
|
-
|
|
2604
|
-
it("accumulates compact_boundary block with trigger label", () => {
|
|
2605
|
-
const s = freshState();
|
|
2606
|
-
accumulateBlockContent(
|
|
2607
|
-
{ type: "compact_boundary", trigger: "auto", pre_tokens: 15000, post_tokens: 8000 },
|
|
2608
|
-
s,
|
|
2609
|
-
);
|
|
2610
|
-
expect(s.accumulatedContent).toContain("🔄"); // 🔄
|
|
2611
|
-
expect(s.accumulatedContent).toContain("自动");
|
|
2612
|
-
expect(s.accumulatedContent).toContain("15000");
|
|
2613
|
-
expect(s.accumulatedContent).toContain("8000");
|
|
2614
|
-
});
|
|
2615
|
-
|
|
2616
|
-
it("accumulates compact_boundary with manual trigger label", () => {
|
|
2617
|
-
const s = freshState();
|
|
2618
|
-
accumulateBlockContent(
|
|
2619
|
-
{ type: "compact_boundary", trigger: "manual", pre_tokens: 20000 },
|
|
2620
|
-
s,
|
|
2621
|
-
);
|
|
2622
|
-
expect(s.accumulatedContent).toContain("手动");
|
|
2623
|
-
});
|
|
2624
|
-
|
|
2625
|
-
it("accumulates multiple blocks in sequence correctly", () => {
|
|
2626
|
-
const s = freshState();
|
|
2627
|
-
accumulateBlockContent({ type: "thinking", thinking: "Hmm..." }, s);
|
|
2628
|
-
accumulateBlockContent({ type: "tool_use", name: "Grep", input: { pattern: "foo" } }, s);
|
|
2629
|
-
accumulateBlockContent(
|
|
2630
|
-
{ type: "tool_result", tool_use_id: "abc123", content: "found 3 matches", is_error: false },
|
|
2631
|
-
s,
|
|
2632
|
-
);
|
|
2633
|
-
accumulateBlockContent({ type: "text", text: "I found the results." }, s);
|
|
2634
|
-
|
|
2635
|
-
expect(s.accumulatedContent).toContain("> Hmm...");
|
|
2636
|
-
expect(s.accumulatedContent).toContain("Grep");
|
|
2637
|
-
expect(s.accumulatedContent).toContain("found 3 matches");
|
|
2638
|
-
expect(s.finalText).toBe("I found the results.");
|
|
2639
|
-
expect(s.chunkCount).toBe(1); // Only thinking increments chunkCount
|
|
2640
|
-
});
|
|
2641
|
-
|
|
2642
|
-
// -------------------------------------------------------------------------
|
|
2643
|
-
// text_final:来自 Cursor CLI 的"完整最终文本"消息
|
|
2644
|
-
// 行为:覆盖(不是追加)finalCompleteText,避免与 partial 累加重复
|
|
2645
|
-
// -------------------------------------------------------------------------
|
|
2646
|
-
|
|
2647
|
-
it("accumulates text_final into finalCompleteText (覆盖语义)", () => {
|
|
2648
|
-
const s = freshState();
|
|
2649
|
-
accumulateBlockContent({ type: "text_final", text: "完整最终文本" } as UnifiedBlock, s);
|
|
2650
|
-
expect(s.finalCompleteText).toBe("完整最终文本");
|
|
2651
|
-
expect(s.finalText).toBe("");
|
|
2652
|
-
});
|
|
2653
|
-
|
|
2654
|
-
it("text_final 多次到达时以最新一次为准(覆盖而非追加)", () => {
|
|
2655
|
-
const s = freshState();
|
|
2656
|
-
accumulateBlockContent({ type: "text_final", text: "first" } as UnifiedBlock, s);
|
|
2657
|
-
accumulateBlockContent({ type: "text_final", text: "second" } as UnifiedBlock, s);
|
|
2658
|
-
expect(s.finalCompleteText).toBe("second");
|
|
2659
|
-
});
|
|
2660
|
-
});
|
|
2661
|
-
|
|
2662
|
-
// ---------------------------------------------------------------------------
|
|
2663
|
-
// pickFinalReply — 在 partial 累加 vs final 完整文本之间挑选最终回复
|
|
2664
|
-
// ---------------------------------------------------------------------------
|
|
2665
|
-
|
|
2666
|
-
describe("pickFinalReply", () => {
|
|
2667
|
-
// 新规则:有 finalCompleteText 永远优先(来自 cursor result.result,官方权威);
|
|
2668
|
-
// 无则回退到 partial 累加 finalText。
|
|
2669
|
-
// 不再做长度比较——因为带 buffered flush 重复时 partial 可能"虚高",长度比较会选错。
|
|
2670
|
-
|
|
2671
|
-
it("finalCompleteText 非空时永远优先(即便等长)", () => {
|
|
2672
|
-
const reply = pickFinalReply({
|
|
2673
|
-
accumulatedContent: "",
|
|
2674
|
-
finalText: "你好世界",
|
|
2675
|
-
finalCompleteText: "你好世界",
|
|
2676
|
-
chunkCount: 0,
|
|
2677
|
-
});
|
|
2678
|
-
expect(reply).toBe("你好世界");
|
|
2679
|
-
});
|
|
2680
|
-
|
|
2681
|
-
it("finalCompleteText 非空时优先(即便 partial 累加更长——可能被 buffered flush 污染)", () => {
|
|
2682
|
-
const reply = pickFinalReply({
|
|
2683
|
-
accumulatedContent: "",
|
|
2684
|
-
finalText: "你好世界你好世界", // 工具调用前 buffered flush 让 partial 累加翻倍
|
|
2685
|
-
finalCompleteText: "你好世界",
|
|
2686
|
-
chunkCount: 0,
|
|
2687
|
-
});
|
|
2688
|
-
expect(reply).toBe("你好世界");
|
|
2689
|
-
});
|
|
2690
|
-
|
|
2691
|
-
it("无 finalCompleteText 时回退到 partial 累加 (finalText)", () => {
|
|
2692
|
-
const reply = pickFinalReply({
|
|
2693
|
-
accumulatedContent: "",
|
|
2694
|
-
finalText: "仅有 partial 累加",
|
|
2695
|
-
finalCompleteText: "",
|
|
2696
|
-
chunkCount: 0,
|
|
2697
|
-
});
|
|
2698
|
-
expect(reply).toBe("仅有 partial 累加");
|
|
2699
|
-
});
|
|
2700
|
-
|
|
2701
|
-
it("两者都为空时返回空串", () => {
|
|
2702
|
-
expect(
|
|
2703
|
-
pickFinalReply({
|
|
2704
|
-
accumulatedContent: "",
|
|
2705
|
-
finalText: "",
|
|
2706
|
-
finalCompleteText: "",
|
|
2707
|
-
chunkCount: 0,
|
|
2708
|
-
}),
|
|
2709
|
-
).toBe("");
|
|
2710
|
-
});
|
|
2711
|
-
});
|
|
2712
|
-
|
|
2713
|
-
// ---------------------------------------------------------------------------
|
|
2714
|
-
// pickDisplayChat — display loop 选择推送目标 chat 的纯函数
|
|
2715
|
-
// 关键不变量:仅当某 chatId 既是 session 的"最后活跃 chat"且仍然绑定到该
|
|
2716
|
-
// session 时才返回。否则返回 undefined(loop 当作"无活跃群",不推送)。
|
|
2717
|
-
// 这是为了修复 /newh 后旧 session 仍向已解绑群推卡片的 bug。
|
|
2718
|
-
// ---------------------------------------------------------------------------
|
|
2719
|
-
|
|
2720
|
-
describe("pickDisplayChat", () => {
|
|
2721
|
-
beforeEach(() => {
|
|
2722
|
-
resetBindingState();
|
|
2723
|
-
});
|
|
2724
|
-
|
|
2725
|
-
it("绑定 + 记录活跃后,返回该 chatId", () => {
|
|
2726
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2727
|
-
recordLastActiveChat("sid-A", "chat_X");
|
|
2728
|
-
expect(pickDisplayChat("sid-A")).toBe("chat_X");
|
|
2729
|
-
});
|
|
2730
|
-
|
|
2731
|
-
it("从未记录过活跃 chat 时返回 undefined", () => {
|
|
2732
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2733
|
-
expect(pickDisplayChat("sid-A")).toBeUndefined();
|
|
2734
|
-
});
|
|
2735
|
-
|
|
2736
|
-
it("最后活跃 chat 已被解绑(如 /newh 场景)时返回 undefined,避免向已离开本 session 的群推送", () => {
|
|
2737
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2738
|
-
recordLastActiveChat("sid-A", "chat_X");
|
|
2739
|
-
// 模拟 /newh:chat_X 被解绑,转给新 session
|
|
2740
|
-
unbindChatFromSession("sid-A", "chat_X");
|
|
2741
|
-
expect(pickDisplayChat("sid-A")).toBeUndefined();
|
|
2742
|
-
});
|
|
2743
|
-
|
|
2744
|
-
it("session 仍绑定其他 chat 但 lastActive 是已解绑 chat 时返回 undefined(不应回退到任意绑定)", () => {
|
|
2745
|
-
// 多群共享 session 的极端情况:lastActive 指向 chat_X,但 chat_X 已解绑
|
|
2746
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2747
|
-
bindChatToSession("sid-A", "chat_Y");
|
|
2748
|
-
recordLastActiveChat("sid-A", "chat_X");
|
|
2749
|
-
unbindChatFromSession("sid-A", "chat_X");
|
|
2750
|
-
expect(pickDisplayChat("sid-A")).toBeUndefined();
|
|
2751
|
-
});
|
|
2752
|
-
|
|
2753
|
-
it("session 绑定多个 chat 且 lastActive 是仍绑定的 chat 时正确返回", () => {
|
|
2754
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2755
|
-
bindChatToSession("sid-A", "chat_Y");
|
|
2756
|
-
recordLastActiveChat("sid-A", "chat_Y");
|
|
2757
|
-
expect(pickDisplayChat("sid-A")).toBe("chat_Y");
|
|
2758
|
-
});
|
|
2759
|
-
});
|
|
2760
|
-
|
|
2761
|
-
// ---------------------------------------------------------------------------
|
|
2762
|
-
// unbindChatFromSession — 双保险:清理 lastActiveChatMap[sessionId]
|
|
2763
|
-
// 若该 sessionId 的 lastActive 正好指向被解绑的 chatId,则一并清掉,
|
|
2764
|
-
// 防止后续逻辑(不仅 display loop)读到悬挂的旧记录。
|
|
2765
|
-
// ---------------------------------------------------------------------------
|
|
2766
|
-
|
|
2767
|
-
describe("unbindChatFromSession 同步清理 lastActiveChatMap", () => {
|
|
2768
|
-
beforeEach(() => {
|
|
2769
|
-
resetBindingState();
|
|
2770
|
-
});
|
|
2771
|
-
|
|
2772
|
-
it("解绑的 chat 正是 lastActive 时清掉记录", () => {
|
|
2773
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2774
|
-
recordLastActiveChat("sid-A", "chat_X");
|
|
2775
|
-
unbindChatFromSession("sid-A", "chat_X");
|
|
2776
|
-
expect(getLastActiveChat("sid-A")).toBeUndefined();
|
|
2777
|
-
});
|
|
2778
|
-
|
|
2779
|
-
it("解绑的 chat 不是 lastActive 时保留 lastActive", () => {
|
|
2780
|
-
// chat_X 是 lastActive,解绑 chat_Y 不应影响
|
|
2781
|
-
bindChatToSession("sid-A", "chat_X");
|
|
2782
|
-
bindChatToSession("sid-A", "chat_Y");
|
|
2783
|
-
recordLastActiveChat("sid-A", "chat_X");
|
|
2784
|
-
unbindChatFromSession("sid-A", "chat_Y");
|
|
2785
|
-
expect(getLastActiveChat("sid-A")).toBe("chat_X");
|
|
2786
|
-
});
|
|
2787
|
-
});
|
|
2788
|
-
|
|
2789
|
-
// ---------------------------------------------------------------------------
|
|
2790
|
-
// switchChatBinding — 事务式 chat→session 切换(/newh、/session N 复用)
|
|
2791
|
-
//
|
|
2792
|
-
// 关键不变量:
|
|
2793
|
-
// 1. p2p chatType 不调 updateChatInfo(私聊飞书 API 会直接抛错)
|
|
2794
|
-
// 2. updateChatInfo 失败时,内存绑定/sessionInfoMap/displayCards 完全不动,
|
|
2795
|
-
// 且 description 还是旧值,下次消息按旧 sessionId 路由不会乱
|
|
2796
|
-
// 3. 成功时按 unbind 旧 → bind 新 → recordLastActiveChat 顺序原子切换
|
|
2797
|
-
// 4. 持久化 registry + sessions.json
|
|
2798
|
-
// ---------------------------------------------------------------------------
|
|
2799
|
-
|
|
2800
|
-
describe("switchChatBinding", () => {
|
|
2801
|
-
let registryFile = "";
|
|
2802
|
-
let sessionsFile = "";
|
|
2803
|
-
|
|
2804
|
-
beforeEach(async () => {
|
|
2805
|
-
resetBindingState();
|
|
2806
|
-
sessionInfoMap.clear();
|
|
2807
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-switch-binding-"));
|
|
2808
|
-
registryFile = join(dir, "session-registry.json");
|
|
2809
|
-
sessionsFile = join(dir, "sessions.json");
|
|
2810
|
-
_setSessionRegistryFileForTest(registryFile);
|
|
2811
|
-
_setSessionToolsFileForTest(sessionsFile);
|
|
2812
|
-
});
|
|
2813
|
-
|
|
2814
|
-
afterEach(async () => {
|
|
2815
|
-
_resetSessionRegistryFileForTest();
|
|
2816
|
-
_resetSessionToolsFileForTest();
|
|
2817
|
-
if (registryFile) {
|
|
2818
|
-
await rm(dirname(registryFile), { recursive: true, force: true });
|
|
2819
|
-
}
|
|
2820
|
-
});
|
|
2821
|
-
|
|
2822
|
-
it("群聊场景:API 成功后内存切换 + 持久化", async () => {
|
|
2823
|
-
const calls: Array<{ chatId: string; name: string; desc: string }> = [];
|
|
2824
|
-
const updateChatInfoFn = async (chatId: string, name: string, desc: string) => {
|
|
2825
|
-
calls.push({ chatId, name, desc });
|
|
2826
|
-
};
|
|
2827
|
-
|
|
2828
|
-
bindChatToSession("old-sid", "chat-1");
|
|
2829
|
-
sessionInfoMap.set("chat-1", {
|
|
2830
|
-
sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
|
|
2831
|
-
startTime: 0, tool: "claude",
|
|
2832
|
-
});
|
|
2833
|
-
displayCards.set("chat-1", {
|
|
2834
|
-
cardId: "c1", sequence: 1, cardBusy: false,
|
|
2835
|
-
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
2836
|
-
sessionId: "old-sid", turnCount: 5, dotCount: 0,
|
|
2837
|
-
});
|
|
2838
|
-
|
|
2839
|
-
const result = await switchChatBinding({
|
|
2840
|
-
chatId: "chat-1",
|
|
2841
|
-
chatType: "group",
|
|
2842
|
-
oldSessionId: "old-sid",
|
|
2843
|
-
newSessionId: "new-sid",
|
|
2844
|
-
tool: "claude",
|
|
2845
|
-
chatName: "新会话-test",
|
|
2846
|
-
newDescription: "Claude Code Session: new-sid",
|
|
2847
|
-
updateChatInfoFn,
|
|
2848
|
-
});
|
|
2849
|
-
|
|
2850
|
-
expect(result.ok).toBe(true);
|
|
2851
|
-
expect(calls).toHaveLength(1);
|
|
2852
|
-
expect(calls[0]).toEqual({
|
|
2853
|
-
chatId: "chat-1",
|
|
2854
|
-
name: "新会话-test",
|
|
2855
|
-
desc: "Claude Code Session: new-sid",
|
|
2856
|
-
});
|
|
2857
|
-
// 旧 session 已解绑,新 session 已绑
|
|
2858
|
-
expect(getChatsForSession("old-sid")).toEqual([]);
|
|
2859
|
-
expect(getChatsForSession("new-sid")).toEqual(["chat-1"]);
|
|
2860
|
-
// displayCards 已清
|
|
2861
|
-
expect(displayCards.has("chat-1")).toBe(false);
|
|
2862
|
-
// sessionInfoMap 指向新 sessionId
|
|
2863
|
-
expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("new-sid");
|
|
2864
|
-
// lastActiveChat 指向当前 chat
|
|
2865
|
-
expect(getLastActiveChat("new-sid")).toBe("chat-1");
|
|
2866
|
-
});
|
|
2867
|
-
|
|
2868
|
-
it("私聊场景:完全跳过 updateChatInfo,仍完成内存切换", async () => {
|
|
2869
|
-
let called = false;
|
|
2870
|
-
const updateChatInfoFn = async () => {
|
|
2871
|
-
called = true;
|
|
2872
|
-
throw new Error("p2p chat API would fail");
|
|
2873
|
-
};
|
|
2874
|
-
|
|
2875
|
-
const result = await switchChatBinding({
|
|
2876
|
-
chatId: "p2p-chat",
|
|
2877
|
-
chatType: "p2p",
|
|
2878
|
-
oldSessionId: null,
|
|
2879
|
-
newSessionId: "new-sid-p2p",
|
|
2880
|
-
tool: "claude",
|
|
2881
|
-
chatName: "新会话-p2p",
|
|
2882
|
-
newDescription: "Claude Code Session: new-sid-p2p",
|
|
2883
|
-
updateChatInfoFn,
|
|
2884
|
-
});
|
|
2885
|
-
|
|
2886
|
-
expect(result.ok).toBe(true);
|
|
2887
|
-
expect(called).toBe(false); // 私聊跳过 API 调用
|
|
2888
|
-
expect(getChatsForSession("new-sid-p2p")).toEqual(["p2p-chat"]);
|
|
2889
|
-
expect(sessionInfoMap.get("p2p-chat")?.sessionId).toBe("new-sid-p2p");
|
|
2890
|
-
});
|
|
2891
|
-
|
|
2892
|
-
it("群聊 + updateChatInfo 抛错:内存完全不动 + 返回 error", async () => {
|
|
2893
|
-
bindChatToSession("old-sid", "chat-1");
|
|
2894
|
-
sessionInfoMap.set("chat-1", {
|
|
2895
|
-
sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
|
|
2896
|
-
startTime: 0, tool: "claude",
|
|
2897
|
-
});
|
|
2898
|
-
const oldDisplay = {
|
|
2899
|
-
cardId: "c1", sequence: 1, cardBusy: false,
|
|
2900
|
-
cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
|
|
2901
|
-
sessionId: "old-sid", turnCount: 5, dotCount: 0,
|
|
2902
|
-
};
|
|
2903
|
-
displayCards.set("chat-1", oldDisplay);
|
|
2904
|
-
|
|
2905
|
-
const updateChatInfoFn = async () => {
|
|
2906
|
-
throw new Error("network timeout");
|
|
2907
|
-
};
|
|
2908
|
-
|
|
2909
|
-
const result = await switchChatBinding({
|
|
2910
|
-
chatId: "chat-1",
|
|
2911
|
-
chatType: "group",
|
|
2912
|
-
oldSessionId: "old-sid",
|
|
2913
|
-
newSessionId: "new-sid",
|
|
2914
|
-
tool: "claude",
|
|
2915
|
-
chatName: "新会话-failed",
|
|
2916
|
-
newDescription: "Claude Code Session: new-sid",
|
|
2917
|
-
updateChatInfoFn,
|
|
2918
|
-
});
|
|
2919
|
-
|
|
2920
|
-
expect(result.ok).toBe(false);
|
|
2921
|
-
expect(result.error?.message).toBe("network timeout");
|
|
2922
|
-
// 内存绑定保持旧状态
|
|
2923
|
-
expect(getChatsForSession("old-sid")).toEqual(["chat-1"]);
|
|
2924
|
-
expect(getChatsForSession("new-sid")).toEqual([]);
|
|
2925
|
-
expect(displayCards.get("chat-1")).toBe(oldDisplay);
|
|
2926
|
-
expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("old-sid");
|
|
2927
|
-
expect(sessionInfoMap.get("chat-1")?.turnCount).toBe(5);
|
|
2928
|
-
});
|
|
2929
|
-
|
|
2930
|
-
it("oldSessionId 为 null 时不调 unbind(适用于私聊首次绑定)", async () => {
|
|
2931
|
-
const updateChatInfoFn = async () => {};
|
|
2932
|
-
|
|
2933
|
-
const result = await switchChatBinding({
|
|
2934
|
-
chatId: "fresh-chat",
|
|
2935
|
-
chatType: "p2p",
|
|
2936
|
-
oldSessionId: null,
|
|
2937
|
-
newSessionId: "fresh-sid",
|
|
2938
|
-
tool: "claude",
|
|
2939
|
-
chatName: "首次会话",
|
|
2940
|
-
newDescription: "Claude Code Session: fresh-sid",
|
|
2941
|
-
updateChatInfoFn,
|
|
2942
|
-
});
|
|
2943
|
-
|
|
2944
|
-
expect(result.ok).toBe(true);
|
|
2945
|
-
expect(getChatsForSession("fresh-sid")).toEqual(["fresh-chat"]);
|
|
2946
|
-
});
|
|
2947
|
-
|
|
2948
|
-
it("API 成功后,registry 持久化记录可被重新加载", async () => {
|
|
2949
|
-
const updateChatInfoFn = async () => {};
|
|
2950
|
-
|
|
2951
|
-
await switchChatBinding({
|
|
2952
|
-
chatId: "chat-persist",
|
|
2953
|
-
chatType: "group",
|
|
2954
|
-
oldSessionId: null,
|
|
2955
|
-
newSessionId: "persist-sid",
|
|
2956
|
-
tool: "cursor",
|
|
2957
|
-
chatName: "persist-name",
|
|
2958
|
-
newDescription: "Cursor Session: persist-sid",
|
|
2959
|
-
initialTurnCount: 3,
|
|
2960
|
-
initialContextTokens: 500,
|
|
2961
|
-
updateChatInfoFn,
|
|
2962
|
-
});
|
|
2963
|
-
|
|
2964
|
-
// 验证 registry 文件已写入
|
|
2965
|
-
const raw = await readFile(registryFile, "utf-8");
|
|
2966
|
-
const parsed = JSON.parse(raw);
|
|
2967
|
-
expect(parsed["chat-persist"]).toMatchObject({
|
|
2968
|
-
chatId: "chat-persist",
|
|
2969
|
-
sessionId: "persist-sid",
|
|
2970
|
-
tool: "cursor",
|
|
2971
|
-
chatName: "persist-name",
|
|
2972
|
-
turnCount: 3,
|
|
2973
|
-
lastContextTokens: 500,
|
|
2974
|
-
running: false,
|
|
2975
|
-
});
|
|
2976
|
-
});
|
|
2977
|
-
});
|
|
2978
|
-
|
|
2979
|
-
// ---------------------------------------------------------------------------
|
|
2980
|
-
// resetState 调用契约:仅供测试 + 进程首次启动。
|
|
2981
|
-
// 不应在 SDK onReady/onReconnected 中调用——会清空 activePrompts 让正在跑
|
|
2982
|
-
// 的后台 prompt 变成"孤儿 generator"(Map 删了但 controller 没 abort,
|
|
2983
|
-
// 导致同一 sessionId 双开 prompt)。
|
|
2984
|
-
// ---------------------------------------------------------------------------
|
|
2985
|
-
|
|
2986
|
-
describe("resetState 契约:清空所有运行时状态", () => {
|
|
2987
|
-
it("清空 activePrompts 但不 abort controller(只能由进程首次启动调用)", () => {
|
|
2988
|
-
const controller = new AbortController();
|
|
2989
|
-
let aborted = false;
|
|
2990
|
-
controller.signal.addEventListener("abort", () => { aborted = true; });
|
|
2991
|
-
activePrompts.set("sid-running", {
|
|
2992
|
-
controller, stopped: false, startTime: 0,
|
|
2993
|
-
});
|
|
2994
|
-
|
|
2995
|
-
resetState();
|
|
2996
|
-
|
|
2997
|
-
expect(activePrompts.size).toBe(0);
|
|
2998
|
-
// 注意:resetState 不主动 abort——所以如果生产代码在 prompt 跑过程中
|
|
2999
|
-
// 误调 resetState,后台 generator 仍会继续跑直到自然结束,但 activePrompts
|
|
3000
|
-
// 已经空了,下条消息会双开 prompt。这是 resetState 仅适用于"启动时"
|
|
3001
|
-
// (Map 本就是空的)的根本原因。
|
|
3002
|
-
expect(aborted).toBe(false);
|
|
3003
|
-
});
|
|
3004
|
-
});
|