chatccc 0.2.210 → 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__/ccc-adapter.test.ts +2 -0
- package/src/__tests__/claude-adapter.test.ts +10 -2
- package/src/__tests__/codex-adapter.test.ts +33 -22
- package/src/__tests__/codex-raw-stream-log.test.ts +11 -4
- package/src/__tests__/cursor-adapter.test.ts +17 -13
- package/src/__tests__/orchestrator.test.ts +7 -1
- package/src/__tests__/session.test.ts +292 -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 +13 -2
- package/src/adapters/cursor-adapter.ts +1 -0
- package/src/orchestrator.ts +41 -5
- package/src/session-chat-binding.ts +5 -0
- package/src/session.ts +155 -33
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
|
@@ -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
|
});
|
|
@@ -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", () => {
|
|
@@ -269,10 +270,17 @@ describe("normalizeSdkMessage", () => {
|
|
|
269
270
|
expect(result!.blocks[0]).toEqual({ type: "text", text: "still here" });
|
|
270
271
|
});
|
|
271
272
|
|
|
272
|
-
it("
|
|
273
|
+
it("marks a successful result event as an authoritative final response", () => {
|
|
273
274
|
expect(
|
|
274
275
|
normalizeSdkMessage({ type: "result", subtype: "success" }),
|
|
275
|
-
).
|
|
276
|
+
).toEqual({
|
|
277
|
+
type: "assistant",
|
|
278
|
+
blocks: [],
|
|
279
|
+
isFinalResponse: true,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns null for other non-assistant/non-user/non-system messages", () => {
|
|
276
284
|
expect(
|
|
277
285
|
normalizeSdkMessage({ type: "stream_event" }),
|
|
278
286
|
).toBeNull();
|
|
@@ -56,15 +56,16 @@ 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
|
-
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).toBeUndefined();
|
|
68
|
+
});
|
|
68
69
|
|
|
69
70
|
it("normalizes command_execution start into tool_use block", () => {
|
|
70
71
|
const result = normalizeCodexMessage({
|
|
@@ -142,13 +143,17 @@ describe("normalizeCodexMessage", () => {
|
|
|
142
143
|
expect(normalizeCodexMessage({ type: "turn.started" })).toBeNull();
|
|
143
144
|
});
|
|
144
145
|
|
|
145
|
-
it("
|
|
146
|
-
expect(
|
|
147
|
-
normalizeCodexMessage({
|
|
148
|
-
type: "turn.completed",
|
|
149
|
-
usage: { input_tokens: 100, output_tokens: 50 },
|
|
150
|
-
}),
|
|
151
|
-
).
|
|
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
|
+
});
|
|
152
157
|
});
|
|
153
158
|
|
|
154
159
|
it("returns null for unknown event types", () => {
|
|
@@ -224,7 +229,7 @@ describe("Codex stream fixtures", () => {
|
|
|
224
229
|
expect(state.accumulatedContent).toContain("tool_test");
|
|
225
230
|
});
|
|
226
231
|
|
|
227
|
-
it("with tool:
|
|
232
|
+
it("with tool: 普通输出不标终态,只有 turn.completed 标记终态", () => {
|
|
228
233
|
const lines = readFixture("codex_with_tool.jsonl");
|
|
229
234
|
const messages: UnifiedStreamMessage[] = [];
|
|
230
235
|
for (const raw of lines) {
|
|
@@ -234,12 +239,18 @@ describe("Codex stream fixtures", () => {
|
|
|
234
239
|
if (normalized) messages.push(normalized);
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
// 应有: tool_use + tool_result + text =
|
|
238
|
-
expect(messages.length).toBe(
|
|
239
|
-
expect(messages[0].blocks[0].type).toBe("tool_use");
|
|
240
|
-
expect(messages[1].blocks[0].type).toBe("tool_result");
|
|
241
|
-
expect(messages[2].blocks[0].type).toBe("text");
|
|
242
|
-
|
|
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
|
+
});
|
|
243
254
|
});
|
|
244
255
|
|
|
245
256
|
// ---------------------------------------------------------------------------
|
|
@@ -136,10 +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
|
-
|
|
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
|
+
]);
|
|
143
150
|
});
|
|
144
151
|
|
|
145
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
|
|
|
@@ -490,10 +493,11 @@ describe("normalizeCursorMessage - result 消息(官方权威最终文本)",
|
|
|
490
493
|
});
|
|
491
494
|
expect(result).not.toBeNull();
|
|
492
495
|
expect(result!.type).toBe("assistant");
|
|
493
|
-
expect(result!.blocks).toEqual([
|
|
494
|
-
{ type: "text_final", text: "权威最终文本" },
|
|
495
|
-
]);
|
|
496
|
-
|
|
496
|
+
expect(result!.blocks).toEqual([
|
|
497
|
+
{ type: "text_final", text: "权威最终文本" },
|
|
498
|
+
]);
|
|
499
|
+
expect(result!.isFinalResponse).toBe(true);
|
|
500
|
+
});
|
|
497
501
|
|
|
498
502
|
it("result 消息没有 result 字段时返回 null(无可用文本)", () => {
|
|
499
503
|
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 () => {
|
|
@@ -122,7 +122,11 @@ import {
|
|
|
122
122
|
_resetResponseStallTimeoutForTest,
|
|
123
123
|
_setResponseStallCheckIntervalForTest,
|
|
124
124
|
_resetResponseStallCheckIntervalForTest,
|
|
125
|
+
_setFinalResponseCloseTimeoutForTest,
|
|
126
|
+
_resetFinalResponseCloseTimeoutForTest,
|
|
125
127
|
setSessionEffortOverride,
|
|
128
|
+
RESPONSE_STALL_RECOVERY_PROMPT,
|
|
129
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
126
130
|
} from "../session.ts";
|
|
127
131
|
import {
|
|
128
132
|
activePrompts,
|
|
@@ -369,6 +373,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
369
373
|
_resetProcessMonitorIntervalForTest();
|
|
370
374
|
_resetResponseStallTimeoutForTest();
|
|
371
375
|
_resetResponseStallCheckIntervalForTest();
|
|
376
|
+
_resetFinalResponseCloseTimeoutForTest();
|
|
372
377
|
resetBindingState();
|
|
373
378
|
vi.useRealTimers();
|
|
374
379
|
});
|
|
@@ -670,7 +675,7 @@ describe("runAgentSession process monitor", () => {
|
|
|
670
675
|
|
|
671
676
|
describe("runAgentSession response stall watchdog", () => {
|
|
672
677
|
let tempDir = "";
|
|
673
|
-
const recoveryPrompt =
|
|
678
|
+
const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
|
|
674
679
|
|
|
675
680
|
beforeEach(async () => {
|
|
676
681
|
vi.useFakeTimers();
|
|
@@ -690,6 +695,7 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
690
695
|
_resetProcessAliveForTest();
|
|
691
696
|
_resetResponseStallTimeoutForTest();
|
|
692
697
|
_resetResponseStallCheckIntervalForTest();
|
|
698
|
+
_resetFinalResponseCloseTimeoutForTest();
|
|
693
699
|
resetBindingState();
|
|
694
700
|
vi.useRealTimers();
|
|
695
701
|
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
@@ -768,6 +774,290 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
768
774
|
});
|
|
769
775
|
});
|
|
770
776
|
|
|
777
|
+
it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
|
|
778
|
+
vi.setSystemTime(0);
|
|
779
|
+
_setResponseStallTimeoutForTest(100);
|
|
780
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
781
|
+
_setProcessAliveForTest(() => true);
|
|
782
|
+
|
|
783
|
+
const platform = mockPlatform("feishu");
|
|
784
|
+
setSessionPlatform(platform);
|
|
785
|
+
|
|
786
|
+
const receivedPrompts: string[] = [];
|
|
787
|
+
const adapter: ToolAdapter = {
|
|
788
|
+
displayName: "Any Agent",
|
|
789
|
+
sessionDescPrefix: "Agent Session:",
|
|
790
|
+
createSession: async () => ({ sessionId: "sid-binding-recovery" }),
|
|
791
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
792
|
+
closeSession: async () => {},
|
|
793
|
+
prompt: async function* (
|
|
794
|
+
_sid: string,
|
|
795
|
+
text: string,
|
|
796
|
+
_cwd: string,
|
|
797
|
+
signal?: AbortSignal,
|
|
798
|
+
options?: ToolPromptOptions,
|
|
799
|
+
) {
|
|
800
|
+
receivedPrompts.push(text);
|
|
801
|
+
options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
|
|
802
|
+
if (receivedPrompts.length === 1) {
|
|
803
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
804
|
+
await new Promise<void>((resolve) => {
|
|
805
|
+
if (signal?.aborted) {
|
|
806
|
+
resolve();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
810
|
+
});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
yield {
|
|
814
|
+
type: "assistant",
|
|
815
|
+
blocks: [{ type: "text", text: "recovery completed" }],
|
|
816
|
+
isFinalResponse: true,
|
|
817
|
+
};
|
|
818
|
+
},
|
|
819
|
+
};
|
|
820
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
821
|
+
|
|
822
|
+
const firstRun = runAgentSession(
|
|
823
|
+
"sid-binding-recovery",
|
|
824
|
+
"first prompt",
|
|
825
|
+
platform,
|
|
826
|
+
"chat-binding-recovery",
|
|
827
|
+
0,
|
|
828
|
+
"claude",
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
await vi.waitFor(() => {
|
|
832
|
+
expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
|
|
833
|
+
});
|
|
834
|
+
const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
|
|
835
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
836
|
+
await firstRun;
|
|
837
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
838
|
+
await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
|
|
839
|
+
await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
|
|
840
|
+
|
|
841
|
+
expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
|
|
842
|
+
expect(platform.cardCreate).toHaveBeenCalledTimes(2);
|
|
843
|
+
expect(platform.cardSend).toHaveBeenCalledTimes(2);
|
|
844
|
+
expect(platform.sendText).toHaveBeenCalledWith(
|
|
845
|
+
"chat-binding-recovery",
|
|
846
|
+
expect.stringContaining(recoveryPrompt),
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
const registry = JSON.parse(
|
|
850
|
+
await readFile(join(tempDir, "session-registry.json"), "utf8"),
|
|
851
|
+
) as Record<string, { running?: boolean }>;
|
|
852
|
+
expect(registry["chat-binding-recovery"]?.running).toBe(false);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("treats an authoritative final response that races timeout cleanup as completed", async () => {
|
|
856
|
+
vi.setSystemTime(0);
|
|
857
|
+
_setResponseStallTimeoutForTest(100);
|
|
858
|
+
_setResponseStallCheckIntervalForTest(10);
|
|
859
|
+
_setProcessAliveForTest(() => true);
|
|
860
|
+
|
|
861
|
+
const platform = mockPlatform("feishu");
|
|
862
|
+
setSessionPlatform(platform);
|
|
863
|
+
bindChatToSession("sid-final-race", "chat-final-race");
|
|
864
|
+
recordLastActiveChat("sid-final-race", "chat-final-race");
|
|
865
|
+
|
|
866
|
+
const receivedPrompts: string[] = [];
|
|
867
|
+
const adapter: ToolAdapter = {
|
|
868
|
+
displayName: "Any Agent",
|
|
869
|
+
sessionDescPrefix: "Agent Session:",
|
|
870
|
+
createSession: async () => ({ sessionId: "sid-final-race" }),
|
|
871
|
+
getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
|
|
872
|
+
closeSession: async () => {},
|
|
873
|
+
prompt: async function* (
|
|
874
|
+
_sid: string,
|
|
875
|
+
text: string,
|
|
876
|
+
_cwd: string,
|
|
877
|
+
signal?: AbortSignal,
|
|
878
|
+
options?: ToolPromptOptions,
|
|
879
|
+
) {
|
|
880
|
+
receivedPrompts.push(text);
|
|
881
|
+
options?.onProcessStart?.({ pid: 5151 });
|
|
882
|
+
yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
|
|
883
|
+
await new Promise<void>((resolve) => {
|
|
884
|
+
if (signal?.aborted) {
|
|
885
|
+
resolve();
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
889
|
+
});
|
|
890
|
+
yield {
|
|
891
|
+
type: "assistant",
|
|
892
|
+
blocks: [{ type: "text", text: "completed at the timeout boundary" }],
|
|
893
|
+
isFinalResponse: true,
|
|
894
|
+
};
|
|
895
|
+
},
|
|
896
|
+
};
|
|
897
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
898
|
+
|
|
899
|
+
const run = runAgentSession(
|
|
900
|
+
"sid-final-race",
|
|
901
|
+
"prompt",
|
|
902
|
+
platform,
|
|
903
|
+
"chat-final-race",
|
|
904
|
+
0,
|
|
905
|
+
"claude",
|
|
906
|
+
);
|
|
907
|
+
await vi.waitFor(() => {
|
|
908
|
+
expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
|
|
909
|
+
});
|
|
910
|
+
const progress = activePrompts.get("sid-final-race")!.responseProgress!;
|
|
911
|
+
await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
|
|
912
|
+
await run;
|
|
913
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
914
|
+
|
|
915
|
+
expect(receivedPrompts).toHaveLength(1);
|
|
916
|
+
expect(mockStreamStates.get("sid-final-race")).toMatchObject({
|
|
917
|
+
status: "done",
|
|
918
|
+
finalReply: "completed at the timeout boundary",
|
|
919
|
+
});
|
|
920
|
+
expect(platform.sendText).not.toHaveBeenCalledWith(
|
|
921
|
+
"chat-final-race",
|
|
922
|
+
RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
|
|
923
|
+
);
|
|
924
|
+
});
|
|
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
|
+
|
|
771
1061
|
it("runs the reserved recovery prompt before an already queued user message", async () => {
|
|
772
1062
|
vi.setSystemTime(0);
|
|
773
1063
|
_setResponseStallTimeoutForTest(100);
|
|
@@ -842,7 +1132,7 @@ describe("runAgentSession response stall watchdog", () => {
|
|
|
842
1132
|
expect(isSessionRunning("sid-recovery-priority")).toBe(true);
|
|
843
1133
|
expect(platform.sendText).toHaveBeenCalledWith(
|
|
844
1134
|
"chat-recovery-priority",
|
|
845
|
-
|
|
1135
|
+
expect.stringContaining(recoveryPrompt),
|
|
846
1136
|
);
|
|
847
1137
|
|
|
848
1138
|
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
|
|
@@ -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" &&
|
|
@@ -142,6 +143,16 @@ export function normalizeCodexMessage(
|
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
// turn.completed 是 Codex 对整轮完成的权威确认。正文已经由之前的
|
|
147
|
+
// agent_message 累计,这里只发送空终态信号,避免重复追加最终文本。
|
|
148
|
+
if (msg.type === "turn.completed") {
|
|
149
|
+
return {
|
|
150
|
+
type: "assistant",
|
|
151
|
+
blocks: [],
|
|
152
|
+
isFinalResponse: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
// command_execution 工具调用开始
|
|
146
157
|
if (
|
|
147
158
|
msg.type === "item.started" &&
|
|
@@ -180,7 +191,7 @@ export function normalizeCodexMessage(
|
|
|
180
191
|
};
|
|
181
192
|
}
|
|
182
193
|
|
|
183
|
-
// thread.started / turn.started
|
|
194
|
+
// thread.started / turn.started → 不映射为用户可见消息
|
|
184
195
|
return null;
|
|
185
196
|
}
|
|
186
197
|
|
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",
|
|
@@ -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. */
|
|
@@ -123,6 +125,8 @@ export interface ActivePrompt {
|
|
|
123
125
|
resourceStuck?: boolean;
|
|
124
126
|
/** True only for the single internal continuation turn after a response stall. */
|
|
125
127
|
autoRecovery?: boolean;
|
|
128
|
+
/** Adapter observed an authoritative completed final-response event. */
|
|
129
|
+
finalResponseObserved?: boolean;
|
|
126
130
|
/** Adapter-provided callback to close the underlying SDK session / subprocess.
|
|
127
131
|
* Called by stop-stuck-loop before controller.abort() to terminate the CLI
|
|
128
132
|
* process immediately, rather than waiting for the async generator to unblock. */
|
|
@@ -273,6 +277,7 @@ export function resetBindingState(): void {
|
|
|
273
277
|
for (const prompt of activePrompts.values()) {
|
|
274
278
|
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
275
279
|
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
280
|
+
if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
|
|
276
281
|
}
|
|
277
282
|
activePrompts.clear();
|
|
278
283
|
finalizingSessions.clear();
|
package/src/session.ts
CHANGED
|
@@ -165,14 +165,17 @@ 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
|
-
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
170
|
+
export const RESPONSE_STALL_RECOVERY_NOTICE =
|
|
171
|
+
`检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
|
|
170
172
|
export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
|
|
171
173
|
"⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
|
|
172
174
|
const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
|
|
173
175
|
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
174
176
|
let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
175
177
|
let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
178
|
+
let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
|
|
176
179
|
let isProcessAliveImpl = (pid: number): boolean => {
|
|
177
180
|
try {
|
|
178
181
|
process.kill(pid, 0);
|
|
@@ -220,7 +223,15 @@ export function _setResponseStallCheckIntervalForTest(ms: number): void {
|
|
|
220
223
|
export function _resetResponseStallCheckIntervalForTest(): void {
|
|
221
224
|
responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
222
225
|
}
|
|
223
|
-
|
|
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
|
+
|
|
224
235
|
function clearPromptProcessMonitor(sessionId: string): void {
|
|
225
236
|
const prompt = activePrompts.get(sessionId);
|
|
226
237
|
if (!prompt?.processMonitor) return;
|
|
@@ -235,6 +246,59 @@ function clearPromptResponseStallMonitor(sessionId: string): void {
|
|
|
235
246
|
prompt.responseStallMonitor = undefined;
|
|
236
247
|
}
|
|
237
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
|
+
|
|
238
302
|
function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
|
|
239
303
|
title: string;
|
|
240
304
|
template?: string;
|
|
@@ -396,6 +460,7 @@ export function resetState(): void {
|
|
|
396
460
|
for (const prompt of activePrompts.values()) {
|
|
397
461
|
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
398
462
|
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
463
|
+
if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
|
|
399
464
|
}
|
|
400
465
|
activePrompts.clear();
|
|
401
466
|
displayCards.clear();
|
|
@@ -997,9 +1062,19 @@ export async function runAgentSession(
|
|
|
997
1062
|
traceId?: string,
|
|
998
1063
|
options: RunAgentSessionOptions = {},
|
|
999
1064
|
): Promise<void> {
|
|
1000
|
-
const tid = traceId ?? "";
|
|
1001
|
-
|
|
1002
|
-
//
|
|
1065
|
+
const tid = traceId ?? "";
|
|
1066
|
+
|
|
1067
|
+
// runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
|
|
1068
|
+
// prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
|
|
1069
|
+
// 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
|
|
1070
|
+
// 相同的卡片、状态和收尾行为。
|
|
1071
|
+
const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
|
|
1072
|
+
if (previousSessionId && previousSessionId !== sessionId) {
|
|
1073
|
+
unbindChatFromSession(previousSessionId, _chatId);
|
|
1074
|
+
}
|
|
1075
|
+
bindChatToSession(sessionId, _chatId);
|
|
1076
|
+
|
|
1077
|
+
// 记录用户最后发送消息的群(display loop 只推送到该群)
|
|
1003
1078
|
// 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
|
|
1004
1079
|
recordChatPlatform(_chatId, platform);
|
|
1005
1080
|
recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
|
|
@@ -1025,6 +1100,7 @@ export async function runAgentSession(
|
|
|
1025
1100
|
stopped: false,
|
|
1026
1101
|
startTime: now,
|
|
1027
1102
|
autoRecovery: options.autoRecovery === true,
|
|
1103
|
+
finalResponseObserved: false,
|
|
1028
1104
|
});
|
|
1029
1105
|
|
|
1030
1106
|
// 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
|
|
@@ -1269,6 +1345,7 @@ export async function runAgentSession(
|
|
|
1269
1345
|
|| current.abnormalExit
|
|
1270
1346
|
|| current.resourceStuck
|
|
1271
1347
|
|| current.autoEnded
|
|
1348
|
+
|| current.finalResponseObserved
|
|
1272
1349
|
|| activityTracker.activity.kind !== "responding"
|
|
1273
1350
|
|| !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
|
|
1274
1351
|
) {
|
|
@@ -1305,6 +1382,18 @@ export async function runAgentSession(
|
|
|
1305
1382
|
autoEndedAt,
|
|
1306
1383
|
});
|
|
1307
1384
|
|
|
1385
|
+
// 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
|
|
1386
|
+
// final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
|
|
1387
|
+
if (current.finalResponseObserved) {
|
|
1388
|
+
current.autoEnded = false;
|
|
1389
|
+
current.autoEndedAt = undefined;
|
|
1390
|
+
cancelAutoRecoveryReservation(sessionId);
|
|
1391
|
+
console.log(
|
|
1392
|
+
`[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
|
|
1393
|
+
);
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1308
1397
|
try {
|
|
1309
1398
|
current.closeSession?.();
|
|
1310
1399
|
} catch (err) {
|
|
@@ -1336,11 +1425,23 @@ export async function runAgentSession(
|
|
|
1336
1425
|
clearPromptProcessMonitor(sessionId);
|
|
1337
1426
|
if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
|
|
1338
1427
|
},
|
|
1339
|
-
onSessionCreated: (closeSession) => {
|
|
1340
|
-
const prompt = activePrompts.get(sessionId);
|
|
1341
|
-
if (prompt) prompt.closeSession = closeSession;
|
|
1342
|
-
},
|
|
1343
|
-
})) {
|
|
1428
|
+
onSessionCreated: (closeSession) => {
|
|
1429
|
+
const prompt = activePrompts.get(sessionId);
|
|
1430
|
+
if (prompt) prompt.closeSession = closeSession;
|
|
1431
|
+
},
|
|
1432
|
+
})) {
|
|
1433
|
+
if (unifiedMsg.isFinalResponse) {
|
|
1434
|
+
const prompt = activePrompts.get(sessionId);
|
|
1435
|
+
if (prompt && prompt === runningPrompt) {
|
|
1436
|
+
// 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
|
|
1437
|
+
// 最终事件后仍把本轮判为停滞。
|
|
1438
|
+
if (!prompt.finalResponseObserved) {
|
|
1439
|
+
prompt.finalResponseObserved = true;
|
|
1440
|
+
scheduleFinalResponseCloseGuard(sessionId, prompt);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1344
1445
|
let activityChanged = false;
|
|
1345
1446
|
for (const block of unifiedMsg.blocks) {
|
|
1346
1447
|
if (updateAgentActivity(activityTracker, block)) activityChanged = true;
|
|
@@ -1401,27 +1502,47 @@ export async function runAgentSession(
|
|
|
1401
1502
|
const wasStopped = prompt?.stopped ?? false;
|
|
1402
1503
|
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1403
1504
|
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1404
|
-
const
|
|
1505
|
+
const timeoutTriggered = prompt?.autoEnded ?? false;
|
|
1506
|
+
const completedAtTimeoutBoundary =
|
|
1507
|
+
timeoutTriggered && (prompt?.finalResponseObserved ?? false);
|
|
1508
|
+
const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
|
|
1405
1509
|
const wasAutoRecovery = prompt?.autoRecovery ?? false;
|
|
1406
|
-
const autoEndedAt = prompt?.autoEndedAt;
|
|
1510
|
+
const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
|
|
1407
1511
|
clearPromptResponseStallMonitor(sessionId);
|
|
1408
1512
|
clearPromptProcessMonitor(sessionId);
|
|
1513
|
+
clearPromptFinalResponseCloseTimer(sessionId);
|
|
1409
1514
|
markSessionFinalizing(sessionId);
|
|
1410
1515
|
activePrompts.delete(sessionId);
|
|
1411
1516
|
|
|
1412
1517
|
try {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1518
|
+
if (completedAtTimeoutBoundary) {
|
|
1519
|
+
// reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
|
|
1520
|
+
// abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
|
|
1521
|
+
cancelAutoRecoveryReservation(sessionId);
|
|
1522
|
+
console.log(
|
|
1523
|
+
`[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
// 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
|
|
1527
|
+
// 永久残留 running=true。
|
|
1528
|
+
const finalizationChatIds = [...new Set([
|
|
1529
|
+
...getChatsForSession(sessionId),
|
|
1530
|
+
_chatId,
|
|
1531
|
+
])];
|
|
1532
|
+
// 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
|
|
1533
|
+
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1534
|
+
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1535
|
+
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1536
|
+
const finalStatus = completedAtTimeoutBoundary
|
|
1537
|
+
? "done"
|
|
1538
|
+
: wasAutoEnded
|
|
1539
|
+
? "auto_ended"
|
|
1540
|
+
: (streamErrored || wasAbnormalExit || wasResourceStuck)
|
|
1541
|
+
? "error"
|
|
1542
|
+
: wasStopped
|
|
1543
|
+
? "stopped"
|
|
1544
|
+
: "done";
|
|
1545
|
+
const finalReply = pickFinalReply(state).trim();
|
|
1425
1546
|
|
|
1426
1547
|
// stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
|
|
1427
1548
|
// stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
|
|
@@ -1459,7 +1580,7 @@ export async function runAgentSession(
|
|
|
1459
1580
|
let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
|
|
1460
1581
|
|
|
1461
1582
|
if (wasStopped) {
|
|
1462
|
-
for (const cid of
|
|
1583
|
+
for (const cid of finalizationChatIds) {
|
|
1463
1584
|
const finfo = sessionInfoMap.get(cid);
|
|
1464
1585
|
await recordSessionRegistry({
|
|
1465
1586
|
chatId: cid,
|
|
@@ -1471,7 +1592,7 @@ export async function runAgentSession(
|
|
|
1471
1592
|
running: false,
|
|
1472
1593
|
});
|
|
1473
1594
|
}
|
|
1474
|
-
const active1 = getLastActiveChat(sessionId) ??
|
|
1595
|
+
const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1475
1596
|
if (active1) {
|
|
1476
1597
|
await platform.sendText(active1, "会话已停止。").catch(() => {});
|
|
1477
1598
|
platform.setChatAvatar(active1, tool, "idle").catch(() => {});
|
|
@@ -1479,7 +1600,7 @@ export async function runAgentSession(
|
|
|
1479
1600
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1480
1601
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1481
1602
|
} else if (wasAutoEnded) {
|
|
1482
|
-
for (const cid of
|
|
1603
|
+
for (const cid of finalizationChatIds) {
|
|
1483
1604
|
const finfo = sessionInfoMap.get(cid);
|
|
1484
1605
|
await recordSessionRegistry({
|
|
1485
1606
|
chatId: cid,
|
|
@@ -1491,7 +1612,7 @@ export async function runAgentSession(
|
|
|
1491
1612
|
running: false,
|
|
1492
1613
|
});
|
|
1493
1614
|
}
|
|
1494
|
-
const activeAutoEnded = getLastActiveChat(sessionId) ??
|
|
1615
|
+
const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1495
1616
|
if (activeAutoEnded) {
|
|
1496
1617
|
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1497
1618
|
const terminalState = await readStreamState(sessionId);
|
|
@@ -1526,7 +1647,7 @@ export async function runAgentSession(
|
|
|
1526
1647
|
console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
|
|
1527
1648
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
|
|
1528
1649
|
} else if (wasAbnormalExit) {
|
|
1529
|
-
for (const cid of
|
|
1650
|
+
for (const cid of finalizationChatIds) {
|
|
1530
1651
|
const finfo = sessionInfoMap.get(cid);
|
|
1531
1652
|
await recordSessionRegistry({
|
|
1532
1653
|
chatId: cid,
|
|
@@ -1538,12 +1659,12 @@ export async function runAgentSession(
|
|
|
1538
1659
|
running: false,
|
|
1539
1660
|
});
|
|
1540
1661
|
}
|
|
1541
|
-
const activeErr = getLastActiveChat(sessionId) ??
|
|
1662
|
+
const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1542
1663
|
if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
|
|
1543
1664
|
console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
|
|
1544
1665
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
|
|
1545
|
-
} else {
|
|
1546
|
-
for (const cid of
|
|
1666
|
+
} else {
|
|
1667
|
+
for (const cid of finalizationChatIds) {
|
|
1547
1668
|
const finfo = sessionInfoMap.get(cid);
|
|
1548
1669
|
await recordSessionRegistry({
|
|
1549
1670
|
chatId: cid,
|
|
@@ -1555,7 +1676,7 @@ export async function runAgentSession(
|
|
|
1555
1676
|
running: false,
|
|
1556
1677
|
});
|
|
1557
1678
|
}
|
|
1558
|
-
const active2 = getLastActiveChat(sessionId) ??
|
|
1679
|
+
const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
|
|
1559
1680
|
if (active2) {
|
|
1560
1681
|
const terminalState = await readStreamState(sessionId);
|
|
1561
1682
|
if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
@@ -2027,6 +2148,7 @@ export function stopSession(sessionId: string): boolean {
|
|
|
2027
2148
|
prompt.stopped = true;
|
|
2028
2149
|
clearPromptResponseStallMonitor(sessionId);
|
|
2029
2150
|
clearPromptProcessMonitor(sessionId);
|
|
2151
|
+
clearPromptFinalResponseCloseTimer(sessionId);
|
|
2030
2152
|
cancelQueuedMessage(sessionId);
|
|
2031
2153
|
try {
|
|
2032
2154
|
prompt.closeSession?.();
|