chatccc 0.2.210 → 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 +153 -2
- 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 +2 -0
- package/src/session.ts +85 -32
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,7 +672,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
670
672
|
|
|
671
673
|
describe("runAgentSession response stall watchdog", () => {
|
|
672
674
|
let tempDir = "";
|
|
673
|
-
const recoveryPrompt =
|
|
675
|
+
const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
|
|
674
676
|
|
|
675
677
|
beforeEach(async () => {
|
|
676
678
|
vi.useFakeTimers();
|
|
@@ -768,6 +770,155 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
768
770
|
});
|
|
769
771
|
});
|
|
770
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
|
+
|
|
771
922
|
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
772
923
|
vi.setSystemTime(0);
|
|
773
924
|
_setResponseStallTimeoutForTest(100);
|
|
@@ -842,7 +993,7 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
842
993
|
expect(isSessionRunning("sid-recovery-priority")).toBe(true);
|
|
843
994
|
expect(platform.sendText).toHaveBeenCalledWith(
|
|
844
995
|
"chat-recovery-priority",
|
|
845
|
-
|
|
996
|
+
expect.stringContaining(recoveryPrompt),
|
|
846
997
|
);
|
|
847
998
|
|
|
848
999
|
await vi.advanceTimersByTimeAsync(200);
|
|
@@ -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",
|
|
@@ -123,6 +123,8 @@ export interface ActivePrompt {
|
|
|
123
123
|
resourceStuck?: boolean;
|
|
124
124
|
/** True only for the single internal continuation turn after a response stall. */
|
|
125
125
|
autoRecovery?: boolean;
|
|
126
|
+
/** Adapter observed an authoritative completed final-response event. */
|
|
127
|
+
finalResponseObserved?: boolean;
|
|
126
128
|
/** Adapter-provided callback to close the underlying SDK session / subprocess.
|
|
127
129
|
* Called by stop-stuck-loop before controller.abort() to terminate the CLI
|
|
128
130
|
* process immediately, rather than waiting for the async generator to unblock. */
|
package/src/session.ts
CHANGED
|
@@ -166,7 +166,8 @@ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
|
166
166
|
const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
|
|
167
167
|
const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
|
|
168
168
|
export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
|
|
169
|
-
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
169
|
+
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
170
|
+
`检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
|
|
170
171
|
export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
|
|
171
172
|
"⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
|
|
172
173
|
const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
|
|
@@ -997,9 +998,19 @@ export async function runAgentSession(
|
|
|
997
998
|
traceId?: string,
|
|
998
999
|
options: RunAgentSessionOptions = {},
|
|
999
1000
|
): Promise<void> {
|
|
1000
|
-
const tid = traceId ?? "";
|
|
1001
|
-
|
|
1002
|
-
//
|
|
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 只推送到该群)
|
|
1003
1014
|
// 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
|
|
1004
1015
|
recordChatPlatform(_chatId, platform);
|
|
1005
1016
|
recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
|
|
@@ -1025,6 +1036,7 @@ export async function runAgentSession(
|
|
|
1025
1036
|
stopped: false,
|
|
1026
1037
|
startTime: now,
|
|
1027
1038
|
autoRecovery: options.autoRecovery === true,
|
|
1039
|
+
finalResponseObserved: false,
|
|
1028
1040
|
});
|
|
1029
1041
|
|
|
1030
1042
|
// 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
|
|
@@ -1269,6 +1281,7 @@ export async function runAgentSession(
|
|
|
1269
1281
|
|| current.abnormalExit
|
|
1270
1282
|
|| current.resourceStuck
|
|
1271
1283
|
|| current.autoEnded
|
|
1284
|
+
|| current.finalResponseObserved
|
|
1272
1285
|
|| activityTracker.activity.kind !== "responding"
|
|
1273
1286
|
|| !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
|
|
1274
1287
|
) {
|
|
@@ -1305,6 +1318,18 @@ export async function runAgentSession(
|
|
|
1305
1318
|
autoEndedAt,
|
|
1306
1319
|
});
|
|
1307
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
|
+
|
|
1308
1333
|
try {
|
|
1309
1334
|
current.closeSession?.();
|
|
1310
1335
|
} catch (err) {
|
|
@@ -1336,11 +1361,20 @@ export async function runAgentSession(
|
|
|
1336
1361
|
clearPromptProcessMonitor(sessionId);
|
|
1337
1362
|
if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
|
|
1338
1363
|
},
|
|
1339
|
-
onSessionCreated: (closeSession) => {
|
|
1340
|
-
const prompt = activePrompts.get(sessionId);
|
|
1341
|
-
if (prompt) prompt.closeSession = closeSession;
|
|
1342
|
-
},
|
|
1343
|
-
})) {
|
|
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
|
+
|
|
1344
1378
|
let activityChanged = false;
|
|
1345
1379
|
for (const block of unifiedMsg.blocks) {
|
|
1346
1380
|
if (updateAgentActivity(activityTracker, block)) activityChanged = true;
|
|
@@ -1401,27 +1435,46 @@ export async function runAgentSession(
|
|
|
1401
1435
|
const wasStopped = prompt?.stopped ?? false;
|
|
1402
1436
|
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1403
1437
|
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1404
|
-
const
|
|
1438
|
+
const timeoutTriggered = prompt?.autoEnded ?? false;
|
|
1439
|
+
const completedAtTimeoutBoundary =
|
|
1440
|
+
timeoutTriggered && (prompt?.finalResponseObserved ?? false);
|
|
1441
|
+
const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
|
|
1405
1442
|
const wasAutoRecovery = prompt?.autoRecovery ?? false;
|
|
1406
|
-
const autoEndedAt = prompt?.autoEndedAt;
|
|
1443
|
+
const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
|
|
1407
1444
|
clearPromptResponseStallMonitor(sessionId);
|
|
1408
1445
|
clearPromptProcessMonitor(sessionId);
|
|
1409
1446
|
markSessionFinalizing(sessionId);
|
|
1410
1447
|
activePrompts.delete(sessionId);
|
|
1411
1448
|
|
|
1412
1449
|
try {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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();
|
|
1425
1478
|
|
|
1426
1479
|
// stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
|
|
1427
1480
|
// stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
|
|
@@ -1459,7 +1512,7 @@ export async function runAgentSession(
|
|
|
1459
1512
|
let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
|
|
1460
1513
|
|
|
1461
1514
|
if (wasStopped) {
|
|
1462
|
-
for (const cid of
|
|
1515
|
+
for (const cid of finalizationChatIds) {
|
|
1463
1516
|
const finfo = sessionInfoMap.get(cid);
|
|
1464
1517
|
await recordSessionRegistry({
|
|
1465
1518
|
chatId: cid,
|
|
@@ -1471,7 +1524,7 @@ export async function runAgentSession(
|
|
|
1471
1524
|
running: false,
|
|
1472
1525
|
});
|
|
1473
1526
|
}
|
|
1474
|
-
const active1 = getLastActiveChat(sessionId) ??
|
|
1527
|
+
const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1475
1528
|
if (active1) {
|
|
1476
1529
|
await platform.sendText(active1, "会话已停止。").catch(() => {});
|
|
1477
1530
|
platform.setChatAvatar(active1, tool, "idle").catch(() => {});
|
|
@@ -1479,7 +1532,7 @@ export async function runAgentSession(
|
|
|
1479
1532
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1480
1533
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1481
1534
|
} else if (wasAutoEnded) {
|
|
1482
|
-
for (const cid of
|
|
1535
|
+
for (const cid of finalizationChatIds) {
|
|
1483
1536
|
const finfo = sessionInfoMap.get(cid);
|
|
1484
1537
|
await recordSessionRegistry({
|
|
1485
1538
|
chatId: cid,
|
|
@@ -1491,7 +1544,7 @@ export async function runAgentSession(
|
|
|
1491
1544
|
running: false,
|
|
1492
1545
|
});
|
|
1493
1546
|
}
|
|
1494
|
-
const activeAutoEnded = getLastActiveChat(sessionId) ??
|
|
1547
|
+
const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1495
1548
|
if (activeAutoEnded) {
|
|
1496
1549
|
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1497
1550
|
const terminalState = await readStreamState(sessionId);
|
|
@@ -1526,7 +1579,7 @@ export async function runAgentSession(
|
|
|
1526
1579
|
console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
|
|
1527
1580
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
|
|
1528
1581
|
} else if (wasAbnormalExit) {
|
|
1529
|
-
for (const cid of
|
|
1582
|
+
for (const cid of finalizationChatIds) {
|
|
1530
1583
|
const finfo = sessionInfoMap.get(cid);
|
|
1531
1584
|
await recordSessionRegistry({
|
|
1532
1585
|
chatId: cid,
|
|
@@ -1538,12 +1591,12 @@ export async function runAgentSession(
|
|
|
1538
1591
|
running: false,
|
|
1539
1592
|
});
|
|
1540
1593
|
}
|
|
1541
|
-
const activeErr = getLastActiveChat(sessionId) ??
|
|
1594
|
+
const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1542
1595
|
if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
|
|
1543
1596
|
console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
|
|
1544
1597
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
|
|
1545
|
-
} else {
|
|
1546
|
-
for (const cid of
|
|
1598
|
+
} else {
|
|
1599
|
+
for (const cid of finalizationChatIds) {
|
|
1547
1600
|
const finfo = sessionInfoMap.get(cid);
|
|
1548
1601
|
await recordSessionRegistry({
|
|
1549
1602
|
chatId: cid,
|
|
@@ -1555,7 +1608,7 @@ export async function runAgentSession(
|
|
|
1555
1608
|
running: false,
|
|
1556
1609
|
});
|
|
1557
1610
|
}
|
|
1558
|
-
const active2 = getLastActiveChat(sessionId) ??
|
|
1611
|
+
const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1559
1612
|
if (active2) {
|
|
1560
1613
|
const terminalState = await readStreamState(sessionId);
|
|
1561
1614
|
if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|