chatccc 0.2.211 → 0.2.212
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/README.md +5 -3
- package/package.json +1 -1
- package/src/__tests__/claude-adapter.test.ts +1 -0
- package/src/__tests__/codex-adapter.test.ts +29 -19
- package/src/__tests__/codex-raw-stream-log.test.ts +11 -5
- package/src/__tests__/cursor-adapter.test.ts +12 -9
- package/src/__tests__/session.test.ts +139 -0
- package/src/adapters/codex-adapter.ts +12 -2
- package/src/session-chat-binding.ts +3 -0
- package/src/session.ts +71 -2
package/README.md
CHANGED
|
@@ -332,9 +332,11 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
332
332
|
|
|
333
333
|
**飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new codex`,机器人会另外创建会话群。
|
|
334
334
|
|
|
335
|
-
**微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
**微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
|
|
336
|
+
|
|
337
|
+
**会话停滞保护:** 当 Agent 连续 3 分钟处于“正在生成回复”且回复字符数没有变化、同时尚未报告权威终态时,ChatCCC 会结束旧 CLI,并优先补发一次“完成了吗?如果没完成继续”;恢复轮再次发生相同停滞时不再递归续跑。Codex 只有 `turn.completed` 才算权威终态,阶段性的 `agent_message` 不算;任一 Agent 报告权威终态后若输出流仍超过 10 秒未关闭,ChatCCC 会强制清理该 CLI 并按正常完成收尾,不会重复询问 Agent。
|
|
338
|
+
|
|
339
|
+
## 可用指令
|
|
338
340
|
|
|
339
341
|
| 指令 | 作用 |
|
|
340
342
|
| --- | --- |
|
package/package.json
CHANGED
|
@@ -52,6 +52,7 @@ describe("normalizeSdkMessage", () => {
|
|
|
52
52
|
expect(result).not.toBeNull();
|
|
53
53
|
expect(result!.type).toBe("assistant");
|
|
54
54
|
expect(result!.blocks).toEqual([{ type: "text", text: "Hello world" }]);
|
|
55
|
+
expect(result!.isFinalResponse).toBeUndefined();
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
it("normalizes assistant message with thinking block", () => {
|
|
@@ -56,15 +56,15 @@ function createInMemoryMetaStore(
|
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
57
57
|
|
|
58
58
|
describe("normalizeCodexMessage", () => {
|
|
59
|
-
it("normalizes agent_message into assistant text block", () => {
|
|
60
|
-
const result = normalizeCodexMessage({
|
|
61
|
-
type: "item.completed",
|
|
62
|
-
item: { id: "item_0", type: "agent_message", text: "hello" },
|
|
59
|
+
it("normalizes agent_message into assistant text block", () => {
|
|
60
|
+
const result = normalizeCodexMessage({
|
|
61
|
+
type: "item.completed",
|
|
62
|
+
item: { id: "item_0", type: "agent_message", text: "hello" },
|
|
63
63
|
});
|
|
64
64
|
expect(result).not.toBeNull();
|
|
65
65
|
expect(result!.type).toBe("assistant");
|
|
66
66
|
expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
|
|
67
|
-
expect(result!.isFinalResponse).
|
|
67
|
+
expect(result!.isFinalResponse).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
it("normalizes command_execution start into tool_use block", () => {
|
|
@@ -143,13 +143,17 @@ describe("normalizeCodexMessage", () => {
|
|
|
143
143
|
expect(normalizeCodexMessage({ type: "turn.started" })).toBeNull();
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("
|
|
147
|
-
expect(
|
|
148
|
-
normalizeCodexMessage({
|
|
149
|
-
type: "turn.completed",
|
|
150
|
-
usage: { input_tokens: 100, output_tokens: 50 },
|
|
151
|
-
}),
|
|
152
|
-
).
|
|
146
|
+
it("marks turn.completed as the authoritative final response", () => {
|
|
147
|
+
expect(
|
|
148
|
+
normalizeCodexMessage({
|
|
149
|
+
type: "turn.completed",
|
|
150
|
+
usage: { input_tokens: 100, output_tokens: 50 },
|
|
151
|
+
}),
|
|
152
|
+
).toEqual({
|
|
153
|
+
type: "assistant",
|
|
154
|
+
blocks: [],
|
|
155
|
+
isFinalResponse: true,
|
|
156
|
+
});
|
|
153
157
|
});
|
|
154
158
|
|
|
155
159
|
it("returns null for unknown event types", () => {
|
|
@@ -225,7 +229,7 @@ describe("Codex stream fixtures", () => {
|
|
|
225
229
|
expect(state.accumulatedContent).toContain("tool_test");
|
|
226
230
|
});
|
|
227
231
|
|
|
228
|
-
it("with tool:
|
|
232
|
+
it("with tool: 普通输出不标终态,只有 turn.completed 标记终态", () => {
|
|
229
233
|
const lines = readFixture("codex_with_tool.jsonl");
|
|
230
234
|
const messages: UnifiedStreamMessage[] = [];
|
|
231
235
|
for (const raw of lines) {
|
|
@@ -235,12 +239,18 @@ describe("Codex stream fixtures", () => {
|
|
|
235
239
|
if (normalized) messages.push(normalized);
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
// 应有: tool_use + tool_result + text =
|
|
239
|
-
expect(messages.length).toBe(
|
|
240
|
-
expect(messages[0].blocks[0].type).toBe("tool_use");
|
|
241
|
-
expect(messages[1].blocks[0].type).toBe("tool_result");
|
|
242
|
-
expect(messages[2].blocks[0].type).toBe("text");
|
|
243
|
-
|
|
242
|
+
// 应有: tool_use + tool_result + text + turn.completed = 4 条消息
|
|
243
|
+
expect(messages.length).toBe(4);
|
|
244
|
+
expect(messages[0].blocks[0].type).toBe("tool_use");
|
|
245
|
+
expect(messages[1].blocks[0].type).toBe("tool_result");
|
|
246
|
+
expect(messages[2].blocks[0].type).toBe("text");
|
|
247
|
+
expect(messages[2].isFinalResponse).toBeUndefined();
|
|
248
|
+
expect(messages[3]).toEqual({
|
|
249
|
+
type: "assistant",
|
|
250
|
+
blocks: [],
|
|
251
|
+
isFinalResponse: true,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
244
254
|
});
|
|
245
255
|
|
|
246
256
|
// ---------------------------------------------------------------------------
|
|
@@ -136,11 +136,17 @@ describe("Codex raw stream logs", () => {
|
|
|
136
136
|
expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, lines[1]);
|
|
137
137
|
expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(3, lines[2]);
|
|
138
138
|
expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: false });
|
|
139
|
-
expect(events).
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
expect(events).toEqual([
|
|
140
|
+
{
|
|
141
|
+
type: "assistant",
|
|
142
|
+
blocks: [{ type: "text", text: "hello" }],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: "assistant",
|
|
146
|
+
blocks: [],
|
|
147
|
+
isFinalResponse: true,
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
144
150
|
});
|
|
145
151
|
|
|
146
152
|
it("fails the turn and kills the process tree when bad JSON is followed by idle stdout", async () => {
|
|
@@ -445,9 +445,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
|
|
|
445
445
|
type: "assistant",
|
|
446
446
|
message: { role: "assistant", content: [{ type: "text", text: "你" }] },
|
|
447
447
|
timestamp_ms: 1778411927583,
|
|
448
|
-
});
|
|
449
|
-
expect(result).not.toBeNull();
|
|
450
|
-
expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
|
|
448
|
+
});
|
|
449
|
+
expect(result).not.toBeNull();
|
|
450
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
|
|
451
|
+
expect(result!.isFinalResponse).toBeUndefined();
|
|
451
452
|
});
|
|
452
453
|
|
|
453
454
|
it("Buffered flush(has timestamp_ms, has model_call_id)→ text_final(覆盖,避免与 delta 重复累加)", () => {
|
|
@@ -461,9 +462,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
|
|
|
461
462
|
model_call_id: "mc-1",
|
|
462
463
|
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
463
464
|
expect(result).not.toBeNull();
|
|
464
|
-
expect(result!.blocks).toEqual([
|
|
465
|
-
{ type: "text_final", text: "完整快照(与 delta 累计相同)" },
|
|
466
|
-
]);
|
|
465
|
+
expect(result!.blocks).toEqual([
|
|
466
|
+
{ type: "text_final", text: "完整快照(与 delta 累计相同)" },
|
|
467
|
+
]);
|
|
468
|
+
expect(result!.isFinalResponse).toBeUndefined();
|
|
467
469
|
});
|
|
468
470
|
|
|
469
471
|
it("Final flush(no timestamp_ms)→ text_final(覆盖)", () => {
|
|
@@ -475,9 +477,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
|
|
|
475
477
|
},
|
|
476
478
|
});
|
|
477
479
|
expect(result).not.toBeNull();
|
|
478
|
-
expect(result!.blocks).toEqual([
|
|
479
|
-
{ type: "text_final", text: "你上一题问的是 1+2=?" },
|
|
480
|
-
]);
|
|
480
|
+
expect(result!.blocks).toEqual([
|
|
481
|
+
{ type: "text_final", text: "你上一题问的是 1+2=?" },
|
|
482
|
+
]);
|
|
483
|
+
expect(result!.isFinalResponse).toBeUndefined();
|
|
481
484
|
});
|
|
482
485
|
});
|
|
483
486
|
|
|
@@ -122,6 +122,8 @@ import {
|
|
|
122
122
|
_resetResponseStallTimeoutForTest,
|
|
123
123
|
_setResponseStallCheckIntervalForTest,
|
|
124
124
|
_resetResponseStallCheckIntervalForTest,
|
|
125
|
+
_setFinalResponseCloseTimeoutForTest,
|
|
126
|
+
_resetFinalResponseCloseTimeoutForTest,
|
|
125
127
|
setSessionEffortOverride,
|
|
126
128
|
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
127
129
|
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
@@ -371,6 +373,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
371
373
|
_resetProcessMonitorIntervalForTest();
|
|
372
374
|
_resetResponseStallTimeoutForTest();
|
|
373
375
|
_resetResponseStallCheckIntervalForTest();
|
|
376
|
+
_resetFinalResponseCloseTimeoutForTest();
|
|
374
377
|
resetBindingState();
|
|
375
378
|
vi.useRealTimers();
|
|
376
379
|
});
|
|
@@ -692,6 +695,7 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
692
695
|
_resetProcessAliveForTest();
|
|
693
696
|
_resetResponseStallTimeoutForTest();
|
|
694
697
|
_resetResponseStallCheckIntervalForTest();
|
|
698
|
+
_resetFinalResponseCloseTimeoutForTest();
|
|
695
699
|
resetBindingState();
|
|
696
700
|
vi.useRealTimers();
|
|
697
701
|
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
@@ -919,6 +923,141 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
919
923
|
);
|
|
920
924
|
});
|
|
921
925
|
|
|
926
|
+
it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
|
|
927
|
+
vi.setSystemTime(0);
|
|
928
|
+
_setResponseStallTimeoutForTest(100);
|
|
929
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
930
|
+
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
931
|
+
_setProcessAliveForTest(() => true);
|
|
932
|
+
|
|
933
|
+
const platform = mockPlatform("feishu");
|
|
934
|
+
setSessionPlatform(platform);
|
|
935
|
+
bindChatToSession("sid-final-close", "chat-final-close");
|
|
936
|
+
recordLastActiveChat("sid-final-close", "chat-final-close");
|
|
937
|
+
|
|
938
|
+
const closeSession = vi.fn();
|
|
939
|
+
const adapter: ToolAdapter = {
|
|
940
|
+
displayName: "Any Agent",
|
|
941
|
+
sessionDescPrefix: "Agent Session:",
|
|
942
|
+
createSession: async () => ({ sessionId: "sid-final-close" }),
|
|
943
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
944
|
+
closeSession: async () => {},
|
|
945
|
+
prompt: async function* (
|
|
946
|
+
_sid: string,
|
|
947
|
+
_text: string,
|
|
948
|
+
_cwd: string,
|
|
949
|
+
signal?: AbortSignal,
|
|
950
|
+
options?: ToolPromptOptions,
|
|
951
|
+
) {
|
|
952
|
+
options?.onSessionCreated?.(closeSession);
|
|
953
|
+
options?.onProcessStart?.({ pid: 7171 });
|
|
954
|
+
yield {
|
|
955
|
+
type: "assistant",
|
|
956
|
+
blocks: [{ type: "text", text: "authoritative answer" }],
|
|
957
|
+
};
|
|
958
|
+
yield {
|
|
959
|
+
type: "assistant",
|
|
960
|
+
blocks: [],
|
|
961
|
+
isFinalResponse: true,
|
|
962
|
+
};
|
|
963
|
+
await new Promise<void>((resolve) => {
|
|
964
|
+
if (signal?.aborted) {
|
|
965
|
+
resolve();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
969
|
+
});
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
973
|
+
|
|
974
|
+
const run = runAgentSession(
|
|
975
|
+
"sid-final-close",
|
|
976
|
+
"prompt",
|
|
977
|
+
platform,
|
|
978
|
+
"chat-final-close",
|
|
979
|
+
0,
|
|
980
|
+
"claude",
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
await vi.waitFor(() => {
|
|
984
|
+
expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
|
|
985
|
+
});
|
|
986
|
+
await vi.advanceTimersByTimeAsync(900);
|
|
987
|
+
expect(activePrompts.has("sid-final-close")).toBe(true);
|
|
988
|
+
|
|
989
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
990
|
+
await run;
|
|
991
|
+
|
|
992
|
+
expect(closeSession).toHaveBeenCalledTimes(1);
|
|
993
|
+
expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
|
|
994
|
+
expect(mockStreamStates.get("sid-final-close")).toMatchObject({
|
|
995
|
+
status: "done",
|
|
996
|
+
finalReply: "authoritative answer",
|
|
997
|
+
});
|
|
998
|
+
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
999
|
+
"chat-final-close",
|
|
1000
|
+
expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
|
|
1001
|
+
);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("cancels the final-response close guard when the stream exits normally", async () => {
|
|
1005
|
+
vi.setSystemTime(0);
|
|
1006
|
+
_setFinalResponseCloseTimeoutForTest(1_000);
|
|
1007
|
+
_setProcessAliveForTest(() => true);
|
|
1008
|
+
|
|
1009
|
+
const platform = mockPlatform("feishu");
|
|
1010
|
+
setSessionPlatform(platform);
|
|
1011
|
+
bindChatToSession("sid-final-normal", "chat-final-normal");
|
|
1012
|
+
recordLastActiveChat("sid-final-normal", "chat-final-normal");
|
|
1013
|
+
|
|
1014
|
+
const closeSession = vi.fn();
|
|
1015
|
+
const adapter: ToolAdapter = {
|
|
1016
|
+
displayName: "Any Agent",
|
|
1017
|
+
sessionDescPrefix: "Agent Session:",
|
|
1018
|
+
createSession: async () => ({ sessionId: "sid-final-normal" }),
|
|
1019
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
1020
|
+
closeSession: async () => {},
|
|
1021
|
+
prompt: async function* (
|
|
1022
|
+
_sid: string,
|
|
1023
|
+
_text: string,
|
|
1024
|
+
_cwd: string,
|
|
1025
|
+
_signal?: AbortSignal,
|
|
1026
|
+
options?: ToolPromptOptions,
|
|
1027
|
+
) {
|
|
1028
|
+
options?.onSessionCreated?.(closeSession);
|
|
1029
|
+
options?.onProcessStart?.({ pid: 7272 });
|
|
1030
|
+
yield {
|
|
1031
|
+
type: "assistant",
|
|
1032
|
+
blocks: [{ type: "text", text: "normal answer" }],
|
|
1033
|
+
};
|
|
1034
|
+
yield {
|
|
1035
|
+
type: "assistant",
|
|
1036
|
+
blocks: [],
|
|
1037
|
+
isFinalResponse: true,
|
|
1038
|
+
};
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
1042
|
+
|
|
1043
|
+
await runAgentSession(
|
|
1044
|
+
"sid-final-normal",
|
|
1045
|
+
"prompt",
|
|
1046
|
+
platform,
|
|
1047
|
+
"chat-final-normal",
|
|
1048
|
+
0,
|
|
1049
|
+
"claude",
|
|
1050
|
+
);
|
|
1051
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
1052
|
+
|
|
1053
|
+
expect(closeSession).not.toHaveBeenCalled();
|
|
1054
|
+
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
|
1055
|
+
expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
|
|
1056
|
+
status: "done",
|
|
1057
|
+
finalReply: "normal answer",
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
922
1061
|
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
923
1062
|
vi.setSystemTime(0);
|
|
924
1063
|
_setResponseStallTimeoutForTest(100);
|
|
@@ -130,7 +130,8 @@ interface CodexEvent {
|
|
|
130
130
|
export function normalizeCodexMessage(
|
|
131
131
|
msg: CodexEvent,
|
|
132
132
|
): UnifiedStreamMessage | null {
|
|
133
|
-
// agent_message
|
|
133
|
+
// agent_message 只是 Codex 的一条阶段性文本 item。即使内容看起来像完整答复,
|
|
134
|
+
// 后面仍可能继续发出工具调用,因此不能用它关闭 response-stall watchdog。
|
|
134
135
|
if (
|
|
135
136
|
msg.type === "item.completed" &&
|
|
136
137
|
msg.item?.type === "agent_message" &&
|
|
@@ -139,6 +140,15 @@ export function normalizeCodexMessage(
|
|
|
139
140
|
return {
|
|
140
141
|
type: "assistant",
|
|
141
142
|
blocks: [{ type: "text", text: msg.item.text }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// turn.completed 是 Codex 对整轮完成的权威确认。正文已经由之前的
|
|
147
|
+
// agent_message 累计,这里只发送空终态信号,避免重复追加最终文本。
|
|
148
|
+
if (msg.type === "turn.completed") {
|
|
149
|
+
return {
|
|
150
|
+
type: "assistant",
|
|
151
|
+
blocks: [],
|
|
142
152
|
isFinalResponse: true,
|
|
143
153
|
};
|
|
144
154
|
}
|
|
@@ -181,7 +191,7 @@ export function normalizeCodexMessage(
|
|
|
181
191
|
};
|
|
182
192
|
}
|
|
183
193
|
|
|
184
|
-
// thread.started / turn.started
|
|
194
|
+
// thread.started / turn.started → 不映射为用户可见消息
|
|
185
195
|
return null;
|
|
186
196
|
}
|
|
187
197
|
|
|
@@ -111,6 +111,8 @@ export interface ActivePrompt {
|
|
|
111
111
|
processPid?: number;
|
|
112
112
|
processMonitor?: ReturnType<typeof setInterval>;
|
|
113
113
|
responseStallMonitor?: ReturnType<typeof setInterval>;
|
|
114
|
+
/** Grace timer that force-closes a stream which stays open after its authoritative final event. */
|
|
115
|
+
finalResponseCloseTimer?: ReturnType<typeof setTimeout>;
|
|
114
116
|
/** Character-count progress observed only while the activity is "responding". */
|
|
115
117
|
responseProgress?: ResponseProgressObservation;
|
|
116
118
|
/** Set before a response-stall auto-end begins so competing monitors cannot win the race. */
|
|
@@ -275,6 +277,7 @@ export function resetBindingState(): void {
|
|
|
275
277
|
for (const prompt of activePrompts.values()) {
|
|
276
278
|
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
277
279
|
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
280
|
+
if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
|
|
278
281
|
}
|
|
279
282
|
activePrompts.clear();
|
|
280
283
|
finalizingSessions.clear();
|
package/src/session.ts
CHANGED
|
@@ -165,6 +165,7 @@ function platformForChat(chatId: string): PlatformAdapter | null {
|
|
|
165
165
|
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
|
+
const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
|
|
168
169
|
export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
|
|
169
170
|
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
170
171
|
`检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
|
|
@@ -174,6 +175,7 @@ const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
|
|
|
174
175
|
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
175
176
|
let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
176
177
|
let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
178
|
+
let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
|
|
177
179
|
let isProcessAliveImpl = (pid: number): boolean => {
|
|
178
180
|
try {
|
|
179
181
|
process.kill(pid, 0);
|
|
@@ -221,7 +223,15 @@ export function _setResponseStallCheckIntervalForTest(ms: number): void {
|
|
|
221
223
|
export function _resetResponseStallCheckIntervalForTest(): void {
|
|
222
224
|
responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
223
225
|
}
|
|
224
|
-
|
|
226
|
+
|
|
227
|
+
export function _setFinalResponseCloseTimeoutForTest(ms: number): void {
|
|
228
|
+
finalResponseCloseTimeoutMs = ms;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function _resetFinalResponseCloseTimeoutForTest(): void {
|
|
232
|
+
finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
|
|
233
|
+
}
|
|
234
|
+
|
|
225
235
|
function clearPromptProcessMonitor(sessionId: string): void {
|
|
226
236
|
const prompt = activePrompts.get(sessionId);
|
|
227
237
|
if (!prompt?.processMonitor) return;
|
|
@@ -236,6 +246,59 @@ function clearPromptResponseStallMonitor(sessionId: string): void {
|
|
|
236
246
|
prompt.responseStallMonitor = undefined;
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
function clearPromptFinalResponseCloseTimer(sessionId: string): void {
|
|
250
|
+
const prompt = activePrompts.get(sessionId);
|
|
251
|
+
if (!prompt?.finalResponseCloseTimer) return;
|
|
252
|
+
clearTimeout(prompt.finalResponseCloseTimer);
|
|
253
|
+
prompt.finalResponseCloseTimer = undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
|
|
258
|
+
* 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
|
|
259
|
+
* 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
|
|
260
|
+
*/
|
|
261
|
+
function scheduleFinalResponseCloseGuard(
|
|
262
|
+
sessionId: string,
|
|
263
|
+
runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
|
|
264
|
+
): void {
|
|
265
|
+
if (runningPrompt.finalResponseCloseTimer) return;
|
|
266
|
+
|
|
267
|
+
const timeoutMs = finalResponseCloseTimeoutMs;
|
|
268
|
+
const handle = setTimeout(() => {
|
|
269
|
+
const current = activePrompts.get(sessionId);
|
|
270
|
+
if (
|
|
271
|
+
!current
|
|
272
|
+
|| current !== runningPrompt
|
|
273
|
+
|| !current.finalResponseObserved
|
|
274
|
+
|| current.stopped
|
|
275
|
+
|| current.abnormalExit
|
|
276
|
+
|| current.resourceStuck
|
|
277
|
+
|| current.autoEnded
|
|
278
|
+
) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
current.finalResponseCloseTimer = undefined;
|
|
283
|
+
clearPromptProcessMonitor(sessionId);
|
|
284
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
285
|
+
try {
|
|
286
|
+
current.closeSession?.();
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.warn(
|
|
289
|
+
`[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${(err as Error).message}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
current.controller.abort();
|
|
293
|
+
void killProcessTree(current.processPid);
|
|
294
|
+
console.warn(
|
|
295
|
+
`[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`,
|
|
296
|
+
);
|
|
297
|
+
}, timeoutMs);
|
|
298
|
+
handle.unref?.();
|
|
299
|
+
runningPrompt.finalResponseCloseTimer = handle;
|
|
300
|
+
}
|
|
301
|
+
|
|
239
302
|
function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
|
|
240
303
|
title: string;
|
|
241
304
|
template?: string;
|
|
@@ -397,6 +460,7 @@ export function resetState(): void {
|
|
|
397
460
|
for (const prompt of activePrompts.values()) {
|
|
398
461
|
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
399
462
|
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
463
|
+
if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
|
|
400
464
|
}
|
|
401
465
|
activePrompts.clear();
|
|
402
466
|
displayCards.clear();
|
|
@@ -1371,7 +1435,10 @@ export async function runAgentSession(
|
|
|
1371
1435
|
if (prompt && prompt === runningPrompt) {
|
|
1372
1436
|
// 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
|
|
1373
1437
|
// 最终事件后仍把本轮判为停滞。
|
|
1374
|
-
prompt.finalResponseObserved
|
|
1438
|
+
if (!prompt.finalResponseObserved) {
|
|
1439
|
+
prompt.finalResponseObserved = true;
|
|
1440
|
+
scheduleFinalResponseCloseGuard(sessionId, prompt);
|
|
1441
|
+
}
|
|
1375
1442
|
}
|
|
1376
1443
|
}
|
|
1377
1444
|
|
|
@@ -1443,6 +1510,7 @@ export async function runAgentSession(
|
|
|
1443
1510
|
const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
|
|
1444
1511
|
clearPromptResponseStallMonitor(sessionId);
|
|
1445
1512
|
clearPromptProcessMonitor(sessionId);
|
|
1513
|
+
clearPromptFinalResponseCloseTimer(sessionId);
|
|
1446
1514
|
markSessionFinalizing(sessionId);
|
|
1447
1515
|
activePrompts.delete(sessionId);
|
|
1448
1516
|
|
|
@@ -2080,6 +2148,7 @@ export function stopSession(sessionId: string): boolean {
|
|
|
2080
2148
|
prompt.stopped = true;
|
|
2081
2149
|
clearPromptResponseStallMonitor(sessionId);
|
|
2082
2150
|
clearPromptProcessMonitor(sessionId);
|
|
2151
|
+
clearPromptFinalResponseCloseTimer(sessionId);
|
|
2083
2152
|
cancelQueuedMessage(sessionId);
|
|
2084
2153
|
try {
|
|
2085
2154
|
prompt.closeSession?.();
|