@ynhcj/xiaoyi 2.5.2 → 2.5.4

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/dist/channel.js CHANGED
@@ -2,7 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.xiaoyiPlugin = void 0;
4
4
  const runtime_1 = require("./runtime");
5
- const file_handler_1 = require("./file-handler");
5
+ const onboarding_1 = require("./onboarding");
6
+ const xiaoyi_media_1 = require("./xiaoyi-media");
6
7
  /**
7
8
  * XiaoYi Channel Plugin
8
9
  * Implements OpenClaw ChannelPlugin interface for XiaoYi A2A protocol
@@ -26,6 +27,7 @@ exports.xiaoyiPlugin = {
26
27
  media: true,
27
28
  nativeCommands: false,
28
29
  },
30
+ onboarding: onboarding_1.xiaoyiOnboardingAdapter,
29
31
  /**
30
32
  * Config adapter - single account mode
31
33
  */
@@ -119,8 +121,11 @@ exports.xiaoyiPlugin = {
119
121
  const agentId = resolvedAccount.config.agentId;
120
122
  // Use 'to' as sessionId (it's set from incoming message's sessionId)
121
123
  const sessionId = ctx.to;
122
- // Get taskId from runtime's session mapping
123
- const taskId = runtime.getTaskIdForSession(sessionId) || `task_${Date.now()}`;
124
+ // Get taskId from runtime's session mapping (must exist - from original A2A request)
125
+ const taskId = runtime.getTaskIdForSession(sessionId);
126
+ if (!taskId) {
127
+ throw new Error(`Cannot send outbound message: No taskId found for session ${sessionId}. Outbound messages must be in response to an incoming A2A request.`);
128
+ }
124
129
  // Build A2A response message
125
130
  const response = {
126
131
  sessionId: sessionId,
@@ -160,8 +165,11 @@ exports.xiaoyiPlugin = {
160
165
  const agentId = resolvedAccount.config.agentId;
161
166
  // Use 'to' as sessionId
162
167
  const sessionId = ctx.to;
163
- // Get taskId from runtime's session mapping
164
- const taskId = runtime.getTaskIdForSession(sessionId) || `task_${Date.now()}`;
168
+ // Get taskId from runtime's session mapping (must exist - from original A2A request)
169
+ const taskId = runtime.getTaskIdForSession(sessionId);
170
+ if (!taskId) {
171
+ throw new Error(`Cannot send outbound media: No taskId found for session ${sessionId}. Outbound messages must be in response to an incoming A2A request.`);
172
+ }
165
173
  // Build A2A response message with media
166
174
  const response = {
167
175
  sessionId: sessionId,
@@ -214,6 +222,16 @@ exports.xiaoyiPlugin = {
214
222
  const { getXiaoYiRuntime } = require("./runtime");
215
223
  const runtime = getXiaoYiRuntime();
216
224
  console.log(`XiaoYi: [Message Handler] Using runtime instance: ${runtime.getInstanceId()}`);
225
+ // CRITICAL FIX: Extract and store config values at message handler level
226
+ // This prevents "Cannot read properties of undefined" errors in concurrent scenarios
227
+ // where the outer scope's resolvedAccount might become unavailable
228
+ const messageHandlerAgentId = resolvedAccount.config?.agentId;
229
+ const messageHandlerAccountId = resolvedAccount.accountId;
230
+ if (!messageHandlerAgentId) {
231
+ console.error("XiaoYi: [FATAL] agentId not available in resolvedAccount.config");
232
+ return;
233
+ }
234
+ console.log(`XiaoYi: [Message Handler] Stored config values - agentId: ${messageHandlerAgentId}, accountId: ${messageHandlerAccountId}`);
217
235
  // For message/stream, prioritize params.sessionId, fallback to top-level sessionId
218
236
  const sessionId = message.params?.sessionId || message.sessionId;
219
237
  // Validate sessionId exists
@@ -221,8 +239,6 @@ exports.xiaoyiPlugin = {
221
239
  console.error("XiaoYi: Missing sessionId in message, cannot process");
222
240
  return;
223
241
  }
224
- // Store sessionId -> taskId mapping in runtime (use params.id as taskId)
225
- runtime.setTaskIdForSession(sessionId, message.params.id);
226
242
  // Get PluginRuntime from our runtime wrapper
227
243
  const pluginRuntime = runtime.getPluginRuntime();
228
244
  if (!pluginRuntime) {
@@ -231,8 +247,8 @@ exports.xiaoyiPlugin = {
231
247
  }
232
248
  // Extract text, file, and image content from parts array
233
249
  let bodyText = "";
234
- let images = [];
235
250
  let fileAttachments = [];
251
+ const mediaFiles = [];
236
252
  for (const part of message.params.message.parts) {
237
253
  if (part.kind === "text" && part.text) {
238
254
  // Handle text content
@@ -246,35 +262,27 @@ exports.xiaoyiPlugin = {
246
262
  continue;
247
263
  }
248
264
  try {
249
- // Handle image files
250
- if ((0, file_handler_1.isImageMimeType)(mimeType)) {
251
- console.log(`XiaoYi: Processing image file: ${name} (${mimeType})`);
252
- const imageContent = await (0, file_handler_1.extractImageFromUrl)(uri, {
253
- maxBytes: 10000000, // 10MB
254
- timeoutMs: 30000, // 30 seconds
255
- });
256
- images.push(imageContent);
257
- fileAttachments.push(`[图片: ${name}]`);
258
- console.log(`XiaoYi: Successfully processed image: ${name}`);
259
- }
260
- // Handle PDF files - extract as text for now
261
- else if ((0, file_handler_1.isPdfMimeType)(mimeType)) {
262
- console.log(`XiaoYi: Processing PDF file: ${name}`);
263
- // Note: PDF text extraction requires pdfjs-dist, for now just add a placeholder
264
- fileAttachments.push(`[PDF文件: ${name} - PDF内容提取需要额外配置]`);
265
- console.log(`XiaoYi: PDF file noted: ${name} (text extraction requires pdfjs-dist)`);
266
- }
267
- // Handle text-based files
268
- else if ((0, file_handler_1.isTextMimeType)(mimeType)) {
269
- console.log(`XiaoYi: Processing text file: ${name} (${mimeType})`);
270
- const textContent = await (0, file_handler_1.extractTextFromUrl)(uri, 5000000, 30000);
271
- bodyText += `\n\n[文件内容: ${name}]\n${textContent}`;
272
- fileAttachments.push(`[文件: ${name}]`);
273
- console.log(`XiaoYi: Successfully processed text file: ${name}`);
265
+ // All files are downloaded to local disk and passed to OpenClaw
266
+ // No type validation - let Agent decide how to handle them
267
+ console.log(`XiaoYi: Processing file: ${name} (${mimeType})`);
268
+ mediaFiles.push({ uri, mimeType, name });
269
+ // For text-based files, also extract content inline
270
+ if ((0, xiaoyi_media_1.isTextMimeType)(mimeType)) {
271
+ try {
272
+ const textContent = await (0, xiaoyi_media_1.extractTextFromUrl)(uri, 5000000, 30000);
273
+ bodyText += `\n\n[文件内容: ${name}]\n${textContent}`;
274
+ fileAttachments.push(`[文件: ${name}]`);
275
+ console.log(`XiaoYi: Successfully extracted text from: ${name}`);
276
+ }
277
+ catch (textError) {
278
+ // Text extraction failed, but file is still in mediaFiles
279
+ console.warn(`XiaoYi: Text extraction failed for ${name}, will download as binary`);
280
+ fileAttachments.push(`[文件: ${name}]`);
281
+ }
274
282
  }
275
283
  else {
276
- console.warn(`XiaoYi: Unsupported file type: ${mimeType}, name: ${name}`);
277
- fileAttachments.push(`[不支持的文件类型: ${name} (${mimeType})]`);
284
+ // Binary files (images, pdf, office docs, etc.)
285
+ fileAttachments.push(`[文件: ${name}]`);
278
286
  }
279
287
  }
280
288
  catch (error) {
@@ -289,12 +297,18 @@ exports.xiaoyiPlugin = {
289
297
  if (fileAttachments.length > 0) {
290
298
  console.log(`XiaoYi: Processed ${fileAttachments.length} file(s): ${fileAttachments.join(", ")}`);
291
299
  }
292
- if (images.length > 0) {
293
- console.log(`XiaoYi: Total ${images.length} image(s) extracted for AI processing`);
300
+ // Download media files to local disk (like feishu does)
301
+ let mediaPayload = {};
302
+ if (mediaFiles.length > 0) {
303
+ console.log(`XiaoYi: Downloading ${mediaFiles.length} media file(s) to local disk...`);
304
+ const downloadedMedia = await (0, xiaoyi_media_1.downloadAndSaveMediaList)(pluginRuntime, mediaFiles, { maxBytes: 30000000, timeoutMs: 60000 });
305
+ console.log(`XiaoYi: Successfully downloaded ${downloadedMedia.length}/${mediaFiles.length} file(s)`);
306
+ mediaPayload = (0, xiaoyi_media_1.buildXiaoYiMediaPayload)(downloadedMedia);
294
307
  }
295
308
  // Determine sender ID from role
296
309
  const senderId = message.params.message.role === "user" ? "user" : message.agentId;
297
310
  // Build MsgContext for OpenClaw's message pipeline
311
+ // Include media payload so OpenClaw can access local file paths
298
312
  const msgContext = {
299
313
  Body: bodyText,
300
314
  From: senderId,
@@ -309,7 +323,24 @@ exports.xiaoyiPlugin = {
309
323
  SenderName: message.params.message.role, // Use role as sender name
310
324
  SenderId: senderId,
311
325
  OriginatingChannel: "xiaoyi",
326
+ ...mediaPayload, // Spread MediaPath, MediaPaths, MediaType, MediaTypes
312
327
  };
328
+ // Log the message context for debugging
329
+ console.log("\n" + "=".repeat(60));
330
+ console.log("XiaoYi: [DEBUG] Message Context");
331
+ console.log(" " + JSON.stringify({
332
+ Body: msgContext.Body.substring(0, 50) + "...",
333
+ From: msgContext.From,
334
+ To: msgContext.To,
335
+ SessionKey: msgContext.SessionKey,
336
+ AccountId: msgContext.AccountId,
337
+ Provider: msgContext.Provider,
338
+ Surface: msgContext.Surface,
339
+ MediaPath: msgContext.MediaPath,
340
+ MediaPaths: msgContext.MediaPaths,
341
+ MediaType: msgContext.MediaType,
342
+ }, null, 2));
343
+ console.log("=".repeat(60) + "\n");
313
344
  // Dispatch message using OpenClaw's reply dispatcher
314
345
  try {
315
346
  console.log("\n" + "=".repeat(60));
@@ -317,39 +348,82 @@ exports.xiaoyiPlugin = {
317
348
  console.log(` Session: ${sessionId}`);
318
349
  console.log(` Task ID: ${message.params.id}`);
319
350
  console.log(` User input: ${bodyText.substring(0, 50)}${bodyText.length > 50 ? "..." : ""}`);
320
- console.log(` Images: ${images.length}`);
351
+ console.log(` Images: ${mediaFiles.length}`);
321
352
  console.log("=".repeat(60) + "\n");
322
- const taskId = runtime.getTaskIdForSession(sessionId) || `task_${Date.now()}`;
353
+ // Get taskId from this message's params.id
354
+ // NOTE: We store this AFTER concurrent check to avoid overwriting active task's taskId
355
+ const currentTaskId = message.params.id;
356
+ // ==================== CONCURRENT REQUEST DETECTION ====================
357
+ // Check if this session already has an active agent run
358
+ // If so, send an immediate "busy" response and skip processing
359
+ if (runtime.isSessionActive(sessionId)) {
360
+ console.log("\n" + "=".repeat(60));
361
+ console.log(`[CONCURRENT] Session ${sessionId} has an active agent run`);
362
+ console.log(` Action: Sending busy response and skipping message`);
363
+ console.log("=".repeat(60) + "\n");
364
+ const conn = runtime.getConnection();
365
+ if (conn) {
366
+ try {
367
+ await conn.sendResponse({
368
+ sessionId: sessionId,
369
+ messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
370
+ timestamp: Date.now(),
371
+ agentId: messageHandlerAgentId,
372
+ sender: {
373
+ id: messageHandlerAgentId,
374
+ name: "OpenClaw Agent",
375
+ type: "agent",
376
+ },
377
+ content: {
378
+ type: "text",
379
+ text: "上一个任务仍在处理中,请稍后再试",
380
+ },
381
+ status: "success",
382
+ }, currentTaskId, sessionId, true, false);
383
+ console.log(`[CONCURRENT] Busy response sent to session ${sessionId}\n`);
384
+ }
385
+ catch (error) {
386
+ console.error(`[CONCURRENT] Failed to send busy response:`, error);
387
+ }
388
+ }
389
+ return; // Skip processing this concurrent request
390
+ }
391
+ // =================================================================
392
+ // Store sessionId -> taskId mapping (only after passing concurrent check)
393
+ runtime.setTaskIdForSession(sessionId, currentTaskId);
323
394
  const startTime = Date.now();
324
395
  let accumulatedText = "";
325
396
  let sentTextLength = 0; // Track sent text length for streaming
326
397
  let hasSentFinal = false; // Track if final content has been sent (to prevent duplicate isFinal=true)
327
398
  // ==================== CREATE ABORT CONTROLLER ====================
328
399
  // Create AbortController for this session to allow cancelation
329
- const { controller: abortController, signal: abortSignal } = runtime.createAbortControllerForSession(sessionId);
400
+ const abortControllerResult = runtime.createAbortControllerForSession(sessionId);
401
+ if (!abortControllerResult) {
402
+ console.error(`[ERROR] Failed to create AbortController for session ${sessionId}`);
403
+ return;
404
+ }
405
+ const { controller: abortController, signal: abortSignal } = abortControllerResult;
330
406
  // ================================================================
331
407
  // ==================== START TIMEOUT PROTECTION ====================
332
- // Start 60-second timeout timer
408
+ // Start periodic 60-second timeout timer
409
+ // Will trigger every 60 seconds until a response is received or session completes
333
410
  const timeoutConfig = runtime.getTimeoutConfig();
334
- console.log(`[TIMEOUT] Starting ${timeoutConfig.duration}ms timeout protection for session ${sessionId}`);
335
- // Define recursive timeout handler
411
+ console.log(`[TIMEOUT] Starting ${timeoutConfig.duration}ms periodic timeout protection for session ${sessionId}`);
412
+ // Define periodic timeout handler (will be called every 60 seconds)
336
413
  const createTimeoutHandler = () => {
337
414
  return async () => {
338
415
  const elapsed = Date.now() - startTime;
339
416
  console.log("\n" + "=".repeat(60));
340
417
  console.log(`[TIMEOUT] Timeout triggered for session ${sessionId}`);
341
418
  console.log(` Elapsed: ${elapsed}ms`);
342
- console.log(` Task ID: ${taskId}`);
419
+ console.log(` Task ID: ${currentTaskId}`);
343
420
  console.log("=".repeat(60) + "\n");
344
421
  const conn = runtime.getConnection();
345
422
  if (conn) {
346
423
  try {
347
- // Send status update instead of artifact-update to keep conversation active
348
- await conn.sendStatusUpdate(taskId, sessionId, timeoutConfig.message);
424
+ // Send status update to keep conversation active
425
+ await conn.sendStatusUpdate(currentTaskId, sessionId, timeoutConfig.message);
349
426
  console.log(`[TIMEOUT] Status update sent successfully to session ${sessionId}\n`);
350
- // Restart the timeout timer - allow repeated timeout messages
351
- console.log(`[TIMEOUT] Restarting timeout timer for session ${sessionId}...\n`);
352
- runtime.setTimeoutForSession(sessionId, createTimeoutHandler());
353
427
  }
354
428
  catch (error) {
355
429
  console.error(`[TIMEOUT] Failed to send status update:`, error);
@@ -358,234 +432,273 @@ exports.xiaoyiPlugin = {
358
432
  else {
359
433
  console.error(`[TIMEOUT] Connection not available, cannot send status update\n`);
360
434
  }
435
+ // Note: Timeout will trigger again in 60 seconds if still active
361
436
  };
362
437
  };
363
- // Start initial timeout
438
+ // Start periodic timeout
364
439
  runtime.setTimeoutForSession(sessionId, createTimeoutHandler());
365
440
  // ==================== END TIMEOUT PROTECTION ====================
366
- await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
367
- ctx: msgContext,
368
- cfg: config,
369
- dispatcherOptions: {
370
- deliver: async (payload, info) => {
371
- const elapsed = Date.now() - startTime;
372
- const completeText = payload.text || "";
373
- accumulatedText = completeText;
374
- const kind = info.kind; // "tool" | "block" | "final"
375
- // ==================== DEBUG LOG - ENTRY POINT ====================
376
- // Log EVERY call to deliver with full details
377
- console.log("\n" + "█".repeat(70));
378
- console.log(`📨 [DELIVER ENTRY] deliver() callback invoked`);
379
- console.log(` Session: ${sessionId}`);
380
- console.log(` Elapsed: ${elapsed}ms`);
381
- console.log(` Kind: "${kind}"`);
382
- console.log(` Text length: ${completeText.length} chars`);
383
- console.log(` Sent so far: ${sentTextLength} chars`);
384
- if (completeText.length > 0) {
385
- console.log(` Text preview: "${completeText.substring(0, 80)}${completeText.length > 80 ? "..." : ""}"`);
386
- }
387
- console.log(` Full info object:`, JSON.stringify(info, null, 2));
388
- console.log(` Full payload keys:`, Object.keys(payload));
389
- console.log("█".repeat(70) + "\n");
390
- // =================================================================
391
- // Check if session was aborted
392
- if (runtime.isSessionAborted(sessionId)) {
393
- console.log("\n" + "=".repeat(60));
394
- console.log(`[ABORT] Response received AFTER abort`);
395
- console.log(` Session: ${sessionId}`);
396
- console.log(` Kind: ${kind}`);
397
- console.log(` Elapsed: ${elapsed}ms`);
398
- console.log(` Action: DISCARDING (session was canceled)`);
399
- console.log("=".repeat(60) + "\n");
400
- return;
441
+ // ==================== CREATE STREAMING DISPATCHER ====================
442
+ // Use createReplyDispatcherWithTyping for real-time streaming feedback
443
+ const { dispatcher, replyOptions, markDispatchIdle } = pluginRuntime.channel.reply.createReplyDispatcherWithTyping({
444
+ humanDelay: 0,
445
+ onReplyStart: async () => {
446
+ const elapsed = Date.now() - startTime;
447
+ console.log("\n" + "=".repeat(60));
448
+ console.log("XiaoYi: [START] Reply started after " + elapsed + "ms");
449
+ console.log(" Session: " + sessionId);
450
+ console.log(" Task ID: " + currentTaskId);
451
+ console.log("=".repeat(60) + "\n");
452
+ // Send immediate status update to let user know Agent is working
453
+ const conn = runtime.getConnection();
454
+ if (conn) {
455
+ try {
456
+ await conn.sendStatusUpdate(currentTaskId, sessionId, "任务正在处理中,请稍后");
457
+ console.log("✓ [START] Initial status update sent\n");
401
458
  }
402
- // ==================== CHECK TIMEOUT ====================
403
- // If timeout already sent, discard this response
404
- if (runtime.isSessionTimeout(sessionId)) {
405
- console.log("\n" + "=".repeat(60));
406
- console.log(`[TIMEOUT] Response received AFTER timeout`);
407
- console.log(` Session: ${sessionId}`);
408
- console.log(` Kind: ${kind}`);
409
- console.log(` Elapsed: ${elapsed}ms`);
410
- console.log(` Action: DISCARDING (timeout message already sent)`);
411
- console.log("=".repeat(60) + "\n");
412
- return;
459
+ catch (error) {
460
+ console.error("✗ [START] Failed to send initial status update:", error);
413
461
  }
414
- // ==================== CHECK EMPTY RESPONSE ====================
415
- // If response is empty, don't clear timeout (let it trigger)
416
- if (!completeText || completeText.length === 0) {
417
- console.log("\n" + "=".repeat(60));
418
- console.log(`[STREAM] Empty ${kind} response detected`);
419
- console.log(` Session: ${sessionId}`);
420
- console.log(` Elapsed: ${elapsed}ms`);
421
- console.log(` Action: SKIPPING (empty response)`);
422
- console.log("=".repeat(60) + "\n");
423
- // Don't send anything, and don't clear timeout
424
- return;
462
+ }
463
+ },
464
+ deliver: async (payload, info) => {
465
+ const elapsed = Date.now() - startTime;
466
+ const text = payload.text || "";
467
+ const kind = info.kind;
468
+ const payloadStatus = payload.status;
469
+ // IMPORTANT: Check if this is actually the final message
470
+ // Check multiple sources: payload.status, payload.queuedFinal, AND info.kind
471
+ // info.kind is the most reliable indicator for final messages
472
+ const isFinal = payloadStatus === "final" || payload.queuedFinal === true || kind === "final";
473
+ accumulatedText = text;
474
+ console.log("\n" + "█".repeat(70));
475
+ console.log("📨 [DELIVER] Payload received");
476
+ console.log(" Session: " + sessionId);
477
+ console.log(" Elapsed: " + elapsed + "ms");
478
+ console.log(" Info Kind: \"" + kind + "\"");
479
+ console.log(" Payload Status: \"" + (payloadStatus || "unknown") + "\"");
480
+ console.log(" Is Final: " + isFinal);
481
+ console.log(" Text length: " + text.length + " chars");
482
+ console.log(" Sent so far: " + sentTextLength + " chars");
483
+ if (text.length > 0) {
484
+ console.log(" Text preview: \"" + text.substring(0, 80) + (text.length > 80 ? "..." : "") + "\"");
485
+ }
486
+ console.log("█".repeat(70) + "\n");
487
+ // Only check for abort, NOT timeout
488
+ // Timeout is just for user notification, final responses should still be delivered
489
+ if (runtime.isSessionAborted(sessionId)) {
490
+ console.log("\n" + "=".repeat(60));
491
+ console.log("[ABORT] Response received AFTER abort");
492
+ console.log(" Session: " + sessionId);
493
+ console.log(" Action: DISCARDING");
494
+ console.log("=".repeat(60) + "\n");
495
+ return;
496
+ }
497
+ // NOTE: We DON'T check timeout here anymore
498
+ // Even if timeout occurred, we should still deliver the final response
499
+ // Timeout was just to keep user informed, not to discard results
500
+ const conn = runtime.getConnection();
501
+ if (!conn) {
502
+ console.error("✗ XiaoYi: Connection not available\n");
503
+ return;
504
+ }
505
+ // ==================== FIX: Empty text handling ====================
506
+ // If text is empty but this is not final, ALWAYS send a status update
507
+ // This ensures user gets feedback for EVERY Agent activity (tool calls, subagent calls, etc.)
508
+ if ((!text || text.length === 0) && !isFinal) {
509
+ console.log("\n" + "=".repeat(60));
510
+ console.log("[STREAM] Empty " + kind + " response detected");
511
+ console.log(" Session: " + sessionId);
512
+ console.log(" Action: Sending status update (no throttling)");
513
+ console.log("=".repeat(60) + "\n");
514
+ try {
515
+ await conn.sendStatusUpdate(currentTaskId, sessionId, "任务正在处理中,请稍后");
516
+ console.log("✓ Status update sent\n");
425
517
  }
426
- const conn = runtime.getConnection();
427
- if (!conn) {
428
- console.error(`✗ XiaoYi: Connection not available\n`);
429
- return;
518
+ catch (error) {
519
+ console.error("✗ Failed to send status update:", error);
430
520
  }
431
- // ==================== STREAMING LOGIC ====================
432
- // For "block" kind: send incremental text (streaming)
433
- // For "final" kind: send complete text (final message)
434
- if (kind === "block") {
435
- // Calculate incremental text
436
- const incrementalText = completeText.slice(sentTextLength);
437
- if (incrementalText.length > 0) {
438
- console.log("\n" + "-".repeat(60));
439
- console.log(`XiaoYi: [STREAM] Incremental block received`);
440
- console.log(` Session: ${sessionId}`);
441
- console.log(` Elapsed: ${elapsed}ms`);
442
- console.log(` Total length: ${completeText.length} chars`);
443
- console.log(` Incremental: +${incrementalText.length} chars (sent: ${sentTextLength})`);
444
- console.log(` Preview: "${incrementalText.substring(0, 50)}${incrementalText.length > 50 ? "..." : ""}"`);
445
- console.log("-".repeat(60) + "\n");
446
- const response = {
447
- sessionId: sessionId,
448
- messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
449
- timestamp: Date.now(),
450
- agentId: resolvedAccount.config.agentId,
451
- sender: {
452
- id: resolvedAccount.config.agentId,
453
- name: "OpenClaw Agent",
454
- type: "agent",
455
- },
456
- content: {
457
- type: "text",
458
- text: incrementalText,
459
- },
460
- status: "success",
461
- };
462
- // Send incremental text, NOT final, append mode
463
- await conn.sendResponse(response, taskId, sessionId, false, true);
464
- console.log(`✓ XiaoYi: Stream chunk sent (+${incrementalText.length} chars)\n`);
465
- // Update sent length
466
- sentTextLength = completeText.length;
467
- }
468
- else {
469
- console.log("\n" + "-".repeat(60));
470
- console.log(`XiaoYi: [STREAM] No new text to send`);
471
- console.log(` Session: ${sessionId}`);
472
- console.log(` Total length: ${completeText.length} chars`);
473
- console.log(` Already sent: ${sentTextLength} chars`);
474
- console.log("-".repeat(60) + "\n");
521
+ return;
522
+ }
523
+ // ==================== END FIX ====================
524
+ const responseStatus = isFinal ? "success" : "processing";
525
+ const incrementalText = text.slice(sentTextLength);
526
+ const isAppend = !isFinal && incrementalText.length > 0;
527
+ if (incrementalText.length > 0 || isFinal) {
528
+ console.log("\n" + "-".repeat(60));
529
+ console.log("XiaoYi: [STREAM] Sending response");
530
+ console.log(" Response Status: " + responseStatus);
531
+ console.log(" Is Final: " + isFinal);
532
+ console.log(" Is Append: " + isAppend);
533
+ console.log("-".repeat(60) + "\n");
534
+ const response = {
535
+ sessionId: sessionId,
536
+ messageId: "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9),
537
+ timestamp: Date.now(),
538
+ agentId: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId
539
+ sender: {
540
+ id: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId
541
+ name: "OpenClaw Agent",
542
+ type: "agent",
543
+ },
544
+ content: {
545
+ type: "text",
546
+ text: isFinal ? text : incrementalText,
547
+ },
548
+ status: responseStatus,
549
+ };
550
+ // ==================== FIX: Prevent duplicate final messages ====================
551
+ // Only send isFinal=true if we haven't sent it before AND this is actually the final message
552
+ const shouldSendFinal = isFinal && !hasSentFinal;
553
+ try {
554
+ await conn.sendResponse(response, currentTaskId, sessionId, shouldSendFinal, isAppend);
555
+ console.log("✓ Sent (status=" + responseStatus + ", isFinal=" + shouldSendFinal + ", append=" + isAppend + ")\n");
556
+ // Mark that we've sent a final message (even if we're still processing subagent responses)
557
+ if (isFinal) {
558
+ hasSentFinal = true;
475
559
  }
476
560
  }
477
- else if (kind === "final") {
478
- console.log("\n" + "=".repeat(60));
479
- console.log(`XiaoYi: [STREAM] Final response received`);
480
- console.log(` Session: ${sessionId}`);
481
- console.log(` Elapsed: ${elapsed}ms`);
482
- console.log(` Total length: ${completeText.length} chars`);
483
- console.log(` Has already sent final: ${hasSentFinal}`);
484
- console.log(` Preview: "${completeText.substring(0, 100)}..."`);
485
- console.log("=".repeat(60) + "\n");
486
- // Check if we've already sent final content - prevent duplicate isFinal=true
487
- if (hasSentFinal) {
488
- console.log(`XiaoYi: [STREAM] Skipping duplicate final response (already sent)\n`);
489
- // Don't send anything, but clear timeout to prevent timeout warnings
490
- runtime.clearSessionTimeout(sessionId);
491
- return;
492
- }
561
+ catch (error) {
562
+ console.error(" Failed to send:", error);
563
+ }
564
+ sentTextLength = text.length;
565
+ }
566
+ // ==================== FIX: SubAgent-friendly cleanup logic ====================
567
+ // Only mark session as completed if we're truly done (no more subagent responses expected)
568
+ // The key insight: we should NOT cleanup on every "final" payload, because subagents
569
+ // can generate additional responses after the main agent returns "final".
570
+ //
571
+ // Instead, we let onIdle handle the cleanup, which is called after ALL processing is done.
572
+ if (isFinal) {
573
+ // Clear timeout but DON'T mark session as completed yet
574
+ // SubAgent might still send more responses
575
+ runtime.clearSessionTimeout(sessionId);
576
+ console.log("[CLEANUP] Final payload received, but NOT marking session completed yet (waiting for onIdle)\n");
577
+ }
578
+ // ==================== END FIX ====================
579
+ },
580
+ onError: (err, info) => {
581
+ console.error("\n" + "=".repeat(60));
582
+ console.error("XiaoYi: [ERROR] " + info.kind + " failed: " + String(err));
583
+ console.log("=".repeat(60) + "\n");
584
+ runtime.clearSessionTimeout(sessionId);
585
+ runtime.clearAbortControllerForSession(sessionId);
586
+ runtime.markSessionCompleted(sessionId);
587
+ },
588
+ onIdle: async () => {
589
+ const elapsed = Date.now() - startTime;
590
+ console.log("\n" + "=".repeat(60));
591
+ console.log("XiaoYi: [IDLE] Processing complete");
592
+ console.log(" Total time: " + elapsed + "ms");
593
+ console.log("=".repeat(60) + "\n");
594
+ // ==================== PUSH MESSAGE FOR BACKGROUND RESULTS ====================
595
+ // NOTE: Push logic disabled because we cannot reliably distinguish between:
596
+ // - Normal responses (should be sent via WebSocket)
597
+ // - Background task completion (should be sent via HTTP push)
598
+ // TODO: Implement proper push message detection and HTTP API call
599
+ console.log("[IDLE] All agent processing complete");
600
+ // ==================== END PUSH MESSAGE ====================
601
+ // This is called AFTER all processing is done (including subagents)
602
+ // NOW we can safely mark the session as completed
603
+ runtime.clearAbortControllerForSession(sessionId);
604
+ runtime.markSessionCompleted(sessionId);
605
+ console.log("[CLEANUP] Session marked as completed in onIdle\n");
606
+ },
607
+ });
608
+ try {
609
+ const result = await pluginRuntime.channel.reply.dispatchReplyFromConfig({
610
+ ctx: msgContext,
611
+ cfg: config,
612
+ dispatcher,
613
+ replyOptions: {
614
+ ...replyOptions,
615
+ abortSignal: abortSignal,
616
+ },
617
+ });
618
+ const { queuedFinal, counts } = result;
619
+ console.log("\n" + "=".repeat(60));
620
+ console.log("XiaoYi: [DISPATCH] Summary");
621
+ console.log(" Queued Final: " + queuedFinal);
622
+ if (counts && Object.keys(counts).length > 0) {
623
+ console.log(" Counts:", JSON.stringify(counts, null, 2));
624
+ }
625
+ console.log("=".repeat(60) + "\n");
626
+ // ==================== ANALYZE EXECUTION RESULT ====================
627
+ // Check if Agent produced any output
628
+ const hasAnyCounts = counts && ((counts.tool && counts.tool > 0) ||
629
+ (counts.block && counts.block > 0) ||
630
+ (counts.final && counts.final > 0));
631
+ if (!hasAnyCounts) {
632
+ // Scenario 1: No Agent output detected
633
+ // This could mean:
634
+ // a) SubAgent running in background (main Agent returned)
635
+ // b) Concurrent request (another Agent already running on this session)
636
+ console.log("\n" + "=".repeat(60));
637
+ console.log("[NO OUTPUT] Agent produced no output");
638
+ console.log(" Session: " + sessionId);
639
+ console.log(" Checking if there's another active Agent...");
640
+ console.log("=".repeat(60) + "\n");
641
+ // Check if there's an active Agent on this session
642
+ // We use the existence of deliver callback triggers as an indicator
643
+ // If the dispatcher's onIdle will be called later, an Agent is still running
644
+ const conn = runtime.getConnection();
645
+ if (conn) {
646
+ // IMPORTANT: Send a response to user for THIS request
647
+ // User needs to know what's happening
648
+ try {
493
649
  const response = {
494
650
  sessionId: sessionId,
495
651
  messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
496
652
  timestamp: Date.now(),
497
- agentId: resolvedAccount.config.agentId,
653
+ agentId: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId
498
654
  sender: {
499
- id: resolvedAccount.config.agentId,
655
+ id: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId
500
656
  name: "OpenClaw Agent",
501
657
  type: "agent",
502
658
  },
503
659
  content: {
504
660
  type: "text",
505
- text: completeText,
661
+ text: "任务正在处理中,请稍候...",
506
662
  },
507
663
  status: "success",
508
664
  };
509
- // Send complete text but NOT as final yet - use append mode to keep stream alive
510
- // The true isFinal=true will be sent in onIdle callback
511
- await conn.sendResponse(response, taskId, sessionId, false, true);
512
- console.log(`✓ XiaoYi: Final response content sent (${completeText.length} chars, stream kept alive)\n`);
513
- // Mark that we've sent the final content
514
- hasSentFinal = true;
515
- // Clear timeout to prevent timeout warnings, but don't mark session as completed yet
516
- runtime.clearSessionTimeout(sessionId);
517
- // Note: We DON'T call markSessionCompleted here
518
- // The session will be marked completed in onIdle when we send isFinal=true
519
- }
520
- else if (kind === "tool") {
521
- // Tool results - typically not sent as messages
522
- console.log("\n" + "-".repeat(60));
523
- console.log(`XiaoYi: [STREAM] Tool result received (not forwarded)`);
524
- console.log(` Session: ${sessionId}`);
525
- console.log(` Elapsed: ${elapsed}ms`);
526
- console.log("-".repeat(60) + "\n");
527
- }
528
- },
529
- onIdle: async () => {
530
- const elapsed = Date.now() - startTime;
531
- console.log("\n" + "=".repeat(60));
532
- console.log(`XiaoYi: [IDLE] Processing complete`);
533
- console.log(` Total time: ${elapsed}ms`);
534
- console.log(` Final length: ${accumulatedText.length} chars`);
535
- console.log(` Has sent final: ${hasSentFinal}`);
536
- // Only send final close message if we have valid content
537
- if (accumulatedText.length > 0) {
538
- const conn = runtime.getConnection();
539
- if (conn) {
540
- try {
541
- // Send the true final response with isFinal=true to properly close the stream
542
- const finalResponse = {
543
- sessionId: sessionId,
544
- messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
545
- timestamp: Date.now(),
546
- agentId: resolvedAccount.config.agentId,
547
- sender: {
548
- id: resolvedAccount.config.agentId,
549
- name: "OpenClaw Agent",
550
- type: "agent",
551
- },
552
- content: {
553
- type: "text",
554
- text: accumulatedText,
555
- },
556
- status: "success",
557
- };
558
- // Send with isFinal=true to properly close the A2A communication stream
559
- await conn.sendResponse(finalResponse, taskId, sessionId, true, false);
560
- console.log(`✓ XiaoYi: True isFinal=true sent to close stream\n`);
561
- }
562
- catch (error) {
563
- console.error(`✗ XiaoYi: Failed to send final close message:`, error);
564
- }
565
- }
566
- // Now mark session as completed and clean up
567
- runtime.markSessionCompleted(sessionId);
568
- runtime.clearAbortControllerForSession(sessionId);
569
- console.log(`[TIMEOUT] Session completed and cleaned up\n`);
665
+ // Send response with isFinal=true to close THIS request
666
+ await conn.sendResponse(response, currentTaskId, sessionId, true, false);
667
+ console.log("✓ [NO OUTPUT] Response sent to user\n");
570
668
  }
571
- else {
572
- console.log(`[TIMEOUT] No valid response, keeping timeout active\n`);
669
+ catch (error) {
670
+ console.error("✗ [NO OUTPUT] Failed to send response:", error);
573
671
  }
574
- console.log("=".repeat(60) + "\n");
575
- },
576
- },
577
- replyOptions: {
578
- abortSignal: abortSignal, // Pass abort signal to allow cancellation
579
- },
580
- images: images.length > 0 ? images : undefined,
581
- });
672
+ }
673
+ // CRITICAL: Don't cleanup resources yet!
674
+ // The original Agent might still be running and needs these resources
675
+ // onIdle will be called when the original Agent completes
676
+ console.log("[NO OUTPUT] Keeping resources alive for potential background Agent\n");
677
+ markDispatchIdle();
678
+ }
679
+ else {
680
+ // Scenario 2: Normal execution with output
681
+ // - Agent produced output synchronously
682
+ // - All cleanup is already handled in deliver/onIdle callbacks
683
+ console.log("[NORMAL] Agent produced output, cleanup handled in callbacks");
684
+ markDispatchIdle();
685
+ }
686
+ // ==================== END ANALYSIS ====================
687
+ }
688
+ catch (error) {
689
+ console.error("XiaoYi: [ERROR] Error dispatching message:", error);
690
+ // Clear timeout on error
691
+ runtime.clearSessionTimeout(sessionId);
692
+ // Clear abort controller on error
693
+ runtime.clearAbortControllerForSession(sessionId);
694
+ // Mark session as completed on error
695
+ runtime.markSessionCompleted(sessionId);
696
+ // Mark dispatcher as idle even on error
697
+ markDispatchIdle();
698
+ }
582
699
  }
583
700
  catch (error) {
584
- console.error("XiaoYi: [ERROR] Error dispatching message:", error);
585
- // Clear timeout on error
586
- runtime.clearSessionTimeout(sessionId);
587
- // Clear abort controller on error
588
- runtime.clearAbortControllerForSession(sessionId);
701
+ console.error("XiaoYi: [ERROR] Unexpected error in message handler:", error);
589
702
  }
590
703
  });
591
704
  // Setup cancel handler