chatccc 0.2.209 → 0.2.211
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__/ccc-adapter.test.ts +2 -0
- package/src/__tests__/claude-adapter.test.ts +9 -2
- package/src/__tests__/codex-adapter.test.ts +5 -4
- package/src/__tests__/codex-raw-stream-log.test.ts +1 -0
- package/src/__tests__/cursor-adapter.test.ts +5 -4
- package/src/__tests__/orchestrator.test.ts +7 -1
- package/src/__tests__/session.test.ts +373 -0
- package/src/adapters/adapter-interface.ts +12 -5
- package/src/adapters/ccc-adapter.ts +6 -0
- package/src/adapters/claude-adapter.ts +11 -0
- package/src/adapters/codex-adapter.ts +1 -0
- package/src/adapters/cursor-adapter.ts +1 -0
- package/src/orchestrator.ts +41 -5
- package/src/session-chat-binding.ts +36 -2
- package/src/session.ts +223 -59
package/package.json
CHANGED
|
@@ -73,6 +73,7 @@ describe("createCccAdapter", () => {
|
|
|
73
73
|
expect(messages).toEqual([
|
|
74
74
|
{ type: "assistant", blocks: [{ type: "text", text: "hello" }] },
|
|
75
75
|
{ type: "assistant", blocks: [{ type: "text", text: " world" }] },
|
|
76
|
+
{ type: "assistant", blocks: [], isFinalResponse: true },
|
|
76
77
|
]);
|
|
77
78
|
expect(streamTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
78
79
|
model: { modelId: "deepseek-v4-flash" },
|
|
@@ -109,6 +110,7 @@ describe("createCccAdapter", () => {
|
|
|
109
110
|
type: "assistant",
|
|
110
111
|
blocks: [{ type: "tool_result", tool_use_id: "call-1", content: { content: "hello" }, is_error: false }],
|
|
111
112
|
},
|
|
113
|
+
{ type: "assistant", blocks: [], isFinalResponse: true },
|
|
112
114
|
]);
|
|
113
115
|
});
|
|
114
116
|
});
|
|
@@ -269,10 +269,17 @@ describe("normalizeSdkMessage", () => {
|
|
|
269
269
|
expect(result!.blocks[0]).toEqual({ type: "text", text: "still here" });
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it("
|
|
272
|
+
it("marks a successful result event as an authoritative final response", () => {
|
|
273
273
|
expect(
|
|
274
274
|
normalizeSdkMessage({ type: "result", subtype: "success" }),
|
|
275
|
-
).
|
|
275
|
+
).toEqual({
|
|
276
|
+
type: "assistant",
|
|
277
|
+
blocks: [],
|
|
278
|
+
isFinalResponse: true,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns null for other non-assistant/non-user/non-system messages", () => {
|
|
276
283
|
expect(
|
|
277
284
|
normalizeSdkMessage({ type: "stream_event" }),
|
|
278
285
|
).toBeNull();
|
|
@@ -61,10 +61,11 @@ describe("normalizeCodexMessage", () => {
|
|
|
61
61
|
type: "item.completed",
|
|
62
62
|
item: { id: "item_0", type: "agent_message", text: "hello" },
|
|
63
63
|
});
|
|
64
|
-
expect(result).not.toBeNull();
|
|
65
|
-
expect(result!.type).toBe("assistant");
|
|
66
|
-
expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
|
|
67
|
-
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result!.type).toBe("assistant");
|
|
66
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
|
|
67
|
+
expect(result!.isFinalResponse).toBe(true);
|
|
68
|
+
});
|
|
68
69
|
|
|
69
70
|
it("normalizes command_execution start into tool_use block", () => {
|
|
70
71
|
const result = normalizeCodexMessage({
|
|
@@ -490,10 +490,11 @@ describe("normalizeCursorMessage - result 消息(官方权威最终文本)",
|
|
|
490
490
|
});
|
|
491
491
|
expect(result).not.toBeNull();
|
|
492
492
|
expect(result!.type).toBe("assistant");
|
|
493
|
-
expect(result!.blocks).toEqual([
|
|
494
|
-
{ type: "text_final", text: "权威最终文本" },
|
|
495
|
-
]);
|
|
496
|
-
|
|
493
|
+
expect(result!.blocks).toEqual([
|
|
494
|
+
{ type: "text_final", text: "权威最终文本" },
|
|
495
|
+
]);
|
|
496
|
+
expect(result!.isFinalResponse).toBe(true);
|
|
497
|
+
});
|
|
497
498
|
|
|
498
499
|
it("result 消息没有 result 字段时返回 null(无可用文本)", () => {
|
|
499
500
|
expect(
|
|
@@ -87,7 +87,12 @@ import {
|
|
|
87
87
|
resetState,
|
|
88
88
|
sessionInfoMap,
|
|
89
89
|
} from "../session.ts";
|
|
90
|
-
import {
|
|
90
|
+
import {
|
|
91
|
+
activePrompts,
|
|
92
|
+
dequeueMessage,
|
|
93
|
+
getChatsForSession,
|
|
94
|
+
resetBindingState,
|
|
95
|
+
} from "../session-chat-binding.ts";
|
|
91
96
|
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
92
97
|
import { config } from "../config.ts";
|
|
93
98
|
|
|
@@ -802,6 +807,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
802
807
|
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
803
808
|
const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
|
|
804
809
|
expect(JSON.stringify(card)).toContain("xhigh");
|
|
810
|
+
expect(getChatsForSession("sid-codex-effort")).toContain("codex-chat");
|
|
805
811
|
});
|
|
806
812
|
|
|
807
813
|
it("rejects /effort in Cursor sessions", async () => {
|
|
@@ -123,6 +123,8 @@ import {
|
|
|
123
123
|
_setResponseStallCheckIntervalForTest,
|
|
124
124
|
_resetResponseStallCheckIntervalForTest,
|
|
125
125
|
setSessionEffortOverride,
|
|
126
|
+
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
127
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
126
128
|
} from "../session.ts";
|
|
127
129
|
import {
|
|
128
130
|
activePrompts,
|
|
@@ -670,6 +672,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
670
672
|
|
|
671
673
|
describe("runAgentSession response stall watchdog", () => {
|
|
672
674
|
let tempDir = "";
|
|
675
|
+
const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
|
|
673
676
|
|
|
674
677
|
beforeEach(async () => {
|
|
675
678
|
vi.useFakeTimers();
|
|
@@ -766,6 +769,376 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
766
769
|
autoEndedAt: expect.any(Number),
|
|
767
770
|
});
|
|
768
771
|
});
|
|
772
|
+
|
|
773
|
+
it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
|
|
774
|
+
vi.setSystemTime(0);
|
|
775
|
+
_setResponseStallTimeoutForTest(100);
|
|
776
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
777
|
+
_setProcessAliveForTest(() => true);
|
|
778
|
+
|
|
779
|
+
const platform = mockPlatform("feishu");
|
|
780
|
+
setSessionPlatform(platform);
|
|
781
|
+
|
|
782
|
+
const receivedPrompts: string[] = [];
|
|
783
|
+
const adapter: ToolAdapter = {
|
|
784
|
+
displayName: "Any Agent",
|
|
785
|
+
sessionDescPrefix: "Agent Session:",
|
|
786
|
+
createSession: async () => ({ sessionId: "sid-binding-recovery" }),
|
|
787
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
788
|
+
closeSession: async () => {},
|
|
789
|
+
prompt: async function* (
|
|
790
|
+
_sid: string,
|
|
791
|
+
text: string,
|
|
792
|
+
_cwd: string,
|
|
793
|
+
signal?: AbortSignal,
|
|
794
|
+
options?: ToolPromptOptions,
|
|
795
|
+
) {
|
|
796
|
+
receivedPrompts.push(text);
|
|
797
|
+
options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
|
|
798
|
+
if (receivedPrompts.length === 1) {
|
|
799
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
800
|
+
await new Promise<void>((resolve) => {
|
|
801
|
+
if (signal?.aborted) {
|
|
802
|
+
resolve();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
806
|
+
});
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
yield {
|
|
810
|
+
type: "assistant",
|
|
811
|
+
blocks: [{ type: "text", text: "recovery completed" }],
|
|
812
|
+
isFinalResponse: true,
|
|
813
|
+
};
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
817
|
+
|
|
818
|
+
const firstRun = runAgentSession(
|
|
819
|
+
"sid-binding-recovery",
|
|
820
|
+
"first prompt",
|
|
821
|
+
platform,
|
|
822
|
+
"chat-binding-recovery",
|
|
823
|
+
0,
|
|
824
|
+
"claude",
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
await vi.waitFor(() => {
|
|
828
|
+
expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
|
|
829
|
+
});
|
|
830
|
+
const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
|
|
831
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
832
|
+
await firstRun;
|
|
833
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
834
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
835
|
+
await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
|
|
836
|
+
|
|
837
|
+
expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
|
|
838
|
+
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
839
|
+
expect(platform.cardSend).toHaveBeenCalledTimes(2);
|
|
840
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
841
|
+
"chat-binding-recovery",
|
|
842
|
+
expect.stringContaining(recoveryPrompt),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const registry = JSON.parse(
|
|
846
|
+
await readFile(join(tempDir, "session-registry.json"), "utf8"),
|
|
847
|
+
) as Record<string, { running?: boolean }>;
|
|
848
|
+
expect(registry["chat-binding-recovery"]?.running).toBe(false);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("treats an authoritative final response that races timeout cleanup as completed", async () => {
|
|
852
|
+
vi.setSystemTime(0);
|
|
853
|
+
_setResponseStallTimeoutForTest(100);
|
|
854
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
855
|
+
_setProcessAliveForTest(() => true);
|
|
856
|
+
|
|
857
|
+
const platform = mockPlatform("feishu");
|
|
858
|
+
setSessionPlatform(platform);
|
|
859
|
+
bindChatToSession("sid-final-race", "chat-final-race");
|
|
860
|
+
recordLastActiveChat("sid-final-race", "chat-final-race");
|
|
861
|
+
|
|
862
|
+
const receivedPrompts: string[] = [];
|
|
863
|
+
const adapter: ToolAdapter = {
|
|
864
|
+
displayName: "Any Agent",
|
|
865
|
+
sessionDescPrefix: "Agent Session:",
|
|
866
|
+
createSession: async () => ({ sessionId: "sid-final-race" }),
|
|
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
|
+
receivedPrompts.push(text);
|
|
877
|
+
options?.onProcessStart?.({ pid: 5151 });
|
|
878
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
879
|
+
await new Promise<void>((resolve) => {
|
|
880
|
+
if (signal?.aborted) {
|
|
881
|
+
resolve();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
885
|
+
});
|
|
886
|
+
yield {
|
|
887
|
+
type: "assistant",
|
|
888
|
+
blocks: [{ type: "text", text: "completed at the timeout boundary" }],
|
|
889
|
+
isFinalResponse: true,
|
|
890
|
+
};
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
894
|
+
|
|
895
|
+
const run = runAgentSession(
|
|
896
|
+
"sid-final-race",
|
|
897
|
+
"prompt",
|
|
898
|
+
platform,
|
|
899
|
+
"chat-final-race",
|
|
900
|
+
0,
|
|
901
|
+
"claude",
|
|
902
|
+
);
|
|
903
|
+
await vi.waitFor(() => {
|
|
904
|
+
expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
|
|
905
|
+
});
|
|
906
|
+
const progress = activePrompts.get("sid-final-race")!.responseProgress!;
|
|
907
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
908
|
+
await run;
|
|
909
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
910
|
+
|
|
911
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
912
|
+
expect(mockStreamStates.get("sid-final-race")).toMatchObject({
|
|
913
|
+
status: "done",
|
|
914
|
+
finalReply: "completed at the timeout boundary",
|
|
915
|
+
});
|
|
916
|
+
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
917
|
+
"chat-final-race",
|
|
918
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
919
|
+
);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
923
|
+
vi.setSystemTime(0);
|
|
924
|
+
_setResponseStallTimeoutForTest(100);
|
|
925
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
926
|
+
_setProcessAliveForTest(() => true);
|
|
927
|
+
|
|
928
|
+
const platform = mockPlatform("feishu");
|
|
929
|
+
setSessionPlatform(platform);
|
|
930
|
+
bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
|
|
931
|
+
recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
|
|
932
|
+
|
|
933
|
+
const receivedPrompts: string[] = [];
|
|
934
|
+
const adapter: ToolAdapter = {
|
|
935
|
+
displayName: "Any Agent",
|
|
936
|
+
sessionDescPrefix: "Agent Session:",
|
|
937
|
+
createSession: async () => ({ sessionId: "sid-recovery-priority" }),
|
|
938
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
939
|
+
closeSession: async () => {},
|
|
940
|
+
prompt: async function* (
|
|
941
|
+
_sid: string,
|
|
942
|
+
text: string,
|
|
943
|
+
_cwd: string,
|
|
944
|
+
signal?: AbortSignal,
|
|
945
|
+
options?: ToolPromptOptions,
|
|
946
|
+
) {
|
|
947
|
+
receivedPrompts.push(text);
|
|
948
|
+
options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
|
|
949
|
+
if (receivedPrompts.length === 1) {
|
|
950
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
951
|
+
await new Promise<void>((resolve) => {
|
|
952
|
+
if (signal?.aborted) {
|
|
953
|
+
resolve();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
957
|
+
});
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
|
|
961
|
+
},
|
|
962
|
+
};
|
|
963
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
964
|
+
|
|
965
|
+
enqueueMessage("sid-recovery-priority", {
|
|
966
|
+
text: "queued user prompt",
|
|
967
|
+
chatId: "chat-recovery-priority",
|
|
968
|
+
openId: "open-user",
|
|
969
|
+
msgTimestamp: 1,
|
|
970
|
+
chatType: "p2p",
|
|
971
|
+
});
|
|
972
|
+
const consumeQueued = vi.fn();
|
|
973
|
+
setQueueConsumer(consumeQueued);
|
|
974
|
+
|
|
975
|
+
const firstRun = runAgentSession(
|
|
976
|
+
"sid-recovery-priority",
|
|
977
|
+
"first prompt",
|
|
978
|
+
platform,
|
|
979
|
+
"chat-recovery-priority",
|
|
980
|
+
0,
|
|
981
|
+
"claude",
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
await vi.waitFor(() => {
|
|
985
|
+
expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
|
|
986
|
+
});
|
|
987
|
+
const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
|
|
988
|
+
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
989
|
+
await firstRun;
|
|
990
|
+
|
|
991
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
992
|
+
expect(consumeQueued).not.toHaveBeenCalled();
|
|
993
|
+
expect(isSessionRunning("sid-recovery-priority")).toBe(true);
|
|
994
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
995
|
+
"chat-recovery-priority",
|
|
996
|
+
expect.stringContaining(recoveryPrompt),
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
1000
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1001
|
+
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1002
|
+
expect(receivedPrompts[1]).not.toContain("queued user prompt");
|
|
1003
|
+
expect(consumeQueued).not.toHaveBeenCalled();
|
|
1004
|
+
|
|
1005
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
1006
|
+
expect(consumeQueued).toHaveBeenCalledTimes(1);
|
|
1007
|
+
expect(consumeQueued).toHaveBeenCalledWith(
|
|
1008
|
+
platform,
|
|
1009
|
+
expect.objectContaining({ text: "queued user prompt" }),
|
|
1010
|
+
);
|
|
1011
|
+
setQueueConsumer(() => {});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it("does not auto-recover a second consecutive response stall", async () => {
|
|
1015
|
+
vi.setSystemTime(0);
|
|
1016
|
+
_setResponseStallTimeoutForTest(100);
|
|
1017
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1018
|
+
_setProcessAliveForTest(() => true);
|
|
1019
|
+
|
|
1020
|
+
const platform = mockPlatform("feishu");
|
|
1021
|
+
setSessionPlatform(platform);
|
|
1022
|
+
bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
|
|
1023
|
+
recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
|
|
1024
|
+
|
|
1025
|
+
const receivedPrompts: string[] = [];
|
|
1026
|
+
const adapter: ToolAdapter = {
|
|
1027
|
+
displayName: "Any Agent",
|
|
1028
|
+
sessionDescPrefix: "Agent Session:",
|
|
1029
|
+
createSession: async () => ({ sessionId: "sid-recovery-limit" }),
|
|
1030
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1031
|
+
closeSession: async () => {},
|
|
1032
|
+
prompt: async function* (
|
|
1033
|
+
_sid: string,
|
|
1034
|
+
text: string,
|
|
1035
|
+
_cwd: string,
|
|
1036
|
+
signal?: AbortSignal,
|
|
1037
|
+
options?: ToolPromptOptions,
|
|
1038
|
+
) {
|
|
1039
|
+
receivedPrompts.push(text);
|
|
1040
|
+
options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
|
|
1041
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1042
|
+
await new Promise<void>((resolve) => {
|
|
1043
|
+
if (signal?.aborted) {
|
|
1044
|
+
resolve();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1048
|
+
});
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1052
|
+
|
|
1053
|
+
const firstRun = runAgentSession(
|
|
1054
|
+
"sid-recovery-limit",
|
|
1055
|
+
"first prompt",
|
|
1056
|
+
platform,
|
|
1057
|
+
"chat-recovery-limit",
|
|
1058
|
+
0,
|
|
1059
|
+
"claude",
|
|
1060
|
+
);
|
|
1061
|
+
await vi.waitFor(() => {
|
|
1062
|
+
expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
|
|
1063
|
+
});
|
|
1064
|
+
const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1065
|
+
await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
|
|
1066
|
+
await firstRun;
|
|
1067
|
+
|
|
1068
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
1069
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
1070
|
+
const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
|
|
1071
|
+
await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
|
|
1072
|
+
await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
|
|
1073
|
+
|
|
1074
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1075
|
+
expect(receivedPrompts).toHaveLength(2);
|
|
1076
|
+
expect(receivedPrompts[1]).toContain(recoveryPrompt);
|
|
1077
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
1078
|
+
"chat-recovery-limit",
|
|
1079
|
+
"⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。",
|
|
1080
|
+
);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it("lets /stop cancel a reserved recovery before it starts", async () => {
|
|
1084
|
+
vi.setSystemTime(0);
|
|
1085
|
+
_setResponseStallTimeoutForTest(100);
|
|
1086
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
1087
|
+
_setProcessAliveForTest(() => true);
|
|
1088
|
+
|
|
1089
|
+
const platform = mockPlatform("feishu");
|
|
1090
|
+
setSessionPlatform(platform);
|
|
1091
|
+
bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
|
|
1092
|
+
recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
|
|
1093
|
+
|
|
1094
|
+
const receivedPrompts: string[] = [];
|
|
1095
|
+
const adapter: ToolAdapter = {
|
|
1096
|
+
displayName: "Any Agent",
|
|
1097
|
+
sessionDescPrefix: "Agent Session:",
|
|
1098
|
+
createSession: async () => ({ sessionId: "sid-recovery-stop" }),
|
|
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
|
+
) {
|
|
1107
|
+
receivedPrompts.push(text);
|
|
1108
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
1109
|
+
await new Promise<void>((resolve) => {
|
|
1110
|
+
if (signal?.aborted) {
|
|
1111
|
+
resolve();
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
1115
|
+
});
|
|
1116
|
+
},
|
|
1117
|
+
};
|
|
1118
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1119
|
+
|
|
1120
|
+
const firstRun = runAgentSession(
|
|
1121
|
+
"sid-recovery-stop",
|
|
1122
|
+
"first prompt",
|
|
1123
|
+
platform,
|
|
1124
|
+
"chat-recovery-stop",
|
|
1125
|
+
0,
|
|
1126
|
+
"claude",
|
|
1127
|
+
);
|
|
1128
|
+
await vi.waitFor(() => {
|
|
1129
|
+
expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
|
|
1130
|
+
});
|
|
1131
|
+
const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
|
|
1132
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
1133
|
+
await firstRun;
|
|
1134
|
+
|
|
1135
|
+
expect(isSessionRunning("sid-recovery-stop")).toBe(true);
|
|
1136
|
+
expect(stopSession("sid-recovery-stop")).toBe(true);
|
|
1137
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
1138
|
+
|
|
1139
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
1140
|
+
expect(isSessionRunning("sid-recovery-stop")).toBe(false);
|
|
1141
|
+
});
|
|
769
1142
|
});
|
|
770
1143
|
|
|
771
1144
|
describe("unified display loop WeChat delta", () => {
|
|
@@ -87,10 +87,17 @@ export type UnifiedBlock =
|
|
|
87
87
|
// UnifiedStreamMessage — 一次 SDK/CLI 事件对应的归一化消息
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
89
|
|
|
90
|
-
export interface UnifiedStreamMessage {
|
|
91
|
-
type: "assistant" | "user" | "system";
|
|
92
|
-
blocks: UnifiedBlock[];
|
|
93
|
-
|
|
90
|
+
export interface UnifiedStreamMessage {
|
|
91
|
+
type: "assistant" | "user" | "system";
|
|
92
|
+
blocks: UnifiedBlock[];
|
|
93
|
+
/**
|
|
94
|
+
* 适配器确认本事件代表一条完整、权威的最终回复,而不是流式文本片段。
|
|
95
|
+
*
|
|
96
|
+
* response-stall 终止与最终事件可能在同一事件循环边界竞态;只有该标记
|
|
97
|
+
* 为 true 时,上层才允许把已经触发的超时改判为正常完成。
|
|
98
|
+
*/
|
|
99
|
+
isFinalResponse?: boolean;
|
|
100
|
+
}
|
|
94
101
|
|
|
95
102
|
// ---------------------------------------------------------------------------
|
|
96
103
|
// CreateSessionResult
|
|
@@ -194,4 +201,4 @@ export function parseUserCommand(userText: string): UserCommand {
|
|
|
194
201
|
if (original.startsWith("/plan")) return { original, mode: "plan" };
|
|
195
202
|
if (original.startsWith("/ask")) return { original, mode: "ask" };
|
|
196
203
|
return { original, mode: null };
|
|
197
|
-
}
|
|
204
|
+
}
|
|
@@ -96,6 +96,12 @@ export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
|
|
|
96
96
|
is_error: event.is_error,
|
|
97
97
|
}],
|
|
98
98
|
};
|
|
99
|
+
} else if (event.type === "done" && !signal?.aborted) {
|
|
100
|
+
yield {
|
|
101
|
+
type: "assistant",
|
|
102
|
+
blocks: [],
|
|
103
|
+
isFinalResponse: true,
|
|
104
|
+
};
|
|
99
105
|
} else if (event.type === "error") {
|
|
100
106
|
throw new Error(event.message);
|
|
101
107
|
}
|
|
@@ -57,6 +57,7 @@ interface SdkContentBlock {
|
|
|
57
57
|
interface SdkMessageLike {
|
|
58
58
|
type?: string;
|
|
59
59
|
subtype?: string;
|
|
60
|
+
result?: string;
|
|
60
61
|
message?: { content?: SdkContentBlock[] };
|
|
61
62
|
compact_metadata?: {
|
|
62
63
|
trigger?: "manual" | "auto";
|
|
@@ -202,6 +203,16 @@ function logMcpConfig(): void {
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage | null {
|
|
206
|
+
// SDK result/success 是 Claude 对本轮完整结束的权威确认。文本已经由之前的
|
|
207
|
+
// assistant 消息累计,因此这里只发送终态信号,避免重复追加 result 文本。
|
|
208
|
+
if (msg.type === "result" && msg.subtype === "success") {
|
|
209
|
+
return {
|
|
210
|
+
type: "assistant",
|
|
211
|
+
blocks: [],
|
|
212
|
+
isFinalResponse: true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
205
216
|
if (
|
|
206
217
|
(msg.type === "assistant" || msg.type === "user") &&
|
|
207
218
|
msg.message?.content
|
package/src/orchestrator.ts
CHANGED
|
@@ -1143,11 +1143,47 @@ export async function handleCommand(
|
|
|
1143
1143
|
chatInfo = await platform.getChatInfo(chatId);
|
|
1144
1144
|
description = chatInfo.description;
|
|
1145
1145
|
const sessionInfo = platform.extractSessionInfo(description);
|
|
1146
|
-
if (sessionInfo) {
|
|
1147
|
-
sessionId = sessionInfo.sessionId;
|
|
1148
|
-
descriptionTool = sessionInfo.tool;
|
|
1149
|
-
toolLabel = toolDisplayName(descriptionTool);
|
|
1150
|
-
|
|
1146
|
+
if (sessionInfo) {
|
|
1147
|
+
sessionId = sessionInfo.sessionId;
|
|
1148
|
+
descriptionTool = sessionInfo.tool;
|
|
1149
|
+
toolLabel = toolDisplayName(descriptionTool);
|
|
1150
|
+
|
|
1151
|
+
// 群描述是群聊会话路由的权威来源。历史群可能早于 registry 创建,
|
|
1152
|
+
// 或在冷启动时没有被重建进内存映射;若只解析 sessionId 而不补绑定,
|
|
1153
|
+
// prompt 虽能启动,却找不到生成卡片目标,收尾也无法清除 running。
|
|
1154
|
+
const registry = await loadSessionRegistryForBinding();
|
|
1155
|
+
const record = registry[chatId];
|
|
1156
|
+
if (record?.sessionId && record.sessionId !== sessionId) {
|
|
1157
|
+
unbindChatFromSession(record.sessionId, chatId);
|
|
1158
|
+
}
|
|
1159
|
+
bindChatToSession(sessionId, chatId);
|
|
1160
|
+
|
|
1161
|
+
const memoryInfo = sessionInfoMap.get(chatId);
|
|
1162
|
+
if (!memoryInfo || memoryInfo.sessionId !== sessionId) {
|
|
1163
|
+
sessionInfoMap.set(chatId, {
|
|
1164
|
+
sessionId,
|
|
1165
|
+
tool: descriptionTool,
|
|
1166
|
+
turnCount: record?.sessionId === sessionId ? record.turnCount : 0,
|
|
1167
|
+
lastContextTokens:
|
|
1168
|
+
record?.sessionId === sessionId ? record.lastContextTokens : 0,
|
|
1169
|
+
startTime:
|
|
1170
|
+
record?.sessionId === sessionId
|
|
1171
|
+
? record.startTime
|
|
1172
|
+
: Date.now(),
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// 同步自愈持久化记录,使下一次重启可以直接重建绑定。running 取实际
|
|
1177
|
+
// 内存状态,顺便修复旧故障遗留的 stale running=true。
|
|
1178
|
+
await recordSessionRegistry({
|
|
1179
|
+
chatId,
|
|
1180
|
+
sessionId,
|
|
1181
|
+
tool: descriptionTool,
|
|
1182
|
+
chatType,
|
|
1183
|
+
chatName: chatInfo.name,
|
|
1184
|
+
running: isSessionRunning(sessionId),
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1151
1187
|
} catch (err) {
|
|
1152
1188
|
logTrace(tid, "BRANCH", {
|
|
1153
1189
|
reason: "get_chat_info_failed",
|
|
@@ -57,9 +57,17 @@ export function hasChatsForSession(sessionId: string): boolean {
|
|
|
57
57
|
// 也必须阻止下一轮提前进入,否则旧会话的落盘可能覆盖新会话绑定。
|
|
58
58
|
const finalizingSessions = new Set<string>();
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// response-stall 自动恢复预约只存在于当前进程内:用户已明确 ChatCCC 重启后
|
|
61
|
+
// 不需要补发恢复 prompt。预约从检测到第一次停滞起一直持有到恢复轮同步进入
|
|
62
|
+
// runAgentSession,填补旧轮收尾和新轮 activePrompts.set 之间的空窗,保证此时
|
|
63
|
+
// 到达的普通用户消息只能进入缓存队列,不能抢在恢复 prompt 前执行。
|
|
64
|
+
const autoRecoveryReservations = new Set<string>();
|
|
65
|
+
|
|
66
|
+
/** 检查 sessionId 是否有活跃 prompt、正在收尾或已预约自动恢复。 */
|
|
61
67
|
export function isSessionRunning(sessionId: string): boolean {
|
|
62
|
-
return activePrompts.has(sessionId)
|
|
68
|
+
return activePrompts.has(sessionId)
|
|
69
|
+
|| finalizingSessions.has(sessionId)
|
|
70
|
+
|| autoRecoveryReservations.has(sessionId);
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
export function markSessionFinalizing(sessionId: string): void {
|
|
@@ -69,6 +77,27 @@ export function markSessionFinalizing(sessionId: string): void {
|
|
|
69
77
|
export function clearSessionFinalizing(sessionId: string): void {
|
|
70
78
|
finalizingSessions.delete(sessionId);
|
|
71
79
|
}
|
|
80
|
+
|
|
81
|
+
/** 为 response-stall 后的内部恢复轮预留下一次执行权。 */
|
|
82
|
+
export function reserveAutoRecovery(sessionId: string): boolean {
|
|
83
|
+
if (autoRecoveryReservations.has(sessionId)) return false;
|
|
84
|
+
autoRecoveryReservations.add(sessionId);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 恢复轮启动前原子消费预约;返回 false 表示已被 /stop 取消。 */
|
|
89
|
+
export function consumeAutoRecoveryReservation(sessionId: string): boolean {
|
|
90
|
+
return autoRecoveryReservations.delete(sessionId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 取消尚未启动的恢复轮。 */
|
|
94
|
+
export function cancelAutoRecoveryReservation(sessionId: string): boolean {
|
|
95
|
+
return autoRecoveryReservations.delete(sessionId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function hasAutoRecoveryReservation(sessionId: string): boolean {
|
|
99
|
+
return autoRecoveryReservations.has(sessionId);
|
|
100
|
+
}
|
|
72
101
|
|
|
73
102
|
// ---------------------------------------------------------------------------
|
|
74
103
|
// activePrompts: sessionId → 活跃 prompt 控制
|
|
@@ -92,6 +121,10 @@ export interface ActivePrompt {
|
|
|
92
121
|
abnormalExitNotified?: boolean;
|
|
93
122
|
/** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
|
|
94
123
|
resourceStuck?: boolean;
|
|
124
|
+
/** True only for the single internal continuation turn after a response stall. */
|
|
125
|
+
autoRecovery?: boolean;
|
|
126
|
+
/** Adapter observed an authoritative completed final-response event. */
|
|
127
|
+
finalResponseObserved?: boolean;
|
|
95
128
|
/** Adapter-provided callback to close the underlying SDK session / subprocess.
|
|
96
129
|
* Called by stop-stuck-loop before controller.abort() to terminate the CLI
|
|
97
130
|
* process immediately, rather than waiting for the async generator to unblock. */
|
|
@@ -245,6 +278,7 @@ export function resetBindingState(): void {
|
|
|
245
278
|
}
|
|
246
279
|
activePrompts.clear();
|
|
247
280
|
finalizingSessions.clear();
|
|
281
|
+
autoRecoveryReservations.clear();
|
|
248
282
|
queuedMessages.clear();
|
|
249
283
|
displayCards.clear();
|
|
250
284
|
if (unifiedDisplayLoopHandle !== null) {
|
package/src/session.ts
CHANGED
|
@@ -77,6 +77,10 @@ import {
|
|
|
77
77
|
consumeQueuePreservedChat,
|
|
78
78
|
markSessionFinalizing,
|
|
79
79
|
clearSessionFinalizing,
|
|
80
|
+
reserveAutoRecovery,
|
|
81
|
+
consumeAutoRecoveryReservation,
|
|
82
|
+
cancelAutoRecoveryReservation,
|
|
83
|
+
hasAutoRecoveryReservation,
|
|
80
84
|
} from "./session-chat-binding.ts";
|
|
81
85
|
|
|
82
86
|
async function sendFinalReplyTextOnce(
|
|
@@ -161,6 +165,12 @@ function platformForChat(chatId: string): PlatformAdapter | null {
|
|
|
161
165
|
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
162
166
|
const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
|
|
163
167
|
const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
|
|
168
|
+
export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
|
|
169
|
+
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
170
|
+
`检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
|
|
171
|
+
export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
|
|
172
|
+
"⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
|
|
173
|
+
const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
|
|
164
174
|
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
165
175
|
let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
166
176
|
let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
@@ -966,22 +976,41 @@ export async function resumeAndPrompt(
|
|
|
966
976
|
return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
|
|
967
977
|
}
|
|
968
978
|
|
|
969
|
-
// ---------------------------------------------------------------------------
|
|
970
|
-
// runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
|
|
971
|
-
// ---------------------------------------------------------------------------
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
interface RunAgentSessionOptions {
|
|
984
|
+
/**
|
|
985
|
+
* 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
|
|
986
|
+
* 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
|
|
987
|
+
*/
|
|
988
|
+
autoRecovery?: boolean;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
export async function runAgentSession(
|
|
992
|
+
sessionId: string,
|
|
993
|
+
userText: string,
|
|
994
|
+
platform: PlatformAdapter,
|
|
995
|
+
_chatId: string,
|
|
996
|
+
msgTimestamp: number,
|
|
997
|
+
tool: string,
|
|
998
|
+
traceId?: string,
|
|
999
|
+
options: RunAgentSessionOptions = {},
|
|
1000
|
+
): Promise<void> {
|
|
1001
|
+
const tid = traceId ?? "";
|
|
1002
|
+
|
|
1003
|
+
// runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
|
|
1004
|
+
// prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
|
|
1005
|
+
// 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
|
|
1006
|
+
// 相同的卡片、状态和收尾行为。
|
|
1007
|
+
const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
|
|
1008
|
+
if (previousSessionId && previousSessionId !== sessionId) {
|
|
1009
|
+
unbindChatFromSession(previousSessionId, _chatId);
|
|
1010
|
+
}
|
|
1011
|
+
bindChatToSession(sessionId, _chatId);
|
|
1012
|
+
|
|
1013
|
+
// 记录用户最后发送消息的群(display loop 只推送到该群)
|
|
985
1014
|
// 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
|
|
986
1015
|
recordChatPlatform(_chatId, platform);
|
|
987
1016
|
recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
|
|
@@ -1002,11 +1031,13 @@ export async function runAgentSession(
|
|
|
1002
1031
|
// 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
|
|
1003
1032
|
const controller = new AbortController();
|
|
1004
1033
|
const now = Date.now();
|
|
1005
|
-
activePrompts.set(sessionId, {
|
|
1006
|
-
controller,
|
|
1007
|
-
stopped: false,
|
|
1008
|
-
startTime: now,
|
|
1009
|
-
|
|
1034
|
+
activePrompts.set(sessionId, {
|
|
1035
|
+
controller,
|
|
1036
|
+
stopped: false,
|
|
1037
|
+
startTime: now,
|
|
1038
|
+
autoRecovery: options.autoRecovery === true,
|
|
1039
|
+
finalResponseObserved: false,
|
|
1040
|
+
});
|
|
1010
1041
|
|
|
1011
1042
|
// 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
|
|
1012
1043
|
const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
|
|
@@ -1250,6 +1281,7 @@ export async function runAgentSession(
|
|
|
1250
1281
|
|| current.abnormalExit
|
|
1251
1282
|
|| current.resourceStuck
|
|
1252
1283
|
|| current.autoEnded
|
|
1284
|
+
|| current.finalResponseObserved
|
|
1253
1285
|
|| activityTracker.activity.kind !== "responding"
|
|
1254
1286
|
|| !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
|
|
1255
1287
|
) {
|
|
@@ -1257,6 +1289,13 @@ export async function runAgentSession(
|
|
|
1257
1289
|
}
|
|
1258
1290
|
|
|
1259
1291
|
const autoEndedAt = Date.now();
|
|
1292
|
+
// 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
|
|
1293
|
+
// 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
|
|
1294
|
+
// 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
|
|
1295
|
+
// 若当前已经是恢复轮,则不再预约第三轮。
|
|
1296
|
+
if (!current.autoRecovery) {
|
|
1297
|
+
reserveAutoRecovery(sessionId);
|
|
1298
|
+
}
|
|
1260
1299
|
current.autoEnded = true;
|
|
1261
1300
|
current.autoEndedAt = autoEndedAt;
|
|
1262
1301
|
clearPromptResponseStallMonitor(sessionId);
|
|
@@ -1279,6 +1318,18 @@ export async function runAgentSession(
|
|
|
1279
1318
|
autoEndedAt,
|
|
1280
1319
|
});
|
|
1281
1320
|
|
|
1321
|
+
// 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
|
|
1322
|
+
// final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
|
|
1323
|
+
if (current.finalResponseObserved) {
|
|
1324
|
+
current.autoEnded = false;
|
|
1325
|
+
current.autoEndedAt = undefined;
|
|
1326
|
+
cancelAutoRecoveryReservation(sessionId);
|
|
1327
|
+
console.log(
|
|
1328
|
+
`[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
|
|
1329
|
+
);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1282
1333
|
try {
|
|
1283
1334
|
current.closeSession?.();
|
|
1284
1335
|
} catch (err) {
|
|
@@ -1310,11 +1361,20 @@ export async function runAgentSession(
|
|
|
1310
1361
|
clearPromptProcessMonitor(sessionId);
|
|
1311
1362
|
if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
|
|
1312
1363
|
},
|
|
1313
|
-
onSessionCreated: (closeSession) => {
|
|
1314
|
-
const prompt = activePrompts.get(sessionId);
|
|
1315
|
-
if (prompt) prompt.closeSession = closeSession;
|
|
1316
|
-
},
|
|
1317
|
-
})) {
|
|
1364
|
+
onSessionCreated: (closeSession) => {
|
|
1365
|
+
const prompt = activePrompts.get(sessionId);
|
|
1366
|
+
if (prompt) prompt.closeSession = closeSession;
|
|
1367
|
+
},
|
|
1368
|
+
})) {
|
|
1369
|
+
if (unifiedMsg.isFinalResponse) {
|
|
1370
|
+
const prompt = activePrompts.get(sessionId);
|
|
1371
|
+
if (prompt && prompt === runningPrompt) {
|
|
1372
|
+
// 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
|
|
1373
|
+
// 最终事件后仍把本轮判为停滞。
|
|
1374
|
+
prompt.finalResponseObserved = true;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1318
1378
|
let activityChanged = false;
|
|
1319
1379
|
for (const block of unifiedMsg.blocks) {
|
|
1320
1380
|
if (updateAgentActivity(activityTracker, block)) activityChanged = true;
|
|
@@ -1375,26 +1435,46 @@ export async function runAgentSession(
|
|
|
1375
1435
|
const wasStopped = prompt?.stopped ?? false;
|
|
1376
1436
|
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1377
1437
|
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1378
|
-
const
|
|
1379
|
-
const
|
|
1438
|
+
const timeoutTriggered = prompt?.autoEnded ?? false;
|
|
1439
|
+
const completedAtTimeoutBoundary =
|
|
1440
|
+
timeoutTriggered && (prompt?.finalResponseObserved ?? false);
|
|
1441
|
+
const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
|
|
1442
|
+
const wasAutoRecovery = prompt?.autoRecovery ?? false;
|
|
1443
|
+
const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
|
|
1380
1444
|
clearPromptResponseStallMonitor(sessionId);
|
|
1381
1445
|
clearPromptProcessMonitor(sessionId);
|
|
1382
1446
|
markSessionFinalizing(sessionId);
|
|
1383
1447
|
activePrompts.delete(sessionId);
|
|
1384
1448
|
|
|
1385
1449
|
try {
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1450
|
+
if (completedAtTimeoutBoundary) {
|
|
1451
|
+
// reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
|
|
1452
|
+
// abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
|
|
1453
|
+
cancelAutoRecoveryReservation(sessionId);
|
|
1454
|
+
console.log(
|
|
1455
|
+
`[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
// 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
|
|
1459
|
+
// 永久残留 running=true。
|
|
1460
|
+
const finalizationChatIds = [...new Set([
|
|
1461
|
+
...getChatsForSession(sessionId),
|
|
1462
|
+
_chatId,
|
|
1463
|
+
])];
|
|
1464
|
+
// 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
|
|
1465
|
+
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1466
|
+
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1467
|
+
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1468
|
+
const finalStatus = completedAtTimeoutBoundary
|
|
1469
|
+
? "done"
|
|
1470
|
+
: wasAutoEnded
|
|
1471
|
+
? "auto_ended"
|
|
1472
|
+
: (streamErrored || wasAbnormalExit || wasResourceStuck)
|
|
1473
|
+
? "error"
|
|
1474
|
+
: wasStopped
|
|
1475
|
+
? "stopped"
|
|
1476
|
+
: "done";
|
|
1477
|
+
const finalReply = pickFinalReply(state).trim();
|
|
1398
1478
|
|
|
1399
1479
|
// stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
|
|
1400
1480
|
// stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
|
|
@@ -1426,11 +1506,13 @@ export async function runAgentSession(
|
|
|
1426
1506
|
...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
|
|
1427
1507
|
...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
|
|
1428
1508
|
});
|
|
1429
|
-
|
|
1509
|
+
|
|
1430
1510
|
// display loop 下一轮会读到最终状态并发送消息
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1511
|
+
|
|
1512
|
+
let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
|
|
1513
|
+
|
|
1514
|
+
if (wasStopped) {
|
|
1515
|
+
for (const cid of finalizationChatIds) {
|
|
1434
1516
|
const finfo = sessionInfoMap.get(cid);
|
|
1435
1517
|
await recordSessionRegistry({
|
|
1436
1518
|
chatId: cid,
|
|
@@ -1442,7 +1524,7 @@ export async function runAgentSession(
|
|
|
1442
1524
|
running: false,
|
|
1443
1525
|
});
|
|
1444
1526
|
}
|
|
1445
|
-
const active1 = getLastActiveChat(sessionId) ??
|
|
1527
|
+
const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1446
1528
|
if (active1) {
|
|
1447
1529
|
await platform.sendText(active1, "会话已停止。").catch(() => {});
|
|
1448
1530
|
platform.setChatAvatar(active1, tool, "idle").catch(() => {});
|
|
@@ -1450,7 +1532,7 @@ export async function runAgentSession(
|
|
|
1450
1532
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1451
1533
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1452
1534
|
} else if (wasAutoEnded) {
|
|
1453
|
-
for (const cid of
|
|
1535
|
+
for (const cid of finalizationChatIds) {
|
|
1454
1536
|
const finfo = sessionInfoMap.get(cid);
|
|
1455
1537
|
await recordSessionRegistry({
|
|
1456
1538
|
chatId: cid,
|
|
@@ -1462,11 +1544,11 @@ export async function runAgentSession(
|
|
|
1462
1544
|
running: false,
|
|
1463
1545
|
});
|
|
1464
1546
|
}
|
|
1465
|
-
const activeAutoEnded = getLastActiveChat(sessionId) ??
|
|
1547
|
+
const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1466
1548
|
if (activeAutoEnded) {
|
|
1549
|
+
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1467
1550
|
const terminalState = await readStreamState(sessionId);
|
|
1468
1551
|
if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
1469
|
-
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1470
1552
|
await sendFinalReplyTextOnce(
|
|
1471
1553
|
pp,
|
|
1472
1554
|
activeAutoEnded,
|
|
@@ -1475,12 +1557,29 @@ export async function runAgentSession(
|
|
|
1475
1557
|
formatAutoEndedReply(finalReplyToWrite),
|
|
1476
1558
|
);
|
|
1477
1559
|
}
|
|
1478
|
-
|
|
1560
|
+
pp.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
|
|
1561
|
+
|
|
1562
|
+
if (wasAutoRecovery) {
|
|
1563
|
+
// 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
|
|
1564
|
+
// 自动链,避免第三轮及之后的无限续跑。
|
|
1565
|
+
await pp.sendText(
|
|
1566
|
+
activeAutoEnded,
|
|
1567
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
1568
|
+
).catch(() => {});
|
|
1569
|
+
} else if (hasAutoRecoveryReservation(sessionId)) {
|
|
1570
|
+
// 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
|
|
1571
|
+
// 内部恢复仍由 reservation 保证先于普通缓存消息。
|
|
1572
|
+
await pp.sendText(
|
|
1573
|
+
activeAutoEnded,
|
|
1574
|
+
RESPONSE_STALL_RECOVERY_NOTICE,
|
|
1575
|
+
).catch(() => {});
|
|
1576
|
+
autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
|
|
1577
|
+
}
|
|
1479
1578
|
}
|
|
1480
1579
|
console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
|
|
1481
1580
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
|
|
1482
1581
|
} else if (wasAbnormalExit) {
|
|
1483
|
-
for (const cid of
|
|
1582
|
+
for (const cid of finalizationChatIds) {
|
|
1484
1583
|
const finfo = sessionInfoMap.get(cid);
|
|
1485
1584
|
await recordSessionRegistry({
|
|
1486
1585
|
chatId: cid,
|
|
@@ -1492,12 +1591,12 @@ export async function runAgentSession(
|
|
|
1492
1591
|
running: false,
|
|
1493
1592
|
});
|
|
1494
1593
|
}
|
|
1495
|
-
const activeErr = getLastActiveChat(sessionId) ??
|
|
1594
|
+
const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1496
1595
|
if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
|
|
1497
1596
|
console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
|
|
1498
1597
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
|
|
1499
|
-
} else {
|
|
1500
|
-
for (const cid of
|
|
1598
|
+
} else {
|
|
1599
|
+
for (const cid of finalizationChatIds) {
|
|
1501
1600
|
const finfo = sessionInfoMap.get(cid);
|
|
1502
1601
|
await recordSessionRegistry({
|
|
1503
1602
|
chatId: cid,
|
|
@@ -1509,7 +1608,7 @@ export async function runAgentSession(
|
|
|
1509
1608
|
running: false,
|
|
1510
1609
|
});
|
|
1511
1610
|
}
|
|
1512
|
-
const active2 = getLastActiveChat(sessionId) ??
|
|
1611
|
+
const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1513
1612
|
if (active2) {
|
|
1514
1613
|
const terminalState = await readStreamState(sessionId);
|
|
1515
1614
|
if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
@@ -1522,6 +1621,18 @@ export async function runAgentSession(
|
|
|
1522
1621
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
1523
1622
|
}
|
|
1524
1623
|
|
|
1624
|
+
// 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
|
|
1625
|
+
if (
|
|
1626
|
+
wasAutoEnded
|
|
1627
|
+
&& hasAutoRecoveryReservation(sessionId)
|
|
1628
|
+
&& !autoRecoveryTarget
|
|
1629
|
+
) {
|
|
1630
|
+
cancelAutoRecoveryReservation(sessionId);
|
|
1631
|
+
}
|
|
1632
|
+
const shouldScheduleAutoRecovery =
|
|
1633
|
+
autoRecoveryTarget !== undefined
|
|
1634
|
+
&& hasAutoRecoveryReservation(sessionId);
|
|
1635
|
+
|
|
1525
1636
|
// 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
|
|
1526
1637
|
// 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
|
|
1527
1638
|
let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
|
|
@@ -1530,7 +1641,9 @@ export async function runAgentSession(
|
|
|
1530
1641
|
if (discarded) {
|
|
1531
1642
|
console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
|
|
1532
1643
|
}
|
|
1533
|
-
} else {
|
|
1644
|
+
} else if (!shouldScheduleAutoRecovery) {
|
|
1645
|
+
// 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
|
|
1646
|
+
// finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
|
|
1534
1647
|
queuedForConsumption = dequeueMessage(sessionId);
|
|
1535
1648
|
}
|
|
1536
1649
|
|
|
@@ -1548,7 +1661,48 @@ export async function runAgentSession(
|
|
|
1548
1661
|
// 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
|
|
1549
1662
|
setTimeout(() => {
|
|
1550
1663
|
consumeQueuedMessage(platform, queued);
|
|
1551
|
-
},
|
|
1664
|
+
}, RESPONSE_STALL_RECOVERY_DELAY_MS);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
|
|
1668
|
+
const target = autoRecoveryTarget;
|
|
1669
|
+
console.log(
|
|
1670
|
+
`[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
|
|
1671
|
+
);
|
|
1672
|
+
// 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
|
|
1673
|
+
// 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
|
|
1674
|
+
// 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
|
|
1675
|
+
// 因而不存在普通用户消息可插入的事件循环空窗。
|
|
1676
|
+
setTimeout(() => {
|
|
1677
|
+
if (!hasAutoRecoveryReservation(sessionId)) return;
|
|
1678
|
+
const recoveryRun = runAgentSession(
|
|
1679
|
+
sessionId,
|
|
1680
|
+
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
1681
|
+
target.platform,
|
|
1682
|
+
target.chatId,
|
|
1683
|
+
Date.now(),
|
|
1684
|
+
tool,
|
|
1685
|
+
undefined,
|
|
1686
|
+
{ autoRecovery: true },
|
|
1687
|
+
);
|
|
1688
|
+
consumeAutoRecoveryReservation(sessionId);
|
|
1689
|
+
void recoveryRun.catch((err) => {
|
|
1690
|
+
console.error(
|
|
1691
|
+
`[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
|
|
1692
|
+
);
|
|
1693
|
+
target.platform.sendText(
|
|
1694
|
+
target.chatId,
|
|
1695
|
+
`⚠️ 自动续跑启动失败:${(err as Error).message}`,
|
|
1696
|
+
).catch(() => {});
|
|
1697
|
+
|
|
1698
|
+
// 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
|
|
1699
|
+
// 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
|
|
1700
|
+
const queued = dequeueMessage(sessionId);
|
|
1701
|
+
if (queued) {
|
|
1702
|
+
consumeQueuedMessage(target.platform, queued);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
}, RESPONSE_STALL_RECOVERY_DELAY_MS);
|
|
1552
1706
|
}
|
|
1553
1707
|
} finally {
|
|
1554
1708
|
clearSessionFinalizing(sessionId);
|
|
@@ -1910,9 +2064,19 @@ export function stopUnifiedDisplayLoop(): void {
|
|
|
1910
2064
|
// 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
|
|
1911
2065
|
// 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
|
|
1912
2066
|
// finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
|
|
1913
|
-
export function stopSession(sessionId: string): boolean {
|
|
1914
|
-
|
|
1915
|
-
|
|
2067
|
+
export function stopSession(sessionId: string): boolean {
|
|
2068
|
+
// /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
|
|
2069
|
+
// 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
|
|
2070
|
+
const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
|
|
2071
|
+
const prompt = activePrompts.get(sessionId);
|
|
2072
|
+
if (!prompt) {
|
|
2073
|
+
if (cancelledRecovery) {
|
|
2074
|
+
cancelQueuedMessage(sessionId);
|
|
2075
|
+
console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
|
|
2076
|
+
return true;
|
|
2077
|
+
}
|
|
2078
|
+
return false;
|
|
2079
|
+
}
|
|
1916
2080
|
prompt.stopped = true;
|
|
1917
2081
|
clearPromptResponseStallMonitor(sessionId);
|
|
1918
2082
|
clearPromptProcessMonitor(sessionId);
|