evolclaw 2.2.0 → 2.3.0
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 +49 -27
- package/data/evolclaw.sample.json +6 -3
- package/dist/agents/claude-runner.js +125 -52
- package/dist/agents/codex-runner.js +10 -5
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +247 -84
- package/dist/channels/feishu.js +556 -96
- package/dist/channels/wechat.js +98 -74
- package/dist/cli.js +132 -50
- package/dist/config.js +185 -31
- package/dist/core/channel-loader.js +11 -4
- package/dist/core/command-handler.js +750 -209
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +216 -0
- package/dist/core/{message-processor.js → message/message-processor.js} +386 -105
- package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
- package/dist/{utils → core/message}/stream-debouncer.js +1 -1
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/core/permission.js +212 -11
- package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
- package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/{utils → core/session}/session-file-health.js +1 -1
- package/dist/core/{session-manager.js → session/session-manager.js} +57 -11
- package/dist/index.js +138 -54
- package/dist/{core/ipc-server.js → ipc.js} +36 -1
- package/dist/types.js +3 -0
- package/dist/utils/cross-platform.js +38 -1
- package/dist/utils/error-utils.js +130 -5
- package/dist/utils/init-channel.js +649 -0
- package/dist/utils/init.js +55 -150
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/{core → utils}/stats-collector.js +16 -0
- package/package.json +3 -3
- package/dist/core/message-bridge.js +0 -187
- package/dist/utils/init-feishu.js +0 -263
- package/dist/utils/init-wechat.js +0 -172
- package/dist/utils/ipc-client.js +0 -36
- package/dist/utils/permission-utils.js +0 -71
- /package/dist/{utils → core/message}/message-cache.js +0 -0
- /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
- /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
-
import { hasCompact } from '
|
|
4
|
-
import { StreamFlusher } from '
|
|
5
|
-
import { StreamIdleMonitor } from '
|
|
6
|
-
import { logger } from '
|
|
7
|
-
import { getErrorMessage, classifyError, ErrorType } from '
|
|
8
|
-
import { summarizeToolInput } from '../
|
|
9
|
-
import { getOwner } from '
|
|
3
|
+
import { hasCompact } from '../../agents/claude-runner.js';
|
|
4
|
+
import { StreamFlusher } from './stream-flusher.js';
|
|
5
|
+
import { StreamIdleMonitor } from './stream-idle-monitor.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, prefixErrorType, isRetryableError } from '../../utils/error-utils.js';
|
|
8
|
+
import { summarizeToolInput } from '../permission.js';
|
|
9
|
+
import { getOwner } from '../../config.js';
|
|
10
10
|
/**
|
|
11
11
|
* 统一消息处理器
|
|
12
12
|
* 负责处理来自不同渠道的消息,协调事件流处理
|
|
@@ -18,11 +18,13 @@ export class MessageProcessor {
|
|
|
18
18
|
eventBus;
|
|
19
19
|
commandHandler;
|
|
20
20
|
channels = new Map();
|
|
21
|
+
channelTypeMap = new Map(); // channelType → channelName(首个实例)
|
|
21
22
|
currentFlusher;
|
|
22
23
|
shouldSuppressActivities = false;
|
|
23
24
|
agentMap;
|
|
24
25
|
defaultAgentId;
|
|
25
26
|
interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
|
|
27
|
+
interactionRouter;
|
|
26
28
|
/** 按 agentId 获取 agent,回退到默认 */
|
|
27
29
|
getAgent(agentId) {
|
|
28
30
|
if (agentId && this.agentMap.has(agentId))
|
|
@@ -62,23 +64,31 @@ export class MessageProcessor {
|
|
|
62
64
|
}
|
|
63
65
|
});
|
|
64
66
|
}
|
|
67
|
+
setInteractionRouter(router) {
|
|
68
|
+
this.interactionRouter = router;
|
|
69
|
+
}
|
|
65
70
|
/**
|
|
66
71
|
* 注册渠道适配器
|
|
67
72
|
*/
|
|
68
73
|
registerChannel(adapter, policy, options) {
|
|
69
|
-
this.channels.set(adapter.
|
|
74
|
+
this.channels.set(adapter.channelName, { adapter, options, policy });
|
|
75
|
+
// 维护 channelType → channelName 映射(首个实例优先)
|
|
76
|
+
const type = options?.channelType || adapter.channelName;
|
|
77
|
+
if (!this.channelTypeMap.has(type)) {
|
|
78
|
+
this.channelTypeMap.set(type, adapter.channelName);
|
|
79
|
+
}
|
|
70
80
|
}
|
|
71
81
|
/**
|
|
72
|
-
*
|
|
82
|
+
* 获取渠道适配器(支持实例名和 channelType)
|
|
73
83
|
*/
|
|
74
84
|
getAdapter(channelName) {
|
|
75
|
-
return this.
|
|
85
|
+
return this.resolveChannelInfo(channelName)?.adapter;
|
|
76
86
|
}
|
|
77
87
|
/**
|
|
78
|
-
* 获取渠道信息(含 policy)
|
|
88
|
+
* 获取渠道信息(含 policy,支持实例名和 channelType)
|
|
79
89
|
*/
|
|
80
90
|
getChannelInfo(channelName) {
|
|
81
|
-
return this.
|
|
91
|
+
return this.resolveChannelInfo(channelName);
|
|
82
92
|
}
|
|
83
93
|
/**
|
|
84
94
|
* 处理 compact 开始事件
|
|
@@ -91,19 +101,48 @@ export class MessageProcessor {
|
|
|
91
101
|
this.currentFlusher.addActivity('\u23f3 会话压缩中...');
|
|
92
102
|
}
|
|
93
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* 根据 channel 标识查找渠道信息
|
|
106
|
+
* 先按实例名精确匹配,再按 channelType 映射到实例名
|
|
107
|
+
*/
|
|
108
|
+
resolveChannelInfo(channel) {
|
|
109
|
+
// 1. 精确匹配实例名
|
|
110
|
+
let info = this.channels.get(channel);
|
|
111
|
+
if (info)
|
|
112
|
+
return info;
|
|
113
|
+
// 2. 按 channelType 查找(兼容按类型名路由)
|
|
114
|
+
const instanceName = this.channelTypeMap.get(channel);
|
|
115
|
+
if (instanceName)
|
|
116
|
+
info = this.channels.get(instanceName);
|
|
117
|
+
return info;
|
|
118
|
+
}
|
|
119
|
+
// 命令前缀列表(与 CommandHandler.quickCommandPrefixes 保持同步)
|
|
120
|
+
static COMMAND_PREFIXES = [
|
|
121
|
+
'/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart',
|
|
122
|
+
'/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork',
|
|
123
|
+
'/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/send', '/check',
|
|
124
|
+
'/p ', '/s ', '/name ',
|
|
125
|
+
];
|
|
126
|
+
/** 判断消息内容是否为已知命令 */
|
|
127
|
+
isKnownCommand(content) {
|
|
128
|
+
return content === '/p' || content === '/s' ||
|
|
129
|
+
MessageProcessor.COMMAND_PREFIXES.some(cmd => content.startsWith(cmd));
|
|
130
|
+
}
|
|
94
131
|
/**
|
|
95
132
|
* 处理消息(主入口)
|
|
96
133
|
*/
|
|
97
134
|
async processMessage(message) {
|
|
98
135
|
const idleMs = (this.config.idleMonitor?.timeout ?? 120) * 1000;
|
|
99
|
-
|
|
136
|
+
// 先解析会话,再优先用 session.metadata.channelName 精确定位实例级 adapter
|
|
137
|
+
// message.channel 现在存实例名(channelName),可直接用于精确路由
|
|
138
|
+
const { session, absoluteProjectPath } = await this.resolveSession(message);
|
|
139
|
+
const channelKey = session.metadata?.channelName || message.channel;
|
|
140
|
+
const channelInfo = this.resolveChannelInfo(channelKey);
|
|
100
141
|
if (!channelInfo) {
|
|
101
|
-
logger.error(`[MessageProcessor] Unknown channel: ${
|
|
142
|
+
logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
|
|
102
143
|
return;
|
|
103
144
|
}
|
|
104
145
|
const { policy } = channelInfo;
|
|
105
|
-
// 解析会话(唯一的 getOrCreateSession 调用点)
|
|
106
|
-
const { session, absoluteProjectPath } = await this.resolveSession(message);
|
|
107
146
|
const streamKey = session.id;
|
|
108
147
|
const chatType = message.chatType || 'private';
|
|
109
148
|
const identityRole = session.identity?.role || 'anonymous';
|
|
@@ -142,7 +181,7 @@ export class MessageProcessor {
|
|
|
142
181
|
const msg = showIdleMonitor
|
|
143
182
|
? result.message
|
|
144
183
|
: `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
|
|
145
|
-
channelInfo.adapter.sendText(message.channelId, msg).catch(e => {
|
|
184
|
+
channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(session)).catch(e => {
|
|
146
185
|
logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
|
|
147
186
|
});
|
|
148
187
|
}
|
|
@@ -157,7 +196,7 @@ export class MessageProcessor {
|
|
|
157
196
|
logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
|
|
158
197
|
if (channelInfo && showIdleMonitor && !shouldSuppress()) {
|
|
159
198
|
if (!isBackground) {
|
|
160
|
-
channelInfo.adapter.sendText(message.channelId, result.message).catch(e => {
|
|
199
|
+
channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(session)).catch(e => {
|
|
161
200
|
logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
|
|
162
201
|
});
|
|
163
202
|
}
|
|
@@ -183,12 +222,21 @@ export class MessageProcessor {
|
|
|
183
222
|
// 上下文过长是可恢复错误,不累计触发安全模式
|
|
184
223
|
if (errorType === ErrorType.CONTEXT_TOO_LONG) {
|
|
185
224
|
logger.info(`[MessageProcessor] Context too long error, skipping safe mode accumulation`);
|
|
225
|
+
// 认证错误(401 / Invalid API Key)不是会话问题,安全模式无法修复,不累计
|
|
226
|
+
}
|
|
227
|
+
else if (errorType === ErrorType.AUTH_ERROR) {
|
|
228
|
+
logger.info(`[MessageProcessor] Auth error (invalid API key), skipping safe mode accumulation`);
|
|
229
|
+
// API 错误(5xx / 算力池切换等)是平台暂时性问题,安全模式无法修复,不累计
|
|
230
|
+
}
|
|
231
|
+
else if (errorType === ErrorType.API_ERROR) {
|
|
232
|
+
logger.info(`[MessageProcessor] API error, skipping safe mode accumulation`);
|
|
186
233
|
}
|
|
187
234
|
else if (!policy.accumulateErrors(chatType, identityRole)) {
|
|
188
235
|
logger.info(`[MessageProcessor] Non-accumulating error (chatType=${chatType}, identity=${identityRole}), skipping safe mode accumulation`);
|
|
189
236
|
}
|
|
190
237
|
else {
|
|
191
|
-
const
|
|
238
|
+
const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
|
|
239
|
+
const newCount = await this.sessionManager.recordError(session.id, prefixed, error.message);
|
|
192
240
|
await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
|
|
193
241
|
}
|
|
194
242
|
}
|
|
@@ -238,9 +286,17 @@ ${suggestions}`, sendOpts);
|
|
|
238
286
|
}
|
|
239
287
|
async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress) {
|
|
240
288
|
const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
|
|
241
|
-
const
|
|
289
|
+
const channelKey = session.metadata?.channelName || message.channel;
|
|
290
|
+
const channelInfo = this.resolveChannelInfo(channelKey);
|
|
242
291
|
if (!channelInfo) {
|
|
243
|
-
logger.error(`[MessageProcessor] Unknown channel: ${
|
|
292
|
+
logger.error(`[MessageProcessor] Unknown channel: ${channelKey}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// 二次拦截:如果命令消息绕过 MessageBridge 的 handleCommand 泄漏到这里,
|
|
296
|
+
// 静默丢弃而不是发送给 Agent(命令已在 MessageBridge 层处理过)
|
|
297
|
+
const rawContent = message.content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
|
|
298
|
+
if (rawContent.startsWith('/') && this.isKnownCommand(rawContent)) {
|
|
299
|
+
logger.warn(`[MessageProcessor] Command leaked past MessageBridge, dropped: "${rawContent.substring(0, 40)}"`);
|
|
244
300
|
return;
|
|
245
301
|
}
|
|
246
302
|
const { adapter, options } = channelInfo;
|
|
@@ -279,21 +335,23 @@ ${suggestions}`, sendOpts);
|
|
|
279
335
|
// 创建 StreamFlusher,传入文件标记模式用于自动过滤
|
|
280
336
|
// 使用动态判断,确保切换项目后不会继续输出
|
|
281
337
|
let firstReply = true;
|
|
282
|
-
const flusher = new StreamFlusher(async (text, isFinal) => {
|
|
338
|
+
const flusher = new StreamFlusher(async (text, isFinal, hasText) => {
|
|
283
339
|
const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
284
340
|
if (!isCurrentlyBackground) {
|
|
285
341
|
const opts = {};
|
|
286
342
|
if (isFinal)
|
|
287
|
-
opts.title = '\u6700\u7ec8\u56de\u590d:';
|
|
343
|
+
opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
|
|
288
344
|
// 话题会话:使用 Channel 预构建的 replyContext(确保消息进入话题)
|
|
289
345
|
const replyCtx = session.metadata?.replyContext;
|
|
290
346
|
if (replyCtx) {
|
|
291
347
|
Object.assign(opts, replyCtx);
|
|
292
348
|
}
|
|
293
349
|
else if (firstReply && message.messageId) {
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
350
|
+
// 主会话:首条消息引用回复用户原消息(只在含真实文字时消费)
|
|
351
|
+
if (hasText) {
|
|
352
|
+
opts.replyToMessageId = message.messageId;
|
|
353
|
+
firstReply = false;
|
|
354
|
+
}
|
|
297
355
|
}
|
|
298
356
|
await adapter.sendText(message.channelId, text, Object.keys(opts).length ? opts : undefined);
|
|
299
357
|
}
|
|
@@ -306,9 +364,15 @@ ${suggestions}`, sendOpts);
|
|
|
306
364
|
agent.setSendPrompt(async (text) => {
|
|
307
365
|
await adapter.sendText(message.channelId, text, this.getReplyContext(session));
|
|
308
366
|
});
|
|
367
|
+
// 设置权限审批的交互上下文(支持交互卡片)
|
|
368
|
+
agent.setPermissionContext?.({
|
|
369
|
+
adapter,
|
|
370
|
+
channelId: message.channelId,
|
|
371
|
+
replyContext: this.getReplyContext(session),
|
|
372
|
+
interactionRouter: this.interactionRouter,
|
|
373
|
+
});
|
|
309
374
|
// 设置 per-session 权限模式
|
|
310
|
-
|
|
311
|
-
agent.setMode(permissionMode);
|
|
375
|
+
agent.setMode(session.metadata?.permissionMode ?? 'bypass');
|
|
312
376
|
// 标记会话为处理中(实时持久化,重启后可恢复)
|
|
313
377
|
this.sessionManager.markProcessing(session.id);
|
|
314
378
|
// 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
|
|
@@ -317,15 +381,16 @@ ${suggestions}`, sendOpts);
|
|
|
317
381
|
const effectivePrompt = prevInterruptReason === 'new_message' && session.agentSessionId
|
|
318
382
|
? `【新消息插入】\n\n${message.content}\n\n【请无视之前中断继续处理】`
|
|
319
383
|
: message.content;
|
|
384
|
+
let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
|
|
320
385
|
try {
|
|
321
386
|
// 动态构建运行时上下文提示
|
|
322
387
|
const contextParts = [];
|
|
388
|
+
const currentChannelType = options?.channelType || message.channel;
|
|
323
389
|
// 1. 当前环境信息
|
|
324
390
|
const peerLabel = session.identity?.role || 'unknown';
|
|
325
|
-
const sessionName = session.name || '默认会话';
|
|
326
391
|
const peerName = message.peerName || session.metadata?.peerName;
|
|
327
392
|
const envParts = [
|
|
328
|
-
`会话通道: ${
|
|
393
|
+
`会话通道: ${currentChannelType}`,
|
|
329
394
|
`当前项目: ${path.basename(absoluteProjectPath)}`,
|
|
330
395
|
];
|
|
331
396
|
if (session.name)
|
|
@@ -333,25 +398,70 @@ ${suggestions}`, sendOpts);
|
|
|
333
398
|
envParts.push(`对端身份: ${peerLabel}`);
|
|
334
399
|
if (peerName)
|
|
335
400
|
envParts.push(`对端名称: ${peerName}`);
|
|
401
|
+
if (session.chatType)
|
|
402
|
+
envParts.push(`聊天类型: ${session.chatType}`);
|
|
403
|
+
if (session.agentId && session.agentId !== 'claude')
|
|
404
|
+
envParts.push(`当前Agent: ${session.agentId}`);
|
|
336
405
|
contextParts.push(`[当前环境] ${envParts.join(' | ')}`);
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
406
|
+
// 只读模式提示
|
|
407
|
+
if (session.metadata?.permissionMode === 'readonly') {
|
|
408
|
+
contextParts.push('[只读模式] 禁止修改项目文件。如需生成文件供用户下载,请写入 .evolclaw/tmp/ 目录后使用 [SEND_FILE:] 发送');
|
|
409
|
+
}
|
|
410
|
+
// 2. 文件发送能力(按 channelType 去重,提示词只展示第一级通道名)
|
|
411
|
+
const fileChannelTypes = new Set();
|
|
412
|
+
const currentCanSend = !!channelInfo.adapter.sendFile;
|
|
413
|
+
for (const [, info] of this.channels) {
|
|
414
|
+
if (info.adapter.sendFile) {
|
|
415
|
+
fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
|
|
419
|
+
if (currentCanSend || crossChannelTypes.length > 0) {
|
|
344
420
|
const hints = [];
|
|
345
421
|
if (currentCanSend)
|
|
346
422
|
hints.push(`[SEND_FILE:路径] 发送文件到当前通道`);
|
|
347
|
-
if (
|
|
348
|
-
hints.push(`[SEND_FILE:${
|
|
423
|
+
if (crossChannelTypes.length > 0)
|
|
424
|
+
hints.push(`[SEND_FILE:${crossChannelTypes[0]}:路径] 发送文件到指定通道(可用: ${crossChannelTypes.join('/')})`);
|
|
349
425
|
contextParts.push(hints.join(','));
|
|
350
426
|
}
|
|
427
|
+
// 3. 当前通道能力
|
|
428
|
+
const capParts = [];
|
|
429
|
+
if (options?.supportsImages)
|
|
430
|
+
capParts.push('图片输入');
|
|
431
|
+
if (channelInfo.adapter.sendImage)
|
|
432
|
+
capParts.push('图片输出');
|
|
433
|
+
if (channelInfo.adapter.sendFile)
|
|
434
|
+
capParts.push('文件发送');
|
|
435
|
+
if (capParts.length > 0) {
|
|
436
|
+
contextParts.push(`[通道能力] ${capParts.join('、')}`);
|
|
437
|
+
}
|
|
351
438
|
const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
439
|
+
// 可重试错误(403/429/5xx)指数退避重试,最多 3 次
|
|
440
|
+
const MAX_RETRIES = 3;
|
|
441
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
442
|
+
let streamRegistered = false;
|
|
443
|
+
try {
|
|
444
|
+
const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
|
|
445
|
+
agent.registerStream(streamKey, stream);
|
|
446
|
+
streamRegistered = true;
|
|
447
|
+
streamResult = await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress);
|
|
448
|
+
break; // 成功,跳出重试循环
|
|
449
|
+
}
|
|
450
|
+
catch (retryError) {
|
|
451
|
+
if (streamRegistered) {
|
|
452
|
+
agent.cleanupStream(streamKey);
|
|
453
|
+
}
|
|
454
|
+
if (attempt < MAX_RETRIES && isRetryableError(retryError)) {
|
|
455
|
+
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
|
|
456
|
+
logger.warn(`[MessageProcessor] Retryable error (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
|
|
457
|
+
flusher.addActivity(`⚠️ API 暂时不可用,${delay / 1000}秒后重试 (${attempt}/${MAX_RETRIES})...`);
|
|
458
|
+
await flusher.flush();
|
|
459
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
throw retryError; // 不可重试或已耗尽重试次数
|
|
463
|
+
}
|
|
464
|
+
}
|
|
355
465
|
}
|
|
356
466
|
catch (error) {
|
|
357
467
|
if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.agentSessionId && hasCompact(agent)) {
|
|
@@ -364,7 +474,7 @@ ${suggestions}`, sendOpts);
|
|
|
364
474
|
flusher.addActivity('\u2705 压缩完成,正在重试...');
|
|
365
475
|
const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, options?.systemPromptAppend, this.sessionManager);
|
|
366
476
|
agent.registerStream(streamKey, retryStream);
|
|
367
|
-
await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
|
|
477
|
+
streamResult = await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
|
|
368
478
|
}
|
|
369
479
|
else {
|
|
370
480
|
throw new Error('CONTEXT_COMPACT_FAILED');
|
|
@@ -375,63 +485,93 @@ ${suggestions}`, sendOpts);
|
|
|
375
485
|
}
|
|
376
486
|
}
|
|
377
487
|
// 处理文件标记 - 支持 [SEND_FILE:path] 和 [SEND_FILE:channel:path]
|
|
488
|
+
// 注意:始终扫描全部文本(含中间轮),因为文件标记可能出现在任意轮次
|
|
489
|
+
// suppressed 模式下 flusher 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
|
|
378
490
|
const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
|
|
379
491
|
const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
|
|
380
|
-
const
|
|
492
|
+
const flusherText = flusher.getFinalText();
|
|
493
|
+
const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
|
|
381
494
|
const fileMatches = [...fullText.matchAll(markerPattern)];
|
|
382
495
|
for (const match of fileMatches) {
|
|
383
496
|
// 兼容旧格式 (1组) 和新格式 (2组)
|
|
384
497
|
const hasChannelGroup = match.length >= 3;
|
|
385
|
-
const
|
|
498
|
+
const targetSpec = hasChannelGroup ? (match[1] ?? undefined) : undefined;
|
|
386
499
|
const filePath = (hasChannelGroup ? match[2] : match[1]).trim();
|
|
387
500
|
if (this.isPlaceholderPath(filePath)) {
|
|
388
|
-
logger.info(`[${adapter.
|
|
501
|
+
logger.info(`[${adapter.channelName}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
|
|
389
502
|
continue;
|
|
390
503
|
}
|
|
504
|
+
// 解析目标:按实例名匹配,再按 channelType 映射
|
|
505
|
+
let targetInfo = targetSpec ? this.channels.get(targetSpec) : channelInfo;
|
|
506
|
+
let targetLabel = targetSpec || message.channel;
|
|
507
|
+
if (targetSpec && !targetInfo) {
|
|
508
|
+
// 按 channelType 查找首个匹配的实例
|
|
509
|
+
const instanceName = this.channelTypeMap.get(targetSpec);
|
|
510
|
+
if (instanceName)
|
|
511
|
+
targetInfo = this.channels.get(instanceName);
|
|
512
|
+
}
|
|
513
|
+
const currentChannelType = channelInfo.options?.channelType || adapter.channelName;
|
|
514
|
+
const isCrossChannel = targetSpec && targetSpec !== message.channel
|
|
515
|
+
&& targetSpec !== currentChannelType;
|
|
391
516
|
// 跨通道仅限 owner
|
|
392
|
-
if (
|
|
517
|
+
if (isCrossChannel && session.identity?.role !== 'owner') {
|
|
393
518
|
await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(session));
|
|
394
519
|
continue;
|
|
395
520
|
}
|
|
396
521
|
const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
|
|
397
522
|
if (!fs.existsSync(resolvedPath)) {
|
|
398
|
-
logger.warn(`[${adapter.
|
|
523
|
+
logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
|
|
399
524
|
await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(session));
|
|
400
525
|
continue;
|
|
401
526
|
}
|
|
402
527
|
// 找目标 adapter
|
|
403
|
-
const targetInfo = this.channels.get(targetChannelName);
|
|
404
528
|
if (!targetInfo) {
|
|
405
|
-
await adapter.sendText(message.channelId, `\u274c 通道 ${
|
|
529
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(session));
|
|
406
530
|
continue;
|
|
407
531
|
}
|
|
408
532
|
if (!targetInfo.adapter.sendFile) {
|
|
409
|
-
await adapter.sendText(message.channelId, `\u274c 通道 ${
|
|
533
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(session));
|
|
410
534
|
continue;
|
|
411
535
|
}
|
|
412
536
|
// 找目标 channelId
|
|
413
537
|
let targetChannelId = message.channelId;
|
|
414
|
-
if (
|
|
415
|
-
const
|
|
416
|
-
|
|
538
|
+
if (isCrossChannel) {
|
|
539
|
+
const targetAdapterName = targetInfo.adapter.channelName;
|
|
540
|
+
const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
|
|
541
|
+
const ownerPeerId = getOwner(this.config, targetAdapterName);
|
|
542
|
+
targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
|
|
417
543
|
if (!targetChannelId) {
|
|
418
|
-
await adapter.sendText(message.channelId, `\u274c 未找到 ${
|
|
544
|
+
await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
|
|
419
545
|
continue;
|
|
420
546
|
}
|
|
421
547
|
}
|
|
422
|
-
logger.info(`[${adapter.
|
|
548
|
+
logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
|
|
423
549
|
try {
|
|
424
550
|
await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(session));
|
|
425
|
-
this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel:
|
|
426
|
-
if (
|
|
427
|
-
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${
|
|
551
|
+
this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
|
|
552
|
+
if (isCrossChannel) {
|
|
553
|
+
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(session));
|
|
428
554
|
}
|
|
429
555
|
}
|
|
430
556
|
catch (error) {
|
|
431
|
-
logger.error(`[${adapter.
|
|
557
|
+
logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
|
|
432
558
|
await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(session));
|
|
433
559
|
}
|
|
434
560
|
}
|
|
561
|
+
// 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
|
|
562
|
+
// suppressed 模式:中间流式文本未推送,使用最后一轮回复(回退到全文)
|
|
563
|
+
// 非 suppressed 且无流式文本:同上
|
|
564
|
+
// 非 suppressed 且有流式文本:已经逐步推送过了,不重复添加
|
|
565
|
+
// 但如果 flusher 既未发送过内容也没有 pending 内容(如 text 事件全为空),仍需兜底
|
|
566
|
+
const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
|
|
567
|
+
if (finalReplyText) {
|
|
568
|
+
if (shouldSuppress()) {
|
|
569
|
+
flusher.addText(finalReplyText);
|
|
570
|
+
}
|
|
571
|
+
else if (!streamResult.hasReceivedText || (!flusher.hasSentContent() && !flusher.hasContent())) {
|
|
572
|
+
flusher.addText(finalReplyText);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
435
575
|
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
436
576
|
await flusher.flush(true);
|
|
437
577
|
// 安全模式尾部提示:如果当前会话处于安全模式,追加提醒
|
|
@@ -444,35 +584,72 @@ ${suggestions}`, sendOpts);
|
|
|
444
584
|
}
|
|
445
585
|
// 清理 activeStreams(正常完成)
|
|
446
586
|
agent.cleanupStream(streamKey);
|
|
447
|
-
// 清除处理中状态
|
|
587
|
+
// 清除处理中状态
|
|
448
588
|
this.sessionManager.clearProcessing(session.id);
|
|
449
589
|
// 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
|
|
450
590
|
const interruptReason = this.interruptedSessions.get(session.id);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
591
|
+
if (streamResult.isError) {
|
|
592
|
+
// Agent 流正常结束但任务结果失败(权限被拒、max turns、工具链失败等)
|
|
593
|
+
const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
|
|
594
|
+
const rawSubtype = streamResult.subtype || 'agent_error';
|
|
595
|
+
const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
|
|
596
|
+
adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(session));
|
|
597
|
+
this.eventBus.publish({
|
|
598
|
+
type: 'message:error',
|
|
599
|
+
sessionId: session.id,
|
|
600
|
+
error: errorSummary,
|
|
601
|
+
errorType,
|
|
602
|
+
terminalReason: streamResult.terminalReason
|
|
603
|
+
});
|
|
604
|
+
// 仅系统级 subtype 累计安全模式(权限拒绝、max turns 等用户操作不累计)
|
|
605
|
+
if (isInfraError(rawSubtype, streamResult.terminalReason)) {
|
|
606
|
+
const chatType = message.chatType || 'private';
|
|
607
|
+
const identityRole = session.identity?.role || 'anonymous';
|
|
608
|
+
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
609
|
+
const { policy } = channelInfo;
|
|
610
|
+
if (policy.accumulateErrors(chatType, identityRole)) {
|
|
611
|
+
const newCount = await this.sessionManager.recordError(session.id, errorType, errorSummary);
|
|
612
|
+
await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
logger.message({
|
|
616
|
+
msgId: messageId,
|
|
617
|
+
sessionId: session.id,
|
|
618
|
+
dir: 'inbound',
|
|
619
|
+
status: 'failed',
|
|
620
|
+
error: errorSummary,
|
|
621
|
+
terminalReason: streamResult.terminalReason
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
// 真正的成功
|
|
626
|
+
adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
|
|
627
|
+
await this.sessionManager.recordSuccess(session.id);
|
|
628
|
+
this.eventBus.publish({
|
|
629
|
+
type: 'message:completed',
|
|
630
|
+
sessionId: session.id,
|
|
631
|
+
channel: message.channel,
|
|
632
|
+
channelId: message.channelId,
|
|
633
|
+
terminalReason: streamResult.terminalReason,
|
|
634
|
+
finalText: streamResult.lastReplyText || undefined,
|
|
635
|
+
durationMs: Date.now() - startTime,
|
|
636
|
+
timestamp: Date.now()
|
|
637
|
+
});
|
|
638
|
+
// 记录处理完成
|
|
639
|
+
logger.message({
|
|
640
|
+
msgId: messageId,
|
|
641
|
+
sessionId: session.id,
|
|
642
|
+
dir: 'inbound',
|
|
643
|
+
status: 'completed',
|
|
644
|
+
duration: Date.now() - startTime
|
|
645
|
+
});
|
|
646
|
+
}
|
|
461
647
|
const isFinallyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
462
648
|
if (isFinallyBackground) {
|
|
463
649
|
const projectName = path.basename(session.projectPath);
|
|
464
650
|
const count = this.messageCache.getCount(session.id);
|
|
465
651
|
await adapter.sendText(message.channelId, `[\u540e\u53f0-${projectName}] \u2713 任务完成 (${count}条消息已缓存)`);
|
|
466
652
|
}
|
|
467
|
-
const duration = Date.now() - startTime;
|
|
468
|
-
// 记录处理完成
|
|
469
|
-
logger.message({
|
|
470
|
-
msgId: messageId,
|
|
471
|
-
sessionId: session.id,
|
|
472
|
-
dir: 'inbound',
|
|
473
|
-
status: 'completed',
|
|
474
|
-
duration
|
|
475
|
-
});
|
|
476
653
|
// 记录发送响应
|
|
477
654
|
logger.message({
|
|
478
655
|
msgId: `${messageId}_reply`,
|
|
@@ -491,39 +668,58 @@ ${suggestions}`, sendOpts);
|
|
|
491
668
|
// 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
|
|
492
669
|
// 区分超时 / 中断 / 错误
|
|
493
670
|
const errType = classifyError(error);
|
|
671
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
672
|
+
const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
|
|
494
673
|
const procStatus = errType === ErrorType.SDK_TIMEOUT ? 'timeout'
|
|
495
674
|
: errType === ErrorType.STREAM_ERROR ? 'interrupted'
|
|
496
675
|
: 'error';
|
|
497
|
-
|
|
498
|
-
|
|
676
|
+
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
|
|
677
|
+
if (!isUserInterrupt) {
|
|
678
|
+
try {
|
|
679
|
+
adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
|
|
680
|
+
}
|
|
681
|
+
catch { }
|
|
682
|
+
}
|
|
683
|
+
// 用户主动中断时降级日志;其余仍按 error 记录
|
|
684
|
+
if (isUserInterrupt) {
|
|
685
|
+
logger.info(`[${message.channel}] Interrupted by user (${interruptReason})`);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
logger.error(`[${message.channel}] Error:`, error);
|
|
499
689
|
}
|
|
500
|
-
catch { }
|
|
501
|
-
logger.error(`[${message.channel}] Error:`, error);
|
|
502
690
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
503
|
-
const errorType = errType;
|
|
691
|
+
const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
|
|
504
692
|
this.eventBus.publish({
|
|
505
693
|
type: 'message:error',
|
|
506
|
-
sessionId:
|
|
694
|
+
sessionId: session.id,
|
|
507
695
|
error: errorMsg,
|
|
508
|
-
errorType
|
|
696
|
+
errorType
|
|
509
697
|
});
|
|
510
698
|
// 记录处理失败
|
|
511
699
|
logger.message({
|
|
512
700
|
msgId: messageId,
|
|
513
|
-
sessionId:
|
|
701
|
+
sessionId: session.id,
|
|
514
702
|
dir: 'inbound',
|
|
515
703
|
status: 'failed',
|
|
516
704
|
error: error instanceof Error ? error.message : String(error)
|
|
517
705
|
});
|
|
518
|
-
if (error instanceof Error) {
|
|
706
|
+
if (error instanceof Error && !isUserInterrupt) {
|
|
519
707
|
logger.error(`[${message.channel}] Error stack:`, error.stack);
|
|
520
708
|
}
|
|
521
709
|
// 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
|
|
710
|
+
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
|
|
711
|
+
// processEventStream 已通过 flusher 发过错误时也跳过
|
|
522
712
|
if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
|
|
523
713
|
logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
|
|
524
714
|
}
|
|
715
|
+
else if (isUserInterrupt) {
|
|
716
|
+
logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
|
|
717
|
+
}
|
|
718
|
+
else if (error?._errorAlreadySent) {
|
|
719
|
+
logger.info(`[MessageProcessor] Error already sent via flusher, skip sending duplicate message`);
|
|
720
|
+
}
|
|
525
721
|
else {
|
|
526
|
-
const userMessage = getErrorMessage(error);
|
|
722
|
+
const userMessage = getErrorMessage(error, undefined);
|
|
527
723
|
// 获取 session 用于话题回复(如果 resolveSession 已执行)
|
|
528
724
|
let sendOpts;
|
|
529
725
|
try {
|
|
@@ -558,6 +754,9 @@ ${suggestions}`, sendOpts);
|
|
|
558
754
|
async processEventStream(stream, session, flusher, resetTimer, shouldSuppress) {
|
|
559
755
|
let hasReceivedText = false;
|
|
560
756
|
let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
|
|
757
|
+
let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
|
|
758
|
+
// 追踪最后一轮 assistant 回复文本(tool_use 之后的纯文本)
|
|
759
|
+
let lastReplyText = '';
|
|
561
760
|
try {
|
|
562
761
|
for await (const event of stream) {
|
|
563
762
|
// 每收到事件重置空闲超时
|
|
@@ -570,12 +769,31 @@ ${suggestions}`, sendOpts);
|
|
|
570
769
|
logger.info(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
|
|
571
770
|
continue;
|
|
572
771
|
}
|
|
772
|
+
// session 状态变更(idle/running/requires_action)
|
|
773
|
+
if (event.type === 'state_changed') {
|
|
774
|
+
logger.info(`[MessageProcessor] Session state: ${event.state} for session: ${session.id}`);
|
|
775
|
+
this.eventBus.publish({ type: 'agent:state-changed', sessionId: session.id, state: event.state });
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// agent 状态通知(仅事件,不直出给用户)
|
|
779
|
+
if (event.type === 'status') {
|
|
780
|
+
logger.info(`[MessageProcessor] Agent status: ${event.subtype}: ${event.message}`);
|
|
781
|
+
this.eventBus.publish({
|
|
782
|
+
type: 'agent:status',
|
|
783
|
+
sessionId: session.id,
|
|
784
|
+
subtype: event.subtype,
|
|
785
|
+
message: event.message,
|
|
786
|
+
timestamp: Date.now()
|
|
787
|
+
});
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
573
790
|
const isCurrentlyBackground = await this.isBackgroundSession(session, session.channel, session.channelId);
|
|
574
791
|
// === 前台任务:正常处理所有事件 ===
|
|
575
792
|
if (!isCurrentlyBackground) {
|
|
576
793
|
// 流式文本
|
|
577
794
|
if (event.type === 'text') {
|
|
578
795
|
hasReceivedText = true;
|
|
796
|
+
lastReplyText += event.text;
|
|
579
797
|
this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
|
|
580
798
|
if (!shouldSuppress()) {
|
|
581
799
|
flusher.addText(event.text);
|
|
@@ -602,6 +820,8 @@ ${suggestions}`, sendOpts);
|
|
|
602
820
|
}
|
|
603
821
|
// 工具调用
|
|
604
822
|
if (event.type === 'tool_use') {
|
|
823
|
+
// 工具调用意味着当前文本是中间轮,重置最后回复追踪
|
|
824
|
+
lastReplyText = '';
|
|
605
825
|
this.eventBus.publish({
|
|
606
826
|
type: 'tool:use',
|
|
607
827
|
sessionId: session.id,
|
|
@@ -638,46 +858,81 @@ ${suggestions}`, sendOpts);
|
|
|
638
858
|
logger.warn(`[MessageProcessor] error event: ${event.errorType}: ${event.error}`);
|
|
639
859
|
if (!hasErrorResult && !shouldSuppress()) {
|
|
640
860
|
hasErrorResult = true;
|
|
641
|
-
flusher.addActivity(`\
|
|
861
|
+
flusher.addActivity(`\u274c ${event.error}`);
|
|
642
862
|
}
|
|
643
863
|
}
|
|
644
864
|
// 完成事件
|
|
865
|
+
// SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
|
|
866
|
+
// 仅记录状态,最终 flush(true) 在流结束后统一执行
|
|
645
867
|
if (event.type === 'complete') {
|
|
646
868
|
logger.debug(`[MessageProcessor] complete event: hasReceivedText=${hasReceivedText}, isError=${event.isError}, shouldSuppress=${shouldSuppress()}`);
|
|
869
|
+
// 自动回填会话名称
|
|
870
|
+
if (event.sessionTitle && session.name === '默认会话') {
|
|
871
|
+
await this.sessionManager.renameSession(session.id, event.sessionTitle);
|
|
872
|
+
logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
|
|
873
|
+
}
|
|
874
|
+
// 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
|
|
875
|
+
completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
|
|
647
876
|
// 失败且无前置错误输出:显示 errors 摘要
|
|
648
|
-
|
|
877
|
+
// 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
|
|
878
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
879
|
+
const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
|
|
880
|
+
if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt) {
|
|
649
881
|
const errorSummary = event.errors?.join('; ') || '\u4efb\u52a1\u6267\u884c\u5931\u8d25';
|
|
650
|
-
|
|
882
|
+
// 使用 terminalReason 提供更友好的错误提示
|
|
883
|
+
const userFriendlyMessage = event.terminalReason
|
|
884
|
+
? getErrorMessage(null, event.terminalReason)
|
|
885
|
+
: `\u274c ${errorSummary}`;
|
|
886
|
+
flusher.addActivity(userFriendlyMessage);
|
|
651
887
|
}
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
else if (!hasReceivedText) {
|
|
658
|
-
flusher.addText(event.result);
|
|
659
|
-
}
|
|
888
|
+
// 中间 complete:flush 掉已有 activities(不带 isFinal),让中间结果及时显示
|
|
889
|
+
// 最终文本留给流结束后的统一 flush(true)
|
|
890
|
+
if (flusher.hasContent()) {
|
|
891
|
+
await flusher.flushActivitiesOnly();
|
|
660
892
|
}
|
|
661
|
-
await flusher.flush(true);
|
|
662
893
|
}
|
|
663
894
|
continue;
|
|
664
895
|
}
|
|
665
|
-
// ===
|
|
896
|
+
// === 后台任务:追踪最后回复文本,但只处理 complete 事件 ===
|
|
897
|
+
if (event.type === 'text') {
|
|
898
|
+
lastReplyText += event.text;
|
|
899
|
+
}
|
|
900
|
+
else if (event.type === 'tool_use') {
|
|
901
|
+
lastReplyText = '';
|
|
902
|
+
}
|
|
666
903
|
if (event.type !== 'complete') {
|
|
667
904
|
continue;
|
|
668
905
|
}
|
|
906
|
+
// 自动回填会话名称
|
|
907
|
+
if (event.sessionTitle && session.name === '默认会话') {
|
|
908
|
+
await this.sessionManager.renameSession(session.id, event.sessionTitle);
|
|
909
|
+
logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
|
|
910
|
+
}
|
|
911
|
+
// 记录完成状态
|
|
912
|
+
completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
|
|
669
913
|
if (event.subtype === 'success') {
|
|
670
914
|
this.messageCache.addEvent(session.id, {
|
|
671
915
|
type: 'completed',
|
|
672
|
-
message: event.result || '',
|
|
916
|
+
message: lastReplyText || event.result || '',
|
|
673
917
|
timestamp: Date.now(),
|
|
674
918
|
metadata: {
|
|
675
919
|
duration: event.durationMs,
|
|
676
920
|
cost: event.costUsd
|
|
677
921
|
}
|
|
678
922
|
});
|
|
923
|
+
// 后台任务完成也纳入统计
|
|
924
|
+
this.eventBus.publish({
|
|
925
|
+
type: 'message:completed',
|
|
926
|
+
sessionId: session.id,
|
|
927
|
+
channel: session.channel,
|
|
928
|
+
channelId: session.channelId,
|
|
929
|
+
finalText: lastReplyText || event.result || undefined,
|
|
930
|
+
durationMs: event.durationMs,
|
|
931
|
+
timestamp: Date.now()
|
|
932
|
+
});
|
|
679
933
|
}
|
|
680
934
|
else if (event.isError === true) {
|
|
935
|
+
const bgErrorType = prefixErrorType(ERROR_PREFIX.AGENT, event.subtype || 'agent_error');
|
|
681
936
|
this.messageCache.addEvent(session.id, {
|
|
682
937
|
type: 'error',
|
|
683
938
|
message: event.errors?.join('\n') || '\u672a\u77e5\u9519\u8bef',
|
|
@@ -686,16 +941,42 @@ ${suggestions}`, sendOpts);
|
|
|
686
941
|
errorType: event.subtype
|
|
687
942
|
}
|
|
688
943
|
});
|
|
944
|
+
// 后台任务失败也纳入统计
|
|
945
|
+
this.eventBus.publish({
|
|
946
|
+
type: 'message:error',
|
|
947
|
+
sessionId: session.id,
|
|
948
|
+
error: event.errors?.join('; ') || '\u672a\u77e5\u9519\u8bef',
|
|
949
|
+
errorType: bgErrorType
|
|
950
|
+
});
|
|
689
951
|
}
|
|
690
952
|
}
|
|
691
953
|
}
|
|
692
954
|
catch (error) {
|
|
693
|
-
|
|
955
|
+
// User interrupt (AbortError) is expected, log at info level
|
|
956
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
957
|
+
logger.info('[MessageProcessor] Stream interrupted (AbortError)');
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
961
|
+
}
|
|
694
962
|
if (error instanceof Error && error.message.includes('process exited')) {
|
|
695
963
|
flusher.addActivity('\u274c Claude Code \u8fdb\u7a0b\u5f02\u5e38\u9000\u51fa\uff0c\u8bf7\u91cd\u8bd5');
|
|
696
964
|
}
|
|
965
|
+
// Flush any pending error activities before re-throwing,
|
|
966
|
+
// and mark the error so outer catch won't send a duplicate message
|
|
967
|
+
if (hasErrorResult || flusher.hasContent()) {
|
|
968
|
+
try {
|
|
969
|
+
await flusher.flush(true);
|
|
970
|
+
}
|
|
971
|
+
catch { }
|
|
972
|
+
if (error instanceof Error) {
|
|
973
|
+
error._errorAlreadySent = true;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
697
976
|
throw error;
|
|
698
977
|
}
|
|
978
|
+
completeResult.hasReceivedText = hasReceivedText;
|
|
979
|
+
return completeResult;
|
|
699
980
|
}
|
|
700
981
|
/**
|
|
701
982
|
* 解析文件路径,支持相对路径和绝对路径
|