chatccc 0.2.204 → 0.2.206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/agent-activity.test.ts +76 -0
- package/src/__tests__/codex-adapter.test.ts +7 -7
- package/src/__tests__/cursor-adapter.test.ts +5 -5
- package/src/__tests__/response-stall.test.ts +49 -0
- package/src/__tests__/session.test.ts +277 -33
- package/src/adapters/codex-adapter.ts +1 -0
- package/src/adapters/cursor-adapter.ts +1 -0
- package/src/agent-activity.ts +170 -0
- package/src/response-stall.ts +28 -0
- package/src/session-chat-binding.ts +20 -10
- package/src/session.ts +317 -121
- package/src/stream-state.ts +18 -10
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createAgentActivityTracker,
|
|
5
|
+
formatAgentActivityTitle,
|
|
6
|
+
updateAgentActivity,
|
|
7
|
+
} from "../agent-activity.ts";
|
|
8
|
+
|
|
9
|
+
describe("agent activity", () => {
|
|
10
|
+
it("starts with an explicit startup status", () => {
|
|
11
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
12
|
+
|
|
13
|
+
expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在启动 Agent · 3秒");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the thinking timer stable across repeated thinking blocks", () => {
|
|
17
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
18
|
+
|
|
19
|
+
expect(updateAgentActivity(tracker, { type: "thinking", thinking: "first" }, 2_000)).toBe(true);
|
|
20
|
+
expect(updateAgentActivity(tracker, { type: "thinking", thinking: "second" }, 5_000)).toBe(false);
|
|
21
|
+
expect(formatAgentActivityTitle(tracker.activity, 8_000)).toBe("思考中 · 6秒");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows the active tool name and elapsed time", () => {
|
|
25
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
26
|
+
|
|
27
|
+
updateAgentActivity(tracker, {
|
|
28
|
+
type: "tool_use",
|
|
29
|
+
id: "tool-1",
|
|
30
|
+
name: "Bash",
|
|
31
|
+
input: { command: "npm test" },
|
|
32
|
+
}, 3_000);
|
|
33
|
+
|
|
34
|
+
expect(formatAgentActivityTitle(tracker.activity, 38_000)).toBe("正在执行 Bash · 35秒");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("tracks parallel tools and keeps the remaining tool after one result", () => {
|
|
38
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
39
|
+
|
|
40
|
+
updateAgentActivity(tracker, { type: "tool_use", id: "read", name: "Read", input: {} }, 2_000);
|
|
41
|
+
updateAgentActivity(tracker, { type: "tool_use", id: "grep", name: "Grep", input: {} }, 3_000);
|
|
42
|
+
expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在执行 Read 等 2 项 · 2秒");
|
|
43
|
+
|
|
44
|
+
updateAgentActivity(tracker, {
|
|
45
|
+
type: "tool_result",
|
|
46
|
+
tool_use_id: "read",
|
|
47
|
+
content: "done",
|
|
48
|
+
}, 5_000);
|
|
49
|
+
expect(formatAgentActivityTitle(tracker.activity, 7_000)).toBe("正在执行 Grep · 4秒");
|
|
50
|
+
|
|
51
|
+
updateAgentActivity(tracker, {
|
|
52
|
+
type: "tool_result",
|
|
53
|
+
tool_use_id: "grep",
|
|
54
|
+
content: "done",
|
|
55
|
+
}, 8_000);
|
|
56
|
+
expect(formatAgentActivityTitle(tracker.activity, 10_000)).toBe("正在处理工具结果 · 2秒");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("switches to response and context-compaction statuses", () => {
|
|
60
|
+
const tracker = createAgentActivityTracker(1_000);
|
|
61
|
+
|
|
62
|
+
updateAgentActivity(tracker, { type: "text", text: "answer" }, 2_000);
|
|
63
|
+
expect(formatAgentActivityTitle(tracker.activity, 3_000)).toBe("正在生成回复 · 1秒");
|
|
64
|
+
|
|
65
|
+
updateAgentActivity(tracker, {
|
|
66
|
+
type: "compact_boundary",
|
|
67
|
+
trigger: "auto",
|
|
68
|
+
pre_tokens: 100_000,
|
|
69
|
+
}, 4_000);
|
|
70
|
+
expect(formatAgentActivityTitle(tracker.activity, 5_000)).toBe("正在整理上下文 · 1秒");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("formats longer elapsed time without decorative animation", () => {
|
|
74
|
+
expect(formatAgentActivityTitle({ kind: "thinking", startedAt: 1_000 }, 74_000)).toBe("思考中 · 1分13秒");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -76,12 +76,12 @@ describe("normalizeCodexMessage", () => {
|
|
|
76
76
|
status: "in_progress",
|
|
77
77
|
},
|
|
78
78
|
});
|
|
79
|
-
expect(result).not.toBeNull();
|
|
80
|
-
expect(result!.type).toBe("assistant");
|
|
81
|
-
expect(result!.blocks).toEqual([
|
|
82
|
-
{ type: "tool_use", name: "Bash", input: { command: "powershell.exe -Command ls" } },
|
|
83
|
-
]);
|
|
84
|
-
});
|
|
79
|
+
expect(result).not.toBeNull();
|
|
80
|
+
expect(result!.type).toBe("assistant");
|
|
81
|
+
expect(result!.blocks).toEqual([
|
|
82
|
+
{ type: "tool_use", id: "item_0", name: "Bash", input: { command: "powershell.exe -Command ls" } },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
85
|
|
|
86
86
|
it("normalizes command_execution completion as tool_result (success)", () => {
|
|
87
87
|
const result = normalizeCodexMessage({
|
|
@@ -302,4 +302,4 @@ describe("createCodexAdapter", () => {
|
|
|
302
302
|
});
|
|
303
303
|
expect(await adapter.getSessionInfo("sid-C")).toBeUndefined();
|
|
304
304
|
});
|
|
305
|
-
});
|
|
305
|
+
});
|
|
@@ -660,11 +660,11 @@ describe("Cursor stream fixture - 端到端不重复", () => {
|
|
|
660
660
|
session_id: "sid",
|
|
661
661
|
timestamp_ms: 1000,
|
|
662
662
|
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
663
|
-
expect(result).not.toBeNull();
|
|
664
|
-
expect(result!.blocks).toEqual([
|
|
665
|
-
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
|
666
|
-
]);
|
|
667
|
-
});
|
|
663
|
+
expect(result).not.toBeNull();
|
|
664
|
+
expect(result!.blocks).toEqual([
|
|
665
|
+
{ type: "tool_use", id: "toolu_abc", name: "Bash", input: { command: "ls" } },
|
|
666
|
+
]);
|
|
667
|
+
});
|
|
668
668
|
|
|
669
669
|
it("normalizes tool_call completed → tool_result block (success)", () => {
|
|
670
670
|
const result = normalizeCursorMessage({
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
hasResponseStalled,
|
|
5
|
+
observeResponseProgress,
|
|
6
|
+
} from "../response-stall.ts";
|
|
7
|
+
|
|
8
|
+
describe("response stall detection", () => {
|
|
9
|
+
it("starts tracking while responding even when the total character count is zero", () => {
|
|
10
|
+
const observation = observeResponseProgress(undefined, true, 0, 1_000);
|
|
11
|
+
|
|
12
|
+
expect(observation).toEqual({
|
|
13
|
+
totalChars: 0,
|
|
14
|
+
unchangedSince: 1_000,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("auto-ends only after the same character count has lasted three minutes", () => {
|
|
19
|
+
const first = observeResponseProgress(undefined, true, 12, 1_000);
|
|
20
|
+
const unchanged = observeResponseProgress(first, true, 12, 90_000);
|
|
21
|
+
|
|
22
|
+
expect(unchanged).toBe(first);
|
|
23
|
+
expect(hasResponseStalled(unchanged, 180_999, 180_000)).toBe(false);
|
|
24
|
+
expect(hasResponseStalled(unchanged, 181_000, 180_000)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("restarts the timer whenever the total character count changes", () => {
|
|
28
|
+
const first = observeResponseProgress(undefined, true, 12, 1_000);
|
|
29
|
+
const changed = observeResponseProgress(first, true, 13, 150_000);
|
|
30
|
+
|
|
31
|
+
expect(changed).toEqual({
|
|
32
|
+
totalChars: 13,
|
|
33
|
+
unchangedSince: 150_000,
|
|
34
|
+
});
|
|
35
|
+
expect(hasResponseStalled(changed, 181_000, 180_000)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clears tracking outside responding and starts a fresh window after returning", () => {
|
|
39
|
+
const first = observeResponseProgress(undefined, true, 0, 1_000);
|
|
40
|
+
const cleared = observeResponseProgress(first, false, 0, 100_000);
|
|
41
|
+
const resumed = observeResponseProgress(cleared, true, 0, 200_000);
|
|
42
|
+
|
|
43
|
+
expect(cleared).toBeUndefined();
|
|
44
|
+
expect(resumed).toEqual({
|
|
45
|
+
totalChars: 0,
|
|
46
|
+
unchangedSince: 200_000,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,13 +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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const killProcessTreeMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
7
|
+
vi.mock("../adapters/proc-tree-kill.ts", () => ({
|
|
8
|
+
killProcessTree: killProcessTreeMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// mock stream-state 以支持在测试中控制累积长度
|
|
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;
|
|
11
23
|
turnCount?: number;
|
|
12
24
|
finalReplySentTurn?: number;
|
|
13
25
|
finalReplySentAt?: number;
|
|
@@ -18,10 +30,12 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
18
30
|
if (!state) return null;
|
|
19
31
|
return {
|
|
20
32
|
sessionId: sid,
|
|
21
|
-
accumulatedContent: state.accumulatedContent,
|
|
22
|
-
finalReply: state.finalReply,
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
accumulatedContent: state.accumulatedContent,
|
|
34
|
+
finalReply: state.finalReply,
|
|
35
|
+
activity: state.activity,
|
|
36
|
+
finalReplySentTurn: state.finalReplySentTurn,
|
|
37
|
+
finalReplySentAt: state.finalReplySentAt,
|
|
38
|
+
autoEndedAt: state.autoEndedAt,
|
|
25
39
|
status: state.status ?? "running",
|
|
26
40
|
chunkCount: 0,
|
|
27
41
|
turnCount: state.turnCount ?? 0,
|
|
@@ -34,24 +48,33 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
34
48
|
writeStreamState: async (state: {
|
|
35
49
|
sessionId: string;
|
|
36
50
|
accumulatedContent: string;
|
|
37
|
-
finalReply: string;
|
|
38
|
-
|
|
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;
|
|
39
60
|
turnCount?: number;
|
|
40
61
|
finalReplySentTurn?: number;
|
|
41
62
|
finalReplySentAt?: number;
|
|
42
63
|
}) => {
|
|
43
64
|
mockStreamStates.set(state.sessionId, {
|
|
44
|
-
accumulatedContent: state.accumulatedContent,
|
|
45
|
-
finalReply: state.finalReply,
|
|
46
|
-
|
|
65
|
+
accumulatedContent: state.accumulatedContent,
|
|
66
|
+
finalReply: state.finalReply,
|
|
67
|
+
activity: state.activity,
|
|
68
|
+
status: state.status,
|
|
69
|
+
autoEndedAt: state.autoEndedAt,
|
|
47
70
|
turnCount: state.turnCount,
|
|
48
71
|
finalReplySentTurn: state.finalReplySentTurn,
|
|
49
72
|
finalReplySentAt: state.finalReplySentAt,
|
|
50
73
|
});
|
|
51
74
|
},
|
|
52
|
-
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
53
|
-
sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
|
|
54
|
-
}),
|
|
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
|
+
}),
|
|
55
78
|
isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
|
|
56
79
|
markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
|
|
57
80
|
const state = mockStreamStates.get(sid);
|
|
@@ -95,6 +118,10 @@ import {
|
|
|
95
118
|
_resetProcessAliveForTest,
|
|
96
119
|
_setProcessMonitorIntervalForTest,
|
|
97
120
|
_resetProcessMonitorIntervalForTest,
|
|
121
|
+
_setResponseStallTimeoutForTest,
|
|
122
|
+
_resetResponseStallTimeoutForTest,
|
|
123
|
+
_setResponseStallCheckIntervalForTest,
|
|
124
|
+
_resetResponseStallCheckIntervalForTest,
|
|
98
125
|
setSessionEffortOverride,
|
|
99
126
|
} from "../session.ts";
|
|
100
127
|
import {
|
|
@@ -335,13 +362,15 @@ describe("runAgentSession process monitor", () => {
|
|
|
335
362
|
_resetSessionRegistryFileForTest();
|
|
336
363
|
_resetSessionToolsFileForTest();
|
|
337
364
|
_clearAdapterCacheForTest();
|
|
338
|
-
_resetProcessAliveForTest();
|
|
339
|
-
_resetProcessMonitorIntervalForTest();
|
|
365
|
+
_resetProcessAliveForTest();
|
|
366
|
+
_resetProcessMonitorIntervalForTest();
|
|
367
|
+
_resetResponseStallTimeoutForTest();
|
|
368
|
+
_resetResponseStallCheckIntervalForTest();
|
|
340
369
|
resetBindingState();
|
|
341
370
|
vi.useRealTimers();
|
|
342
371
|
});
|
|
343
372
|
|
|
344
|
-
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
373
|
+
it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
|
|
345
374
|
const platform = mockPlatform("feishu");
|
|
346
375
|
setSessionPlatform(platform);
|
|
347
376
|
bindChatToSession("sid-process", "chat-process");
|
|
@@ -569,8 +598,108 @@ describe("runAgentSession process monitor", () => {
|
|
|
569
598
|
});
|
|
570
599
|
});
|
|
571
600
|
|
|
572
|
-
describe("
|
|
573
|
-
|
|
601
|
+
describe("runAgentSession response stall watchdog", () => {
|
|
602
|
+
let tempDir = "";
|
|
603
|
+
|
|
604
|
+
beforeEach(async () => {
|
|
605
|
+
vi.useFakeTimers();
|
|
606
|
+
resetState();
|
|
607
|
+
resetBindingState();
|
|
608
|
+
mockStreamStates.clear();
|
|
609
|
+
killProcessTreeMock.mockClear();
|
|
610
|
+
tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
|
|
611
|
+
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
612
|
+
_setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
afterEach(async () => {
|
|
616
|
+
_resetSessionRegistryFileForTest();
|
|
617
|
+
_resetSessionToolsFileForTest();
|
|
618
|
+
_clearAdapterCacheForTest();
|
|
619
|
+
_resetProcessAliveForTest();
|
|
620
|
+
_resetResponseStallTimeoutForTest();
|
|
621
|
+
_resetResponseStallCheckIntervalForTest();
|
|
622
|
+
resetBindingState();
|
|
623
|
+
vi.useRealTimers();
|
|
624
|
+
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
|
|
628
|
+
vi.setSystemTime(0);
|
|
629
|
+
_setResponseStallTimeoutForTest(180_000);
|
|
630
|
+
_setResponseStallCheckIntervalForTest(1_000);
|
|
631
|
+
_setProcessAliveForTest(() => true);
|
|
632
|
+
|
|
633
|
+
const platform = mockPlatform("feishu");
|
|
634
|
+
setSessionPlatform(platform);
|
|
635
|
+
bindChatToSession("sid-response-stall", "chat-response-stall");
|
|
636
|
+
recordLastActiveChat("sid-response-stall", "chat-response-stall");
|
|
637
|
+
|
|
638
|
+
const closeSession = vi.fn();
|
|
639
|
+
const adapter: ToolAdapter = {
|
|
640
|
+
displayName: "Any Agent",
|
|
641
|
+
sessionDescPrefix: "Agent Session:",
|
|
642
|
+
createSession: async () => ({ sessionId: "sid-response-stall" }),
|
|
643
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
644
|
+
closeSession: async () => {},
|
|
645
|
+
prompt: async function* (
|
|
646
|
+
_sid: string,
|
|
647
|
+
_text: string,
|
|
648
|
+
_cwd: string,
|
|
649
|
+
signal?: AbortSignal,
|
|
650
|
+
options?: ToolPromptOptions,
|
|
651
|
+
) {
|
|
652
|
+
options?.onSessionCreated?.(closeSession);
|
|
653
|
+
options?.onProcessStart?.({ pid: 4242 });
|
|
654
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
655
|
+
await new Promise<void>((resolve) => {
|
|
656
|
+
if (signal?.aborted) {
|
|
657
|
+
resolve();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
661
|
+
});
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
665
|
+
|
|
666
|
+
const runPromise = runAgentSession(
|
|
667
|
+
"sid-response-stall",
|
|
668
|
+
"prompt",
|
|
669
|
+
platform,
|
|
670
|
+
"chat-response-stall",
|
|
671
|
+
Date.now(),
|
|
672
|
+
"claude",
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
await vi.waitFor(() => {
|
|
676
|
+
expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
|
|
677
|
+
totalChars: 0,
|
|
678
|
+
unchangedSince: expect.any(Number),
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
|
|
683
|
+
const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
|
|
684
|
+
await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
|
|
685
|
+
expect(activePrompts.has("sid-response-stall")).toBe(true);
|
|
686
|
+
|
|
687
|
+
await vi.advanceTimersByTimeAsync(1_001);
|
|
688
|
+
await runPromise;
|
|
689
|
+
|
|
690
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
691
|
+
expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
|
|
692
|
+
expect(activePrompts.has("sid-response-stall")).toBe(false);
|
|
693
|
+
expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
|
|
694
|
+
status: "auto_ended",
|
|
695
|
+
finalReply: "",
|
|
696
|
+
autoEndedAt: expect.any(Number),
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("unified display loop WeChat delta", () => {
|
|
702
|
+
beforeEach(() => {
|
|
574
703
|
vi.useFakeTimers();
|
|
575
704
|
resetState();
|
|
576
705
|
resetBindingState();
|
|
@@ -636,10 +765,77 @@ describe("unified display loop WeChat delta", () => {
|
|
|
636
765
|
"chat-wechat",
|
|
637
766
|
"tool output",
|
|
638
767
|
);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
describe("unified display loop activity status", () => {
|
|
773
|
+
beforeEach(() => {
|
|
774
|
+
vi.useFakeTimers();
|
|
775
|
+
vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
|
|
776
|
+
resetState();
|
|
777
|
+
resetBindingState();
|
|
778
|
+
mockStreamStates.clear();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
afterEach(() => {
|
|
782
|
+
stopUnifiedDisplayLoop();
|
|
783
|
+
resetBindingState();
|
|
784
|
+
vi.useRealTimers();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
|
|
788
|
+
const platform = mockPlatform("feishu");
|
|
789
|
+
setSessionPlatform(platform);
|
|
790
|
+
|
|
791
|
+
bindChatToSession("sid-activity", "chat-activity");
|
|
792
|
+
recordLastActiveChat("sid-activity", "chat-activity");
|
|
793
|
+
sessionInfoMap.set("chat-activity", {
|
|
794
|
+
sessionId: "sid-activity",
|
|
795
|
+
turnCount: 1,
|
|
796
|
+
lastContextTokens: 0,
|
|
797
|
+
startTime: Date.now() - 12_000,
|
|
798
|
+
tool: "codex",
|
|
799
|
+
});
|
|
800
|
+
displayCards.set("chat-activity", {
|
|
801
|
+
cardId: "card-activity",
|
|
802
|
+
sequence: 1,
|
|
803
|
+
cardBusy: false,
|
|
804
|
+
cardCreatedAt: Date.now(),
|
|
805
|
+
lastSentContent: "",
|
|
806
|
+
streamErrorNotified: false,
|
|
807
|
+
sessionId: "sid-activity",
|
|
808
|
+
turnCount: 1,
|
|
809
|
+
dotCount: 0,
|
|
810
|
+
});
|
|
811
|
+
mockStreamStates.set("sid-activity", {
|
|
812
|
+
accumulatedContent: "正在检查日志",
|
|
813
|
+
finalReply: "",
|
|
814
|
+
activity: {
|
|
815
|
+
kind: "tool",
|
|
816
|
+
startedAt: Date.now() - 12_000,
|
|
817
|
+
toolName: "Shell",
|
|
818
|
+
toolCount: 1,
|
|
819
|
+
},
|
|
820
|
+
status: "running",
|
|
821
|
+
turnCount: 1,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
startUnifiedDisplayLoop();
|
|
825
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
826
|
+
|
|
827
|
+
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
828
|
+
expect(payload).toBeTypeOf("string");
|
|
829
|
+
const card = JSON.parse(payload as string) as {
|
|
830
|
+
header: { title: { content: string } };
|
|
831
|
+
body: { elements: Array<{ tag: string; content?: string }> };
|
|
832
|
+
};
|
|
833
|
+
expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
|
|
834
|
+
expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
describe("unified display loop terminal card update", () => {
|
|
643
839
|
beforeEach(() => {
|
|
644
840
|
vi.useFakeTimers();
|
|
645
841
|
resetState();
|
|
@@ -647,13 +843,61 @@ describe("unified display loop terminal card update", () => {
|
|
|
647
843
|
mockStreamStates.clear();
|
|
648
844
|
});
|
|
649
845
|
|
|
650
|
-
afterEach(() => {
|
|
846
|
+
afterEach(() => {
|
|
651
847
|
stopUnifiedDisplayLoop();
|
|
652
848
|
resetBindingState();
|
|
653
|
-
vi.useRealTimers();
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
it("
|
|
849
|
+
vi.useRealTimers();
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
|
|
853
|
+
const platform = mockPlatform("feishu");
|
|
854
|
+
setSessionPlatform(platform);
|
|
855
|
+
|
|
856
|
+
bindChatToSession("sid-auto-ended", "chat-auto-ended");
|
|
857
|
+
recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
|
|
858
|
+
sessionInfoMap.set("chat-auto-ended", {
|
|
859
|
+
sessionId: "sid-auto-ended",
|
|
860
|
+
turnCount: 1,
|
|
861
|
+
lastContextTokens: 0,
|
|
862
|
+
startTime: 0,
|
|
863
|
+
tool: "cursor",
|
|
864
|
+
});
|
|
865
|
+
displayCards.set("chat-auto-ended", {
|
|
866
|
+
cardId: "card-auto-ended",
|
|
867
|
+
sequence: 1,
|
|
868
|
+
cardBusy: false,
|
|
869
|
+
cardCreatedAt: Date.now(),
|
|
870
|
+
lastSentContent: "",
|
|
871
|
+
streamErrorNotified: false,
|
|
872
|
+
sessionId: "sid-auto-ended",
|
|
873
|
+
turnCount: 1,
|
|
874
|
+
dotCount: 0,
|
|
875
|
+
});
|
|
876
|
+
mockStreamStates.set("sid-auto-ended", {
|
|
877
|
+
accumulatedContent: "",
|
|
878
|
+
finalReply: "",
|
|
879
|
+
status: "auto_ended",
|
|
880
|
+
turnCount: 1,
|
|
881
|
+
autoEndedAt: Date.now(),
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
startUnifiedDisplayLoop();
|
|
885
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
886
|
+
|
|
887
|
+
const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
|
|
888
|
+
const card = JSON.parse(payload as string) as {
|
|
889
|
+
header: { title: { content: string }; template?: string };
|
|
890
|
+
};
|
|
891
|
+
expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
|
|
892
|
+
expect(card.header.template).toBe("orange");
|
|
893
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
894
|
+
"chat-auto-ended",
|
|
895
|
+
"⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。本轮没有可发送的回复内容。",
|
|
896
|
+
);
|
|
897
|
+
expect(displayCards.has("chat-auto-ended")).toBe(false);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
|
|
657
901
|
const platform = mockPlatform("feishu");
|
|
658
902
|
platform.cardUpdate = vi.fn(async () => {
|
|
659
903
|
throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
|