chatccc 0.2.222 → 0.2.224
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/config.sample.json +40 -39
- package/package.json +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +33 -0
- package/src/__tests__/builtin-config.test.ts +1 -0
- package/src/__tests__/card-plain-text.test.ts +7 -6
- package/src/__tests__/cards.test.ts +179 -178
- package/src/__tests__/ccc-adapter.test.ts +20 -0
- package/src/__tests__/config-reload.test.ts +52 -52
- package/src/__tests__/config-sample.test.ts +41 -40
- package/src/__tests__/orchestrator.test.ts +836 -803
- package/src/__tests__/progress-reducer.test.ts +110 -0
- package/src/__tests__/session.test.ts +1183 -1173
- package/src/__tests__/terminal-renderer.test.ts +143 -0
- package/src/adapters/ccc-adapter.ts +1 -0
- package/src/builtin/cli.ts +42 -1
- package/src/builtin/index.ts +10 -0
- package/src/cards.ts +280 -275
- package/src/config.ts +203 -192
- package/src/progress/reducer.ts +108 -0
- package/src/progress/terminal-renderer.ts +190 -0
- package/src/progress/view.ts +77 -0
- package/src/session.ts +938 -937
- package/src/web-ui.ts +610 -607
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
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;
|
|
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
23
|
turnCount?: number;
|
|
24
24
|
finalReplySentTurn?: number;
|
|
25
25
|
finalReplySentAt?: number;
|
|
@@ -30,12 +30,12 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
30
30
|
if (!state) return null;
|
|
31
31
|
return {
|
|
32
32
|
sessionId: sid,
|
|
33
|
-
accumulatedContent: state.accumulatedContent,
|
|
34
|
-
finalReply: state.finalReply,
|
|
35
|
-
activity: state.activity,
|
|
36
|
-
finalReplySentTurn: state.finalReplySentTurn,
|
|
37
|
-
finalReplySentAt: state.finalReplySentAt,
|
|
38
|
-
autoEndedAt: state.autoEndedAt,
|
|
33
|
+
accumulatedContent: state.accumulatedContent,
|
|
34
|
+
finalReply: state.finalReply,
|
|
35
|
+
activity: state.activity,
|
|
36
|
+
finalReplySentTurn: state.finalReplySentTurn,
|
|
37
|
+
finalReplySentAt: state.finalReplySentAt,
|
|
38
|
+
autoEndedAt: state.autoEndedAt,
|
|
39
39
|
status: state.status ?? "running",
|
|
40
40
|
chunkCount: 0,
|
|
41
41
|
turnCount: state.turnCount ?? 0,
|
|
@@ -48,33 +48,33 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
48
48
|
writeStreamState: async (state: {
|
|
49
49
|
sessionId: string;
|
|
50
50
|
accumulatedContent: string;
|
|
51
|
-
finalReply: string;
|
|
52
|
-
activity?: {
|
|
53
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
54
|
-
startedAt: number;
|
|
55
|
-
toolName?: string;
|
|
56
|
-
toolCount?: number;
|
|
57
|
-
};
|
|
58
|
-
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
59
|
-
autoEndedAt?: number;
|
|
51
|
+
finalReply: string;
|
|
52
|
+
activity?: {
|
|
53
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
54
|
+
startedAt: number;
|
|
55
|
+
toolName?: string;
|
|
56
|
+
toolCount?: number;
|
|
57
|
+
};
|
|
58
|
+
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
59
|
+
autoEndedAt?: number;
|
|
60
60
|
turnCount?: number;
|
|
61
61
|
finalReplySentTurn?: number;
|
|
62
62
|
finalReplySentAt?: number;
|
|
63
63
|
}) => {
|
|
64
64
|
mockStreamStates.set(state.sessionId, {
|
|
65
|
-
accumulatedContent: state.accumulatedContent,
|
|
66
|
-
finalReply: state.finalReply,
|
|
67
|
-
activity: state.activity,
|
|
68
|
-
status: state.status,
|
|
69
|
-
autoEndedAt: state.autoEndedAt,
|
|
65
|
+
accumulatedContent: state.accumulatedContent,
|
|
66
|
+
finalReply: state.finalReply,
|
|
67
|
+
activity: state.activity,
|
|
68
|
+
status: state.status,
|
|
69
|
+
autoEndedAt: state.autoEndedAt,
|
|
70
70
|
turnCount: state.turnCount,
|
|
71
71
|
finalReplySentTurn: state.finalReplySentTurn,
|
|
72
72
|
finalReplySentAt: state.finalReplySentAt,
|
|
73
73
|
});
|
|
74
74
|
},
|
|
75
|
-
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
76
|
-
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,
|
|
77
|
-
}),
|
|
75
|
+
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
76
|
+
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,
|
|
77
|
+
}),
|
|
78
78
|
isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
|
|
79
79
|
markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
|
|
80
80
|
const state = mockStreamStates.get(sid);
|
|
@@ -109,29 +109,31 @@ import {
|
|
|
109
109
|
_clearAdapterCacheForTest,
|
|
110
110
|
setSessionPlatform,
|
|
111
111
|
recordChatPlatform,
|
|
112
|
-
_getPlatformForChatForTest,
|
|
113
|
-
initClaudeSession,
|
|
114
|
-
runAgentSession,
|
|
112
|
+
_getPlatformForChatForTest,
|
|
113
|
+
initClaudeSession,
|
|
114
|
+
runAgentSession,
|
|
115
115
|
stopSession,
|
|
116
116
|
startUnifiedDisplayLoop,
|
|
117
117
|
stopUnifiedDisplayLoop,
|
|
118
|
-
_setProcessAliveForTest,
|
|
119
|
-
_resetProcessAliveForTest,
|
|
120
|
-
_setProcessMonitorIntervalForTest,
|
|
121
|
-
_resetProcessMonitorIntervalForTest,
|
|
122
|
-
_setResponseStallTimeoutForTest,
|
|
123
|
-
_resetResponseStallTimeoutForTest,
|
|
124
|
-
_setResponseStallCheckIntervalForTest,
|
|
125
|
-
_resetResponseStallCheckIntervalForTest,
|
|
126
|
-
_setFinalResponseCloseTimeoutForTest,
|
|
127
|
-
_resetFinalResponseCloseTimeoutForTest,
|
|
128
|
-
setSessionEffortOverride,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
118
|
+
_setProcessAliveForTest,
|
|
119
|
+
_resetProcessAliveForTest,
|
|
120
|
+
_setProcessMonitorIntervalForTest,
|
|
121
|
+
_resetProcessMonitorIntervalForTest,
|
|
122
|
+
_setResponseStallTimeoutForTest,
|
|
123
|
+
_resetResponseStallTimeoutForTest,
|
|
124
|
+
_setResponseStallCheckIntervalForTest,
|
|
125
|
+
_resetResponseStallCheckIntervalForTest,
|
|
126
|
+
_setFinalResponseCloseTimeoutForTest,
|
|
127
|
+
_resetFinalResponseCloseTimeoutForTest,
|
|
128
|
+
setSessionEffortOverride,
|
|
129
|
+
clearSessionEffortOverride,
|
|
130
|
+
getEffectiveEffortForTool,
|
|
131
|
+
getEffectiveFastModeForTool,
|
|
132
|
+
setSessionFastModeOverride,
|
|
133
|
+
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
134
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
135
|
+
} from "../session.ts";
|
|
136
|
+
import { config } from "../config.ts";
|
|
135
137
|
import {
|
|
136
138
|
activePrompts,
|
|
137
139
|
bindChatToSession,
|
|
@@ -140,34 +142,34 @@ import {
|
|
|
140
142
|
getLastActiveChat,
|
|
141
143
|
pickDisplayChat,
|
|
142
144
|
resetBindingState,
|
|
143
|
-
getChatsForSession,
|
|
144
|
-
displayCards,
|
|
145
|
-
enqueueMessage,
|
|
146
|
-
setQueueConsumer,
|
|
147
|
-
isSessionRunning,
|
|
148
|
-
} from "../session-chat-binding.ts";
|
|
145
|
+
getChatsForSession,
|
|
146
|
+
displayCards,
|
|
147
|
+
enqueueMessage,
|
|
148
|
+
setQueueConsumer,
|
|
149
|
+
isSessionRunning,
|
|
150
|
+
} from "../session-chat-binding.ts";
|
|
149
151
|
import type { AccumulatorState } from "../session.ts";
|
|
150
152
|
import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
151
|
-
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
152
|
-
|
|
153
|
-
describe("Codex Fast mode overrides", () => {
|
|
154
|
-
it("uses the global default until the current session overrides it", () => {
|
|
155
|
-
const previousFastMode = config.codex.fastMode;
|
|
156
|
-
config.codex.fastMode = false;
|
|
157
|
-
resetState();
|
|
158
|
-
|
|
159
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
160
|
-
setSessionFastModeOverride("sid-fast", true);
|
|
161
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(true);
|
|
162
|
-
setSessionFastModeOverride("sid-fast", false);
|
|
163
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
164
|
-
expect(getEffectiveFastModeForTool("claude", "sid-fast")).toBe(false);
|
|
165
|
-
|
|
166
|
-
resetState();
|
|
167
|
-
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
168
|
-
config.codex.fastMode = previousFastMode;
|
|
169
|
-
});
|
|
170
|
-
});
|
|
153
|
+
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
154
|
+
|
|
155
|
+
describe("Codex Fast mode overrides", () => {
|
|
156
|
+
it("uses the global default until the current session overrides it", () => {
|
|
157
|
+
const previousFastMode = config.codex.fastMode;
|
|
158
|
+
config.codex.fastMode = false;
|
|
159
|
+
resetState();
|
|
160
|
+
|
|
161
|
+
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
162
|
+
setSessionFastModeOverride("sid-fast", true);
|
|
163
|
+
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(true);
|
|
164
|
+
setSessionFastModeOverride("sid-fast", false);
|
|
165
|
+
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
166
|
+
expect(getEffectiveFastModeForTool("claude", "sid-fast")).toBe(false);
|
|
167
|
+
|
|
168
|
+
resetState();
|
|
169
|
+
expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
|
|
170
|
+
config.codex.fastMode = previousFastMode;
|
|
171
|
+
});
|
|
172
|
+
});
|
|
171
173
|
|
|
172
174
|
// Helper to create a mock active session entry
|
|
173
175
|
function mockActiveSession(chatId: string, overrides: Partial<{
|
|
@@ -249,7 +251,7 @@ function mockPlatform(name: string): PlatformAdapter {
|
|
|
249
251
|
};
|
|
250
252
|
}
|
|
251
253
|
|
|
252
|
-
describe("resetState", () => {
|
|
254
|
+
describe("resetState", () => {
|
|
253
255
|
it("clears all maps and sets", () => {
|
|
254
256
|
chatSessionMap.set("chat1", {
|
|
255
257
|
gen: 1, close: () => {}, cardId: null, stopped: false,
|
|
@@ -268,58 +270,58 @@ describe("resetState", () => {
|
|
|
268
270
|
expect(sessionInfoMap.size).toBe(0);
|
|
269
271
|
expect(processedMessages.size).toBe(0);
|
|
270
272
|
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
describe("initClaudeSession startup watchdog", () => {
|
|
274
|
-
afterEach(() => {
|
|
275
|
-
_clearAdapterCacheForTest();
|
|
276
|
-
_resetResponseStallTimeoutForTest();
|
|
277
|
-
vi.useRealTimers();
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("aborts session creation when an Agent never emits its init event", async () => {
|
|
281
|
-
vi.useFakeTimers();
|
|
282
|
-
_setResponseStallTimeoutForTest(100);
|
|
283
|
-
|
|
284
|
-
let aborted = false;
|
|
285
|
-
const adapter: ToolAdapter = {
|
|
286
|
-
displayName: "Silent Cursor",
|
|
287
|
-
sessionDescPrefix: "Cursor Session:",
|
|
288
|
-
createSession: async (_cwd: string, signal?: AbortSignal) => {
|
|
289
|
-
await new Promise<void>((resolve) => {
|
|
290
|
-
if (signal?.aborted) {
|
|
291
|
-
aborted = true;
|
|
292
|
-
resolve();
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
signal?.addEventListener("abort", () => {
|
|
296
|
-
aborted = true;
|
|
297
|
-
resolve();
|
|
298
|
-
}, { once: true });
|
|
299
|
-
});
|
|
300
|
-
throw new Error("adapter aborted");
|
|
301
|
-
},
|
|
302
|
-
prompt: async function* () {},
|
|
303
|
-
getSessionInfo: async () => undefined,
|
|
304
|
-
closeSession: async () => {},
|
|
305
|
-
};
|
|
306
|
-
_setAdapterForToolForTest("cursor", adapter);
|
|
307
|
-
|
|
308
|
-
let outcome: unknown;
|
|
309
|
-
void initClaudeSession("cursor", "F:\\repo").then(
|
|
310
|
-
(value) => { outcome = value; },
|
|
311
|
-
(err) => { outcome = err; },
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
await vi.advanceTimersByTimeAsync(101);
|
|
315
|
-
|
|
316
|
-
expect(aborted).toBe(true);
|
|
317
|
-
expect(outcome).toBeInstanceOf(Error);
|
|
318
|
-
expect((outcome as Error).message).toContain("3 minutes");
|
|
319
|
-
});
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
describe("chat platform routing", () => {
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("initClaudeSession startup watchdog", () => {
|
|
276
|
+
afterEach(() => {
|
|
277
|
+
_clearAdapterCacheForTest();
|
|
278
|
+
_resetResponseStallTimeoutForTest();
|
|
279
|
+
vi.useRealTimers();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("aborts session creation when an Agent never emits its init event", async () => {
|
|
283
|
+
vi.useFakeTimers();
|
|
284
|
+
_setResponseStallTimeoutForTest(100);
|
|
285
|
+
|
|
286
|
+
let aborted = false;
|
|
287
|
+
const adapter: ToolAdapter = {
|
|
288
|
+
displayName: "Silent Cursor",
|
|
289
|
+
sessionDescPrefix: "Cursor Session:",
|
|
290
|
+
createSession: async (_cwd: string, signal?: AbortSignal) => {
|
|
291
|
+
await new Promise<void>((resolve) => {
|
|
292
|
+
if (signal?.aborted) {
|
|
293
|
+
aborted = true;
|
|
294
|
+
resolve();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
signal?.addEventListener("abort", () => {
|
|
298
|
+
aborted = true;
|
|
299
|
+
resolve();
|
|
300
|
+
}, { once: true });
|
|
301
|
+
});
|
|
302
|
+
throw new Error("adapter aborted");
|
|
303
|
+
},
|
|
304
|
+
prompt: async function* () {},
|
|
305
|
+
getSessionInfo: async () => undefined,
|
|
306
|
+
closeSession: async () => {},
|
|
307
|
+
};
|
|
308
|
+
_setAdapterForToolForTest("cursor", adapter);
|
|
309
|
+
|
|
310
|
+
let outcome: unknown;
|
|
311
|
+
void initClaudeSession("cursor", "F:\\repo").then(
|
|
312
|
+
(value) => { outcome = value; },
|
|
313
|
+
(err) => { outcome = err; },
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await vi.advanceTimersByTimeAsync(101);
|
|
317
|
+
|
|
318
|
+
expect(aborted).toBe(true);
|
|
319
|
+
expect(outcome).toBeInstanceOf(Error);
|
|
320
|
+
expect((outcome as Error).message).toContain("3 minutes");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("chat platform routing", () => {
|
|
323
325
|
beforeEach(() => {
|
|
324
326
|
resetState();
|
|
325
327
|
});
|
|
@@ -441,16 +443,16 @@ describe("runAgentSession process monitor", () => {
|
|
|
441
443
|
_resetSessionRegistryFileForTest();
|
|
442
444
|
_resetSessionToolsFileForTest();
|
|
443
445
|
_clearAdapterCacheForTest();
|
|
444
|
-
_resetProcessAliveForTest();
|
|
445
|
-
_resetProcessMonitorIntervalForTest();
|
|
446
|
-
_resetResponseStallTimeoutForTest();
|
|
447
|
-
_resetResponseStallCheckIntervalForTest();
|
|
448
|
-
_resetFinalResponseCloseTimeoutForTest();
|
|
446
|
+
_resetProcessAliveForTest();
|
|
447
|
+
_resetProcessMonitorIntervalForTest();
|
|
448
|
+
_resetResponseStallTimeoutForTest();
|
|
449
|
+
_resetResponseStallCheckIntervalForTest();
|
|
450
|
+
_resetFinalResponseCloseTimeoutForTest();
|
|
449
451
|
resetBindingState();
|
|
450
452
|
vi.useRealTimers();
|
|
451
453
|
});
|
|
452
454
|
|
|
453
|
-
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
455
|
+
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
454
456
|
const platform = mockPlatform("feishu");
|
|
455
457
|
setSessionPlatform(platform);
|
|
456
458
|
bindChatToSession("sid-process", "chat-process");
|
|
@@ -545,7 +547,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
545
547
|
);
|
|
546
548
|
});
|
|
547
549
|
|
|
548
|
-
it("does not register an invisible progress card and sends the final text fallback", async () => {
|
|
550
|
+
it("does not register an invisible progress card and sends the final text fallback", async () => {
|
|
549
551
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
550
552
|
const platform = mockPlatform("feishu");
|
|
551
553
|
platform.cardCreate = vi.fn()
|
|
@@ -578,77 +580,77 @@ describe("runAgentSession process monitor", () => {
|
|
|
578
580
|
"生成中卡片发送失败,结果将以文本形式发送。",
|
|
579
581
|
);
|
|
580
582
|
expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
|
|
581
|
-
expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it("consumes a queued message only after the previous turn finishes final delivery", async () => {
|
|
585
|
-
const platform = mockPlatform("feishu");
|
|
586
|
-
setSessionPlatform(platform);
|
|
587
|
-
bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
|
|
588
|
-
recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
|
|
589
|
-
|
|
590
|
-
let releaseFinalDelivery: (() => void) | undefined;
|
|
591
|
-
const finalDeliveryGate = new Promise<void>((resolve) => {
|
|
592
|
-
releaseFinalDelivery = resolve;
|
|
593
|
-
});
|
|
594
|
-
platform.sendText = vi.fn(async (_chatId, content) => {
|
|
595
|
-
if (content === "final answer") await finalDeliveryGate;
|
|
596
|
-
return true;
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
const adapter: ToolAdapter = {
|
|
600
|
-
displayName: "Claude Code",
|
|
601
|
-
sessionDescPrefix: "Claude Code Session:",
|
|
602
|
-
createSession: async () => ({ sessionId: "sid-queue-finalize" }),
|
|
603
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
604
|
-
closeSession: async () => {},
|
|
605
|
-
prompt: async function* () {
|
|
606
|
-
// 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
|
|
607
|
-
displayCards.delete("chat-queue-finalize");
|
|
608
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
|
|
609
|
-
},
|
|
610
|
-
};
|
|
611
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
612
|
-
|
|
613
|
-
enqueueMessage("sid-queue-finalize", {
|
|
614
|
-
text: "queued prompt",
|
|
615
|
-
chatId: "chat-queue-finalize",
|
|
616
|
-
openId: "open-user",
|
|
617
|
-
msgTimestamp: Date.now(),
|
|
618
|
-
chatType: "p2p",
|
|
619
|
-
});
|
|
620
|
-
const consumeQueued = vi.fn();
|
|
621
|
-
setQueueConsumer(consumeQueued);
|
|
622
|
-
|
|
623
|
-
const runPromise = runAgentSession(
|
|
624
|
-
"sid-queue-finalize",
|
|
625
|
-
"first prompt",
|
|
626
|
-
platform,
|
|
627
|
-
"chat-queue-finalize",
|
|
628
|
-
Date.now(),
|
|
629
|
-
"claude",
|
|
630
|
-
);
|
|
631
|
-
await vi.waitFor(() => {
|
|
632
|
-
expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
|
|
633
|
-
});
|
|
634
|
-
expect(isSessionRunning("sid-queue-finalize")).toBe(true);
|
|
635
|
-
|
|
636
|
-
await vi.advanceTimersByTimeAsync(250);
|
|
637
|
-
expect(consumeQueued).not.toHaveBeenCalled();
|
|
638
|
-
|
|
639
|
-
releaseFinalDelivery?.();
|
|
640
|
-
await runPromise;
|
|
641
|
-
expect(isSessionRunning("sid-queue-finalize")).toBe(false);
|
|
642
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
643
|
-
expect(consumeQueued).toHaveBeenCalledTimes(1);
|
|
644
|
-
expect(consumeQueued).toHaveBeenCalledWith(
|
|
645
|
-
platform,
|
|
646
|
-
expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
|
|
647
|
-
);
|
|
648
|
-
setQueueConsumer(() => {});
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
it("sends the stopped notice only after the prompt generator exits", async () => {
|
|
583
|
+
expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("consumes a queued message only after the previous turn finishes final delivery", async () => {
|
|
587
|
+
const platform = mockPlatform("feishu");
|
|
588
|
+
setSessionPlatform(platform);
|
|
589
|
+
bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
|
|
590
|
+
recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
|
|
591
|
+
|
|
592
|
+
let releaseFinalDelivery: (() => void) | undefined;
|
|
593
|
+
const finalDeliveryGate = new Promise<void>((resolve) => {
|
|
594
|
+
releaseFinalDelivery = resolve;
|
|
595
|
+
});
|
|
596
|
+
platform.sendText = vi.fn(async (_chatId, content) => {
|
|
597
|
+
if (content === "final answer") await finalDeliveryGate;
|
|
598
|
+
return true;
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const adapter: ToolAdapter = {
|
|
602
|
+
displayName: "Claude Code",
|
|
603
|
+
sessionDescPrefix: "Claude Code Session:",
|
|
604
|
+
createSession: async () => ({ sessionId: "sid-queue-finalize" }),
|
|
605
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
606
|
+
closeSession: async () => {},
|
|
607
|
+
prompt: async function* () {
|
|
608
|
+
// 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
|
|
609
|
+
displayCards.delete("chat-queue-finalize");
|
|
610
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
614
|
+
|
|
615
|
+
enqueueMessage("sid-queue-finalize", {
|
|
616
|
+
text: "queued prompt",
|
|
617
|
+
chatId: "chat-queue-finalize",
|
|
618
|
+
openId: "open-user",
|
|
619
|
+
msgTimestamp: Date.now(),
|
|
620
|
+
chatType: "p2p",
|
|
621
|
+
});
|
|
622
|
+
const consumeQueued = vi.fn();
|
|
623
|
+
setQueueConsumer(consumeQueued);
|
|
624
|
+
|
|
625
|
+
const runPromise = runAgentSession(
|
|
626
|
+
"sid-queue-finalize",
|
|
627
|
+
"first prompt",
|
|
628
|
+
platform,
|
|
629
|
+
"chat-queue-finalize",
|
|
630
|
+
Date.now(),
|
|
631
|
+
"claude",
|
|
632
|
+
);
|
|
633
|
+
await vi.waitFor(() => {
|
|
634
|
+
expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
|
|
635
|
+
});
|
|
636
|
+
expect(isSessionRunning("sid-queue-finalize")).toBe(true);
|
|
637
|
+
|
|
638
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
639
|
+
expect(consumeQueued).not.toHaveBeenCalled();
|
|
640
|
+
|
|
641
|
+
releaseFinalDelivery?.();
|
|
642
|
+
await runPromise;
|
|
643
|
+
expect(isSessionRunning("sid-queue-finalize")).toBe(false);
|
|
644
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
645
|
+
expect(consumeQueued).toHaveBeenCalledTimes(1);
|
|
646
|
+
expect(consumeQueued).toHaveBeenCalledWith(
|
|
647
|
+
platform,
|
|
648
|
+
expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
|
|
649
|
+
);
|
|
650
|
+
setQueueConsumer(() => {});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("sends the stopped notice only after the prompt generator exits", async () => {
|
|
652
654
|
const platform = mockPlatform("feishu");
|
|
653
655
|
setSessionPlatform(platform);
|
|
654
656
|
bindChatToSession("sid-stop-notice", "chat-stop-notice");
|
|
@@ -745,947 +747,947 @@ describe("runAgentSession process monitor", () => {
|
|
|
745
747
|
});
|
|
746
748
|
});
|
|
747
749
|
|
|
748
|
-
describe("runAgentSession response stall watchdog", () => {
|
|
749
|
-
let tempDir = "";
|
|
750
|
-
const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
|
|
751
|
-
|
|
752
|
-
beforeEach(async () => {
|
|
753
|
-
vi.useFakeTimers();
|
|
754
|
-
resetState();
|
|
755
|
-
resetBindingState();
|
|
756
|
-
mockStreamStates.clear();
|
|
757
|
-
killProcessTreeMock.mockClear();
|
|
758
|
-
tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
|
|
759
|
-
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
760
|
-
_setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
afterEach(async () => {
|
|
764
|
-
_resetSessionRegistryFileForTest();
|
|
765
|
-
_resetSessionToolsFileForTest();
|
|
766
|
-
_clearAdapterCacheForTest();
|
|
767
|
-
_resetProcessAliveForTest();
|
|
768
|
-
_resetResponseStallTimeoutForTest();
|
|
769
|
-
_resetResponseStallCheckIntervalForTest();
|
|
770
|
-
_resetFinalResponseCloseTimeoutForTest();
|
|
771
|
-
resetBindingState();
|
|
772
|
-
vi.useRealTimers();
|
|
773
|
-
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
|
|
777
|
-
vi.setSystemTime(0);
|
|
778
|
-
_setResponseStallTimeoutForTest(180_000);
|
|
779
|
-
_setResponseStallCheckIntervalForTest(1_000);
|
|
780
|
-
_setProcessAliveForTest(() => true);
|
|
781
|
-
|
|
782
|
-
const platform = mockPlatform("feishu");
|
|
783
|
-
setSessionPlatform(platform);
|
|
784
|
-
bindChatToSession("sid-response-stall", "chat-response-stall");
|
|
785
|
-
recordLastActiveChat("sid-response-stall", "chat-response-stall");
|
|
786
|
-
|
|
787
|
-
const closeSession = vi.fn();
|
|
788
|
-
const adapter: ToolAdapter = {
|
|
789
|
-
displayName: "Any Agent",
|
|
790
|
-
sessionDescPrefix: "Agent Session:",
|
|
791
|
-
createSession: async () => ({ sessionId: "sid-response-stall" }),
|
|
792
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
793
|
-
closeSession: async () => {},
|
|
794
|
-
prompt: async function* (
|
|
795
|
-
_sid: string,
|
|
796
|
-
_text: string,
|
|
797
|
-
_cwd: string,
|
|
798
|
-
signal?: AbortSignal,
|
|
799
|
-
options?: ToolPromptOptions,
|
|
800
|
-
) {
|
|
801
|
-
options?.onSessionCreated?.(closeSession);
|
|
802
|
-
options?.onProcessStart?.({ pid: 4242 });
|
|
803
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
804
|
-
await new Promise<void>((resolve) => {
|
|
805
|
-
if (signal?.aborted) {
|
|
806
|
-
resolve();
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
810
|
-
});
|
|
811
|
-
},
|
|
812
|
-
};
|
|
813
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
814
|
-
|
|
815
|
-
const runPromise = runAgentSession(
|
|
816
|
-
"sid-response-stall",
|
|
817
|
-
"prompt",
|
|
818
|
-
platform,
|
|
819
|
-
"chat-response-stall",
|
|
820
|
-
Date.now(),
|
|
821
|
-
"claude",
|
|
822
|
-
);
|
|
823
|
-
|
|
824
|
-
await vi.waitFor(() => {
|
|
825
|
-
expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
|
|
826
|
-
totalChars: 0,
|
|
827
|
-
unchangedSince: expect.any(Number),
|
|
828
|
-
});
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
|
|
832
|
-
const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
|
|
833
|
-
await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
|
|
834
|
-
expect(activePrompts.has("sid-response-stall")).toBe(true);
|
|
835
|
-
|
|
836
|
-
await vi.advanceTimersByTimeAsync(1_001);
|
|
837
|
-
await runPromise;
|
|
838
|
-
|
|
839
|
-
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
840
|
-
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
|
841
|
-
expect(activePrompts.has("sid-response-stall")).toBe(false);
|
|
842
|
-
expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
|
|
843
|
-
status: "auto_ended",
|
|
844
|
-
finalReply: "",
|
|
845
|
-
autoEndedAt: expect.any(Number),
|
|
846
|
-
});
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
it("auto-ends an Agent that stays in starting state without emitting any event", async () => {
|
|
850
|
-
vi.setSystemTime(0);
|
|
851
|
-
_setResponseStallTimeoutForTest(180_000);
|
|
852
|
-
_setResponseStallCheckIntervalForTest(1_000);
|
|
853
|
-
_setProcessAliveForTest(() => true);
|
|
854
|
-
|
|
855
|
-
const platform = mockPlatform("feishu");
|
|
856
|
-
setSessionPlatform(platform);
|
|
857
|
-
bindChatToSession("sid-starting-stall", "chat-starting-stall");
|
|
858
|
-
recordLastActiveChat("sid-starting-stall", "chat-starting-stall");
|
|
859
|
-
|
|
860
|
-
const closeSession = vi.fn();
|
|
861
|
-
const adapter: ToolAdapter = {
|
|
862
|
-
displayName: "Silent Agent",
|
|
863
|
-
sessionDescPrefix: "Agent Session:",
|
|
864
|
-
createSession: async () => ({ sessionId: "sid-starting-stall" }),
|
|
865
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
866
|
-
closeSession: async () => {},
|
|
867
|
-
prompt: async function* (
|
|
868
|
-
_sid: string,
|
|
869
|
-
_text: string,
|
|
870
|
-
_cwd: string,
|
|
871
|
-
signal?: AbortSignal,
|
|
872
|
-
options?: ToolPromptOptions,
|
|
873
|
-
) {
|
|
874
|
-
options?.onSessionCreated?.(closeSession);
|
|
875
|
-
options?.onProcessStart?.({ pid: 4343 });
|
|
876
|
-
await new Promise<void>((resolve) => {
|
|
877
|
-
if (signal?.aborted) {
|
|
878
|
-
resolve();
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
882
|
-
});
|
|
883
|
-
if (false) yield { type: "assistant", blocks: [] };
|
|
884
|
-
},
|
|
885
|
-
};
|
|
886
|
-
_setAdapterForToolForTest("codex", adapter);
|
|
887
|
-
|
|
888
|
-
const runPromise = runAgentSession(
|
|
889
|
-
"sid-starting-stall",
|
|
890
|
-
"prompt",
|
|
891
|
-
platform,
|
|
892
|
-
"chat-starting-stall",
|
|
893
|
-
Date.now(),
|
|
894
|
-
"codex",
|
|
895
|
-
);
|
|
896
|
-
|
|
897
|
-
await vi.waitFor(() => {
|
|
898
|
-
expect(activePrompts.get("sid-starting-stall")).toMatchObject({
|
|
899
|
-
processPid: 4343,
|
|
900
|
-
responseProgress: {
|
|
901
|
-
totalChars: 0,
|
|
902
|
-
unchangedSince: expect.any(Number),
|
|
903
|
-
},
|
|
904
|
-
});
|
|
905
|
-
expect(closeSession).toHaveBeenCalledTimes(0);
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
await vi.advanceTimersByTimeAsync(181_001);
|
|
909
|
-
const stateAfterDeadline = mockStreamStates.get("sid-starting-stall");
|
|
910
|
-
const closeCountAfterDeadline = closeSession.mock.calls.length;
|
|
911
|
-
const treeKillCountAfterDeadline = killProcessTreeMock.mock.calls
|
|
912
|
-
.filter(([pid]) => pid === 4343)
|
|
913
|
-
.length;
|
|
914
|
-
|
|
915
|
-
// 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
|
|
916
|
-
if (stateAfterDeadline?.status !== "auto_ended") {
|
|
917
|
-
stopSession("sid-starting-stall");
|
|
918
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
919
|
-
}
|
|
920
|
-
await runPromise;
|
|
921
|
-
|
|
922
|
-
expect(stateAfterDeadline).toMatchObject({
|
|
923
|
-
status: "auto_ended",
|
|
924
|
-
finalReply: "",
|
|
925
|
-
autoEndedAt: expect.any(Number),
|
|
926
|
-
});
|
|
927
|
-
expect(closeCountAfterDeadline).toBe(1);
|
|
928
|
-
expect(treeKillCountAfterDeadline).toBeGreaterThanOrEqual(1);
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
|
|
932
|
-
vi.setSystemTime(0);
|
|
933
|
-
_setResponseStallTimeoutForTest(100);
|
|
934
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
935
|
-
_setProcessAliveForTest(() => true);
|
|
936
|
-
|
|
937
|
-
const platform = mockPlatform("feishu");
|
|
938
|
-
setSessionPlatform(platform);
|
|
939
|
-
|
|
940
|
-
const receivedPrompts: string[] = [];
|
|
941
|
-
const adapter: ToolAdapter = {
|
|
942
|
-
displayName: "Any Agent",
|
|
943
|
-
sessionDescPrefix: "Agent Session:",
|
|
944
|
-
createSession: async () => ({ sessionId: "sid-binding-recovery" }),
|
|
945
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
946
|
-
closeSession: async () => {},
|
|
947
|
-
prompt: async function* (
|
|
948
|
-
_sid: string,
|
|
949
|
-
text: string,
|
|
950
|
-
_cwd: string,
|
|
951
|
-
signal?: AbortSignal,
|
|
952
|
-
options?: ToolPromptOptions,
|
|
953
|
-
) {
|
|
954
|
-
receivedPrompts.push(text);
|
|
955
|
-
options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
|
|
956
|
-
if (receivedPrompts.length === 1) {
|
|
957
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
958
|
-
await new Promise<void>((resolve) => {
|
|
959
|
-
if (signal?.aborted) {
|
|
960
|
-
resolve();
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
964
|
-
});
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
yield {
|
|
968
|
-
type: "assistant",
|
|
969
|
-
blocks: [{ type: "text", text: "recovery completed" }],
|
|
970
|
-
isFinalResponse: true,
|
|
971
|
-
};
|
|
972
|
-
},
|
|
973
|
-
};
|
|
974
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
975
|
-
|
|
976
|
-
const firstRun = runAgentSession(
|
|
977
|
-
"sid-binding-recovery",
|
|
978
|
-
"first prompt",
|
|
979
|
-
platform,
|
|
980
|
-
"chat-binding-recovery",
|
|
981
|
-
0,
|
|
982
|
-
"claude",
|
|
983
|
-
);
|
|
984
|
-
|
|
985
|
-
await vi.waitFor(() => {
|
|
986
|
-
expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
|
|
987
|
-
});
|
|
988
|
-
const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
|
|
989
|
-
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
990
|
-
await firstRun;
|
|
991
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
992
|
-
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
993
|
-
await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
|
|
994
|
-
|
|
995
|
-
expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
|
|
996
|
-
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
997
|
-
expect(platform.cardSend).toHaveBeenCalledTimes(2);
|
|
998
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
999
|
-
"chat-binding-recovery",
|
|
1000
|
-
expect.stringContaining(recoveryPrompt),
|
|
1001
|
-
);
|
|
1002
|
-
|
|
1003
|
-
const registry = JSON.parse(
|
|
1004
|
-
await readFile(join(tempDir, "session-registry.json"), "utf8"),
|
|
1005
|
-
) as Record<string, { running?: boolean }>;
|
|
1006
|
-
expect(registry["chat-binding-recovery"]?.running).toBe(false);
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
it("treats an authoritative final response that races timeout cleanup as completed", async () => {
|
|
1010
|
-
vi.setSystemTime(0);
|
|
1011
|
-
_setResponseStallTimeoutForTest(100);
|
|
1012
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1013
|
-
_setProcessAliveForTest(() => true);
|
|
1014
|
-
|
|
1015
|
-
const platform = mockPlatform("feishu");
|
|
1016
|
-
setSessionPlatform(platform);
|
|
1017
|
-
bindChatToSession("sid-final-race", "chat-final-race");
|
|
1018
|
-
recordLastActiveChat("sid-final-race", "chat-final-race");
|
|
1019
|
-
|
|
1020
|
-
const receivedPrompts: string[] = [];
|
|
1021
|
-
const adapter: ToolAdapter = {
|
|
1022
|
-
displayName: "Any Agent",
|
|
1023
|
-
sessionDescPrefix: "Agent Session:",
|
|
1024
|
-
createSession: async () => ({ sessionId: "sid-final-race" }),
|
|
1025
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1026
|
-
closeSession: async () => {},
|
|
1027
|
-
prompt: async function* (
|
|
1028
|
-
_sid: string,
|
|
1029
|
-
text: string,
|
|
1030
|
-
_cwd: string,
|
|
1031
|
-
signal?: AbortSignal,
|
|
1032
|
-
options?: ToolPromptOptions,
|
|
1033
|
-
) {
|
|
1034
|
-
receivedPrompts.push(text);
|
|
1035
|
-
options?.onProcessStart?.({ pid: 5151 });
|
|
1036
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1037
|
-
await new Promise<void>((resolve) => {
|
|
1038
|
-
if (signal?.aborted) {
|
|
1039
|
-
resolve();
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1043
|
-
});
|
|
1044
|
-
yield {
|
|
1045
|
-
type: "assistant",
|
|
1046
|
-
blocks: [{ type: "text", text: "completed at the timeout boundary" }],
|
|
1047
|
-
isFinalResponse: true,
|
|
1048
|
-
};
|
|
1049
|
-
},
|
|
1050
|
-
};
|
|
1051
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1052
|
-
|
|
1053
|
-
const run = runAgentSession(
|
|
1054
|
-
"sid-final-race",
|
|
1055
|
-
"prompt",
|
|
1056
|
-
platform,
|
|
1057
|
-
"chat-final-race",
|
|
1058
|
-
0,
|
|
1059
|
-
"claude",
|
|
1060
|
-
);
|
|
1061
|
-
await vi.waitFor(() => {
|
|
1062
|
-
expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
|
|
1063
|
-
});
|
|
1064
|
-
const progress = activePrompts.get("sid-final-race")!.responseProgress!;
|
|
1065
|
-
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1066
|
-
await run;
|
|
1067
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
1068
|
-
|
|
1069
|
-
expect(receivedPrompts).toHaveLength(1);
|
|
1070
|
-
expect(mockStreamStates.get("sid-final-race")).toMatchObject({
|
|
1071
|
-
status: "done",
|
|
1072
|
-
finalReply: "completed at the timeout boundary",
|
|
1073
|
-
});
|
|
1074
|
-
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
1075
|
-
"chat-final-race",
|
|
1076
|
-
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
1077
|
-
);
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
|
|
1081
|
-
vi.setSystemTime(0);
|
|
1082
|
-
_setResponseStallTimeoutForTest(100);
|
|
1083
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1084
|
-
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1085
|
-
_setProcessAliveForTest(() => true);
|
|
1086
|
-
|
|
1087
|
-
const platform = mockPlatform("feishu");
|
|
1088
|
-
setSessionPlatform(platform);
|
|
1089
|
-
bindChatToSession("sid-final-close", "chat-final-close");
|
|
1090
|
-
recordLastActiveChat("sid-final-close", "chat-final-close");
|
|
1091
|
-
|
|
1092
|
-
const closeSession = vi.fn();
|
|
1093
|
-
const adapter: ToolAdapter = {
|
|
1094
|
-
displayName: "Any Agent",
|
|
1095
|
-
sessionDescPrefix: "Agent Session:",
|
|
1096
|
-
createSession: async () => ({ sessionId: "sid-final-close" }),
|
|
1097
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1098
|
-
closeSession: async () => {},
|
|
1099
|
-
prompt: async function* (
|
|
1100
|
-
_sid: string,
|
|
1101
|
-
_text: string,
|
|
1102
|
-
_cwd: string,
|
|
1103
|
-
signal?: AbortSignal,
|
|
1104
|
-
options?: ToolPromptOptions,
|
|
1105
|
-
) {
|
|
1106
|
-
options?.onSessionCreated?.(closeSession);
|
|
1107
|
-
options?.onProcessStart?.({ pid: 7171 });
|
|
1108
|
-
yield {
|
|
1109
|
-
type: "assistant",
|
|
1110
|
-
blocks: [{ type: "text", text: "authoritative answer" }],
|
|
1111
|
-
};
|
|
1112
|
-
yield {
|
|
1113
|
-
type: "assistant",
|
|
1114
|
-
blocks: [],
|
|
1115
|
-
isFinalResponse: true,
|
|
1116
|
-
};
|
|
1117
|
-
await new Promise<void>((resolve) => {
|
|
1118
|
-
if (signal?.aborted) {
|
|
1119
|
-
resolve();
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1123
|
-
});
|
|
1124
|
-
},
|
|
1125
|
-
};
|
|
1126
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1127
|
-
|
|
1128
|
-
const run = runAgentSession(
|
|
1129
|
-
"sid-final-close",
|
|
1130
|
-
"prompt",
|
|
1131
|
-
platform,
|
|
1132
|
-
"chat-final-close",
|
|
1133
|
-
0,
|
|
1134
|
-
"claude",
|
|
1135
|
-
);
|
|
1136
|
-
|
|
1137
|
-
await vi.waitFor(() => {
|
|
1138
|
-
expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
|
|
1139
|
-
});
|
|
1140
|
-
await vi.advanceTimersByTimeAsync(900);
|
|
1141
|
-
expect(activePrompts.has("sid-final-close")).toBe(true);
|
|
1142
|
-
|
|
1143
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
1144
|
-
await run;
|
|
1145
|
-
|
|
1146
|
-
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
1147
|
-
expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
|
|
1148
|
-
expect(mockStreamStates.get("sid-final-close")).toMatchObject({
|
|
1149
|
-
status: "done",
|
|
1150
|
-
finalReply: "authoritative answer",
|
|
1151
|
-
});
|
|
1152
|
-
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
1153
|
-
"chat-final-close",
|
|
1154
|
-
expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
|
|
1155
|
-
);
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
it("cancels the final-response close guard when the stream exits normally", async () => {
|
|
1159
|
-
vi.setSystemTime(0);
|
|
1160
|
-
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1161
|
-
_setProcessAliveForTest(() => true);
|
|
1162
|
-
|
|
1163
|
-
const platform = mockPlatform("feishu");
|
|
1164
|
-
setSessionPlatform(platform);
|
|
1165
|
-
bindChatToSession("sid-final-normal", "chat-final-normal");
|
|
1166
|
-
recordLastActiveChat("sid-final-normal", "chat-final-normal");
|
|
1167
|
-
|
|
1168
|
-
const closeSession = vi.fn();
|
|
1169
|
-
const adapter: ToolAdapter = {
|
|
1170
|
-
displayName: "Any Agent",
|
|
1171
|
-
sessionDescPrefix: "Agent Session:",
|
|
1172
|
-
createSession: async () => ({ sessionId: "sid-final-normal" }),
|
|
1173
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1174
|
-
closeSession: async () => {},
|
|
1175
|
-
prompt: async function* (
|
|
1176
|
-
_sid: string,
|
|
1177
|
-
_text: string,
|
|
1178
|
-
_cwd: string,
|
|
1179
|
-
_signal?: AbortSignal,
|
|
1180
|
-
options?: ToolPromptOptions,
|
|
1181
|
-
) {
|
|
1182
|
-
options?.onSessionCreated?.(closeSession);
|
|
1183
|
-
options?.onProcessStart?.({ pid: 7272 });
|
|
1184
|
-
yield {
|
|
1185
|
-
type: "assistant",
|
|
1186
|
-
blocks: [{ type: "text", text: "normal answer" }],
|
|
1187
|
-
};
|
|
1188
|
-
yield {
|
|
1189
|
-
type: "assistant",
|
|
1190
|
-
blocks: [],
|
|
1191
|
-
isFinalResponse: true,
|
|
1192
|
-
};
|
|
1193
|
-
},
|
|
1194
|
-
};
|
|
1195
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1196
|
-
|
|
1197
|
-
await runAgentSession(
|
|
1198
|
-
"sid-final-normal",
|
|
1199
|
-
"prompt",
|
|
1200
|
-
platform,
|
|
1201
|
-
"chat-final-normal",
|
|
1202
|
-
0,
|
|
1203
|
-
"claude",
|
|
1204
|
-
);
|
|
1205
|
-
await vi.advanceTimersByTimeAsync(2_000);
|
|
1206
|
-
|
|
1207
|
-
expect(closeSession).not.toHaveBeenCalled();
|
|
1208
|
-
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
|
1209
|
-
expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
|
|
1210
|
-
status: "done",
|
|
1211
|
-
finalReply: "normal answer",
|
|
1212
|
-
});
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
|
-
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
1216
|
-
vi.setSystemTime(0);
|
|
1217
|
-
_setResponseStallTimeoutForTest(100);
|
|
1218
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1219
|
-
_setProcessAliveForTest(() => true);
|
|
1220
|
-
|
|
1221
|
-
const platform = mockPlatform("feishu");
|
|
1222
|
-
setSessionPlatform(platform);
|
|
1223
|
-
bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
|
|
1224
|
-
recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
|
|
1225
|
-
|
|
1226
|
-
const receivedPrompts: string[] = [];
|
|
1227
|
-
const adapter: ToolAdapter = {
|
|
1228
|
-
displayName: "Any Agent",
|
|
1229
|
-
sessionDescPrefix: "Agent Session:",
|
|
1230
|
-
createSession: async () => ({ sessionId: "sid-recovery-priority" }),
|
|
1231
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1232
|
-
closeSession: async () => {},
|
|
1233
|
-
prompt: async function* (
|
|
1234
|
-
_sid: string,
|
|
1235
|
-
text: string,
|
|
1236
|
-
_cwd: string,
|
|
1237
|
-
signal?: AbortSignal,
|
|
1238
|
-
options?: ToolPromptOptions,
|
|
1239
|
-
) {
|
|
1240
|
-
receivedPrompts.push(text);
|
|
1241
|
-
options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
|
|
1242
|
-
if (receivedPrompts.length === 1) {
|
|
1243
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1244
|
-
await new Promise<void>((resolve) => {
|
|
1245
|
-
if (signal?.aborted) {
|
|
1246
|
-
resolve();
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1250
|
-
});
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
|
|
1254
|
-
},
|
|
1255
|
-
};
|
|
1256
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1257
|
-
|
|
1258
|
-
enqueueMessage("sid-recovery-priority", {
|
|
1259
|
-
text: "queued user prompt",
|
|
1260
|
-
chatId: "chat-recovery-priority",
|
|
1261
|
-
openId: "open-user",
|
|
1262
|
-
msgTimestamp: 1,
|
|
1263
|
-
chatType: "p2p",
|
|
1264
|
-
});
|
|
1265
|
-
const consumeQueued = vi.fn();
|
|
1266
|
-
setQueueConsumer(consumeQueued);
|
|
1267
|
-
|
|
1268
|
-
const firstRun = runAgentSession(
|
|
1269
|
-
"sid-recovery-priority",
|
|
1270
|
-
"first prompt",
|
|
1271
|
-
platform,
|
|
1272
|
-
"chat-recovery-priority",
|
|
1273
|
-
0,
|
|
1274
|
-
"claude",
|
|
1275
|
-
);
|
|
1276
|
-
|
|
1277
|
-
await vi.waitFor(() => {
|
|
1278
|
-
expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
|
|
1279
|
-
});
|
|
1280
|
-
const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
|
|
1281
|
-
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1282
|
-
await firstRun;
|
|
1283
|
-
|
|
1284
|
-
expect(receivedPrompts).toHaveLength(1);
|
|
1285
|
-
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1286
|
-
expect(isSessionRunning("sid-recovery-priority")).toBe(true);
|
|
1287
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1288
|
-
"chat-recovery-priority",
|
|
1289
|
-
expect.stringContaining(recoveryPrompt),
|
|
1290
|
-
);
|
|
1291
|
-
|
|
1292
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1293
|
-
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1294
|
-
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1295
|
-
expect(receivedPrompts[1]).not.toContain("queued user prompt");
|
|
1296
|
-
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1297
|
-
|
|
1298
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1299
|
-
await vi.waitFor(() => expect(consumeQueued).toHaveBeenCalledTimes(1));
|
|
1300
|
-
expect(consumeQueued).toHaveBeenCalledWith(
|
|
1301
|
-
platform,
|
|
1302
|
-
expect.objectContaining({ text: "queued user prompt" }),
|
|
1303
|
-
);
|
|
1304
|
-
setQueueConsumer(() => {});
|
|
1305
|
-
});
|
|
1306
|
-
|
|
1307
|
-
it("does not auto-recover a second consecutive response stall", async () => {
|
|
1308
|
-
vi.setSystemTime(0);
|
|
1309
|
-
_setResponseStallTimeoutForTest(100);
|
|
1310
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1311
|
-
_setProcessAliveForTest(() => true);
|
|
1312
|
-
|
|
1313
|
-
const platform = mockPlatform("feishu");
|
|
1314
|
-
setSessionPlatform(platform);
|
|
1315
|
-
bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
|
|
1316
|
-
recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
|
|
1317
|
-
|
|
1318
|
-
const receivedPrompts: string[] = [];
|
|
1319
|
-
const adapter: ToolAdapter = {
|
|
1320
|
-
displayName: "Any Agent",
|
|
1321
|
-
sessionDescPrefix: "Agent Session:",
|
|
1322
|
-
createSession: async () => ({ sessionId: "sid-recovery-limit" }),
|
|
1323
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1324
|
-
closeSession: async () => {},
|
|
1325
|
-
prompt: async function* (
|
|
1326
|
-
_sid: string,
|
|
1327
|
-
text: string,
|
|
1328
|
-
_cwd: string,
|
|
1329
|
-
signal?: AbortSignal,
|
|
1330
|
-
options?: ToolPromptOptions,
|
|
1331
|
-
) {
|
|
1332
|
-
receivedPrompts.push(text);
|
|
1333
|
-
options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
|
|
1334
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1335
|
-
await new Promise<void>((resolve) => {
|
|
1336
|
-
if (signal?.aborted) {
|
|
1337
|
-
resolve();
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1341
|
-
});
|
|
1342
|
-
},
|
|
1343
|
-
};
|
|
1344
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1345
|
-
|
|
1346
|
-
const firstRun = runAgentSession(
|
|
1347
|
-
"sid-recovery-limit",
|
|
1348
|
-
"first prompt",
|
|
1349
|
-
platform,
|
|
1350
|
-
"chat-recovery-limit",
|
|
1351
|
-
0,
|
|
1352
|
-
"claude",
|
|
1353
|
-
);
|
|
1354
|
-
await vi.waitFor(() => {
|
|
1355
|
-
expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
|
|
1356
|
-
});
|
|
1357
|
-
const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1358
|
-
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1359
|
-
await firstRun;
|
|
1360
|
-
|
|
1361
|
-
await vi.advanceTimersByTimeAsync(200);
|
|
1362
|
-
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1363
|
-
const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1364
|
-
await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
|
|
1365
|
-
await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
|
|
1366
|
-
|
|
1367
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
1368
|
-
expect(receivedPrompts).toHaveLength(2);
|
|
1369
|
-
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1370
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1371
|
-
"chat-recovery-limit",
|
|
1372
|
-
"⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
|
|
1373
|
-
);
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
it("lets /stop cancel a reserved recovery before it starts", async () => {
|
|
1377
|
-
vi.setSystemTime(0);
|
|
1378
|
-
_setResponseStallTimeoutForTest(100);
|
|
1379
|
-
_setResponseStallCheckIntervalForTest(10);
|
|
1380
|
-
_setProcessAliveForTest(() => true);
|
|
1381
|
-
|
|
1382
|
-
const platform = mockPlatform("feishu");
|
|
1383
|
-
setSessionPlatform(platform);
|
|
1384
|
-
bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
|
|
1385
|
-
recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
|
|
1386
|
-
|
|
1387
|
-
const receivedPrompts: string[] = [];
|
|
1388
|
-
const adapter: ToolAdapter = {
|
|
1389
|
-
displayName: "Any Agent",
|
|
1390
|
-
sessionDescPrefix: "Agent Session:",
|
|
1391
|
-
createSession: async () => ({ sessionId: "sid-recovery-stop" }),
|
|
1392
|
-
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1393
|
-
closeSession: async () => {},
|
|
1394
|
-
prompt: async function* (
|
|
1395
|
-
_sid: string,
|
|
1396
|
-
text: string,
|
|
1397
|
-
_cwd: string,
|
|
1398
|
-
signal?: AbortSignal,
|
|
1399
|
-
) {
|
|
1400
|
-
receivedPrompts.push(text);
|
|
1401
|
-
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1402
|
-
await new Promise<void>((resolve) => {
|
|
1403
|
-
if (signal?.aborted) {
|
|
1404
|
-
resolve();
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1408
|
-
});
|
|
1409
|
-
},
|
|
1410
|
-
};
|
|
1411
|
-
_setAdapterForToolForTest("claude", adapter);
|
|
1412
|
-
|
|
1413
|
-
const firstRun = runAgentSession(
|
|
1414
|
-
"sid-recovery-stop",
|
|
1415
|
-
"first prompt",
|
|
1416
|
-
platform,
|
|
1417
|
-
"chat-recovery-stop",
|
|
1418
|
-
0,
|
|
1419
|
-
"claude",
|
|
1420
|
-
);
|
|
1421
|
-
await vi.waitFor(() => {
|
|
1422
|
-
expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
|
|
1423
|
-
});
|
|
1424
|
-
const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
|
|
1425
|
-
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1426
|
-
await firstRun;
|
|
1427
|
-
|
|
1428
|
-
expect(isSessionRunning("sid-recovery-stop")).toBe(true);
|
|
1429
|
-
expect(stopSession("sid-recovery-stop")).toBe(true);
|
|
1430
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
1431
|
-
|
|
1432
|
-
expect(receivedPrompts).toHaveLength(1);
|
|
1433
|
-
expect(isSessionRunning("sid-recovery-stop")).toBe(false);
|
|
1434
|
-
});
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
describe("unified display loop WeChat delta", () => {
|
|
1438
|
-
beforeEach(() => {
|
|
750
|
+
describe("runAgentSession response stall watchdog", () => {
|
|
751
|
+
let tempDir = "";
|
|
752
|
+
const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
|
|
753
|
+
|
|
754
|
+
beforeEach(async () => {
|
|
1439
755
|
vi.useFakeTimers();
|
|
1440
756
|
resetState();
|
|
1441
757
|
resetBindingState();
|
|
1442
758
|
mockStreamStates.clear();
|
|
759
|
+
killProcessTreeMock.mockClear();
|
|
760
|
+
tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
|
|
761
|
+
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
762
|
+
_setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
|
|
1443
763
|
});
|
|
1444
764
|
|
|
1445
|
-
afterEach(() => {
|
|
1446
|
-
|
|
765
|
+
afterEach(async () => {
|
|
766
|
+
_resetSessionRegistryFileForTest();
|
|
767
|
+
_resetSessionToolsFileForTest();
|
|
768
|
+
_clearAdapterCacheForTest();
|
|
769
|
+
_resetProcessAliveForTest();
|
|
770
|
+
_resetResponseStallTimeoutForTest();
|
|
771
|
+
_resetResponseStallCheckIntervalForTest();
|
|
772
|
+
_resetFinalResponseCloseTimeoutForTest();
|
|
1447
773
|
resetBindingState();
|
|
1448
774
|
vi.useRealTimers();
|
|
775
|
+
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
1449
776
|
});
|
|
1450
777
|
|
|
1451
|
-
it("
|
|
1452
|
-
|
|
1453
|
-
|
|
778
|
+
it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
|
|
779
|
+
vi.setSystemTime(0);
|
|
780
|
+
_setResponseStallTimeoutForTest(180_000);
|
|
781
|
+
_setResponseStallCheckIntervalForTest(1_000);
|
|
782
|
+
_setProcessAliveForTest(() => true);
|
|
783
|
+
|
|
784
|
+
const platform = mockPlatform("feishu");
|
|
1454
785
|
setSessionPlatform(platform);
|
|
786
|
+
bindChatToSession("sid-response-stall", "chat-response-stall");
|
|
787
|
+
recordLastActiveChat("sid-response-stall", "chat-response-stall");
|
|
1455
788
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
789
|
+
const closeSession = vi.fn();
|
|
790
|
+
const adapter: ToolAdapter = {
|
|
791
|
+
displayName: "Any Agent",
|
|
792
|
+
sessionDescPrefix: "Agent Session:",
|
|
793
|
+
createSession: async () => ({ sessionId: "sid-response-stall" }),
|
|
794
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
795
|
+
closeSession: async () => {},
|
|
796
|
+
prompt: async function* (
|
|
797
|
+
_sid: string,
|
|
798
|
+
_text: string,
|
|
799
|
+
_cwd: string,
|
|
800
|
+
signal?: AbortSignal,
|
|
801
|
+
options?: ToolPromptOptions,
|
|
802
|
+
) {
|
|
803
|
+
options?.onSessionCreated?.(closeSession);
|
|
804
|
+
options?.onProcessStart?.({ pid: 4242 });
|
|
805
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
806
|
+
await new Promise<void>((resolve) => {
|
|
807
|
+
if (signal?.aborted) {
|
|
808
|
+
resolve();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
812
|
+
});
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1465
816
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
sessionId: "sid-wechat",
|
|
1475
|
-
turnCount: 1,
|
|
1476
|
-
dotCount: 0,
|
|
1477
|
-
});
|
|
817
|
+
const runPromise = runAgentSession(
|
|
818
|
+
"sid-response-stall",
|
|
819
|
+
"prompt",
|
|
820
|
+
platform,
|
|
821
|
+
"chat-response-stall",
|
|
822
|
+
Date.now(),
|
|
823
|
+
"claude",
|
|
824
|
+
);
|
|
1478
825
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
826
|
+
await vi.waitFor(() => {
|
|
827
|
+
expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
|
|
828
|
+
totalChars: 0,
|
|
829
|
+
unchangedSince: expect.any(Number),
|
|
830
|
+
});
|
|
1483
831
|
});
|
|
1484
|
-
startUnifiedDisplayLoop();
|
|
1485
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
1486
832
|
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
});
|
|
1492
|
-
await vi.advanceTimersByTimeAsync(3000);
|
|
833
|
+
const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
|
|
834
|
+
const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
|
|
835
|
+
await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
|
|
836
|
+
expect(activePrompts.has("sid-response-stall")).toBe(true);
|
|
1493
837
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
);
|
|
1499
|
-
expect(
|
|
1500
|
-
|
|
1501
|
-
"
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
});
|
|
1507
|
-
|
|
1508
|
-
describe("unified display loop activity status", () => {
|
|
1509
|
-
beforeEach(() => {
|
|
1510
|
-
vi.useFakeTimers();
|
|
1511
|
-
vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
|
|
1512
|
-
resetState();
|
|
1513
|
-
resetBindingState();
|
|
1514
|
-
mockStreamStates.clear();
|
|
1515
|
-
});
|
|
1516
|
-
|
|
1517
|
-
afterEach(() => {
|
|
1518
|
-
stopUnifiedDisplayLoop();
|
|
1519
|
-
resetBindingState();
|
|
1520
|
-
vi.useRealTimers();
|
|
1521
|
-
});
|
|
1522
|
-
|
|
1523
|
-
it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
|
|
1524
|
-
const platform = mockPlatform("feishu");
|
|
1525
|
-
setSessionPlatform(platform);
|
|
1526
|
-
|
|
1527
|
-
bindChatToSession("sid-activity", "chat-activity");
|
|
1528
|
-
recordLastActiveChat("sid-activity", "chat-activity");
|
|
1529
|
-
sessionInfoMap.set("chat-activity", {
|
|
1530
|
-
sessionId: "sid-activity",
|
|
1531
|
-
turnCount: 1,
|
|
1532
|
-
lastContextTokens: 0,
|
|
1533
|
-
startTime: Date.now() - 12_000,
|
|
1534
|
-
tool: "codex",
|
|
1535
|
-
});
|
|
1536
|
-
displayCards.set("chat-activity", {
|
|
1537
|
-
cardId: "card-activity",
|
|
1538
|
-
sequence: 1,
|
|
1539
|
-
cardBusy: false,
|
|
1540
|
-
cardCreatedAt: Date.now(),
|
|
1541
|
-
lastSentContent: "",
|
|
1542
|
-
streamErrorNotified: false,
|
|
1543
|
-
sessionId: "sid-activity",
|
|
1544
|
-
turnCount: 1,
|
|
1545
|
-
dotCount: 0,
|
|
1546
|
-
});
|
|
1547
|
-
mockStreamStates.set("sid-activity", {
|
|
1548
|
-
accumulatedContent: "正在检查日志",
|
|
1549
|
-
finalReply: "",
|
|
1550
|
-
activity: {
|
|
1551
|
-
kind: "tool",
|
|
1552
|
-
startedAt: Date.now() - 12_000,
|
|
1553
|
-
toolName: "Shell",
|
|
1554
|
-
toolCount: 1,
|
|
1555
|
-
},
|
|
1556
|
-
status: "running",
|
|
1557
|
-
turnCount: 1,
|
|
1558
|
-
});
|
|
1559
|
-
|
|
1560
|
-
startUnifiedDisplayLoop();
|
|
1561
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
1562
|
-
|
|
1563
|
-
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1564
|
-
expect(payload).toBeTypeOf("string");
|
|
1565
|
-
const card = JSON.parse(payload as string) as {
|
|
1566
|
-
header: { title: { content: string } };
|
|
1567
|
-
body: { elements: Array<{ tag: string; content?: string }> };
|
|
1568
|
-
};
|
|
1569
|
-
expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
|
|
1570
|
-
expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
|
|
1571
|
-
});
|
|
1572
|
-
});
|
|
1573
|
-
|
|
1574
|
-
describe("unified display loop terminal card update", () => {
|
|
1575
|
-
beforeEach(() => {
|
|
1576
|
-
vi.useFakeTimers();
|
|
1577
|
-
resetState();
|
|
1578
|
-
resetBindingState();
|
|
1579
|
-
mockStreamStates.clear();
|
|
838
|
+
await vi.advanceTimersByTimeAsync(1_001);
|
|
839
|
+
await runPromise;
|
|
840
|
+
|
|
841
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
842
|
+
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
|
843
|
+
expect(activePrompts.has("sid-response-stall")).toBe(false);
|
|
844
|
+
expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
|
|
845
|
+
status: "auto_ended",
|
|
846
|
+
finalReply: "",
|
|
847
|
+
autoEndedAt: expect.any(Number),
|
|
848
|
+
});
|
|
1580
849
|
});
|
|
1581
850
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
|
|
1589
|
-
const platform = mockPlatform("feishu");
|
|
1590
|
-
setSessionPlatform(platform);
|
|
1591
|
-
|
|
1592
|
-
bindChatToSession("sid-auto-ended", "chat-auto-ended");
|
|
1593
|
-
recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
|
|
1594
|
-
sessionInfoMap.set("chat-auto-ended", {
|
|
1595
|
-
sessionId: "sid-auto-ended",
|
|
1596
|
-
turnCount: 1,
|
|
1597
|
-
lastContextTokens: 0,
|
|
1598
|
-
startTime: 0,
|
|
1599
|
-
tool: "cursor",
|
|
1600
|
-
});
|
|
1601
|
-
displayCards.set("chat-auto-ended", {
|
|
1602
|
-
cardId: "card-auto-ended",
|
|
1603
|
-
sequence: 1,
|
|
1604
|
-
cardBusy: false,
|
|
1605
|
-
cardCreatedAt: Date.now(),
|
|
1606
|
-
lastSentContent: "",
|
|
1607
|
-
streamErrorNotified: false,
|
|
1608
|
-
sessionId: "sid-auto-ended",
|
|
1609
|
-
turnCount: 1,
|
|
1610
|
-
dotCount: 0,
|
|
1611
|
-
});
|
|
1612
|
-
mockStreamStates.set("sid-auto-ended", {
|
|
1613
|
-
accumulatedContent: "",
|
|
1614
|
-
finalReply: "",
|
|
1615
|
-
status: "auto_ended",
|
|
1616
|
-
turnCount: 1,
|
|
1617
|
-
autoEndedAt: Date.now(),
|
|
1618
|
-
});
|
|
1619
|
-
|
|
1620
|
-
startUnifiedDisplayLoop();
|
|
1621
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
1622
|
-
|
|
1623
|
-
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1624
|
-
const card = JSON.parse(payload as string) as {
|
|
1625
|
-
header: { title: { content: string }; template?: string };
|
|
1626
|
-
};
|
|
1627
|
-
expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
|
|
1628
|
-
expect(card.header.template).toBe("orange");
|
|
1629
|
-
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1630
|
-
"chat-auto-ended",
|
|
1631
|
-
"⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
|
|
1632
|
-
);
|
|
1633
|
-
expect(displayCards.has("chat-auto-ended")).toBe(false);
|
|
1634
|
-
});
|
|
1635
|
-
|
|
1636
|
-
it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
|
|
851
|
+
it("auto-ends an Agent that stays in starting state without emitting any event", async () => {
|
|
852
|
+
vi.setSystemTime(0);
|
|
853
|
+
_setResponseStallTimeoutForTest(180_000);
|
|
854
|
+
_setResponseStallCheckIntervalForTest(1_000);
|
|
855
|
+
_setProcessAliveForTest(() => true);
|
|
856
|
+
|
|
1637
857
|
const platform = mockPlatform("feishu");
|
|
1638
|
-
platform.cardUpdate = vi.fn(async () => {
|
|
1639
|
-
throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
|
|
1640
|
-
});
|
|
1641
858
|
setSessionPlatform(platform);
|
|
859
|
+
bindChatToSession("sid-starting-stall", "chat-starting-stall");
|
|
860
|
+
recordLastActiveChat("sid-starting-stall", "chat-starting-stall");
|
|
1642
861
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
862
|
+
const closeSession = vi.fn();
|
|
863
|
+
const adapter: ToolAdapter = {
|
|
864
|
+
displayName: "Silent Agent",
|
|
865
|
+
sessionDescPrefix: "Agent Session:",
|
|
866
|
+
createSession: async () => ({ sessionId: "sid-starting-stall" }),
|
|
867
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
868
|
+
closeSession: async () => {},
|
|
869
|
+
prompt: async function* (
|
|
870
|
+
_sid: string,
|
|
871
|
+
_text: string,
|
|
872
|
+
_cwd: string,
|
|
873
|
+
signal?: AbortSignal,
|
|
874
|
+
options?: ToolPromptOptions,
|
|
875
|
+
) {
|
|
876
|
+
options?.onSessionCreated?.(closeSession);
|
|
877
|
+
options?.onProcessStart?.({ pid: 4343 });
|
|
878
|
+
await new Promise<void>((resolve) => {
|
|
879
|
+
if (signal?.aborted) {
|
|
880
|
+
resolve();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
884
|
+
});
|
|
885
|
+
if (false) yield { type: "assistant", blocks: [] };
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
_setAdapterForToolForTest("codex", adapter);
|
|
889
|
+
|
|
890
|
+
const runPromise = runAgentSession(
|
|
891
|
+
"sid-starting-stall",
|
|
892
|
+
"prompt",
|
|
893
|
+
platform,
|
|
894
|
+
"chat-starting-stall",
|
|
895
|
+
Date.now(),
|
|
896
|
+
"codex",
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
await vi.waitFor(() => {
|
|
900
|
+
expect(activePrompts.get("sid-starting-stall")).toMatchObject({
|
|
901
|
+
processPid: 4343,
|
|
902
|
+
responseProgress: {
|
|
903
|
+
totalChars: 0,
|
|
904
|
+
unchangedSince: expect.any(Number),
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
expect(closeSession).toHaveBeenCalledTimes(0);
|
|
1673
908
|
});
|
|
1674
909
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
910
|
+
await vi.advanceTimersByTimeAsync(181_001);
|
|
911
|
+
const stateAfterDeadline = mockStreamStates.get("sid-starting-stall");
|
|
912
|
+
const closeCountAfterDeadline = closeSession.mock.calls.length;
|
|
913
|
+
const treeKillCountAfterDeadline = killProcessTreeMock.mock.calls
|
|
914
|
+
.filter(([pid]) => pid === 4343)
|
|
915
|
+
.length;
|
|
916
|
+
|
|
917
|
+
// 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
|
|
918
|
+
if (stateAfterDeadline?.status !== "auto_ended") {
|
|
919
|
+
stopSession("sid-starting-stall");
|
|
920
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
921
|
+
}
|
|
922
|
+
await runPromise;
|
|
1678
923
|
|
|
1679
|
-
expect(
|
|
1680
|
-
|
|
1681
|
-
"
|
|
1682
|
-
expect.any(
|
|
1683
|
-
|
|
1684
|
-
);
|
|
1685
|
-
expect(
|
|
924
|
+
expect(stateAfterDeadline).toMatchObject({
|
|
925
|
+
status: "auto_ended",
|
|
926
|
+
finalReply: "",
|
|
927
|
+
autoEndedAt: expect.any(Number),
|
|
928
|
+
});
|
|
929
|
+
expect(closeCountAfterDeadline).toBe(1);
|
|
930
|
+
expect(treeKillCountAfterDeadline).toBeGreaterThanOrEqual(1);
|
|
1686
931
|
});
|
|
1687
932
|
|
|
1688
|
-
it("
|
|
933
|
+
it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
|
|
934
|
+
vi.setSystemTime(0);
|
|
935
|
+
_setResponseStallTimeoutForTest(100);
|
|
936
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
937
|
+
_setProcessAliveForTest(() => true);
|
|
938
|
+
|
|
939
|
+
const platform = mockPlatform("feishu");
|
|
940
|
+
setSessionPlatform(platform);
|
|
941
|
+
|
|
942
|
+
const receivedPrompts: string[] = [];
|
|
943
|
+
const adapter: ToolAdapter = {
|
|
944
|
+
displayName: "Any Agent",
|
|
945
|
+
sessionDescPrefix: "Agent Session:",
|
|
946
|
+
createSession: async () => ({ sessionId: "sid-binding-recovery" }),
|
|
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
|
+
receivedPrompts.push(text);
|
|
957
|
+
options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
|
|
958
|
+
if (receivedPrompts.length === 1) {
|
|
959
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
960
|
+
await new Promise<void>((resolve) => {
|
|
961
|
+
if (signal?.aborted) {
|
|
962
|
+
resolve();
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
966
|
+
});
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
yield {
|
|
970
|
+
type: "assistant",
|
|
971
|
+
blocks: [{ type: "text", text: "recovery completed" }],
|
|
972
|
+
isFinalResponse: true,
|
|
973
|
+
};
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
977
|
+
|
|
978
|
+
const firstRun = runAgentSession(
|
|
979
|
+
"sid-binding-recovery",
|
|
980
|
+
"first prompt",
|
|
981
|
+
platform,
|
|
982
|
+
"chat-binding-recovery",
|
|
983
|
+
0,
|
|
984
|
+
"claude",
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
await vi.waitFor(() => {
|
|
988
|
+
expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
|
|
989
|
+
});
|
|
990
|
+
const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
|
|
991
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
992
|
+
await firstRun;
|
|
993
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
994
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
995
|
+
await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
|
|
996
|
+
|
|
997
|
+
expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
|
|
998
|
+
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
999
|
+
expect(platform.cardSend).toHaveBeenCalledTimes(2);
|
|
1000
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1001
|
+
"chat-binding-recovery",
|
|
1002
|
+
expect.stringContaining(recoveryPrompt),
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
const registry = JSON.parse(
|
|
1006
|
+
await readFile(join(tempDir, "session-registry.json"), "utf8"),
|
|
1007
|
+
) as Record<string, { running?: boolean }>;
|
|
1008
|
+
expect(registry["chat-binding-recovery"]?.running).toBe(false);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("treats an authoritative final response that races timeout cleanup as completed", async () => {
|
|
1012
|
+
vi.setSystemTime(0);
|
|
1013
|
+
_setResponseStallTimeoutForTest(100);
|
|
1014
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1015
|
+
_setProcessAliveForTest(() => true);
|
|
1016
|
+
|
|
1017
|
+
const platform = mockPlatform("feishu");
|
|
1018
|
+
setSessionPlatform(platform);
|
|
1019
|
+
bindChatToSession("sid-final-race", "chat-final-race");
|
|
1020
|
+
recordLastActiveChat("sid-final-race", "chat-final-race");
|
|
1021
|
+
|
|
1022
|
+
const receivedPrompts: string[] = [];
|
|
1023
|
+
const adapter: ToolAdapter = {
|
|
1024
|
+
displayName: "Any Agent",
|
|
1025
|
+
sessionDescPrefix: "Agent Session:",
|
|
1026
|
+
createSession: async () => ({ sessionId: "sid-final-race" }),
|
|
1027
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1028
|
+
closeSession: async () => {},
|
|
1029
|
+
prompt: async function* (
|
|
1030
|
+
_sid: string,
|
|
1031
|
+
text: string,
|
|
1032
|
+
_cwd: string,
|
|
1033
|
+
signal?: AbortSignal,
|
|
1034
|
+
options?: ToolPromptOptions,
|
|
1035
|
+
) {
|
|
1036
|
+
receivedPrompts.push(text);
|
|
1037
|
+
options?.onProcessStart?.({ pid: 5151 });
|
|
1038
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1039
|
+
await new Promise<void>((resolve) => {
|
|
1040
|
+
if (signal?.aborted) {
|
|
1041
|
+
resolve();
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1045
|
+
});
|
|
1046
|
+
yield {
|
|
1047
|
+
type: "assistant",
|
|
1048
|
+
blocks: [{ type: "text", text: "completed at the timeout boundary" }],
|
|
1049
|
+
isFinalResponse: true,
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1054
|
+
|
|
1055
|
+
const run = runAgentSession(
|
|
1056
|
+
"sid-final-race",
|
|
1057
|
+
"prompt",
|
|
1058
|
+
platform,
|
|
1059
|
+
"chat-final-race",
|
|
1060
|
+
0,
|
|
1061
|
+
"claude",
|
|
1062
|
+
);
|
|
1063
|
+
await vi.waitFor(() => {
|
|
1064
|
+
expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
|
|
1065
|
+
});
|
|
1066
|
+
const progress = activePrompts.get("sid-final-race")!.responseProgress!;
|
|
1067
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1068
|
+
await run;
|
|
1069
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1070
|
+
|
|
1071
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
1072
|
+
expect(mockStreamStates.get("sid-final-race")).toMatchObject({
|
|
1073
|
+
status: "done",
|
|
1074
|
+
finalReply: "completed at the timeout boundary",
|
|
1075
|
+
});
|
|
1076
|
+
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
1077
|
+
"chat-final-race",
|
|
1078
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
1079
|
+
);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
|
|
1083
|
+
vi.setSystemTime(0);
|
|
1084
|
+
_setResponseStallTimeoutForTest(100);
|
|
1085
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1086
|
+
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1087
|
+
_setProcessAliveForTest(() => true);
|
|
1088
|
+
|
|
1089
|
+
const platform = mockPlatform("feishu");
|
|
1090
|
+
setSessionPlatform(platform);
|
|
1091
|
+
bindChatToSession("sid-final-close", "chat-final-close");
|
|
1092
|
+
recordLastActiveChat("sid-final-close", "chat-final-close");
|
|
1093
|
+
|
|
1094
|
+
const closeSession = vi.fn();
|
|
1095
|
+
const adapter: ToolAdapter = {
|
|
1096
|
+
displayName: "Any Agent",
|
|
1097
|
+
sessionDescPrefix: "Agent Session:",
|
|
1098
|
+
createSession: async () => ({ sessionId: "sid-final-close" }),
|
|
1099
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1100
|
+
closeSession: async () => {},
|
|
1101
|
+
prompt: async function* (
|
|
1102
|
+
_sid: string,
|
|
1103
|
+
_text: string,
|
|
1104
|
+
_cwd: string,
|
|
1105
|
+
signal?: AbortSignal,
|
|
1106
|
+
options?: ToolPromptOptions,
|
|
1107
|
+
) {
|
|
1108
|
+
options?.onSessionCreated?.(closeSession);
|
|
1109
|
+
options?.onProcessStart?.({ pid: 7171 });
|
|
1110
|
+
yield {
|
|
1111
|
+
type: "assistant",
|
|
1112
|
+
blocks: [{ type: "text", text: "authoritative answer" }],
|
|
1113
|
+
};
|
|
1114
|
+
yield {
|
|
1115
|
+
type: "assistant",
|
|
1116
|
+
blocks: [],
|
|
1117
|
+
isFinalResponse: true,
|
|
1118
|
+
};
|
|
1119
|
+
await new Promise<void>((resolve) => {
|
|
1120
|
+
if (signal?.aborted) {
|
|
1121
|
+
resolve();
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1125
|
+
});
|
|
1126
|
+
},
|
|
1127
|
+
};
|
|
1128
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1129
|
+
|
|
1130
|
+
const run = runAgentSession(
|
|
1131
|
+
"sid-final-close",
|
|
1132
|
+
"prompt",
|
|
1133
|
+
platform,
|
|
1134
|
+
"chat-final-close",
|
|
1135
|
+
0,
|
|
1136
|
+
"claude",
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
await vi.waitFor(() => {
|
|
1140
|
+
expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
|
|
1141
|
+
});
|
|
1142
|
+
await vi.advanceTimersByTimeAsync(900);
|
|
1143
|
+
expect(activePrompts.has("sid-final-close")).toBe(true);
|
|
1144
|
+
|
|
1145
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1146
|
+
await run;
|
|
1147
|
+
|
|
1148
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
1149
|
+
expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
|
|
1150
|
+
expect(mockStreamStates.get("sid-final-close")).toMatchObject({
|
|
1151
|
+
status: "done",
|
|
1152
|
+
finalReply: "authoritative answer",
|
|
1153
|
+
});
|
|
1154
|
+
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
1155
|
+
"chat-final-close",
|
|
1156
|
+
expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
|
|
1157
|
+
);
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it("cancels the final-response close guard when the stream exits normally", async () => {
|
|
1161
|
+
vi.setSystemTime(0);
|
|
1162
|
+
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1163
|
+
_setProcessAliveForTest(() => true);
|
|
1164
|
+
|
|
1165
|
+
const platform = mockPlatform("feishu");
|
|
1166
|
+
setSessionPlatform(platform);
|
|
1167
|
+
bindChatToSession("sid-final-normal", "chat-final-normal");
|
|
1168
|
+
recordLastActiveChat("sid-final-normal", "chat-final-normal");
|
|
1169
|
+
|
|
1170
|
+
const closeSession = vi.fn();
|
|
1171
|
+
const adapter: ToolAdapter = {
|
|
1172
|
+
displayName: "Any Agent",
|
|
1173
|
+
sessionDescPrefix: "Agent Session:",
|
|
1174
|
+
createSession: async () => ({ sessionId: "sid-final-normal" }),
|
|
1175
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1176
|
+
closeSession: async () => {},
|
|
1177
|
+
prompt: async function* (
|
|
1178
|
+
_sid: string,
|
|
1179
|
+
_text: string,
|
|
1180
|
+
_cwd: string,
|
|
1181
|
+
_signal?: AbortSignal,
|
|
1182
|
+
options?: ToolPromptOptions,
|
|
1183
|
+
) {
|
|
1184
|
+
options?.onSessionCreated?.(closeSession);
|
|
1185
|
+
options?.onProcessStart?.({ pid: 7272 });
|
|
1186
|
+
yield {
|
|
1187
|
+
type: "assistant",
|
|
1188
|
+
blocks: [{ type: "text", text: "normal answer" }],
|
|
1189
|
+
};
|
|
1190
|
+
yield {
|
|
1191
|
+
type: "assistant",
|
|
1192
|
+
blocks: [],
|
|
1193
|
+
isFinalResponse: true,
|
|
1194
|
+
};
|
|
1195
|
+
},
|
|
1196
|
+
};
|
|
1197
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1198
|
+
|
|
1199
|
+
await runAgentSession(
|
|
1200
|
+
"sid-final-normal",
|
|
1201
|
+
"prompt",
|
|
1202
|
+
platform,
|
|
1203
|
+
"chat-final-normal",
|
|
1204
|
+
0,
|
|
1205
|
+
"claude",
|
|
1206
|
+
);
|
|
1207
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
1208
|
+
|
|
1209
|
+
expect(closeSession).not.toHaveBeenCalled();
|
|
1210
|
+
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
|
1211
|
+
expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
|
|
1212
|
+
status: "done",
|
|
1213
|
+
finalReply: "normal answer",
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
1218
|
+
vi.setSystemTime(0);
|
|
1219
|
+
_setResponseStallTimeoutForTest(100);
|
|
1220
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1221
|
+
_setProcessAliveForTest(() => true);
|
|
1222
|
+
|
|
1223
|
+
const platform = mockPlatform("feishu");
|
|
1224
|
+
setSessionPlatform(platform);
|
|
1225
|
+
bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
|
|
1226
|
+
recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
|
|
1227
|
+
|
|
1228
|
+
const receivedPrompts: string[] = [];
|
|
1229
|
+
const adapter: ToolAdapter = {
|
|
1230
|
+
displayName: "Any Agent",
|
|
1231
|
+
sessionDescPrefix: "Agent Session:",
|
|
1232
|
+
createSession: async () => ({ sessionId: "sid-recovery-priority" }),
|
|
1233
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1234
|
+
closeSession: async () => {},
|
|
1235
|
+
prompt: async function* (
|
|
1236
|
+
_sid: string,
|
|
1237
|
+
text: string,
|
|
1238
|
+
_cwd: string,
|
|
1239
|
+
signal?: AbortSignal,
|
|
1240
|
+
options?: ToolPromptOptions,
|
|
1241
|
+
) {
|
|
1242
|
+
receivedPrompts.push(text);
|
|
1243
|
+
options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
|
|
1244
|
+
if (receivedPrompts.length === 1) {
|
|
1245
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1246
|
+
await new Promise<void>((resolve) => {
|
|
1247
|
+
if (signal?.aborted) {
|
|
1248
|
+
resolve();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1252
|
+
});
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1259
|
+
|
|
1260
|
+
enqueueMessage("sid-recovery-priority", {
|
|
1261
|
+
text: "queued user prompt",
|
|
1262
|
+
chatId: "chat-recovery-priority",
|
|
1263
|
+
openId: "open-user",
|
|
1264
|
+
msgTimestamp: 1,
|
|
1265
|
+
chatType: "p2p",
|
|
1266
|
+
});
|
|
1267
|
+
const consumeQueued = vi.fn();
|
|
1268
|
+
setQueueConsumer(consumeQueued);
|
|
1269
|
+
|
|
1270
|
+
const firstRun = runAgentSession(
|
|
1271
|
+
"sid-recovery-priority",
|
|
1272
|
+
"first prompt",
|
|
1273
|
+
platform,
|
|
1274
|
+
"chat-recovery-priority",
|
|
1275
|
+
0,
|
|
1276
|
+
"claude",
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
await vi.waitFor(() => {
|
|
1280
|
+
expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
|
|
1281
|
+
});
|
|
1282
|
+
const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
|
|
1283
|
+
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1284
|
+
await firstRun;
|
|
1285
|
+
|
|
1286
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
1287
|
+
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1288
|
+
expect(isSessionRunning("sid-recovery-priority")).toBe(true);
|
|
1289
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1290
|
+
"chat-recovery-priority",
|
|
1291
|
+
expect.stringContaining(recoveryPrompt),
|
|
1292
|
+
);
|
|
1293
|
+
|
|
1294
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
1295
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1296
|
+
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1297
|
+
expect(receivedPrompts[1]).not.toContain("queued user prompt");
|
|
1298
|
+
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1299
|
+
|
|
1300
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
1301
|
+
await vi.waitFor(() => expect(consumeQueued).toHaveBeenCalledTimes(1));
|
|
1302
|
+
expect(consumeQueued).toHaveBeenCalledWith(
|
|
1303
|
+
platform,
|
|
1304
|
+
expect.objectContaining({ text: "queued user prompt" }),
|
|
1305
|
+
);
|
|
1306
|
+
setQueueConsumer(() => {});
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("does not auto-recover a second consecutive response stall", async () => {
|
|
1310
|
+
vi.setSystemTime(0);
|
|
1311
|
+
_setResponseStallTimeoutForTest(100);
|
|
1312
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1313
|
+
_setProcessAliveForTest(() => true);
|
|
1314
|
+
|
|
1315
|
+
const platform = mockPlatform("feishu");
|
|
1316
|
+
setSessionPlatform(platform);
|
|
1317
|
+
bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
|
|
1318
|
+
recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
|
|
1319
|
+
|
|
1320
|
+
const receivedPrompts: string[] = [];
|
|
1321
|
+
const adapter: ToolAdapter = {
|
|
1322
|
+
displayName: "Any Agent",
|
|
1323
|
+
sessionDescPrefix: "Agent Session:",
|
|
1324
|
+
createSession: async () => ({ sessionId: "sid-recovery-limit" }),
|
|
1325
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1326
|
+
closeSession: async () => {},
|
|
1327
|
+
prompt: async function* (
|
|
1328
|
+
_sid: string,
|
|
1329
|
+
text: string,
|
|
1330
|
+
_cwd: string,
|
|
1331
|
+
signal?: AbortSignal,
|
|
1332
|
+
options?: ToolPromptOptions,
|
|
1333
|
+
) {
|
|
1334
|
+
receivedPrompts.push(text);
|
|
1335
|
+
options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
|
|
1336
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1337
|
+
await new Promise<void>((resolve) => {
|
|
1338
|
+
if (signal?.aborted) {
|
|
1339
|
+
resolve();
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1343
|
+
});
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1347
|
+
|
|
1348
|
+
const firstRun = runAgentSession(
|
|
1349
|
+
"sid-recovery-limit",
|
|
1350
|
+
"first prompt",
|
|
1351
|
+
platform,
|
|
1352
|
+
"chat-recovery-limit",
|
|
1353
|
+
0,
|
|
1354
|
+
"claude",
|
|
1355
|
+
);
|
|
1356
|
+
await vi.waitFor(() => {
|
|
1357
|
+
expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
|
|
1358
|
+
});
|
|
1359
|
+
const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1360
|
+
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1361
|
+
await firstRun;
|
|
1362
|
+
|
|
1363
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
1364
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1365
|
+
const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1366
|
+
await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
|
|
1367
|
+
await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
|
|
1368
|
+
|
|
1369
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1370
|
+
expect(receivedPrompts).toHaveLength(2);
|
|
1371
|
+
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1372
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1373
|
+
"chat-recovery-limit",
|
|
1374
|
+
"⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
|
|
1375
|
+
);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("lets /stop cancel a reserved recovery before it starts", async () => {
|
|
1379
|
+
vi.setSystemTime(0);
|
|
1380
|
+
_setResponseStallTimeoutForTest(100);
|
|
1381
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1382
|
+
_setProcessAliveForTest(() => true);
|
|
1383
|
+
|
|
1384
|
+
const platform = mockPlatform("feishu");
|
|
1385
|
+
setSessionPlatform(platform);
|
|
1386
|
+
bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
|
|
1387
|
+
recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
|
|
1388
|
+
|
|
1389
|
+
const receivedPrompts: string[] = [];
|
|
1390
|
+
const adapter: ToolAdapter = {
|
|
1391
|
+
displayName: "Any Agent",
|
|
1392
|
+
sessionDescPrefix: "Agent Session:",
|
|
1393
|
+
createSession: async () => ({ sessionId: "sid-recovery-stop" }),
|
|
1394
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1395
|
+
closeSession: async () => {},
|
|
1396
|
+
prompt: async function* (
|
|
1397
|
+
_sid: string,
|
|
1398
|
+
text: string,
|
|
1399
|
+
_cwd: string,
|
|
1400
|
+
signal?: AbortSignal,
|
|
1401
|
+
) {
|
|
1402
|
+
receivedPrompts.push(text);
|
|
1403
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1404
|
+
await new Promise<void>((resolve) => {
|
|
1405
|
+
if (signal?.aborted) {
|
|
1406
|
+
resolve();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1410
|
+
});
|
|
1411
|
+
},
|
|
1412
|
+
};
|
|
1413
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1414
|
+
|
|
1415
|
+
const firstRun = runAgentSession(
|
|
1416
|
+
"sid-recovery-stop",
|
|
1417
|
+
"first prompt",
|
|
1418
|
+
platform,
|
|
1419
|
+
"chat-recovery-stop",
|
|
1420
|
+
0,
|
|
1421
|
+
"claude",
|
|
1422
|
+
);
|
|
1423
|
+
await vi.waitFor(() => {
|
|
1424
|
+
expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
|
|
1425
|
+
});
|
|
1426
|
+
const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
|
|
1427
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1428
|
+
await firstRun;
|
|
1429
|
+
|
|
1430
|
+
expect(isSessionRunning("sid-recovery-stop")).toBe(true);
|
|
1431
|
+
expect(stopSession("sid-recovery-stop")).toBe(true);
|
|
1432
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
1433
|
+
|
|
1434
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
1435
|
+
expect(isSessionRunning("sid-recovery-stop")).toBe(false);
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
describe("unified display loop WeChat delta", () => {
|
|
1440
|
+
beforeEach(() => {
|
|
1441
|
+
vi.useFakeTimers();
|
|
1442
|
+
resetState();
|
|
1443
|
+
resetBindingState();
|
|
1444
|
+
mockStreamStates.clear();
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
afterEach(() => {
|
|
1448
|
+
stopUnifiedDisplayLoop();
|
|
1449
|
+
resetBindingState();
|
|
1450
|
+
vi.useRealTimers();
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it("sends only new accumulated content when tool output arrives before an already-sent final reply", async () => {
|
|
1454
|
+
const platform = mockPlatform("wechat");
|
|
1455
|
+
platform.kind = "wechat";
|
|
1456
|
+
setSessionPlatform(platform);
|
|
1457
|
+
|
|
1458
|
+
bindChatToSession("sid-wechat", "chat-wechat");
|
|
1459
|
+
recordLastActiveChat("sid-wechat", "chat-wechat");
|
|
1460
|
+
sessionInfoMap.set("chat-wechat", {
|
|
1461
|
+
sessionId: "sid-wechat",
|
|
1462
|
+
turnCount: 1,
|
|
1463
|
+
lastContextTokens: 0,
|
|
1464
|
+
startTime: 0,
|
|
1465
|
+
tool: "claude",
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
// 模拟 runAgentSession 创建的 WeChat display 条目
|
|
1469
|
+
displayCards.set("chat-wechat", {
|
|
1470
|
+
cardId: "",
|
|
1471
|
+
sequence: 0,
|
|
1472
|
+
cardBusy: false,
|
|
1473
|
+
cardCreatedAt: Date.now(),
|
|
1474
|
+
lastSentContent: "",
|
|
1475
|
+
streamErrorNotified: false,
|
|
1476
|
+
sessionId: "sid-wechat",
|
|
1477
|
+
turnCount: 1,
|
|
1478
|
+
dotCount: 0,
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
mockStreamStates.set("sid-wechat", {
|
|
1482
|
+
accumulatedContent: "",
|
|
1483
|
+
finalReply: "partial reply",
|
|
1484
|
+
status: "running",
|
|
1485
|
+
});
|
|
1486
|
+
startUnifiedDisplayLoop();
|
|
1487
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
1488
|
+
|
|
1489
|
+
mockStreamStates.set("sid-wechat", {
|
|
1490
|
+
accumulatedContent: "tool output\n",
|
|
1491
|
+
finalReply: "partial reply",
|
|
1492
|
+
status: "running",
|
|
1493
|
+
});
|
|
1494
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
1495
|
+
|
|
1496
|
+
expect(platform.sendText).toHaveBeenNthCalledWith(
|
|
1497
|
+
1,
|
|
1498
|
+
"chat-wechat",
|
|
1499
|
+
"partial reply",
|
|
1500
|
+
);
|
|
1501
|
+
expect(platform.sendText).toHaveBeenNthCalledWith(
|
|
1502
|
+
2,
|
|
1503
|
+
"chat-wechat",
|
|
1504
|
+
"tool output",
|
|
1505
|
+
);
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
describe("unified display loop activity status", () => {
|
|
1511
|
+
beforeEach(() => {
|
|
1512
|
+
vi.useFakeTimers();
|
|
1513
|
+
vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
|
|
1514
|
+
resetState();
|
|
1515
|
+
resetBindingState();
|
|
1516
|
+
mockStreamStates.clear();
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
afterEach(() => {
|
|
1520
|
+
stopUnifiedDisplayLoop();
|
|
1521
|
+
resetBindingState();
|
|
1522
|
+
vi.useRealTimers();
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
|
|
1526
|
+
const platform = mockPlatform("feishu");
|
|
1527
|
+
setSessionPlatform(platform);
|
|
1528
|
+
|
|
1529
|
+
bindChatToSession("sid-activity", "chat-activity");
|
|
1530
|
+
recordLastActiveChat("sid-activity", "chat-activity");
|
|
1531
|
+
sessionInfoMap.set("chat-activity", {
|
|
1532
|
+
sessionId: "sid-activity",
|
|
1533
|
+
turnCount: 1,
|
|
1534
|
+
lastContextTokens: 0,
|
|
1535
|
+
startTime: Date.now() - 12_000,
|
|
1536
|
+
tool: "codex",
|
|
1537
|
+
});
|
|
1538
|
+
displayCards.set("chat-activity", {
|
|
1539
|
+
cardId: "card-activity",
|
|
1540
|
+
sequence: 1,
|
|
1541
|
+
cardBusy: false,
|
|
1542
|
+
cardCreatedAt: Date.now(),
|
|
1543
|
+
lastSentContent: "",
|
|
1544
|
+
streamErrorNotified: false,
|
|
1545
|
+
sessionId: "sid-activity",
|
|
1546
|
+
turnCount: 1,
|
|
1547
|
+
dotCount: 0,
|
|
1548
|
+
});
|
|
1549
|
+
mockStreamStates.set("sid-activity", {
|
|
1550
|
+
accumulatedContent: "正在检查日志",
|
|
1551
|
+
finalReply: "",
|
|
1552
|
+
activity: {
|
|
1553
|
+
kind: "tool",
|
|
1554
|
+
startedAt: Date.now() - 12_000,
|
|
1555
|
+
toolName: "Shell",
|
|
1556
|
+
toolCount: 1,
|
|
1557
|
+
},
|
|
1558
|
+
status: "running",
|
|
1559
|
+
turnCount: 1,
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
startUnifiedDisplayLoop();
|
|
1563
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
1564
|
+
|
|
1565
|
+
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1566
|
+
expect(payload).toBeTypeOf("string");
|
|
1567
|
+
const card = JSON.parse(payload as string) as {
|
|
1568
|
+
header: { title: { content: string } };
|
|
1569
|
+
body: { elements: Array<{ tag: string; content?: string }> };
|
|
1570
|
+
};
|
|
1571
|
+
expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
|
|
1572
|
+
expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
|
|
1573
|
+
});
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
describe("unified display loop terminal card update", () => {
|
|
1577
|
+
beforeEach(() => {
|
|
1578
|
+
vi.useFakeTimers();
|
|
1579
|
+
resetState();
|
|
1580
|
+
resetBindingState();
|
|
1581
|
+
mockStreamStates.clear();
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
afterEach(() => {
|
|
1585
|
+
stopUnifiedDisplayLoop();
|
|
1586
|
+
resetBindingState();
|
|
1587
|
+
vi.useRealTimers();
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
|
|
1591
|
+
const platform = mockPlatform("feishu");
|
|
1592
|
+
setSessionPlatform(platform);
|
|
1593
|
+
|
|
1594
|
+
bindChatToSession("sid-auto-ended", "chat-auto-ended");
|
|
1595
|
+
recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
|
|
1596
|
+
sessionInfoMap.set("chat-auto-ended", {
|
|
1597
|
+
sessionId: "sid-auto-ended",
|
|
1598
|
+
turnCount: 1,
|
|
1599
|
+
lastContextTokens: 0,
|
|
1600
|
+
startTime: 0,
|
|
1601
|
+
tool: "cursor",
|
|
1602
|
+
});
|
|
1603
|
+
displayCards.set("chat-auto-ended", {
|
|
1604
|
+
cardId: "card-auto-ended",
|
|
1605
|
+
sequence: 1,
|
|
1606
|
+
cardBusy: false,
|
|
1607
|
+
cardCreatedAt: Date.now(),
|
|
1608
|
+
lastSentContent: "",
|
|
1609
|
+
streamErrorNotified: false,
|
|
1610
|
+
sessionId: "sid-auto-ended",
|
|
1611
|
+
turnCount: 1,
|
|
1612
|
+
dotCount: 0,
|
|
1613
|
+
});
|
|
1614
|
+
mockStreamStates.set("sid-auto-ended", {
|
|
1615
|
+
accumulatedContent: "",
|
|
1616
|
+
finalReply: "",
|
|
1617
|
+
status: "auto_ended",
|
|
1618
|
+
turnCount: 1,
|
|
1619
|
+
autoEndedAt: Date.now(),
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
startUnifiedDisplayLoop();
|
|
1623
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
1624
|
+
|
|
1625
|
+
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
1626
|
+
const card = JSON.parse(payload as string) as {
|
|
1627
|
+
header: { title: { content: string }; template?: string };
|
|
1628
|
+
};
|
|
1629
|
+
expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
|
|
1630
|
+
expect(card.header.template).toBe("orange");
|
|
1631
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1632
|
+
"chat-auto-ended",
|
|
1633
|
+
"⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
|
|
1634
|
+
);
|
|
1635
|
+
expect(displayCards.has("chat-auto-ended")).toBe(false);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
|
|
1639
|
+
const platform = mockPlatform("feishu");
|
|
1640
|
+
platform.cardUpdate = vi.fn(async () => {
|
|
1641
|
+
throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
|
|
1642
|
+
});
|
|
1643
|
+
setSessionPlatform(platform);
|
|
1644
|
+
|
|
1645
|
+
bindChatToSession("sid-terminal", "chat-terminal");
|
|
1646
|
+
recordLastActiveChat("sid-terminal", "chat-terminal");
|
|
1647
|
+
sessionInfoMap.set("chat-terminal", {
|
|
1648
|
+
sessionId: "sid-terminal",
|
|
1649
|
+
turnCount: 1,
|
|
1650
|
+
lastContextTokens: 0,
|
|
1651
|
+
startTime: 0,
|
|
1652
|
+
tool: "claude",
|
|
1653
|
+
});
|
|
1654
|
+
activePrompts.set("sid-terminal", {
|
|
1655
|
+
controller: new AbortController(),
|
|
1656
|
+
stopped: false,
|
|
1657
|
+
startTime: Date.now(),
|
|
1658
|
+
});
|
|
1659
|
+
displayCards.set("chat-terminal", {
|
|
1660
|
+
cardId: "card-terminal",
|
|
1661
|
+
sequence: 109,
|
|
1662
|
+
cardBusy: false,
|
|
1663
|
+
cardCreatedAt: Date.now(),
|
|
1664
|
+
lastSentContent: "",
|
|
1665
|
+
streamErrorNotified: false,
|
|
1666
|
+
sessionId: "sid-terminal",
|
|
1667
|
+
turnCount: 1,
|
|
1668
|
+
dotCount: 0,
|
|
1669
|
+
});
|
|
1670
|
+
mockStreamStates.set("sid-terminal", {
|
|
1671
|
+
accumulatedContent: "partial tool output",
|
|
1672
|
+
finalReply: "",
|
|
1673
|
+
status: "stopped",
|
|
1674
|
+
turnCount: 1,
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
startUnifiedDisplayLoop();
|
|
1678
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
1679
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
1680
|
+
|
|
1681
|
+
expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
|
|
1682
|
+
expect(platform.cardUpdate).toHaveBeenCalledWith(
|
|
1683
|
+
"card-terminal",
|
|
1684
|
+
expect.any(String),
|
|
1685
|
+
110,
|
|
1686
|
+
);
|
|
1687
|
+
expect(displayCards.get("chat-terminal")?.sequence).toBe(110);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
it("keeps terminal display and retries final text when sending fails", async () => {
|
|
1689
1691
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1690
1692
|
const platform = mockPlatform("feishu");
|
|
1691
1693
|
platform.cardUpdate = vi.fn(async () => {});
|
|
@@ -1876,17 +1878,25 @@ describe("getSessionStatus", () => {
|
|
|
1876
1878
|
expect(status!.effort).not.toBeNull();
|
|
1877
1879
|
// model 必为字符串(留空时显示 '(留空)',否则为环境变量值);不应是占位符
|
|
1878
1880
|
expect(typeof status!.model).toBe("string");
|
|
1879
|
-
expect(status!.model.length).toBeGreaterThan(0);
|
|
1880
|
-
});
|
|
1881
|
-
|
|
1882
|
-
it("Codex session status uses the per-session effort override", async () => {
|
|
1883
|
-
mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
|
|
1884
|
-
setSessionEffortOverride("sid-codex-effort", "xhigh");
|
|
1885
|
-
|
|
1886
|
-
const status = await getSessionStatus("chat-codex");
|
|
1887
|
-
|
|
1888
|
-
expect(status!.effort).toBe("xhigh");
|
|
1889
|
-
});
|
|
1881
|
+
expect(status!.model.length).toBeGreaterThan(0);
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
it("Codex session status uses the per-session effort override", async () => {
|
|
1885
|
+
mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
|
|
1886
|
+
setSessionEffortOverride("sid-codex-effort", "xhigh");
|
|
1887
|
+
|
|
1888
|
+
const status = await getSessionStatus("chat-codex");
|
|
1889
|
+
|
|
1890
|
+
expect(status!.effort).toBe("xhigh");
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
it("CCC 会话:effort 默认取 config.ccc.effort,session override 优先", async () => {
|
|
1894
|
+
expect(getEffectiveEffortForTool("ccc")).toBe(config.ccc.effort);
|
|
1895
|
+
setSessionEffortOverride("sid-ccc-effort-default", "low");
|
|
1896
|
+
expect(getEffectiveEffortForTool("ccc", "sid-ccc-effort-default")).toBe("low");
|
|
1897
|
+
clearSessionEffortOverride("sid-ccc-effort-default");
|
|
1898
|
+
expect(getEffectiveEffortForTool("ccc", "sid-ccc-effort-default")).toBe(config.ccc.effort);
|
|
1899
|
+
});
|
|
1890
1900
|
|
|
1891
1901
|
it("Cursor 会话:effort 恒为 null(卡片渲染时隐藏该行,避免显示无意义的 effort)", async () => {
|
|
1892
1902
|
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
@@ -1922,29 +1932,29 @@ describe("getSessionStatus", () => {
|
|
|
1922
1932
|
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
1923
1933
|
});
|
|
1924
1934
|
|
|
1925
|
-
it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
|
|
1926
|
-
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
1927
|
-
_setAdapterForToolForTest(
|
|
1928
|
-
"cursor",
|
|
1929
|
-
mockAdapter(() => {
|
|
1930
|
-
throw new Error("simulated adapter failure");
|
|
1931
|
-
}),
|
|
1932
|
-
);
|
|
1933
|
-
const status = await getSessionStatus("chat-cursor");
|
|
1934
|
-
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
1935
|
-
expect(status!.effort).toBeNull();
|
|
1936
|
-
});
|
|
1937
|
-
|
|
1938
|
-
it("CCC Agent 会话:model 来自 ccc 配置且 effort 恒为 null", async () => {
|
|
1939
|
-
mockSessionInfo("chat-ccc", { sessionId: "session-ccc", tool: "ccc" });
|
|
1940
|
-
|
|
1941
|
-
const status = await getSessionStatus("chat-ccc");
|
|
1942
|
-
|
|
1943
|
-
expect(status!.model).toBeTruthy();
|
|
1944
|
-
expect(status!.model).not.toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
1945
|
-
expect(status!.effort).toBeNull();
|
|
1946
|
-
});
|
|
1947
|
-
});
|
|
1935
|
+
it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
|
|
1936
|
+
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
1937
|
+
_setAdapterForToolForTest(
|
|
1938
|
+
"cursor",
|
|
1939
|
+
mockAdapter(() => {
|
|
1940
|
+
throw new Error("simulated adapter failure");
|
|
1941
|
+
}),
|
|
1942
|
+
);
|
|
1943
|
+
const status = await getSessionStatus("chat-cursor");
|
|
1944
|
+
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
1945
|
+
expect(status!.effort).toBeNull();
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
it("CCC Agent 会话:model 来自 ccc 配置且 effort 恒为 null", async () => {
|
|
1949
|
+
mockSessionInfo("chat-ccc", { sessionId: "session-ccc", tool: "ccc" });
|
|
1950
|
+
|
|
1951
|
+
const status = await getSessionStatus("chat-ccc");
|
|
1952
|
+
|
|
1953
|
+
expect(status!.model).toBeTruthy();
|
|
1954
|
+
expect(status!.model).not.toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
1955
|
+
expect(status!.effort).toBeNull();
|
|
1956
|
+
});
|
|
1957
|
+
});
|
|
1948
1958
|
|
|
1949
1959
|
describe("getAllSessionsStatus", () => {
|
|
1950
1960
|
let registryFile = "";
|
|
@@ -1985,10 +1995,10 @@ describe("getAllSessionsStatus", () => {
|
|
|
1985
1995
|
|
|
1986
1996
|
it("returns statuses from disk registry", async () => {
|
|
1987
1997
|
await recordSessionRegistry({
|
|
1988
|
-
chatId: "chat1",
|
|
1989
|
-
sessionId: "s1",
|
|
1990
|
-
tool: "claude",
|
|
1991
|
-
chatType: "p2p",
|
|
1998
|
+
chatId: "chat1",
|
|
1999
|
+
sessionId: "s1",
|
|
2000
|
+
tool: "claude",
|
|
2001
|
+
chatType: "p2p",
|
|
1992
2002
|
chatName: "test-chat-1",
|
|
1993
2003
|
turnCount: 2,
|
|
1994
2004
|
startTime: 1000,
|
|
@@ -2008,8 +2018,8 @@ describe("getAllSessionsStatus", () => {
|
|
|
2008
2018
|
|
|
2009
2019
|
const result = await getAllSessionsStatus();
|
|
2010
2020
|
expect(result).toHaveLength(2);
|
|
2011
|
-
expect(result[0].chatId).toBe("chat1");
|
|
2012
|
-
expect(result[0].chatType).toBe("p2p");
|
|
2021
|
+
expect(result[0].chatId).toBe("chat1");
|
|
2022
|
+
expect(result[0].chatType).toBe("p2p");
|
|
2013
2023
|
// running=true in registry doesn't make it active — must be in activePrompts
|
|
2014
2024
|
expect(result[0].active).toBe(false);
|
|
2015
2025
|
expect(result[0].turnCount).toBe(2);
|