From 9a9cd0c0abdf4a399137ac63d33d98373ac8203d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 22:28:47 +0100 Subject: [PATCH] refactor(channels): add shared turn kernel --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../bluebubbles/src/monitor-processing.ts | 27 +- .../src/monitor/agent-components.dispatch.ts | 145 ++--- .../src/monitor/message-handler.context.ts | 30 +- .../monitor/message-handler.process.test.ts | 11 + .../src/monitor/message-handler.process.ts | 224 ++++--- extensions/feishu/src/bot.test.ts | 6 + extensions/feishu/src/bot.ts | 112 +++- extensions/feishu/src/comment-handler.test.ts | 26 + extensions/feishu/src/comment-handler.ts | 59 +- extensions/googlechat/src/monitor.ts | 31 +- .../imessage/src/monitor/monitor-provider.ts | 87 +-- extensions/line/src/bot-message-context.ts | 79 +-- extensions/line/src/monitor.ts | 129 ++-- .../matrix/monitor/handler.test-helpers.ts | 27 + .../matrix/src/matrix/monitor/handler.ts | 179 +++--- .../monitor.inbound-system-event.test.ts | 41 ++ .../mattermost/src/mattermost/monitor.ts | 118 ++-- .../src/monitor-handler.test-helpers.ts | 26 + .../src/monitor-handler/message-handler.ts | 42 +- .../inbound-pipeline.self-echo.test.ts | 10 + .../engine/gateway/outbound-dispatch.test.ts | 10 + .../src/engine/gateway/outbound-dispatch.ts | 432 +++++++------ extensions/qqbot/src/engine/gateway/types.ts | 7 + .../signal/src/monitor/event-handler.ts | 109 ++-- .../dispatch.preview-fallback.test.ts | 9 + .../src/monitor/message-handler/dispatch.ts | 165 ++--- .../src/monitor/message-handler/prepare.ts | 81 +-- .../src/monitor/message-handler/types.ts | 4 + extensions/slack/src/monitor/reply.runtime.ts | 1 + .../synology-chat/src/channel.test-mocks.ts | 17 + extensions/synology-chat/src/inbound-turn.ts | 27 +- .../bot-message-context.acp-bindings.test.ts | 2 +- .../bot-message-context.route-test-support.ts | 13 +- .../src/bot-message-context.session.ts | 84 +-- ...bot-message-context.thread-binding.test.ts | 2 +- .../telegram/src/bot-message-context.ts | 4 +- .../telegram/src/bot-message-dispatch.test.ts | 7 + .../telegram/src/bot-message-dispatch.ts | 584 +++++++++--------- extensions/tlon/src/monitor/index.ts | 28 +- extensions/twitch/src/monitor.ts | 88 ++- .../monitor/process-message.test.ts | 33 +- .../src/auto-reply/monitor/process-message.ts | 79 +-- extensions/zalo/src/monitor.ts | 30 +- .../test-support/lifecycle-test-support.ts | 35 ++ .../zalouser/src/monitor.group-gating.test.ts | 37 ++ extensions/zalouser/src/monitor.ts | 30 +- src/auto-reply/dispatch-dispatcher.ts | 20 +- src/auto-reply/dispatch.ts | 2 +- src/channels/session.ts | 5 +- src/channels/session.types.ts | 1 + src/channels/turn/context.test.ts | 142 +++++ src/channels/turn/context.ts | 123 ++++ src/channels/turn/kernel.test.ts | 280 +++++++++ src/channels/turn/kernel.ts | 309 +++++++++ src/channels/turn/types.ts | 295 +++++++++ src/plugin-sdk/inbound-reply-dispatch.test.ts | 67 ++ src/plugin-sdk/inbound-reply-dispatch.ts | 34 +- src/plugin-sdk/reply-runtime.ts | 1 + .../test-helpers/plugin-runtime-mock.ts | 150 +++++ src/plugins/runtime/runtime-channel.ts | 15 +- src/plugins/runtime/types-channel.ts | 7 + 62 files changed, 3449 insertions(+), 1333 deletions(-) create mode 100644 src/channels/turn/context.test.ts create mode 100644 src/channels/turn/context.ts create mode 100644 src/channels/turn/kernel.test.ts create mode 100644 src/channels/turn/kernel.ts create mode 100644 src/channels/turn/types.ts create mode 100644 src/plugin-sdk/inbound-reply-dispatch.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f068317752a..80e18d9c9f4 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -cb1975fe65fcab0d50f4bf368118e61640d870a13bb8d9a44a9abb0f79f3c729 plugin-sdk-api-baseline.json -c8e2ebe7dc13d170b83b96109dd46fc33057e6f4200f981dc5ea9623e73affab plugin-sdk-api-baseline.jsonl +6e8aa3634daa81d054c339d2a8b6a526ec22b93e737980d21191ff7d53449eda plugin-sdk-api-baseline.json +6bb635a9d95b671c24251406d098ac052a6773551a1db30529bdc97caf1bb735 plugin-sdk-api-baseline.jsonl diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 9e8371d24f2..2fb42e38684 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1714,11 +1714,18 @@ async function processMessageAfterDedupe( }, }, }); - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, + await core.channel.turn.dispatchAssembled({ cfg: config, - dispatcherOptions: { - ...replyPipeline, + channel: "bluebubbles", + accountId: account.accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver: async (payload, info) => { const rawReplyToId = privateApiEnabled && typeof payload.replyToId === "string" @@ -1845,8 +1852,6 @@ async function processMessageAfterDedupe( } } }, - onReplyStart: typingCallbacks?.onReplyStart, - onIdle: typingCallbacks?.onIdle, onError: (err, info) => { // Flag the outer dedupe wrapper so it releases the claim instead // of committing. Without this, a transient BlueBubbles send failure @@ -1864,6 +1869,11 @@ async function processMessageAfterDedupe( runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`); }, }, + dispatcherOptions: { + ...replyPipeline, + onReplyStart: typingCallbacks?.onReplyStart, + onIdle: typingCallbacks?.onIdle, + }, replyOptions: { onModelSelected, disableBlockStreaming: @@ -1871,6 +1881,11 @@ async function processMessageAfterDedupe( ? !account.config.blockStreaming : undefined, }, + record: { + onRecordError: (err) => { + runtime.error?.(`[bluebubbles] failed updating session meta: ${sanitizeForLog(err)}`); + }, + }, }); } finally { const shouldStopTyping = diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index 2e284abda2f..fa36babcf3b 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -4,6 +4,7 @@ import { resolveEnvelopeFormatOptions, } from "openclaw/plugin-sdk/channel-inbound"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -238,35 +239,6 @@ export async function dispatchDiscordComponentEvent(params: { resolveDiscordComponentOriginatingTo(interactionCtx) ?? `channel:${interactionCtx.channelId}`, }); - await recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? sessionKey, - ctx: ctxPayload, - updateLastRoute: interactionCtx.isDirectMessage - ? { - sessionKey: route.mainSessionKey, - channel: "discord", - to: - resolveDiscordComponentOriginatingTo(interactionCtx) ?? `user:${interactionCtx.userId}`, - accountId, - mainDmOwnerPin: pinnedMainDmOwner - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: interactionCtx.userId, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, - onRecordError: (err) => { - logVerbose(`discord: failed updating component session meta: ${String(err)}`); - }, - }); - const deliverTarget = `channel:${interactionCtx.channelId}`; const typingChannelId = interactionCtx.channelId; const { createChannelReplyPipeline } = await loadReplyPipelineRuntime(); @@ -298,48 +270,83 @@ export async function dispatchDiscordComponentEvent(params: { startId: params.replyToId, }); - await dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: ctx.cfg, - replyOptions: { onModelSelected }, - dispatcherOptions: { - ...replyPipeline, - humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId), - deliver: async (payload) => { - const replyToId = replyReference.use(); - await deliverDiscordReply({ - cfg: ctx.cfg, - replies: [payload], - target: deliverTarget, - token, - accountId, - rest: interaction.client.rest, - runtime, - replyToId, - replyToMode, - textLimit, - maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ - cfg: ctx.cfg, - discordConfig: ctx.discordConfig, + await runPreparedInboundReplyTurn({ + channel: "discord", + accountId, + routeSessionKey: sessionKey, + storePath, + ctxPayload, + recordInboundSession, + record: { + updateLastRoute: interactionCtx.isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "discord", + to: + resolveDiscordComponentOriginatingTo(interactionCtx) ?? + `user:${interactionCtx.userId}`, accountId, - }), - tableMode, - chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), - mediaLocalRoots, - }); - replyReference.markSent(); - }, - onReplyStart: async () => { - try { - const { sendTyping } = await loadTypingRuntime(); - await sendTyping({ rest: feedbackRest, channelId: typingChannelId }); - } catch (err) { - logVerbose(`discord: typing failed for component reply: ${String(err)}`); - } - }, - onError: (err) => { - logError(`discord component dispatch failed: ${String(err)}`); + mainDmOwnerPin: pinnedMainDmOwner + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: interactionCtx.userId, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, + onRecordError: (err) => { + logVerbose(`discord: failed updating component session meta: ${String(err)}`); }, }, + runDispatch: () => + dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: ctx.cfg, + replyOptions: { onModelSelected }, + dispatcherOptions: { + ...replyPipeline, + humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId), + deliver: async (payload) => { + const replyToId = replyReference.use(); + await deliverDiscordReply({ + cfg: ctx.cfg, + replies: [payload], + target: deliverTarget, + token, + accountId, + rest: interaction.client.rest, + runtime, + replyToId, + replyToMode, + textLimit, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ + cfg: ctx.cfg, + discordConfig: ctx.discordConfig, + accountId, + }), + tableMode, + chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), + mediaLocalRoots, + }); + replyReference.markSent(); + }, + onReplyStart: async () => { + try { + const { sendTyping } = await loadTypingRuntime(); + await sendTyping({ rest: feedbackRest, channelId: typingChannelId }); + } catch (err) { + logVerbose(`discord: typing failed for component reply: ${String(err)}`); + } + }, + onError: (err) => { + logError(`discord component dispatch failed: ${String(err)}`); + }, + }, + }), }); } diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index d4ff91a32de..2875082494d 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -3,7 +3,6 @@ import { resolveEnvelopeFormatOptions, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { buildPendingHistoryContextFromMap } from "openclaw/plugin-sdk/reply-history"; @@ -330,21 +329,6 @@ export async function buildDiscordMessageProcessContext(params: { }); const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey; - await recordInboundSession({ - storePath, - sessionKey: persistedSessionKey, - ctx: ctxPayload, - updateLastRoute: { - sessionKey: persistedSessionKey, - channel: "discord", - to: lastRouteTo, - accountId: route.accountId, - }, - onRecordError: (err) => { - logVerbose(`discord: failed updating session meta: ${String(err)}`); - }, - }); - if (shouldLogVerbose()) { const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); logVerbose( @@ -355,6 +339,20 @@ export async function buildDiscordMessageProcessContext(params: { return { ctxPayload, persistedSessionKey, + turn: { + storePath, + record: { + updateLastRoute: { + sessionKey: persistedSessionKey, + channel: "discord", + to: lastRouteTo, + accountId: route.accountId, + }, + onRecordError: (err: unknown) => { + logVerbose(`discord: failed updating session meta: ${String(err)}`); + }, + }, + }, replyPlan, deliverTarget, replyTarget, diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 08576babf14..1ae3f132262 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -162,6 +162,17 @@ let processDiscordMessage: typeof import("./message-handler.process.js").process vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ dispatchInboundMessage: (params: DispatchInboundParams) => dispatchInboundMessage(params), + settleReplyDispatcher: async (params: { + dispatcher: { markComplete: () => void; waitForIdle: () => Promise }; + onSettled?: () => void | Promise; + }) => { + params.dispatcher.markComplete(); + try { + await params.dispatcher.waitForIdle(); + } finally { + await params.onSettled?.(); + } + }, createReplyDispatcherWithTyping: (opts: { deliver: (payload: unknown, info: { kind: string }) => Promise | void; }) => ({ diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index e9292906b8a..217a4b78d8c 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -12,6 +12,8 @@ import { resolveChannelSourceReplyDeliveryMode, } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking"; @@ -145,7 +147,8 @@ export async function processDiscordMessage( if (boundThreadId && typeof threadBindings.touchThread === "function") { threadBindings.touchThread({ threadId: boundThreadId }); } - const { createReplyDispatcherWithTyping, dispatchInboundMessage } = await loadReplyRuntime(); + const { createReplyDispatcherWithTyping, dispatchInboundMessage, settleReplyDispatcher } = + await loadReplyRuntime(); const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: isGuildMessage ? "channel" : undefined }, @@ -226,8 +229,15 @@ export async function processDiscordMessage( if (!processContext) { return; } - const { ctxPayload, persistedSessionKey, replyPlan, deliverTarget, replyTarget, replyReference } = - processContext; + const { + ctxPayload, + persistedSessionKey, + turn, + replyPlan, + deliverTarget, + replyTarget, + replyReference, + } = processContext; observer?.onReplyPlanResolved?.({ createdThreadId: replyPlan.createdThreadId, sessionKey: persistedSessionKey, @@ -450,99 +460,127 @@ export async function processDiscordMessage( let dispatchResult: Awaited> | null = null; let dispatchError = false; let dispatchAborted = false; + let dispatchSettledBeforeStart = false; + const settleDispatchBeforeStart = async () => { + dispatchSettledBeforeStart = true; + await settleReplyDispatcher({ + dispatcher, + onSettled: () => { + markRunComplete(); + markDispatchIdle(); + }, + }); + }; try { if (isProcessAborted(abortSignal)) { dispatchAborted = true; + await settleDispatchBeforeStart(); return; } - dispatchResult = await dispatchInboundMessage({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - abortSignal, - skillFilter: channelConfig?.skills, - sourceReplyDeliveryMode, - disableBlockStreaming: sourceRepliesAreToolOnly - ? true - : (draftPreview.disableBlockStreamingForDraft ?? - (typeof resolvedBlockStreamingEnabled === "boolean" - ? !resolvedBlockStreamingEnabled - : undefined)), - onPartialReply: draftPreview.draftStream - ? (payload) => draftPreview.updateFromPartial(payload.text) - : undefined, - onAssistantMessageStart: draftPreview.draftStream - ? draftPreview.handleAssistantMessageBoundary - : undefined, - onReasoningEnd: draftPreview.draftStream - ? draftPreview.handleAssistantMessageBoundary - : undefined, - onModelSelected, - suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled - ? true - : undefined, - onReasoningStream: async () => { - await statusReactions.setThinking(); - }, - onToolStart: async (payload) => { - if (isProcessAborted(abortSignal)) { - return; - } - await statusReactions.setTool(payload.name); - draftPreview.pushToolProgress(payload.name ? `tool: ${payload.name}` : "tool running"); - }, - onItemEvent: async (payload) => { - draftPreview.pushToolProgress( - payload.progressText ?? payload.summary ?? payload.title ?? payload.name, - ); - }, - onPlanUpdate: async (payload) => { - if (payload.phase !== "update") { - return; - } - draftPreview.pushToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning"); - }, - onApprovalEvent: async (payload) => { - if (payload.phase !== "requested") { - return; - } - draftPreview.pushToolProgress( - payload.command ? `approval: ${payload.command}` : "approval requested", - ); - }, - onCommandOutput: async (payload) => { - if (payload.phase !== "end") { - return; - } - draftPreview.pushToolProgress( - payload.name - ? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}` - : payload.title, - ); - }, - onPatchSummary: async (payload) => { - if (payload.phase !== "end") { - return; - } - draftPreview.pushToolProgress(payload.summary ?? payload.title ?? "patch applied"); - }, - onCompactionStart: async () => { - if (isProcessAborted(abortSignal)) { - return; - } - await statusReactions.setCompacting(); - }, - onCompactionEnd: async () => { - if (isProcessAborted(abortSignal)) { - return; - } - statusReactions.cancelPending(); - await statusReactions.setThinking(); - }, - }, + const preparedResult = await runPreparedInboundReplyTurn({ + channel: "discord", + accountId: route.accountId, + routeSessionKey: persistedSessionKey, + storePath: turn.storePath, + ctxPayload, + recordInboundSession, + record: turn.record, + onPreDispatchFailure: settleDispatchBeforeStart, + runDispatch: () => + dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + abortSignal, + skillFilter: channelConfig?.skills, + sourceReplyDeliveryMode, + disableBlockStreaming: sourceRepliesAreToolOnly + ? true + : (draftPreview.disableBlockStreamingForDraft ?? + (typeof resolvedBlockStreamingEnabled === "boolean" + ? !resolvedBlockStreamingEnabled + : undefined)), + onPartialReply: draftPreview.draftStream + ? (payload) => draftPreview.updateFromPartial(payload.text) + : undefined, + onAssistantMessageStart: draftPreview.draftStream + ? draftPreview.handleAssistantMessageBoundary + : undefined, + onReasoningEnd: draftPreview.draftStream + ? draftPreview.handleAssistantMessageBoundary + : undefined, + onModelSelected, + suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled + ? true + : undefined, + onReasoningStream: async () => { + await statusReactions.setThinking(); + }, + onToolStart: async (payload) => { + if (isProcessAborted(abortSignal)) { + return; + } + await statusReactions.setTool(payload.name); + draftPreview.pushToolProgress( + payload.name ? `tool: ${payload.name}` : "tool running", + ); + }, + onItemEvent: async (payload) => { + draftPreview.pushToolProgress( + payload.progressText ?? payload.summary ?? payload.title ?? payload.name, + ); + }, + onPlanUpdate: async (payload) => { + if (payload.phase !== "update") { + return; + } + draftPreview.pushToolProgress( + payload.explanation ?? payload.steps?.[0] ?? "planning", + ); + }, + onApprovalEvent: async (payload) => { + if (payload.phase !== "requested") { + return; + } + draftPreview.pushToolProgress( + payload.command ? `approval: ${payload.command}` : "approval requested", + ); + }, + onCommandOutput: async (payload) => { + if (payload.phase !== "end") { + return; + } + draftPreview.pushToolProgress( + payload.name + ? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}` + : payload.title, + ); + }, + onPatchSummary: async (payload) => { + if (payload.phase !== "end") { + return; + } + draftPreview.pushToolProgress(payload.summary ?? payload.title ?? "patch applied"); + }, + onCompactionStart: async () => { + if (isProcessAborted(abortSignal)) { + return; + } + await statusReactions.setCompacting(); + }, + onCompactionEnd: async () => { + if (isProcessAborted(abortSignal)) { + return; + } + statusReactions.cancelPending(); + await statusReactions.setThinking(); + }, + }, + }), }); + dispatchResult = preparedResult.dispatchResult; if (isProcessAborted(abortSignal)) { dispatchAborted = true; return; @@ -558,8 +596,10 @@ export async function processDiscordMessage( try { await draftPreview.cleanup(); } finally { - markRunComplete(); - markDispatchIdle(); + if (!dispatchSettledBeforeStart) { + markRunComplete(); + markDispatchIdle(); + } } if (statusReactionsEnabled) { if (dispatchAborted) { diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index ee6ddf7f4d6..503e411426f 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -175,6 +175,7 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): Plu session: { readSessionUpdatedAt: readSessionUpdatedAtMock, resolveStorePath: resolveStorePathMock, + recordInboundSession: vi.fn(async () => undefined), }, reply: { resolveEnvelopeFormatOptions: @@ -196,6 +197,11 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): Plu upsertPairingRequest: vi.fn(), buildPairingReply: vi.fn(), }, + turn: { + runPrepared: vi.fn(async (params) => ({ + dispatchResult: await params.runDispatch(), + })), + }, ...overrides.channel, }, ...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}), diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 9d734fbafd7..3d88ad790d0 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1268,8 +1268,18 @@ export async function handleFeishuMessage(params: { } const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId); + const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId, + }); + const agentRecord = { + onRecordError: (err: unknown) => { + log( + `feishu[${account.accountId}]: failed to record broadcast inbound session ${agentSessionKey}: ${String(err)}`, + ); + }, + }; const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ - storePath: core.channel.session.resolveStorePath(cfg.session?.store, { agentId }), + storePath: agentStorePath, sessionKey: agentSessionKey, }); const agentCtx = await buildCtxPayloadForAgent( @@ -1302,15 +1312,30 @@ export async function handleFeishuMessage(params: { log( `feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`, ); - await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => markDispatchIdle(), - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: agentCtx, - cfg, + await core.channel.turn.runPrepared({ + channel: "feishu", + accountId: route.accountId, + routeSessionKey: agentSessionKey, + storePath: agentStorePath, + ctxPayload: agentCtx, + recordInboundSession: core.channel.session.recordInboundSession, + record: agentRecord, + onPreDispatchFailure: () => + core.channel.reply.settleReplyDispatcher({ dispatcher, - replyOptions, + onSettled: () => markDispatchIdle(), + }), + runDispatch: () => + core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => markDispatchIdle(), + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: agentCtx, + cfg, + dispatcher, + replyOptions, + }), }), }); } else { @@ -1331,13 +1356,23 @@ export async function handleFeishuMessage(params: { log( `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`, ); - await core.channel.reply.withReplyDispatcher({ - dispatcher: noopDispatcher, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: agentCtx, - cfg, + await core.channel.turn.runPrepared({ + channel: "feishu", + accountId: route.accountId, + routeSessionKey: agentSessionKey, + storePath: agentStorePath, + ctxPayload: agentCtx, + recordInboundSession: core.channel.session.recordInboundSession, + record: agentRecord, + runDispatch: () => + core.channel.reply.withReplyDispatcher({ dispatcher: noopDispatcher, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: agentCtx, + cfg, + dispatcher: noopDispatcher, + }), }), }); } @@ -1385,10 +1420,11 @@ export async function handleFeishuMessage(params: { ); const identity = resolveAgentOutboundIdentity(cfg, route.agentId); + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ - storePath: core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }), + storePath, sessionKey: route.sessionKey, }); const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ @@ -1409,19 +1445,41 @@ export async function handleFeishuMessage(params: { }); log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`); - const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); + const { dispatchResult } = await core.channel.turn.runPrepared({ + channel: "feishu", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + record: { + onRecordError: (err) => { + log( + `feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`, + ); + }, }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, + onPreDispatchFailure: () => + core.channel.reply.settleReplyDispatcher({ dispatcher, - replyOptions, + onSettled: () => markDispatchIdle(), + }), + runDispatch: () => + core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }), }), }); + const { queuedFinal, counts } = dispatchResult; if (isGroup && historyKey && chatHistories) { clearHistoryEntriesIfEnabled({ diff --git a/extensions/feishu/src/comment-handler.test.ts b/extensions/feishu/src/comment-handler.test.ts index ad7a241f3df..5c8875e2ab0 100644 --- a/extensions/feishu/src/comment-handler.test.ts +++ b/extensions/feishu/src/comment-handler.test.ts @@ -86,6 +86,27 @@ function createTestRuntime(overrides?: { }, ); const recordInboundSession = vi.fn(async () => {}); + const runPrepared = vi.fn( + async (turn: Parameters[0]) => { + await turn.recordInboundSession({ + storePath: turn.storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + ctx: turn.ctxPayload, + groupResolution: turn.record?.groupResolution, + createIfMissing: turn.record?.createIfMissing, + updateLastRoute: turn.record?.updateLastRoute, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + const dispatchResult = await turn.runDispatch(); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.routeSessionKey, + dispatchResult, + }; + }, + ); return { channel: { @@ -112,6 +133,11 @@ function createTestRuntime(overrides?: { resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), recordInboundSession, }, + turn: { + runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"], + dispatchAssembled: + vi.fn() as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"], + }, pairing: { readAllowFromStore: vi.fn(overrides?.readAllowFromStore ?? (async () => [])), upsertPairingRequest: vi.fn( diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts index 522df8ac573..ce14619f15b 100644 --- a/extensions/feishu/src/comment-handler.ts +++ b/extensions/feishu/src/comment-handler.ts @@ -221,16 +221,6 @@ export async function handleFeishuCommentEvent( const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { agentId: route.agentId, }); - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: commentSessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - error( - `feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`, - ); - }, - }); const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } = createFeishuCommentReplyDispatcher({ @@ -245,28 +235,59 @@ export async function handleFeishuCommentEvent( isWholeComment: turn.isWholeComment, }); + let dispatchSettledBeforeStart = false; try { log( `feishu[${account.accountId}]: dispatching drive comment to agent ` + `(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`, ); - const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ - dispatcher, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg: effectiveCfg, + const { dispatchResult } = await core.channel.turn.runPrepared({ + channel: "feishu", + accountId: route.accountId, + routeSessionKey: commentSessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + record: { + onRecordError: (err) => { + error( + `feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`, + ); + }, + }, + onPreDispatchFailure: async () => { + dispatchSettledBeforeStart = true; + await core.channel.reply.settleReplyDispatcher({ dispatcher, - replyOptions, + onSettled: () => { + markRunComplete(); + markDispatchIdle(); + }, + }); + }, + runDispatch: () => + core.channel.reply.withReplyDispatcher({ + dispatcher, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg: effectiveCfg, + dispatcher, + replyOptions, + }), }), }); + const queuedFinal = dispatchResult?.queuedFinal ?? false; + const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 }; log( `feishu[${account.accountId}]: drive comment dispatch complete ` + `(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`, ); } finally { - markRunComplete(); - markDispatchIdle(); + if (!dispatchSettledBeforeStart) { + markRunComplete(); + markDispatchIdle(); + } void cleanupTypingReaction(); } } diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 72a42d48a39..83c7a6a2d01 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -226,16 +226,6 @@ async function processMessageWithPipeline(params: { OriginatingTo: `googlechat:${spaceId}`, }); - void core.channel.session - .recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }) - .catch((err) => { - runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`); - }); - // Typing indicator setup // Note: Reaction mode requires user OAuth, not available with service account auth. // If reaction is configured, we fall back to message mode with a warning. @@ -275,11 +265,18 @@ async function processMessageWithPipeline(params: { accountId: route.accountId, }); - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, + await core.channel.turn.dispatchAssembled({ cfg: config, - dispatcherOptions: { - ...replyPipeline, + channel: "googlechat", + accountId: route.accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver: async (payload) => { await deliverGoogleChatReply({ payload, @@ -300,9 +297,15 @@ async function processMessageWithPipeline(params: { ); }, }, + dispatcherOptions: replyPipeline, replyOptions: { onModelSelected, }, + record: { + onRecordError: (err) => { + runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`); + }, + }, }); } diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 8671e1e454a..d5505434175 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -12,6 +12,7 @@ import { } from "openclaw/plugin-sdk/conversation-runtime"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { clearHistoryEntriesIfEnabled, @@ -21,6 +22,7 @@ import { import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; +import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; import { @@ -395,36 +397,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P allowFrom, normalizeEntry: normalizeIMessageHandle, }); - await recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey, - ctx: ctxPayload, - updateLastRoute: - !decision.isGroup && updateTarget - ? { - sessionKey: decision.route.mainSessionKey, - channel: "imessage", - to: updateTarget, - accountId: decision.route.accountId, - mainDmOwnerPin: - pinnedMainDmOwner && decision.senderNormalized - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: decision.senderNormalized, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, - onRecordError: (err) => { - logVerbose(`imessage: failed updating session meta: ${String(err)}`); - }, - }); - if (shouldLogVerbose()) { const preview = truncateUtf16Safe(ctxPayload.Body ?? "", 200).replace(/\n/g, "\\n"); logVerbose( @@ -467,18 +439,55 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, }); - const { queuedFinal } = await dispatchInboundMessage({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - disableBlockStreaming: - typeof accountInfo.config.blockStreaming === "boolean" - ? !accountInfo.config.blockStreaming + const { dispatchResult } = await runPreparedInboundReplyTurn({ + channel: "imessage", + accountId: decision.route.accountId, + routeSessionKey: decision.route.sessionKey, + storePath, + ctxPayload, + recordInboundSession, + record: { + updateLastRoute: + !decision.isGroup && updateTarget + ? { + sessionKey: decision.route.mainSessionKey, + channel: "imessage", + to: updateTarget, + accountId: decision.route.accountId, + mainDmOwnerPin: + pinnedMainDmOwner && decision.senderNormalized + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: decision.senderNormalized, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } : undefined, - onModelSelected, + onRecordError: (err) => { + logVerbose(`imessage: failed updating session meta: ${String(err)}`); + }, }, + onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }), + runDispatch: () => + dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + disableBlockStreaming: + typeof accountInfo.config.blockStreaming === "boolean" + ? !accountInfo.config.blockStreaming + : undefined, + onModelSelected, + }, + }), }); + const queuedFinal = dispatchResult.queuedFinal; if (!queuedFinal) { if (decision.isGroup && decision.historyKey) { diff --git a/extensions/line/src/bot-message-context.ts b/extensions/line/src/bot-message-context.ts index e4bf514d541..5514bea428b 100644 --- a/extensions/line/src/bot-message-context.ts +++ b/extensions/line/src/bot-message-context.ts @@ -9,7 +9,6 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { ensureConfiguredBindingRouteReady, - recordInboundSession, resolvePinnedMainDmOwnerFromAllowlist, resolveConfiguredBindingRoute, resolveRuntimeConversationBindingRoute, @@ -378,35 +377,6 @@ async function finalizeLineInboundContext(params: { normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0], }) : null; - await recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey, - ctx: ctxPayload, - updateLastRoute: !params.source.isGroup - ? { - sessionKey: params.route.mainSessionKey, - channel: "line", - to: params.source.userId ?? params.source.peerId, - accountId: params.route.accountId, - mainDmOwnerPin: - pinnedMainDmOwner && params.source.userId - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: params.source.userId, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, - onRecordError: (err) => { - logVerbose(`line: failed updating session meta: ${String(err)}`); - }, - }); - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const mediaInfo = @@ -419,7 +389,44 @@ async function finalizeLineInboundContext(params: { ); } - return { ctxPayload, replyToken: (params.event as { replyToken: string }).replyToken }; + return { + ctxPayload, + replyToken: (params.event as { replyToken: string }).replyToken, + turn: { + storePath, + record: { + updateLastRoute: !params.source.isGroup + ? { + sessionKey: params.route.mainSessionKey, + channel: "line", + to: params.source.userId ?? params.source.peerId, + accountId: params.route.accountId, + mainDmOwnerPin: + pinnedMainDmOwner && params.source.userId + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: params.source.userId, + onSkip: ({ + ownerRecipient, + senderRecipient, + }: { + ownerRecipient: string; + senderRecipient: string; + }) => { + logVerbose( + `line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, + onRecordError: (err: unknown) => { + logVerbose(`line: failed updating session meta: ${String(err)}`); + }, + }, + }, + }; } export async function buildLineMessageContext(params: BuildLineMessageContextParams) { @@ -469,7 +476,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar })) : undefined; - const { ctxPayload } = await finalizeLineInboundContext({ + const finalized = await finalizeLineInboundContext({ cfg, account, event, @@ -494,7 +501,8 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar }); return { - ctxPayload, + ctxPayload: finalized.ctxPayload, + turn: finalized.turn, event, userId, groupId, @@ -535,7 +543,7 @@ export async function buildLinePostbackContext(params: { } const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`; - const { ctxPayload } = await finalizeLineInboundContext({ + const finalized = await finalizeLineInboundContext({ cfg, account, event, @@ -555,7 +563,8 @@ export async function buildLinePostbackContext(params: { }); return { - ctxPayload, + ctxPayload: finalized.ctxPayload, + turn: finalized.turn, event, userId, groupId, diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index 6e63025e4af..da9fb60d97b 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -1,6 +1,8 @@ import type { webhook } from "@line/bot-sdk"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { dispatchReplyWithBufferedBlockDispatcher, chunkMarkdownText, @@ -231,69 +233,80 @@ export async function monitorLineProvider( accountId: route.accountId, }); - const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: config, - dispatcherOptions: { - ...replyPipeline, - deliver: async (payload, _info) => { - const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; + const { dispatchResult } = await runPreparedInboundReplyTurn({ + channel: "line", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath: ctx.turn.storePath, + ctxPayload, + recordInboundSession, + record: ctx.turn.record, + runDispatch: () => + dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + ...replyPipeline, + deliver: async (payload, _info) => { + const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; - if (ctx.userId && !ctx.isGroup) { - void showLoadingAnimation(ctx.userId, { - cfg: config, - accountId: ctx.accountId, - }).catch(() => {}); - } + if (ctx.userId && !ctx.isGroup) { + void showLoadingAnimation(ctx.userId, { + cfg: config, + accountId: ctx.accountId, + }).catch(() => {}); + } - const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({ - payload, - lineData, - to: ctxPayload.From, - replyToken, - replyTokenUsed, - accountId: ctx.accountId, - cfg: config, - textLimit, - deps: { - buildTemplateMessageFromPayload, - processLineMessage, - chunkMarkdownText, - sendLineReplyChunks, - replyMessageLine, - pushMessageLine, - pushTextMessageWithQuickReplies, - createQuickReplyItems, - createTextMessageWithQuickReplies, - pushMessagesLine, - createFlexMessage, - createImageMessage, - createLocationMessage, - onReplyError: (replyErr) => { - logVerbose( - `line: reply token failed, falling back to push: ${String(replyErr)}`, - ); - }, + const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({ + payload, + lineData, + to: ctxPayload.From, + replyToken, + replyTokenUsed, + accountId: ctx.accountId, + cfg: config, + textLimit, + deps: { + buildTemplateMessageFromPayload, + processLineMessage, + chunkMarkdownText, + sendLineReplyChunks, + replyMessageLine, + pushMessageLine, + pushTextMessageWithQuickReplies, + createQuickReplyItems, + createTextMessageWithQuickReplies, + pushMessagesLine, + createFlexMessage, + createImageMessage, + createLocationMessage, + onReplyError: (replyErr) => { + logVerbose( + `line: reply token failed, falling back to push: ${String(replyErr)}`, + ); + }, + }, + }); + replyTokenUsed = nextReplyTokenUsed; + + recordChannelRuntimeState({ + channel: "line", + accountId: resolvedAccountId, + state: { + lastOutboundAt: Date.now(), + }, + }); }, - }); - replyTokenUsed = nextReplyTokenUsed; - - recordChannelRuntimeState({ - channel: "line", - accountId: resolvedAccountId, - state: { - lastOutboundAt: Date.now(), + onError: (err, info) => { + runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`)); }, - }); - }, - onError: (err, info) => { - runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - onModelSelected, - }, + }, + replyOptions: { + onModelSelected, + }, + }), }); + const queuedFinal = dispatchResult.queuedFinal; if (!queuedFinal) { logVerbose(`line: no response generated for message from ${ctxPayload.From}`); diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 8855894aacd..76ae6822b2e 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -119,6 +119,29 @@ export function createMatrixHandlerTestHarness( counts: { final: 0, block: 0, tool: 0 }, })); const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn(); + const runPrepared = vi.fn( + async ( + turn: Parameters[0], + ) => { + await turn.recordInboundSession({ + storePath: turn.storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + ctx: turn.ctxPayload, + groupResolution: turn.record?.groupResolution, + createIfMissing: turn.record?.createIfMissing, + updateLastRoute: turn.record?.updateLastRoute, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + const dispatchResult = await turn.runDispatch(); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.routeSessionKey, + dispatchResult, + }; + }, + ); const dmPolicy = options.dmPolicy ?? "open"; const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []); const cfgForHandler = @@ -205,6 +228,10 @@ export function createMatrixHandlerTestHarness( } }), }, + turn: { + runPrepared, + dispatchAssembled: vi.fn(), + }, reactions: { shouldAckReaction: options.shouldAckReaction ?? (() => false), }, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 2282be8885f..39c388cde29 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1352,40 +1352,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam OriginatingTo: `room:${roomId}`, }); - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? _route.sessionKey, - ctx: ctxPayload, - updateLastRoute: isDirectMessage - ? { - sessionKey: _route.mainSessionKey, - channel: "matrix", - to: `room:${roomId}`, - accountId: _route.accountId, - } - : undefined, - onRecordError: (err) => { - logger.warn("failed updating session meta", { - error: String(err), - storePath, - sessionKey: ctxPayload.SessionKey ?? _route.sessionKey, - }); - }, - }); - - if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) { - client - .sendMessage(roomId, { - msgtype: "m.notice", - body: sharedDmContextNotice, - }) - .catch((err) => { - logVerboseMessage( - `matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`, - ); - }); - } - const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); @@ -1862,58 +1828,107 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onIdle: typingCallbacks.onIdle, }); - const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: async () => { - try { - return await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - // Keep block streaming enabled when explicitly requested, even - // with draft previews on. The draft remains the live preview - // for the current assistant block, while block deliveries - // finalize completed blocks into their own preserved events. - disableBlockStreaming: !blockStreamingEnabled, - onPartialReply: draftStream - ? (payload) => { - latestDraftFullText = payload.text ?? ""; - suppressPreviewToolProgressForAnswerText(latestDraftFullText); - updateDraftFromLatestFullText(); - } - : undefined, - onBlockReplyQueued: draftStream - ? (payload, context) => { - if (payload.isCompactionNotice === true) { - return; - } - queueDraftBlockBoundary(payload, context); - } - : undefined, - // Reset draft boundary bookkeeping on assistant message - // boundaries so post-tool blocks stream from a fresh - // cumulative payload (payload.text resets upstream). - onAssistantMessageStart: draftStream - ? () => { - resetDraftBlockOffsets(); - resetPreviewToolProgress(); - } - : undefined, - ...buildPreviewToolProgressReplyOptions(), - onModelSelected, - }, + const { dispatchResult } = await core.channel.turn.runPrepared({ + channel: "matrix", + accountId: _route.accountId, + routeSessionKey: _route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + record: { + updateLastRoute: isDirectMessage + ? { + sessionKey: _route.mainSessionKey, + channel: "matrix", + to: `room:${roomId}`, + accountId: _route.accountId, + } + : undefined, + onRecordError: (err) => { + logger.warn("failed updating session meta", { + error: String(err), + storePath, + sessionKey: ctxPayload.SessionKey ?? _route.sessionKey, }); - } finally { - markRunComplete(); + }, + }, + onPreDispatchFailure: () => + core.channel.reply.settleReplyDispatcher({ + dispatcher, + onSettled: () => { + markRunComplete(); + markDispatchIdle(); + }, + }), + runDispatch: async () => { + if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) { + client + .sendMessage(roomId, { + msgtype: "m.notice", + body: sharedDmContextNotice, + }) + .catch((err) => { + logVerboseMessage( + `matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`, + ); + }); } + + return await core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => { + markDispatchIdle(); + }, + run: async () => { + try { + return await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + // Keep block streaming enabled when explicitly requested, even + // with draft previews on. The draft remains the live preview + // for the current assistant block, while block deliveries + // finalize completed blocks into their own preserved events. + disableBlockStreaming: !blockStreamingEnabled, + onPartialReply: draftStream + ? (payload) => { + latestDraftFullText = payload.text ?? ""; + suppressPreviewToolProgressForAnswerText(latestDraftFullText); + updateDraftFromLatestFullText(); + } + : undefined, + onBlockReplyQueued: draftStream + ? (payload, context) => { + if (payload.isCompactionNotice === true) { + return; + } + queueDraftBlockBoundary(payload, context); + } + : undefined, + // Reset draft boundary bookkeeping on assistant message + // boundaries so post-tool blocks stream from a fresh + // cumulative payload (payload.text resets upstream). + onAssistantMessageStart: draftStream + ? () => { + resetDraftBlockOffsets(); + resetPreviewToolProgress(); + } + : undefined, + ...buildPreviewToolProgressReplyOptions(), + onModelSelected, + }, + }); + } finally { + markRunComplete(); + } + }, + }); }, }); + const { queuedFinal, counts } = dispatchResult; if (finalReplyDeliveryFailed) { if (retryableReplyDeliveryFailed) { logVerboseMessage( diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 7fa1ccfc310..fdad21388db 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -132,6 +132,42 @@ vi.mock("./runtime-api.js", async () => { }); function createRuntimeCore(cfg: OpenClawConfig) { + const runPrepared = vi.fn( + async (turn: { + storePath: string; + routeSessionKey: string; + ctxPayload: { SessionKey?: string }; + recordInboundSession: (params: unknown) => Promise; + record?: { + groupResolution?: unknown; + createIfMissing?: boolean; + updateLastRoute?: unknown; + onRecordError?: (err: unknown) => void; + }; + runDispatch: () => Promise<{ + queuedFinal: boolean; + counts: { tool: number; block: number; final: number }; + }>; + }) => { + await turn.recordInboundSession({ + storePath: turn.storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + ctx: turn.ctxPayload, + groupResolution: turn.record?.groupResolution, + createIfMissing: turn.record?.createIfMissing, + updateLastRoute: turn.record?.updateLastRoute, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + const dispatchResult = await turn.runDispatch(); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.routeSessionKey, + dispatchResult, + }; + }, + ); return { config: { current: () => cfg, @@ -212,8 +248,13 @@ function createRuntimeCore(cfg: OpenClawConfig) { }, session: { resolveStorePath: () => "/tmp/openclaw-test-sessions.json", + recordInboundSession: vi.fn(async () => {}), updateLastRoute: vi.fn(async () => {}), }, + turn: { + runPrepared, + dispatchAssembled: vi.fn(), + }, text: { chunkMarkdownTextWithMode: (text: string) => [text], convertMarkdownTables: (text: string) => text, diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 739e0aadecd..d4ca3f5118f 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1570,21 +1570,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} ...mediaPayload, }); - if (kind === "direct") { - const sessionCfg = cfg.session; - const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, { - agentId: route.agentId, - }); - await core.channel.session.updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - deliveryContext: { - channel: "mattermost", - to, - accountId: route.accountId, - }, - }); - } + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n"); logVerboseMessage( @@ -1731,39 +1719,75 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }, }); + let dispatchSettledBeforeStart = false; try { - await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - disableBlockStreaming: true, - onModelSelected, - onPartialReply: (payload) => { - updateDraftFromPartial(payload.text); - }, - onAssistantMessageStart: () => { - lastPartialText = ""; - }, - onReasoningEnd: () => { - lastPartialText = ""; - }, - onReasoningStream: async () => { - if (!lastPartialText) { - draftStream.update("Thinking…"); + await core.channel.turn.runPrepared({ + channel: "mattermost", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + record: { + updateLastRoute: + kind === "direct" + ? { + sessionKey: route.mainSessionKey, + channel: "mattermost", + to, + accountId: route.accountId, } - }, - onToolStart: async (payload) => { - draftStream.update(buildMattermostToolStatusText(payload)); - }, + : undefined, + onRecordError: (err) => { + logVerboseMessage( + `mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`, + ); + }, + }, + onPreDispatchFailure: async () => { + dispatchSettledBeforeStart = true; + await core.channel.reply.settleReplyDispatcher({ + dispatcher, + onSettled: () => { + markRunComplete(); + markDispatchIdle(); }, + }); + }, + runDispatch: () => + core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: true, + onModelSelected, + onPartialReply: (payload) => { + updateDraftFromPartial(payload.text); + }, + onAssistantMessageStart: () => { + lastPartialText = ""; + }, + onReasoningEnd: () => { + lastPartialText = ""; + }, + onReasoningStream: async () => { + if (!lastPartialText) { + draftStream.update("Thinking…"); + } + }, + onToolStart: async (payload) => { + draftStream.update(buildMattermostToolStatusText(payload)); + }, + }, + }), }), }); } finally { @@ -1772,7 +1796,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } catch (err) { logVerboseMessage(`mattermost draft preview cleanup failed: ${String(err)}`); } - markRunComplete(); + if (!dispatchSettledBeforeStart) { + markRunComplete(); + } } if (historyKey) { clearHistoryEntriesIfEnabled({ diff --git a/extensions/msteams/src/monitor-handler.test-helpers.ts b/extensions/msteams/src/monitor-handler.test-helpers.ts index 85c52111cfa..7ae4b0d2f16 100644 --- a/extensions/msteams/src/monitor-handler.test-helpers.ts +++ b/extensions/msteams/src/monitor-handler.test-helpers.ts @@ -19,6 +19,27 @@ type MSTeamsTestRuntimeOptions = { }; export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void { + const runPrepared = vi.fn( + async (turn: Parameters[0]) => { + await turn.recordInboundSession({ + storePath: turn.storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + ctx: turn.ctxPayload, + groupResolution: turn.record?.groupResolution, + createIfMissing: turn.record?.createIfMissing, + updateLastRoute: turn.record?.updateLastRoute, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + const dispatchResult = await turn.runDispatch(); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.routeSessionKey, + dispatchResult, + }; + }, + ); setMSTeamsRuntime({ logging: { shouldLogVerbose: () => false }, system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() }, @@ -68,6 +89,11 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = { recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined), ...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}), }, + turn: { + runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"], + dispatchAssembled: + vi.fn() as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"], + }, }, } as unknown as PluginRuntime); } diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 79b3f030849..e0756803f9b 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -793,15 +793,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ...mediaPayload, }); - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`); - }, - }); - logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); const sharePointSiteId = msteamsCfg?.sharePointSiteId; @@ -845,14 +836,35 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { log.info("dispatching to agent", { sessionKey: route.sessionKey }); try { - const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({ - cfg, + const { dispatchResult } = await core.channel.turn.runPrepared({ + channel: "msteams", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath, ctxPayload, - dispatcher, - onSettled: () => markDispatchIdle(), - replyOptions, - configOverride, + recordInboundSession: core.channel.session.recordInboundSession, + record: { + onRecordError: (err) => { + logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`); + }, + }, + onPreDispatchFailure: () => + core.channel.reply.settleReplyDispatcher({ + dispatcher, + onSettled: () => markDispatchIdle(), + }), + runDispatch: () => + dispatchReplyFromConfigWithSettledDispatcher({ + cfg, + ctxPayload, + dispatcher, + onSettled: () => markDispatchIdle(), + replyOptions, + configOverride, + }), }); + const queuedFinal = dispatchResult?.queuedFinal ?? false; + const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 }; log.info("dispatch complete", { queuedFinal, counts }); diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts index 595f2c08863..29f884e7579 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts @@ -64,6 +64,16 @@ function makeRuntime(): GatewayPluginRuntime { resolveEffectiveMessagesConfig: vi.fn(() => ({})), resolveEnvelopeFormatOptions: vi.fn(() => ({})), }, + session: { + resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"), + recordInboundSession: vi.fn(async () => undefined), + }, + turn: { + runPrepared: vi.fn(async (rawParams: unknown) => { + const params = rawParams as { runDispatch: () => Promise }; + return { dispatchResult: await params.runDispatch() }; + }), + }, text: { chunkMarkdownText: (text: string) => [text], }, diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts index 1dd61b98915..2af25134627 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts @@ -136,6 +136,16 @@ function makeRuntime(params: { resolveEffectiveMessagesConfig: vi.fn(() => ({})), resolveEnvelopeFormatOptions: vi.fn(() => ({})), }, + session: { + resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"), + recordInboundSession: vi.fn(async () => undefined), + }, + turn: { + runPrepared: vi.fn(async (rawParams: unknown) => { + const params = rawParams as { runDispatch: () => Promise }; + return { dispatchResult: await params.runDispatch() }; + }), + }, text: { chunkMarkdownText: (text: string) => [text], }, diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts index 90cb8916e84..f07f5711eed 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts @@ -219,221 +219,243 @@ export async function dispatchOutbound( }); } - const dispatchPromise = runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - responsePrefix: messagesConfig.responsePrefix, - deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => { - hasResponse = true; + const cfgWithSession = cfg as { session?: { store?: unknown } }; + const agentId = inbound.route.agentId ?? "default"; + const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { + agentId, + }); + const dispatchPromise = runtime.channel.turn.runPrepared({ + channel: "qqbot", + accountId: inbound.route.accountId, + routeSessionKey: inbound.route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: runtime.channel.session.recordInboundSession, + record: { + onRecordError: (err: unknown) => { + log?.error( + `Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }, + }, + runDispatch: () => + runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + responsePrefix: messagesConfig.responsePrefix, + deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => { + hasResponse = true; - // ---- Tool deliver ---- - if (info.kind === "tool") { - toolDeliverCount++; - const toolText = (payload.text ?? "").trim(); - if (toolText) { - toolTexts.push(toolText); - } - if (payload.mediaUrls?.length) { - toolMediaUrls.push(...payload.mediaUrls); - } - if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { - toolMediaUrls.push(payload.mediaUrl); - } + // ---- Tool deliver ---- + if (info.kind === "tool") { + toolDeliverCount++; + const toolText = (payload.text ?? "").trim(); + if (toolText) { + toolTexts.push(toolText); + } + if (payload.mediaUrls?.length) { + toolMediaUrls.push(...payload.mediaUrls); + } + if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { + toolMediaUrls.push(payload.mediaUrl); + } - if (hasBlockResponse && toolMediaUrls.length > 0) { - const urlsToSend = [...toolMediaUrls]; - toolMediaUrls.length = 0; - for (const mediaUrl of urlsToSend) { - try { - await sendMedia({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - }); - } catch {} - } - return; - } - if (toolFallbackSent) { - return; - } - if (toolOnlyTimeoutId) { - if (toolRenewalCount < MAX_TOOL_RENEWALS) { - clearTimeout(toolOnlyTimeoutId); - toolRenewalCount++; - } else { + if (hasBlockResponse && toolMediaUrls.length > 0) { + const urlsToSend = [...toolMediaUrls]; + toolMediaUrls.length = 0; + for (const mediaUrl of urlsToSend) { + try { + await sendMedia({ + to: qualifiedTarget, + text: "", + mediaUrl, + accountId: account.accountId, + replyToId: event.messageId, + account, + }); + } catch {} + } + return; + } + if (toolFallbackSent) { + return; + } + if (toolOnlyTimeoutId) { + if (toolRenewalCount < MAX_TOOL_RENEWALS) { + clearTimeout(toolOnlyTimeoutId); + toolRenewalCount++; + } else { + return; + } + } + toolOnlyTimeoutId = setTimeout(async () => { + if (!hasBlockResponse && !toolFallbackSent) { + toolFallbackSent = true; + try { + await sendToolFallback(); + } catch {} + } + }, TOOL_ONLY_TIMEOUT); return; } - } - toolOnlyTimeoutId = setTimeout(async () => { - if (!hasBlockResponse && !toolFallbackSent) { - toolFallbackSent = true; - try { - await sendToolFallback(); - } catch {} + + // ---- Block deliver ---- + hasBlockResponse = true; + inbound.typing.keepAlive?.stop(); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; } - }, TOOL_ONLY_TIMEOUT); - return; - } - - // ---- Block deliver ---- - hasBlockResponse = true; - inbound.typing.keepAlive?.stop(); - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - - if (streamingController && !streamingController.isTerminalPhase) { - try { - await streamingController.onDeliver(payload); - } catch (err) { - log?.error( - `Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - const replyPreview = (payload.text ?? "").trim(); - if ( - event.type === "group" && - (replyPreview === "NO_REPLY" || replyPreview === "[SKIP]") - ) { - log?.info( - `Model decided to skip group message (${replyPreview}) from ${event.senderId}`, - ); - return; - } - - if (streamingController.shouldFallbackToStatic) { - log?.info("Streaming API unavailable, falling back to static for this deliver"); - } else { - recordOutbound(); - return; - } - } - - const quoteRef = event.msgIdx; - let quoteRefUsed = false; - const consumeQuoteRef = (): string | undefined => { - if (quoteRef && !quoteRefUsed) { - quoteRefUsed = true; - return quoteRef; - } - return undefined; - }; - - let replyText = payload.text ?? ""; - const deliverEvent = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }; - const deliverActx = { account, qualifiedTarget, log }; - - // 1. Media tags - const mediaResult = await parseAndSendMediaTags( - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - deliverDeps, - ); - if (mediaResult.handled) { - recordOutbound(); - return; - } - replyText = mediaResult.normalizedText; - - // 2. Structured payload (QQBOT_PAYLOAD:) - const handled = await handleStructuredPayload( - replyCtx, - replyText, - recordOutbound, - replyDeps, - ); - if (handled) { - return; - } - - // 3. Voice-intent plain text - if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) { - const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps); - if (sentVoice) { - recordOutbound(); - return; - } - } - - // 4. Plain text + images/media - await sendPlainReply( - payload, - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - toolMediaUrls, - deliverDeps, - ); - recordOutbound(); - }, - onError: async (err: unknown) => { - if (streamingController && !streamingController.isTerminalPhase) { - try { - await streamingController.onError(err); - } catch (streamErr) { - const streamErrMsg = streamErr instanceof Error ? streamErr.message : String(streamErr); - log?.error(`Streaming onError failed: ${streamErrMsg}`); - } - if (!streamingController.shouldFallbackToStatic) { - return; - } - } - const errMsg = err instanceof Error ? err.message : String(err); - log?.error(`Dispatch error: ${errMsg}`); - hasResponse = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }, - }, - replyOptions: { - disableBlockStreaming: useOfficialC2cStream - ? true - : (() => { - const s = account.config?.streaming; - if (s === false) { - return true; + if (toolOnlyTimeoutId) { + clearTimeout(toolOnlyTimeoutId); + toolOnlyTimeoutId = null; } - return typeof s === "object" && s !== null && s.mode === "off"; - })(), - ...(streamingController - ? { - onPartialReply: async (payload: { text?: string }) => { + + if (streamingController && !streamingController.isTerminalPhase) { try { - await streamingController.onPartialReply(payload); - } catch (partialErr) { + await streamingController.onDeliver(payload); + } catch (err) { log?.error( - `Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`, + `Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`, ); } - }, - } - : {}), - }, + + const replyPreview = (payload.text ?? "").trim(); + if ( + event.type === "group" && + (replyPreview === "NO_REPLY" || replyPreview === "[SKIP]") + ) { + log?.info( + `Model decided to skip group message (${replyPreview}) from ${event.senderId}`, + ); + return; + } + + if (streamingController.shouldFallbackToStatic) { + log?.info("Streaming API unavailable, falling back to static for this deliver"); + } else { + recordOutbound(); + return; + } + } + + const quoteRef = event.msgIdx; + let quoteRefUsed = false; + const consumeQuoteRef = (): string | undefined => { + if (quoteRef && !quoteRefUsed) { + quoteRefUsed = true; + return quoteRef; + } + return undefined; + }; + + let replyText = payload.text ?? ""; + const deliverEvent = { + type: event.type, + senderId: event.senderId, + messageId: event.messageId, + channelId: event.channelId, + groupOpenid: event.groupOpenid, + msgIdx: event.msgIdx, + }; + const deliverActx = { account, qualifiedTarget, log }; + + // 1. Media tags + const mediaResult = await parseAndSendMediaTags( + replyText, + deliverEvent, + deliverActx, + sendWithRetry, + consumeQuoteRef, + deliverDeps, + ); + if (mediaResult.handled) { + recordOutbound(); + return; + } + replyText = mediaResult.normalizedText; + + // 2. Structured payload (QQBOT_PAYLOAD:) + const handled = await handleStructuredPayload( + replyCtx, + replyText, + recordOutbound, + replyDeps, + ); + if (handled) { + return; + } + + // 3. Voice-intent plain text + if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) { + const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps); + if (sentVoice) { + recordOutbound(); + return; + } + } + + // 4. Plain text + images/media + await sendPlainReply( + payload, + replyText, + deliverEvent, + deliverActx, + sendWithRetry, + consumeQuoteRef, + toolMediaUrls, + deliverDeps, + ); + recordOutbound(); + }, + onError: async (err: unknown) => { + if (streamingController && !streamingController.isTerminalPhase) { + try { + await streamingController.onError(err); + } catch (streamErr) { + const streamErrMsg = + streamErr instanceof Error ? streamErr.message : String(streamErr); + log?.error(`Streaming onError failed: ${streamErrMsg}`); + } + if (!streamingController.shouldFallbackToStatic) { + return; + } + } + const errMsg = err instanceof Error ? err.message : String(err); + log?.error(`Dispatch error: ${errMsg}`); + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }, + }, + replyOptions: { + disableBlockStreaming: useOfficialC2cStream + ? true + : (() => { + const s = account.config?.streaming; + if (s === false) { + return true; + } + return typeof s === "object" && s !== null && s.mode === "off"; + })(), + ...(streamingController + ? { + onPartialReply: async (payload: { text?: string }) => { + try { + await streamingController.onPartialReply(payload); + } catch (partialErr) { + log?.error( + `Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`, + ); + } + }, + } + : {}), + }, + }), }); try { diff --git a/extensions/qqbot/src/engine/gateway/types.ts b/extensions/qqbot/src/engine/gateway/types.ts index ae2304447fa..1ce28d84946 100644 --- a/extensions/qqbot/src/engine/gateway/types.ts +++ b/extensions/qqbot/src/engine/gateway/types.ts @@ -52,6 +52,13 @@ export interface GatewayPluginRuntime { formatInboundEnvelope: (params: unknown) => string; resolveEnvelopeFormatOptions: (cfg: unknown) => unknown; }; + session: { + resolveStorePath: (store: unknown, params: { agentId: string }) => string; + recordInboundSession: (params: unknown) => Promise; + }; + turn: { + runPrepared: (params: unknown) => Promise; + }; text: { chunkMarkdownText: (text: string, limit: number) => string[]; }; diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 7b234832f2d..edbe53d3269 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -25,6 +25,7 @@ import { toInternalMessageReceivedContext, triggerInternalHook, } from "openclaw/plugin-sdk/hook-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { buildPendingHistoryContextFromMap, @@ -34,6 +35,7 @@ import { import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; +import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { @@ -232,42 +234,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { OriginatingTo: signalTo, }); - await recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - updateLastRoute: !entry.isGroup - ? { - sessionKey: route.mainSessionKey, - channel: "signal", - to: entry.senderRecipient, - accountId: route.accountId, - mainDmOwnerPin: (() => { - const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({ - dmScope: deps.cfg.session?.dmScope, - allowFrom: deps.allowFrom, - normalizeEntry: normalizeSignalAllowRecipient, - }); - if (!pinnedOwner) { - return undefined; - } - return { - ownerRecipient: pinnedOwner, - senderRecipient: entry.senderRecipient, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - }; - })(), - } - : undefined, - onRecordError: (err) => { - logVerbose(`signal: failed updating session meta: ${String(err)}`); - }, - }); - if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n"); logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`); @@ -323,18 +289,69 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }, }); - const { queuedFinal } = await dispatchInboundMessage({ - ctx: ctxPayload, - cfg: deps.cfg, - dispatcher, - replyOptions: { - ...replyOptions, - disableBlockStreaming: - typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, - onModelSelected, + const { dispatchResult } = await runPreparedInboundReplyTurn({ + channel: "signal", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession, + record: { + updateLastRoute: !entry.isGroup + ? { + sessionKey: route.mainSessionKey, + channel: "signal", + to: entry.senderRecipient, + accountId: route.accountId, + mainDmOwnerPin: (() => { + const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: deps.cfg.session?.dmScope, + allowFrom: deps.allowFrom, + normalizeEntry: normalizeSignalAllowRecipient, + }); + if (!pinnedOwner) { + return undefined; + } + return { + ownerRecipient: pinnedOwner, + senderRecipient: entry.senderRecipient, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + }; + })(), + } + : undefined, + onRecordError: (err) => { + logVerbose(`signal: failed updating session meta: ${String(err)}`); + }, + }, + onPreDispatchFailure: () => + settleReplyDispatcher({ + dispatcher, + onSettled: () => markDispatchIdle(), + }), + runDispatch: async () => { + try { + return await dispatchInboundMessage({ + ctx: ctxPayload, + cfg: deps.cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, + onModelSelected, + }, + }); + } finally { + markDispatchIdle(); + } }, }); - markDispatchIdle(); + const queuedFinal = dispatchResult?.queuedFinal ?? false; if (!queuedFinal) { if (entry.isGroup && historyKey) { clearHistoryEntriesIfEnabled({ diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index c7b2d91f69b..1c0abd53c22 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -100,12 +100,17 @@ function createPreparedSlackMessage(params?: { agentId: "agent-1", accountId: "default", mainSessionKey: "main", + sessionKey: "agent:agent-1:slack:C123", }, channelConfig: null, replyTarget: "channel:C123", ctxPayload: { MessageThreadId: THREAD_TS, }, + turn: { + storePath: "/tmp/slack-sessions.json", + record: {}, + }, replyToMode: params?.replyToMode ?? "all", isDirectMessage: false, isRoomish: false, @@ -139,6 +144,10 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({ removeAckReactionAfterReply: () => {}, })); +vi.mock("../conversation.runtime.js", () => ({ + recordInboundSession: vi.fn(async () => undefined), +})); + vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({ createChannelReplyPipeline: () => ({ typingCallbacks: { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index a0b26d4e358..74ada3fb910 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -18,6 +18,7 @@ import { resolveChannelStreamingPreviewToolProgress, } from "openclaw/plugin-sdk/channel-streaming"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; @@ -50,6 +51,7 @@ import { import { resolveSlackThreadTargets } from "../../threading.js"; import { normalizeSlackAllowOwnerEntry } from "../allow-list.js"; import { resolveStorePath, updateLastRoute } from "../config.runtime.js"; +import { recordInboundSession } from "../conversation.runtime.js"; import { escapeSlackMrkdwn } from "../mrkdwn.js"; import { createSlackReplyDeliveryPlan, @@ -58,7 +60,11 @@ import { resolveDeliveredSlackReplyThreadTs, resolveSlackThreadTs, } from "../replies.js"; -import { createReplyDispatcherWithTyping, dispatchInboundMessage } from "../reply.runtime.js"; +import { + createReplyDispatcherWithTyping, + dispatchInboundMessage, + settleReplyDispatcher, +} from "../reply.runtime.js"; import { finalizeSlackPreviewEdit } from "./preview-finalize.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -976,83 +982,104 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag let dispatchError: unknown; let queuedFinal = false; let counts: { final?: number; block?: number } = {}; + let dispatchSettledBeforeStart = false; try { - const result = await dispatchInboundMessage({ - ctx: prepared.ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: prepared.channelConfig?.skills, - sourceReplyDeliveryMode, - hasRepliedRef, - disableBlockStreaming, - onModelSelected, - suppressDefaultToolProgressMessages: previewToolProgressEnabled ? true : undefined, - onPartialReply: useStreaming - ? undefined - : !previewStreamingEnabled - ? undefined - : async (payload) => { - updateDraftFromPartial(payload.text); - }, - onAssistantMessageStart: onDraftBoundary, - onReasoningEnd: onDraftBoundary, - onReasoningStream: statusReactionsEnabled - ? async () => { - await statusReactions.setThinking(); - } - : undefined, - onToolStart: async (payload) => { - if (statusReactionsEnabled) { - await statusReactions.setTool(payload.name); - } - pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running"); - }, - onItemEvent: async (payload) => { - pushPreviewToolProgress( - payload.progressText ?? payload.summary ?? payload.title ?? payload.name, - ); - }, - onPlanUpdate: async (payload) => { - if (payload.phase !== "update") { - return; - } - pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning"); - }, - onApprovalEvent: async (payload) => { - if (payload.phase !== "requested") { - return; - } - pushPreviewToolProgress( - payload.command ? `approval: ${payload.command}` : "approval requested", - ); - }, - onCommandOutput: async (payload) => { - if (payload.phase !== "end") { - return; - } - pushPreviewToolProgress( - payload.name - ? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}` - : payload.title, - ); - }, - onPatchSummary: async (payload) => { - if (payload.phase !== "end") { - return; - } - pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied"); - }, + const { dispatchResult } = await runPreparedInboundReplyTurn({ + channel: "slack", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath: prepared.turn.storePath, + ctxPayload: prepared.ctxPayload, + recordInboundSession, + record: prepared.turn.record as Parameters[0]["record"], + onPreDispatchFailure: async () => { + dispatchSettledBeforeStart = true; + await settleReplyDispatcher({ + dispatcher, + onSettled: () => markDispatchIdle(), + }); }, + runDispatch: () => + dispatchInboundMessage({ + ctx: prepared.ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: prepared.channelConfig?.skills, + sourceReplyDeliveryMode, + hasRepliedRef, + disableBlockStreaming, + onModelSelected, + suppressDefaultToolProgressMessages: previewToolProgressEnabled ? true : undefined, + onPartialReply: useStreaming + ? undefined + : !previewStreamingEnabled + ? undefined + : async (payload) => { + updateDraftFromPartial(payload.text); + }, + onAssistantMessageStart: onDraftBoundary, + onReasoningEnd: onDraftBoundary, + onReasoningStream: statusReactionsEnabled + ? async () => { + await statusReactions.setThinking(); + } + : undefined, + onToolStart: async (payload) => { + if (statusReactionsEnabled) { + await statusReactions.setTool(payload.name); + } + pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running"); + }, + onItemEvent: async (payload) => { + pushPreviewToolProgress( + payload.progressText ?? payload.summary ?? payload.title ?? payload.name, + ); + }, + onPlanUpdate: async (payload) => { + if (payload.phase !== "update") { + return; + } + pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning"); + }, + onApprovalEvent: async (payload) => { + if (payload.phase !== "requested") { + return; + } + pushPreviewToolProgress( + payload.command ? `approval: ${payload.command}` : "approval requested", + ); + }, + onCommandOutput: async (payload) => { + if (payload.phase !== "end") { + return; + } + pushPreviewToolProgress( + payload.name + ? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}` + : payload.title, + ); + }, + onPatchSummary: async (payload) => { + if (payload.phase !== "end") { + return; + } + pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied"); + }, + }, + }), }); + const result = dispatchResult; queuedFinal = result.queuedFinal; counts = result.counts; } catch (err) { dispatchError = err; } finally { await draftStream?.discardPending(); - markDispatchIdle(); + if (!dispatchSettledBeforeStart) { + markDispatchIdle(); + } } // ----------------------------------------------------------------------- diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index d1f50c5a1c5..07272b4d1d9 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -54,7 +54,7 @@ import { resolveSlackChatType, type SlackMonitorContext, } from "../context.js"; -import { recordInboundSession, resolveConversationLabel } from "../conversation.runtime.js"; +import { resolveConversationLabel } from "../conversation.runtime.js"; import { authorizeSlackDirectMessage } from "../dm-auth.js"; import { resolveSlackRoomContextHints } from "../room-context.js"; import { sendMessageSlack } from "../send.runtime.js"; @@ -746,43 +746,6 @@ export async function prepareSlackMessage(params: { }) : null; - await recordInboundSession({ - storePath, - sessionKey, - ctx: ctxPayload, - updateLastRoute: isDirectMessage - ? { - sessionKey: route.mainSessionKey, - channel: "slack", - to: `user:${message.user}`, - accountId: route.accountId, - threadId: threadContext.messageThreadId, - mainDmOwnerPin: - pinnedMainDmOwner && message.user - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: normalizeLowercaseStringOrEmpty(message.user), - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, - onRecordError: (err) => { - ctx.logger.warn( - { - error: formatErrorMessage(err), - storePath, - sessionKey, - }, - "failed updating session meta", - ); - }, - }); - // Live DM replies should target the concrete Slack DM channel id we just // received on. This avoids depending on a follow-up conversations.open // round-trip for the normal reply path while keeping persisted routing @@ -804,6 +767,48 @@ export async function prepareSlackMessage(params: { channelConfig, replyTarget, ctxPayload, + turn: { + storePath, + record: { + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "slack", + to: `user:${message.user}`, + accountId: route.accountId, + threadId: threadContext.messageThreadId, + mainDmOwnerPin: + pinnedMainDmOwner && message.user + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: normalizeLowercaseStringOrEmpty(message.user), + onSkip: ({ + ownerRecipient, + senderRecipient, + }: { + ownerRecipient: string; + senderRecipient: string; + }) => { + logVerbose( + `slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, + onRecordError: (err: unknown) => { + ctx.logger.warn( + { + error: formatErrorMessage(err), + storePath, + sessionKey, + }, + "failed updating session meta", + ); + }, + }, + }, replyToMode, isDirectMessage, isRoomish, diff --git a/extensions/slack/src/monitor/message-handler/types.ts b/extensions/slack/src/monitor/message-handler/types.ts index 017395d66f3..6c8dfac3826 100644 --- a/extensions/slack/src/monitor/message-handler/types.ts +++ b/extensions/slack/src/monitor/message-handler/types.ts @@ -13,6 +13,10 @@ export type PreparedSlackMessage = { channelConfig: SlackChannelConfigResolved | null; replyTarget: string; ctxPayload: FinalizedMsgContext; + turn: { + storePath: string; + record: unknown; + }; replyToMode: "off" | "first" | "all" | "batched"; isDirectMessage: boolean; isRoomish: boolean; diff --git a/extensions/slack/src/monitor/reply.runtime.ts b/extensions/slack/src/monitor/reply.runtime.ts index ca05e1eb87c..0957f68fdce 100644 --- a/extensions/slack/src/monitor/reply.runtime.ts +++ b/extensions/slack/src/monitor/reply.runtime.ts @@ -7,5 +7,6 @@ export { getReplyFromConfig, isSilentReplyText, resolveTextChunkLimit, + settleReplyDispatcher, SILENT_REPLY_TOKEN, } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index 815951227d6..ddcfda1c00d 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -95,6 +95,23 @@ vi.mock("./runtime.js", () => ({ finalizeInboundContext: finalizeInboundContextMock, dispatchReplyWithBufferedBlockDispatcher, }, + session: { + resolveStorePath: vi.fn(() => "/tmp/openclaw/synology-chat-sessions.json"), + recordInboundSession: vi.fn(async () => undefined), + }, + turn: { + dispatchAssembled: vi.fn(async (params) => ({ + dispatchResult: await params.dispatchReplyWithBufferedBlockDispatcher({ + ctx: params.ctxPayload, + cfg: mockRuntimeConfig, + dispatcherOptions: { + ...params.dispatcherOptions, + deliver: params.delivery.deliver, + onError: params.delivery.onError, + }, + }), + })), + }, }, })), setSynologyRuntime: vi.fn(), diff --git a/extensions/synology-chat/src/inbound-turn.ts b/extensions/synology-chat/src/inbound-turn.ts index 185b28a367d..9160356dadb 100644 --- a/extensions/synology-chat/src/inbound-turn.ts +++ b/extensions/synology-chat/src/inbound-turn.ts @@ -78,21 +78,40 @@ export async function dispatchSynologyChatInboundTurn(params: { sessionKey: resolved.sessionKey, }); - await resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: msgCtx, + const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, { + agentId: resolved.route.agentId, + }); + + await resolved.rt.channel.turn.dispatchAssembled({ cfg: currentCfg, - dispatcherOptions: { - deliver: async (payload: { text?: string; body?: string }) => { + channel: CHANNEL_ID, + accountId: params.account.accountId, + agentId: resolved.route.agentId, + routeSessionKey: resolved.route.sessionKey, + storePath, + ctxPayload: msgCtx, + recordInboundSession: resolved.rt.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload) => { await deliverSynologyChatReply({ account: params.account, sendUserId, payload, }); }, + }, + dispatcherOptions: { onReplyStart: () => { params.log?.info?.(`Agent reply started for ${params.msg.from}`); }, }, + record: { + onRecordError: (err) => { + params.log?.info?.(`Session metadata update failed for ${params.msg.from}`, err); + }, + }, }); return null; diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index cbde8881f63..896c7dd6930 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -169,7 +169,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123"); - expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({ + expect(ctx?.turn.record).toMatchObject({ updateLastRoute: undefined, }); expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); diff --git a/extensions/telegram/src/bot-message-context.route-test-support.ts b/extensions/telegram/src/bot-message-context.route-test-support.ts index 1a60ccd6427..379b14b6425 100644 --- a/extensions/telegram/src/bot-message-context.route-test-support.ts +++ b/extensions/telegram/src/bot-message-context.route-test-support.ts @@ -40,16 +40,23 @@ export const telegramRouteTestSessionRuntime = { export async function loadTelegramMessageContextRouteHarness() { const { buildTelegramMessageContextForTest } = await import("./bot-message-context.test-harness.js"); - const buildTelegramMessageContextForRouteTest = ( + const buildTelegramMessageContextForRouteTest = async ( params: BuildTelegramMessageContextForTestParams, - ) => - buildTelegramMessageContextForTest({ + ) => { + const ctx = await buildTelegramMessageContextForTest({ ...params, sessionRuntime: { ...telegramRouteTestSessionRuntime, ...params.sessionRuntime, }, }); + if (ctx) { + await recordInboundSessionMock({ + updateLastRoute: ctx.turn.record.updateLastRoute, + }); + } + return ctx; + }; return { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 8d67edaaeb2..1069b922795 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -122,6 +122,16 @@ export async function buildTelegramInboundContextPayload(params: { }): Promise<{ ctxPayload: FinalizedTelegramInboundContext; skillFilter: string[] | undefined; + turn: { + storePath: string; + recordInboundSession: TelegramMessageContextSessionRuntime["recordInboundSession"]; + record: { + updateLastRoute?: Parameters< + TelegramMessageContextSessionRuntime["recordInboundSession"] + >[0]["updateLastRoute"]; + onRecordError: (err: unknown) => void; + }; + }; }> { const { cfg, @@ -415,42 +425,34 @@ export async function buildTelegramInboundContextPayload(params: { ? String(dmThreadId) : undefined; - await sessionRuntime.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - updateLastRoute: - !isGroup || updateLastRouteThreadId != null - ? { - sessionKey: updateLastRouteSessionKey, - channel: "telegram", - to: - isGroup && updateLastRouteThreadId != null - ? `telegram:${chatId}:topic:${updateLastRouteThreadId}` - : `telegram:${chatId}`, - accountId: route.accountId, - threadId: updateLastRouteThreadId, - mainDmOwnerPin: - !isGroup && - updateLastRouteSessionKey === route.mainSessionKey && - pinnedMainDmOwner && - senderId - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: senderId, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, - onRecordError: (err) => { - logVerbose(`telegram: failed updating session meta: ${String(err)}`); - }, - }); + const updateLastRoute = + !isGroup || updateLastRouteThreadId != null + ? { + sessionKey: updateLastRouteSessionKey, + channel: "telegram" as const, + to: + isGroup && updateLastRouteThreadId != null + ? `telegram:${chatId}:topic:${updateLastRouteThreadId}` + : `telegram:${chatId}`, + accountId: route.accountId, + threadId: updateLastRouteThreadId, + mainDmOwnerPin: + !isGroup && + updateLastRouteSessionKey === route.mainSessionKey && + pinnedMainDmOwner && + senderId + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: senderId, + onSkip: (skipParams: { ownerRecipient: string; senderRecipient: string }) => { + logVerbose( + `telegram: skip main-session last route for ${skipParams.senderRecipient} (pinned owner ${skipParams.ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined; if (visibleReplyTarget && shouldLogVerbose()) { const preview = (visibleReplyTarget.body ?? "").replace(/\s+/g, " ").slice(0, 120); @@ -477,5 +479,15 @@ export async function buildTelegramInboundContextPayload(params: { return { ctxPayload, skillFilter, + turn: { + storePath, + recordInboundSession: sessionRuntime.recordInboundSession, + record: { + updateLastRoute, + onRecordError: (err) => { + logVerbose(`telegram: failed updating session meta: ${String(err)}`); + }, + }, + }, }; } diff --git a/extensions/telegram/src/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts index 577902a67ca..bac7d3c9722 100644 --- a/extensions/telegram/src/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -93,7 +93,7 @@ describe("buildTelegramMessageContext thread binding override", () => { }), ); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-1"); - expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({ + expect(ctx?.turn.record).toMatchObject({ updateLastRoute: undefined, }); }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 58a8fa01989..a1b2afcbe3e 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -73,6 +73,7 @@ type TelegramStatusReactionController = { export type TelegramMessageContext = { ctxPayload: TelegramMessageContextPayload["ctxPayload"]; + turn: TelegramMessageContextPayload["turn"]; primaryCtx: BuildTelegramMessageContextParams["primaryCtx"]; msg: BuildTelegramMessageContextParams["primaryCtx"]["message"]; chatId: BuildTelegramMessageContextParams["primaryCtx"]["message"]["chat"]["id"]; @@ -554,7 +555,7 @@ export const buildTelegramMessageContext = async ({ ) : null; - const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({ + const { ctxPayload, skillFilter, turn } = await buildTelegramInboundContextPayload({ cfg, primaryCtx, msg, @@ -592,6 +593,7 @@ export const buildTelegramMessageContext = async ({ return { ctxPayload, + turn, primaryCtx, msg, chatId, diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index b5fb8b5e707..17bb79a291c 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -290,6 +290,13 @@ describe("dispatchTelegramMessage draft streaming", () => { reactionApi: null, removeAckAfterReply: false, } as unknown as TelegramMessageContext; + base.turn = { + storePath: "/tmp/openclaw/telegram-sessions.json", + recordInboundSession: vi.fn(async () => undefined), + record: { + onRecordError: vi.fn(), + }, + } as unknown as TelegramMessageContext["turn"]; return { ...base, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index a835aa231cb..d72a0e8461a 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -17,6 +17,7 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { createOutboundPayloadPlan, projectOutboundPayloadPlanForDelivery, @@ -840,300 +841,313 @@ export const dispatchTelegramMessage = async ({ }); try { - ({ queuedFinal } = await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - ...replyPipeline, - beforeDeliver: async (payload) => payload, - deliver: async (payload, info) => { - if (isDispatchSuperseded()) { - return; - } - const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (didDeliver: boolean) => { - if (didDeliver && info.kind !== "final") { - pendingCompactionReplayBoundary = false; - } - }; - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } - if (info.kind === "final") { - await enqueueDraftLaneEvent(async () => {}); - } - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg, - accountId: route.accountId, - payload, - }) - ) { - queuedFinal = true; - return; - } - const previewButtons = ( - payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined - )?.buttons; - const split = splitTextIntoLaneSegments(payload.text); - const segments = split.segments; - const reply = resolveSendableOutboundReplyParts(payload); - const _hasMedia = reply.hasMedia; - - const flushBufferedFinalAnswer = async () => { - const buffered = reasoningStepState.takeBufferedFinalAnswer(); - if (!buffered) { - return; - } - const bufferedButtons = ( - buffered.payload.channelData?.telegram as - | { buttons?: TelegramInlineButtons } - | undefined - )?.buttons; - await deliverLaneText({ - laneName: "answer", - text: buffered.text, - payload: buffered.payload, - infoKind: "final", - previewButtons: bufferedButtons, - }); - reasoningStepState.resetForNextStep(); - }; - - for (const segment of segments) { - if ( - segment.lane === "answer" && - info.kind === "final" && - reasoningStepState.shouldBufferFinalAnswer() - ) { - reasoningStepState.bufferFinalAnswer({ - payload, - text: segment.text, - }); - continue; - } - if (segment.lane === "reasoning") { - reasoningStepState.noteReasoningHint(); - } - const result = await deliverLaneText({ - laneName: segment.lane, - text: segment.text, - payload, - infoKind: info.kind, - previewButtons, - allowPreviewUpdateForNonFinal: segment.lane === "reasoning", - }); - if (info.kind === "final") { - emitPreviewFinalizedHook(result); - } - if (segment.lane === "reasoning") { - if (result.kind !== "skipped") { - reasoningStepState.noteReasoningDelivered(); - await flushBufferedFinalAnswer(); + const { dispatchResult } = await runPreparedInboundReplyTurn({ + channel: "telegram", + accountId: route.accountId, + routeSessionKey: route.sessionKey, + storePath: context.turn.storePath, + ctxPayload, + recordInboundSession: context.turn.recordInboundSession, + record: context.turn.record, + runDispatch: () => + telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + ...replyPipeline, + beforeDeliver: async (payload) => payload, + deliver: async (payload, info) => { + if (isDispatchSuperseded()) { + return; } - continue; - } - if (info.kind === "final") { - if (reasoningLane.hasStreamedMessage) { - activePreviewLifecycleByLane.reasoning = "complete"; - retainPreviewOnCleanupByLane.reasoning = true; - } - reasoningStepState.resetForNextStep(); - } - } - if (segments.length > 0) { - if (info.kind === "final") { - pendingCompactionReplayBoundary = false; - } - return; - } - if (split.suppressedReasoningOnly) { - if (reply.hasMedia) { - const payloadWithoutSuppressedReasoning = - typeof payload.text === "string" ? { ...payload, text: "" } : payload; - clearPendingCompactionReplayBoundaryOnVisibleBoundary( - await sendPayload(payloadWithoutSuppressedReasoning), - ); - } - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - pendingCompactionReplayBoundary = false; - } - return; - } - - if (info.kind === "final") { - await answerLane.stream?.stop(); - await reasoningLane.stream?.stop(); - reasoningStepState.resetForNextStep(); - } - const canSendAsIs = reply.hasMedia || reply.text.length > 0; - if (!canSendAsIs) { - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - pendingCompactionReplayBoundary = false; - } - return; - } - clearPendingCompactionReplayBoundaryOnVisibleBoundary(await sendPayload(payload)); - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - pendingCompactionReplayBoundary = false; - } - }, - onSkip: (payload, info) => { - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } - if (info.reason !== "silent") { - deliveryState.markNonSilentSkip(); - } - }, - onError: (err, info) => { - const errorPolicy = resolveTelegramErrorPolicy({ - accountConfig: telegramCfg, - groupConfig, - topicConfig, - }); - if (isSilentErrorPolicy(errorPolicy.policy)) { - return; - } - if ( - errorPolicy.policy === "once" && - shouldSuppressTelegramError({ - scopeKey: buildTelegramErrorScopeKey({ - accountId: route.accountId, - chatId, - threadId: threadSpec.id, - }), - cooldownMs: errorPolicy.cooldownMs, - errorMessage: String(err), - }) - ) { - return; - } - deliveryState.markNonSilentFailure(); - runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - skillFilter, - disableBlockStreaming, - onPartialReply: - answerLane.stream || reasoningLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - await ingestDraftLaneSegments(payload.text); - }) - : undefined, - onReasoningStream: reasoningLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - if (splitReasoningOnNextStream) { - reasoningLane.stream?.forceNewMessage(); - resetDraftLaneState(reasoningLane); - splitReasoningOnNextStream = false; - } - await ingestDraftLaneSegments(payload.text); - }) - : undefined, - onAssistantMessageStart: answerLane.stream - ? () => - enqueueDraftLaneEvent(async () => { - reasoningStepState.resetForNextStep(); - previewToolProgressSuppressed = false; - previewToolProgressLines = []; - if (skipNextAnswerMessageStartRotation) { - skipNextAnswerMessageStartRotation = false; - activePreviewLifecycleByLane.answer = "transient"; - retainPreviewOnCleanupByLane.answer = false; - return; - } - if (pendingCompactionReplayBoundary) { + const clearPendingCompactionReplayBoundaryOnVisibleBoundary = ( + didDeliver: boolean, + ) => { + if (didDeliver && info.kind !== "final") { pendingCompactionReplayBoundary = false; - activePreviewLifecycleByLane.answer = "transient"; - retainPreviewOnCleanupByLane.answer = false; + } + }; + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } + if (info.kind === "final") { + await enqueueDraftLaneEvent(async () => {}); + } + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + queuedFinal = true; + return; + } + const previewButtons = ( + payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined + )?.buttons; + const split = splitTextIntoLaneSegments(payload.text); + const segments = split.segments; + const reply = resolveSendableOutboundReplyParts(payload); + const _hasMedia = reply.hasMedia; + + const flushBufferedFinalAnswer = async () => { + const buffered = reasoningStepState.takeBufferedFinalAnswer(); + if (!buffered) { return; } - await rotateAnswerLaneForNewAssistantMessage(); - activePreviewLifecycleByLane.answer = "transient"; - retainPreviewOnCleanupByLane.answer = false; - }) - : undefined, - onReasoningEnd: reasoningLane.stream - ? () => - enqueueDraftLaneEvent(async () => { - splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; - previewToolProgressSuppressed = false; - previewToolProgressLines = []; - }) - : undefined, - suppressDefaultToolProgressMessages: - !previewStreamingEnabled || Boolean(answerLane.stream), - onToolStart: async (payload) => { - const toolName = payload.name?.trim(); - if (statusReactionController && toolName) { - await statusReactionController.setTool(toolName); - } - pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running"); - }, - onItemEvent: async (payload) => { - pushPreviewToolProgress( - payload.progressText ?? payload.summary ?? payload.title ?? payload.name, - ); - }, - onPlanUpdate: async (payload) => { - if (payload.phase !== "update") { - return; - } - pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning"); - }, - onApprovalEvent: async (payload) => { - if (payload.phase !== "requested") { - return; - } - pushPreviewToolProgress( - payload.command ? `approval: ${payload.command}` : "approval requested", - ); - }, - onCommandOutput: async (payload) => { - if (payload.phase !== "end") { - return; - } - pushPreviewToolProgress( - payload.name - ? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}` - : payload.title, - ); - }, - onPatchSummary: async (payload) => { - if (payload.phase !== "end") { - return; - } - pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied"); - }, - onCompactionStart: - statusReactionController || answerLane.stream - ? async () => { + const bufferedButtons = ( + buffered.payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons } + | undefined + )?.buttons; + await deliverLaneText({ + laneName: "answer", + text: buffered.text, + payload: buffered.payload, + infoKind: "final", + previewButtons: bufferedButtons, + }); + reasoningStepState.resetForNextStep(); + }; + + for (const segment of segments) { if ( - answerLane.hasStreamedMessage && - activePreviewLifecycleByLane.answer === "transient" + segment.lane === "answer" && + info.kind === "final" && + reasoningStepState.shouldBufferFinalAnswer() ) { - pendingCompactionReplayBoundary = true; + reasoningStepState.bufferFinalAnswer({ + payload, + text: segment.text, + }); + continue; } - if (statusReactionController) { - await statusReactionController.setCompacting(); + if (segment.lane === "reasoning") { + reasoningStepState.noteReasoningHint(); + } + const result = await deliverLaneText({ + laneName: segment.lane, + text: segment.text, + payload, + infoKind: info.kind, + previewButtons, + allowPreviewUpdateForNonFinal: segment.lane === "reasoning", + }); + if (info.kind === "final") { + emitPreviewFinalizedHook(result); + } + if (segment.lane === "reasoning") { + if (result.kind !== "skipped") { + reasoningStepState.noteReasoningDelivered(); + await flushBufferedFinalAnswer(); + } + continue; + } + if (info.kind === "final") { + if (reasoningLane.hasStreamedMessage) { + activePreviewLifecycleByLane.reasoning = "complete"; + retainPreviewOnCleanupByLane.reasoning = true; + } + reasoningStepState.resetForNextStep(); } } - : undefined, - onCompactionEnd: statusReactionController - ? async () => { - statusReactionController.cancelPending(); - await statusReactionController.setThinking(); - } - : undefined, - onModelSelected, - }, - })); + if (segments.length > 0) { + if (info.kind === "final") { + pendingCompactionReplayBoundary = false; + } + return; + } + if (split.suppressedReasoningOnly) { + if (reply.hasMedia) { + const payloadWithoutSuppressedReasoning = + typeof payload.text === "string" ? { ...payload, text: "" } : payload; + clearPendingCompactionReplayBoundaryOnVisibleBoundary( + await sendPayload(payloadWithoutSuppressedReasoning), + ); + } + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + pendingCompactionReplayBoundary = false; + } + return; + } + + if (info.kind === "final") { + await answerLane.stream?.stop(); + await reasoningLane.stream?.stop(); + reasoningStepState.resetForNextStep(); + } + const canSendAsIs = reply.hasMedia || reply.text.length > 0; + if (!canSendAsIs) { + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + pendingCompactionReplayBoundary = false; + } + return; + } + clearPendingCompactionReplayBoundaryOnVisibleBoundary(await sendPayload(payload)); + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + pendingCompactionReplayBoundary = false; + } + }, + onSkip: (payload, info) => { + if (payload.isError === true) { + hadErrorReplyFailureOrSkip = true; + } + if (info.reason !== "silent") { + deliveryState.markNonSilentSkip(); + } + }, + onError: (err, info) => { + const errorPolicy = resolveTelegramErrorPolicy({ + accountConfig: telegramCfg, + groupConfig, + topicConfig, + }); + if (isSilentErrorPolicy(errorPolicy.policy)) { + return; + } + if ( + errorPolicy.policy === "once" && + shouldSuppressTelegramError({ + scopeKey: buildTelegramErrorScopeKey({ + accountId: route.accountId, + chatId, + threadId: threadSpec.id, + }), + cooldownMs: errorPolicy.cooldownMs, + errorMessage: String(err), + }) + ) { + return; + } + deliveryState.markNonSilentFailure(); + runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); + }, + }, + replyOptions: { + skillFilter, + disableBlockStreaming, + onPartialReply: + answerLane.stream || reasoningLane.stream + ? (payload) => + enqueueDraftLaneEvent(async () => { + await ingestDraftLaneSegments(payload.text); + }) + : undefined, + onReasoningStream: reasoningLane.stream + ? (payload) => + enqueueDraftLaneEvent(async () => { + if (splitReasoningOnNextStream) { + reasoningLane.stream?.forceNewMessage(); + resetDraftLaneState(reasoningLane); + splitReasoningOnNextStream = false; + } + await ingestDraftLaneSegments(payload.text); + }) + : undefined, + onAssistantMessageStart: answerLane.stream + ? () => + enqueueDraftLaneEvent(async () => { + reasoningStepState.resetForNextStep(); + previewToolProgressSuppressed = false; + previewToolProgressLines = []; + if (skipNextAnswerMessageStartRotation) { + skipNextAnswerMessageStartRotation = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; + return; + } + if (pendingCompactionReplayBoundary) { + pendingCompactionReplayBoundary = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; + return; + } + await rotateAnswerLaneForNewAssistantMessage(); + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; + }) + : undefined, + onReasoningEnd: reasoningLane.stream + ? () => + enqueueDraftLaneEvent(async () => { + splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; + previewToolProgressSuppressed = false; + previewToolProgressLines = []; + }) + : undefined, + suppressDefaultToolProgressMessages: + !previewStreamingEnabled || Boolean(answerLane.stream), + onToolStart: async (payload) => { + const toolName = payload.name?.trim(); + if (statusReactionController && toolName) { + await statusReactionController.setTool(toolName); + } + pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running"); + }, + onItemEvent: async (payload) => { + pushPreviewToolProgress( + payload.progressText ?? payload.summary ?? payload.title ?? payload.name, + ); + }, + onPlanUpdate: async (payload) => { + if (payload.phase !== "update") { + return; + } + pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning"); + }, + onApprovalEvent: async (payload) => { + if (payload.phase !== "requested") { + return; + } + pushPreviewToolProgress( + payload.command ? `approval: ${payload.command}` : "approval requested", + ); + }, + onCommandOutput: async (payload) => { + if (payload.phase !== "end") { + return; + } + pushPreviewToolProgress( + payload.name + ? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}` + : payload.title, + ); + }, + onPatchSummary: async (payload) => { + if (payload.phase !== "end") { + return; + } + pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied"); + }, + onCompactionStart: + statusReactionController || answerLane.stream + ? async () => { + if ( + answerLane.hasStreamedMessage && + activePreviewLifecycleByLane.answer === "transient" + ) { + pendingCompactionReplayBoundary = true; + } + if (statusReactionController) { + await statusReactionController.setCompacting(); + } + } + : undefined, + onCompactionEnd: statusReactionController + ? async () => { + statusReactionController.cancelPending(); + await statusReactionController.setThinking(); + } + : undefined, + onModelSelected, + }, + }), + }); + ({ queuedFinal } = dispatchResult); } catch (err) { dispatchError = err; runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`)); diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index ff7e1dda8a2..903e7f7e837 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -550,13 +550,22 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { let replyText = payload.text; if (!replyText) { @@ -607,6 +616,15 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`); + }, + }, }); }; diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index 27cb2be3dfd..11f5e3c3bf7 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -62,6 +62,7 @@ async function processTwitchMessage(params: { }); const rawBody = message.message; + const senderId = message.userId ?? message.username; const body = core.channel.reply.formatAgentEnvelope({ channel: "Twitch", from: message.displayName ?? message.username, @@ -70,38 +71,47 @@ async function processTwitchMessage(params: { body: rawBody, }); - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: rawBody, - RawBody: rawBody, - CommandBody: rawBody, - From: `twitch:user:${message.userId}`, - To: `twitch:channel:${message.channel}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: "group", - ConversationLabel: message.channel, - SenderName: message.displayName ?? message.username, - SenderId: message.userId, - SenderUsername: message.username, - Provider: "twitch", - Surface: "twitch", - MessageSid: message.id, - OriginatingChannel: "twitch", - OriginatingTo: `twitch:channel:${message.channel}`, + const ctxPayload = core.channel.turn.buildContext({ + channel: "twitch", + accountId, + messageId: message.id, + timestamp: message.timestamp?.getTime(), + from: `twitch:user:${senderId}`, + sender: { + id: senderId, + name: message.displayName ?? message.username, + username: message.username, + }, + conversation: { + kind: "group", + id: message.channel, + label: message.channel, + routePeer: { + kind: "group", + id: message.channel, + }, + }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: route.sessionKey, + }, + reply: { + to: `twitch:channel:${message.channel}`, + originatingTo: `twitch:channel:${message.channel}`, + }, + message: { + body, + rawBody, + bodyForAgent: rawBody, + commandBody: rawBody, + envelopeFrom: message.displayName ?? message.username, + }, }); const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - runtime.error?.(`Failed updating session meta: ${String(err)}`); - }, - }); const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, @@ -115,11 +125,18 @@ async function processTwitchMessage(params: { accountId, }); - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, + await core.channel.turn.dispatchAssembled({ cfg, - dispatcherOptions: { - ...replyPipeline, + channel: "twitch", + accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver: async (payload) => { await deliverTwitchReply({ payload, @@ -132,10 +149,19 @@ async function processTwitchMessage(params: { statusSink, }); }, + onError: (err, info) => { + runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`); + }, }, + dispatcherOptions: replyPipeline, replyOptions: { onModelSelected, }, + record: { + onRecordError: (err) => { + runtime.error?.(`Failed updating session meta: ${String(err)}`); + }, + }, }); } diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index 0e1e7d1b452..d972b3b375e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Hoisted mocks used across tests so vi.mock factories can reference them. -const { resolvePolicyMock, buildContextMock, runMessageReceivedMock } = vi.hoisted(() => ({ - resolvePolicyMock: vi.fn(), - buildContextMock: vi.fn(), - runMessageReceivedMock: vi.fn(async () => undefined), -})); +const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } = + vi.hoisted(() => ({ + resolvePolicyMock: vi.fn(), + buildContextMock: vi.fn(), + runMessageReceivedMock: vi.fn(async () => undefined), + trackBackgroundTaskMock: vi.fn(), + })); vi.mock("../../inbound-policy.js", async (importOriginal) => { const actual = await importOriginal(); @@ -89,7 +91,7 @@ vi.mock("./last-route.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - trackBackgroundTask: () => {}, + trackBackgroundTask: trackBackgroundTaskMock, updateLastRouteInBackground: () => {}, }; }); @@ -211,6 +213,7 @@ describe("processMessage group system prompt wiring", () => { buildContextMock.mockReset(); resolvePolicyMock.mockReset(); runMessageReceivedMock.mockClear(); + trackBackgroundTaskMock.mockClear(); clearInternalHooks(); buildContextMock.mockImplementation( (params: { groupSystemPrompt?: string; combinedBody?: string }) => ({ @@ -320,4 +323,22 @@ describe("processMessage group system prompt wiring", () => { expect(runMessageReceivedMock).not.toHaveBeenCalled(); expect(internalReceived).not.toHaveBeenCalled(); }); + + it("tracks session metadata writes as connection background tasks", async () => { + resolvePolicyMock.mockReturnValue(makePolicy(makeAccount())); + buildContextMock.mockImplementationOnce(() => ({ + Body: "hi", + RawBody: "hi", + CommandBody: "hi", + SessionKey: baseRoute.sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + })); + + await callProcessMessage(); + + expect(trackBackgroundTaskMock).toHaveBeenCalledTimes(1); + expect(trackBackgroundTaskMock.mock.calls[0]?.[0]).toBeInstanceOf(Set); + expect(trackBackgroundTaskMock.mock.calls[0]?.[1]).toBeInstanceOf(Promise); + }); }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 995a8380d3f..2bcbaaba203 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -3,6 +3,7 @@ import { removeAckReactionHandleAfterReply, type AckReactionHandle, } from "openclaw/plugin-sdk/channel-feedback"; +import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { createInternalHookEvent, deriveInboundMessageHookContext, @@ -12,6 +13,7 @@ import { toPluginMessageReceivedEvent, triggerInternalHook, } from "openclaw/plugin-sdk/hook-runtime"; +import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference"; import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js"; @@ -51,7 +53,6 @@ import { formatInboundEnvelope, logVerbose, normalizeE164, - recordSessionMetaFromInbound, resolveChannelContextVisibilityMode, resolveInboundSessionEnvelopeContext, resolvePinnedMainDmOwnerFromAllowlist, @@ -453,43 +454,51 @@ export async function processMessage(params: { warn: params.replyLogger.warn.bind(params.replyLogger), }); - const metaTask = recordSessionMetaFromInbound({ + const { dispatchResult: didSendReply } = await runPreparedInboundReplyTurn({ + channel: "whatsapp", + accountId: params.route.accountId, + routeSessionKey: params.route.sessionKey, storePath, - sessionKey: params.route.sessionKey, - ctx: ctxPayload, - }).catch((err) => { - params.replyLogger.warn( - { - error: formatError(err), - storePath, - sessionKey: params.route.sessionKey, + ctxPayload, + recordInboundSession, + record: { + onRecordError: (err) => { + params.replyLogger.warn( + { + error: formatError(err), + storePath, + sessionKey: params.route.sessionKey, + }, + "failed updating session meta", + ); + }, + trackSessionMetaTask: (task) => { + trackBackgroundTask(params.backgroundTasks, task); }, - "failed updating session meta", - ); - }); - trackBackgroundTask(params.backgroundTasks, metaTask); - - const didSendReply = await dispatchWhatsAppBufferedReply({ - cfg: params.cfg, - connectionId: params.connectionId, - context: ctxPayload, - conversationId, - deliverReply: deliverWebReply, - groupHistories: params.groupHistories, - groupHistoryKey: params.groupHistoryKey, - maxMediaBytes: params.maxMediaBytes, - maxMediaTextChunkLimit: params.maxMediaTextChunkLimit, - msg: params.msg, - onModelSelected, - rememberSentText: params.rememberSentText, - replyLogger: params.replyLogger, - replyPipeline: { - ...replyPipeline, - responsePrefix, }, - replyResolver: params.replyResolver, - route: params.route, - shouldClearGroupHistory, + runDispatch: () => + dispatchWhatsAppBufferedReply({ + cfg: params.cfg, + connectionId: params.connectionId, + context: ctxPayload, + conversationId, + deliverReply: deliverWebReply, + groupHistories: params.groupHistories, + groupHistoryKey: params.groupHistoryKey, + maxMediaBytes: params.maxMediaBytes, + maxMediaTextChunkLimit: params.maxMediaTextChunkLimit, + msg: params.msg, + onModelSelected, + rememberSentText: params.rememberSentText, + replyLogger: params.replyLogger, + replyPipeline: { + ...replyPipeline, + responsePrefix, + }, + replyResolver: params.replyResolver, + route: params.route, + shouldClearGroupHistory, + }), }); removeAckReactionHandleAfterReply({ removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply), diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index a28e9dbaee1..011c39e6067 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -606,15 +606,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr OriginatingTo: `zalo:${chatId}`, }); - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); - }, - }); - const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: config, channel: "zalo", @@ -649,11 +640,18 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr }, }); - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, + await core.channel.turn.dispatchAssembled({ cfg: config, - dispatcherOptions: { - ...replyPipeline, + channel: "zalo", + accountId: account.accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver: async (payload) => { await deliverZaloReply({ payload, @@ -677,9 +675,15 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`); }, }, + dispatcherOptions: replyPipeline, replyOptions: { onModelSelected, }, + record: { + onRecordError: (err) => { + runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); + }, + }, }); } diff --git a/extensions/zalo/src/test-support/lifecycle-test-support.ts b/extensions/zalo/src/test-support/lifecycle-test-support.ts index 650ced850ff..be1c2a085a5 100644 --- a/extensions/zalo/src/test-support/lifecycle-test-support.ts +++ b/extensions/zalo/src/test-support/lifecycle-test-support.ts @@ -187,6 +187,41 @@ export function createImageLifecycleCore() { async () => undefined, ) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], }, + turn: { + dispatchAssembled: vi.fn( + async (turn: Parameters[0]) => { + await turn.recordInboundSession({ + storePath: turn.storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + ctx: turn.ctxPayload, + groupResolution: turn.record?.groupResolution, + createIfMissing: turn.record?.createIfMissing, + updateLastRoute: turn.record?.updateLastRoute, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcherOptions: { + ...turn.dispatcherOptions, + deliver: async (payload, info) => { + await turn.delivery.deliver(payload, info); + }, + onError: turn.delivery.onError, + }, + replyOptions: turn.replyOptions, + replyResolver: turn.replyResolver, + }); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.routeSessionKey, + dispatchResult, + }; + }, + ) as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"], + }, commands: { shouldComputeCommandAuthorized: vi.fn( () => false, diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index 944ef3c0b5a..e0e831a17e9 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -89,6 +89,39 @@ function installRuntime(params: { const readSessionUpdatedAt = vi.fn( (_params?: { storePath: string; sessionKey: string }): number | undefined => undefined, ); + const dispatchAssembled = vi.fn( + async (turn: Parameters[0]) => { + await turn.recordInboundSession({ + storePath: turn.storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + ctx: turn.ctxPayload, + groupResolution: turn.record?.groupResolution, + createIfMissing: turn.record?.createIfMissing, + updateLastRoute: turn.record?.updateLastRoute, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcherOptions: { + ...turn.dispatcherOptions, + deliver: async (payload, info) => { + await turn.delivery.deliver(payload, info); + }, + onError: turn.delivery.onError, + }, + replyOptions: turn.replyOptions, + replyResolver: turn.replyResolver, + }); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.routeSessionKey, + dispatchResult, + }; + }, + ); const buildAgentSessionKey = vi.fn( (input: { agentId: string; @@ -167,6 +200,10 @@ function installRuntime(params: { finalizeInboundContext: vi.fn((ctx) => ctx), dispatchReplyWithBufferedBlockDispatcher, }, + turn: { + dispatchAssembled: + dispatchAssembled as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"], + }, text: { resolveMarkdownTableMode: vi.fn(() => "code"), convertMarkdownTables: vi.fn((text: string) => text), diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 39956ddcea8..89eb10accc1 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -628,15 +628,6 @@ async function processMessage( OriginatingTo: normalizedTo, }); - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - onRecordError: (err) => { - runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); - }, - }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ cfg: config, agentId: route.agentId, @@ -658,11 +649,18 @@ async function processMessage( }, }); - await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, + await core.channel.turn.dispatchAssembled({ cfg: config, - dispatcherOptions: { - ...replyPipeline, + channel: "zalouser", + accountId: account.accountId, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath, + ctxPayload, + recordInboundSession: core.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver: async (payload) => { await deliverZalouserReply({ payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }, @@ -685,9 +683,15 @@ async function processMessage( runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`); }, }, + dispatcherOptions: replyPipeline, replyOptions: { onModelSelected, }, + record: { + onRecordError: (err) => { + runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); + }, + }, }); if (isGroup && historyKey) { clearHistoryEntriesIfEnabled({ diff --git a/src/auto-reply/dispatch-dispatcher.ts b/src/auto-reply/dispatch-dispatcher.ts index 0a642b8a34a..3c6f95ba980 100644 --- a/src/auto-reply/dispatch-dispatcher.ts +++ b/src/auto-reply/dispatch-dispatcher.ts @@ -1,5 +1,17 @@ import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js"; +export async function settleReplyDispatcher(params: { + dispatcher: ReplyDispatcher; + onSettled?: () => void | Promise; +}): Promise { + params.dispatcher.markComplete(); + try { + await params.dispatcher.waitForIdle(); + } finally { + await params.onSettled?.(); + } +} + export async function withReplyDispatcher(params: { dispatcher: ReplyDispatcher; run: () => Promise; @@ -8,12 +20,6 @@ export async function withReplyDispatcher(params: { try { return await params.run(); } finally { - // Ensure dispatcher reservations are always released on every exit path. - params.dispatcher.markComplete(); - try { - await params.dispatcher.waitForIdle(); - } finally { - await params.onSettled?.(); - } + await settleReplyDispatcher(params); } } diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 6b9a72e523e..737aa8804ca 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -96,7 +96,7 @@ function buildMessageSendingBeforeDeliver( } export type DispatchInboundResult = DispatchFromConfigResult; -export { withReplyDispatcher } from "./dispatch-dispatcher.js"; +export { settleReplyDispatcher, withReplyDispatcher } from "./dispatch-dispatcher.js"; function finalizeDispatchResult( result: DispatchFromConfigResult, diff --git a/src/channels/session.ts b/src/channels/session.ts index 41428905af9..501a511b920 100644 --- a/src/channels/session.ts +++ b/src/channels/session.ts @@ -36,11 +36,12 @@ export async function recordInboundSession(params: { createIfMissing?: boolean; updateLastRoute?: InboundLastRouteUpdate; onRecordError: (err: unknown) => void; + trackSessionMetaTask?: (task: Promise) => void; }): Promise { const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params; const canonicalSessionKey = normalizeLowercaseStringOrEmpty(sessionKey); const runtime = await loadInboundSessionRuntime(); - void runtime + const metaTask = runtime .recordSessionMetaFromInbound({ storePath, sessionKey: canonicalSessionKey, @@ -49,6 +50,8 @@ export async function recordInboundSession(params: { createIfMissing, }) .catch(params.onRecordError); + params.trackSessionMetaTask?.(metaTask); + void metaTask; const update = params.updateLastRoute; if (!update) { diff --git a/src/channels/session.types.ts b/src/channels/session.types.ts index b311ee14322..8da57ef8444 100644 --- a/src/channels/session.types.ts +++ b/src/channels/session.types.ts @@ -22,4 +22,5 @@ export type RecordInboundSession = (params: { createIfMissing?: boolean; updateLastRoute?: InboundLastRouteUpdate; onRecordError: (err: unknown) => void; + trackSessionMetaTask?: (task: Promise) => void; }) => Promise; diff --git a/src/channels/turn/context.test.ts b/src/channels/turn/context.test.ts new file mode 100644 index 00000000000..cec36b15223 --- /dev/null +++ b/src/channels/turn/context.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { buildChannelTurnContext } from "./context.js"; + +describe("buildChannelTurnContext", () => { + it("maps normalized turn facts into a finalized message context", () => { + const ctx = buildChannelTurnContext({ + channel: "test", + accountId: "acct", + provider: "test-provider", + surface: "test-surface", + messageId: "msg-1", + timestamp: 123, + from: "test:user:u1", + sender: { + id: "u1", + name: "User One", + username: "userone", + tag: "User#0001", + roles: ["admin"], + }, + conversation: { + kind: "group", + id: "room-1", + label: "Room One", + spaceId: "workspace", + threadId: "thread-1", + routePeer: { + kind: "group", + id: "room-1", + }, + }, + route: { + agentId: "main", + accountId: "acct", + routeSessionKey: "agent:main:test:group:room-1", + parentSessionKey: "agent:main:test:group", + modelParentSessionKey: "agent:main:test:model", + }, + reply: { + to: "test:room:room-1", + originatingTo: "test:room:room-1", + replyToId: "root-1", + nativeChannelId: "native-room-1", + }, + message: { + body: "[User One] hello", + rawBody: "hello", + bodyForAgent: "hello", + commandBody: "/status", + envelopeFrom: "User One", + inboundHistory: [{ sender: "Other", body: "previous", timestamp: 100 }], + }, + access: { + commands: { + allowTextCommands: true, + useAccessGroups: true, + authorizers: [{ configured: true, allowed: true }], + }, + mentions: { + canDetectMention: true, + wasMentioned: true, + }, + }, + media: [ + { + path: "/tmp/image.png", + contentType: "image/png", + kind: "image", + }, + { + url: "https://example.test/audio.mp3", + contentType: "audio/mpeg", + kind: "audio", + transcribed: true, + }, + ], + supplemental: { + quote: { + id: "quote-1", + body: "quoted", + sender: "Quoted User", + isQuote: true, + }, + thread: { + starterBody: "thread starter", + historyBody: "thread history", + label: "thread label", + }, + groupSystemPrompt: "group prompt", + }, + }); + + expect(ctx).toEqual( + expect.objectContaining({ + Body: "[User One] hello", + BodyForAgent: "hello", + RawBody: "hello", + CommandBody: "/status", + BodyForCommands: "/status", + From: "test:user:u1", + To: "test:room:room-1", + SessionKey: "agent:main:test:group:room-1", + AccountId: "acct", + ParentSessionKey: "agent:main:test:group", + ModelParentSessionKey: "agent:main:test:model", + MessageSid: "msg-1", + ReplyToId: "root-1", + ReplyToBody: "quoted", + ReplyToSender: "Quoted User", + MediaPath: "/tmp/image.png", + MediaUrl: "/tmp/image.png", + MediaType: "image/png", + MediaPaths: ["/tmp/image.png"], + MediaUrls: ["/tmp/image.png", "https://example.test/audio.mp3"], + MediaTypes: ["image/png", "audio/mpeg"], + MediaTranscribedIndexes: [1], + ChatType: "group", + ConversationLabel: "Room One", + GroupSubject: "Room One", + GroupSpace: "workspace", + GroupSystemPrompt: "group prompt", + SenderName: "User One", + SenderId: "u1", + SenderUsername: "userone", + SenderTag: "User#0001", + MemberRoleIds: ["admin"], + Timestamp: 123, + Provider: "test-provider", + Surface: "test-surface", + WasMentioned: true, + CommandAuthorized: true, + MessageThreadId: "thread-1", + NativeChannelId: "native-room-1", + OriginatingChannel: "test", + OriginatingTo: "test:room:room-1", + ThreadStarterBody: "thread starter", + ThreadHistoryBody: "thread history", + ThreadLabel: "thread label", + }), + ); + }); +}); diff --git a/src/channels/turn/context.ts b/src/channels/turn/context.ts new file mode 100644 index 00000000000..230928d807f --- /dev/null +++ b/src/channels/turn/context.ts @@ -0,0 +1,123 @@ +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; +import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js"; +import type { + AccessFacts, + ConversationFacts, + InboundMediaFacts, + MessageFacts, + ReplyPlanFacts, + RouteFacts, + SenderFacts, + SupplementalContextFacts, +} from "./types.js"; + +export type BuildChannelTurnContextParams = { + channel: string; + accountId?: string; + provider?: string; + surface?: string; + messageId?: string; + messageIdFull?: string; + timestamp?: number; + from: string; + sender: SenderFacts; + conversation: ConversationFacts; + route: RouteFacts; + reply: ReplyPlanFacts; + message: MessageFacts; + access?: AccessFacts; + media?: InboundMediaFacts[]; + supplemental?: SupplementalContextFacts; + extra?: MsgContext; +}; + +function compactStrings(values: Array): string[] | undefined { + const compacted = values.filter((value): value is string => Boolean(value)); + return compacted.length > 0 ? compacted : undefined; +} + +function mediaTranscribedIndexes(media: InboundMediaFacts[]): number[] | undefined { + const indexes = media + .map((item, index) => (item.transcribed ? index : undefined)) + .filter((index): index is number => index !== undefined); + return indexes.length > 0 ? indexes : undefined; +} + +function commandAuthorized(access: AccessFacts | undefined): boolean | undefined { + const commands = access?.commands; + if (!commands) { + return undefined; + } + return commands.authorizers.some((entry) => entry.allowed); +} + +export function buildChannelTurnContext( + params: BuildChannelTurnContextParams, +): FinalizedMsgContext { + const media = params.media ?? []; + const supplemental = params.supplemental; + const body = params.message.body ?? params.message.rawBody; + + return finalizeInboundContext({ + Body: body, + BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody, + InboundHistory: params.message.inboundHistory, + RawBody: params.message.rawBody, + CommandBody: params.message.commandBody ?? params.message.rawBody, + BodyForCommands: params.message.commandBody ?? params.message.rawBody, + From: params.from, + To: params.reply.to, + SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey, + AccountId: params.route.accountId ?? params.accountId, + ParentSessionKey: params.route.parentSessionKey, + ModelParentSessionKey: params.route.modelParentSessionKey, + MessageSid: params.messageId, + MessageSidFull: params.messageIdFull, + ReplyToId: params.reply.replyToId ?? supplemental?.quote?.id, + ReplyToIdFull: params.reply.replyToIdFull ?? supplemental?.quote?.fullId, + ReplyToBody: supplemental?.quote?.body, + ReplyToSender: supplemental?.quote?.sender, + ReplyToIsQuote: supplemental?.quote?.isQuote, + ForwardedFrom: supplemental?.forwarded?.from, + ForwardedFromType: supplemental?.forwarded?.fromType, + ForwardedFromId: supplemental?.forwarded?.fromId, + ForwardedDate: supplemental?.forwarded?.date, + ThreadStarterBody: supplemental?.thread?.starterBody, + ThreadHistoryBody: supplemental?.thread?.historyBody, + ThreadLabel: supplemental?.thread?.label, + MediaPath: media[0]?.path, + MediaUrl: media[0]?.url ?? media[0]?.path, + MediaType: media[0]?.contentType ?? media[0]?.kind, + MediaPaths: compactStrings(media.map((item) => item.path)), + MediaUrls: compactStrings(media.map((item) => item.url ?? item.path)), + MediaTypes: compactStrings(media.map((item) => item.contentType ?? item.kind)), + MediaTranscribedIndexes: mediaTranscribedIndexes(media), + ChatType: params.conversation.kind, + ConversationLabel: params.conversation.label, + GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined, + GroupSpace: params.conversation.spaceId, + GroupSystemPrompt: supplemental?.groupSystemPrompt, + UntrustedStructuredContext: Array.isArray(supplemental?.untrustedContext) + ? supplemental.untrustedContext.map((payload, index) => ({ + label: `context ${index + 1}`, + payload, + })) + : undefined, + SenderName: params.sender.name ?? params.sender.displayLabel, + SenderId: params.sender.id, + SenderUsername: params.sender.username, + SenderTag: params.sender.tag, + MemberRoleIds: params.sender.roles, + Timestamp: params.timestamp, + Provider: params.provider ?? params.channel, + Surface: params.surface ?? params.provider ?? params.channel, + WasMentioned: params.access?.mentions?.wasMentioned, + CommandAuthorized: commandAuthorized(params.access), + MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId, + NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId, + OriginatingChannel: params.channel, + OriginatingTo: params.reply.originatingTo, + ThreadParentId: params.reply.threadParentId ?? params.conversation.parentId, + ...params.extra, + }); +} diff --git a/src/channels/turn/kernel.test.ts b/src/channels/turn/kernel.test.ts new file mode 100644 index 00000000000..e4325bd14b0 --- /dev/null +++ b/src/channels/turn/kernel.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js"; +import type { FinalizedMsgContext } from "../../auto-reply/templating.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { RecordInboundSession } from "../session.types.js"; +import { + createNoopChannelTurnDeliveryAdapter, + dispatchAssembledChannelTurn, + runPreparedChannelTurn, + runChannelTurn, +} from "./kernel.js"; + +const cfg = {} as OpenClawConfig; + +function createCtx(overrides: Partial = {}): FinalizedMsgContext { + return { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "sender", + To: "target", + SessionKey: "agent:main:test:peer", + Provider: "test", + Surface: "test", + ...overrides, + } as FinalizedMsgContext; +} + +function createRecordInboundSession(events: string[] = []): RecordInboundSession { + return vi.fn(async () => { + events.push("record"); + }) as unknown as RecordInboundSession; +} + +function createDispatch( + events: string[] = [], + deliverPayload: { text: string } = { text: "reply" }, +): DispatchReplyWithBufferedBlockDispatcher { + return vi.fn(async (params) => { + events.push("dispatch"); + await params.dispatcherOptions.deliver(deliverPayload, { kind: "final" }); + return { + queuedFinal: true, + counts: { tool: 0, block: 0, final: 1 }, + }; + }) as DispatchReplyWithBufferedBlockDispatcher; +} + +describe("channel turn kernel", () => { + it("records inbound session before dispatching delivery", async () => { + const events: string[] = []; + const deliver = vi.fn(async () => { + events.push("deliver"); + }); + const recordInboundSession = createRecordInboundSession(events); + const dispatchReplyWithBufferedBlockDispatcher = createDispatch(events); + + const result = await dispatchAssembledChannelTurn({ + cfg, + channel: "test", + agentId: "main", + routeSessionKey: "agent:main:test:peer", + storePath: "/tmp/sessions.json", + ctxPayload: createCtx(), + recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver }, + record: { + onRecordError: vi.fn(), + }, + }); + + expect(result.dispatched).toBe(true); + expect(result.dispatchResult?.counts.final).toBe(1); + expect(events).toEqual(["record", "dispatch", "deliver"]); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:test:peer", + storePath: "/tmp/sessions.json", + }), + ); + expect(deliver).toHaveBeenCalledWith({ text: "reply" }, { kind: "final" }); + }); + + it("runs prepared dispatches after recording session metadata", async () => { + const events: string[] = []; + const recordInboundSession = createRecordInboundSession(events); + const runDispatch = vi.fn(async () => { + events.push("dispatch"); + return { + queuedFinal: true, + counts: { tool: 0, block: 0, final: 1 }, + }; + }); + + const result = await runPreparedChannelTurn({ + channel: "test", + routeSessionKey: "agent:main:test:peer", + storePath: "/tmp/sessions.json", + ctxPayload: createCtx(), + recordInboundSession, + runDispatch, + record: { + onRecordError: vi.fn(), + }, + }); + + expect(events).toEqual(["record", "dispatch"]); + expect(result.dispatchResult?.queuedFinal).toBe(true); + }); + + it("cleans up pre-created dispatchers when session recording fails", async () => { + const events: string[] = []; + const recordError = new Error("session store failed"); + const recordInboundSession = vi.fn(async () => { + events.push("record"); + throw recordError; + }) as unknown as RecordInboundSession; + const runDispatch = vi.fn(); + const onPreDispatchFailure = vi.fn(async () => { + events.push("cleanup"); + }); + + await expect( + runPreparedChannelTurn({ + channel: "test", + routeSessionKey: "agent:main:test:peer", + storePath: "/tmp/sessions.json", + ctxPayload: createCtx(), + recordInboundSession, + onPreDispatchFailure, + runDispatch, + record: { + onRecordError: vi.fn(), + }, + }), + ).rejects.toThrow(recordError); + + expect(events).toEqual(["record", "cleanup"]); + expect(runDispatch).not.toHaveBeenCalled(); + expect(onPreDispatchFailure).toHaveBeenCalledWith(recordError); + }); + + it("drops when ingest returns null", async () => { + const result = await runChannelTurn({ + channel: "test", + raw: {}, + adapter: { + ingest: () => null, + resolveTurn: vi.fn(), + }, + }); + + expect(result).toEqual({ + admission: { kind: "drop", reason: "ingest-null" }, + dispatched: false, + }); + }); + + it("handles non-turn event classes without dispatch", async () => { + const resolveTurn = vi.fn(); + const result = await runChannelTurn({ + channel: "test", + raw: {}, + adapter: { + ingest: () => ({ id: "evt-1", rawText: "" }), + classify: () => ({ kind: "reaction", canStartAgentTurn: false }), + resolveTurn, + }, + }); + + expect(result.admission).toEqual({ kind: "handled", reason: "event:reaction" }); + expect(result.dispatched).toBe(false); + expect(resolveTurn).not.toHaveBeenCalled(); + }); + + it("stops on preflight admission drops", async () => { + const resolveTurn = vi.fn(); + const result = await runChannelTurn({ + channel: "test", + raw: {}, + adapter: { + ingest: () => ({ id: "msg-1", rawText: "hello" }), + preflight: () => ({ kind: "drop", reason: "missing-mention", recordHistory: true }), + resolveTurn, + }, + }); + + expect(result.admission).toEqual({ + kind: "drop", + reason: "missing-mention", + recordHistory: true, + }); + expect(result.dispatched).toBe(false); + expect(resolveTurn).not.toHaveBeenCalled(); + }); + + it("runs observe-only preflights through resolve, record, dispatch, and finalize", async () => { + const events: string[] = []; + const onFinalize = vi.fn(); + const result = await runChannelTurn({ + channel: "test", + raw: {}, + adapter: { + ingest: () => ({ id: "msg-1", rawText: "observe" }), + preflight: () => ({ kind: "observeOnly", reason: "broadcast-observer" }), + resolveTurn: () => ({ + cfg, + channel: "test", + agentId: "observer", + routeSessionKey: "agent:observer:test:peer", + storePath: "/tmp/sessions.json", + ctxPayload: createCtx({ SessionKey: "agent:observer:test:peer" }), + recordInboundSession: createRecordInboundSession(events), + dispatchReplyWithBufferedBlockDispatcher: createDispatch(events), + delivery: createNoopChannelTurnDeliveryAdapter(), + record: { + onRecordError: vi.fn(), + }, + }), + onFinalize, + }, + }); + + expect(result.admission).toEqual({ + kind: "observeOnly", + reason: "broadcast-observer", + }); + expect(result.dispatched).toBe(true); + expect(events).toEqual(["record", "dispatch"]); + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ + admission: { kind: "observeOnly", reason: "broadcast-observer" }, + dispatched: true, + routeSessionKey: "agent:observer:test:peer", + }), + ); + }); + + it("finalizes failed dispatches before rethrowing", async () => { + const onFinalize = vi.fn(); + const dispatchError = new Error("dispatch failed"); + const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => { + throw dispatchError; + }) as unknown as DispatchReplyWithBufferedBlockDispatcher; + + await expect( + runChannelTurn({ + channel: "test", + raw: {}, + adapter: { + ingest: () => ({ id: "msg-1", rawText: "hello" }), + resolveTurn: () => ({ + cfg, + channel: "test", + agentId: "main", + routeSessionKey: "agent:main:test:peer", + storePath: "/tmp/sessions.json", + ctxPayload: createCtx(), + recordInboundSession: createRecordInboundSession(), + dispatchReplyWithBufferedBlockDispatcher, + delivery: createNoopChannelTurnDeliveryAdapter(), + record: { + onRecordError: vi.fn(), + }, + }), + onFinalize, + }, + }), + ).rejects.toThrow(dispatchError); + + expect(onFinalize).toHaveBeenCalledWith( + expect.objectContaining({ + admission: { kind: "dispatch" }, + dispatched: false, + routeSessionKey: "agent:main:test:peer", + }), + ); + }); +}); diff --git a/src/channels/turn/kernel.ts b/src/channels/turn/kernel.ts new file mode 100644 index 00000000000..6ed51bd78c6 --- /dev/null +++ b/src/channels/turn/kernel.ts @@ -0,0 +1,309 @@ +import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +export { buildChannelTurnContext } from "./context.js"; +export type { BuildChannelTurnContextParams } from "./context.js"; +import type { + AssembledChannelTurn, + ChannelEventClass, + ChannelTurnAdmission, + ChannelTurnDeliveryAdapter, + ChannelTurnLogEvent, + ChannelTurnResult, + DispatchedChannelTurnResult, + PreparedChannelTurn, + PreflightFacts, + RunChannelTurnParams, +} from "./types.js"; +export type { + AccessFacts, + AssembledChannelTurn, + ChannelDeliveryInfo, + ChannelDeliveryResult, + ChannelEventClass, + ChannelTurnAdapter, + ChannelTurnAdmission, + ChannelTurnDeliveryAdapter, + ChannelTurnDispatcherOptions, + ChannelTurnLogEvent, + ChannelTurnRecordOptions, + ChannelTurnResolved, + ChannelTurnResult, + DispatchedChannelTurnResult, + ConversationFacts, + InboundMediaFacts, + MessageFacts, + NormalizedTurnInput, + PreflightFacts, + PreparedChannelTurn, + ReplyPlanFacts, + RouteFacts, + RunChannelTurnParams, + SenderFacts, + SupplementalContextFacts, +} from "./types.js"; + +const DEFAULT_EVENT_CLASS: ChannelEventClass = { + kind: "message", + canStartAgentTurn: true, +}; + +function isAdmission(value: unknown): value is ChannelTurnAdmission { + if (!value || typeof value !== "object") { + return false; + } + const kind = (value as { kind?: unknown }).kind; + return kind === "dispatch" || kind === "observeOnly" || kind === "handled" || kind === "drop"; +} + +function normalizePreflight( + value: PreflightFacts | ChannelTurnAdmission | null | undefined, +): PreflightFacts { + if (!value) { + return {}; + } + if (isAdmission(value)) { + return { admission: value }; + } + return value; +} + +function emit(params: { + log?: (event: ChannelTurnLogEvent) => void; + event: Omit; + channel: string; + accountId?: string; +}) { + params.log?.({ + channel: params.channel, + accountId: params.accountId, + ...params.event, + }); +} + +export function createNoopChannelTurnDeliveryAdapter(): ChannelTurnDeliveryAdapter { + return { + deliver: async () => ({ + visibleReplySent: false, + }), + }; +} + +export async function dispatchAssembledChannelTurn( + params: AssembledChannelTurn, +): Promise { + return await runPreparedChannelTurn({ + channel: params.channel, + accountId: params.accountId, + routeSessionKey: params.routeSessionKey, + storePath: params.storePath, + ctxPayload: params.ctxPayload, + recordInboundSession: params.recordInboundSession, + record: params.record, + runDispatch: async () => + await params.dispatchReplyWithBufferedBlockDispatcher({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...params.dispatcherOptions, + deliver: async (payload: ReplyPayload, info) => { + await params.delivery.deliver(payload, info); + }, + onError: params.delivery.onError, + }, + replyOptions: params.replyOptions, + replyResolver: params.replyResolver, + }), + }); +} + +export async function runPreparedChannelTurn< + TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], +>( + params: PreparedChannelTurn, +): Promise> { + try { + await params.recordInboundSession({ + storePath: params.storePath, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + ctx: params.ctxPayload, + groupResolution: params.record?.groupResolution, + createIfMissing: params.record?.createIfMissing, + updateLastRoute: params.record?.updateLastRoute, + onRecordError: params.record?.onRecordError ?? (() => undefined), + trackSessionMetaTask: params.record?.trackSessionMetaTask, + }); + } catch (err) { + try { + await params.onPreDispatchFailure?.(err); + } catch { + // Preserve the original session-recording error. + } + throw err; + } + + const dispatchResult = await params.runDispatch(); + + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: params.ctxPayload, + routeSessionKey: params.routeSessionKey, + dispatchResult, + }; +} + +export async function runChannelTurn( + params: RunChannelTurnParams, +): Promise { + emit({ + ...params, + event: { stage: "ingest", event: "start" }, + }); + const input = await params.adapter.ingest(params.raw); + if (!input) { + const admission: ChannelTurnAdmission = { kind: "drop", reason: "ingest-null" }; + emit({ + ...params, + event: { + stage: "ingest", + event: "drop", + admission: admission.kind, + reason: admission.reason, + }, + }); + return { admission, dispatched: false }; + } + emit({ + ...params, + event: { stage: "ingest", event: "done", messageId: input.id }, + }); + + const eventClass = (await params.adapter.classify?.(input)) ?? DEFAULT_EVENT_CLASS; + if (!eventClass.canStartAgentTurn) { + const admission: ChannelTurnAdmission = { + kind: "handled", + reason: `event:${eventClass.kind}`, + }; + emit({ + ...params, + event: { + stage: "classify", + event: "handled", + messageId: input.id, + admission: admission.kind, + reason: admission.reason, + }, + }); + return { admission, dispatched: false }; + } + + const preflight = normalizePreflight(await params.adapter.preflight?.(input, eventClass)); + const preflightAdmission = preflight.admission; + if ( + preflightAdmission && + preflightAdmission.kind !== "dispatch" && + preflightAdmission.kind !== "observeOnly" + ) { + emit({ + ...params, + event: { + stage: "preflight", + event: preflightAdmission.kind === "handled" ? "handled" : "drop", + messageId: input.id, + admission: preflightAdmission.kind, + reason: preflightAdmission.reason, + }, + }); + return { admission: preflightAdmission, dispatched: false }; + } + + const resolved = await params.adapter.resolveTurn(input, eventClass, preflight); + emit({ + ...params, + accountId: resolved.accountId ?? params.accountId, + event: { + stage: "assemble", + event: "done", + messageId: input.id, + sessionKey: resolved.routeSessionKey, + admission: resolved.admission?.kind ?? "dispatch", + }, + }); + + const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const); + let result: ChannelTurnResult; + try { + const dispatchResult = await dispatchAssembledChannelTurn(resolved); + result = { + ...dispatchResult, + admission, + }; + + emit({ + ...params, + accountId: resolved.accountId ?? params.accountId, + event: { + stage: "dispatch", + event: "done", + messageId: input.id, + sessionKey: resolved.routeSessionKey, + admission: admission.kind, + }, + }); + } catch (err) { + const failedResult: ChannelTurnResult = { + admission, + dispatched: false, + ctxPayload: resolved.ctxPayload, + routeSessionKey: resolved.routeSessionKey, + }; + try { + await params.adapter.onFinalize?.(failedResult); + } catch { + // Preserve the original dispatch error. + } + emit({ + ...params, + accountId: resolved.accountId ?? params.accountId, + event: { + stage: "dispatch", + event: "error", + messageId: input.id, + sessionKey: resolved.routeSessionKey, + admission: admission.kind, + error: err, + }, + }); + throw err; + } + + try { + await params.adapter.onFinalize?.(result); + emit({ + ...params, + accountId: resolved.accountId ?? params.accountId, + event: { + stage: "finalize", + event: "done", + messageId: input.id, + sessionKey: resolved.routeSessionKey, + admission: admission.kind, + }, + }); + } catch (err) { + emit({ + ...params, + accountId: resolved.accountId ?? params.accountId, + event: { + stage: "finalize", + event: "error", + messageId: input.id, + sessionKey: resolved.routeSessionKey, + admission: admission.kind, + error: err, + }, + }); + throw err; + } + + return result; +} diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts new file mode 100644 index 00000000000..fc914e94200 --- /dev/null +++ b/src/channels/turn/types.ts @@ -0,0 +1,295 @@ +import type { GetReplyOptions } from "../../auto-reply/get-reply-options.types.js"; +import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +import type { DispatchFromConfigResult } from "../../auto-reply/reply/dispatch-from-config.types.js"; +import type { GetReplyFromConfig } from "../../auto-reply/reply/get-reply.types.js"; +import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js"; +import type { ReplyDispatcherWithTypingOptions } from "../../auto-reply/reply/reply-dispatcher.js"; +import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js"; +import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js"; +import type { GroupKeyResolution } from "../../config/sessions/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { InboundLastRouteUpdate, RecordInboundSession } from "../session.types.js"; + +export type ChannelTurnAdmission = + | { kind: "dispatch"; reason?: string } + | { kind: "observeOnly"; reason: string } + | { kind: "handled"; reason: string } + | { kind: "drop"; reason: string; recordHistory?: boolean }; + +export type ChannelEventClass = { + kind: "message" | "command" | "interaction" | "reaction" | "lifecycle" | "unknown"; + canStartAgentTurn: boolean; + requiresImmediateAck?: boolean; +}; + +export type NormalizedTurnInput = { + id: string; + timestamp?: number; + rawText: string; + textForAgent?: string; + textForCommands?: string; + raw?: unknown; +}; + +export type SenderFacts = { + id: string; + name?: string; + username?: string; + tag?: string; + roles?: string[]; + isBot?: boolean; + isSelf?: boolean; + displayLabel?: string; +}; + +export type ConversationFacts = { + kind: "direct" | "group" | "channel"; + id: string; + label?: string; + spaceId?: string; + parentId?: string; + threadId?: string; + nativeChannelId?: string; + routePeer: { + kind: "direct" | "group" | "channel"; + id: string; + }; +}; + +export type RouteFacts = { + agentId: string; + accountId?: string; + routeSessionKey: string; + dispatchSessionKey?: string; + persistedSessionKey?: string; + parentSessionKey?: string; + modelParentSessionKey?: string; + mainSessionKey?: string; + createIfMissing?: boolean; +}; + +export type ReplyPlanFacts = { + to: string; + originatingTo: string; + nativeChannelId?: string; + replyTarget?: string; + deliveryTarget?: string; + replyToId?: string; + replyToIdFull?: string; + messageThreadId?: string; + threadParentId?: string; + sourceReplyDeliveryMode?: "thread" | "reply" | "channel" | "direct" | "none"; +}; + +export type AccessFacts = { + dm?: { + decision: "allow" | "pairing" | "deny"; + reason?: string; + allowFrom: string[]; + }; + group?: { + policy: "open" | "allowlist" | "disabled"; + routeAllowed: boolean; + senderAllowed: boolean; + allowFrom: string[]; + requireMention: boolean; + }; + commands?: { + useAccessGroups: boolean; + allowTextCommands: boolean; + authorizers: Array<{ configured: boolean; allowed: boolean }>; + }; + mentions?: { + canDetectMention: boolean; + wasMentioned: boolean; + hasAnyMention?: boolean; + implicitMentionKinds?: Array<"reply_to_bot" | "bot_thread_participant" | "native">; + }; +}; + +export type MessageFacts = { + body?: string; + rawBody: string; + bodyForAgent?: string; + commandBody?: string; + envelopeFrom: string; + senderLabel?: string; + preview?: string; + inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>; +}; + +export type SupplementalContextFacts = { + quote?: { + id?: string; + fullId?: string; + body?: string; + sender?: string; + senderAllowed?: boolean; + isExternal?: boolean; + isQuote?: boolean; + }; + forwarded?: { + from?: string; + fromType?: string; + fromId?: string; + date?: number; + }; + thread?: { + id?: string; + starterBody?: string; + historyBody?: string; + label?: string; + parentSessionKey?: string; + modelParentSessionKey?: string; + senderAllowed?: boolean; + }; + untrustedContext?: unknown[]; + groupSystemPrompt?: string; +}; + +export type InboundMediaFacts = { + path?: string; + url?: string; + contentType?: string; + kind?: "image" | "video" | "audio" | "document" | "unknown"; + transcribed?: boolean; +}; + +export type PreflightFacts = { + admission?: ChannelTurnAdmission; + message?: Partial; + media?: InboundMediaFacts[]; + supplemental?: SupplementalContextFacts; +}; + +export type ChannelDeliveryInfo = { + kind: ReplyDispatchKind; +}; + +export type ChannelDeliveryResult = { + messageIds?: string[]; + threadId?: string; + replyToId?: string; + visibleReplySent?: boolean; +}; + +export type ChannelTurnDeliveryAdapter = { + deliver: ( + payload: ReplyPayload, + info: ChannelDeliveryInfo, + ) => Promise; + onError?: (err: unknown, info: { kind: string }) => void; +}; + +export type ChannelTurnRecordOptions = { + groupResolution?: GroupKeyResolution | null; + createIfMissing?: boolean; + updateLastRoute?: InboundLastRouteUpdate; + onRecordError?: (err: unknown) => void; + trackSessionMetaTask?: (task: Promise) => void; +}; + +export type ChannelTurnDispatcherOptions = Omit< + ReplyDispatcherWithTypingOptions, + "deliver" | "onError" +>; + +export type AssembledChannelTurn = { + cfg: OpenClawConfig; + channel: string; + accountId?: string; + agentId: string; + routeSessionKey: string; + storePath: string; + ctxPayload: FinalizedMsgContext; + recordInboundSession: RecordInboundSession; + dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher; + delivery: ChannelTurnDeliveryAdapter; + dispatcherOptions?: ChannelTurnDispatcherOptions; + replyOptions?: Omit; + replyResolver?: GetReplyFromConfig; + record?: ChannelTurnRecordOptions; +}; + +export type PreparedChannelTurn = { + channel: string; + accountId?: string; + routeSessionKey: string; + storePath: string; + ctxPayload: FinalizedMsgContext; + recordInboundSession: RecordInboundSession; + record?: ChannelTurnRecordOptions; + onPreDispatchFailure?: (err: unknown) => void | Promise; + runDispatch: () => Promise; +}; + +export type ChannelTurnResolved = AssembledChannelTurn & { + admission?: Extract; +}; + +export type ChannelTurnStage = + | "ingest" + | "classify" + | "preflight" + | "resolve" + | "authorize" + | "assemble" + | "record" + | "dispatch" + | "finalize"; + +export type ChannelTurnLogEvent = { + stage: ChannelTurnStage; + event: "start" | "done" | "drop" | "handled" | "error"; + channel: string; + accountId?: string; + messageId?: string; + sessionKey?: string; + admission?: ChannelTurnAdmission["kind"]; + reason?: string; + error?: unknown; +}; + +export type ChannelTurnResult = { + admission: ChannelTurnAdmission; + dispatched: boolean; + ctxPayload?: MsgContext; + routeSessionKey?: string; + dispatchResult?: DispatchFromConfigResult; +}; + +export type DispatchedChannelTurnResult = { + admission: Extract; + dispatched: true; + ctxPayload: MsgContext; + routeSessionKey: string; + dispatchResult: TDispatchResult; +}; + +export type ChannelTurnAdapter = { + ingest: (raw: TRaw) => Promise | NormalizedTurnInput | null; + classify?: (input: NormalizedTurnInput) => Promise | ChannelEventClass; + preflight?: ( + input: NormalizedTurnInput, + eventClass: ChannelEventClass, + ) => + | Promise + | PreflightFacts + | ChannelTurnAdmission + | null + | undefined; + resolveTurn: ( + input: NormalizedTurnInput, + eventClass: ChannelEventClass, + preflight: PreflightFacts, + ) => Promise | ChannelTurnResolved; + onFinalize?: (result: ChannelTurnResult) => Promise | void; +}; + +export type RunChannelTurnParams = { + channel: string; + accountId?: string; + raw: TRaw; + adapter: ChannelTurnAdapter; + log?: (event: ChannelTurnLogEvent) => void; +}; diff --git a/src/plugin-sdk/inbound-reply-dispatch.test.ts b/src/plugin-sdk/inbound-reply-dispatch.test.ts new file mode 100644 index 00000000000..21abe7adb29 --- /dev/null +++ b/src/plugin-sdk/inbound-reply-dispatch.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.types.js"; +import type { FinalizedMsgContext } from "../auto-reply/templating.js"; +import type { RecordInboundSession } from "../channels/session.types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { recordInboundSessionAndDispatchReply } from "./inbound-reply-dispatch.js"; + +describe("recordInboundSessionAndDispatchReply", () => { + it("delegates record and dispatch through the channel turn kernel once", async () => { + const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession; + const deliver = vi.fn(async () => undefined); + const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => { + await params.dispatcherOptions.deliver( + { + text: "hello", + mediaUrls: ["https://example.com/a.png"], + }, + { kind: "final" }, + ); + return { + queuedFinal: true, + counts: { tool: 0, block: 0, final: 1 }, + }; + }) as DispatchReplyWithBufferedBlockDispatcher; + const ctxPayload = { + Body: "body", + RawBody: "body", + CommandBody: "body", + From: "sender", + To: "target", + SessionKey: "agent:main:test:peer", + Provider: "test", + Surface: "test", + } as FinalizedMsgContext; + + await recordInboundSessionAndDispatchReply({ + cfg: {} as OpenClawConfig, + channel: "test", + accountId: "default", + agentId: "main", + routeSessionKey: "agent:main:test:peer", + storePath: "/tmp/sessions.json", + ctxPayload, + recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher, + deliver, + onRecordError: vi.fn(), + onDispatchError: vi.fn(), + }); + + expect(recordInboundSession).toHaveBeenCalledTimes(1); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:test:peer", + ctx: ctxPayload, + }), + ); + expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith({ + text: "hello", + mediaUrls: ["https://example.com/a.png"], + mediaUrl: undefined, + sensitiveMedia: undefined, + replyToId: undefined, + }); + }); +}); diff --git a/src/plugin-sdk/inbound-reply-dispatch.ts b/src/plugin-sdk/inbound-reply-dispatch.ts index dc733bdfa61..d83cf47f4db 100644 --- a/src/plugin-sdk/inbound-reply-dispatch.ts +++ b/src/plugin-sdk/inbound-reply-dispatch.ts @@ -7,6 +7,8 @@ import { import type { DispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.types.js"; import type { ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.types.js"; import type { FinalizedMsgContext } from "../auto-reply/templating.js"; +import { dispatchAssembledChannelTurn, runPreparedChannelTurn } from "../channels/turn/kernel.js"; +import type { PreparedChannelTurn } from "../channels/turn/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js"; @@ -19,6 +21,13 @@ type RecordInboundSessionFn = typeof import("../channels/session.js").recordInbo type ReplyDispatchFromConfigOptions = Omit; +/** Run an already assembled channel turn through shared session-record + dispatch ordering. */ +export async function runPreparedInboundReplyTurn( + params: PreparedChannelTurn, +) { + return await runPreparedChannelTurn(params); +} + /** Run `dispatchReplyFromConfig` with a dispatcher that always gets its settled callback. */ export async function dispatchReplyFromConfigWithSettledDispatcher(params: { cfg: OpenClawConfig; @@ -117,13 +126,6 @@ export async function recordInboundSessionAndDispatchReply(params: { onDispatchError: (err: unknown, info: { kind: string }) => void; replyOptions?: ReplyOptionsWithoutModelSelected; }): Promise { - await params.recordInboundSession({ - storePath: params.storePath, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - ctx: params.ctxPayload, - onRecordError: params.onRecordError, - }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ cfg: params.cfg, agentId: params.agentId, @@ -132,17 +134,27 @@ export async function recordInboundSessionAndDispatchReply(params: { }); const deliver = createNormalizedOutboundDeliverer(params.deliver); - await params.dispatchReplyWithBufferedBlockDispatcher({ - ctx: params.ctxPayload, + await dispatchAssembledChannelTurn({ cfg: params.cfg, - dispatcherOptions: { - ...replyPipeline, + channel: params.channel, + accountId: params.accountId, + agentId: params.agentId, + routeSessionKey: params.routeSessionKey, + storePath: params.storePath, + ctxPayload: params.ctxPayload, + recordInboundSession: params.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: params.dispatchReplyWithBufferedBlockDispatcher, + delivery: { deliver, onError: params.onDispatchError, }, + dispatcherOptions: replyPipeline, replyOptions: { ...params.replyOptions, onModelSelected, }, + record: { + onRecordError: params.onRecordError, + }, }); } diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts index 1a1b25ab43c..4657749ad01 100644 --- a/src/plugin-sdk/reply-runtime.ts +++ b/src/plugin-sdk/reply-runtime.ts @@ -14,6 +14,7 @@ export { dispatchInboundMessage, dispatchInboundMessageWithBufferedDispatcher, dispatchInboundMessageWithDispatcher, + settleReplyDispatcher, } from "../auto-reply/dispatch.js"; export { normalizeGroupActivation, diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index 63dfc2047a6..3e0fdb028da 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -78,6 +78,142 @@ export function createPluginRuntimeMock(overrides: DeepPartial = createTaskFlowSessionMock, ) as unknown as PluginRuntime["tasks"]["managedFlows"]["fromToolContext"], }; + const dispatchAssembledChannelTurnMock = vi.fn( + async (params: Parameters[0]) => { + await params.recordInboundSession({ + storePath: params.storePath, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + ctx: params.ctxPayload, + groupResolution: params.record?.groupResolution, + createIfMissing: params.record?.createIfMissing, + updateLastRoute: params.record?.updateLastRoute, + onRecordError: params.record?.onRecordError ?? (() => undefined), + trackSessionMetaTask: params.record?.trackSessionMetaTask, + }); + const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...params.dispatcherOptions, + deliver: async (payload, info) => { + await params.delivery.deliver(payload, info); + }, + onError: params.delivery.onError, + }, + replyOptions: params.replyOptions, + replyResolver: params.replyResolver, + }); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: params.ctxPayload, + routeSessionKey: params.routeSessionKey, + dispatchResult, + }; + }, + ) as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"]; + const runPreparedChannelTurnMock = vi.fn( + async (params: Parameters[0]) => { + try { + await params.recordInboundSession({ + storePath: params.storePath, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + ctx: params.ctxPayload, + groupResolution: params.record?.groupResolution, + createIfMissing: params.record?.createIfMissing, + updateLastRoute: params.record?.updateLastRoute, + onRecordError: params.record?.onRecordError ?? (() => undefined), + trackSessionMetaTask: params.record?.trackSessionMetaTask, + }); + } catch (err) { + try { + await params.onPreDispatchFailure?.(err); + } catch { + // Preserve the original session-recording error. + } + throw err; + } + const dispatchResult = await params.runDispatch(); + return { + admission: { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: params.ctxPayload, + routeSessionKey: params.routeSessionKey, + dispatchResult, + }; + }, + ) as unknown as PluginRuntime["channel"]["turn"]["runPrepared"]; + const runChannelTurnMock = vi.fn( + async (params: Parameters[0]) => { + const input = await params.adapter.ingest(params.raw); + if (!input) { + return { + admission: { kind: "drop" as const, reason: "ingest-null" }, + dispatched: false, + }; + } + const eventClass = (await params.adapter.classify?.(input)) ?? { + kind: "message" as const, + canStartAgentTurn: true, + }; + if (!eventClass.canStartAgentTurn) { + return { + admission: { kind: "handled" as const, reason: `event:${eventClass.kind}` }, + dispatched: false, + }; + } + const preflightValue = await params.adapter.preflight?.(input, eventClass); + const preflight = + preflightValue && "kind" in preflightValue + ? { admission: preflightValue } + : (preflightValue ?? {}); + if ( + preflight.admission && + preflight.admission.kind !== "dispatch" && + preflight.admission.kind !== "observeOnly" + ) { + return { + admission: preflight.admission, + dispatched: false, + }; + } + const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {}); + const dispatchResult = await dispatchAssembledChannelTurnMock(resolved); + const result = { + ...dispatchResult, + admission: resolved.admission ?? preflight.admission ?? dispatchResult.admission, + }; + await params.adapter.onFinalize?.(result); + return result; + }, + ) as unknown as PluginRuntime["channel"]["turn"]["run"]; + const buildChannelTurnContextMock = vi.fn( + (params: Parameters[0]) => + ({ + Body: params.message.body ?? params.message.rawBody, + BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody, + RawBody: params.message.rawBody, + CommandBody: params.message.commandBody ?? params.message.rawBody, + BodyForCommands: params.message.commandBody ?? params.message.rawBody, + From: params.from, + To: params.reply.to, + SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey, + AccountId: params.route.accountId ?? params.accountId, + ChatType: params.conversation.kind, + ConversationLabel: params.conversation.label, + SenderName: params.sender.name ?? params.sender.displayLabel, + SenderId: params.sender.id, + SenderUsername: params.sender.username, + Provider: params.provider ?? params.channel, + Surface: params.surface ?? params.provider ?? params.channel, + OriginatingChannel: params.channel, + OriginatingTo: params.reply.originatingTo, + CommandAuthorized: params.access?.commands + ? params.access.commands.authorizers.some((entry) => entry.allowed) + : false, + ...params.extra, + }) as ReturnType, + ) as unknown as PluginRuntime["channel"]["turn"]["buildContext"]; const base: PluginRuntime = { version: "1.0.0-test", config: { @@ -251,6 +387,14 @@ export function createPluginRuntimeMock(overrides: DeepPartial = vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"], dispatchReplyFromConfig: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], + settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => { + dispatcher.markComplete(); + try { + await dispatcher.waitForIdle(); + } finally { + await onSettled?.(); + } + }) as unknown as PluginRuntime["channel"]["reply"]["settleReplyDispatcher"], withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => { try { return await run(); @@ -422,6 +566,12 @@ export function createPluginRuntimeMock(overrides: DeepPartial = outbound: { loadAdapter: vi.fn() as unknown as PluginRuntime["channel"]["outbound"]["loadAdapter"], }, + turn: { + run: runChannelTurnMock, + buildContext: buildChannelTurnContextMock, + runPrepared: runPreparedChannelTurnMock, + dispatchAssembled: dispatchAssembledChannelTurnMock, + }, threadBindings: { setIdleTimeoutBySessionKey: vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setIdleTimeoutBySessionKey"], diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 981c75ae7dd..3813877d2e3 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -14,7 +14,7 @@ import { shouldComputeCommandAuthorized, } from "../../auto-reply/command-detection.js"; import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; -import { withReplyDispatcher } from "../../auto-reply/dispatch.js"; +import { settleReplyDispatcher, withReplyDispatcher } from "../../auto-reply/dispatch.js"; import { formatAgentEnvelope, formatInboundEnvelope, @@ -50,6 +50,12 @@ import { } from "../../channels/plugins/conversation-bindings.js"; import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import { recordInboundSession } from "../../channels/session.js"; +import { + buildChannelTurnContext, + dispatchAssembledChannelTurn, + runChannelTurn, + runPreparedChannelTurn, +} from "../../channels/turn/kernel.js"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, @@ -95,6 +101,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { resolveHumanDelayConfig, dispatchReplyFromConfig, withReplyDispatcher, + settleReplyDispatcher, finalizeInboundContext, formatAgentEnvelope, /** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */ @@ -164,6 +171,12 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { outbound: { loadAdapter: loadChannelOutboundAdapter, }, + turn: { + run: runChannelTurn, + buildContext: buildChannelTurnContext, + runPrepared: runPreparedChannelTurn, + dispatchAssembled: dispatchAssembledChannelTurn, + }, threadBindings: { setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) => setChannelConversationBindingIdleTimeoutBySessionKey({ diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 1f82d27c171..e7341d932da 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -90,6 +90,7 @@ export type PluginRuntimeChannel = { resolveHumanDelayConfig: typeof import("../../agents/identity.js").resolveHumanDelayConfig; dispatchReplyFromConfig: import("../../auto-reply/reply/dispatch-from-config.types.js").DispatchReplyFromConfig; withReplyDispatcher: typeof import("../../auto-reply/dispatch-dispatcher.js").withReplyDispatcher; + settleReplyDispatcher: typeof import("../../auto-reply/dispatch-dispatcher.js").settleReplyDispatcher; finalizeInboundContext: typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext; formatAgentEnvelope: typeof import("../../auto-reply/envelope.js").formatAgentEnvelope; /** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */ @@ -150,6 +151,12 @@ export type PluginRuntimeChannel = { outbound: { loadAdapter: import("../../channels/plugins/outbound/load.types.js").LoadChannelOutboundAdapter; }; + turn: { + run: typeof import("../../channels/turn/kernel.js").runChannelTurn; + buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext; + runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn; + dispatchAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn; + }; threadBindings: { setIdleTimeoutBySessionKey: (params: { channelId: string;