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.
Files changed (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +283 -95
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +232 -57
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +803 -247
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +217 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
  25. package/dist/index.js +140 -57
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /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 '../agents/claude-runner.js';
4
- import { StreamFlusher } from '../utils/stream-flusher.js';
5
- import { StreamIdleMonitor } from '../utils/stream-idle-monitor.js';
6
- import { logger } from '../utils/logger.js';
7
- import { getErrorMessage, classifyError, ErrorType } from '../utils/error-utils.js';
8
- import { summarizeToolInput } from '../utils/permission-utils.js';
9
- import { getOwner } from '../config.js';
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.name, { adapter, options, policy });
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.channels.get(channelName)?.adapter;
85
+ return this.resolveChannelInfo(channelName)?.adapter;
76
86
  }
77
87
  /**
78
- * 获取渠道信息(含 policy)
88
+ * 获取渠道信息(含 policy,支持实例名和 channelType
79
89
  */
80
90
  getChannelInfo(channelName) {
81
- return this.channels.get(channelName);
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
- const channelInfo = this.channels.get(message.channel);
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: ${message.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 newCount = await this.sessionManager.recordError(session.id, errorType, error.message);
192
- await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
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
- /** session 提取渠道预构建的回复上下文 */
207
- getReplyContext(session) {
208
- return session.metadata?.replyContext;
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(session);
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 channelInfo = this.channels.get(message.channel);
289
+ const channelKey = session.metadata?.channelName || message.channel;
290
+ const channelInfo = this.resolveChannelInfo(channelKey);
242
291
  if (!channelInfo) {
243
- logger.error(`[MessageProcessor] Unknown channel: ${message.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(session));
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
- // 话题会话:使用 Channel 预构建的 replyContext(确保消息进入话题)
289
- const replyCtx = session.metadata?.replyContext;
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
- opts.replyToMessageId = message.messageId;
296
- firstReply = false;
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(session));
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 permissionMode = session.metadata?.permissionMode || 'default';
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
- `会话通道: ${message.channel}`,
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
- // 2. 文件发送能力
338
- const fileChannels = [...this.channels.entries()]
339
- .filter(([, info]) => info.adapter.sendFile)
340
- .map(([name]) => name);
341
- const currentCanSend = fileChannels.includes(message.channel);
342
- const crossChannels = fileChannels.filter(n => n !== message.channel);
343
- if (currentCanSend || crossChannels.length > 0) {
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 (crossChannels.length > 0)
348
- hints.push(`[SEND_FILE:${crossChannels[0]}:路径] 发送文件到指定通道(可用: ${crossChannels.join('/')})`);
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
- const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, session.agentSessionId, message.images, effectiveSystemPrompt, this.sessionManager);
353
- agent.registerStream(streamKey, stream);
354
- await this.processEventStream(stream, session, flusher, resetTimer, shouldSuppress);
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 fullText = flusher.getFinalText();
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 targetChannelName = hasChannelGroup ? (match[1] ?? message.channel) : message.channel;
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.name}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
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 (targetChannelName !== message.channel && session.identity?.role !== 'owner') {
393
- await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(session));
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.name}] File not found: ${resolvedPath}`);
399
- await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(session));
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 通道 ${targetChannelName} 未启用或不存在`, this.getReplyContext(session));
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 通道 ${targetChannelName} 不支持文件发送`, this.getReplyContext(session));
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 (targetChannelName !== message.channel) {
415
- const ownerPeerId = getOwner(this.config, targetChannelName);
416
- targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelName, ownerPeerId) ?? '') : '';
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 未找到 ${targetChannelName} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
549
+ await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
419
550
  continue;
420
551
  }
421
552
  }
422
- logger.info(`[${adapter.name}] Sending file via ${targetChannelName}: ${resolvedPath}`);
553
+ logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
423
554
  try {
424
- await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(session));
425
- this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetChannelName });
426
- if (targetChannelName !== message.channel) {
427
- await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetChannelName} 发送`, this.getReplyContext(session));
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.name}] Failed to send file: ${resolvedPath}`, error);
432
- await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(session));
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(session));
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
- adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
452
- await this.sessionManager.recordSuccess(session.id);
453
- this.eventBus.publish({
454
- type: 'message:completed',
455
- sessionId: session.id,
456
- channel: message.channel,
457
- channelId: message.channelId,
458
- durationMs: Date.now() - startTime,
459
- timestamp: Date.now()
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
- try {
498
- adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
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: message.channelId,
699
+ sessionId: session.id,
507
700
  error: errorMsg,
508
- errorType: String(errorType)
701
+ errorType
509
702
  });
510
703
  // 记录处理失败
511
704
  logger.message({
512
705
  msgId: messageId,
513
- sessionId: message.channelId,
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(session);
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
- // 话题会话:使用 Channel 预构建的 replyContext
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(`\u26a0\ufe0f ${event.error}`);
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
- if (event.isError && !hasErrorResult && !shouldSuppress()) {
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
- flusher.addActivity(`\u26a0\ufe0f ${errorSummary}`);
888
+ // 使用 terminalReason 提供更友好的错误提示
889
+ const userFriendlyMessage = event.terminalReason
890
+ ? getErrorMessage(null, event.terminalReason)
891
+ : `\u274c ${errorSummary}`;
892
+ flusher.addActivity(userFriendlyMessage);
651
893
  }
652
- // 成功结果文本:suppressed 模式下总是添加,否则仅在无流式文本时添加
653
- if (event.result) {
654
- if (shouldSuppress()) {
655
- flusher.addText(event.result);
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
- // === 后台任务:只处理 complete 事件,仅缓存不发送 ===
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
- logger.error('[MessageProcessor] Stream processing error:', error);
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
  * 解析文件路径,支持相对路径和绝对路径