chatccc 0.2.205 → 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__/response-stall.test.ts +49 -0
- package/src/__tests__/session.test.ts +183 -19
- package/src/response-stall.ts +28 -0
- package/src/session-chat-binding.ts +14 -6
- package/src/session.ts +228 -61
- package/src/stream-state.ts +5 -3
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
hasResponseStalled,
|
|
5
|
+
observeResponseProgress,
|
|
6
|
+
} from "../response-stall.ts";
|
|
7
|
+
|
|
8
|
+
describe("response stall detection", () => {
|
|
9
|
+
it("starts tracking while responding even when the total character count is zero", () => {
|
|
10
|
+
const observation = observeResponseProgress(undefined, true, 0, 1_000);
|
|
11
|
+
|
|
12
|
+
expect(observation).toEqual({
|
|
13
|
+
totalChars: 0,
|
|
14
|
+
unchangedSince: 1_000,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("auto-ends only after the same character count has lasted three minutes", () => {
|
|
19
|
+
const first = observeResponseProgress(undefined, true, 12, 1_000);
|
|
20
|
+
const unchanged = observeResponseProgress(first, true, 12, 90_000);
|
|
21
|
+
|
|
22
|
+
expect(unchanged).toBe(first);
|
|
23
|
+
expect(hasResponseStalled(unchanged, 180_999, 180_000)).toBe(false);
|
|
24
|
+
expect(hasResponseStalled(unchanged, 181_000, 180_000)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("restarts the timer whenever the total character count changes", () => {
|
|
28
|
+
const first = observeResponseProgress(undefined, true, 12, 1_000);
|
|
29
|
+
const changed = observeResponseProgress(first, true, 13, 150_000);
|
|
30
|
+
|
|
31
|
+
expect(changed).toEqual({
|
|
32
|
+
totalChars: 13,
|
|
33
|
+
unchangedSince: 150_000,
|
|
34
|
+
});
|
|
35
|
+
expect(hasResponseStalled(changed, 181_000, 180_000)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clears tracking outside responding and starts a fresh window after returning", () => {
|
|
39
|
+
const first = observeResponseProgress(undefined, true, 0, 1_000);
|
|
40
|
+
const cleared = observeResponseProgress(first, false, 0, 100_000);
|
|
41
|
+
const resumed = observeResponseProgress(cleared, true, 0, 200_000);
|
|
42
|
+
|
|
43
|
+
expect(cleared).toBeUndefined();
|
|
44
|
+
expect(resumed).toEqual({
|
|
45
|
+
totalChars: 0,
|
|
46
|
+
unchangedSince: 200_000,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const killProcessTreeMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
7
|
+
vi.mock("../adapters/proc-tree-kill.ts", () => ({
|
|
8
|
+
killProcessTree: killProcessTreeMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
// mock stream-state 以支持在测试中控制累积长度
|
|
7
12
|
const mockStreamStates = new Map<string, {
|
|
8
13
|
accumulatedContent: string;
|
|
9
14
|
finalReply: string;
|
|
10
15
|
activity?: {
|
|
11
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
|
|
16
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
12
17
|
startedAt: number;
|
|
13
18
|
toolName?: string;
|
|
14
19
|
toolCount?: number;
|
|
15
20
|
};
|
|
16
|
-
status?: "running" | "done" | "stopped" | "error";
|
|
21
|
+
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
22
|
+
autoEndedAt?: number;
|
|
17
23
|
turnCount?: number;
|
|
18
24
|
finalReplySentTurn?: number;
|
|
19
25
|
finalReplySentAt?: number;
|
|
@@ -27,8 +33,9 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
27
33
|
accumulatedContent: state.accumulatedContent,
|
|
28
34
|
finalReply: state.finalReply,
|
|
29
35
|
activity: state.activity,
|
|
30
|
-
finalReplySentTurn: state.finalReplySentTurn,
|
|
31
|
-
finalReplySentAt: state.finalReplySentAt,
|
|
36
|
+
finalReplySentTurn: state.finalReplySentTurn,
|
|
37
|
+
finalReplySentAt: state.finalReplySentAt,
|
|
38
|
+
autoEndedAt: state.autoEndedAt,
|
|
32
39
|
status: state.status ?? "running",
|
|
33
40
|
chunkCount: 0,
|
|
34
41
|
turnCount: state.turnCount ?? 0,
|
|
@@ -43,12 +50,13 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
43
50
|
accumulatedContent: string;
|
|
44
51
|
finalReply: string;
|
|
45
52
|
activity?: {
|
|
46
|
-
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
|
|
53
|
+
kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
|
|
47
54
|
startedAt: number;
|
|
48
55
|
toolName?: string;
|
|
49
56
|
toolCount?: number;
|
|
50
57
|
};
|
|
51
|
-
status?: "running" | "done" | "stopped" | "error";
|
|
58
|
+
status?: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
59
|
+
autoEndedAt?: number;
|
|
52
60
|
turnCount?: number;
|
|
53
61
|
finalReplySentTurn?: number;
|
|
54
62
|
finalReplySentAt?: number;
|
|
@@ -57,7 +65,8 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
57
65
|
accumulatedContent: state.accumulatedContent,
|
|
58
66
|
finalReply: state.finalReply,
|
|
59
67
|
activity: state.activity,
|
|
60
|
-
status: state.status,
|
|
68
|
+
status: state.status,
|
|
69
|
+
autoEndedAt: state.autoEndedAt,
|
|
61
70
|
turnCount: state.turnCount,
|
|
62
71
|
finalReplySentTurn: state.finalReplySentTurn,
|
|
63
72
|
finalReplySentAt: state.finalReplySentAt,
|
|
@@ -109,6 +118,10 @@ import {
|
|
|
109
118
|
_resetProcessAliveForTest,
|
|
110
119
|
_setProcessMonitorIntervalForTest,
|
|
111
120
|
_resetProcessMonitorIntervalForTest,
|
|
121
|
+
_setResponseStallTimeoutForTest,
|
|
122
|
+
_resetResponseStallTimeoutForTest,
|
|
123
|
+
_setResponseStallCheckIntervalForTest,
|
|
124
|
+
_resetResponseStallCheckIntervalForTest,
|
|
112
125
|
setSessionEffortOverride,
|
|
113
126
|
} from "../session.ts";
|
|
114
127
|
import {
|
|
@@ -349,13 +362,15 @@ describe("runAgentSession process monitor", () => {
|
|
|
349
362
|
_resetSessionRegistryFileForTest();
|
|
350
363
|
_resetSessionToolsFileForTest();
|
|
351
364
|
_clearAdapterCacheForTest();
|
|
352
|
-
_resetProcessAliveForTest();
|
|
353
|
-
_resetProcessMonitorIntervalForTest();
|
|
365
|
+
_resetProcessAliveForTest();
|
|
366
|
+
_resetProcessMonitorIntervalForTest();
|
|
367
|
+
_resetResponseStallTimeoutForTest();
|
|
368
|
+
_resetResponseStallCheckIntervalForTest();
|
|
354
369
|
resetBindingState();
|
|
355
370
|
vi.useRealTimers();
|
|
356
371
|
});
|
|
357
372
|
|
|
358
|
-
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 () => {
|
|
359
374
|
const platform = mockPlatform("feishu");
|
|
360
375
|
setSessionPlatform(platform);
|
|
361
376
|
bindChatToSession("sid-process", "chat-process");
|
|
@@ -583,8 +598,108 @@ describe("runAgentSession process monitor", () => {
|
|
|
583
598
|
});
|
|
584
599
|
});
|
|
585
600
|
|
|
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
|
+
|
|
586
701
|
describe("unified display loop WeChat delta", () => {
|
|
587
|
-
beforeEach(() => {
|
|
702
|
+
beforeEach(() => {
|
|
588
703
|
vi.useFakeTimers();
|
|
589
704
|
resetState();
|
|
590
705
|
resetBindingState();
|
|
@@ -651,6 +766,7 @@ describe("unified display loop WeChat delta", () => {
|
|
|
651
766
|
"tool output",
|
|
652
767
|
);
|
|
653
768
|
});
|
|
769
|
+
|
|
654
770
|
});
|
|
655
771
|
|
|
656
772
|
describe("unified display loop activity status", () => {
|
|
@@ -727,13 +843,61 @@ describe("unified display loop terminal card update", () => {
|
|
|
727
843
|
mockStreamStates.clear();
|
|
728
844
|
});
|
|
729
845
|
|
|
730
|
-
afterEach(() => {
|
|
846
|
+
afterEach(() => {
|
|
731
847
|
stopUnifiedDisplayLoop();
|
|
732
848
|
resetBindingState();
|
|
733
|
-
vi.useRealTimers();
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
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 () => {
|
|
737
901
|
const platform = mockPlatform("feishu");
|
|
738
902
|
platform.cardUpdate = vi.fn(async () => {
|
|
739
903
|
throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A snapshot of response output progress while the Agent is generating a reply. */
|
|
2
|
+
export interface ResponseProgressObservation {
|
|
3
|
+
totalChars: number;
|
|
4
|
+
unchangedSince: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks how long the displayed response character count has remained unchanged.
|
|
9
|
+
* Leaving the responding phase clears the window; returning starts a fresh one.
|
|
10
|
+
*/
|
|
11
|
+
export function observeResponseProgress(
|
|
12
|
+
previous: ResponseProgressObservation | undefined,
|
|
13
|
+
isResponding: boolean,
|
|
14
|
+
totalChars: number,
|
|
15
|
+
now = Date.now(),
|
|
16
|
+
): ResponseProgressObservation | undefined {
|
|
17
|
+
if (!isResponding) return undefined;
|
|
18
|
+
if (previous?.totalChars === totalChars) return previous;
|
|
19
|
+
return { totalChars, unchangedSince: now };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hasResponseStalled(
|
|
23
|
+
observation: ResponseProgressObservation | undefined,
|
|
24
|
+
now: number,
|
|
25
|
+
timeoutMs: number,
|
|
26
|
+
): boolean {
|
|
27
|
+
return observation !== undefined && now - observation.unchangedSince >= timeoutMs;
|
|
28
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// 由 session.ts 在初始化时调用 rebuildSessionChatsFromRegistry 重建
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
-
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
8
|
+
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
9
|
+
import type { ResponseProgressObservation } from "./response-stall.ts";
|
|
9
10
|
|
|
10
11
|
const sessionChatsMap = new Map<string, Set<string>>();
|
|
11
12
|
|
|
@@ -66,8 +67,14 @@ export interface ActivePrompt {
|
|
|
66
67
|
stopped: boolean;
|
|
67
68
|
startTime: number;
|
|
68
69
|
/** Root PID for the CLI process currently serving this prompt, if the adapter exposes one. */
|
|
69
|
-
processPid?: number;
|
|
70
|
-
processMonitor?: ReturnType<typeof setInterval>;
|
|
70
|
+
processPid?: number;
|
|
71
|
+
processMonitor?: ReturnType<typeof setInterval>;
|
|
72
|
+
responseStallMonitor?: ReturnType<typeof setInterval>;
|
|
73
|
+
/** Character-count progress observed only while the activity is "responding". */
|
|
74
|
+
responseProgress?: ResponseProgressObservation;
|
|
75
|
+
/** Set before a response-stall auto-end begins so competing monitors cannot win the race. */
|
|
76
|
+
autoEnded?: boolean;
|
|
77
|
+
autoEndedAt?: number;
|
|
71
78
|
/** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
|
|
72
79
|
abnormalExit?: boolean;
|
|
73
80
|
abnormalExitNotified?: boolean;
|
|
@@ -220,9 +227,10 @@ export function consumeQueuedMessage(platform: PlatformAdapter, msg: QueuedMessa
|
|
|
220
227
|
export function resetBindingState(): void {
|
|
221
228
|
sessionChatsMap.clear();
|
|
222
229
|
lastActiveChatMap.clear();
|
|
223
|
-
for (const prompt of activePrompts.values()) {
|
|
224
|
-
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
225
|
-
|
|
230
|
+
for (const prompt of activePrompts.values()) {
|
|
231
|
+
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
232
|
+
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
233
|
+
}
|
|
226
234
|
activePrompts.clear();
|
|
227
235
|
queuedMessages.clear();
|
|
228
236
|
displayCards.clear();
|
package/src/session.ts
CHANGED
|
@@ -36,9 +36,11 @@ import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
|
36
36
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
37
37
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
38
38
|
import { createCccAdapter } from "./adapters/ccc-adapter.ts";
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import
|
|
39
|
+
import { killProcessTree } from "./adapters/proc-tree-kill.ts";
|
|
40
|
+
import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.ts";
|
|
41
|
+
import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
|
|
42
|
+
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
43
|
+
import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
|
|
42
44
|
|
|
43
45
|
// 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
|
|
44
46
|
function compressWechatDisplayText(text: string): string {
|
|
@@ -154,8 +156,12 @@ function platformForChat(chatId: string): PlatformAdapter | null {
|
|
|
154
156
|
return chatPlatformMap.get(chatId) ?? platformRef;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
158
|
-
|
|
159
|
+
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
160
|
+
const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
|
|
161
|
+
const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
|
|
162
|
+
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
163
|
+
let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
164
|
+
let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
159
165
|
let isProcessAliveImpl = (pid: number): boolean => {
|
|
160
166
|
try {
|
|
161
167
|
process.kill(pid, 0);
|
|
@@ -184,29 +190,68 @@ export function _setProcessMonitorIntervalForTest(ms: number): void {
|
|
|
184
190
|
processMonitorIntervalMs = ms;
|
|
185
191
|
}
|
|
186
192
|
|
|
187
|
-
export function _resetProcessMonitorIntervalForTest(): void {
|
|
188
|
-
processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
189
|
-
}
|
|
193
|
+
export function _resetProcessMonitorIntervalForTest(): void {
|
|
194
|
+
processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function _setResponseStallTimeoutForTest(ms: number): void {
|
|
198
|
+
responseStallTimeoutMs = ms;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function _resetResponseStallTimeoutForTest(): void {
|
|
202
|
+
responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function _setResponseStallCheckIntervalForTest(ms: number): void {
|
|
206
|
+
responseStallCheckIntervalMs = ms;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function _resetResponseStallCheckIntervalForTest(): void {
|
|
210
|
+
responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
211
|
+
}
|
|
190
212
|
|
|
191
|
-
function clearPromptProcessMonitor(sessionId: string): void {
|
|
213
|
+
function clearPromptProcessMonitor(sessionId: string): void {
|
|
192
214
|
const prompt = activePrompts.get(sessionId);
|
|
193
215
|
if (!prompt?.processMonitor) return;
|
|
194
216
|
clearInterval(prompt.processMonitor);
|
|
195
217
|
prompt.processMonitor = undefined;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function clearPromptResponseStallMonitor(sessionId: string): void {
|
|
221
|
+
const prompt = activePrompts.get(sessionId);
|
|
222
|
+
if (!prompt?.responseStallMonitor) return;
|
|
223
|
+
clearInterval(prompt.responseStallMonitor);
|
|
224
|
+
prompt.responseStallMonitor = undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
|
|
228
|
+
title: string;
|
|
229
|
+
template?: string;
|
|
230
|
+
} {
|
|
231
|
+
if (status === "auto_ended") return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
|
|
232
|
+
if (status === "stopped") return { title: "已停止", template: "red" };
|
|
233
|
+
if (status === "error") return { title: "异常结束", template: "red" };
|
|
234
|
+
return { title: "完成" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "auto_ended"): "done" | "stopped" {
|
|
238
|
+
return status === "stopped" || status === "error" || status === "auto_ended" ? "stopped" : "done";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatAutoEndedReply(finalReply: string): string {
|
|
242
|
+
const reason = "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。";
|
|
243
|
+
return finalReply
|
|
244
|
+
? `${reason}以下回复可能不完整。\n\n${finalReply}`
|
|
245
|
+
: `${reason}本轮没有可发送的回复内容。`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function formatTerminalReply(
|
|
249
|
+
status: "running" | "done" | "stopped" | "error" | "auto_ended",
|
|
250
|
+
finalReply: string,
|
|
251
|
+
): string | null {
|
|
252
|
+
if (status === "auto_ended") return formatAutoEndedReply(finalReply);
|
|
253
|
+
return finalReply || null;
|
|
254
|
+
}
|
|
210
255
|
|
|
211
256
|
function isCardKitSequenceConflict(err: unknown): boolean {
|
|
212
257
|
return err instanceof Error && err.message.includes("300317");
|
|
@@ -224,7 +269,7 @@ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): vo
|
|
|
224
269
|
clearPromptProcessMonitor(sessionId);
|
|
225
270
|
return;
|
|
226
271
|
}
|
|
227
|
-
if (current.stopped || current.abnormalExit || current.resourceStuck) return;
|
|
272
|
+
if (current.stopped || current.abnormalExit || current.resourceStuck || current.autoEnded) return;
|
|
228
273
|
if (isProcessAliveImpl(info.pid)) return;
|
|
229
274
|
|
|
230
275
|
current.abnormalExit = true;
|
|
@@ -337,9 +382,10 @@ export function resetState(): void {
|
|
|
337
382
|
processedMessages.clear();
|
|
338
383
|
lastMsgTimestamps.clear();
|
|
339
384
|
chatPlatformMap.clear();
|
|
340
|
-
for (const prompt of activePrompts.values()) {
|
|
341
|
-
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
342
|
-
|
|
385
|
+
for (const prompt of activePrompts.values()) {
|
|
386
|
+
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
387
|
+
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
388
|
+
}
|
|
343
389
|
activePrompts.clear();
|
|
344
390
|
displayCards.clear();
|
|
345
391
|
sessionModelOverrides.clear();
|
|
@@ -964,7 +1010,7 @@ export async function runAgentSession(
|
|
|
964
1010
|
const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
|
|
965
1011
|
if (data.sessionId !== sessionId) return;
|
|
966
1012
|
const prompt = activePrompts.get(sessionId);
|
|
967
|
-
if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck) return;
|
|
1013
|
+
if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
|
|
968
1014
|
prompt.resourceStuck = true;
|
|
969
1015
|
|
|
970
1016
|
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
@@ -1071,9 +1117,10 @@ export async function runAgentSession(
|
|
|
1071
1117
|
// 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
|
|
1072
1118
|
// 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
|
|
1073
1119
|
// 再开始缓存问题对应的任务"。
|
|
1074
|
-
const prevState = await readStreamState(sessionId);
|
|
1075
|
-
if (prevState && prevState.status !== "running") {
|
|
1076
|
-
const
|
|
1120
|
+
const prevState = await readStreamState(sessionId);
|
|
1121
|
+
if (prevState && prevState.status !== "running") {
|
|
1122
|
+
const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply);
|
|
1123
|
+
const displayChatId = pickDisplayChat(sessionId);
|
|
1077
1124
|
if (displayChatId) {
|
|
1078
1125
|
const pp = platformForChat(displayChatId);
|
|
1079
1126
|
const display = displayCards.get(displayChatId);
|
|
@@ -1105,16 +1152,16 @@ export async function runAgentSession(
|
|
|
1105
1152
|
const finalStatus = turnFinalStatus(prevState.status);
|
|
1106
1153
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1107
1154
|
|
|
1108
|
-
if (
|
|
1109
|
-
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount,
|
|
1110
|
-
}
|
|
1155
|
+
if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
|
|
1156
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1157
|
+
}
|
|
1111
1158
|
pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
|
|
1112
1159
|
}
|
|
1113
|
-
} else if (pp &&
|
|
1160
|
+
} else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
|
|
1114
1161
|
// 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
|
|
1115
1162
|
const finalStatus = turnFinalStatus(prevState.status);
|
|
1116
1163
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1117
|
-
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount,
|
|
1164
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1118
1165
|
}
|
|
1119
1166
|
// else: displayCards 无记录且无 finalReply → 无需处理
|
|
1120
1167
|
}
|
|
@@ -1188,6 +1235,69 @@ export async function runAgentSession(
|
|
|
1188
1235
|
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
1189
1236
|
let streamErrored = false;
|
|
1190
1237
|
|
|
1238
|
+
const runningPrompt = activePrompts.get(sessionId);
|
|
1239
|
+
if (runningPrompt) {
|
|
1240
|
+
const checkResponseStall = async () => {
|
|
1241
|
+
const current = activePrompts.get(sessionId);
|
|
1242
|
+
if (!current || current !== runningPrompt) {
|
|
1243
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (
|
|
1247
|
+
current.stopped
|
|
1248
|
+
|| current.abnormalExit
|
|
1249
|
+
|| current.resourceStuck
|
|
1250
|
+
|| current.autoEnded
|
|
1251
|
+
|| activityTracker.activity.kind !== "responding"
|
|
1252
|
+
|| !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
|
|
1253
|
+
) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const autoEndedAt = Date.now();
|
|
1258
|
+
current.autoEnded = true;
|
|
1259
|
+
current.autoEndedAt = autoEndedAt;
|
|
1260
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1261
|
+
clearPromptProcessMonitor(sessionId);
|
|
1262
|
+
|
|
1263
|
+
// First publish an atomic terminal state so the card cannot keep claiming the
|
|
1264
|
+
// Agent is running while process cleanup is underway.
|
|
1265
|
+
await writeStreamState({
|
|
1266
|
+
sessionId,
|
|
1267
|
+
status: "auto_ended",
|
|
1268
|
+
accumulatedContent: state.accumulatedContent,
|
|
1269
|
+
finalReply: pickFinalReply(state).trim(),
|
|
1270
|
+
activity: activityTracker.activity,
|
|
1271
|
+
chunkCount: state.chunkCount,
|
|
1272
|
+
turnCount: nextTurnCount,
|
|
1273
|
+
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1274
|
+
updatedAt: autoEndedAt,
|
|
1275
|
+
cwd,
|
|
1276
|
+
tool,
|
|
1277
|
+
autoEndedAt,
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
try {
|
|
1281
|
+
current.closeSession?.();
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${(err as Error).message}`);
|
|
1284
|
+
}
|
|
1285
|
+
current.controller.abort();
|
|
1286
|
+
await killProcessTree(current.processPid);
|
|
1287
|
+
console.warn(
|
|
1288
|
+
`[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without reply character changes`,
|
|
1289
|
+
);
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
const responseStallMonitor = setInterval(() => {
|
|
1293
|
+
void checkResponseStall().catch((err) => {
|
|
1294
|
+
console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${(err as Error).message}`);
|
|
1295
|
+
});
|
|
1296
|
+
}, responseStallCheckIntervalMs);
|
|
1297
|
+
responseStallMonitor.unref?.();
|
|
1298
|
+
runningPrompt.responseStallMonitor = responseStallMonitor;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1191
1301
|
try {
|
|
1192
1302
|
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
|
|
1193
1303
|
onProcessStart: (processInfo) => {
|
|
@@ -1220,10 +1330,21 @@ export async function runAgentSession(
|
|
|
1220
1330
|
lastContextTokens: block.post_tokens,
|
|
1221
1331
|
running: true,
|
|
1222
1332
|
});
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const prompt = activePrompts.get(sessionId);
|
|
1337
|
+
if (prompt && !prompt.autoEnded) {
|
|
1338
|
+
const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
|
|
1339
|
+
prompt.responseProgress = observeResponseProgress(
|
|
1340
|
+
prompt.responseProgress,
|
|
1341
|
+
activityTracker.activity.kind === "responding",
|
|
1342
|
+
totalChars,
|
|
1343
|
+
Date.now(),
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// 定时写入文件
|
|
1227
1348
|
const now2 = Date.now();
|
|
1228
1349
|
if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
|
|
1229
1350
|
lastFileWrite = now2;
|
|
@@ -1249,17 +1370,26 @@ export async function runAgentSession(
|
|
|
1249
1370
|
// 标记 prompt 结束
|
|
1250
1371
|
resourceMonitor.off("stuck", onResourceStuck);
|
|
1251
1372
|
const prompt = activePrompts.get(sessionId);
|
|
1252
|
-
const wasStopped = prompt?.stopped ?? false;
|
|
1253
|
-
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1254
|
-
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1255
|
-
|
|
1256
|
-
|
|
1373
|
+
const wasStopped = prompt?.stopped ?? false;
|
|
1374
|
+
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1375
|
+
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1376
|
+
const wasAutoEnded = prompt?.autoEnded ?? false;
|
|
1377
|
+
const autoEndedAt = prompt?.autoEndedAt;
|
|
1378
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1379
|
+
clearPromptProcessMonitor(sessionId);
|
|
1380
|
+
activePrompts.delete(sessionId);
|
|
1257
1381
|
|
|
1258
1382
|
// 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
|
|
1259
1383
|
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1260
1384
|
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1261
1385
|
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1262
|
-
const finalStatus =
|
|
1386
|
+
const finalStatus = wasAutoEnded
|
|
1387
|
+
? "auto_ended"
|
|
1388
|
+
: (streamErrored || wasAbnormalExit || wasResourceStuck)
|
|
1389
|
+
? "error"
|
|
1390
|
+
: wasStopped
|
|
1391
|
+
? "stopped"
|
|
1392
|
+
: "done";
|
|
1263
1393
|
const finalReply = pickFinalReply(state).trim();
|
|
1264
1394
|
|
|
1265
1395
|
// stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
|
|
@@ -1288,9 +1418,10 @@ export async function runAgentSession(
|
|
|
1288
1418
|
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1289
1419
|
updatedAt: Date.now(),
|
|
1290
1420
|
cwd,
|
|
1291
|
-
tool,
|
|
1292
|
-
...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
|
|
1293
|
-
|
|
1421
|
+
tool,
|
|
1422
|
+
...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
|
|
1423
|
+
...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
|
|
1424
|
+
});
|
|
1294
1425
|
|
|
1295
1426
|
// 消费队列中的缓存消息(异步,不阻塞后续清理)
|
|
1296
1427
|
// 用户 /stop 后应丢弃队列消息,避免用户停止后又自动开始新轮
|
|
@@ -1342,7 +1473,37 @@ export async function runAgentSession(
|
|
|
1342
1473
|
}
|
|
1343
1474
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1344
1475
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1345
|
-
} else if (
|
|
1476
|
+
} else if (wasAutoEnded) {
|
|
1477
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
1478
|
+
const finfo = sessionInfoMap.get(cid);
|
|
1479
|
+
await recordSessionRegistry({
|
|
1480
|
+
chatId: cid,
|
|
1481
|
+
sessionId,
|
|
1482
|
+
tool,
|
|
1483
|
+
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1484
|
+
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1485
|
+
startTime: finfo?.startTime ?? now,
|
|
1486
|
+
running: false,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
const activeAutoEnded = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1490
|
+
if (activeAutoEnded) {
|
|
1491
|
+
const terminalState = await readStreamState(sessionId);
|
|
1492
|
+
if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
1493
|
+
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1494
|
+
await sendFinalReplyTextOnce(
|
|
1495
|
+
pp,
|
|
1496
|
+
activeAutoEnded,
|
|
1497
|
+
sessionId,
|
|
1498
|
+
nextTurnCount,
|
|
1499
|
+
formatAutoEndedReply(finalReplyToWrite),
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
platform.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
|
|
1503
|
+
}
|
|
1504
|
+
console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
|
|
1505
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
|
|
1506
|
+
} else if (wasAbnormalExit) {
|
|
1346
1507
|
for (const cid of getChatsForSession(sessionId)) {
|
|
1347
1508
|
const finfo = sessionInfoMap.get(cid);
|
|
1348
1509
|
await recordSessionRegistry({
|
|
@@ -1459,10 +1620,14 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1459
1620
|
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1460
1621
|
// 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
|
|
1461
1622
|
// 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
|
|
1462
|
-
if (activePrompts.has(sessionId)) continue;
|
|
1463
|
-
|
|
1464
|
-
const tail = "━━━ 回答结束 ━━━";
|
|
1465
|
-
const finalMsg =
|
|
1623
|
+
if (activePrompts.has(sessionId)) continue;
|
|
1624
|
+
|
|
1625
|
+
const tail = "━━━ 回答结束 ━━━";
|
|
1626
|
+
const finalMsg = state.status === "auto_ended"
|
|
1627
|
+
? formatAutoEndedReply(remaining)
|
|
1628
|
+
: remaining
|
|
1629
|
+
? remaining + "\n" + tail
|
|
1630
|
+
: tail;
|
|
1466
1631
|
if (!isFinalReplySentForTurn(state)) {
|
|
1467
1632
|
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
|
|
1468
1633
|
}
|
|
@@ -1511,11 +1676,12 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1511
1676
|
continue;
|
|
1512
1677
|
}
|
|
1513
1678
|
|
|
1514
|
-
let terminalTextDelivered = true;
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1679
|
+
let terminalTextDelivered = true;
|
|
1680
|
+
const terminalReply = formatTerminalReply(state.status, state.finalReply);
|
|
1681
|
+
if (terminalReply) {
|
|
1682
|
+
if (!isFinalReplySentForTurn(state)) {
|
|
1683
|
+
terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
|
|
1684
|
+
}
|
|
1519
1685
|
} else if (state.accumulatedContent.trim()) {
|
|
1520
1686
|
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
1521
1687
|
terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
|
|
@@ -1739,8 +1905,9 @@ export function stopUnifiedDisplayLoop(): void {
|
|
|
1739
1905
|
export function stopSession(sessionId: string): boolean {
|
|
1740
1906
|
const prompt = activePrompts.get(sessionId);
|
|
1741
1907
|
if (!prompt) return false;
|
|
1742
|
-
prompt.stopped = true;
|
|
1743
|
-
|
|
1908
|
+
prompt.stopped = true;
|
|
1909
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1910
|
+
clearPromptProcessMonitor(sessionId);
|
|
1744
1911
|
cancelQueuedMessage(sessionId);
|
|
1745
1912
|
try {
|
|
1746
1913
|
prompt.closeSession?.();
|
package/src/stream-state.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
|
13
13
|
|
|
14
14
|
export interface StreamState {
|
|
15
15
|
sessionId: string;
|
|
16
|
-
status: "running" | "done" | "stopped" | "error";
|
|
16
|
+
status: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
17
17
|
accumulatedContent: string;
|
|
18
18
|
/** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
|
|
19
19
|
* 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
|
|
@@ -32,8 +32,10 @@ export interface StreamState {
|
|
|
32
32
|
tool: string;
|
|
33
33
|
/** Set by stop-stuck-loop to prevent the session from being resumed.
|
|
34
34
|
* The orchestrator checks this before resuming and creates a new session instead. */
|
|
35
|
-
stuckAt?: number;
|
|
36
|
-
|
|
35
|
+
stuckAt?: number;
|
|
36
|
+
/** Set when the shared response watchdog ends a turn after three minutes without new characters. */
|
|
37
|
+
autoEndedAt?: number;
|
|
38
|
+
}
|
|
37
39
|
|
|
38
40
|
function getStreamStatePath(sessionId: string): string {
|
|
39
41
|
return join(STREAMS_DIR, `${sessionId}.json`);
|