chatccc 0.2.205 → 0.2.207
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 +2 -2
- package/package.json +1 -1
- package/src/__tests__/cards.test.ts +3 -2
- package/src/__tests__/orchestrator.test.ts +144 -2
- package/src/__tests__/response-stall.test.ts +49 -0
- package/src/__tests__/session.test.ts +261 -27
- package/src/cards.ts +2 -2
- package/src/orchestrator.ts +178 -13
- package/src/response-stall.ts +28 -0
- package/src/session-chat-binding.ts +33 -12
- package/src/session.ts +276 -101
- package/src/stream-state.ts +5 -3
package/src/session.ts
CHANGED
|
@@ -36,9 +36,11 @@ import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
|
36
36
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
37
37
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
38
38
|
import { createCccAdapter } from "./adapters/ccc-adapter.ts";
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import
|
|
39
|
+
import { killProcessTree } from "./adapters/proc-tree-kill.ts";
|
|
40
|
+
import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.ts";
|
|
41
|
+
import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
|
|
42
|
+
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
43
|
+
import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
|
|
42
44
|
|
|
43
45
|
// 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
|
|
44
46
|
function compressWechatDisplayText(text: string): string {
|
|
@@ -70,10 +72,12 @@ import {
|
|
|
70
72
|
pickDisplayChat,
|
|
71
73
|
dequeueMessage,
|
|
72
74
|
consumeQueuedMessage,
|
|
73
|
-
cancelQueuedMessage,
|
|
74
|
-
setQueuePreservedChat,
|
|
75
|
-
consumeQueuePreservedChat,
|
|
76
|
-
|
|
75
|
+
cancelQueuedMessage,
|
|
76
|
+
setQueuePreservedChat,
|
|
77
|
+
consumeQueuePreservedChat,
|
|
78
|
+
markSessionFinalizing,
|
|
79
|
+
clearSessionFinalizing,
|
|
80
|
+
} from "./session-chat-binding.ts";
|
|
77
81
|
|
|
78
82
|
async function sendFinalReplyTextOnce(
|
|
79
83
|
platform: PlatformAdapter,
|
|
@@ -154,8 +158,12 @@ function platformForChat(chatId: string): PlatformAdapter | null {
|
|
|
154
158
|
return chatPlatformMap.get(chatId) ?? platformRef;
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
158
|
-
|
|
161
|
+
const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
|
|
162
|
+
const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
|
|
163
|
+
const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
|
|
164
|
+
let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
165
|
+
let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
166
|
+
let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
159
167
|
let isProcessAliveImpl = (pid: number): boolean => {
|
|
160
168
|
try {
|
|
161
169
|
process.kill(pid, 0);
|
|
@@ -184,29 +192,68 @@ export function _setProcessMonitorIntervalForTest(ms: number): void {
|
|
|
184
192
|
processMonitorIntervalMs = ms;
|
|
185
193
|
}
|
|
186
194
|
|
|
187
|
-
export function _resetProcessMonitorIntervalForTest(): void {
|
|
188
|
-
processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
189
|
-
}
|
|
195
|
+
export function _resetProcessMonitorIntervalForTest(): void {
|
|
196
|
+
processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function _setResponseStallTimeoutForTest(ms: number): void {
|
|
200
|
+
responseStallTimeoutMs = ms;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function _resetResponseStallTimeoutForTest(): void {
|
|
204
|
+
responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function _setResponseStallCheckIntervalForTest(ms: number): void {
|
|
208
|
+
responseStallCheckIntervalMs = ms;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function _resetResponseStallCheckIntervalForTest(): void {
|
|
212
|
+
responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
|
|
213
|
+
}
|
|
190
214
|
|
|
191
|
-
function clearPromptProcessMonitor(sessionId: string): void {
|
|
215
|
+
function clearPromptProcessMonitor(sessionId: string): void {
|
|
192
216
|
const prompt = activePrompts.get(sessionId);
|
|
193
217
|
if (!prompt?.processMonitor) return;
|
|
194
218
|
clearInterval(prompt.processMonitor);
|
|
195
219
|
prompt.processMonitor = undefined;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function clearPromptResponseStallMonitor(sessionId: string): void {
|
|
223
|
+
const prompt = activePrompts.get(sessionId);
|
|
224
|
+
if (!prompt?.responseStallMonitor) return;
|
|
225
|
+
clearInterval(prompt.responseStallMonitor);
|
|
226
|
+
prompt.responseStallMonitor = undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
|
|
230
|
+
title: string;
|
|
231
|
+
template?: string;
|
|
232
|
+
} {
|
|
233
|
+
if (status === "auto_ended") return { title: "已自动结束 · 3分钟无新内容", template: "orange" };
|
|
234
|
+
if (status === "stopped") return { title: "已停止", template: "red" };
|
|
235
|
+
if (status === "error") return { title: "异常结束", template: "red" };
|
|
236
|
+
return { title: "完成" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "auto_ended"): "done" | "stopped" {
|
|
240
|
+
return status === "stopped" || status === "error" || status === "auto_ended" ? "stopped" : "done";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatAutoEndedReply(finalReply: string): string {
|
|
244
|
+
const reason = "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。";
|
|
245
|
+
return finalReply
|
|
246
|
+
? `${reason}以下回复可能不完整。\n\n${finalReply}`
|
|
247
|
+
: `${reason}本轮没有可发送的回复内容。`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatTerminalReply(
|
|
251
|
+
status: "running" | "done" | "stopped" | "error" | "auto_ended",
|
|
252
|
+
finalReply: string,
|
|
253
|
+
): string | null {
|
|
254
|
+
if (status === "auto_ended") return formatAutoEndedReply(finalReply);
|
|
255
|
+
return finalReply || null;
|
|
256
|
+
}
|
|
210
257
|
|
|
211
258
|
function isCardKitSequenceConflict(err: unknown): boolean {
|
|
212
259
|
return err instanceof Error && err.message.includes("300317");
|
|
@@ -224,7 +271,7 @@ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): vo
|
|
|
224
271
|
clearPromptProcessMonitor(sessionId);
|
|
225
272
|
return;
|
|
226
273
|
}
|
|
227
|
-
if (current.stopped || current.abnormalExit || current.resourceStuck) return;
|
|
274
|
+
if (current.stopped || current.abnormalExit || current.resourceStuck || current.autoEnded) return;
|
|
228
275
|
if (isProcessAliveImpl(info.pid)) return;
|
|
229
276
|
|
|
230
277
|
current.abnormalExit = true;
|
|
@@ -337,9 +384,10 @@ export function resetState(): void {
|
|
|
337
384
|
processedMessages.clear();
|
|
338
385
|
lastMsgTimestamps.clear();
|
|
339
386
|
chatPlatformMap.clear();
|
|
340
|
-
for (const prompt of activePrompts.values()) {
|
|
341
|
-
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
342
|
-
|
|
387
|
+
for (const prompt of activePrompts.values()) {
|
|
388
|
+
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
389
|
+
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
390
|
+
}
|
|
343
391
|
activePrompts.clear();
|
|
344
392
|
displayCards.clear();
|
|
345
393
|
sessionModelOverrides.clear();
|
|
@@ -964,7 +1012,7 @@ export async function runAgentSession(
|
|
|
964
1012
|
const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
|
|
965
1013
|
if (data.sessionId !== sessionId) return;
|
|
966
1014
|
const prompt = activePrompts.get(sessionId);
|
|
967
|
-
if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck) return;
|
|
1015
|
+
if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck || prompt.autoEnded) return;
|
|
968
1016
|
prompt.resourceStuck = true;
|
|
969
1017
|
|
|
970
1018
|
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
@@ -1071,9 +1119,10 @@ export async function runAgentSession(
|
|
|
1071
1119
|
// 导致 finalReply 丢失、完成卡片空白。此处主动读取上一轮终端状态完成
|
|
1072
1120
|
// 卡片终结和回复发送,不依赖 display loop 时序,保证"先发完上一个回答
|
|
1073
1121
|
// 再开始缓存问题对应的任务"。
|
|
1074
|
-
const prevState = await readStreamState(sessionId);
|
|
1075
|
-
if (prevState && prevState.status !== "running") {
|
|
1076
|
-
const
|
|
1122
|
+
const prevState = await readStreamState(sessionId);
|
|
1123
|
+
if (prevState && prevState.status !== "running") {
|
|
1124
|
+
const prevTerminalReply = formatTerminalReply(prevState.status, prevState.finalReply);
|
|
1125
|
+
const displayChatId = pickDisplayChat(sessionId);
|
|
1077
1126
|
if (displayChatId) {
|
|
1078
1127
|
const pp = platformForChat(displayChatId);
|
|
1079
1128
|
const display = displayCards.get(displayChatId);
|
|
@@ -1105,16 +1154,16 @@ export async function runAgentSession(
|
|
|
1105
1154
|
const finalStatus = turnFinalStatus(prevState.status);
|
|
1106
1155
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1107
1156
|
|
|
1108
|
-
if (
|
|
1109
|
-
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount,
|
|
1110
|
-
}
|
|
1157
|
+
if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
|
|
1158
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1159
|
+
}
|
|
1111
1160
|
pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
|
|
1112
1161
|
}
|
|
1113
|
-
} else if (pp &&
|
|
1162
|
+
} else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
|
|
1114
1163
|
// 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
|
|
1115
1164
|
const finalStatus = turnFinalStatus(prevState.status);
|
|
1116
1165
|
finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
|
|
1117
|
-
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount,
|
|
1166
|
+
await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
|
|
1118
1167
|
}
|
|
1119
1168
|
// else: displayCards 无记录且无 finalReply → 无需处理
|
|
1120
1169
|
}
|
|
@@ -1188,6 +1237,69 @@ export async function runAgentSession(
|
|
|
1188
1237
|
const toolCallMap = new Map<string, { name: string; input: unknown }>();
|
|
1189
1238
|
let streamErrored = false;
|
|
1190
1239
|
|
|
1240
|
+
const runningPrompt = activePrompts.get(sessionId);
|
|
1241
|
+
if (runningPrompt) {
|
|
1242
|
+
const checkResponseStall = async () => {
|
|
1243
|
+
const current = activePrompts.get(sessionId);
|
|
1244
|
+
if (!current || current !== runningPrompt) {
|
|
1245
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if (
|
|
1249
|
+
current.stopped
|
|
1250
|
+
|| current.abnormalExit
|
|
1251
|
+
|| current.resourceStuck
|
|
1252
|
+
|| current.autoEnded
|
|
1253
|
+
|| activityTracker.activity.kind !== "responding"
|
|
1254
|
+
|| !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
|
|
1255
|
+
) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const autoEndedAt = Date.now();
|
|
1260
|
+
current.autoEnded = true;
|
|
1261
|
+
current.autoEndedAt = autoEndedAt;
|
|
1262
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1263
|
+
clearPromptProcessMonitor(sessionId);
|
|
1264
|
+
|
|
1265
|
+
// First publish an atomic terminal state so the card cannot keep claiming the
|
|
1266
|
+
// Agent is running while process cleanup is underway.
|
|
1267
|
+
await writeStreamState({
|
|
1268
|
+
sessionId,
|
|
1269
|
+
status: "auto_ended",
|
|
1270
|
+
accumulatedContent: state.accumulatedContent,
|
|
1271
|
+
finalReply: pickFinalReply(state).trim(),
|
|
1272
|
+
activity: activityTracker.activity,
|
|
1273
|
+
chunkCount: state.chunkCount,
|
|
1274
|
+
turnCount: nextTurnCount,
|
|
1275
|
+
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1276
|
+
updatedAt: autoEndedAt,
|
|
1277
|
+
cwd,
|
|
1278
|
+
tool,
|
|
1279
|
+
autoEndedAt,
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
try {
|
|
1283
|
+
current.closeSession?.();
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
console.warn(`[${ts()}] [RESPONSE-STALL] closeSession failed for ${sessionId}: ${(err as Error).message}`);
|
|
1286
|
+
}
|
|
1287
|
+
current.controller.abort();
|
|
1288
|
+
await killProcessTree(current.processPid);
|
|
1289
|
+
console.warn(
|
|
1290
|
+
`[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without reply character changes`,
|
|
1291
|
+
);
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
const responseStallMonitor = setInterval(() => {
|
|
1295
|
+
void checkResponseStall().catch((err) => {
|
|
1296
|
+
console.warn(`[${ts()}] [RESPONSE-STALL] check failed for ${sessionId}: ${(err as Error).message}`);
|
|
1297
|
+
});
|
|
1298
|
+
}, responseStallCheckIntervalMs);
|
|
1299
|
+
responseStallMonitor.unref?.();
|
|
1300
|
+
runningPrompt.responseStallMonitor = responseStallMonitor;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1191
1303
|
try {
|
|
1192
1304
|
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
|
|
1193
1305
|
onProcessStart: (processInfo) => {
|
|
@@ -1220,10 +1332,21 @@ export async function runAgentSession(
|
|
|
1220
1332
|
lastContextTokens: block.post_tokens,
|
|
1221
1333
|
running: true,
|
|
1222
1334
|
});
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const prompt = activePrompts.get(sessionId);
|
|
1339
|
+
if (prompt && !prompt.autoEnded) {
|
|
1340
|
+
const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
|
|
1341
|
+
prompt.responseProgress = observeResponseProgress(
|
|
1342
|
+
prompt.responseProgress,
|
|
1343
|
+
activityTracker.activity.kind === "responding",
|
|
1344
|
+
totalChars,
|
|
1345
|
+
Date.now(),
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// 定时写入文件
|
|
1227
1350
|
const now2 = Date.now();
|
|
1228
1351
|
if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
|
|
1229
1352
|
lastFileWrite = now2;
|
|
@@ -1249,17 +1372,28 @@ export async function runAgentSession(
|
|
|
1249
1372
|
// 标记 prompt 结束
|
|
1250
1373
|
resourceMonitor.off("stuck", onResourceStuck);
|
|
1251
1374
|
const prompt = activePrompts.get(sessionId);
|
|
1252
|
-
const wasStopped = prompt?.stopped ?? false;
|
|
1253
|
-
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1254
|
-
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1375
|
+
const wasStopped = prompt?.stopped ?? false;
|
|
1376
|
+
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1377
|
+
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1378
|
+
const wasAutoEnded = prompt?.autoEnded ?? false;
|
|
1379
|
+
const autoEndedAt = prompt?.autoEndedAt;
|
|
1380
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1381
|
+
clearPromptProcessMonitor(sessionId);
|
|
1382
|
+
markSessionFinalizing(sessionId);
|
|
1383
|
+
activePrompts.delete(sessionId);
|
|
1384
|
+
|
|
1385
|
+
try {
|
|
1386
|
+
// 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
|
|
1259
1387
|
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1260
1388
|
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1261
1389
|
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1262
|
-
const finalStatus =
|
|
1390
|
+
const finalStatus = wasAutoEnded
|
|
1391
|
+
? "auto_ended"
|
|
1392
|
+
: (streamErrored || wasAbnormalExit || wasResourceStuck)
|
|
1393
|
+
? "error"
|
|
1394
|
+
: wasStopped
|
|
1395
|
+
? "stopped"
|
|
1396
|
+
: "done";
|
|
1263
1397
|
const finalReply = pickFinalReply(state).trim();
|
|
1264
1398
|
|
|
1265
1399
|
// stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
|
|
@@ -1288,39 +1422,12 @@ export async function runAgentSession(
|
|
|
1288
1422
|
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
1289
1423
|
updatedAt: Date.now(),
|
|
1290
1424
|
cwd,
|
|
1291
|
-
tool,
|
|
1292
|
-
...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
|
|
1293
|
-
|
|
1425
|
+
tool,
|
|
1426
|
+
...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
|
|
1427
|
+
...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
|
|
1428
|
+
});
|
|
1294
1429
|
|
|
1295
|
-
//
|
|
1296
|
-
// 用户 /stop 后应丢弃队列消息,避免用户停止后又自动开始新轮
|
|
1297
|
-
if (wasStopped) {
|
|
1298
|
-
const discarded = dequeueMessage(sessionId);
|
|
1299
|
-
if (discarded) {
|
|
1300
|
-
console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
|
|
1301
|
-
}
|
|
1302
|
-
} else {
|
|
1303
|
-
const queued = dequeueMessage(sessionId);
|
|
1304
|
-
if (queued) {
|
|
1305
|
-
// 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
|
|
1306
|
-
// 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
|
|
1307
|
-
// 用保存的 chat 替代 queued.chatId 作为 display 目标)
|
|
1308
|
-
const preservedChat = getLastActiveChat(sessionId);
|
|
1309
|
-
if (preservedChat && preservedChat !== queued.chatId) {
|
|
1310
|
-
setQueuePreservedChat(sessionId, preservedChat);
|
|
1311
|
-
}
|
|
1312
|
-
console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
|
|
1313
|
-
// setTimeout 而非 setImmediate:给 display loop 的 setInterval
|
|
1314
|
-
// 足够时间读到 "done" 状态并终结旧卡片,避免新轮更新旧卡片的 bug。
|
|
1315
|
-
// setImmediate 在 check 阶段触发早于下一个 timers 阶段,
|
|
1316
|
-
// display loop (setInterval) 还没机会读到 "done" 就被新 "running" 覆盖。
|
|
1317
|
-
setTimeout(() => {
|
|
1318
|
-
consumeQueuedMessage(platform, queued);
|
|
1319
|
-
}, 200);
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// display loop 下一轮会读到最终状态并发送消息
|
|
1430
|
+
// display loop 下一轮会读到最终状态并发送消息
|
|
1324
1431
|
|
|
1325
1432
|
if (wasStopped) {
|
|
1326
1433
|
for (const cid of getChatsForSession(sessionId)) {
|
|
@@ -1342,7 +1449,37 @@ export async function runAgentSession(
|
|
|
1342
1449
|
}
|
|
1343
1450
|
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
1344
1451
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1345
|
-
} else if (
|
|
1452
|
+
} else if (wasAutoEnded) {
|
|
1453
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
1454
|
+
const finfo = sessionInfoMap.get(cid);
|
|
1455
|
+
await recordSessionRegistry({
|
|
1456
|
+
chatId: cid,
|
|
1457
|
+
sessionId,
|
|
1458
|
+
tool,
|
|
1459
|
+
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
1460
|
+
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
1461
|
+
startTime: finfo?.startTime ?? now,
|
|
1462
|
+
running: false,
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
const activeAutoEnded = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
1466
|
+
if (activeAutoEnded) {
|
|
1467
|
+
const terminalState = await readStreamState(sessionId);
|
|
1468
|
+
if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
|
|
1469
|
+
const pp = platformForChat(activeAutoEnded) ?? platform;
|
|
1470
|
+
await sendFinalReplyTextOnce(
|
|
1471
|
+
pp,
|
|
1472
|
+
activeAutoEnded,
|
|
1473
|
+
sessionId,
|
|
1474
|
+
nextTurnCount,
|
|
1475
|
+
formatAutoEndedReply(finalReplyToWrite),
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
platform.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
|
|
1479
|
+
}
|
|
1480
|
+
console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
|
|
1481
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
|
|
1482
|
+
} else if (wasAbnormalExit) {
|
|
1346
1483
|
for (const cid of getChatsForSession(sessionId)) {
|
|
1347
1484
|
const finfo = sessionInfoMap.get(cid);
|
|
1348
1485
|
await recordSessionRegistry({
|
|
@@ -1381,11 +1518,43 @@ export async function runAgentSession(
|
|
|
1381
1518
|
}
|
|
1382
1519
|
platform.setChatAvatar(active2, tool, "idle").catch(() => {});
|
|
1383
1520
|
}
|
|
1384
|
-
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
1385
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1521
|
+
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
1522
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
|
|
1526
|
+
// 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
|
|
1527
|
+
let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
|
|
1528
|
+
if (wasStopped) {
|
|
1529
|
+
const discarded = dequeueMessage(sessionId);
|
|
1530
|
+
if (discarded) {
|
|
1531
|
+
console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
|
|
1532
|
+
}
|
|
1533
|
+
} else {
|
|
1534
|
+
queuedForConsumption = dequeueMessage(sessionId);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (queuedForConsumption) {
|
|
1538
|
+
const queued = queuedForConsumption;
|
|
1539
|
+
// 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
|
|
1540
|
+
// 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
|
|
1541
|
+
// 用保存的 chat 替代 queued.chatId 作为 display 目标)。
|
|
1542
|
+
const preservedChat = getLastActiveChat(sessionId);
|
|
1543
|
+
if (preservedChat && preservedChat !== queued.chatId) {
|
|
1544
|
+
setQueuePreservedChat(sessionId, preservedChat);
|
|
1545
|
+
}
|
|
1546
|
+
console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
|
|
1547
|
+
// setTimeout 而非 setImmediate:给 display loop 的 setInterval
|
|
1548
|
+
// 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
|
|
1549
|
+
setTimeout(() => {
|
|
1550
|
+
consumeQueuedMessage(platform, queued);
|
|
1551
|
+
}, 200);
|
|
1552
|
+
}
|
|
1553
|
+
} finally {
|
|
1554
|
+
clearSessionFinalizing(sessionId);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1389
1558
|
|
|
1390
1559
|
// ---------------------------------------------------------------------------
|
|
1391
1560
|
// startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片
|
|
@@ -1459,10 +1628,14 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1459
1628
|
// 还没执行,当前 stream state 可能是 stopSession fire-and-forget
|
|
1460
1629
|
// 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
|
|
1461
1630
|
// 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
|
|
1462
|
-
if (activePrompts.has(sessionId)) continue;
|
|
1463
|
-
|
|
1464
|
-
const tail = "━━━ 回答结束 ━━━";
|
|
1465
|
-
const finalMsg =
|
|
1631
|
+
if (activePrompts.has(sessionId)) continue;
|
|
1632
|
+
|
|
1633
|
+
const tail = "━━━ 回答结束 ━━━";
|
|
1634
|
+
const finalMsg = state.status === "auto_ended"
|
|
1635
|
+
? formatAutoEndedReply(remaining)
|
|
1636
|
+
: remaining
|
|
1637
|
+
? remaining + "\n" + tail
|
|
1638
|
+
: tail;
|
|
1466
1639
|
if (!isFinalReplySentForTurn(state)) {
|
|
1467
1640
|
await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
|
|
1468
1641
|
}
|
|
@@ -1511,11 +1684,12 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1511
1684
|
continue;
|
|
1512
1685
|
}
|
|
1513
1686
|
|
|
1514
|
-
let terminalTextDelivered = true;
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1687
|
+
let terminalTextDelivered = true;
|
|
1688
|
+
const terminalReply = formatTerminalReply(state.status, state.finalReply);
|
|
1689
|
+
if (terminalReply) {
|
|
1690
|
+
if (!isFinalReplySentForTurn(state)) {
|
|
1691
|
+
terminalTextDelivered = await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, terminalReply);
|
|
1692
|
+
}
|
|
1519
1693
|
} else if (state.accumulatedContent.trim()) {
|
|
1520
1694
|
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
1521
1695
|
terminalTextDelivered = await p.sendText(chatId, `[生成过程]\n${short}`).then((ok) => ok !== false).catch(() => false);
|
|
@@ -1739,8 +1913,9 @@ export function stopUnifiedDisplayLoop(): void {
|
|
|
1739
1913
|
export function stopSession(sessionId: string): boolean {
|
|
1740
1914
|
const prompt = activePrompts.get(sessionId);
|
|
1741
1915
|
if (!prompt) return false;
|
|
1742
|
-
prompt.stopped = true;
|
|
1743
|
-
|
|
1916
|
+
prompt.stopped = true;
|
|
1917
|
+
clearPromptResponseStallMonitor(sessionId);
|
|
1918
|
+
clearPromptProcessMonitor(sessionId);
|
|
1744
1919
|
cancelQueuedMessage(sessionId);
|
|
1745
1920
|
try {
|
|
1746
1921
|
prompt.closeSession?.();
|
package/src/stream-state.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
|
13
13
|
|
|
14
14
|
export interface StreamState {
|
|
15
15
|
sessionId: string;
|
|
16
|
-
status: "running" | "done" | "stopped" | "error";
|
|
16
|
+
status: "running" | "done" | "stopped" | "error" | "auto_ended";
|
|
17
17
|
accumulatedContent: string;
|
|
18
18
|
/** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
|
|
19
19
|
* 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
|
|
@@ -32,8 +32,10 @@ export interface StreamState {
|
|
|
32
32
|
tool: string;
|
|
33
33
|
/** Set by stop-stuck-loop to prevent the session from being resumed.
|
|
34
34
|
* The orchestrator checks this before resuming and creates a new session instead. */
|
|
35
|
-
stuckAt?: number;
|
|
36
|
-
|
|
35
|
+
stuckAt?: number;
|
|
36
|
+
/** Set when the shared response watchdog ends a turn after three minutes without new characters. */
|
|
37
|
+
autoEndedAt?: number;
|
|
38
|
+
}
|
|
37
39
|
|
|
38
40
|
function getStreamStatePath(sessionId: string): string {
|
|
39
41
|
return join(STREAMS_DIR, `${sessionId}.json`);
|