"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.xiaoyiPlugin = void 0; const runtime_1 = require("./runtime"); const onboarding_1 = require("./onboarding"); const xiaoyi_media_1 = require("./xiaoyi-media"); /** * Track if message handlers have been registered to prevent duplicate registrations * when startAccount() is called multiple times due to auto-restart attempts */ let handlersRegistered = false; /** * XiaoYi Channel Plugin * Implements OpenClaw ChannelPlugin interface for XiaoYi A2A protocol * Single account mode only */ exports.xiaoyiPlugin = { id: "xiaoyi", meta: { id: "xiaoyi", label: "XiaoYi", selectionLabel: "XiaoYi (小艺)", docsPath: "/channels/xiaoyi", blurb: "小艺 A2A 协议支持,通过 WebSocket 连接。", aliases: ["xiaoyi"], }, capabilities: { chatTypes: ["direct"], polls: false, reactions: false, threads: false, media: true, nativeCommands: false, }, /** * Config schema for UI form rendering */ configSchema: { schema: { type: "object", properties: { enabled: { type: "boolean", default: false, description: "Enable XiaoYi channel", }, wsUrl1: { type: "string", default: "wss://hag.cloud.huawei.com/openclaw/v1/ws/link", description: "Primary WebSocket server URL", }, wsUrl2: { type: "string", default: "wss://116.63.174.231/openclaw/v1/ws/link", description: "Secondary WebSocket server URL", }, ak: { type: "string", description: "Access Key", }, sk: { type: "string", description: "Secret Key", }, agentId: { type: "string", description: "Agent ID", }, debug: { type: "boolean", default: false, description: "Enable debug logging", }, apiId: { type: "string", default: "", description: "API ID for push notifications", }, pushId: { type: "string", default: "", description: "Push ID for push notifications", }, taskTimeoutMs: { type: "number", default: 3600000, description: "Task timeout in milliseconds (default: 1 hour)", }, }, }, }, onboarding: onboarding_1.xiaoyiOnboardingAdapter, /** * Config adapter - single account mode */ config: { listAccountIds: (cfg) => { const channelConfig = cfg?.channels?.xiaoyi; if (!channelConfig || !channelConfig.enabled) { return []; } // Single account mode: always return "default" return ["default"]; }, resolveAccount: (cfg, accountId) => { // Single account mode: always use "default" const resolvedAccountId = "default"; // Access channel config from cfg.channels.xiaoyi const channelConfig = cfg?.channels?.xiaoyi; // If channel is not configured yet, return empty config if (!channelConfig) { return { accountId: resolvedAccountId, config: { enabled: false, wsUrl: "", wsUrl1: "", wsUrl2: "", ak: "", sk: "", agentId: "", }, enabled: false, }; } return { accountId: resolvedAccountId, config: channelConfig, enabled: channelConfig.enabled !== false, }; }, defaultAccountId: (cfg) => { const channelConfig = cfg?.channels?.xiaoyi; if (!channelConfig || !channelConfig.enabled) { return undefined; } // Single account mode: always return "default" return "default"; }, isConfigured: (account, cfg) => { // Safely check if all required fields are present and non-empty if (!account || !account.config) { return false; } const config = account.config; // Check each field is a string and has content after trimming // Note: wsUrl1/wsUrl2 are optional (defaults will be used if not provided) const hasAk = typeof config.ak === 'string' && config.ak.trim().length > 0; const hasSk = typeof config.sk === 'string' && config.sk.trim().length > 0; const hasAgentId = typeof config.agentId === 'string' && config.agentId.trim().length > 0; return hasAk && hasSk && hasAgentId; }, isEnabled: (account, cfg) => { return account?.enabled !== false; }, disabledReason: (account, cfg) => { return "Channel is disabled in configuration"; }, unconfiguredReason: (account, cfg) => { return "Missing required configuration: ak, sk, or agentId (wsUrl1/wsUrl2 are optional, defaults will be used)"; }, describeAccount: (account, cfg) => ({ accountId: account.accountId, name: 'XiaoYi', enabled: account.enabled, configured: Boolean(account.config?.ak && account.config?.sk && account.config?.agentId), }), }, /** * Outbound adapter - send messages */ outbound: { deliveryMode: "direct", textChunkLimit: 4000, sendText: async (ctx) => { const runtime = (0, runtime_1.getXiaoYiRuntime)(); const connection = runtime.getConnection(); if (!connection || !connection.isReady()) { throw new Error("XiaoYi channel not connected"); } // Get account config to retrieve agentId const resolvedAccount = ctx.account; const agentId = resolvedAccount.config.agentId; // Use 'to' as sessionId (it's set from incoming message's sessionId) const sessionId = ctx.to; // Get taskId from runtime's session mapping (must exist - from original A2A request) const taskId = runtime.getTaskIdForSession(sessionId); if (!taskId) { throw new Error(`Cannot send outbound message: No taskId found for session ${sessionId}. Outbound messages must be in response to an incoming A2A request.`); } // Build A2A response message const response = { sessionId: sessionId, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), agentId: agentId, sender: { id: agentId, name: "OpenClaw Agent", type: "agent", }, content: { type: "text", text: ctx.text, }, context: ctx.replyToId ? { replyToMessageId: ctx.replyToId, } : undefined, status: "success", }; // Send via WebSocket with taskId and sessionId await connection.sendResponse(response, taskId, sessionId); return { channel: "xiaoyi", messageId: response.messageId, conversationId: sessionId, timestamp: response.timestamp, }; }, sendMedia: async (ctx) => { const runtime = (0, runtime_1.getXiaoYiRuntime)(); const connection = runtime.getConnection(); if (!connection || !connection.isReady()) { throw new Error("XiaoYi channel not connected"); } const resolvedAccount = ctx.account; const agentId = resolvedAccount.config.agentId; // Use 'to' as sessionId const sessionId = ctx.to; // Get taskId from runtime's session mapping (must exist - from original A2A request) const taskId = runtime.getTaskIdForSession(sessionId); if (!taskId) { throw new Error(`Cannot send outbound media: No taskId found for session ${sessionId}. Outbound messages must be in response to an incoming A2A request.`); } // Build A2A response message with media const response = { sessionId: sessionId, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), agentId: agentId, sender: { id: agentId, name: "OpenClaw Agent", type: "agent", }, content: { type: "image", // Assume image for now, could be extended text: ctx.text, mediaUrl: ctx.mediaUrl, }, context: ctx.replyToId ? { replyToMessageId: ctx.replyToId, } : undefined, status: "success", }; await connection.sendResponse(response, taskId, sessionId); return { channel: "xiaoyi", messageId: response.messageId, conversationId: sessionId, timestamp: response.timestamp, }; }, }, /** * Gateway adapter - manage connections */ gateway: { startAccount: async (ctx) => { console.log("XiaoYi: startAccount() called - START"); const runtime = (0, runtime_1.getXiaoYiRuntime)(); const resolvedAccount = ctx.account; const config = ctx.cfg; // Start WebSocket connection (single account mode) // Wrap in try-catch to prevent startup errors from causing auto-restart let connection = null; try { await runtime.start(resolvedAccount.config); connection = runtime.getConnection(); } catch (error) { console.error("XiaoYi: [STARTUP] Failed to start WebSocket connection:", error); // Don't throw - let the connection retry logic handle reconnection // The runtime.start() will handle reconnection internally } // Setup message handler IMMEDIATELY after connection is established if (!connection) { connection = runtime.getConnection(); } if (!connection) { console.warn("XiaoYi: [STARTUP] No WebSocket connection available yet, will retry..."); // Throw error to prevent auto-restart - let runtime handle reconnection // The runtime.start() will keep trying to reconnect internally throw new Error("XiaoYi: WebSocket connection not available, runtime will retry"); } // Only register handlers once to prevent duplicate message processing // when startAccount() is called multiple times due to auto-restart attempts if (!handlersRegistered) { console.log("XiaoYi: [STARTUP] Registering message and cancel handlers"); // Setup message handler with try-catch to prevent individual message errors from crashing the channel connection.on("message", async (message) => { // CRITICAL: Use dynamic require to get the latest runtime module after hot-reload const { getXiaoYiRuntime } = require("./runtime"); const runtime = getXiaoYiRuntime(); console.log(`XiaoYi: [Message Handler] Using runtime instance: ${runtime.getInstanceId()}`); // CRITICAL FIX: Extract and store config values at message handler level // This prevents "Cannot read properties of undefined" errors in concurrent scenarios // where the outer scope's resolvedAccount might become unavailable const messageHandlerAgentId = resolvedAccount.config?.agentId; const messageHandlerAccountId = resolvedAccount.accountId; const messageHandlerConfig = resolvedAccount.config; if (!messageHandlerAgentId) { console.error("XiaoYi: [FATAL] agentId not available in resolvedAccount.config"); return; } // Set task timeout time from configuration runtime.setTaskTimeout(messageHandlerConfig.taskTimeoutMs || 3600000); console.log(`XiaoYi: [Message Handler] Stored config values - agentId: ${messageHandlerAgentId}, accountId: ${messageHandlerAccountId}`); // For message/stream, prioritize params.sessionId, fallback to top-level sessionId const sessionId = message.params?.sessionId || message.sessionId; // Validate sessionId exists if (!sessionId) { console.error("XiaoYi: Missing sessionId in message, cannot process"); return; } // Get PluginRuntime from our runtime wrapper const pluginRuntime = runtime.getPluginRuntime(); if (!pluginRuntime) { console.error("PluginRuntime not available"); return; } // Extract text, file, and image content from parts array let bodyText = ""; let fileAttachments = []; const mediaFiles = []; for (const part of message.params.message.parts) { if (part.kind === "text" && part.text) { // Handle text content bodyText += part.text; } else if (part.kind === "file" && part.file) { // Handle file content const { uri, mimeType, name } = part.file; if (!uri) { console.warn(`XiaoYi: File part without URI, skipping: ${name}`); continue; } try { // All files are downloaded to local disk and passed to OpenClaw // No type validation - let Agent decide how to handle them console.log(`XiaoYi: Processing file: ${name} (${mimeType})`); mediaFiles.push({ uri, mimeType, name }); // For text-based files, also extract content inline if ((0, xiaoyi_media_1.isTextMimeType)(mimeType)) { try { const textContent = await (0, xiaoyi_media_1.extractTextFromUrl)(uri, 5000000, 30000); bodyText += `\n\n[文件内容: ${name}]\n${textContent}`; fileAttachments.push(`[文件: ${name}]`); console.log(`XiaoYi: Successfully extracted text from: ${name}`); } catch (textError) { // Text extraction failed, but file is still in mediaFiles console.warn(`XiaoYi: Text extraction failed for ${name}, will download as binary`); fileAttachments.push(`[文件: ${name}]`); } } else { // Binary files (images, pdf, office docs, etc.) fileAttachments.push(`[文件: ${name}]`); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`XiaoYi: Failed to process file ${name}: ${errorMsg}`); fileAttachments.push(`[文件处理失败: ${name} - ${errorMsg}]`); } } // Ignore kind: "data" as per user request } // Log summary of processed attachments if (fileAttachments.length > 0) { console.log(`XiaoYi: Processed ${fileAttachments.length} file(s): ${fileAttachments.join(", ")}`); } // Download media files to local disk (like feishu does) let mediaPayload = {}; if (mediaFiles.length > 0) { console.log(`XiaoYi: Downloading ${mediaFiles.length} media file(s) to local disk...`); const downloadedMedia = await (0, xiaoyi_media_1.downloadAndSaveMediaList)(pluginRuntime, mediaFiles, { maxBytes: 30000000, timeoutMs: 60000 }); console.log(`XiaoYi: Successfully downloaded ${downloadedMedia.length}/${mediaFiles.length} file(s)`); mediaPayload = (0, xiaoyi_media_1.buildXiaoYiMediaPayload)(downloadedMedia); } // Determine sender ID from role const senderId = message.params.message.role === "user" ? "user" : message.agentId; // Build MsgContext for OpenClaw's message pipeline // Include media payload so OpenClaw can access local file paths const msgContext = { Body: bodyText, From: senderId, To: sessionId, SessionKey: `xiaoyi:${resolvedAccount.accountId}:${sessionId}`, AccountId: resolvedAccount.accountId, MessageSid: message.id, // Use top-level id as message sequence number Timestamp: Date.now(), // Generate timestamp since new format doesn't include it Provider: "xiaoyi", Surface: "xiaoyi", ChatType: "direct", SenderName: message.params.message.role, // Use role as sender name SenderId: senderId, OriginatingChannel: "xiaoyi", ...mediaPayload, // Spread MediaPath, MediaPaths, MediaType, MediaTypes }; // Log the message context for debugging console.log("\n" + "=".repeat(60)); console.log("XiaoYi: [DEBUG] Message Context"); console.log(" " + JSON.stringify({ Body: msgContext.Body.substring(0, 50) + "...", From: msgContext.From, To: msgContext.To, SessionKey: msgContext.SessionKey, AccountId: msgContext.AccountId, Provider: msgContext.Provider, Surface: msgContext.Surface, MediaPath: msgContext.MediaPath, MediaPaths: msgContext.MediaPaths, MediaType: msgContext.MediaType, }, null, 2)); console.log("=".repeat(60) + "\n"); // Dispatch message using OpenClaw's reply dispatcher try { console.log("\n" + "=".repeat(60)); console.log(`XiaoYi: [MESSAGE] Processing user message`); console.log(` Session: ${sessionId}`); console.log(` Task ID: ${message.params.id}`); console.log(` User input: ${bodyText.substring(0, 50)}${bodyText.length > 50 ? "..." : ""}`); console.log(` Images: ${mediaFiles.length}`); console.log("=".repeat(60) + "\n"); // Get taskId from this message's params.id // NOTE: We store this AFTER concurrent check to avoid overwriting active task's taskId const currentTaskId = message.params.id; // ==================== CONCURRENT REQUEST DETECTION ==================== // Check if this session already has an active agent run // If so, send an immediate "busy" response and skip processing if (runtime.isSessionActive(sessionId)) { console.log("\n" + "=".repeat(60)); console.log(`[CONCURRENT] Session ${sessionId} has an active agent run`); console.log(` Action: Sending busy response and skipping message`); console.log("=".repeat(60) + "\n"); const conn = runtime.getConnection(); if (conn) { try { await conn.sendResponse({ sessionId: sessionId, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), agentId: messageHandlerAgentId, sender: { id: messageHandlerAgentId, name: "OpenClaw Agent", type: "agent", }, content: { type: "text", text: "上一个任务仍在处理中,请稍后再试", }, status: "success", }, currentTaskId, sessionId, true, false); console.log(`[CONCURRENT] Busy response sent to session ${sessionId}\n`); } catch (error) { console.error(`[CONCURRENT] Failed to send busy response:`, error); } } return; // Skip processing this concurrent request } // ================================================================= // Store sessionId -> taskId mapping (only after passing concurrent check) runtime.setTaskIdForSession(sessionId, currentTaskId); const startTime = Date.now(); let accumulatedText = ""; let sentTextLength = 0; // Track sent text length for streaming // ==================== CREATE ABORT CONTROLLER ==================== // Create AbortController for this session to allow cancelation const abortControllerResult = runtime.createAbortControllerForSession(sessionId); if (!abortControllerResult) { console.error(`[ERROR] Failed to create AbortController for session ${sessionId}`); return; } const { controller: abortController, signal: abortSignal } = abortControllerResult; // ================================================================ // ==================== 1-HOUR TASK TIMEOUT PROTECTION ==================== // Start 1-hour task timeout timer // Will trigger once after 1 hour if no response received console.log(`[TASK TIMEOUT] Starting ${messageHandlerConfig.taskTimeoutMs || 3600000}ms task timeout protection for session ${sessionId}`); // Define task timeout handler (will be called once after 1 hour) const createTaskTimeoutHandler = () => { return async (timeoutSessionId, timeoutTaskId) => { const elapsed = Date.now() - startTime; console.log("\n" + "=".repeat(60)); console.log(`[TASK TIMEOUT] 1-hour timeout triggered for session ${sessionId}`); console.log(` Elapsed: ${elapsed}ms`); console.log(` Task ID: ${currentTaskId}`); console.log("=".repeat(60) + "\n"); const conn = runtime.getConnection(); if (conn) { try { // Send default message with isFinal=true await conn.sendResponse({ sessionId: timeoutSessionId, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), agentId: messageHandlerAgentId, sender: { id: messageHandlerAgentId, name: "OpenClaw Agent", type: "agent" }, content: { type: "text", text: "任务还在处理中,完成后将提醒您~" }, status: "success", }, timeoutTaskId, timeoutSessionId, true, false); // isFinal=true console.log(`[TASK TIMEOUT] Default message sent (isFinal=true) to session ${timeoutSessionId}\n`); } catch (error) { console.error(`[TASK TIMEOUT] Failed to send default message:`, error); } } // Cancel 60-second periodic timeout runtime.clearSessionTimeout(timeoutSessionId); // Mark as waiting for push state runtime.markSessionWaitingForPush(timeoutSessionId, timeoutTaskId); }; }; // Start 1-hour task timeout timer runtime.setTaskTimeoutForSession(sessionId, currentTaskId, createTaskTimeoutHandler()); // Also start 60-second periodic timeout for status updates (for messages before 1-hour timeout) const timeoutConfig = runtime.getTimeoutConfig(); const createPeriodicTimeoutHandler = () => { return async () => { // Skip if already waiting for push (1-hour timeout already triggered) if (runtime.isSessionWaitingForPush(sessionId, currentTaskId)) { return; } const elapsed = Date.now() - startTime; console.log("\n" + "=".repeat(60)); console.log(`[TIMEOUT] Periodic timeout triggered for session ${sessionId}`); console.log(` Elapsed: ${elapsed}ms`); console.log("=".repeat(60) + "\n"); const conn = runtime.getConnection(); if (conn) { try { await conn.sendStatusUpdate(currentTaskId, sessionId, timeoutConfig.message); console.log(`[TIMEOUT] Status update sent successfully to session ${sessionId}\n`); } catch (error) { console.error(`[TIMEOUT] Failed to send status update:`, error); } } }; }; runtime.setTimeoutForSession(sessionId, createPeriodicTimeoutHandler()); // ==================== END TASK TIMEOUT PROTECTION ==================== // ==================== CREATE STREAMING DISPATCHER ==================== // ==================== DISPATCHER OPTIONS ==================== // Define dispatcher options for dispatchInboundMessageWithBufferedDispatcher // This uses the standard OpenClaw pattern which properly handles dispatcher lifecycle const dispatcherOptions = { humanDelay: 0, onReplyStart: async () => { const elapsed = Date.now() - startTime; console.log("\n" + "=".repeat(60)); console.log("XiaoYi: [START] Reply started after " + elapsed + "ms"); console.log(" Session: " + sessionId); console.log(" Task ID: " + currentTaskId); console.log("=".repeat(60) + "\n"); // Send immediate status update to let user know Agent is working const conn = runtime.getConnection(); if (conn) { try { await conn.sendStatusUpdate(currentTaskId, sessionId, "任务正在处理中,请稍后"); console.log("✓ [START] Initial status update sent\n"); } catch (error) { console.error("✗ [START] Failed to send initial status update:", error); } } }, deliver: async (payload, info) => { const elapsed = Date.now() - startTime; const text = payload.text || ""; const kind = info.kind; const payloadStatus = payload.status; // IMPORTANT: Check if this is actually the final message // Check multiple sources: payload.status, payload.queuedFinal, AND info.kind // info.kind is the most reliable indicator for final messages const isFinal = payloadStatus === "final" || payload.queuedFinal === true || kind === "final"; // If session is waiting for push (1-hour timeout occurred), ignore non-final responses if (runtime.isSessionWaitingForPush(sessionId, currentTaskId) && !payload.queuedFinal && info.kind !== "final") { console.log(`[TASK TIMEOUT] Ignoring non-final response for session ${sessionId} (already timed out)`); return; } accumulatedText = text; console.log("\n" + "█".repeat(70)); console.log("📨 [DELIVER] Payload received"); console.log(" Session: " + sessionId); console.log(" Elapsed: " + elapsed + "ms"); console.log(" Info Kind: \"" + kind + "\""); console.log(" Payload Status: \"" + (payloadStatus || "unknown") + "\""); console.log(" Is Final: " + isFinal); console.log(" Text length: " + text.length + " chars"); console.log(" Sent so far: " + sentTextLength + " chars"); if (text.length > 0) { console.log(" Text preview: \"" + text.substring(0, 80) + (text.length > 80 ? "..." : "") + "\""); } console.log("█".repeat(70) + "\n"); // Only check for abort, NOT timeout // Timeout is just for user notification, final responses should still be delivered if (runtime.isSessionAborted(sessionId)) { console.log("\n" + "=".repeat(60)); console.log("[ABORT] Response received AFTER abort"); console.log(" Session: " + sessionId); console.log(" Action: DISCARDING"); console.log("=".repeat(60) + "\n"); return; } // NOTE: We DON'T check timeout here anymore // Even if timeout occurred, we should still deliver the final response // Timeout was just to keep user informed, not to discard results const conn = runtime.getConnection(); if (!conn) { console.error("✗ XiaoYi: Connection not available\n"); return; } // ==================== FIX: Empty text handling ==================== // If text is empty but this is not final, ALWAYS send a status update // This ensures user gets feedback for EVERY Agent activity (tool calls, subagent calls, etc.) if ((!text || text.length === 0) && !isFinal) { console.log("\n" + "=".repeat(60)); console.log("[STREAM] Empty " + kind + " response detected"); console.log(" Session: " + sessionId); console.log(" Action: Sending status update (no throttling)"); console.log("=".repeat(60) + "\n"); try { await conn.sendStatusUpdate(currentTaskId, sessionId, "任务正在处理中,请稍后"); console.log("✓ Status update sent\n"); } catch (error) { console.error("✗ Failed to send status update:", error); } return; } // ==================== END FIX ==================== const responseStatus = isFinal ? "success" : "processing"; const incrementalText = text.slice(sentTextLength); // ==================== FIX: Always send isFinal=false in deliver ==================== // All responses from deliver callback are sent with isFinal=false // The final isFinal=true will be sent in onIdle callback when ALL processing is complete const shouldSendFinal = false; // Always use append=true for all responses const isAppend = true; if (incrementalText.length > 0 || isFinal) { console.log("\n" + "-".repeat(60)); console.log("XiaoYi: [STREAM] Sending response"); console.log(" Response Status: " + responseStatus); console.log(" Is Final: " + isFinal); console.log(" Is Append: " + isAppend); console.log("-".repeat(60) + "\n"); const response = { sessionId: sessionId, messageId: "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9), timestamp: Date.now(), agentId: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId sender: { id: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId name: "OpenClaw Agent", type: "agent", }, content: { type: "text", text: isFinal ? text : incrementalText, }, status: responseStatus, }; try { await conn.sendResponse(response, currentTaskId, sessionId, shouldSendFinal, isAppend); console.log("✓ Sent (status=" + responseStatus + ", isFinal=false, append=" + isAppend + ")\n"); } catch (error) { console.error("✗ Failed to send:", error); } sentTextLength = text.length; } // ==================== FIX: SubAgent-friendly cleanup logic ==================== // Only mark session as completed if we're truly done (no more subagent responses expected) // The key insight: we should NOT cleanup on every "final" payload, because subagents // can generate additional responses after the main agent returns "final". // // Instead, we let onIdle handle the cleanup, which is called after ALL processing is done. if (isFinal) { // Clear timeout but DON'T mark session as completed yet // SubAgent might still send more responses runtime.clearSessionTimeout(sessionId); console.log("[CLEANUP] Final payload received, but NOT marking session completed yet (waiting for onIdle)\n"); } // ==================== END FIX ==================== }, onError: (err, info) => { console.error("\n" + "=".repeat(60)); console.error("XiaoYi: [ERROR] " + info.kind + " failed: " + String(err)); console.log("=".repeat(60) + "\n"); runtime.clearSessionTimeout(sessionId); runtime.clearTaskTimeoutForSession(sessionId); runtime.clearSessionWaitingForPush(sessionId, currentTaskId); runtime.clearAbortControllerForSession(sessionId); // Check if session was cleared const conn = runtime.getConnection(); if (conn && conn.isSessionPendingCleanup(sessionId)) { conn.forceCleanupSession(sessionId); } runtime.markSessionCompleted(sessionId); }, onIdle: async () => { const elapsed = Date.now() - startTime; console.log("\n" + "=".repeat(60)); console.log("XiaoYi: [IDLE] Processing complete"); console.log(" Total time: " + elapsed + "ms"); console.log("=".repeat(60) + "\n"); // Clear 1-hour task timeout timer runtime.clearTaskTimeoutForSession(sessionId); // ==================== CHECK IF SESSION WAS CLEARED ==================== const conn = runtime.getConnection(); const isPendingCleanup = conn && conn.isSessionPendingCleanup(sessionId); const isWaitingForPush = runtime.isSessionWaitingForPush(sessionId, currentTaskId); // ==================== PUSH NOTIFICATION LOGIC ==================== // Send push if task timeout was triggered (regardless of session cleanup status) // This ensures users get notified when long-running tasks complete if (isWaitingForPush && accumulatedText.length > 0) { const pushReason = isPendingCleanup ? `Session ${sessionId} was cleared` : `Session ${sessionId} task timeout triggered`; console.log(`[CLEANUP] ${pushReason}, sending push notification`); try { const { XiaoYiPushService } = require("./push"); const pushService = new XiaoYiPushService(messageHandlerConfig); if (pushService.isConfigured()) { // Generate summary const summary = accumulatedText.length > 30 ? accumulatedText.substring(0, 30) + "..." : accumulatedText; await pushService.sendPush(summary, "后台任务已完成:" + summary); console.log("✓ [CLEANUP] Push notification sent\n"); // Clear push waiting state for this specific task runtime.clearSessionWaitingForPush(sessionId, currentTaskId); } else { console.log("[CLEANUP] Push not configured, skipping notification"); runtime.clearSessionWaitingForPush(sessionId, currentTaskId); } } catch (error) { console.error("[CLEANUP] Error sending push:", error); runtime.clearSessionWaitingForPush(sessionId, currentTaskId); } // If session was cleared, update cleanup state if (isPendingCleanup) { conn?.updateAccumulatedTextForCleanup(sessionId, accumulatedText); conn?.forceCleanupSession(sessionId); } } // ==================== NORMAL WEBSOCKET FLOW (no timeout triggered) ==================== else if (!isPendingCleanup) { // Normal flow: send WebSocket response (no timeout, session still active) const conn = runtime.getConnection(); if (conn) { try { await conn.sendResponse({ sessionId: sessionId, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), agentId: messageHandlerAgentId, sender: { id: messageHandlerAgentId, name: "OpenClaw Agent", type: "agent", }, content: { type: "text", text: accumulatedText, }, status: "success", }, currentTaskId, sessionId, true, true); // isFinal=true, append=true console.log("✓ [IDLE] Final response sent (isFinal=true)\n"); } catch (error) { console.error("✗ [IDLE] Failed to send final response:", error); } } } // ==================== SESSION CLEARED BUT NO TIMEOUT ==================== else { // Session was cleared but no timeout triggered - edge case, just cleanup console.log(`[CLEANUP] Session ${sessionId} was cleared but no push needed`); conn?.forceCleanupSession(sessionId); } // This is called AFTER all processing is done (including subagents) // NOW we can safely mark the session as completed runtime.clearAbortControllerForSession(sessionId); runtime.markSessionCompleted(sessionId); console.log("[CLEANUP] Session marked as completed in onIdle\n"); }, }; try { // Use standard OpenClaw pattern with dispatchReplyWithBufferedBlockDispatcher // This properly handles dispatcher lifecycle: // 1. Calls dispatcher.markComplete() after run() completes // 2. Waits for waitForIdle() to ensure all deliveries are done // 3. Then calls markDispatchIdle() in the finally block const result = await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: msgContext, cfg: config, dispatcherOptions: dispatcherOptions, replyOptions: { abortSignal: abortSignal, }, }); const { queuedFinal, counts } = result; console.log("\n" + "=".repeat(60)); console.log("XiaoYi: [DISPATCH] Summary"); console.log(" Queued Final: " + queuedFinal); if (counts && Object.keys(counts).length > 0) { console.log(" Counts:", JSON.stringify(counts, null, 2)); } console.log("=".repeat(60) + "\n"); // ==================== ANALYZE EXECUTION RESULT ==================== // Check if Agent produced any output const hasAnyCounts = counts && ((counts.tool && counts.tool > 0) || (counts.block && counts.block > 0) || (counts.final && counts.final > 0)); if (!hasAnyCounts) { // Scenario 1: No Agent output detected // This could mean: // a) SubAgent running in background (main Agent returned) // b) Concurrent request (another Agent already running on this session) console.log("\n" + "=".repeat(60)); console.log("[NO OUTPUT] Agent produced no output"); console.log(" Session: " + sessionId); console.log(" Checking if there's another active Agent..."); console.log("=".repeat(60) + "\n"); // Check if there's an active Agent on this session // We use the existence of deliver callback triggers as an indicator // If the dispatcher's onIdle will be called later, an Agent is still running const conn = runtime.getConnection(); if (conn) { // IMPORTANT: Send a response to user for THIS request // User needs to know what's happening try { const response = { sessionId: sessionId, messageId: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), agentId: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId sender: { id: messageHandlerAgentId, // Use stored value instead of resolvedAccount.config.agentId name: "OpenClaw Agent", type: "agent", }, content: { type: "text", text: "任务正在处理中,请稍候...", }, status: "success", }; // Send response with isFinal=true to close THIS request await conn.sendResponse(response, currentTaskId, sessionId, true, true); console.log("✓ [NO OUTPUT] Response sent to user\n"); } catch (error) { console.error("✗ [NO OUTPUT] Failed to send response:", error); } } // CRITICAL: Don't cleanup resources yet! // The original Agent might still be running and needs these resources // onIdle will be called when the original Agent completes console.log("[NO OUTPUT] Keeping resources alive for potential background Agent\n"); // Note: No need to call markDispatchIdle() manually // dispatchInboundMessageWithBufferedDispatcher handles this in its finally block } else { // Scenario 2: Normal execution with output // - Agent produced output synchronously // - All cleanup is already handled in deliver/onIdle callbacks console.log("[NORMAL] Agent produced output, cleanup handled in callbacks"); // Note: No need to call markDispatchIdle() manually // dispatchInboundMessageWithBufferedDispatcher handles this in its finally block } // ==================== END ANALYSIS ==================== } catch (error) { console.error("XiaoYi: [ERROR] Error dispatching message:", error); // Clear timeout on error runtime.clearSessionTimeout(sessionId); runtime.clearTaskTimeoutForSession(sessionId); runtime.clearSessionWaitingForPush(sessionId, currentTaskId); // Clear abort controller on error runtime.clearAbortControllerForSession(sessionId); // Mark session as completed on error runtime.markSessionCompleted(sessionId); // Note: No need to call markDispatchIdle() manually // dispatchInboundMessageWithBufferedDispatcher handles this in its finally block } } catch (error) { console.error("XiaoYi: [ERROR] Unexpected error in message handler:", error); } }); // Setup cancel handler // When tasks/cancel is received, abort the current session's agent run connection.on("cancel", async (data) => { const { sessionId } = data; console.log("\n" + "=".repeat(60)); console.log(`XiaoYi: [CANCEL] Cancel event received`); console.log(` Session: ${sessionId}`); console.log(` Task ID: ${data.taskId || "N/A"}`); console.log("=".repeat(60) + "\n"); // Abort the session's agent run const aborted = runtime.abortSession(sessionId); if (aborted) { console.log(`[CANCEL] Successfully triggered abort for session ${sessionId}`); } else { console.log(`[CANCEL] No active agent run found for session ${sessionId}`); } // Clear timeout and push state as the session is being canceled runtime.clearTaskTimeoutForSession(sessionId); runtime.clearSessionWaitingForPush(sessionId, data.taskId); runtime.markSessionCompleted(sessionId); }); // Handle clear context events connection.on("clear", async (data) => { const { sessionId, serverId } = data; console.log("\n" + "=".repeat(60)); console.log("[CLEAR] Context cleared by user"); console.log(` Session: ${sessionId}`); console.log("=".repeat(60) + "\n"); // Check if there's an active task for this session const hasActiveTask = runtime.isSessionActive(sessionId); if (hasActiveTask) { console.log(`[CLEAR] Active task exists for session ${sessionId}, will continue in background`); // Session is already marked for cleanup in websocket.ts // Just track that we're waiting for completion } else { console.log(`[CLEAR] No active task for session ${sessionId}, clean up immediately`); const conn = runtime.getConnection(); if (conn) { conn.forceCleanupSession(sessionId); } } }); // Mark handlers as registered to prevent duplicate registration handlersRegistered = true; } else { console.log("XiaoYi: [STARTUP] Handlers already registered, skipping duplicate registration"); } console.log("XiaoYi: Event handlers registered"); // Keep the channel running by waiting for the abort signal // This prevents the Promise from resolving, keeping 'running' status as true // The channel will stop when stopAccount() is called or the abort signal is triggered await new Promise((resolve) => { ctx.abortSignal.addEventListener("abort", () => { console.log("XiaoYi: abort signal received, stopping channel"); resolve(); }, { once: true }); // Also handle case where abort is already triggered if (ctx.abortSignal.aborted) { console.log("XiaoYi: abort signal already triggered"); resolve(); } }); console.log("XiaoYi: startAccount() exiting - END"); }, stopAccount: async (ctx) => { const runtime = (0, runtime_1.getXiaoYiRuntime)(); runtime.stop(); }, }, /** * Messaging adapter - normalize targets */ messaging: { normalizeTarget: async (ctx) => { // For XiaoYi, we use sessionId as the target // The sessionId comes from the incoming message's meta return ctx.to; }, }, /** * Status adapter - health checks */ status: { getAccountStatus: async (ctx) => { const runtime = (0, runtime_1.getXiaoYiRuntime)(); const connection = runtime.getConnection(); if (!connection) { return { status: "offline", message: "Not connected", }; } const state = connection.getState(); if (state.connected && state.authenticated) { return { status: "online", message: "Connected and authenticated", }; } else if (state.connected) { return { status: "connecting", message: "Connected but not authenticated", }; } else { return { status: "offline", message: `Reconnect attempts: ${state.reconnectAttempts}/${state.maxReconnectAttempts}`, }; } }, }, };