evolclaw 2.2.0 → 2.4.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 +283 -95
- package/dist/channels/feishu.js +556 -96
- package/dist/channels/wechat.js +98 -74
- package/dist/cli.js +232 -57
- package/dist/config.js +185 -31
- package/dist/core/channel-loader.js +11 -4
- package/dist/core/command-handler.js +803 -247
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +217 -0
- package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
- 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} +61 -11
- package/dist/index.js +140 -57
- 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(message)).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(message)).catch(e => {
|
|
161
200
|
logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
|
|
162
201
|
});
|
|
163
202
|
}
|
|
@@ -183,13 +222,22 @@ 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
|
|
192
|
-
await this.
|
|
238
|
+
const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
|
|
239
|
+
const newCount = await this.sessionManager.recordError(session.id, prefixed, error.message);
|
|
240
|
+
await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount, message);
|
|
193
241
|
}
|
|
194
242
|
}
|
|
195
243
|
catch (statusError) {
|
|
@@ -203,18 +251,18 @@ export class MessageProcessor {
|
|
|
203
251
|
clearInterval(monitorInterval);
|
|
204
252
|
}
|
|
205
253
|
}
|
|
206
|
-
/**
|
|
207
|
-
getReplyContext(
|
|
208
|
-
return
|
|
254
|
+
/** 获取回复上下文(跟着任务走) */
|
|
255
|
+
getReplyContext(message) {
|
|
256
|
+
return message.replyContext;
|
|
209
257
|
}
|
|
210
258
|
/**
|
|
211
259
|
* 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
|
|
212
260
|
*/
|
|
213
|
-
async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors) {
|
|
261
|
+
async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors, message) {
|
|
214
262
|
if (safeModeThreshold <= 0)
|
|
215
263
|
return;
|
|
216
264
|
const health = await this.sessionManager.getHealthStatus(session.id);
|
|
217
|
-
const sendOpts = this.getReplyContext(
|
|
265
|
+
const sendOpts = this.getReplyContext(message);
|
|
218
266
|
const isThread = !!session.threadId;
|
|
219
267
|
if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
|
|
220
268
|
await this.sessionManager.setSafeMode(session.id, true);
|
|
@@ -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;
|
|
@@ -268,7 +324,7 @@ ${suggestions}`, sendOpts);
|
|
|
268
324
|
logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
|
|
269
325
|
// 记录开始处理
|
|
270
326
|
this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
|
|
271
|
-
adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(
|
|
327
|
+
adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(message));
|
|
272
328
|
logger.message({
|
|
273
329
|
msgId: messageId,
|
|
274
330
|
sessionId: session.id,
|
|
@@ -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:';
|
|
288
|
-
//
|
|
289
|
-
const replyCtx =
|
|
343
|
+
opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
|
|
344
|
+
// replyContext 跟着任务走:优先用当前 message 的,兜底用 session 的(话题会话创建时写入)
|
|
345
|
+
const replyCtx = this.getReplyContext(message);
|
|
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
|
}
|
|
@@ -304,11 +362,18 @@ ${suggestions}`, sendOpts);
|
|
|
304
362
|
// 调用 AgentRunner(含上下文过长自动 compact 重试)
|
|
305
363
|
// 设置权限审批的消息发送回调(指向当前渠道)
|
|
306
364
|
agent.setSendPrompt(async (text) => {
|
|
307
|
-
await adapter.sendText(message.channelId, text, this.getReplyContext(
|
|
365
|
+
await adapter.sendText(message.channelId, text, this.getReplyContext(message));
|
|
366
|
+
});
|
|
367
|
+
// 设置权限审批的交互上下文(支持交互卡片)
|
|
368
|
+
agent.setPermissionContext?.({
|
|
369
|
+
adapter,
|
|
370
|
+
channelId: message.channelId,
|
|
371
|
+
replyContext: this.getReplyContext(message),
|
|
372
|
+
interactionRouter: this.interactionRouter,
|
|
308
373
|
});
|
|
309
|
-
// 设置 per-session
|
|
310
|
-
const
|
|
311
|
-
agent.setMode(permissionMode);
|
|
374
|
+
// 设置 per-session 权限模式(动态默认值:owner → bypass,guest → readonly)
|
|
375
|
+
const defaultPermMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
|
|
376
|
+
agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
|
|
312
377
|
// 标记会话为处理中(实时持久化,重启后可恢复)
|
|
313
378
|
this.sessionManager.markProcessing(session.id);
|
|
314
379
|
// 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
|
|
@@ -317,15 +382,16 @@ ${suggestions}`, sendOpts);
|
|
|
317
382
|
const effectivePrompt = prevInterruptReason === 'new_message' && session.agentSessionId
|
|
318
383
|
? `【新消息插入】\n\n${message.content}\n\n【请无视之前中断继续处理】`
|
|
319
384
|
: message.content;
|
|
385
|
+
let streamResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
|
|
320
386
|
try {
|
|
321
387
|
// 动态构建运行时上下文提示
|
|
322
388
|
const contextParts = [];
|
|
389
|
+
const currentChannelType = options?.channelType || message.channel;
|
|
323
390
|
// 1. 当前环境信息
|
|
324
391
|
const peerLabel = session.identity?.role || 'unknown';
|
|
325
|
-
const sessionName = session.name || '默认会话';
|
|
326
392
|
const peerName = message.peerName || session.metadata?.peerName;
|
|
327
393
|
const envParts = [
|
|
328
|
-
`会话通道: ${
|
|
394
|
+
`会话通道: ${currentChannelType}`,
|
|
329
395
|
`当前项目: ${path.basename(absoluteProjectPath)}`,
|
|
330
396
|
];
|
|
331
397
|
if (session.name)
|
|
@@ -333,25 +399,74 @@ ${suggestions}`, sendOpts);
|
|
|
333
399
|
envParts.push(`对端身份: ${peerLabel}`);
|
|
334
400
|
if (peerName)
|
|
335
401
|
envParts.push(`对端名称: ${peerName}`);
|
|
402
|
+
if (session.chatType)
|
|
403
|
+
envParts.push(`聊天类型: ${session.chatType}`);
|
|
404
|
+
if (session.agentId && session.agentId !== 'claude')
|
|
405
|
+
envParts.push(`当前Agent: ${session.agentId}`);
|
|
336
406
|
contextParts.push(`[当前环境] ${envParts.join(' | ')}`);
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
407
|
+
// 只读模式提示
|
|
408
|
+
if (session.metadata?.permissionMode === 'readonly') {
|
|
409
|
+
contextParts.push('[只读模式] 禁止修改项目文件。如需生成文件供用户下载,请写入 .evolclaw/tmp/ 目录后使用 [SEND_FILE:] 发送');
|
|
410
|
+
}
|
|
411
|
+
// 2. 文件发送能力(按 channelType 去重,提示词只展示第一级通道名)
|
|
412
|
+
const fileChannelTypes = new Set();
|
|
413
|
+
const currentCanSend = !!channelInfo.adapter.sendFile;
|
|
414
|
+
for (const [, info] of this.channels) {
|
|
415
|
+
if (info.adapter.sendFile) {
|
|
416
|
+
fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
|
|
420
|
+
if (currentCanSend || crossChannelTypes.length > 0) {
|
|
344
421
|
const hints = [];
|
|
345
422
|
if (currentCanSend)
|
|
346
423
|
hints.push(`[SEND_FILE:路径] 发送文件到当前通道`);
|
|
347
|
-
if (
|
|
348
|
-
hints.push(`[SEND_FILE:${
|
|
424
|
+
if (crossChannelTypes.length > 0)
|
|
425
|
+
hints.push(`[SEND_FILE:${crossChannelTypes[0]}:路径] 发送文件到指定通道(可用: ${crossChannelTypes.join('/')})`);
|
|
349
426
|
contextParts.push(hints.join(','));
|
|
350
427
|
}
|
|
428
|
+
// 3. 当前通道能力
|
|
429
|
+
const capParts = [];
|
|
430
|
+
if (options?.supportsImages)
|
|
431
|
+
capParts.push('图片输入');
|
|
432
|
+
if (channelInfo.adapter.sendImage)
|
|
433
|
+
capParts.push('图片输出');
|
|
434
|
+
if (channelInfo.adapter.sendFile)
|
|
435
|
+
capParts.push('文件发送');
|
|
436
|
+
if (capParts.length > 0) {
|
|
437
|
+
contextParts.push(`[通道能力] ${capParts.join('、')}`);
|
|
438
|
+
}
|
|
439
|
+
// 4. 群聊 @ 规则:告知 agent 应该 @ 谁,由 agent 自行在回复中添加
|
|
440
|
+
if (message.chatType === 'group' && message.peerId) {
|
|
441
|
+
contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
|
|
442
|
+
}
|
|
351
443
|
const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
444
|
+
// 可重试错误(403/429/5xx)指数退避重试,最多 3 次
|
|
445
|
+
const MAX_RETRIES = 3;
|
|
446
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
447
|
+
let streamRegistered = false;
|
|
448
|
+
try {
|
|
449
|
+
const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
|
|
450
|
+
agent.registerStream(streamKey, stream);
|
|
451
|
+
streamRegistered = true;
|
|
452
|
+
streamResult = await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress);
|
|
453
|
+
break; // 成功,跳出重试循环
|
|
454
|
+
}
|
|
455
|
+
catch (retryError) {
|
|
456
|
+
if (streamRegistered) {
|
|
457
|
+
agent.cleanupStream(streamKey);
|
|
458
|
+
}
|
|
459
|
+
if (attempt < MAX_RETRIES && isRetryableError(retryError)) {
|
|
460
|
+
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s
|
|
461
|
+
logger.warn(`[MessageProcessor] Retryable error (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms:`, retryError);
|
|
462
|
+
flusher.addActivity(`⚠️ API 暂时不可用,${delay / 1000}秒后重试 (${attempt}/${MAX_RETRIES})...`);
|
|
463
|
+
await flusher.flush();
|
|
464
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
throw retryError; // 不可重试或已耗尽重试次数
|
|
468
|
+
}
|
|
469
|
+
}
|
|
355
470
|
}
|
|
356
471
|
catch (error) {
|
|
357
472
|
if (classifyError(error) === ErrorType.CONTEXT_TOO_LONG && session.agentSessionId && hasCompact(agent)) {
|
|
@@ -364,7 +479,7 @@ ${suggestions}`, sendOpts);
|
|
|
364
479
|
flusher.addActivity('\u2705 压缩完成,正在重试...');
|
|
365
480
|
const retryStream = await agent.runQuery(session.id, '上下文已自动压缩,请继续之前未完成的任务。', absoluteProjectPath, session.agentSessionId, undefined, options?.systemPromptAppend, this.sessionManager);
|
|
366
481
|
agent.registerStream(streamKey, retryStream);
|
|
367
|
-
await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
|
|
482
|
+
streamResult = await this.processEventStream(retryStream, session, flusher, resetTimer, shouldSuppress);
|
|
368
483
|
}
|
|
369
484
|
else {
|
|
370
485
|
throw new Error('CONTEXT_COMPACT_FAILED');
|
|
@@ -375,61 +490,91 @@ ${suggestions}`, sendOpts);
|
|
|
375
490
|
}
|
|
376
491
|
}
|
|
377
492
|
// 处理文件标记 - 支持 [SEND_FILE:path] 和 [SEND_FILE:channel:path]
|
|
493
|
+
// 注意:始终扫描全部文本(含中间轮),因为文件标记可能出现在任意轮次
|
|
494
|
+
// suppressed 模式下 flusher 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
|
|
378
495
|
const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
|
|
379
496
|
const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
|
|
380
|
-
const
|
|
497
|
+
const flusherText = flusher.getFinalText();
|
|
498
|
+
const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
|
|
381
499
|
const fileMatches = [...fullText.matchAll(markerPattern)];
|
|
382
500
|
for (const match of fileMatches) {
|
|
383
501
|
// 兼容旧格式 (1组) 和新格式 (2组)
|
|
384
502
|
const hasChannelGroup = match.length >= 3;
|
|
385
|
-
const
|
|
503
|
+
const targetSpec = hasChannelGroup ? (match[1] ?? undefined) : undefined;
|
|
386
504
|
const filePath = (hasChannelGroup ? match[2] : match[1]).trim();
|
|
387
505
|
if (this.isPlaceholderPath(filePath)) {
|
|
388
|
-
logger.info(`[${adapter.
|
|
506
|
+
logger.info(`[${adapter.channelName}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
|
|
389
507
|
continue;
|
|
390
508
|
}
|
|
509
|
+
// 解析目标:按实例名匹配,再按 channelType 映射
|
|
510
|
+
let targetInfo = targetSpec ? this.channels.get(targetSpec) : channelInfo;
|
|
511
|
+
let targetLabel = targetSpec || message.channel;
|
|
512
|
+
if (targetSpec && !targetInfo) {
|
|
513
|
+
// 按 channelType 查找首个匹配的实例
|
|
514
|
+
const instanceName = this.channelTypeMap.get(targetSpec);
|
|
515
|
+
if (instanceName)
|
|
516
|
+
targetInfo = this.channels.get(instanceName);
|
|
517
|
+
}
|
|
518
|
+
const currentChannelType = channelInfo.options?.channelType || adapter.channelName;
|
|
519
|
+
const isCrossChannel = targetSpec && targetSpec !== message.channel
|
|
520
|
+
&& targetSpec !== currentChannelType;
|
|
391
521
|
// 跨通道仅限 owner
|
|
392
|
-
if (
|
|
393
|
-
await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(
|
|
522
|
+
if (isCrossChannel && session.identity?.role !== 'owner') {
|
|
523
|
+
await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(message));
|
|
394
524
|
continue;
|
|
395
525
|
}
|
|
396
526
|
const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
|
|
397
527
|
if (!fs.existsSync(resolvedPath)) {
|
|
398
|
-
logger.warn(`[${adapter.
|
|
399
|
-
await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(
|
|
528
|
+
logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
|
|
529
|
+
await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(message));
|
|
400
530
|
continue;
|
|
401
531
|
}
|
|
402
532
|
// 找目标 adapter
|
|
403
|
-
const targetInfo = this.channels.get(targetChannelName);
|
|
404
533
|
if (!targetInfo) {
|
|
405
|
-
await adapter.sendText(message.channelId, `\u274c 通道 ${
|
|
534
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(message));
|
|
406
535
|
continue;
|
|
407
536
|
}
|
|
408
537
|
if (!targetInfo.adapter.sendFile) {
|
|
409
|
-
await adapter.sendText(message.channelId, `\u274c 通道 ${
|
|
538
|
+
await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(message));
|
|
410
539
|
continue;
|
|
411
540
|
}
|
|
412
541
|
// 找目标 channelId
|
|
413
542
|
let targetChannelId = message.channelId;
|
|
414
|
-
if (
|
|
415
|
-
const
|
|
416
|
-
|
|
543
|
+
if (isCrossChannel) {
|
|
544
|
+
const targetAdapterName = targetInfo.adapter.channelName;
|
|
545
|
+
const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
|
|
546
|
+
const ownerPeerId = getOwner(this.config, targetAdapterName);
|
|
547
|
+
targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
|
|
417
548
|
if (!targetChannelId) {
|
|
418
|
-
await adapter.sendText(message.channelId, `\u274c 未找到 ${
|
|
549
|
+
await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
|
|
419
550
|
continue;
|
|
420
551
|
}
|
|
421
552
|
}
|
|
422
|
-
logger.info(`[${adapter.
|
|
553
|
+
logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
|
|
423
554
|
try {
|
|
424
|
-
await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(
|
|
425
|
-
this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel:
|
|
426
|
-
if (
|
|
427
|
-
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${
|
|
555
|
+
await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(message));
|
|
556
|
+
this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
|
|
557
|
+
if (isCrossChannel) {
|
|
558
|
+
await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(message));
|
|
428
559
|
}
|
|
429
560
|
}
|
|
430
561
|
catch (error) {
|
|
431
|
-
logger.error(`[${adapter.
|
|
432
|
-
await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(
|
|
562
|
+
logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
|
|
563
|
+
await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(message));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
|
|
567
|
+
// suppressed 模式:中间流式文本未推送,使用最后一轮回复(回退到全文)
|
|
568
|
+
// 非 suppressed 且无流式文本:同上
|
|
569
|
+
// 非 suppressed 且有流式文本:已经逐步推送过了,不重复添加
|
|
570
|
+
// 但如果 flusher 既未发送过内容也没有 pending 内容(如 text 事件全为空),仍需兜底
|
|
571
|
+
const finalReplyText = streamResult.lastReplyText || streamResult.fullText;
|
|
572
|
+
if (finalReplyText) {
|
|
573
|
+
if (shouldSuppress()) {
|
|
574
|
+
flusher.addText(finalReplyText);
|
|
575
|
+
}
|
|
576
|
+
else if (!streamResult.hasReceivedText || (!flusher.hasSentContent() && !flusher.hasContent())) {
|
|
577
|
+
flusher.addText(finalReplyText);
|
|
433
578
|
}
|
|
434
579
|
}
|
|
435
580
|
// Flush 剩余内容(文件标记已在 flush 时自动移除)
|
|
@@ -440,39 +585,76 @@ ${suggestions}`, sendOpts);
|
|
|
440
585
|
const hint = session.threadId
|
|
441
586
|
? '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
|
|
442
587
|
: '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
|
|
443
|
-
await adapter.sendText(message.channelId, hint, this.getReplyContext(
|
|
588
|
+
await adapter.sendText(message.channelId, hint, this.getReplyContext(message));
|
|
444
589
|
}
|
|
445
590
|
// 清理 activeStreams(正常完成)
|
|
446
591
|
agent.cleanupStream(streamKey);
|
|
447
|
-
// 清除处理中状态
|
|
592
|
+
// 清除处理中状态
|
|
448
593
|
this.sessionManager.clearProcessing(session.id);
|
|
449
594
|
// 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
|
|
450
595
|
const interruptReason = this.interruptedSessions.get(session.id);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
596
|
+
if (streamResult.isError) {
|
|
597
|
+
// Agent 流正常结束但任务结果失败(权限被拒、max turns、工具链失败等)
|
|
598
|
+
const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
|
|
599
|
+
const rawSubtype = streamResult.subtype || 'agent_error';
|
|
600
|
+
const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
|
|
601
|
+
adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(message));
|
|
602
|
+
this.eventBus.publish({
|
|
603
|
+
type: 'message:error',
|
|
604
|
+
sessionId: session.id,
|
|
605
|
+
error: errorSummary,
|
|
606
|
+
errorType,
|
|
607
|
+
terminalReason: streamResult.terminalReason
|
|
608
|
+
});
|
|
609
|
+
// 仅系统级 subtype 累计安全模式(权限拒绝、max turns 等用户操作不累计)
|
|
610
|
+
if (isInfraError(rawSubtype, streamResult.terminalReason)) {
|
|
611
|
+
const chatType = message.chatType || 'private';
|
|
612
|
+
const identityRole = session.identity?.role || 'anonymous';
|
|
613
|
+
const safeModeThreshold = this.config.idleMonitor?.safeModeThreshold ?? 3;
|
|
614
|
+
const { policy } = channelInfo;
|
|
615
|
+
if (policy.accumulateErrors(chatType, identityRole)) {
|
|
616
|
+
const newCount = await this.sessionManager.recordError(session.id, errorType, errorSummary);
|
|
617
|
+
await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount, message);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
logger.message({
|
|
621
|
+
msgId: messageId,
|
|
622
|
+
sessionId: session.id,
|
|
623
|
+
dir: 'inbound',
|
|
624
|
+
status: 'failed',
|
|
625
|
+
error: errorSummary,
|
|
626
|
+
terminalReason: streamResult.terminalReason
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
// 真正的成功
|
|
631
|
+
adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(message));
|
|
632
|
+
await this.sessionManager.recordSuccess(session.id);
|
|
633
|
+
this.eventBus.publish({
|
|
634
|
+
type: 'message:completed',
|
|
635
|
+
sessionId: session.id,
|
|
636
|
+
channel: message.channel,
|
|
637
|
+
channelId: message.channelId,
|
|
638
|
+
terminalReason: streamResult.terminalReason,
|
|
639
|
+
finalText: streamResult.lastReplyText || undefined,
|
|
640
|
+
durationMs: Date.now() - startTime,
|
|
641
|
+
timestamp: Date.now()
|
|
642
|
+
});
|
|
643
|
+
// 记录处理完成
|
|
644
|
+
logger.message({
|
|
645
|
+
msgId: messageId,
|
|
646
|
+
sessionId: session.id,
|
|
647
|
+
dir: 'inbound',
|
|
648
|
+
status: 'completed',
|
|
649
|
+
duration: Date.now() - startTime
|
|
650
|
+
});
|
|
651
|
+
}
|
|
461
652
|
const isFinallyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
|
|
462
653
|
if (isFinallyBackground) {
|
|
463
654
|
const projectName = path.basename(session.projectPath);
|
|
464
655
|
const count = this.messageCache.getCount(session.id);
|
|
465
656
|
await adapter.sendText(message.channelId, `[\u540e\u53f0-${projectName}] \u2713 任务完成 (${count}条消息已缓存)`);
|
|
466
657
|
}
|
|
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
658
|
// 记录发送响应
|
|
477
659
|
logger.message({
|
|
478
660
|
msgId: `${messageId}_reply`,
|
|
@@ -491,44 +673,63 @@ ${suggestions}`, sendOpts);
|
|
|
491
673
|
// 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
|
|
492
674
|
// 区分超时 / 中断 / 错误
|
|
493
675
|
const errType = classifyError(error);
|
|
676
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
677
|
+
const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
|
|
494
678
|
const procStatus = errType === ErrorType.SDK_TIMEOUT ? 'timeout'
|
|
495
679
|
: errType === ErrorType.STREAM_ERROR ? 'interrupted'
|
|
496
680
|
: 'error';
|
|
497
|
-
|
|
498
|
-
|
|
681
|
+
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
|
|
682
|
+
if (!isUserInterrupt) {
|
|
683
|
+
try {
|
|
684
|
+
adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(message));
|
|
685
|
+
}
|
|
686
|
+
catch { }
|
|
687
|
+
}
|
|
688
|
+
// 用户主动中断时降级日志;其余仍按 error 记录
|
|
689
|
+
if (isUserInterrupt) {
|
|
690
|
+
logger.info(`[${message.channel}] Interrupted by user (${interruptReason})`);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
logger.error(`[${message.channel}] Error:`, error);
|
|
499
694
|
}
|
|
500
|
-
catch { }
|
|
501
|
-
logger.error(`[${message.channel}] Error:`, error);
|
|
502
695
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
503
|
-
const errorType = errType;
|
|
696
|
+
const errorType = prefixErrorType(ERROR_PREFIX.INFRA, errType);
|
|
504
697
|
this.eventBus.publish({
|
|
505
698
|
type: 'message:error',
|
|
506
|
-
sessionId:
|
|
699
|
+
sessionId: session.id,
|
|
507
700
|
error: errorMsg,
|
|
508
|
-
errorType
|
|
701
|
+
errorType
|
|
509
702
|
});
|
|
510
703
|
// 记录处理失败
|
|
511
704
|
logger.message({
|
|
512
705
|
msgId: messageId,
|
|
513
|
-
sessionId:
|
|
706
|
+
sessionId: session.id,
|
|
514
707
|
dir: 'inbound',
|
|
515
708
|
status: 'failed',
|
|
516
709
|
error: error instanceof Error ? error.message : String(error)
|
|
517
710
|
});
|
|
518
|
-
if (error instanceof Error) {
|
|
711
|
+
if (error instanceof Error && !isUserInterrupt) {
|
|
519
712
|
logger.error(`[${message.channel}] Error stack:`, error.stack);
|
|
520
713
|
}
|
|
521
714
|
// 发送用户友好的错误消息(SDK_TIMEOUT 已在 kill 级别发过提示,跳过)
|
|
715
|
+
// 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送错误提示
|
|
716
|
+
// processEventStream 已通过 flusher 发过错误时也跳过
|
|
522
717
|
if (error instanceof Error && error.message === 'SDK_TIMEOUT') {
|
|
523
718
|
logger.info(`[MessageProcessor] SDK_TIMEOUT error, skip sending duplicate message`);
|
|
524
719
|
}
|
|
720
|
+
else if (isUserInterrupt) {
|
|
721
|
+
logger.info(`[MessageProcessor] User interrupt by new_message, skip sending error message`);
|
|
722
|
+
}
|
|
723
|
+
else if (error?._errorAlreadySent) {
|
|
724
|
+
logger.info(`[MessageProcessor] Error already sent via flusher, skip sending duplicate message`);
|
|
725
|
+
}
|
|
525
726
|
else {
|
|
526
|
-
const userMessage = getErrorMessage(error);
|
|
727
|
+
const userMessage = getErrorMessage(error, undefined);
|
|
527
728
|
// 获取 session 用于话题回复(如果 resolveSession 已执行)
|
|
528
729
|
let sendOpts;
|
|
529
730
|
try {
|
|
530
731
|
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
|
|
531
|
-
sendOpts = this.getReplyContext(
|
|
732
|
+
sendOpts = this.getReplyContext(message);
|
|
532
733
|
}
|
|
533
734
|
catch { }
|
|
534
735
|
await adapter.sendText(message.channelId, userMessage, sendOpts);
|
|
@@ -539,11 +740,12 @@ ${suggestions}`, sendOpts);
|
|
|
539
740
|
* 解析会话和项目路径
|
|
540
741
|
*/
|
|
541
742
|
async resolveSession(message) {
|
|
542
|
-
//
|
|
543
|
-
const metadata = message.replyContext
|
|
743
|
+
// 话题会话创建时写入 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
|
|
744
|
+
const metadata = (message.threadId && message.replyContext)
|
|
544
745
|
? { replyContext: message.replyContext }
|
|
545
746
|
: undefined;
|
|
546
747
|
const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata, undefined, message.peerId);
|
|
748
|
+
// replyContext 不再写入 session.metadata(跟着 message 走,避免群聊多人覆盖)
|
|
547
749
|
const absoluteProjectPath = path.isAbsolute(session.projectPath)
|
|
548
750
|
? session.projectPath
|
|
549
751
|
: path.resolve(process.cwd(), session.projectPath);
|
|
@@ -558,6 +760,9 @@ ${suggestions}`, sendOpts);
|
|
|
558
760
|
async processEventStream(stream, session, flusher, resetTimer, shouldSuppress) {
|
|
559
761
|
let hasReceivedText = false;
|
|
560
762
|
let hasErrorResult = false; // 是否已有 tool_result/error 事件输出过错误
|
|
763
|
+
let completeResult = { isError: false, lastReplyText: '', fullText: '', hasReceivedText: false };
|
|
764
|
+
// 追踪最后一轮 assistant 回复文本(tool_use 之后的纯文本)
|
|
765
|
+
let lastReplyText = '';
|
|
561
766
|
try {
|
|
562
767
|
for await (const event of stream) {
|
|
563
768
|
// 每收到事件重置空闲超时
|
|
@@ -570,12 +775,31 @@ ${suggestions}`, sendOpts);
|
|
|
570
775
|
logger.info(`[MessageProcessor] Session ID updated: ${event.sessionId} for session: ${session.id}`);
|
|
571
776
|
continue;
|
|
572
777
|
}
|
|
778
|
+
// session 状态变更(idle/running/requires_action)
|
|
779
|
+
if (event.type === 'state_changed') {
|
|
780
|
+
logger.info(`[MessageProcessor] Session state: ${event.state} for session: ${session.id}`);
|
|
781
|
+
this.eventBus.publish({ type: 'agent:state-changed', sessionId: session.id, state: event.state });
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
// agent 状态通知(仅事件,不直出给用户)
|
|
785
|
+
if (event.type === 'status') {
|
|
786
|
+
logger.info(`[MessageProcessor] Agent status: ${event.subtype}: ${event.message}`);
|
|
787
|
+
this.eventBus.publish({
|
|
788
|
+
type: 'agent:status',
|
|
789
|
+
sessionId: session.id,
|
|
790
|
+
subtype: event.subtype,
|
|
791
|
+
message: event.message,
|
|
792
|
+
timestamp: Date.now()
|
|
793
|
+
});
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
573
796
|
const isCurrentlyBackground = await this.isBackgroundSession(session, session.channel, session.channelId);
|
|
574
797
|
// === 前台任务:正常处理所有事件 ===
|
|
575
798
|
if (!isCurrentlyBackground) {
|
|
576
799
|
// 流式文本
|
|
577
800
|
if (event.type === 'text') {
|
|
578
801
|
hasReceivedText = true;
|
|
802
|
+
lastReplyText += event.text;
|
|
579
803
|
this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: event.text, isFinal: false });
|
|
580
804
|
if (!shouldSuppress()) {
|
|
581
805
|
flusher.addText(event.text);
|
|
@@ -602,6 +826,8 @@ ${suggestions}`, sendOpts);
|
|
|
602
826
|
}
|
|
603
827
|
// 工具调用
|
|
604
828
|
if (event.type === 'tool_use') {
|
|
829
|
+
// 工具调用意味着当前文本是中间轮,重置最后回复追踪
|
|
830
|
+
lastReplyText = '';
|
|
605
831
|
this.eventBus.publish({
|
|
606
832
|
type: 'tool:use',
|
|
607
833
|
sessionId: session.id,
|
|
@@ -638,46 +864,81 @@ ${suggestions}`, sendOpts);
|
|
|
638
864
|
logger.warn(`[MessageProcessor] error event: ${event.errorType}: ${event.error}`);
|
|
639
865
|
if (!hasErrorResult && !shouldSuppress()) {
|
|
640
866
|
hasErrorResult = true;
|
|
641
|
-
flusher.addActivity(`\
|
|
867
|
+
flusher.addActivity(`\u274c ${event.error}`);
|
|
642
868
|
}
|
|
643
869
|
}
|
|
644
870
|
// 完成事件
|
|
871
|
+
// SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
|
|
872
|
+
// 仅记录状态,最终 flush(true) 在流结束后统一执行
|
|
645
873
|
if (event.type === 'complete') {
|
|
646
874
|
logger.debug(`[MessageProcessor] complete event: hasReceivedText=${hasReceivedText}, isError=${event.isError}, shouldSuppress=${shouldSuppress()}`);
|
|
875
|
+
// 自动回填会话名称
|
|
876
|
+
if (event.sessionTitle && session.name === '默认会话') {
|
|
877
|
+
await this.sessionManager.renameSession(session.id, event.sessionTitle);
|
|
878
|
+
logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
|
|
879
|
+
}
|
|
880
|
+
// 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
|
|
881
|
+
completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
|
|
647
882
|
// 失败且无前置错误输出:显示 errors 摘要
|
|
648
|
-
|
|
883
|
+
// 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
|
|
884
|
+
const interruptReason = this.interruptedSessions.get(session.id);
|
|
885
|
+
const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
|
|
886
|
+
if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt) {
|
|
649
887
|
const errorSummary = event.errors?.join('; ') || '\u4efb\u52a1\u6267\u884c\u5931\u8d25';
|
|
650
|
-
|
|
888
|
+
// 使用 terminalReason 提供更友好的错误提示
|
|
889
|
+
const userFriendlyMessage = event.terminalReason
|
|
890
|
+
? getErrorMessage(null, event.terminalReason)
|
|
891
|
+
: `\u274c ${errorSummary}`;
|
|
892
|
+
flusher.addActivity(userFriendlyMessage);
|
|
651
893
|
}
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
else if (!hasReceivedText) {
|
|
658
|
-
flusher.addText(event.result);
|
|
659
|
-
}
|
|
894
|
+
// 中间 complete:flush 掉已有 activities(不带 isFinal),让中间结果及时显示
|
|
895
|
+
// 最终文本留给流结束后的统一 flush(true)
|
|
896
|
+
if (flusher.hasContent()) {
|
|
897
|
+
await flusher.flushActivitiesOnly();
|
|
660
898
|
}
|
|
661
|
-
await flusher.flush(true);
|
|
662
899
|
}
|
|
663
900
|
continue;
|
|
664
901
|
}
|
|
665
|
-
// ===
|
|
902
|
+
// === 后台任务:追踪最后回复文本,但只处理 complete 事件 ===
|
|
903
|
+
if (event.type === 'text') {
|
|
904
|
+
lastReplyText += event.text;
|
|
905
|
+
}
|
|
906
|
+
else if (event.type === 'tool_use') {
|
|
907
|
+
lastReplyText = '';
|
|
908
|
+
}
|
|
666
909
|
if (event.type !== 'complete') {
|
|
667
910
|
continue;
|
|
668
911
|
}
|
|
912
|
+
// 自动回填会话名称
|
|
913
|
+
if (event.sessionTitle && session.name === '默认会话') {
|
|
914
|
+
await this.sessionManager.renameSession(session.id, event.sessionTitle);
|
|
915
|
+
logger.info(`[MessageProcessor] Auto-filled session name: ${event.sessionTitle}`);
|
|
916
|
+
}
|
|
917
|
+
// 记录完成状态
|
|
918
|
+
completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText };
|
|
669
919
|
if (event.subtype === 'success') {
|
|
670
920
|
this.messageCache.addEvent(session.id, {
|
|
671
921
|
type: 'completed',
|
|
672
|
-
message: event.result || '',
|
|
922
|
+
message: lastReplyText || event.result || '',
|
|
673
923
|
timestamp: Date.now(),
|
|
674
924
|
metadata: {
|
|
675
925
|
duration: event.durationMs,
|
|
676
926
|
cost: event.costUsd
|
|
677
927
|
}
|
|
678
928
|
});
|
|
929
|
+
// 后台任务完成也纳入统计
|
|
930
|
+
this.eventBus.publish({
|
|
931
|
+
type: 'message:completed',
|
|
932
|
+
sessionId: session.id,
|
|
933
|
+
channel: session.channel,
|
|
934
|
+
channelId: session.channelId,
|
|
935
|
+
finalText: lastReplyText || event.result || undefined,
|
|
936
|
+
durationMs: event.durationMs,
|
|
937
|
+
timestamp: Date.now()
|
|
938
|
+
});
|
|
679
939
|
}
|
|
680
940
|
else if (event.isError === true) {
|
|
941
|
+
const bgErrorType = prefixErrorType(ERROR_PREFIX.AGENT, event.subtype || 'agent_error');
|
|
681
942
|
this.messageCache.addEvent(session.id, {
|
|
682
943
|
type: 'error',
|
|
683
944
|
message: event.errors?.join('\n') || '\u672a\u77e5\u9519\u8bef',
|
|
@@ -686,16 +947,42 @@ ${suggestions}`, sendOpts);
|
|
|
686
947
|
errorType: event.subtype
|
|
687
948
|
}
|
|
688
949
|
});
|
|
950
|
+
// 后台任务失败也纳入统计
|
|
951
|
+
this.eventBus.publish({
|
|
952
|
+
type: 'message:error',
|
|
953
|
+
sessionId: session.id,
|
|
954
|
+
error: event.errors?.join('; ') || '\u672a\u77e5\u9519\u8bef',
|
|
955
|
+
errorType: bgErrorType
|
|
956
|
+
});
|
|
689
957
|
}
|
|
690
958
|
}
|
|
691
959
|
}
|
|
692
960
|
catch (error) {
|
|
693
|
-
|
|
961
|
+
// User interrupt (AbortError) is expected, log at info level
|
|
962
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
963
|
+
logger.info('[MessageProcessor] Stream interrupted (AbortError)');
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
logger.error('[MessageProcessor] Stream processing error:', error);
|
|
967
|
+
}
|
|
694
968
|
if (error instanceof Error && error.message.includes('process exited')) {
|
|
695
969
|
flusher.addActivity('\u274c Claude Code \u8fdb\u7a0b\u5f02\u5e38\u9000\u51fa\uff0c\u8bf7\u91cd\u8bd5');
|
|
696
970
|
}
|
|
971
|
+
// Flush any pending error activities before re-throwing,
|
|
972
|
+
// and mark the error so outer catch won't send a duplicate message
|
|
973
|
+
if (hasErrorResult || flusher.hasContent()) {
|
|
974
|
+
try {
|
|
975
|
+
await flusher.flush(true);
|
|
976
|
+
}
|
|
977
|
+
catch { }
|
|
978
|
+
if (error instanceof Error) {
|
|
979
|
+
error._errorAlreadySent = true;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
697
982
|
throw error;
|
|
698
983
|
}
|
|
984
|
+
completeResult.hasReceivedText = hasReceivedText;
|
|
985
|
+
return completeResult;
|
|
699
986
|
}
|
|
700
987
|
/**
|
|
701
988
|
* 解析文件路径,支持相对路径和绝对路径
|