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/orchestrator.ts
CHANGED
|
@@ -411,10 +411,134 @@ function shouldSendWechatProcessingAck(
|
|
|
411
411
|
return platform.kind === "wechat" && chatType === "p2p" && !isCommandText;
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
/**
|
|
414
|
+
/** 飞书私聊是专属会话容器;显式 /new 才创建独立群聊。 */
|
|
415
415
|
function isFeishuP2p(platform: PlatformAdapter, chatType: string): boolean {
|
|
416
416
|
return chatType === "p2p" && platform.kind === "feishu";
|
|
417
417
|
}
|
|
418
|
+
|
|
419
|
+
interface FeishuP2pRegistryRecord {
|
|
420
|
+
sessionId: string;
|
|
421
|
+
tool: string;
|
|
422
|
+
chatType?: string;
|
|
423
|
+
chatName?: string;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
type FeishuP2pAgentResolution =
|
|
427
|
+
| { kind: "ready"; sessionId: string; tool: string }
|
|
428
|
+
| { kind: "waiting"; sessionId: string; tool: string; desiredTool: AgentTool }
|
|
429
|
+
| { kind: "error"; previousTool: string; desiredTool: AgentTool; error: Error };
|
|
430
|
+
|
|
431
|
+
// 同一个飞书私聊可能短时间收到多条消息。切换期间共享同一个 Promise,避免
|
|
432
|
+
// 为同一次默认 Agent 变化创建多个空会话。
|
|
433
|
+
const feishuP2pAgentSwitches = new Map<string, Promise<FeishuP2pAgentResolution>>();
|
|
434
|
+
|
|
435
|
+
async function resolveFeishuP2pAgent(
|
|
436
|
+
platform: PlatformAdapter,
|
|
437
|
+
chatId: string,
|
|
438
|
+
text: string,
|
|
439
|
+
record: FeishuP2pRegistryRecord,
|
|
440
|
+
traceId: string,
|
|
441
|
+
): Promise<FeishuP2pAgentResolution> {
|
|
442
|
+
const desiredTool = resolveDefaultAgentTool();
|
|
443
|
+
if (record.tool === desiredTool) {
|
|
444
|
+
return { kind: "ready", sessionId: record.sessionId, tool: record.tool };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (isSessionRunning(record.sessionId)) {
|
|
448
|
+
return {
|
|
449
|
+
kind: "waiting",
|
|
450
|
+
sessionId: record.sessionId,
|
|
451
|
+
tool: record.tool,
|
|
452
|
+
desiredTool,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const existingSwitch = feishuP2pAgentSwitches.get(chatId);
|
|
457
|
+
if (existingSwitch) return existingSwitch;
|
|
458
|
+
|
|
459
|
+
const switchOperation = (async (): Promise<FeishuP2pAgentResolution> => {
|
|
460
|
+
try {
|
|
461
|
+
// 异步创建开始前重新读取一次,防止另一个请求刚完成了绑定切换。
|
|
462
|
+
const latestRecord = (await loadSessionRegistryForBinding())[chatId];
|
|
463
|
+
if (!latestRecord?.sessionId || !latestRecord.tool || latestRecord.chatType !== "p2p") {
|
|
464
|
+
return {
|
|
465
|
+
kind: "error",
|
|
466
|
+
previousTool: record.tool,
|
|
467
|
+
desiredTool,
|
|
468
|
+
error: new Error("飞书私聊绑定在切换前已发生变化"),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (latestRecord.tool === desiredTool) {
|
|
472
|
+
return { kind: "ready", sessionId: latestRecord.sessionId, tool: latestRecord.tool };
|
|
473
|
+
}
|
|
474
|
+
if (isSessionRunning(latestRecord.sessionId)) {
|
|
475
|
+
return {
|
|
476
|
+
kind: "waiting",
|
|
477
|
+
sessionId: latestRecord.sessionId,
|
|
478
|
+
tool: latestRecord.tool,
|
|
479
|
+
desiredTool,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const cwd = homedir();
|
|
484
|
+
const init = await initClaudeSession(desiredTool, cwd);
|
|
485
|
+
const chatName = sessionChatName(text.slice(0, 10) || "私聊会话", cwd);
|
|
486
|
+
const switchResult = await switchChatBinding({
|
|
487
|
+
chatId,
|
|
488
|
+
chatType: "p2p",
|
|
489
|
+
oldSessionId: latestRecord.sessionId,
|
|
490
|
+
newSessionId: init.sessionId,
|
|
491
|
+
tool: desiredTool,
|
|
492
|
+
chatName,
|
|
493
|
+
newDescription: `${sessionPrefixForTool(desiredTool)} ${init.sessionId}`,
|
|
494
|
+
updateChatInfoFn: (id, name, desc) => platform.updateChatInfo(id, name, desc),
|
|
495
|
+
});
|
|
496
|
+
if (!switchResult.ok) {
|
|
497
|
+
return {
|
|
498
|
+
kind: "error",
|
|
499
|
+
previousTool: latestRecord.tool,
|
|
500
|
+
desiredTool,
|
|
501
|
+
error: switchResult.error ?? new Error("更新飞书私聊绑定失败"),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const previousLabel = toolDisplayName(latestRecord.tool);
|
|
506
|
+
const desiredLabel = toolDisplayName(desiredTool);
|
|
507
|
+
logTrace(traceId, "BRANCH", {
|
|
508
|
+
reason: "switch_feishu_p2p_default_agent",
|
|
509
|
+
chatId,
|
|
510
|
+
oldSessionId: latestRecord.sessionId,
|
|
511
|
+
newSessionId: init.sessionId,
|
|
512
|
+
oldTool: latestRecord.tool,
|
|
513
|
+
newTool: desiredTool,
|
|
514
|
+
});
|
|
515
|
+
await platform.sendCard(
|
|
516
|
+
chatId,
|
|
517
|
+
"默认 Agent 已切换",
|
|
518
|
+
`检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`,
|
|
519
|
+
"green",
|
|
520
|
+
).catch(() => {});
|
|
521
|
+
platform.setChatAvatar(chatId, desiredTool, "new").catch(() => {});
|
|
522
|
+
return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
|
|
523
|
+
} catch (err) {
|
|
524
|
+
return {
|
|
525
|
+
kind: "error",
|
|
526
|
+
previousTool: record.tool,
|
|
527
|
+
desiredTool,
|
|
528
|
+
error: err as Error,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
})();
|
|
532
|
+
|
|
533
|
+
feishuP2pAgentSwitches.set(chatId, switchOperation);
|
|
534
|
+
try {
|
|
535
|
+
return await switchOperation;
|
|
536
|
+
} finally {
|
|
537
|
+
if (feishuP2pAgentSwitches.get(chatId) === switchOperation) {
|
|
538
|
+
feishuP2pAgentSwitches.delete(chatId);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
418
542
|
|
|
419
543
|
/** 检测当前进程是否从 npm 全局安装启动 */
|
|
420
544
|
function isRunningFromGlobalNpm(): boolean {
|
|
@@ -532,7 +656,7 @@ export async function handleCommand(
|
|
|
532
656
|
"配置已重新加载。",
|
|
533
657
|
`默认 Agent: ${toolDisplayName(result.defaultAgent)}`,
|
|
534
658
|
`配置文件: ${result.configPath}`,
|
|
535
|
-
"
|
|
659
|
+
"后续新会话会使用最新配置;飞书私聊会在下一条普通消息时跟随默认 Agent,正在生成的会话不会被中断。",
|
|
536
660
|
].join("\n"),
|
|
537
661
|
).catch(() => {});
|
|
538
662
|
logTrace(tid, "DONE", { outcome: "reload", defaultAgent: result.defaultAgent });
|
|
@@ -965,11 +1089,12 @@ export async function handleCommand(
|
|
|
965
1089
|
|
|
966
1090
|
// 检测会话上下文:群聊从 description 获取,飞书/微信私聊都从
|
|
967
1091
|
// session-registry 获取。私聊 chatId 是稳定容器,进程重启后仍恢复绑定。
|
|
968
|
-
let sessionId: string | null = null;
|
|
969
|
-
let descriptionTool: string | null = null;
|
|
970
|
-
let toolLabel: string | null = null;
|
|
971
|
-
let
|
|
972
|
-
let
|
|
1092
|
+
let sessionId: string | null = null;
|
|
1093
|
+
let descriptionTool: string | null = null;
|
|
1094
|
+
let toolLabel: string | null = null;
|
|
1095
|
+
let pendingFeishuP2pDefaultTool: AgentTool | null = null;
|
|
1096
|
+
let chatInfo: Awaited<ReturnType<PlatformAdapter["getChatInfo"]>> | undefined;
|
|
1097
|
+
let description: string | undefined;
|
|
973
1098
|
|
|
974
1099
|
if (chatType !== "p2p") {
|
|
975
1100
|
try {
|
|
@@ -1010,9 +1135,38 @@ export async function handleCommand(
|
|
|
1010
1135
|
oldSessionId: record.sessionId,
|
|
1011
1136
|
});
|
|
1012
1137
|
} else if (record && record.sessionId && record.tool) {
|
|
1013
|
-
sessionId = record
|
|
1014
|
-
|
|
1015
|
-
|
|
1138
|
+
let resolvedRecord: { sessionId: string; tool: string } = record;
|
|
1139
|
+
if (platform.kind === "feishu" && !isCommandText && record.chatType === "p2p") {
|
|
1140
|
+
const resolution = await resolveFeishuP2pAgent(platform, chatId, text, record, tid);
|
|
1141
|
+
if (resolution.kind === "error") {
|
|
1142
|
+
const previousLabel = toolDisplayName(resolution.previousTool);
|
|
1143
|
+
const desiredLabel = toolDisplayName(resolution.desiredTool);
|
|
1144
|
+
console.error(
|
|
1145
|
+
`[${ts()}] [P2P-SWITCH] ${previousLabel} -> ${desiredLabel} FAIL: ${resolution.error.message}`,
|
|
1146
|
+
);
|
|
1147
|
+
logTrace(tid, "DONE", {
|
|
1148
|
+
outcome: "switch_feishu_p2p_default_agent_fail",
|
|
1149
|
+
oldTool: resolution.previousTool,
|
|
1150
|
+
newTool: resolution.desiredTool,
|
|
1151
|
+
error: resolution.error.message,
|
|
1152
|
+
});
|
|
1153
|
+
await platform.sendCard(
|
|
1154
|
+
chatId,
|
|
1155
|
+
"Agent 切换失败",
|
|
1156
|
+
`无法从 ${previousLabel} 切换到 ${desiredLabel}:\n${resolution.error.message}`,
|
|
1157
|
+
"red",
|
|
1158
|
+
).catch(() => {});
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
resolvedRecord = resolution;
|
|
1162
|
+
if (resolution.kind === "waiting") {
|
|
1163
|
+
pendingFeishuP2pDefaultTool = resolution.desiredTool;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
sessionId = resolvedRecord.sessionId;
|
|
1168
|
+
descriptionTool = resolvedRecord.tool;
|
|
1169
|
+
toolLabel = toolDisplayName(descriptionTool);
|
|
1016
1170
|
// 确保内存状态在冷启动后恢复;bindChatToSession 是幂等的。
|
|
1017
1171
|
if (!sessionInfoMap.has(chatId)) {
|
|
1018
1172
|
sessionInfoMap.set(chatId, {
|
|
@@ -1389,7 +1543,7 @@ export async function handleCommand(
|
|
|
1389
1543
|
await platform.sendCard(
|
|
1390
1544
|
chatId,
|
|
1391
1545
|
"/session",
|
|
1392
|
-
"
|
|
1546
|
+
"飞书私聊不能通过 /session 切换到历史群聊会话;它会在下一条普通消息时跟随默认 Agent。请回到对应群聊继续,或发送 /new 新建群聊。",
|
|
1393
1547
|
"yellow",
|
|
1394
1548
|
);
|
|
1395
1549
|
logTrace(tid, "DONE", { outcome: "session_switch_disabled_feishu_p2p" });
|
|
@@ -1762,8 +1916,19 @@ export async function handleCommand(
|
|
|
1762
1916
|
if (platform.kind === "wechat") {
|
|
1763
1917
|
await platform.sendText(chatId, "当前会话正在生成中,你的消息已进入缓存队列,生成完成后会立即处理。发送 /cancel 可取消缓存。").catch(() => {});
|
|
1764
1918
|
} else {
|
|
1765
|
-
|
|
1766
|
-
|
|
1919
|
+
if (isFeishuP2p(platform, chatType) && pendingFeishuP2pDefaultTool) {
|
|
1920
|
+
const currentLabel = toolDisplayName(descriptionTool);
|
|
1921
|
+
const desiredLabel = toolDisplayName(pendingFeishuP2pDefaultTool);
|
|
1922
|
+
await platform.sendCard(
|
|
1923
|
+
chatId,
|
|
1924
|
+
"Agent 切换等待中",
|
|
1925
|
+
`当前 ${currentLabel} 正在生成;完成后会切换到 ${desiredLabel},并用新的空会话处理这条消息。\n\n发送 **/cancel** 可取消缓存。`,
|
|
1926
|
+
"blue",
|
|
1927
|
+
).catch(() => {});
|
|
1928
|
+
} else {
|
|
1929
|
+
await platform.sendRawCard(chatId, buildQueuedCard(text)).catch(() => {});
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1767
1932
|
} else {
|
|
1768
1933
|
logTrace(tid, "QUEUE_FULL", { sessionId });
|
|
1769
1934
|
console.log(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A snapshot of response output progress while the Agent is generating a reply. */
|
|
2
|
+
export interface ResponseProgressObservation {
|
|
3
|
+
totalChars: number;
|
|
4
|
+
unchangedSince: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks how long the displayed response character count has remained unchanged.
|
|
9
|
+
* Leaving the responding phase clears the window; returning starts a fresh one.
|
|
10
|
+
*/
|
|
11
|
+
export function observeResponseProgress(
|
|
12
|
+
previous: ResponseProgressObservation | undefined,
|
|
13
|
+
isResponding: boolean,
|
|
14
|
+
totalChars: number,
|
|
15
|
+
now = Date.now(),
|
|
16
|
+
): ResponseProgressObservation | undefined {
|
|
17
|
+
if (!isResponding) return undefined;
|
|
18
|
+
if (previous?.totalChars === totalChars) return previous;
|
|
19
|
+
return { totalChars, unchangedSince: now };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hasResponseStalled(
|
|
23
|
+
observation: ResponseProgressObservation | undefined,
|
|
24
|
+
now: number,
|
|
25
|
+
timeoutMs: number,
|
|
26
|
+
): boolean {
|
|
27
|
+
return observation !== undefined && now - observation.unchangedSince >= timeoutMs;
|
|
28
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// 由 session.ts 在初始化时调用 rebuildSessionChatsFromRegistry 重建
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
-
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
8
|
+
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
9
|
+
import type { ResponseProgressObservation } from "./response-stall.ts";
|
|
9
10
|
|
|
10
11
|
const sessionChatsMap = new Map<string, Set<string>>();
|
|
11
12
|
|
|
@@ -52,10 +53,22 @@ export function hasChatsForSession(sessionId: string): boolean {
|
|
|
52
53
|
return (sessionChatsMap.get(sessionId)?.size ?? 0) > 0;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// Agent generator 退出后,最终卡片、registry、头像等仍可能在异步收尾。该阶段
|
|
57
|
+
// 也必须阻止下一轮提前进入,否则旧会话的落盘可能覆盖新会话绑定。
|
|
58
|
+
const finalizingSessions = new Set<string>();
|
|
59
|
+
|
|
60
|
+
/** 检查 sessionId 是否有活跃 prompt 或正在完成本轮异步收尾。 */
|
|
61
|
+
export function isSessionRunning(sessionId: string): boolean {
|
|
62
|
+
return activePrompts.has(sessionId) || finalizingSessions.has(sessionId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function markSessionFinalizing(sessionId: string): void {
|
|
66
|
+
finalizingSessions.add(sessionId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clearSessionFinalizing(sessionId: string): void {
|
|
70
|
+
finalizingSessions.delete(sessionId);
|
|
71
|
+
}
|
|
59
72
|
|
|
60
73
|
// ---------------------------------------------------------------------------
|
|
61
74
|
// activePrompts: sessionId → 活跃 prompt 控制
|
|
@@ -66,8 +79,14 @@ export interface ActivePrompt {
|
|
|
66
79
|
stopped: boolean;
|
|
67
80
|
startTime: number;
|
|
68
81
|
/** Root PID for the CLI process currently serving this prompt, if the adapter exposes one. */
|
|
69
|
-
processPid?: number;
|
|
70
|
-
processMonitor?: ReturnType<typeof setInterval>;
|
|
82
|
+
processPid?: number;
|
|
83
|
+
processMonitor?: ReturnType<typeof setInterval>;
|
|
84
|
+
responseStallMonitor?: ReturnType<typeof setInterval>;
|
|
85
|
+
/** Character-count progress observed only while the activity is "responding". */
|
|
86
|
+
responseProgress?: ResponseProgressObservation;
|
|
87
|
+
/** Set before a response-stall auto-end begins so competing monitors cannot win the race. */
|
|
88
|
+
autoEnded?: boolean;
|
|
89
|
+
autoEndedAt?: number;
|
|
71
90
|
/** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
|
|
72
91
|
abnormalExit?: boolean;
|
|
73
92
|
abnormalExitNotified?: boolean;
|
|
@@ -220,11 +239,13 @@ export function consumeQueuedMessage(platform: PlatformAdapter, msg: QueuedMessa
|
|
|
220
239
|
export function resetBindingState(): void {
|
|
221
240
|
sessionChatsMap.clear();
|
|
222
241
|
lastActiveChatMap.clear();
|
|
223
|
-
for (const prompt of activePrompts.values()) {
|
|
224
|
-
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
for (const prompt of activePrompts.values()) {
|
|
243
|
+
if (prompt.processMonitor) clearInterval(prompt.processMonitor);
|
|
244
|
+
if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
|
|
245
|
+
}
|
|
246
|
+
activePrompts.clear();
|
|
247
|
+
finalizingSessions.clear();
|
|
248
|
+
queuedMessages.clear();
|
|
228
249
|
displayCards.clear();
|
|
229
250
|
if (unifiedDisplayLoopHandle !== null) {
|
|
230
251
|
clearInterval(unifiedDisplayLoopHandle);
|