diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 06c8c41d4543..3994021b22e2 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -cd7189431f2805258afc4ecb993fe286a2606035636a5f4055b1158a76c27d62 plugin-sdk-api-baseline.json -660dbaa276792415bf7eb87c19240b19decd400b7e58745e87fe0f1f1cc33353 plugin-sdk-api-baseline.jsonl +7e2ff4dedb7b220a133cb419ee91e67585b9b9f2799706f336cdea973be80bfb plugin-sdk-api-baseline.json +0a85effc98fb17a65d463956704bcb2dab31076cf0fa0d85b463a765ec9dc439 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 4d27bf7e112e..45cf47e1df57 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -571,7 +571,7 @@ Matrix inherits global defaults from `session.threadBindings` and supports per-c - `threadBindings.idleHours` - `threadBindings.maxAgeHours` - `threadBindings.spawnSessions`: gates both subagent and ACP thread spawns. -- `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions`: narrower overrides for subagent-only or ACP-only spawns. +- Deprecated `threadBindings.spawnSubagentSessions` / `threadBindings.spawnAcpSessions` keys are migrated to `spawnSessions` by `openclaw doctor --fix`. - `threadBindings.defaultSpawnContext` Matrix thread-bound session spawns default on. Set `threadBindings.spawnSessions: false` to block top-level `/focus` and `/acp spawn --thread auto|here` from creating/binding Matrix threads. Set `threadBindings.defaultSpawnContext: "isolated"` when native subagent thread spawns should not fork the parent transcript. diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index 292ff05ed210..6bc532eb3130 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -174,7 +174,7 @@ describe("handleClickClackInbound", () => { correlationId: "fakeco.case_1", }); - expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); + expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled(); const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0]; expect(completionRequest?.agentId).toBe("service-bot"); @@ -270,9 +270,9 @@ describe("handleClickClackInbound", () => { message: createMessage(), }); - const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); - expect(dispatchReply).toHaveBeenCalledTimes(1); - expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn).toHaveBeenCalledTimes(1); + expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); }); it("propagates account toolsAllow into agent reply dispatch", async () => { @@ -297,9 +297,9 @@ describe("handleClickClackInbound", () => { message: createMessage(), }); - const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); - expect(dispatchReply).toHaveBeenCalledTimes(1); - const dispatchParams = dispatchReply.mock.calls[0]?.[0] as + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn).toHaveBeenCalledTimes(1); + const dispatchParams = dispatchTurn.mock.calls[0]?.[0] as | (Record & { toolsAllow?: unknown; }) @@ -335,12 +335,12 @@ describe("handleClickClackInbound", () => { }), }); - const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); - expect(dispatchReply).toHaveBeenCalledTimes(2); - const withoutOptIn = dispatchReply.mock.calls[0]?.[0] as { + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn).toHaveBeenCalledTimes(2); + const withoutOptIn = dispatchTurn.mock.calls[0]?.[0] as { replyOptions?: { runId?: unknown; onItemEvent?: unknown; onModelSelected?: unknown }; }; - const withOptIn = dispatchReply.mock.calls[1]?.[0] as { + const withOptIn = dispatchTurn.mock.calls[1]?.[0] as { replyOptions?: { onItemEvent?: unknown; onModelSelected?: unknown; @@ -377,7 +377,7 @@ describe("handleClickClackInbound", () => { correlationId: "fakeco.case_2", }); - const dispatchParams = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0]; + const dispatchParams = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0]; expect(dispatchParams?.replyOptions?.runId).toBe(`clickclack:${VALID_MESSAGE_ID}`); await dispatchParams?.delivery.deliver({ text: "correlated reply" }, {} as never); @@ -404,7 +404,7 @@ describe("handleClickClackInbound", () => { }), }); - const delivery = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].delivery; + const delivery = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].delivery; if (typeof delivery?.durable !== "function") { throw new Error("expected ClickClack media durable delivery resolver"); } @@ -437,7 +437,7 @@ describe("handleClickClackInbound", () => { message: createMessage({ id: "msg_invalid" }), }); - expect(vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]?.[0].replyOptions).toBe( + expect(vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]?.[0].replyOptions).toBe( undefined, ); }); @@ -461,15 +461,15 @@ describe("handleClickClackInbound", () => { }), config: cfg, message: createMessage({ - channel_id: undefined, + channel_id: "", direct_conversation_id: "dcn_1", }), }); - const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); - expect(dispatchReply).toHaveBeenCalledTimes(1); - expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct"); - expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn).toHaveBeenCalledTimes(1); + expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct"); + expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); }); it("preserves session policy when an account overrides the routed agent", async () => { @@ -503,8 +503,8 @@ describe("handleClickClackInbound", () => { }), }); - const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); - expect(dispatchReply.mock.calls[0]?.[0].routeSessionKey).toBe( + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn.mock.calls[0]?.[0].route.sessionKey).toBe( "agent:service-bot:clickclack:direct:alice", ); expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({ @@ -546,10 +546,12 @@ describe("handleClickClackInbound", () => { }), }); - const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); - expect(dispatchReply.mock.calls[0]?.[0]).toMatchObject({ - agentId: "service-bot", - routeSessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner", + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn.mock.calls[0]?.[0]).toMatchObject({ + route: { + agentId: "service-bot", + sessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner", + }, }); }); @@ -584,7 +586,7 @@ describe("handleClickClackInbound", () => { }), }); - expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); + expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); }); diff --git a/extensions/clickclack/src/inbound.ts b/extensions/clickclack/src/inbound.ts index cd4259cfa999..40c90b22c4d0 100644 --- a/extensions/clickclack/src/inbound.ts +++ b/extensions/clickclack/src/inbound.ts @@ -1,3 +1,4 @@ +import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound"; import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound"; /** * Converts authorized ClickClack messages into OpenClaw agent/model replies and @@ -150,6 +151,10 @@ export async function handleClickClackInbound(params: { if (!access.shouldDispatch) { return; } + const conversationId = message.channel_id || message.direct_conversation_id; + if (!conversationId) { + return; + } const isDirect = Boolean(message.direct_conversation_id); const target = buildClickClackTarget( isDirect @@ -200,52 +205,53 @@ export async function handleClickClackInbound(params: { }); } const senderName = message.author?.display_name || message.author_id; - const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({ - storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, { - agentId: route.agentId, - }), - sessionKey: route.sessionKey, - }); // Preserve both normalized channel fields and ClickClack-native ids so reply // routing, session recovery, and command authorization see the same message. - const body = runtime.channel.reply.formatAgentEnvelope({ + const body = createChannelInboundEnvelopeBuilder({ + cfg: params.config as OpenClawConfig, + route, + })({ channel: "ClickClack", from: senderName, timestamp: new Date(message.created_at), - previousTimestamp, - envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig), body: message.body, }); - const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, { - agentId: route.agentId, - }); - const ctxPayload = runtime.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: message.body, - RawBody: message.body, - CommandBody: message.body, - From: target, - To: target, - SessionKey: route.sessionKey, - AccountId: route.accountId ?? params.account.accountId, - ChatType: isDirect ? "direct" : "group", - WasMentioned: isDirect ? undefined : true, - ConversationLabel: isDirect ? senderName : message.channel_id, - GroupChannel: message.channel_id, - NativeChannelId: message.channel_id || message.direct_conversation_id, - MessageThreadId: message.parent_message_id ? message.thread_root_id : undefined, - ThreadParentId: message.parent_message_id ? message.thread_root_id : undefined, - SenderName: senderName, - SenderId: message.author_id, - Provider: CHANNEL_ID, - Surface: CHANNEL_ID, - MessageSid: message.id, - MessageSidFull: message.id, - ReplyToId: message.id, - Timestamp: message.created_at, - OriginatingChannel: CHANNEL_ID, - OriginatingTo: target, - CommandAuthorized: access.commandAuthorized, + const ctxPayload = runtime.channel.inbound.buildContext({ + channel: CHANNEL_ID, + accountId: route.accountId ?? params.account.accountId, + messageId: message.id, + messageIdFull: message.id, + timestamp: new Date(message.created_at).getTime(), + from: target, + sender: { id: message.author_id, name: senderName }, + conversation: { + kind: isDirect ? "direct" : "group", + id: conversationId, + label: isDirect ? senderName : message.channel_id, + threadId: message.parent_message_id ? message.thread_root_id : undefined, + nativeChannelId: conversationId, + }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: route.sessionKey, + }, + reply: { + to: target, + originatingTo: target, + replyToId: message.id, + messageThreadId: message.parent_message_id ? message.thread_root_id : undefined, + threadParentId: message.parent_message_id ? message.thread_root_id : undefined, + }, + message: { body, bodyForAgent: message.body, rawBody: message.body, commandBody: message.body }, + access: { + commands: { authorized: access.commandAuthorized }, + mentions: { + canDetectMention: !isDirect, + wasMentioned: !isDirect, + }, + }, + extra: { GroupChannel: message.channel_id }, }); const runId = resolveClickClackAgentRunId(message.id); const activityReplyOptions = activity @@ -266,17 +272,12 @@ export async function handleClickClackInbound(params: { allowProgressCallbacksWhenSourceDeliverySuppressed: true, } : undefined; - const dispatchPromise = runtime.channel.inbound.dispatchReply({ + const dispatchPromise = runtime.channel.inbound.dispatch({ cfg: params.config as OpenClawConfig, channel: CHANNEL_ID, accountId: params.account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: runtime.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, toolsAllow: params.account.toolsAllow, // Provenance stamping shares the agentActivity opt-in: with the flag off // the extension's wire payloads stay byte-identical to pre-activity diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index e7c15a8be987..0b24afc264cc 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -66,7 +66,7 @@ export function createCodexAppServerAgentHarness(options: { delegatedExecutionPluginIds: ["voice-call"], contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, deliveryDefaults: { - sourceVisibleReplies: "message_tool", + visibleReplies: "message_tool", }, authBootstrap: "harness", authBinding: { diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index c476b662d982..a6d49ad40694 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -121,7 +121,7 @@ describe("codex plugin", () => { expect(agentHarnessRegistration.id).toBe("codex"); expect(agentHarnessRegistration.label).toBe("Codex agent harness"); expect(agentHarnessRegistration.deliveryDefaults).toEqual({ - sourceVisibleReplies: "message_tool", + visibleReplies: "message_tool", }); expect(typeof agentHarnessRegistration.dispose).toBe("function"); expect(typeof agentHarnessRegistration.fetchUsageSnapshot).toBe("function"); @@ -419,7 +419,7 @@ describe("codex plugin", () => { bindingStore: testCodexAppServerBindingStore, }); - expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool"); + expect(harness.deliveryDefaults?.visibleReplies).toBe("message_tool"); expect( harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" }) .supported, diff --git a/extensions/codex/src/app-server/run-attempt-lifecycle-controller.ts b/extensions/codex/src/app-server/run-attempt-lifecycle-controller.ts index 6639ac4ee70e..15c3b91c989f 100644 --- a/extensions/codex/src/app-server/run-attempt-lifecycle-controller.ts +++ b/extensions/codex/src/app-server/run-attempt-lifecycle-controller.ts @@ -153,9 +153,7 @@ export function createCodexAttemptLifecycleController( startedAt: attemptStartedAt, endedAt: Date.now(), ...data, - ...((params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd) - ? { phase: "finishing" } - : {}), + ...(params.deferTerminalLifecycle ? { phase: "finishing" } : {}), }, }); state.lifecycleTerminalEmitted = true; diff --git a/extensions/codex/src/app-server/run-attempt.hooks.test.ts b/extensions/codex/src/app-server/run-attempt.hooks.test.ts index 833b361751a4..58c11b27d6df 100644 --- a/extensions/codex/src/app-server/run-attempt.hooks.test.ts +++ b/extensions/codex/src/app-server/run-attempt.hooks.test.ts @@ -51,25 +51,15 @@ setupRunAttemptTestHooks(); describe("runCodexAppServerAttempt hooks and model diagnostics", () => { it.each([ - { label: "completed", status: "completed" as const, error: undefined, legacy: false }, - { label: "failed", status: "failed" as const, error: "codex exploded", legacy: false }, - { - label: "completed legacy alias", - status: "completed" as const, - error: undefined, - legacy: true, - }, - ])("defers $label lifecycle terminal ownership", async ({ status, error, legacy }) => { + { label: "completed", status: "completed" as const, error: undefined }, + { label: "failed", status: "failed" as const, error: "codex exploded" }, + ])("defers $label lifecycle terminal ownership", async ({ status, error }) => { const onRunAgentEvent = vi.fn(); const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`); const workspaceDir = path.join(tempDir, `workspace-${status}`); const harness = createStartedThreadHarness(); const params = createParams(sessionFile, workspaceDir); - if (legacy) { - params.deferTerminalLifecycleEnd = true; - } else { - params.deferTerminalLifecycle = true; - } + params.deferTerminalLifecycle = true; params.onAgentEvent = onRunAgentEvent; const run = runCodexAppServerAttempt(params); await harness.waitForMethod("turn/start"); diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index f124aeab3eed..2e64694ca56c 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { adaptMessagePresentationForChannel, - normalizeInteractiveReply, + normalizeLegacyInteractiveReply, normalizeMessagePresentation, renderMessagePresentationFallbackText, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -153,7 +153,7 @@ export async function handleDiscordMessageAction( ? undefined : (params.components ?? presentationComponents ?? - buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive))); + buildDiscordInteractiveComponents(normalizeLegacyInteractiveReply(params.interactive))); const hasComponents = Boolean(rawComponents) && (typeof rawComponents === "function" || typeof rawComponents === "object"); diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index 7489559edc89..cf4c5ecd2063 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -178,11 +178,9 @@ export async function dispatchDiscordComponentEvent(params: { const { createReplyReferencePlanner, - dispatchReplyWithBufferedBlockDispatcher, finalizeInboundContext, resolveChunkMode, resolveTextChunkLimit, - recordInboundSession, } = await (async () => { const conversationRuntime = await loadConversationRuntime(); return { @@ -273,12 +271,8 @@ export async function dispatchDiscordComponentEvent(params: { cfg: ctx.cfg, channel: "discord", accountId, - agentId, - routeSessionKey: sessionKey, - storePath, + route: { agentId, sessionKey }, ctxPayload, - recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher, record: { updateLastRoute: interactionCtx.isDirectMessage ? { diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index ce9f45531243..9e848e6e66ca 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -369,6 +369,55 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ }, })); +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + const replyRuntime = await import("openclaw/plugin-sdk/reply-runtime"); + return { + ...actual, + dispatchChannelInboundTurn: async ( + plan: Parameters[0], + ) => { + const { cfg, route, delivery, sessionInitRetry, ...prepared } = plan; + const runDispatch = async () => { + for (let retryIndex = 0; ; retryIndex += 1) { + try { + return await replyRuntime.dispatchReplyWithBufferedBlockDispatcher({ + ctx: plan.ctxPayload, + cfg, + dispatcherOptions: { + ...plan.dispatcherOptions, + deliver: delivery.deliver, + onError: delivery.onError, + }, + toolsAllow: plan.toolsAllow, + replyOptions: plan.replyOptions, + replyResolver: plan.replyResolver, + }); + } catch (error) { + const delayMs = sessionInitRetry?.delaysMs[retryIndex]; + const message = error instanceof Error ? error.message : String(error); + if ( + delayMs === undefined || + sessionInitRetry?.signal?.aborted === true || + !/^reply session initialization conflicted for \S+$/u.test(message) + ) { + throw error; + } + await sessionInitRetry?.sleep?.(delayMs, sessionInitRetry.signal); + } + } + }; + return await actual.runPreparedInboundReply({ + ...prepared, + routeSessionKey: route.sessionKey, + storePath: resolveStorePath(cfg.session?.store, { agentId: route.agentId }), + recordInboundSession, + runDispatch, + }); + }, + }; +}); + vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSession(...args), resolvePinnedMainDmOwnerFromAllowlist: (params: { diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 9629e33ab6cf..213dcfb5260a 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -8,7 +8,7 @@ import { shouldAckReaction as shouldAckReactionGate, } from "openclaw/plugin-sdk/channel-feedback"; import { - dispatchChannelInboundReply, + dispatchChannelInboundTurn, hasFinalInboundReplyDispatch, } from "openclaw/plugin-sdk/channel-inbound"; import { @@ -24,8 +24,6 @@ import { resolveChannelStreamingBlockEnabled, resolveTranscriptBackedChannelFinalText, } from "openclaw/plugin-sdk/channel-outbound"; -import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; -import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; 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"; @@ -36,7 +34,13 @@ import { resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { danger, logVerbose, shouldLogVerbose, sleep } from "openclaw/plugin-sdk/runtime-env"; +import { + danger, + logVerbose, + shouldLogVerbose, + sleep, + sleepWithAbort, +} from "openclaw/plugin-sdk/runtime-env"; import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; @@ -57,15 +61,11 @@ import { import { buildDiscordMessageProcessContext } from "./message-handler.context.js"; import { createDiscordDraftPreviewController } from "./message-handler.draft-preview.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; -import { - completeDiscordSessionConflict, - withDiscordSessionRetry, -} from "./message-handler.retry.js"; +import { completeDiscordSessionConflict } from "./message-handler.retry.js"; import { deliverDiscordReply, formatDiscordReplyDeliveryFailure } from "./reply-delivery.js"; import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js"; import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js"; -const loadReplyRuntime = createLazyRuntimeModule(() => import("openclaw/plugin-sdk/reply-runtime")); const TARGETED_ONLY_ALLOWED_MENTIONS = { parse: ["users", "roles"], } as APIAllowedMentions; @@ -185,7 +185,6 @@ async function processDiscordMessageInner( if (boundThreadId && typeof threadBindings.touchThread === "function") { threadBindings.touchThread({ threadId: boundThreadId }); } - const { dispatchReplyWithBufferedBlockDispatcher: dispatchReply } = await loadReplyRuntime(); const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({ cfg, ctx: { @@ -991,7 +990,11 @@ async function processDiscordMessageInner( ); }; const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig); - let dispatchResult: Awaited> | null = null; + let dispatchResult: { + queuedFinal: boolean; + counts: Record; + failedCounts?: Partial>; + } | null = null; let dispatchError = false; let dispatchAborted = false; const deliverPendingToolWarningFinalIfNeeded = async () => { @@ -1015,17 +1018,18 @@ async function processDiscordMessageInner( dispatchAborted = true; return; } - const preparedResult = await dispatchChannelInboundReply({ + const preparedResult = await dispatchChannelInboundTurn({ cfg, channel: "discord", accountId: route.accountId, - agentId: route.agentId, - routeSessionKey: persistedSessionKey, - storePath: turn.storePath, + route: { agentId: route.agentId, sessionKey: persistedSessionKey }, ctxPayload, - recordInboundSession, afterRecord: queueInitialAckReactionAfterRecord, - dispatchReplyWithBufferedBlockDispatcher: withDiscordSessionRetry(dispatchReply, abortSignal), + sessionInitRetry: { + delaysMs: [250, 1_000, 2_500], + signal: abortSignal, + sleep: sleepWithAbort, + }, dispatcherOptions: { ...replyPipeline, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), diff --git a/extensions/discord/src/monitor/message-handler.retry.ts b/extensions/discord/src/monitor/message-handler.retry.ts index f9f1fbd39427..3676fc514e25 100644 --- a/extensions/discord/src/monitor/message-handler.retry.ts +++ b/extensions/discord/src/monitor/message-handler.retry.ts @@ -1,13 +1,9 @@ -// Discord plugin module implements narrow inbound dispatch retry behavior. -import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { DiscordRetryableInboundError } from "./inbound-dedupe.js"; const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u; -const DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS = [250, 1_000, 2_500] as const; const DISCORD_SESSION_CONFLICT_FAILURE_TEXT = "⚠️ Couldn't process this message because the session stayed busy. Please try again in a moment."; -type AsyncDispatch = (params: TParams) => Promise; type TerminalFailureDelivery = ( payload: { text: string; isError: true }, info: { kind: "final" }, @@ -19,63 +15,12 @@ function isReplySessionInitConflictError(error: unknown): boolean { return REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(message); } -class DiscordReplySessionConflictExhaustedError extends DiscordRetryableInboundError { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "DiscordReplySessionConflictExhaustedError"; - } -} - -async function dispatchDiscordReplyWithSessionConflictRetry(params: { - dispatch: () => Promise; - abortSignal?: AbortSignal; - onRetry?: (attempt: number, delayMs: number) => void; -}): Promise { - for (let retryIndex = 0; ; retryIndex += 1) { - try { - return await params.dispatch(); - } catch (error) { - if (!isReplySessionInitConflictError(error)) { - throw error; - } - const delayMs = DISCORD_SESSION_INIT_CONFLICT_RETRY_DELAYS_MS[retryIndex]; - if (delayMs === undefined) { - const message = error instanceof Error ? error.message : String(error); - // Let the caller either complete with a visible terminal notice or - // reopen replay ownership when that notice cannot land. - throw new DiscordReplySessionConflictExhaustedError( - `discord: reply session init conflict persisted after shared and channel retries: ${message}`, - { cause: error }, - ); - } - params.onRetry?.(retryIndex + 1, delayMs); - await sleepWithAbort(delayMs, params.abortSignal); - } - } -} - -export function withDiscordSessionRetry( - dispatch: AsyncDispatch, - abortSignal: AbortSignal | undefined, -): AsyncDispatch { - return (dispatchParams) => - dispatchDiscordReplyWithSessionConflictRetry({ - dispatch: () => dispatch(dispatchParams), - abortSignal, - onRetry: (attempt, delayMs) => { - logVerbose( - `discord: reply session init conflict; retrying dispatch ${attempt} after ${delayMs}ms`, - ); - }, - }); -} - export async function completeDiscordSessionConflict( error: unknown, deliver: TerminalFailureDelivery, onDeliveryError: DeliveryErrorHandler, ): Promise { - if (!(error instanceof DiscordReplySessionConflictExhaustedError)) { + if (!isReplySessionInitConflictError(error)) { return false; } try { @@ -87,7 +32,10 @@ export async function completeDiscordSessionConflict( } catch (deliveryError) { // Keep the conflict retryable when its visible terminal notice cannot land. onDeliveryError(deliveryError, { kind: "final" }); - return false; + throw new DiscordRetryableInboundError( + `discord: reply session init conflict exhausted and terminal notice failed: ${String(deliveryError)}`, + { cause: error }, + ); } } diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts index 0a08b922b763..0b28cf273133 100644 --- a/extensions/discord/src/shared-interactive.ts +++ b/extensions/discord/src/shared-interactive.ts @@ -1,12 +1,12 @@ // Discord plugin module implements shared interactive behavior. import { - reduceInteractiveReply, + reduceLegacyInteractiveReply, resolveMessagePresentationButtonAction, resolveMessagePresentationOptionAction, } from "openclaw/plugin-sdk/interactive-runtime"; import type { InteractiveButtonStyle, - InteractiveReply, + LegacyInteractiveReply, MessagePresentation, MessagePresentationButton, MessagePresentationOption, @@ -141,9 +141,9 @@ function appendDiscordButtonBlocks( * @deprecated Use buildDiscordPresentationComponents with MessagePresentation. */ export function buildDiscordInteractiveComponents( - interactive?: InteractiveReply, + interactive?: LegacyInteractiveReply, ): DiscordComponentMessageSpec | undefined { - const blocks = reduceInteractiveReply( + const blocks = reduceLegacyInteractiveReply( interactive, [] as NonNullable, (state, block) => { diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts index 69533e241919..537d606cb2ff 100644 --- a/extensions/feishu/src/bot.broadcast.test.ts +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -1,5 +1,4 @@ // Feishu tests cover bot.broadcast plugin behavior. -import type { EnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; import { feishuGroupNameCache } from "./bot-group-name-state.js"; @@ -7,25 +6,71 @@ import type { FeishuMessageEvent } from "./bot.js"; import { handleFeishuMessage } from "./bot.js"; import { setFeishuRuntime } from "./runtime.js"; -const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } = - vi.hoisted(() => ({ - mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({ - dispatcher: { - sendToolResult: vi.fn(), - sendBlockReply: vi.fn(), - sendFinalReply: vi.fn(), - waitForIdle: vi.fn(), - getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), - getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), - markComplete: vi.fn(), - }, - replyOptions: {}, - markDispatchIdle: vi.fn(), - ensureNoVisibleReplyFallback: vi.fn(), - })), - mockCreateFeishuClient: vi.fn(), - mockResolveAgentRoute: vi.fn(), - })); +const { + builtInboundContextCalls, + mockCreateFeishuReplyDispatcher, + mockCreateFeishuClient, + mockDispatchInboundMessage, + mockRecordInboundSession, + mockResolveAgentRoute, + mockResolveStorePath, +} = vi.hoisted(() => ({ + builtInboundContextCalls: [] as Array>, + mockCreateFeishuReplyDispatcher: vi.fn((_params?: unknown) => ({ + dispatcher: { + sendToolResult: vi.fn(), + sendBlockReply: vi.fn(), + sendFinalReply: vi.fn(), + waitForIdle: vi.fn(), + getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), + getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), + markComplete: vi.fn(), + }, + replyOptions: {}, + markDispatchIdle: vi.fn(), + ensureNoVisibleReplyFallback: vi.fn(), + })), + mockCreateFeishuClient: vi.fn(), + mockDispatchInboundMessage: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }), + mockRecordInboundSession: vi.fn().mockResolvedValue(undefined), + mockResolveAgentRoute: vi.fn(), + mockResolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), +})); + +vi.mock("openclaw/plugin-sdk/channel-inbound", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/channel-inbound", + ); + return { + ...actual, + buildChannelInboundEventContext: ( + params: Parameters[0], + ) => + actual.buildChannelInboundEventContext({ + ...params, + finalize: (ctx) => { + builtInboundContextCalls.push(ctx); + return ctx as never; + }, + }), + }; +}); + +vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/reply-runtime", + ); + return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage }; +}); + +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { ...actual, resolveStorePath: mockResolveStorePath }; +}); vi.mock("./reply-dispatcher.js", () => ({ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, @@ -48,43 +93,7 @@ function createRuntimeEnv() { } describe("broadcast dispatch", () => { - const finalizeInboundContextCalls: Array> = []; const mockGetChatInfo = vi.fn(); - const mockFinalizeInboundContext: PluginRuntime["channel"]["reply"]["finalizeInboundContext"] = ( - ctx, - ) => { - finalizeInboundContextCalls.push(ctx); - return { - ...ctx, - CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false, - CommandTurn: { - kind: "normal", - source: "message", - authorized: false, - }, - }; - }; - const mockDispatchReplyFromConfig = vi - .fn() - .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }); - const mockWithReplyDispatcher: PluginRuntime["channel"]["reply"]["withReplyDispatcher"] = async ({ - dispatcher, - run, - onSettled, - }) => { - try { - return await run(); - } finally { - dispatcher.markComplete(); - try { - await dispatcher.waitForIdle(); - } finally { - await onSettled?.(); - } - } - }; - const resolveEnvelopeFormatOptionsMock: PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"] = - () => ({}) satisfies EnvelopeFormatOptions; const mockShouldComputeCommandAuthorized = vi.fn(() => false); const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ path: "/tmp/inbound-clip.mp4", @@ -99,18 +108,10 @@ describe("broadcast dispatch", () => { resolveAgentRoute: (params: unknown) => mockResolveAgentRoute(params), }, session: { - resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), - recordInboundSession: vi.fn().mockResolvedValue(undefined), - }, - reply: { - resolveEnvelopeFormatOptions: resolveEnvelopeFormatOptionsMock, - formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), - finalizeInboundContext: - mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], - dispatchReplyFromConfig: mockDispatchReplyFromConfig, - withReplyDispatcher: - mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], + resolveStorePath: mockResolveStorePath, + recordInboundSession: mockRecordInboundSession, }, + reply: {}, commands: { shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), @@ -135,9 +136,13 @@ describe("broadcast dispatch", () => { if (!("runDispatch" in turn)) { throw new Error("feishu broadcast test runtime only supports prepared turns"); } - await turn.recordInboundSession({ - storePath: turn.storePath, - sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + const routeSessionKey = "route" in turn ? turn.route.sessionKey : turn.routeSessionKey; + const storePath = "storePath" in turn ? turn.storePath : mockResolveStorePath(); + const recordInboundSession = + "recordInboundSession" in turn ? turn.recordInboundSession : mockRecordInboundSession; + await recordInboundSession({ + storePath, + sessionKey: turn.ctxPayload.SessionKey ?? routeSessionKey, ctx: turn.ctxPayload, groupResolution: turn.record?.groupResolution, createIfMissing: turn.record?.createIfMissing, @@ -148,7 +153,7 @@ describe("broadcast dispatch", () => { admission: { kind: "dispatch" as const }, dispatched: true, ctxPayload: turn.ctxPayload, - routeSessionKey: turn.routeSessionKey, + routeSessionKey, dispatchResult: await turn.runDispatch(), }; }), @@ -219,8 +224,12 @@ describe("broadcast dispatch", () => { beforeEach(() => { vi.clearAllMocks(); + mockDispatchInboundMessage.mockReset().mockResolvedValue({ + queuedFinal: false, + counts: { final: 1 }, + }); feishuGroupNameCache.clear(); - finalizeInboundContextCalls.length = 0; + builtInboundContextCalls.length = 0; mockResolveAgentRoute.mockReturnValue({ agentId: "main", channel: "feishu", @@ -277,8 +286,8 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); - const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey); + expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2); + const sessionKeys = builtInboundContextCalls.map((call) => call.SessionKey); expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group"); expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group"); const recordCalls = ( @@ -320,7 +329,7 @@ describe("broadcast dispatch", () => { ]); expect(mockGetChatInfo).toHaveBeenCalledTimes(1); expect( - finalizeInboundContextCalls + builtInboundContextCalls .map((call) => ({ sessionKey: call.SessionKey, groupSubject: call.GroupSubject, @@ -347,7 +356,7 @@ describe("broadcast dispatch", () => { }); it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => { - mockDispatchReplyFromConfig + mockDispatchInboundMessage .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, @@ -389,7 +398,7 @@ describe("broadcast dispatch", () => { }); it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => { - mockDispatchReplyFromConfig + mockDispatchInboundMessage .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: true, @@ -430,7 +439,7 @@ describe("broadcast dispatch", () => { }); it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => { - mockDispatchReplyFromConfig + mockDispatchInboundMessage .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, @@ -484,7 +493,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockDispatchInboundMessage).not.toHaveBeenCalled(); expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled(); }); @@ -502,7 +511,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockDispatchInboundMessage).not.toHaveBeenCalled(); expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled(); }); @@ -539,14 +548,14 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1); expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); - expect(finalizeInboundContextCalls).toHaveLength(1); - expect(finalizeInboundContextCalls[0]?.SessionKey).toBe( + expect(builtInboundContextCalls).toHaveLength(1); + expect(builtInboundContextCalls[0]?.SessionKey).toBe( "agent:main:feishu:group:oc-broadcast-group", ); - expect(finalizeInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team"); - expect(finalizeInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team"); + expect(builtInboundContextCalls[0]?.GroupSubject).toBe("Broadcast Team"); + expect(builtInboundContextCalls[0]?.ConversationLabel).toBe("Broadcast Team"); expect(mockGetChatInfo).toHaveBeenCalledTimes(1); }); @@ -584,11 +593,11 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), accountId: "account-A", }); - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); + expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2); - mockDispatchReplyFromConfig.mockClear(); + mockDispatchInboundMessage.mockClear(); mockGetChatInfo.mockClear(); - finalizeInboundContextCalls.length = 0; + builtInboundContextCalls.length = 0; await handleFeishuMessage({ cfg, @@ -596,7 +605,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), accountId: "account-B", }); - expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockDispatchInboundMessage).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled(); }); @@ -634,10 +643,10 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1); const sessionKey = - typeof finalizeInboundContextCalls[0]?.SessionKey === "string" - ? finalizeInboundContextCalls[0].SessionKey + typeof builtInboundContextCalls[0]?.SessionKey === "string" + ? builtInboundContextCalls[0].SessionKey : ""; expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group"); }); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 684d595fa3ce..6655d647149b 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -169,7 +169,7 @@ function buildDefaultResolveRoute(): ResolvedAgentRoute { let currentRuntimeConfig = {} as ClawdbotConfig; function createFeishuBotRuntime(overrides: DeepPartial = {}): PluginRuntime { - return { + const runtime = { config: { current: vi.fn(() => currentRuntimeConfig), }, @@ -209,9 +209,14 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): Plu kind: "message", canStartAgentTurn: true, }); - await turn.recordInboundSession({ - storePath: turn.storePath, - sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + if (!("route" in turn) || !("runDispatch" in turn)) { + throw new Error("expected a prepared channel turn plan"); + } + await runtime.channel.session.recordInboundSession({ + storePath: runtime.channel.session.resolveStorePath(turn.cfg.session?.store, { + agentId: turn.route.agentId, + }), + sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey, ctx: turn.ctxPayload, groupResolution: turn.record?.groupResolution, createIfMissing: turn.record?.createIfMissing, @@ -229,6 +234,7 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): Plu ...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}), ...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}), } as unknown as PluginRuntime; + return runtime; } const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) => @@ -239,7 +245,6 @@ const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSession const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) => mockResolveStorePath(params); const resolveEnvelopeFormatOptionsMock = () => ({}); -const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); const withReplyDispatcherMock = async ({ run, }: Parameters[0]) => await run(); @@ -299,6 +304,8 @@ const { mockResolveFeishuReasoningPreviewEnabled, mockTranscribeFirstAudio, mockMaybeCreateDynamicAgent, + mockBuildChannelInboundEventContext, + mockDispatchInboundMessage, } = vi.hoisted(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({ dispatcher: createReplyDispatcher(), @@ -336,8 +343,49 @@ const { mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false), mockTranscribeFirstAudio: vi.fn(), mockMaybeCreateDynamicAgent: vi.fn(), + mockBuildChannelInboundEventContext: vi.fn(), + mockDispatchInboundMessage: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }), })); +const finalizeInboundContextMock = mockBuildChannelInboundEventContext; + +vi.mock("openclaw/plugin-sdk/channel-inbound", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/channel-inbound", + ); + return { + ...actual, + formatAgentEnvelope: ({ body }: { body: string }) => body, + resolveEnvelopeFormatOptions: () => ({}), + buildChannelInboundEventContext: ( + params: Parameters[0], + ) => + actual.buildChannelInboundEventContext({ + ...params, + finalize: (ctx) => { + mockBuildChannelInboundEventContext(ctx); + return ctx as never; + }, + }), + }; +}); + +vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/reply-runtime", + ); + return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage }; +}); + +vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/session-store-runtime", + ); + return { ...actual, resolveStorePath: mockResolveStorePath }; +}); + vi.mock("./reply-dispatcher.js", () => ({ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, })); @@ -966,42 +1014,11 @@ describe("handleFeishuMessage ACP routing", () => { ); expect(dispatcherOptions.allowReasoningPreview).toBe(true); }); - - it("falls back to full runtime channel when partial channelRuntime lacks inbound", async () => { - const partialChannelRuntime = { - runtimeContexts: {} as PluginRuntime["channel"]["runtimeContexts"], - } as PluginRuntime["channel"]; - - await dispatchMessage({ - cfg: { - session: { mainKey: "main", scope: "per-sender" }, - channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } }, - }, - event: { - sender: { sender_id: { open_id: "ou_sender_1" } }, - message: { - message_id: "msg-partial-runtime", - chat_id: "oc_dm", - chat_type: "p2p", - message_type: "text", - content: JSON.stringify({ text: "hello" }), - }, - }, - channelRuntime: partialChannelRuntime, - }); - - expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1); - }); }); describe("handleFeishuMessage command authorization", () => { - const mockFinalizeInboundContext = vi.fn((ctx: Record) => ({ - ...ctx, - CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false, - })); - const mockDispatchReplyFromConfig = vi - .fn() - .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }); + const mockFinalizeInboundContext = mockBuildChannelInboundEventContext; + const mockDispatchReplyFromConfig = mockDispatchInboundMessage; const mockWithReplyDispatcher = vi.fn( async ({ dispatcher, @@ -1037,6 +1054,10 @@ describe("handleFeishuMessage command authorization", () => { beforeEach(() => { vi.clearAllMocks(); + mockDispatchReplyFromConfig.mockReset().mockResolvedValue({ + queuedFinal: false, + counts: { final: 1 }, + }); mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true); mockGetMessageFeishu.mockReset().mockResolvedValue(null); mockListFeishuThreadMessages.mockReset().mockResolvedValue([]); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index fd54e20663c8..1d2c4d18e3e3 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1,7 +1,8 @@ -// Feishu plugin module implements bot behavior. import { buildChannelInboundEventContext, + formatAgentEnvelope, formatInboundMediaUnavailableText, + resolveEnvelopeFormatOptions, toInboundMediaFacts, } from "openclaw/plugin-sdk/channel-inbound"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/channel-outbound"; @@ -17,6 +18,7 @@ import { createChannelHistoryWindow, type HistoryEntry, } from "openclaw/plugin-sdk/reply-history"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolveDefaultGroupPolicy, @@ -24,6 +26,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; +import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; @@ -977,7 +980,7 @@ export async function handleFeishuMessage(params: { (groupSession?.groupSessionScope === "group_topic" || groupSession?.groupSessionScope === "group_topic_sender"); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); const messageBody = buildFeishuAgentBody({ ctx: agentFacingCtx, quotedContent, @@ -990,7 +993,7 @@ export async function handleFeishuMessage(params: { log(`feishu[${account.accountId}]: appending permission error notice to message body`); } - const body = core.channel.reply.formatAgentEnvelope({ + const body = formatAgentEnvelope({ channel: "Feishu", from: envelopeFrom, timestamp: new Date(), @@ -1008,7 +1011,7 @@ export async function handleFeishuMessage(params: { limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - core.channel.reply.formatAgentEnvelope({ + formatAgentEnvelope({ channel: "Feishu", // Preserve speaker identity in group history as well. from: `${ctx.chatId}:${entry.sender}`, @@ -1116,7 +1119,7 @@ export async function handleFeishuMessage(params: { return threadContext; } - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId }); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({ storePath, sessionKey: agentSessionKey, @@ -1182,7 +1185,7 @@ export async function handleFeishuMessage(params: { : relevantMessages.slice(1); const historyParts = historyMessages.map((msg) => { const role = msg.senderType === "app" ? "assistant" : "user"; - return core.channel.reply.formatAgentEnvelope({ + return formatAgentEnvelope({ channel: "Feishu", from: `${msg.senderId ?? "Unknown"} (${role})`, timestamp: msg.createTime, @@ -1216,7 +1219,6 @@ export async function handleFeishuMessage(params: { const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName); return buildChannelInboundEventContext({ channel: "feishu", - finalize: core.channel.reply.finalizeInboundContext, supplemental: { quote: quotedContent ? { id: ctx.parentId, body: quotedContent } : undefined, thread: { @@ -1388,7 +1390,7 @@ export async function handleFeishuMessage(params: { } const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId); - const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, { + const agentStorePath = resolveStorePath(cfg.session?.store, { agentId, }); const agentRecord = { @@ -1456,12 +1458,11 @@ export async function handleFeishuMessage(params: { raw: ctx, }), resolveTurn: () => ({ + cfg, channel: "feishu", accountId: route.accountId, - routeSessionKey: agentSessionKey, - storePath: agentStorePath, + route: { agentId, sessionKey: agentSessionKey }, ctxPayload: agentCtx, - recordInboundSession: core.channel.session.recordInboundSession, record: agentRecord, onPreDispatchFailure: () => core.channel.reply.settleReplyDispatcher({ @@ -1469,16 +1470,12 @@ export async function handleFeishuMessage(params: { onSettled: () => markDispatchIdle(), }), runDispatch: () => - core.channel.reply.withReplyDispatcher({ + dispatchInboundMessage({ + ctx: agentCtx, + cfg, dispatcher, onSettled: () => markDispatchIdle(), - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: agentCtx, - cfg, - dispatcher, - replyOptions, - }), + replyOptions, }), }), }, @@ -1524,22 +1521,17 @@ export async function handleFeishuMessage(params: { raw: ctx, }), resolveTurn: () => ({ + cfg, channel: "feishu", accountId: route.accountId, - routeSessionKey: agentSessionKey, - storePath: agentStorePath, + route: { agentId, sessionKey: agentSessionKey }, ctxPayload: agentCtx, - recordInboundSession: core.channel.session.recordInboundSession, record: agentRecord, runDispatch: () => - core.channel.reply.withReplyDispatcher({ + dispatchInboundMessage({ + ctx: agentCtx, + cfg, dispatcher: noopDispatcher, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: agentCtx, - cfg, - dispatcher: noopDispatcher, - }), }), }), }, @@ -1592,7 +1584,7 @@ export async function handleFeishuMessage(params: { ); const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId); - const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { + const storePath = resolveStorePath(effectiveCfg.session?.store, { agentId: route.agentId, }); const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({ @@ -1637,12 +1629,11 @@ export async function handleFeishuMessage(params: { raw: ctx, }), resolveTurn: () => ({ + cfg: effectiveCfg, channel: "feishu", accountId: route.accountId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, record: { updateLastRoute: buildFeishuInboundLastRouteUpdate({ sessionKey: route.sessionKey, @@ -1666,18 +1657,12 @@ export async function handleFeishuMessage(params: { onSettled: () => markDispatchIdle(), }), runDispatch: () => - core.channel.reply.withReplyDispatcher({ + dispatchInboundMessage({ + ctx: ctxPayload, + cfg: effectiveCfg, dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg: effectiveCfg, - dispatcher, - replyOptions, - }), + onSettled: () => markDispatchIdle(), + replyOptions, }), }), }, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index e7be59fc3856..bd81eaa5cd28 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -30,10 +30,10 @@ import { createRuntimeDirectoryLiveAdapter, } from "openclaw/plugin-sdk/directory-runtime"; import { - interactiveReplyToPresentation, - normalizeInteractiveReply, + legacyInteractiveReplyToPresentation, + normalizeLegacyInteractiveReply, normalizeMessagePresentation, - resolveInteractiveTextFallback, + resolveLegacyInteractiveTextFallback, } from "openclaw/plugin-sdk/interactive-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; @@ -1074,10 +1074,10 @@ export const feishuPlugin: ChannelPlugin { await typingReaction.start(); }, diff --git a/extensions/feishu/src/comment-handler.test.ts b/extensions/feishu/src/comment-handler.test.ts index 4acd492869db..178c3cdc2b1e 100644 --- a/extensions/feishu/src/comment-handler.test.ts +++ b/extensions/feishu/src/comment-handler.test.ts @@ -1,5 +1,4 @@ // Feishu tests cover comment handler plugin behavior. -import type { PreparedInboundReply } from "openclaw/plugin-sdk/channel-inbound"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; import { handleFeishuCommentEvent } from "./comment-handler.js"; @@ -10,6 +9,7 @@ const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn()); const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() }))); const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn()); +const dispatchInboundMessageMock = vi.hoisted(() => vi.fn()); vi.mock("./monitor.comment.js", () => ({ resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock, @@ -31,6 +31,11 @@ vi.mock("./drive.js", () => ({ deliverCommentThreadText: deliverCommentThreadTextMock, })); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => ({ + ...(await importOriginal()), + dispatchInboundMessage: dispatchInboundMessageMock, +})); + async function raceWithNextMacrotask(promise: Promise): Promise { return await Promise.race([ promise, @@ -83,38 +88,19 @@ function createTestRuntime(overrides?: { readAllowFromStore?: () => Promise; upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>; resolveAgentRoute?: () => ReturnType; - dispatchReplyFromConfig?: PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"]; - withReplyDispatcher?: PluginRuntime["channel"]["reply"]["withReplyDispatcher"]; }) { - const finalizeInboundContext = vi.fn((ctx: Record) => ctx); - const dispatchReplyFromConfig = - overrides?.dispatchReplyFromConfig ?? - vi.fn(async () => ({ - queuedFinal: true, - counts: { tool: 0, block: 0, final: 1 }, - })); - const withReplyDispatcher = - overrides?.withReplyDispatcher ?? - vi.fn( - async ({ - run, - onSettled, - }: { - run: () => Promise; - onSettled?: () => Promise | void; - }) => { - try { - return await run(); - } finally { - await onSettled?.(); - } - }, - ); - const recordInboundSession = vi.fn(async () => {}); - const dispatchPreparedForTest = vi.fn(async (turn: PreparedInboundReply) => { - await turn.recordInboundSession({ - storePath: turn.storePath, - sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + const recordInboundSession = vi.fn(async (_params: unknown) => {}); + type PreparedCommentTurnPlan = { + route: { agentId: string; sessionKey: string }; + ctxPayload: { SessionKey?: string }; + record?: Record & { onRecordError?: (error: unknown) => void }; + runDispatch: () => Promise; + }; + const dispatchPreparedForTest = vi.fn(async (turn: PreparedCommentTurnPlan) => { + const storePath = "/tmp/feishu-session-store.json"; + await recordInboundSession({ + storePath, + sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey, ctx: turn.ctxPayload, groupResolution: turn.record?.groupResolution, createIfMissing: turn.record?.createIfMissing, @@ -126,7 +112,7 @@ function createTestRuntime(overrides?: { admission: { kind: "dispatch" as const }, dispatched: true, ctxPayload: turn.ctxPayload, - routeSessionKey: turn.routeSessionKey, + routeSessionKey: turn.route.sessionKey, dispatchResult, }; }); @@ -151,9 +137,11 @@ function createTestRuntime(overrides?: { resolveAgentRoute: vi.fn(overrides?.resolveAgentRoute ?? (() => buildResolvedRoute())), }, reply: { - finalizeInboundContext, - dispatchReplyFromConfig, - withReplyDispatcher, + settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => { + dispatcher.markComplete(); + await dispatcher.waitForIdle(); + await onSettled?.(); + }), }, session: { resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), @@ -176,7 +164,7 @@ function createTestRuntime(overrides?: { if (!("runDispatch" in turn)) { throw new Error("feishu comment test runtime only supports prepared turns"); } - return await dispatchPreparedForTest(turn as PreparedInboundReply); + return await dispatchPreparedForTest(turn as PreparedCommentTurnPlan); }) as unknown as PluginRuntime["channel"]["inbound"]["run"], }, pairing: { @@ -206,6 +194,10 @@ describe("handleFeishuCommentEvent", () => { beforeEach(() => { vi.clearAllMocks(); + dispatchInboundMessageMock.mockResolvedValue({ + queuedFinal: true, + counts: { tool: 0, block: 0, final: 1 }, + }); currentRuntimeConfig = buildConfig(); maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({ created: false, @@ -270,20 +262,16 @@ describe("handleFeishuCommentEvent", () => { ); const runtime = (await import("./runtime.js")).getFeishuRuntime(); - const finalizeInboundContext = runtime.channel.reply.finalizeInboundContext as ReturnType< - typeof vi.fn - >; const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType< typeof vi.fn >; - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; - expect(finalizeInboundContext).toHaveBeenCalledTimes(1); - const finalizedContext = mockCallArg(finalizeInboundContext, "finalizeInboundContext") as - | Record - | undefined; + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); + const finalizedContext = ( + mockCallArg(dispatchInboundMessageMock, "dispatchInboundMessage") as { + ctx?: Record; + } + ).ctx; expect({ from: finalizedContext?.From, to: finalizedContext?.To, @@ -306,7 +294,6 @@ describe("handleFeishuCommentEvent", () => { | { sessionKey?: string } | undefined; expect(recordArgs?.sessionKey).toBe("agent:main:feishu:direct:comment-doc:docx:doc_token_1"); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); }); it("allows comment senders matched by user_id allowlist entries", async () => { @@ -332,10 +319,7 @@ describe("handleFeishuCommentEvent", () => { } as never, }); - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); expect(deliverCommentThreadTextMock).not.toHaveBeenCalled(); }); @@ -376,10 +360,7 @@ describe("handleFeishuCommentEvent", () => { | undefined; expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender"); expect(dynamicAgentArgs?.accountId).toBe("default"); - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); }); it("drops a comment denied by refreshed dynamic-agent policy", async () => { @@ -410,12 +391,9 @@ describe("handleFeishuCommentEvent", () => { } as never, }); - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled(); expect(deliverCommentThreadTextMock).not.toHaveBeenCalled(); - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); }); it("issues a pairing challenge before dynamic comment-agent creation", async () => { @@ -446,12 +424,9 @@ describe("handleFeishuCommentEvent", () => { } as never, }); - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled(); expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1); - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); }); it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => { @@ -506,10 +481,7 @@ describe("handleFeishuCommentEvent", () => { ].join("\n"), is_whole_comment: false, }); - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessageMock).not.toHaveBeenCalled(); }); it("passes whole-comment metadata to the comment reply dispatcher", async () => { @@ -565,10 +537,8 @@ describe("handleFeishuCommentEvent", () => { }); it("always finalizes comment typing cleanup even when dispatch fails", async () => { - const dispatchReplyFromConfig = vi.fn(async () => { - throw new Error("dispatch failed"); - }); - const runtime = createTestRuntime({ dispatchReplyFromConfig }); + dispatchInboundMessageMock.mockRejectedValueOnce(new Error("dispatch failed")); + const runtime = createTestRuntime(); setFeishuRuntime(runtime); const markRunComplete = vi.fn(); const markDispatchIdle = vi.fn(); @@ -669,10 +639,6 @@ describe("handleFeishuCommentEvent", () => { }); expect(startTypingReaction).not.toHaveBeenCalled(); - const runtime = (await import("./runtime.js")).getFeishuRuntime(); - const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< - typeof vi.fn - >; - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts index 96d134ba62b1..43c979a74b51 100644 --- a/extensions/feishu/src/comment-handler.ts +++ b/extensions/feishu/src/comment-handler.ts @@ -1,5 +1,7 @@ // Feishu plugin module implements comment handler behavior. +import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound"; import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; +import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; @@ -214,36 +216,36 @@ export async function handleFeishuCommentEvent( fileToken: turn.fileToken, }); const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`; - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: bodyForAgent, - BodyForAgent: bodyForAgent, - RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, - CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, - From: `feishu:${turn.senderId}`, - To: commentTarget, - SessionKey: commentSessionKey, - AccountId: route.accountId, - ChatType: "direct", - ConversationLabel: turn.documentTitle - ? `Feishu comment · ${turn.documentTitle}` - : "Feishu comment", - SenderName: turn.senderId, - SenderId: turn.senderId, - Provider: "feishu", - Surface: "feishu-comment", - MessageSid: turn.messageId, - // For Feishu comment turns, MessageThreadId carries the inbound reply_id so - // comment-aware tools can clean typing reaction before sending visible output. - MessageThreadId: turn.replyId, - Timestamp: parseTimestampMs(turn.timestamp), - WasMentioned: turn.isMentioned, - CommandAuthorized: false, - OriginatingChannel: "feishu", - OriginatingTo: commentTarget, - }); - - const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { - agentId: route.agentId, + const rawBody = turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt; + const conversationLabel = turn.documentTitle + ? `Feishu comment · ${turn.documentTitle}` + : "Feishu comment"; + const ctxPayload = buildChannelInboundEventContext({ + channel: "feishu", + accountId: route.accountId, + surface: "feishu-comment", + messageId: turn.messageId, + timestamp: parseTimestampMs(turn.timestamp), + from: `feishu:${turn.senderId}`, + sender: { id: turn.senderId, name: turn.senderId }, + conversation: { kind: "direct", id: commentTarget, label: conversationLabel }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: commentSessionKey, + dispatchSessionKey: commentSessionKey, + }, + reply: { + to: commentTarget, + originatingTo: commentTarget, + // Comment-aware tools use the inbound reply id as the native thread id. + messageThreadId: turn.replyId, + }, + message: { body: bodyForAgent, bodyForAgent, rawBody, commandBody: rawBody }, + access: { + commands: { authorized: false }, + mentions: { canDetectMention: true, wasMentioned: turn.isMentioned ?? false }, + }, }); const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } = @@ -279,12 +281,11 @@ export async function handleFeishuCommentEvent( raw: turn, }), resolveTurn: () => ({ + cfg: effectiveCfg, channel: "feishu", accountId: route.accountId, - routeSessionKey: commentSessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: commentSessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, record: { onRecordError: (err) => { error( @@ -303,15 +304,11 @@ export async function handleFeishuCommentEvent( }); }, runDispatch: () => - core.channel.reply.withReplyDispatcher({ + dispatchInboundMessage({ + ctx: ctxPayload, + cfg: effectiveCfg, dispatcher, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg: effectiveCfg, - dispatcher, - replyOptions, - }), + replyOptions, }), }), }, diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 853e8b087a83..c2d819320c4d 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -7,11 +7,11 @@ import { } from "openclaw/plugin-sdk/channel-send-result"; import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime"; import { - interactiveReplyToPresentation, - normalizeInteractiveReply, + legacyInteractiveReplyToPresentation, + normalizeLegacyInteractiveReply, normalizeMessagePresentation, renderMessagePresentationFallbackText, - resolveInteractiveTextFallback, + resolveLegacyInteractiveTextFallback, } from "openclaw/plugin-sdk/interactive-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; @@ -179,10 +179,10 @@ function buildFeishuPayloadCard(params: { const rawText = params.text ?? params.payload.text; const textCard = readNativeFeishuCardJson(rawText); - const interactive = normalizeInteractiveReply(params.payload.interactive); + const interactive = normalizeLegacyInteractiveReply(params.payload.interactive); const presentation = normalizeMessagePresentation(params.payload.presentation) ?? - (interactive ? interactiveReplyToPresentation(interactive) : undefined); + (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined); if (!presentation && !interactive) { if (!textCard) { return undefined; @@ -193,7 +193,7 @@ function buildFeishuPayloadCard(params: { const text = textCard ? undefined - : resolveInteractiveTextFallback({ + : resolveLegacyInteractiveTextFallback({ text: rawText, interactive, }); @@ -600,10 +600,10 @@ export const feishuOutbound: ChannelOutboundAdapter = { const { payload, presentationFallback } = consumeFeishuPresentationFallbackMarker(ctx.payload); const ttsSupplement = getReplyPayloadTtsSupplement(payload); if (parseFeishuCommentTarget(ctx.to)) { - const interactive = normalizeInteractiveReply(payload.interactive); + const interactive = normalizeLegacyInteractiveReply(payload.interactive); const normalizedPresentation = normalizeMessagePresentation(payload.presentation) ?? - (interactive ? interactiveReplyToPresentation(interactive) : undefined); + (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined); // Document comments cannot render cards. Resolve the text path before // validating card limits so unused native card data cannot block delivery. const textCard = readNativeFeishuCardJson(payload.text); @@ -652,10 +652,10 @@ export const feishuOutbound: ChannelOutboundAdapter = { if (ttsSupplement) { return await sendFeishuTtsSupplementPayload({ ctx, payload, supplement: ttsSupplement }); } - const interactive = normalizeInteractiveReply(payload.interactive); + const interactive = normalizeLegacyInteractiveReply(payload.interactive); const presentation = normalizeMessagePresentation(payload.presentation) ?? - (interactive ? interactiveReplyToPresentation(interactive) : undefined); + (interactive ? legacyInteractiveReplyToPresentation(interactive) : undefined); const fallbackPayload = presentation ? { ...payload, diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 86889a12b433..dc104fbbb0a8 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,5 +1,5 @@ // Feishu plugin module implements reply dispatcher behavior. -import { formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime"; +import { formatReasoningMessage, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound"; import { @@ -14,6 +14,7 @@ import { resolveTextChunksWithFallback, sendMediaWithLeadingCaption, } from "openclaw/plugin-sdk/reply-payload"; +import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { resolveConfiguredHttpTimeoutMs } from "./client-timeout.js"; @@ -637,245 +638,240 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return nextIdleSideEffects; }; - const { dispatcher, replyOptions, markDispatchIdle } = - core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), - silentReplyContext: { - cfg, - sessionKey: params.sessionKey, - surface: "feishu", - conversationType: chatId.startsWith("oc_") ? "group" : "direct", - }, - onSkip: (_payload, info) => { - if (info.kind === "final") { - skippedFinalReason = info.reason; - } - }, - onReplyStart: async () => { - if (!replyLifecycleStateInitialized) { - replyLifecycleStateInitialized = true; - deliveredFinalTexts.clear(); - sentIndependentBlockText = false; - streamingClosedForReply = false; - streamingCloseErroredForReply = false; - visibleReplySent = false; - skippedFinalReason = null; - } - if (streamingEnabled && renderMode === "card") { - startStreaming(); - } - await Promise.resolve(typingCallbacks?.onReplyStart?.()); - }, - deliver: async (payload: ReplyPayload, info) => { - if (info?.kind === "final") { - skippedFinalReason = null; - } - const payloadText = - payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text; - const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText }); - const text = - info?.kind === "final" - ? mergeStreamingFinalText( - streamText, - reply.text, - payload.isError === true && hasStreamingFinalText, - ) - : reply.text; - const hasText = reply.hasText; - const hasMedia = reply.hasMedia; - const ttsSupplement = getReplyPayloadTtsSupplement(payload); - const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true; - const hasVoiceMedia = - hasMedia && - reply.mediaUrls.some((mediaUrl) => - shouldSuppressFeishuTextForVoiceMedia({ - mediaUrl, - ...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}), - ttsSupplement, - }), - ); - const finalTextExceedsStreamingLimit = - info?.kind === "final" && hasText && text.length > textChunkLimit; - const useStaticCard = - hasText && - (renderMode === "card" || - (info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") || - (renderMode === "auto" && shouldUseCard(text))); - const useStreamingCard = - hasText && - streamingEnabled && - !finalTextExceedsStreamingLimit && - (info?.kind === "final" || useStaticCard); - const finalTextWouldUseStreamingCard = - info?.kind === "final" && hasText && streamingEnabled; - const useCard = useStaticCard || useStreamingCard; - const skipTextForDuplicateFinal = - info?.kind === "final" && hasText && deliveredFinalTexts.has(text); - const skipTextForClosedStreamingFinal = - info?.kind === "final" && - hasText && - streamingClosedForReply && - !streamingCloseErroredForReply && - finalTextWouldUseStreamingCard; - const shouldDeliverText = - hasText && - !hasVoiceMedia && - !skipTextForDuplicateFinal && - !skipTextForClosedStreamingFinal; - const shouldDiscardStreamingPreview = - info?.kind === "final" && - (finalTextExceedsStreamingLimit || - (hasMedia && - ((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) || - skipTextForDuplicateFinal))); + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: resolveHumanDelayConfig(cfg, agentId), + silentReplyContext: { + cfg, + sessionKey: params.sessionKey, + surface: "feishu", + conversationType: chatId.startsWith("oc_") ? "group" : "direct", + }, + onSkip: (_payload, info) => { + if (info.kind === "final") { + skippedFinalReason = info.reason; + } + }, + onReplyStart: async () => { + if (!replyLifecycleStateInitialized) { + replyLifecycleStateInitialized = true; + deliveredFinalTexts.clear(); + sentIndependentBlockText = false; + streamingClosedForReply = false; + streamingCloseErroredForReply = false; + visibleReplySent = false; + skippedFinalReason = null; + } + if (streamingEnabled && renderMode === "card") { + startStreaming(); + } + await Promise.resolve(typingCallbacks?.onReplyStart?.()); + }, + deliver: async (payload: ReplyPayload, info) => { + if (info?.kind === "final") { + skippedFinalReason = null; + } + const payloadText = + payload.isReasoning && payload.text ? formatReasoningMessage(payload.text) : payload.text; + const reply = resolveSendableOutboundReplyParts({ ...payload, text: payloadText }); + const text = + info?.kind === "final" + ? mergeStreamingFinalText( + streamText, + reply.text, + payload.isError === true && hasStreamingFinalText, + ) + : reply.text; + const hasText = reply.hasText; + const hasMedia = reply.hasMedia; + const ttsSupplement = getReplyPayloadTtsSupplement(payload); + const ttsTextAlreadyVisible = ttsSupplement?.visibleTextAlreadyDelivered === true; + const hasVoiceMedia = + hasMedia && + reply.mediaUrls.some((mediaUrl) => + shouldSuppressFeishuTextForVoiceMedia({ + mediaUrl, + ...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}), + ttsSupplement, + }), + ); + const finalTextExceedsStreamingLimit = + info?.kind === "final" && hasText && text.length > textChunkLimit; + const useStaticCard = + hasText && + (renderMode === "card" || + (info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") || + (renderMode === "auto" && shouldUseCard(text))); + const useStreamingCard = + hasText && + streamingEnabled && + !finalTextExceedsStreamingLimit && + (info?.kind === "final" || useStaticCard); + const finalTextWouldUseStreamingCard = info?.kind === "final" && hasText && streamingEnabled; + const useCard = useStaticCard || useStreamingCard; + const skipTextForDuplicateFinal = + info?.kind === "final" && hasText && deliveredFinalTexts.has(text); + const skipTextForClosedStreamingFinal = + info?.kind === "final" && + hasText && + streamingClosedForReply && + !streamingCloseErroredForReply && + finalTextWouldUseStreamingCard; + const shouldDeliverText = + hasText && !hasVoiceMedia && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal; + const shouldDiscardStreamingPreview = + info?.kind === "final" && + (finalTextExceedsStreamingLimit || + (hasMedia && + ((hasVoiceMedia && !shouldDeliverText && !ttsTextAlreadyVisible) || + skipTextForDuplicateFinal))); - if (!shouldDeliverText && !hasMedia) { - return; - } + if (!shouldDeliverText && !hasMedia) { + return; + } - if (shouldDiscardStreamingPreview) { - await discardStreamingPreview(); - } + if (shouldDiscardStreamingPreview) { + await discardStreamingPreview(); + } - if (shouldDeliverText) { - if (info?.kind === "block") { - // Drop internal block chunks unless we can safely consume them as - // streaming-card fallback content or send them as independent - // messages for true progressive delivery. - if (!useStreamingCard) { - if (coreBlockStreamingEnabled) { - // Reuse normal text chunking, but notify mentions only on the first visible chunk. - const isFirstBlock = !sentIndependentBlockText; - const firstChunkMentions = - isFirstBlock && mentionTargets?.length ? mentionTargets : undefined; - await sendChunkedTextReply({ - text, - useCard: false, - infoKind: "block", - firstChunkMentions, - sendChunk: async ({ chunk, isFirst }) => { - await sendMessageFeishu({ - cfg, - to: sendTarget, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - allowTopLevelReplyFallback, - accountId, - ...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}), - }); - }, - }); - sentIndependentBlockText = true; - if (hasMedia) { - await sendMediaReplies(payload); - } + if (shouldDeliverText) { + if (info?.kind === "block") { + // Drop internal block chunks unless we can safely consume them as + // streaming-card fallback content or send them as independent + // messages for true progressive delivery. + if (!useStreamingCard) { + if (coreBlockStreamingEnabled) { + // Reuse normal text chunking, but notify mentions only on the first visible chunk. + const isFirstBlock = !sentIndependentBlockText; + const firstChunkMentions = + isFirstBlock && mentionTargets?.length ? mentionTargets : undefined; + await sendChunkedTextReply({ + text, + useCard: false, + infoKind: "block", + firstChunkMentions, + sendChunk: async ({ chunk, isFirst }) => { + await sendMessageFeishu({ + cfg, + to: sendTarget, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + allowTopLevelReplyFallback, + accountId, + ...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}), + }); + }, + }); + sentIndependentBlockText = true; + if (hasMedia) { + await sendMediaReplies(payload); } - return; - } - startStreaming(); - if (streamingStartPromise) { - await streamingStartPromise; - } - } - - if (info?.kind === "final" && useStreamingCard) { - startStreaming(); - if (streamingStartPromise) { - await streamingStartPromise; - } - } - - const shouldStreamText = info?.kind === "block" || info?.kind === "final"; - if (streaming?.isActive() && shouldStreamText) { - if (info?.kind === "block") { - // Some runtimes emit block payloads without onPartial/final callbacks. - // Mirror block text into streamText so onIdle close still sends content. - queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true }); - } - if (info?.kind === "final") { - // Final payloads can be cumulative snapshots or independent - // notices. Preserve both when the latter arrives after an answer. - streamText = text; - hasStreamingFinalText = true; - snapshotBaseText = ""; - lastSnapshotTextLength = text.length; - flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText)); - } - // Send media even when streaming handled the text - if (hasMedia) { - await sendMediaReplies(payload); } return; } - - if (useCard) { - const cardHeader = resolveCardHeader(agentId, identity); - const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); - await sendChunkedTextReply({ - text, - useCard: true, - infoKind: info?.kind, - sendChunk: async ({ chunk }) => { - await sendStructuredCardFeishu({ - cfg, - to: sendTarget, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - allowTopLevelReplyFallback, - accountId, - header: cardHeader, - note: cardNote, - }); - }, - }); - } else { - const firstChunkMentions = - info?.kind === "final" && mentionTargets?.length ? mentionTargets : undefined; - await sendChunkedTextReply({ - text, - useCard: false, - infoKind: info?.kind, - firstChunkMentions, - sendChunk: async ({ chunk, isFirst }) => { - await sendMessageFeishu({ - cfg, - to: sendTarget, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - allowTopLevelReplyFallback, - accountId, - ...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}), - }); - }, - }); + startStreaming(); + if (streamingStartPromise) { + await streamingStartPromise; } } - if (hasMedia) { - await sendMediaReplies( - payload, - hasVoiceMedia && hasText ? { fallbackText: text } : undefined, - ); + if (info?.kind === "final" && useStreamingCard) { + startStreaming(); + if (streamingStartPromise) { + await streamingStartPromise; + } } - }, - onError: async (error, info) => { - streamingCloseErroredForReply = true; - streamingClosedForReply = false; - params.runtime.error?.( - `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`, + + const shouldStreamText = info?.kind === "block" || info?.kind === "final"; + if (streaming?.isActive() && shouldStreamText) { + if (info?.kind === "block") { + // Some runtimes emit block payloads without onPartial/final callbacks. + // Mirror block text into streamText so onIdle close still sends content. + queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true }); + } + if (info?.kind === "final") { + // Final payloads can be cumulative snapshots or independent + // notices. Preserve both when the latter arrives after an answer. + streamText = text; + hasStreamingFinalText = true; + snapshotBaseText = ""; + lastSnapshotTextLength = text.length; + flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText)); + } + // Send media even when streaming handled the text + if (hasMedia) { + await sendMediaReplies(payload); + } + return; + } + + if (useCard) { + const cardHeader = resolveCardHeader(agentId, identity); + const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); + await sendChunkedTextReply({ + text, + useCard: true, + infoKind: info?.kind, + sendChunk: async ({ chunk }) => { + await sendStructuredCardFeishu({ + cfg, + to: sendTarget, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + allowTopLevelReplyFallback, + accountId, + header: cardHeader, + note: cardNote, + }); + }, + }); + } else { + const firstChunkMentions = + info?.kind === "final" && mentionTargets?.length ? mentionTargets : undefined; + await sendChunkedTextReply({ + text, + useCard: false, + infoKind: info?.kind, + firstChunkMentions, + sendChunk: async ({ chunk, isFirst }) => { + await sendMessageFeishu({ + cfg, + to: sendTarget, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + allowTopLevelReplyFallback, + accountId, + ...(isFirst && firstChunkMentions ? { mentions: firstChunkMentions } : {}), + }); + }, + }); + } + } + + if (hasMedia) { + await sendMediaReplies( + payload, + hasVoiceMedia && hasText ? { fallbackText: text } : undefined, ); - await queueIdleSideEffects({ markClosedForReply: false }); - }, - onIdle: () => queueIdleSideEffects(), - onCleanup: () => { - typingCallbacks?.onCleanup?.(); - }, - }); + } + }, + onError: async (error, info) => { + streamingCloseErroredForReply = true; + streamingClosedForReply = false; + params.runtime.error?.( + `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`, + ); + await queueIdleSideEffects({ markClosedForReply: false }); + }, + onIdle: () => queueIdleSideEffects(), + onCleanup: () => { + typingCallbacks?.onCleanup?.(); + }, + }); return { dispatcher, diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index d34eaa3c56a4..22bfa84b33ad 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -40,7 +40,6 @@ export type { } from "openclaw/plugin-sdk/config-contracts"; export { extractToolSend } from "openclaw/plugin-sdk/tool-send"; export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; -export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress"; export { registerWebhookTargetWithPluginRoute, diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts index 7c129fb9aee7..2839ca1b9a50 100644 --- a/extensions/googlechat/src/monitor.test.ts +++ b/extensions/googlechat/src/monitor.test.ts @@ -21,6 +21,19 @@ const routingMocks = vi.hoisted(() => ({ | undefined, })); +const inboundMocks = vi.hoisted(() => ({ + buildEnvelope: vi.fn(({ body }: { body: string }) => body), + resolveChannelInboundRouteEnvelope: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveChannelInboundRouteEnvelope: inboundMocks.resolveChannelInboundRouteEnvelope, + }; +}); + vi.mock("./api.js", () => ({ downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia, sendGoogleChatMessage: apiMocks.sendGoogleChatMessage, @@ -43,34 +56,29 @@ beforeEach(() => { apiMocks.downloadGoogleChatMedia.mockReset(); apiMocks.sendGoogleChatMessage.mockReset(); accessMocks.applyGoogleChatInboundAccessPolicy.mockReset(); + inboundMocks.buildEnvelope.mockReset().mockImplementation(({ body }: { body: string }) => body); + inboundMocks.resolveChannelInboundRouteEnvelope + .mockReset() + .mockImplementation(({ accountId }: { accountId: string }) => ({ + route: { + agentId: "agent-1", + accountId, + sessionKey: "session-1", + }, + buildEnvelope: inboundMocks.buildEnvelope, + })); }); function createInboundClassificationHarness() { - const resolveAgentRoute = vi.fn(() => ({ - agentId: "agent-1", - accountId: "work", - sessionKey: "session-1", - })); const buildContext = vi.fn((payload: unknown) => payload); const runTurn = vi.fn(); const core = { logging: { shouldLogVerbose: () => false }, channel: { - routing: { resolveAgentRoute }, - session: { - resolveStorePath: () => "/tmp/openclaw-googlechat-test", - readSessionUpdatedAt: () => undefined, - recordInboundSession: vi.fn(), - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - dispatchReplyWithBufferedBlockDispatcher: vi.fn(), - }, inbound: { buildContext, run: runTurn }, }, } as unknown as GoogleChatCoreRuntime; - return { buildContext, core, resolveAgentRoute, runTurn }; + return { buildContext, core, runTurn }; } async function processGoogleChatTestEvent(params: { @@ -177,7 +185,7 @@ describe("googlechat monitor inbound space classification", () => { ] as const; it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => { - const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness(); + const { buildContext, core, runTurn } = createInboundClassificationHarness(); const account = { accountId: "work", config: {}, @@ -214,7 +222,7 @@ describe("googlechat monitor inbound space classification", () => { expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith( expect.objectContaining({ isGroup }), ); - expect(resolveAgentRoute).toHaveBeenCalledWith({ + expect(inboundMocks.resolveChannelInboundRouteEnvelope).toHaveBeenCalledWith({ cfg: {}, channel: "googlechat", accountId: "work", @@ -509,7 +517,6 @@ describe("googlechat monitor direct messages", () => { it("drops invalid event timestamps from inbound runtime payloads", async () => { const runTurn = vi.fn(); const buildContext = vi.fn((payload: unknown) => payload); - const formatAgentEnvelope = vi.fn(({ body }: { body: string }) => body); const core = { logging: { shouldLogVerbose: () => false }, channel: { @@ -527,7 +534,7 @@ describe("googlechat monitor direct messages", () => { }, reply: { resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope, + formatAgentEnvelope: ({ body }: { body: string }) => body, dispatchReplyWithBufferedBlockDispatcher: vi.fn(), }, inbound: { buildContext, run: runTurn }, @@ -569,7 +576,7 @@ describe("googlechat monitor direct messages", () => { mediaMaxMb: 10, }); - expect(formatAgentEnvelope).toHaveBeenCalledWith( + expect(inboundMocks.buildEnvelope).toHaveBeenCalledWith( expect.objectContaining({ timestamp: undefined }), ); expect(buildContext).toHaveBeenCalledWith(expect.objectContaining({ timestamp: undefined })); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 4bc8396a57fc..70e6fa48efe6 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,15 +1,13 @@ // Googlechat plugin module implements monitor behavior. import { recordChannelBotPairLoopAndCheckSuppression, + resolveChannelInboundRouteEnvelope, type ChannelBotLoopProtectionFacts, } from "openclaw/plugin-sdk/channel-inbound"; import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../runtime-api.js"; -import { - resolveInboundRouteEnvelopeBuilderWithRuntime, - resolveWebhookPath, -} from "../runtime-api.js"; +import { resolveWebhookPath } from "../runtime-api.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js"; @@ -252,7 +250,7 @@ async function processMessageWithPipeline(params: { return; } - const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ + const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg: config, channel: "googlechat", accountId: account.accountId, @@ -260,8 +258,6 @@ async function processMessageWithPipeline(params: { kind: isGroup ? ("group" as const) : ("direct" as const), id: spaceId, }, - runtime: core.channel, - sessionStore: config.session?.store, }); let mediaPath: string | undefined; @@ -279,7 +275,7 @@ async function processMessageWithPipeline(params: { ? space.displayName || `space:${spaceId}` : senderName || `user:${senderId}`; const timestampMs = resolveGoogleChatTimestampMs(event.eventTime); - const { storePath, body } = buildEnvelope({ + const body = buildEnvelope({ channel: "Google Chat", from: fromLabel, timestamp: timestampMs, @@ -399,13 +395,8 @@ async function processMessageWithPipeline(params: { cfg: config, channel: "googlechat", accountId: route.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { durable: (payload, info) => resolveGoogleChatDurableReplyOptions({ diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 0eb93d4fa265..fbcd8cf1b193 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -23,7 +23,6 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "openclaw/plugin-sdk/conversation-runtime"; -import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime"; import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; @@ -1453,12 +1452,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P raw: decision, }), resolveTurn: () => ({ + cfg, channel: "imessage", accountId: decision.route.accountId, - routeSessionKey: decision.route.sessionKey, - storePath, + route: { + agentId: decision.route.agentId, + sessionKey: decision.route.sessionKey, + }, ctxPayload, - recordInboundSession, record: { updateLastRoute: !decision.isGroup && updateTarget diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 6eaeba1699ae..755011afb938 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,5 +1,9 @@ // Irc plugin module implements inbound behavior. -import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; +import { + buildChannelInboundEventContext, + logInboundDrop, + resolveChannelInboundRouteEnvelope, +} from "openclaw/plugin-sdk/channel-inbound"; import { channelIngressRoutes, createChannelIngressResolver, @@ -9,7 +13,6 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; -import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { deliverFormattedTextWithAttachments, type OutboundReplyPayload, @@ -371,7 +374,7 @@ export async function handleIrcInbound(params: { ? message.target : `#${message.target}`; const peerId = message.isGroup ? channelTarget : message.senderNick; - const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ + const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, @@ -379,12 +382,10 @@ export async function handleIrcInbound(params: { kind: message.isGroup ? "group" : "direct", id: peerId, }, - runtime: core.channel, - sessionStore: config.session?.store, }); const fromLabel = message.isGroup ? message.target : senderDisplay; - const { storePath, body } = buildEnvelope({ + const body = buildEnvelope({ channel: "IRC", from: fromLabel, timestamp: message.timestamp, @@ -394,41 +395,44 @@ export async function handleIrcInbound(params: { const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt); const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config); - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: body, - RawBody: rawBody, - CommandBody: rawBody, - From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`, - To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: message.isGroup ? "group" : "direct", - ConversationLabel: fromLabel, - SenderName: message.senderNick || undefined, - SenderId: senderDisplay, - GroupSubject: message.isGroup ? message.target : undefined, - GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined, - Provider: CHANNEL_ID, - Surface: CHANNEL_ID, - WasMentioned: message.isGroup ? wasMentioned : undefined, - MessageSid: message.messageId, - Timestamp: message.timestamp, - OriginatingChannel: CHANNEL_ID, - OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`, - CommandAuthorized: commandAuthorized, + const ctxPayload = buildChannelInboundEventContext({ + channel: CHANNEL_ID, + accountId: route.accountId, + messageId: message.messageId, + timestamp: message.timestamp, + from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`, + sender: { id: senderDisplay, name: message.senderNick || undefined }, + conversation: { + kind: message.isGroup ? "group" : "direct", + id: peerId, + label: fromLabel, + }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: route.sessionKey, + }, + reply: { + to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`, + originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`, + }, + message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody }, + access: { + commands: { authorized: commandAuthorized }, + mentions: { canDetectMention: message.isGroup, wasMentioned }, + }, + extra: { + GroupSubject: message.isGroup ? message.target : undefined, + GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined, + }, }); - await core.channel.inbound.dispatchReply({ + await core.channel.inbound.dispatch({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { deliver: async (payload) => { await deliverIrcReply({ diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index b4532b85f345..249360caf1d6 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -191,13 +191,8 @@ export async function monitorLineProvider( cfg: config, channel: "line", accountId: route.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath: ctx.turn.storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, record: ctx.turn.record, replyPipeline: {}, ...(deliveryControl.abortSignal diff --git a/extensions/matrix/src/delivery-trace.test.ts b/extensions/matrix/src/delivery-trace.test.ts index 67dd2dc4722c..c8e515b775fd 100644 --- a/extensions/matrix/src/delivery-trace.test.ts +++ b/extensions/matrix/src/delivery-trace.test.ts @@ -161,7 +161,7 @@ async function setupMatrixTrace(recorder: WireRecorder) { } }; - // The scripted steps stand in for the model run: dispatchReplyFromConfig + // The scripted steps stand in for the model run: dispatchInboundMessage // stays pending until the script's final/cancel step settles it, so the // handler's post-dispatch flow (including the finally-block draft abandon // path) runs exactly where the real run would settle. @@ -188,7 +188,7 @@ async function setupMatrixTrace(recorder: WireRecorder) { markRunComplete: () => {}, }; }, - dispatchReplyFromConfig: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => { + dispatchInboundMessage: (async (args: { replyOptions?: MatrixTraceReplyOptions }) => { capturedReplyOptions = args?.replyOptions; notifyCaptured(); const result = await runGate; diff --git a/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts b/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts index 1e75747f8838..e7c79cb0254f 100644 --- a/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.audio-preflight.test.ts @@ -42,7 +42,6 @@ function createAudioPreflightHarness( matchedBy: "binding.account", }), resolveStorePath: () => "/tmp/openclaw-test-session.json", - readSessionUpdatedAt: () => 123, getRoomInfo: async () => ({ name: "Audio Room", canonicalAlias: "#audio:example.org", diff --git a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts index b4cc44fcc9c2..667175c40c13 100644 --- a/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.group-history.test.ts @@ -110,7 +110,7 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown groupPolicy: "open", isDirectMessage: false, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -119,24 +119,17 @@ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown }) => { capturedOnError = params?.onError; return { - dispatcher: {}, + dispatcher: { + markComplete: () => {}, + waitForIdle: async () => { + capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" }); + }, + }, replyOptions: {}, markDispatchIdle: () => {}, markRunComplete: () => {}, }; }, - withReplyDispatcher: async (params: { - dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => { - const result = await params.run(); - capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" }); - params.dispatcher.markComplete?.(); - await params.dispatcher.waitForIdle?.(); - await params.onSettled?.(); - return result; - }, }); } @@ -173,7 +166,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { groupPolicy: "open", isDirectMessage: false, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -200,7 +193,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { isDirectMessage: false, threadReplies: "off", finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -233,7 +226,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { isDirectMessage: false, threadReplies: "always", finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -267,7 +260,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { isDirectMessage: false, finalizeInboundContext, resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)), - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -313,7 +306,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { groupPolicy: "open", isDirectMessage: false, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -341,7 +334,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { groupPolicy: "open", isDirectMessage: false, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -371,7 +364,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { return "@bot:example.org"; }, }, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -394,7 +387,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { historyLimit: 20, isDirectMessage: true, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -428,7 +421,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { historyLimit: 20, isDirectMessage: true, getMemberDisplayName, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -458,7 +451,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { groupPolicy: "open", isDirectMessage: false, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -520,7 +513,7 @@ describe("matrix group chat history — scenario 1: basic accumulation", () => { getRelations, }, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -560,7 +553,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () = let firstDispatchStarted = false; const finalizeInboundContext = vi.fn((ctx: unknown) => ctx); - const dispatchReplyFromConfig = vi.fn(async () => { + const dispatchInboundMessage = vi.fn(async () => { if (!firstDispatchStarted) { firstDispatchStarted = true; await new Promise((resolve) => { @@ -575,7 +568,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () = groupPolicy: "open", isDirectMessage: false, finalizeInboundContext, - dispatchReplyFromConfig, + dispatchInboundMessage, }); // Step 1: trigger msg A — don't await, let it block in dispatch @@ -687,7 +680,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () = isDirectMessage: false, getMemberDisplayName, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), @@ -739,7 +732,7 @@ describe("matrix group chat history — scenario 2: race condition safety", () = getEvent: async () => ({ sender: "@bot:example.org" }), }, finalizeInboundContext, - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, }), diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index 9f80c0493b9a..a29d75cc7909 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -42,7 +42,6 @@ function createMediaFailureHarness() { matchedBy: "binding.account", }), resolveStorePath: () => "/tmp/openclaw-test-session.json", - readSessionUpdatedAt: () => 123, getRoomInfo: async () => ({ name: "Media Room", canonicalAlias: "#media:example.org", diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index b9146d34a54c..62d2c67bc6f1 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -14,6 +14,15 @@ import { createMatrixRoomMessageHandler } from "./handler.js"; import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js"; type MatrixMonitorHandlerParams = Parameters[0]; +type MatrixDispatchInboundMessage = (params: { + ctx: unknown; + cfg: unknown; + dispatcher: unknown; + replyOptions?: Record; +}) => Promise<{ + queuedFinal: boolean; + counts: { final: number; block: number; tool: number }; +}>; const DEFAULT_ROUTE = { agentId: "ops", @@ -68,9 +77,7 @@ type MatrixHandlerTestHarnessOptions = { resolveMarkdownTableMode?: () => string; resolveAgentRoute?: () => typeof DEFAULT_ROUTE; resolveStorePath?: () => string; - readSessionUpdatedAt?: () => number | undefined; recordInboundSession?: (...args: unknown[]) => Promise; - resolveEnvelopeFormatOptions?: () => Record; formatAgentEnvelope?: ({ body }: { body: string }) => string; finalizeInboundContext?: (ctx: unknown) => unknown; createReplyDispatcherWithTyping?: (params?: { @@ -82,19 +89,8 @@ type MatrixHandlerTestHarnessOptions = { markRunComplete: () => void; }; resolveHumanDelayConfig?: () => undefined; - dispatchReplyFromConfig?: () => Promise<{ - queuedFinal: boolean; - counts: { final: number; block: number; tool: number }; - }>; + dispatchInboundMessage?: MatrixDispatchInboundMessage; runPrepared?: MatrixRunPreparedMock; - withReplyDispatcher?: (params: { - dispatcher: { - markComplete?: () => void; - waitForIdle?: () => Promise; - }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => Promise; inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"]; shouldAckReaction?: () => boolean; enqueueSystemEvent?: (...args: unknown[]) => void; @@ -104,10 +100,7 @@ type MatrixHandlerTestHarnessOptions = { }; type MatrixHandlerTestHarness = { - dispatchReplyFromConfig: () => Promise<{ - queuedFinal: boolean; - counts: { final: number; block: number; tool: number }; - }>; + dispatchInboundMessage: MatrixDispatchInboundMessage; enqueueSystemEvent: (...args: unknown[]) => void; finalizeInboundContext: (ctx: unknown) => unknown; handler: ReturnType; @@ -137,12 +130,55 @@ export function createMatrixHandlerTestHarness( ? finalizeCoreInboundContext(ctx as Record) : ctx, ); - const dispatchReplyFromConfig = - options.dispatchReplyFromConfig ?? + const dispatchInboundMessage = + options.dispatchInboundMessage ?? (async () => ({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 }, })); + const createReplyDispatcherWithTyping = + options.createReplyDispatcherWithTyping ?? + (() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => {}, + markRunComplete: () => {}, + })); + const dispatchInboundMessageWithBufferedDispatcher = (async ({ + ctx, + cfg, + dispatcherOptions, + replyOptions, + }: { + ctx: unknown; + cfg: unknown; + dispatcherOptions: Record; + replyOptions?: Record; + }) => { + const prepared = createReplyDispatcherWithTyping(dispatcherOptions); + try { + return await dispatchInboundMessage({ + ctx, + cfg, + dispatcher: prepared.dispatcher, + replyOptions: { ...replyOptions, ...prepared.replyOptions }, + } as never); + } finally { + const dispatcher = prepared.dispatcher as { + markComplete?: () => void; + waitForIdle?: () => Promise; + }; + dispatcher.markComplete?.(); + await dispatcher.waitForIdle?.(); + await (dispatcherOptions.onSettled as (() => Promise | void) | undefined)?.(); + prepared.markRunComplete(); + prepared.markDispatchIdle(); + } + }) as NonNullable; + const createChannelInboundEnvelopeBuilder = (() => (input: { body: string }) => + (options.formatAgentEnvelope ?? (({ body }: { body: string }) => body))({ + body: input.body, + })) as NonNullable; const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn(); const runPrepared = options.runPrepared ?? @@ -184,7 +220,16 @@ export function createMatrixHandlerTestHarness( : (preflightResult ?? {}); const turn = await params.adapter.resolveTurn(input, eventClass, preflight); if ("runDispatch" in turn) { - return await runPrepared(turn); + const preparedTurn = + "route" in turn + ? ({ + ...turn, + routeSessionKey: turn.route.sessionKey, + storePath: "/tmp/matrix-sessions.json", + recordInboundSession, + } as PreparedInboundReply) + : turn; + return await runPrepared(preparedTurn); } throw new Error("matrix test helper only supports prepared turn dispatch"); }, @@ -233,47 +278,19 @@ export function createMatrixHandlerTestHarness( buildMentionRegexes: () => options.mentionRegexes ?? [], }, session: { - resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"), - readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined), recordInboundSession, }, reply: { - resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})), - formatAgentEnvelope: - options.formatAgentEnvelope ?? (({ body }: { body: string }) => body), - finalizeInboundContext, - createReplyDispatcherWithTyping: - options.createReplyDispatcherWithTyping ?? - (() => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: () => {}, - markRunComplete: () => {}, - })), - resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), - dispatchReplyFromConfig, - withReplyDispatcher: - options.withReplyDispatcher ?? - (async (params: { - dispatcher: { - markComplete?: () => void; - waitForIdle?: () => Promise; - }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => { - const { dispatcher, run: runLocal, onSettled } = params; - try { - return await runLocal(); - } finally { - dispatcher.markComplete?.(); - try { - await dispatcher.waitForIdle?.(); - } finally { - await onSettled?.(); - } - } - }), + settleReplyDispatcher: async ({ + dispatcher, + onSettled, + }: Parameters< + MatrixMonitorHandlerParams["core"]["channel"]["reply"]["settleReplyDispatcher"] + >[0]) => { + dispatcher.markComplete?.(); + await dispatcher.waitForIdle?.(); + await onSettled?.(); + }, }, inbound: { run, @@ -332,11 +349,16 @@ export function createMatrixHandlerTestHarness( getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"), needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false, resolveLiveUserAllowlist: options.resolveLiveUserAllowlist, + resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"), + createChannelInboundEnvelopeBuilder, + finalizeInboundContext, + resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), + dispatchInboundMessageWithBufferedDispatcher, historyLimit: options.historyLimit ?? 0, }); return { - dispatchReplyFromConfig, + dispatchInboundMessage, enqueueSystemEvent, finalizeInboundContext, handler, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 29713aae400d..b34357523391 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -11,7 +11,6 @@ import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; -import { createMatrixRoomMessageHandler } from "./handler.js"; import { createMatrixHandlerTestHarness, createMatrixReactionEvent, @@ -527,6 +526,10 @@ describe("matrix monitor handler pairing account scope", () => { getMemberDisplayName: async () => "sender", dropPreStartupMessages: true, needsRoomAliasesForConfig: false, + dispatchInboundMessage: async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }), }); await handler( @@ -580,12 +583,12 @@ describe("matrix monitor handler pairing account scope", () => { }); it("does not enqueue delivered text messages into system events", async () => { - const dispatchReplyFromConfig = vi.fn(async () => ({ + const dispatchInboundMessage = vi.fn(async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, })); const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({ - dispatchReplyFromConfig, + dispatchInboundMessage, isDirectMessage: true, getMemberDisplayName: async () => "sender", }); @@ -599,7 +602,7 @@ describe("matrix monitor handler pairing account scope", () => { }), ); - expect(dispatchReplyFromConfig).toHaveBeenCalled(); + expect(dispatchInboundMessage).toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); @@ -1275,7 +1278,7 @@ describe("matrix monitor handler pairing account scope", () => { resolveNotice = resolve; }); const sendNotice = vi.fn(() => noticeSent); - const dispatchReplyFromConfig = vi.fn(async () => ({ + const dispatchInboundMessage = vi.fn(async () => ({ counts: { block: 0, final: 0, tool: 0 }, queuedFinal: false, })); @@ -1289,7 +1292,7 @@ describe("matrix monitor handler pairing account scope", () => { }); const { handler } = createMatrixHandlerTestHarness({ - dispatchReplyFromConfig, + dispatchInboundMessage, isDirectMessage: true, resolveStorePath: () => storePath, client: { @@ -1308,12 +1311,12 @@ describe("matrix monitor handler pairing account scope", () => { await vi.waitFor(() => { expect(sendNotice).toHaveBeenCalledTimes(1); }); - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessage).not.toHaveBeenCalled(); resolveNotice?.("$notice"); await handled; - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -1613,7 +1616,7 @@ describe("matrix monitor handler pairing account scope", () => { altAliases: ["#alt:example.org"], }), getMemberDisplayName: async () => "sender", - dispatchReplyFromConfig: async () => ({ + dispatchInboundMessage: async () => ({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 }, }), @@ -1755,121 +1758,13 @@ describe("matrix monitor handler pairing account scope", () => { it("does not enqueue system events for delivered text replies", async () => { const enqueueSystemEvent = vi.fn(); - - const handler = createMatrixRoomMessageHandler({ - client: { - getUserId: async () => "@bot:example.org", - } as never, - core: { - channel: { - pairing: { - readAllowFromStore: async () => [] as string[], - upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), - buildPairingReply: () => "pairing", - }, - commands: { - shouldHandleTextCommands: () => false, - }, - text: { - hasControlCommand: () => false, - resolveMarkdownTableMode: () => "preserve", - }, - routing: { - resolveAgentRoute: () => ({ - agentId: "ops", - channel: "matrix", - accountId: "ops", - sessionKey: "agent:ops:main", - mainSessionKey: "agent:ops:main", - matchedBy: "binding.account", - }), - }, - mentions: { - buildMentionRegexes: () => [], - }, - session: { - resolveStorePath: () => "/tmp/session-store", - readSessionUpdatedAt: () => undefined, - recordInboundSession: vi.fn(async () => {}), - }, - reply: { - resolveEnvelopeFormatOptions: () => ({}), - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext: (ctx: unknown) => ctx, - createReplyDispatcherWithTyping: () => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: () => {}, - markRunComplete: () => {}, - }), - resolveHumanDelayConfig: () => undefined, - dispatchReplyFromConfig: async () => ({ - queuedFinal: true, - counts: { final: 1, block: 0, tool: 0 }, - }), - withReplyDispatcher: async ({ - dispatcher, - run, - onSettled, - }: { - dispatcher: { - markComplete?: () => void; - waitForIdle?: () => Promise; - }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => { - try { - return await run(); - } finally { - dispatcher.markComplete?.(); - try { - await dispatcher.waitForIdle?.(); - } finally { - await onSettled?.(); - } - } - }, - }, - reactions: { - shouldAckReaction: () => false, - }, - }, - system: { - enqueueSystemEvent, - }, - } as never, - cfg: {} as never, - accountId: "ops", - runtime: { - error: () => {}, - } as never, - logger: { - info: () => {}, - warn: () => {}, - } as never, - logVerboseMessage: () => {}, - allowFrom: [], - groupPolicy: "open", - replyToMode: "off", - threadReplies: "inbound", - streaming: "off", - previewToolProgressEnabled: false, - blockStreamingEnabled: false, - dmEnabled: true, - dmPolicy: "open", - textLimit: 8_000, - mediaMaxBytes: 10_000_000, - historyLimit: 0, - startupMs: 0, - startupGraceMs: 0, - directTracker: { - isDirectMessage: async () => false, - }, - dropPreStartupMessages: true, - getRoomInfo: async () => ({ altAliases: [] }), - getMemberDisplayName: async () => "sender", - needsRoomAliasesForConfig: false, + const { handler } = createMatrixHandlerTestHarness({ + enqueueSystemEvent, + isDirectMessage: false, + dispatchInboundMessage: async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }), }); await handler( @@ -2177,7 +2072,7 @@ describe("matrix monitor handler pairing account scope", () => { describe("matrix monitor handler live allowlist reload", () => { type MatrixHandler = ReturnType["handler"]; - const createDispatchReplyFromConfig = () => + const createDispatchInboundMessage = () => vi.fn(async () => ({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 }, @@ -2222,7 +2117,7 @@ describe("matrix monitor handler live allowlist reload", () => { ).length; it("accepts a DM sender added to live dm.allowFrom", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const cfg = { channels: { matrix: { @@ -2236,7 +2131,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: [], allowFromResolvedEntries: [], - dispatchReplyFromConfig, + dispatchInboundMessage, }); await sendLiveAllowlistMessage(handler, { @@ -2244,7 +2139,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessage).not.toHaveBeenCalled(); cfg.channels.matrix.dm.allowFrom = ["@alice:example.org"]; await sendLiveAllowlistMessage(handler, { @@ -2253,11 +2148,11 @@ describe("matrix monitor handler live allowlist reload", () => { body: "hello again", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("blocks a DM sender removed from live dm.allowFrom", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const cfg = { channels: { matrix: { @@ -2271,7 +2166,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: ["@alice:example.org"], allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }], - dispatchReplyFromConfig, + dispatchInboundMessage, }); await sendLiveAllowlistMessage(handler, { @@ -2279,7 +2174,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); cfg.channels.matrix.dm.allowFrom = []; await sendLiveAllowlistMessage(handler, { @@ -2288,11 +2183,11 @@ describe("matrix monitor handler live allowlist reload", () => { body: "hello again", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("blocks a DM sender after live wildcard removal", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const cfg = { channels: { matrix: { @@ -2306,7 +2201,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: ["*"], allowFromResolvedEntries: [], - dispatchReplyFromConfig, + dispatchInboundMessage, }); await sendLiveAllowlistMessage(handler, { @@ -2314,7 +2209,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); cfg.channels.matrix.dm.allowFrom = []; await sendLiveAllowlistMessage(handler, { @@ -2323,11 +2218,11 @@ describe("matrix monitor handler live allowlist reload", () => { body: "hello again", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("uses account-scoped live dm.allowFrom overrides", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const cfg = { channels: { matrix: { @@ -2347,7 +2242,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: ["@alice:example.org"], allowFromResolvedEntries: [{ input: "@alice:example.org", id: "@alice:example.org" }], - dispatchReplyFromConfig, + dispatchInboundMessage, }); await sendLiveAllowlistMessage(handler, { @@ -2355,7 +2250,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); cfg.channels.matrix.accounts.ops.dm.allowFrom = []; await sendLiveAllowlistMessage(handler, { @@ -2364,11 +2259,11 @@ describe("matrix monitor handler live allowlist reload", () => { body: "hello again", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("keeps startup-resolved display names only while the raw input remains configured", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const cfg = { channels: { matrix: { @@ -2383,7 +2278,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: ["@alice:example.org"], allowFromResolvedEntries: [{ input: "Alice", id: "@alice:example.org" }], - dispatchReplyFromConfig, + dispatchInboundMessage, }); await sendLiveAllowlistMessage(handler, { @@ -2391,7 +2286,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); cfg.channels.matrix.dm.allowFrom = []; await sendLiveAllowlistMessage(handler, { @@ -2400,11 +2295,11 @@ describe("matrix monitor handler live allowlist reload", () => { body: "hello again", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("accepts a DM sender added as a live-resolved display name", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const resolveLiveUserAllowlist = vi.fn( async (params: { entries?: ReadonlyArray }) => { const entries = (params.entries ?? []).map(String); @@ -2425,7 +2320,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: [], allowFromResolvedEntries: [], - dispatchReplyFromConfig, + dispatchInboundMessage, resolveLiveUserAllowlist, }); @@ -2434,7 +2329,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessage).not.toHaveBeenCalled(); cfg.channels.matrix.dm.allowFrom = ["Alice"]; await sendLiveAllowlistMessage(handler, { @@ -2449,11 +2344,11 @@ describe("matrix monitor handler live allowlist reload", () => { ); expect(liveAllowlistRequest.accountId).toBe("ops"); expect(liveAllowlistRequest.entries).toEqual(["Alice"]); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("refreshes cached live display-name allowlists when name matching is disabled", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) => isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [], ); @@ -2471,7 +2366,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: [], allowFromResolvedEntries: [], - dispatchReplyFromConfig, + dispatchInboundMessage, resolveLiveUserAllowlist, }); @@ -2480,7 +2375,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); cfg.channels.matrix.dangerouslyAllowNameMatching = false; await sendLiveAllowlistMessage(handler, { @@ -2492,11 +2387,11 @@ describe("matrix monitor handler live allowlist reload", () => { expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe( 2, ); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("refreshes cached live display-name allowlists when name matching is enabled", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const resolveLiveUserAllowlist = vi.fn(async (params: LiveNameMatchingResolveParams) => isLiveNameMatchingEnabled(params.cfg) ? ["@alice:example.org"] : [], ); @@ -2514,7 +2409,7 @@ describe("matrix monitor handler live allowlist reload", () => { isDirectMessage: true, allowFrom: [], allowFromResolvedEntries: [], - dispatchReplyFromConfig, + dispatchInboundMessage, resolveLiveUserAllowlist, }); @@ -2523,7 +2418,7 @@ describe("matrix monitor handler live allowlist reload", () => { sender: "@alice:example.org", body: "hello", }); - expect(dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(dispatchInboundMessage).not.toHaveBeenCalled(); cfg.channels.matrix.dangerouslyAllowNameMatching = true; await sendLiveAllowlistMessage(handler, { @@ -2535,11 +2430,11 @@ describe("matrix monitor handler live allowlist reload", () => { expect(countLiveAllowlistCallsForEntries(resolveLiveUserAllowlist.mock.calls, ["Alice"])).toBe( 2, ); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); it("blocks a room sender removed from live groupAllowFrom while the group list remains configured", async () => { - const dispatchReplyFromConfig = createDispatchReplyFromConfig(); + const dispatchInboundMessage = createDispatchInboundMessage(); const cfg = { channels: { matrix: { @@ -2557,7 +2452,7 @@ describe("matrix monitor handler live allowlist reload", () => { { input: "@alice:example.org", id: "@alice:example.org" }, { input: "@bob:example.org", id: "@bob:example.org" }, ], - dispatchReplyFromConfig, + dispatchInboundMessage, }); await sendLiveAllowlistMessage(handler, { @@ -2567,7 +2462,7 @@ describe("matrix monitor handler live allowlist reload", () => { body: "@room hello", mentions: { room: true }, }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); cfg.channels.matrix.groupAllowFrom = ["@bob:example.org"]; await sendLiveAllowlistMessage(handler, { @@ -2578,7 +2473,7 @@ describe("matrix monitor handler live allowlist reload", () => { mentions: { room: true }, }); - expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(dispatchInboundMessage).toHaveBeenCalledTimes(1); }); }); @@ -2589,7 +2484,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { }; const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ inboundDeduper, - dispatchReplyFromConfig: vi.fn(async () => ({ + dispatchInboundMessage: vi.fn(async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, })), @@ -2631,7 +2526,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { const recordInboundSession = vi.fn(async () => { callOrder.push("record"); }); - const dispatchReplyFromConfig = vi.fn(async () => { + const dispatchInboundMessage = vi.fn(async () => { callOrder.push("dispatch"); return { queuedFinal: true, @@ -2641,7 +2536,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { const { handler } = createMatrixHandlerTestHarness({ inboundDeduper, recordInboundSession, - dispatchReplyFromConfig, + dispatchInboundMessage, createReplyDispatcherWithTyping: () => ({ dispatcher: { markComplete: () => { @@ -2673,9 +2568,9 @@ describe("matrix monitor handler durable inbound dedupe", () => { "claim", "record", "dispatch", - "run-complete", "mark-complete", "wait-for-idle", + "run-complete", "dispatch-idle", "commit", ]); @@ -2742,7 +2637,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { recordInboundSession: vi.fn(async () => { throw new Error("disk failed"); }), - dispatchReplyFromConfig: vi.fn(async () => ({ + dispatchInboundMessage: vi.fn(async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, })), @@ -2776,7 +2671,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { const { handler } = createMatrixHandlerTestHarness({ inboundDeduper, runtime: runtime as never, - dispatchReplyFromConfig: vi.fn(async () => ({ + dispatchInboundMessage: vi.fn(async () => ({ queuedFinal: true, counts: { final: 1, block: 0, tool: 0 }, })), @@ -2823,7 +2718,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { const { handler } = createMatrixHandlerTestHarness({ inboundDeduper, runtime: runtime as never, - dispatchReplyFromConfig: vi.fn(async () => ({ + dispatchInboundMessage: vi.fn(async () => ({ queuedFinal: false, counts: { final: 0, @@ -2881,7 +2776,7 @@ describe("matrix monitor handler durable inbound dedupe", () => { recordInboundSession: vi.fn(async () => { callOrder.push("record"); }), - dispatchReplyFromConfig: vi.fn(async () => { + dispatchInboundMessage: vi.fn(async () => { callOrder.push("dispatch"); return { queuedFinal: false, @@ -3031,22 +2926,13 @@ describe("matrix monitor handler draft streaming", () => { markRunComplete: () => {}, }; }, - dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { + dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { capturedReplyOpts = args?.replyOptions; notifyCaptured(); // Block until the test is done exercising callbacks. await runGate; return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } }; }) as never, - withReplyDispatcher: async (params: { - dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => { - const result = await params.run(); - await params.onSettled?.(); - return result; - }, }); const dispatch = async () => { @@ -4098,7 +3984,7 @@ describe("matrix monitor handler draft streaming", () => { markDispatchIdle: () => {}, markRunComplete: () => {}, }), - dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { + dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { capturedReplyOpts = args?.replyOptions; // Simulate streaming then model error. capturedReplyOpts?.onPartialReply?.({ text: "partial" }); @@ -4107,15 +3993,6 @@ describe("matrix monitor handler draft streaming", () => { }); throw new Error("model timeout"); }) as never, - withReplyDispatcher: async (params: { - dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => { - const result = await params.run(); - await params.onSettled?.(); - return result; - }, }); // Handler should not throw (outer catch absorbs it). @@ -4156,7 +4033,7 @@ describe("matrix monitor handler draft streaming", () => { markDispatchIdle: () => {}, markRunComplete: () => {}, }), - dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { + dispatchInboundMessage: vi.fn(async (args: { replyOptions?: ReplyOpts }) => { capturedReplyOpts = args?.replyOptions; capturedReplyOpts?.onPartialReply?.({ text: "partial" }); await vi.waitFor(() => { @@ -4164,15 +4041,6 @@ describe("matrix monitor handler draft streaming", () => { }); throw new Error("model timeout"); }) as never, - withReplyDispatcher: async (params: { - dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise }; - run: () => Promise; - onSettled?: () => void | Promise; - }) => { - const result = await params.run(); - await params.onSettled?.(); - return result; - }, }); await handler( @@ -4448,7 +4316,7 @@ describe("matrix monitor handler block streaming config", () => { const { handler } = createMatrixHandlerTestHarness({ streaming: "off", - dispatchReplyFromConfig: vi.fn( + dispatchInboundMessage: vi.fn( async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; @@ -4469,7 +4337,7 @@ describe("matrix monitor handler block streaming config", () => { const { handler } = createMatrixHandlerTestHarness({ streaming: "partial", - dispatchReplyFromConfig: vi.fn( + dispatchInboundMessage: vi.fn( async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; @@ -4490,7 +4358,7 @@ describe("matrix monitor handler block streaming config", () => { const { handler } = createMatrixHandlerTestHarness({ streaming: "quiet", - dispatchReplyFromConfig: vi.fn( + dispatchInboundMessage: vi.fn( async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; @@ -4512,7 +4380,7 @@ describe("matrix monitor handler block streaming config", () => { const { handler } = createMatrixHandlerTestHarness({ streaming: "partial", blockStreamingEnabled: true, - dispatchReplyFromConfig: vi.fn( + dispatchInboundMessage: vi.fn( async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; @@ -4534,7 +4402,7 @@ describe("matrix monitor handler block streaming config", () => { const { handler } = createMatrixHandlerTestHarness({ streaming: "off", blockStreamingEnabled: true, - dispatchReplyFromConfig: vi.fn( + dispatchInboundMessage: vi.fn( async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => { capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming; return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 6604b5cfa73b..c7c5e5ec6d49 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,28 +1,27 @@ -// Matrix plugin module implements handler behavior. +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { buildChannelInboundEventContext, + createChannelInboundEnvelopeBuilder, + hasFinalInboundReplyDispatch, resolveInboundMentionDecision, toInboundMediaFacts, + type ChannelBotLoopProtectionFacts, } from "openclaw/plugin-sdk/channel-inbound"; -import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound"; -import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound"; -import { - createPreviewMessageReceipt, - defineFinalizableLivePreviewAdapter, - deliverWithFinalizableLivePreviewAdapter, - type MessageReceipt, -} from "openclaw/plugin-sdk/channel-outbound"; import { type AgentPlanStep, buildChannelProgressDraftLineForEntry, - createChannelProgressDraftGate, type ChannelProgressDraftLine, + createChannelProgressDraftGate, + createPreviewMessageReceipt, + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, formatChannelProgressDraftLine, formatChannelProgressDraftText, isChannelProgressDraftWorkToolName, mergeChannelProgressDraftLine, normalizeChannelProgressDraftLineIdentity, resolveChannelProgressDraftMaxLines, + type MessageReceipt, } from "openclaw/plugin-sdk/channel-outbound"; import { evaluateSupplementalContextVisibility, @@ -41,10 +40,13 @@ import { buildTtsSupplementMediaPayload, getReplyPayloadTtsSupplement, } from "openclaw/plugin-sdk/reply-payload"; -import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime"; +import { + dispatchInboundMessageWithBufferedDispatcher, + type GetReplyOptions, +} from "openclaw/plugin-sdk/reply-runtime"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; -import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { @@ -228,6 +230,11 @@ type MatrixMonitorHandlerParams = { getMemberDisplayName: (roomId: string, userId: string) => Promise; needsRoomAliasesForConfig: boolean; resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist; + resolveStorePath?: typeof resolveStorePath; + createChannelInboundEnvelopeBuilder?: typeof createChannelInboundEnvelopeBuilder; + finalizeInboundContext?: (ctx: Record) => unknown; + resolveHumanDelayConfig?: typeof resolveHumanDelayConfig; + dispatchInboundMessageWithBufferedDispatcher?: typeof dispatchInboundMessageWithBufferedDispatcher; }; function resolveMatrixMentionPrecheckText(params: { @@ -472,6 +479,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam getMemberDisplayName, needsRoomAliasesForConfig, resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist, + resolveStorePath: resolveStorePathImpl = resolveStorePath, + createChannelInboundEnvelopeBuilder: + createChannelInboundEnvelopeBuilderImpl = createChannelInboundEnvelopeBuilder, + finalizeInboundContext, + resolveHumanDelayConfig: resolveHumanDelayConfigImpl = resolveHumanDelayConfig, + dispatchInboundMessageWithBufferedDispatcher: + dispatchInboundMessageWithBufferedDispatcherImpl = dispatchInboundMessageWithBufferedDispatcher, } = params; const contextVisibilityMode = resolveChannelContextVisibilityMode({ cfg, @@ -1536,14 +1550,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const roomName = roomInfo?.name; const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + const storePath = resolveStorePathImpl(cfg.session?.store, { agentId: _route.agentId, }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: _route.sessionKey, - }); + const buildEnvelope = createChannelInboundEnvelopeBuilderImpl({ cfg, route: _route }); const sharedDmNoticeSessionKey = threadTarget ? _route.mainSessionKey || _route.sessionKey : _route.sessionKey; @@ -1560,12 +1570,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage, }) : null; - const body = core.channel.reply.formatAgentEnvelope({ + const body = buildEnvelope({ channel: "Matrix", from: envelopeFrom, timestamp: eventTs ?? undefined, - previousTimestamp, - envelope: envelopeOptions, body: textWithId, }); const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt); @@ -1579,8 +1587,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam ); const ctxPayload = buildChannelInboundEventContext({ channel: "matrix", - finalize: core.channel.reply.finalizeInboundContext, contextVisibility: contextVisibilityMode, + finalize: finalizeInboundContext, supplemental: { quote: replyContext ? { @@ -2098,261 +2106,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam resetPreviewToolProgress(); }; - const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = - core.channel.reply.createReplyDispatcherWithTyping({ - ...prefixOptions, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId), - deliver: async (payload: ReplyPayload, info: { kind: string }) => { - if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) { - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const ttsSupplement = getReplyPayloadTtsSupplement(payload); - const fallbackPayload = - ttsSupplement && - ttsSupplement.visibleTextAlreadyDelivered !== true && - !payload.text?.trim() - ? { ...payload, text: ttsSupplement.spokenText } - : payload; + const dispatcherOptions = { + ...prefixOptions, + humanDelay: resolveHumanDelayConfigImpl(cfg, _route.agentId), + deliver: async (payload: ReplyPayload, info: { kind: string }) => { + if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) { + const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const ttsSupplement = getReplyPayloadTtsSupplement(payload); + const fallbackPayload = + ttsSupplement && + ttsSupplement.visibleTextAlreadyDelivered !== true && + !payload.text?.trim() + ? { ...payload, text: ttsSupplement.spokenText } + : payload; - if (draftConsumed) { - await draftStream.discardPending(); - await deliverMatrixReplies({ - cfg, - replies: [fallbackPayload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - replyToId: threadTarget ?? replyToEventId ?? undefined, - accountId: _route.accountId, - mediaLocalRoots, - tableMode, - }); - return; - } - - const payloadReplyToId = normalizeOptionalString(payload.replyToId); - const payloadReplyMismatch = - replyToMode !== "off" && - !threadTarget && - payloadReplyToId !== currentDraftReplyToId; - let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally(); - const canPotentiallyFinalizeDraft = - Boolean(payload.text?.trim()) && - !payload.isError && - !payloadReplyMismatch && - !mustDeliverFinalNormally; - - if (canPotentiallyFinalizeDraft) { - await draftStream.stop(); - mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally(); - } else { - await draftStream.discardPending(); - } - const draftEventId = draftStream.eventId(); - const draftFinalTextNeedsNormalMentionDelivery = - Boolean(draftEventId) && - typeof payload.text === "string" && - Boolean(payload.text.trim()) && - !payload.isError && - !payloadReplyMismatch && - !mustDeliverFinalNormally && - (await matrixTextWouldActivateMentions(client, payload.text)); - - if ( - draftEventId && - payload.text && - !payload.isError && - !hasMedia && - !payloadReplyMismatch && - !mustDeliverFinalNormally && - !draftFinalTextNeedsNormalMentionDelivery - ) { - const finalPreviewText = payload.text; - await deliverWithFinalizableLivePreviewAdapter< - ReplyPayload, - string, - { - text: string; - finalizeLive: boolean; - extraContent?: Record; - } - >({ - kind: "final", - payload, - adapter: defineFinalizableLivePreviewAdapter({ - draft: { - flush: async () => {}, - clear: async () => {}, - discardPending: async () => {}, - id: () => draftEventId, - }, - buildFinalEdit: () => ({ - text: finalPreviewText, - finalizeLive: !( - quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText) - ), - ...(quietDraftStreaming - ? { extraContent: buildMatrixFinalizedPreviewContent() } - : {}), - }), - editFinal: async (_draftEventId, edit) => { - if (edit.finalizeLive) { - if (!(await draftStream.finalizeLive())) { - throw new Error("Matrix draft live finalize failed"); - } - return; - } - const { editMessageMatrix } = await loadMatrixSendModule(); - await editMessageMatrix(roomId, _draftEventId, edit.text, { - client, - cfg, - threadId: threadTarget, - accountId: _route.accountId, - extraContent: edit.extraContent, - }); - }, - createPreviewReceipt: (id): MessageReceipt => - createPreviewMessageReceipt({ - id, - ...(threadTarget ? { threadId: threadTarget } : {}), - ...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}), - }), - logPreviewEditFailure: (err) => { - logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`); - }, - }), - deliverNormally: async () => { - await redactMatrixDraftEvent(client, roomId, draftEventId); - await deliverMatrixReplies({ - cfg, - replies: [fallbackPayload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - replyToId: threadTarget ?? replyToEventId ?? undefined, - accountId: _route.accountId, - mediaLocalRoots, - tableMode, - }); - }, - }); - draftConsumed = true; - } else if (draftEventId && hasMedia && !payloadReplyMismatch) { - let textEditOk = !mustDeliverFinalNormally; - const payloadText = payload.text ?? ttsSupplement?.spokenText; - const payloadTextMatchesDraft = - typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText); - const reusesDraftTextUnchanged = - typeof payloadText === "string" && - Boolean(payloadText.trim()) && - payloadTextMatchesDraft; - const mediaTextNeedsNormalMentionDelivery = - typeof payloadText === "string" && - Boolean(payloadText.trim()) && - (await matrixTextWouldActivateMentions(client, payloadText)); - const requiresFinalTextEdit = - quietDraftStreaming || - (typeof payloadText === "string" && !payloadTextMatchesDraft); - if (textEditOk && mediaTextNeedsNormalMentionDelivery) { - textEditOk = false; - } else if (textEditOk && payloadText && requiresFinalTextEdit) { - const { editMessageMatrix } = await loadMatrixSendModule(); - textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, { - client, - cfg, - threadId: threadTarget, - accountId: _route.accountId, - extraContent: quietDraftStreaming - ? buildMatrixFinalizedPreviewContent() - : undefined, - }).then( - () => true, - () => false, - ); - } else if (textEditOk && reusesDraftTextUnchanged) { - textEditOk = await draftStream.finalizeLive(); - } - const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk; - if (!reusesDraftAsFinalText) { - await redactMatrixDraftEvent(client, roomId, draftEventId); - } - const mediaPayload = - ttsSupplement && reusesDraftAsFinalText - ? buildTtsSupplementMediaPayload(payload) - : { - ...payload, - text: reusesDraftAsFinalText - ? undefined - : (payload.text ?? - (ttsSupplement?.visibleTextAlreadyDelivered === true - ? undefined - : ttsSupplement?.spokenText)), - }; - await deliverMatrixReplies({ - cfg, - replies: [mediaPayload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - replyToId: threadTarget ?? replyToEventId ?? undefined, - accountId: _route.accountId, - mediaLocalRoots, - tableMode, - }); - draftConsumed = true; - } else { - const draftRedacted = - Boolean(draftEventId) && - (payload.isError || - payloadReplyMismatch || - mustDeliverFinalNormally || - draftFinalTextNeedsNormalMentionDelivery); - if (draftRedacted && draftEventId) { - await redactMatrixDraftEvent(client, roomId, draftEventId); - } - const deliveredFallback = await deliverMatrixReplies({ - cfg, - replies: [fallbackPayload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - replyToId: threadTarget ?? replyToEventId ?? undefined, - accountId: _route.accountId, - mediaLocalRoots, - tableMode, - }); - if (draftRedacted || deliveredFallback) { - draftConsumed = true; - } - } - - if (info.kind === "block") { - draftConsumed = false; - advanceDraftBlockBoundary({ fallbackToLatestEnd: true }); - draftStream.reset(); - currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined; - updateDraftFromLatestFullText(); - - // Re-assert typing so the user still sees the indicator while - // the next block generates. - const { sendTypingMatrix } = await loadMatrixSendModule(); - await sendTypingMatrix(roomId, true, undefined, client).catch(() => {}); - } - } else { + if (draftConsumed) { + await draftStream.discardPending(); await deliverMatrixReplies({ cfg, - replies: [payload], + replies: [fallbackPayload], roomId, client, runtime, @@ -2364,22 +2136,255 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam mediaLocalRoots, tableMode, }); + return; } - }, - onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { - if (info.kind === "final") { - finalReplyDeliveryFailed = true; + + const payloadReplyToId = normalizeOptionalString(payload.replyToId); + const payloadReplyMismatch = + replyToMode !== "off" && !threadTarget && payloadReplyToId !== currentDraftReplyToId; + let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally(); + const canPotentiallyFinalizeDraft = + Boolean(payload.text?.trim()) && + !payload.isError && + !payloadReplyMismatch && + !mustDeliverFinalNormally; + + if (canPotentiallyFinalizeDraft) { + await draftStream.stop(); + mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally(); } else { - nonFinalReplyDeliveryFailed = true; + await draftStream.discardPending(); } + const draftEventId = draftStream.eventId(); + const draftFinalTextNeedsNormalMentionDelivery = + Boolean(draftEventId) && + typeof payload.text === "string" && + Boolean(payload.text.trim()) && + !payload.isError && + !payloadReplyMismatch && + !mustDeliverFinalNormally && + (await matrixTextWouldActivateMentions(client, payload.text)); + + if ( + draftEventId && + payload.text && + !payload.isError && + !hasMedia && + !payloadReplyMismatch && + !mustDeliverFinalNormally && + !draftFinalTextNeedsNormalMentionDelivery + ) { + const finalPreviewText = payload.text; + await deliverWithFinalizableLivePreviewAdapter< + ReplyPayload, + string, + { + text: string; + finalizeLive: boolean; + extraContent?: Record; + } + >({ + kind: "final", + payload, + adapter: defineFinalizableLivePreviewAdapter({ + draft: { + flush: async () => {}, + clear: async () => {}, + discardPending: async () => {}, + id: () => draftEventId, + }, + buildFinalEdit: () => ({ + text: finalPreviewText, + finalizeLive: !( + quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText) + ), + ...(quietDraftStreaming + ? { extraContent: buildMatrixFinalizedPreviewContent() } + : {}), + }), + editFinal: async (_draftEventId, edit) => { + if (edit.finalizeLive) { + if (!(await draftStream.finalizeLive())) { + throw new Error("Matrix draft live finalize failed"); + } + return; + } + const { editMessageMatrix } = await loadMatrixSendModule(); + await editMessageMatrix(roomId, _draftEventId, edit.text, { + client, + cfg, + threadId: threadTarget, + accountId: _route.accountId, + extraContent: edit.extraContent, + }); + }, + createPreviewReceipt: (id): MessageReceipt => + createPreviewMessageReceipt({ + id, + ...(threadTarget ? { threadId: threadTarget } : {}), + ...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}), + }), + logPreviewEditFailure: (err) => { + logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`); + }, + }), + deliverNormally: async () => { + await redactMatrixDraftEvent(client, roomId, draftEventId); + await deliverMatrixReplies({ + cfg, + replies: [fallbackPayload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, + accountId: _route.accountId, + mediaLocalRoots, + tableMode, + }); + }, + }); + draftConsumed = true; + } else if (draftEventId && hasMedia && !payloadReplyMismatch) { + let textEditOk = !mustDeliverFinalNormally; + const payloadText = payload.text ?? ttsSupplement?.spokenText; + const payloadTextMatchesDraft = + typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText); + const reusesDraftTextUnchanged = + typeof payloadText === "string" && + Boolean(payloadText.trim()) && + payloadTextMatchesDraft; + const mediaTextNeedsNormalMentionDelivery = + typeof payloadText === "string" && + Boolean(payloadText.trim()) && + (await matrixTextWouldActivateMentions(client, payloadText)); + const requiresFinalTextEdit = + quietDraftStreaming || + (typeof payloadText === "string" && !payloadTextMatchesDraft); + if (textEditOk && mediaTextNeedsNormalMentionDelivery) { + textEditOk = false; + } else if (textEditOk && payloadText && requiresFinalTextEdit) { + const { editMessageMatrix } = await loadMatrixSendModule(); + textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, { + client, + cfg, + threadId: threadTarget, + accountId: _route.accountId, + extraContent: quietDraftStreaming + ? buildMatrixFinalizedPreviewContent() + : undefined, + }).then( + () => true, + () => false, + ); + } else if (textEditOk && reusesDraftTextUnchanged) { + textEditOk = await draftStream.finalizeLive(); + } + const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk; + if (!reusesDraftAsFinalText) { + await redactMatrixDraftEvent(client, roomId, draftEventId); + } + const mediaPayload = + ttsSupplement && reusesDraftAsFinalText + ? buildTtsSupplementMediaPayload(payload) + : { + ...payload, + text: reusesDraftAsFinalText + ? undefined + : (payload.text ?? + (ttsSupplement?.visibleTextAlreadyDelivered === true + ? undefined + : ttsSupplement?.spokenText)), + }; + await deliverMatrixReplies({ + cfg, + replies: [mediaPayload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, + accountId: _route.accountId, + mediaLocalRoots, + tableMode, + }); + draftConsumed = true; + } else { + const draftRedacted = + Boolean(draftEventId) && + (payload.isError || + payloadReplyMismatch || + mustDeliverFinalNormally || + draftFinalTextNeedsNormalMentionDelivery); + if (draftRedacted && draftEventId) { + await redactMatrixDraftEvent(client, roomId, draftEventId); + } + const deliveredFallback = await deliverMatrixReplies({ + cfg, + replies: [fallbackPayload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, + accountId: _route.accountId, + mediaLocalRoots, + tableMode, + }); + if (draftRedacted || deliveredFallback) { + draftConsumed = true; + } + } + if (info.kind === "block") { + draftConsumed = false; advanceDraftBlockBoundary({ fallbackToLatestEnd: true }); + draftStream.reset(); + currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined; + updateDraftFromLatestFullText(); + + // Re-assert typing so the user still sees the indicator while + // the next block generates. + const { sendTypingMatrix } = await loadMatrixSendModule(); + await sendTypingMatrix(roomId, true, undefined, client).catch(() => {}); } - runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); - }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - }); + } else { + await deliverMatrixReplies({ + cfg, + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + replyToId: threadTarget ?? replyToEventId ?? undefined, + accountId: _route.accountId, + mediaLocalRoots, + tableMode, + }); + } + }, + onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { + if (info.kind === "final") { + finalReplyDeliveryFailed = true; + } else { + nonFinalReplyDeliveryFailed = true; + } + if (info.kind === "block") { + advanceDraftBlockBoundary({ fallbackToLatestEnd: true }); + } + runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + }; const pinnedMainDmOwner = isDirectMessage ? await (async () => { const livePinnedCfg = core.config.current() as CoreConfig; @@ -2422,12 +2427,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam raw: event, }), resolveTurn: () => ({ + cfg, channel: "matrix", accountId: _route.accountId, - routeSessionKey: _route.sessionKey, - storePath, + route: { agentId: _route.agentId, sessionKey: _route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, botLoopProtection, record: { updateLastRoute: isDirectMessage @@ -2464,14 +2468,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, }, - onPreDispatchFailure: () => - core.channel.reply.settleReplyDispatcher({ - dispatcher, - onSettled: () => { - markRunComplete(); - markDispatchIdle(); - }, - }), runDispatch: async () => { if ( sharedDmContextNotice && @@ -2489,61 +2485,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } } - return await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); + return await dispatchInboundMessageWithBufferedDispatcherImpl({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + ...dispatcherOptions, + onSettled: () => progressDraftGate.cancel(), }, - 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) => { - if (progressDraftStreaming) { - return; - } - 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, - onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined, - ...buildPreviewToolProgressReplyOptions(), - onModelSelected, - }, - }); - } finally { - progressDraftGate.cancel(); - markRunComplete(); - } + 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) => { + if (progressDraftStreaming) { + return; + } + 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, + onQueuedFollowupAdmitted: draftStream ? resetDraftDeliveryState : undefined, + ...buildPreviewToolProgressReplyOptions(), + onModelSelected, }, }); }, diff --git a/extensions/matrix/src/matrix/read-policy.test.ts b/extensions/matrix/src/matrix/read-policy.test.ts index ff35092cdc16..60fadb342d0f 100644 --- a/extensions/matrix/src/matrix/read-policy.test.ts +++ b/extensions/matrix/src/matrix/read-policy.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { installMatrixTestRuntime } from "../test-runtime.js"; import type { CoreConfig } from "../types.js"; import { withAuthorizedMatrixReadTarget } from "./read-policy.js"; import type { MatrixClient } from "./sdk.js"; @@ -32,6 +33,10 @@ function createClient( } describe("Matrix read policy", () => { + beforeEach(() => { + installMatrixTestRuntime(); + }); + it("allows configured rooms and rejects other rooms before the read", async () => { const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]); const cfg = { diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index 806b7d89b727..340aa1811bec 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -1,4 +1,5 @@ // Matrix helper module supports formatting behavior. +import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; import { getMatrixRuntime } from "../../runtime.js"; import { markdownToMatrixHtml, @@ -187,7 +188,7 @@ export function resolveMatrixVoiceDecision(opts: { function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { // Matrix currently shares the core voice compatibility policy. // Keep this wrapper as the seam if Matrix policy diverges later. - return getCore().media.isVoiceCompatibleAudio({ + return isVoiceMessageCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName, }); 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 b06b05d1be00..5352ddd5750e 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -80,9 +80,10 @@ class FakeWebSocket { const mockState = vi.hoisted(() => ({ abortController: undefined as AbortController | undefined, + createReplyDispatcherWithTyping: vi.fn(), createMattermostClient: vi.fn(), createMattermostDraftStream: vi.fn(), - dispatchReplyFromConfig: vi.fn(), + dispatchInboundMessage: vi.fn(), enqueueSystemEvent: vi.fn(), fetchMattermostMe: vi.fn(), registerMattermostMonitorSlashCommands: vi.fn(), @@ -96,6 +97,22 @@ const mockState = vi.hoisted(() => ({ updateMattermostPost: vi.fn(), })); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyDispatcherWithTyping: (...args: unknown[]) => + mockState.createReplyDispatcherWithTyping(...args), + dispatchInboundMessage: async (params: Parameters[0]) => { + try { + return await mockState.dispatchInboundMessage(params); + } finally { + await params.onSettled?.(); + } + }, + }; +}); + vi.mock("./client.js", async () => { const actual = await vi.importActual("./client.js"); return { @@ -193,16 +210,43 @@ function createRuntimeCore( type ReplyDispatcherOptions = { deliver: (payload: ReplyPayload, info: { kind: "tool" | "block" | "final" }) => Promise; }; + mockState.createReplyDispatcherWithTyping.mockImplementation( + (options: ReplyDispatcherOptions) => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + options, + }), + ); + type RecordInboundSessionInput = { + storePath: string; + sessionKey: string; + ctx: unknown; + createIfMissing?: boolean; + groupResolution?: unknown; + onRecordError?: (error: unknown) => void; + updateLastRoute?: { + accountId?: string; + channel?: string; + mainDmOwnerPin?: { + onSkip?: () => void; + ownerRecipient?: string; + senderRecipient?: string; + }; + sessionKey?: string; + to?: string; + }; + }; + const recordInboundSession = vi.fn(async (_params: RecordInboundSessionInput) => {}); const dispatchPreparedForTest = vi.fn( async (turn: { - storePath: string; - routeSessionKey: string; + route: { agentId: string; sessionKey: string }; ctxPayload: { SessionKey?: string }; - recordInboundSession: (params: unknown) => Promise; record?: { groupResolution?: unknown; createIfMissing?: boolean; - updateLastRoute?: unknown; + updateLastRoute?: RecordInboundSessionInput["updateLastRoute"]; onRecordError?: (err: unknown) => void; }; runDispatch: () => Promise<{ @@ -210,9 +254,9 @@ function createRuntimeCore( counts: { tool: number; block: number; final: number }; }>; }) => { - await turn.recordInboundSession({ - storePath: turn.storePath, - sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, + await recordInboundSession({ + storePath: "/tmp/openclaw-test-sessions.json", + sessionKey: turn.ctxPayload.SessionKey ?? turn.route.sessionKey, ctx: turn.ctxPayload, groupResolution: turn.record?.groupResolution, createIfMissing: turn.record?.createIfMissing, @@ -224,7 +268,7 @@ function createRuntimeCore( admission: { kind: "dispatch" as const }, dispatched: true, ctxPayload: turn.ctxPayload, - routeSessionKey: turn.routeSessionKey, + routeSessionKey: turn.route.sessionKey, dispatchResult, }; }, @@ -304,25 +348,7 @@ function createRuntimeCore( buildPairingReply: () => "pairing required", }, reply: { - createReplyDispatcherWithTyping: vi.fn((options: ReplyDispatcherOptions) => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: vi.fn(), - markRunComplete: vi.fn(), - options, - })), - dispatchReplyFromConfig: mockState.dispatchReplyFromConfig, - finalizeInboundContext: (context: unknown) => context, - formatInboundEnvelope: (params: { channel: string; from: string; body: string }) => - `${params.channel} ${params.from}\n${params.body}`, - resolveHumanDelayConfig: () => ({}), - withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => { - try { - return await params.run(); - } finally { - params.onSettled?.(); - } - }, + settleReplyDispatcher: vi.fn(async ({ onSettled }) => onSettled?.()), }, routing: { resolveAgentRoute: () => ({ @@ -335,26 +361,7 @@ function createRuntimeCore( }, session: { resolveStorePath: () => "/tmp/openclaw-test-sessions.json", - recordInboundSession: vi.fn( - async (_params: { - createIfMissing?: unknown; - groupResolution?: unknown; - onRecordError?: unknown; - sessionKey?: string; - storePath?: string; - updateLastRoute?: { - accountId?: string; - channel?: string; - mainDmOwnerPin?: { - onSkip?: unknown; - ownerRecipient?: string; - senderRecipient?: string; - }; - sessionKey?: string; - to?: string; - }; - }) => {}, - ), + recordInboundSession, updateLastRoute: vi.fn(async () => {}), }, inbound: { @@ -457,7 +464,7 @@ describe("mattermost inbound user posts", () => { mockState.resolveMattermostMedia.mockResolvedValue([]); mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" }); mockState.sendMessageMattermost.mockResolvedValue({}); - mockState.dispatchReplyFromConfig.mockImplementation(async () => { + mockState.dispatchInboundMessage.mockImplementation(async () => { mockState.abortController?.abort(); }); }); @@ -503,8 +510,8 @@ describe("mattermost inbound user posts", () => { await monitor; expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled(); - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); + const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("hello from mattermost"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular"); @@ -599,8 +606,8 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); + const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("@openclaw"); expect(ctx?.MessageSid).toBe("post-bare-mention"); expect(ctx?.OriginatingChannel).toBe("mattermost"); @@ -638,7 +645,7 @@ describe("mattermost inbound user posts", () => { }, }; mockState.runtimeCore = createRuntimeCore(progressConfig); - mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { + mockState.dispatchInboundMessage.mockImplementation(async (params) => { await params.replyOptions?.onToolStart?.({ toolCallId: "read-1", name: "read", @@ -707,7 +714,7 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions; + const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions; expect(replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true); expect(draftStream.clear).toHaveBeenCalledTimes(1); const updates = draftStream.update.mock.calls.map((call) => String(call[0])); @@ -779,8 +786,8 @@ describe("mattermost inbound user posts", () => { await monitor; expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig); - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); + const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("hello /status"); expect(ctx?.CommandAuthorized).toBe(false); // Inline non-control text must not be tagged as an explicit text-slash command turn — @@ -861,8 +868,8 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); + const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("/reset"); expect(ctx?.CommandBody).toBe("/reset"); expect(ctx?.CommandAuthorized).toBe(true); @@ -913,8 +920,8 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); + const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("hello with websocket kind"); expect(ctx?.ChatType).toBe("channel"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); @@ -976,7 +983,7 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled(); expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled(); }); @@ -1041,7 +1048,7 @@ describe("mattermost inbound user posts", () => { user_id: "user-1", }, }); - expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockState.dispatchInboundMessage).not.toHaveBeenCalled(); await socket.emitMessage({ event: "posted", @@ -1066,8 +1073,8 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx; + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); + const ctx = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].ctx; expect(ctx?.BodyForAgent).toBe("abort"); expect(ctx?.CommandAuthorized).toBe(true); }); @@ -1266,9 +1273,9 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); expect(mockState.createMattermostDraftStream).not.toHaveBeenCalled(); - const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions; + const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions; expect(replyOptions?.disableBlockStreaming).toBe(false); expect(replyOptions?.preserveProgressCallbackStartOrder).toBeUndefined(); }); @@ -1354,7 +1361,7 @@ describe("mattermost inbound user posts", () => { let finalToolDraft = ""; let secondPartialArrivedBeforeBoundarySettled = false; let finalDeliveryWaitedForBoundary = false; - mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { + mockState.dispatchInboundMessage.mockImplementation(async (params) => { await params.replyOptions?.onAssistantMessageStart?.(); params.replyOptions?.onPartialReply?.({ text: "A much longer first block" }); const firstToolStart = params.replyOptions?.onToolStart?.({ @@ -1427,8 +1434,7 @@ describe("mattermost inbound user posts", () => { toolBeforeFinalBoundaryCount = forceNewMessage.mock.calls.length; finalToolDraft = String(draftUpdate.mock.calls.at(-1)?.[0] ?? ""); const dispatcherOptions = - runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value - ?.options; + mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options; const finalDelivery = dispatcherOptions?.deliver( { text: "Final without a partial" }, { kind: "final" }, @@ -1462,14 +1468,14 @@ describe("mattermost inbound user posts", () => { socket.emitClose(1000); await monitor; - expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(mockState.dispatchInboundMessage).toHaveBeenCalledTimes(1); const draftStreamOptions = mockState.createMattermostDraftStream.mock.calls.at(0)?.[0] as | { chunkText?: (text: string) => string[] } | undefined; chunkMarkdownTextWithMode.mockClear(); expect(draftStreamOptions?.chunkText?.("first\n\nsecond")).toEqual(["first\n\nsecond"]); expect(chunkMarkdownTextWithMode).toHaveBeenCalledWith("first\n\nsecond", 1234, "newline"); - const replyOptions = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].replyOptions; + const replyOptions = mockState.dispatchInboundMessage.mock.calls.at(0)?.[0].replyOptions; expect(replyOptions?.disableBlockStreaming).toBe(true); expect(replyOptions?.preserveProgressCallbackStartOrder).toBe(true); expect(sameToolUpdateBoundaryCount).toBe(1); @@ -1540,14 +1546,13 @@ describe("mattermost inbound user posts", () => { const socket = new FakeWebSocket(); const abortController = new AbortController(); mockState.abortController = abortController; - mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { + mockState.dispatchInboundMessage.mockImplementation(async (params) => { await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onPartialReply?.({ text: "First block" }); await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onPartialReply?.({ text: "Second block" }); const dispatcherOptions = - runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value - ?.options; + mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options; await dispatcherOptions?.deliver( { text: "[bot] First block\n\nSecond block" }, { kind: "final" }, @@ -1621,13 +1626,12 @@ describe("mattermost inbound user posts", () => { const socket = new FakeWebSocket(); const abortController = new AbortController(); mockState.abortController = abortController; - mockState.dispatchReplyFromConfig.mockImplementation(async (params) => { + mockState.dispatchInboundMessage.mockImplementation(async (params) => { await params.replyOptions?.onAssistantMessageStart?.(); await params.replyOptions?.onPartialReply?.({ text: "Only block" }); await params.replyOptions?.onAssistantMessageStart?.(); const dispatcherOptions = - runtimeCore.channel.reply.createReplyDispatcherWithTyping.mock.results.at(-1)?.value - ?.options; + mockState.createReplyDispatcherWithTyping.mock.results.at(-1)?.value?.options; await dispatcherOptions?.deliver({ text: "Only block" }, { kind: "final" }); abortController.abort(); }); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 49254a77996e..d066944dd513 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,10 +1,17 @@ -import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound"; // Mattermost plugin module implements monitor behavior. +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { implicitMentionKindWhen } from "openclaw/plugin-sdk/channel-inbound"; +import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound"; import { buildChannelProgressDraftLineForEntry, createChannelProgressDraftCompositor, } from "openclaw/plugin-sdk/channel-outbound"; import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; +import { + createReplyDispatcherWithTyping, + dispatchInboundMessage, + finalizeInboundContext, +} from "openclaw/plugin-sdk/reply-runtime"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -430,7 +437,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const to = kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`; const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`; - const ctxPayload = core.channel.reply.finalizeInboundContext({ + const ctxPayload = finalizeInboundContext({ Body: bodyText, BodyForAgent: bodyText, RawBody: bodyText, @@ -497,55 +504,48 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isDirect: kind === "direct", dmRetryOptions: account.config.dmChannelRetry, }); - const { dispatcher, replyOptions, markDispatchIdle } = - core.channel.reply.createReplyDispatcherWithTyping({ - ...replyPipeline, - resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, - onDeliverySettled: deliveryBarrier.markDeliverySettled, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), - deliver: async (payload: ReplyPayload) => { - await deliverMattermostReplyPayload({ - core, - cfg, - payload, - to, - accountId: account.accountId, - agentId: route.agentId, - replyToId: resolveMattermostReplyRootId({ - kind, - threadRootId: threadContext.effectiveReplyToId, - replyToId: payload.replyToId, - }), - textLimit, - tableMode, - sendMessage: sendMessageMattermost, - onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, - }); - runtime.log?.(`delivered button-click reply to ${to}`); - }, - onError: (err, info) => { - runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`); - }, - onReplyStart: typingCallbacks?.onReplyStart, - }); - - await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + ...replyPipeline, + resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, + onDeliverySettled: deliveryBarrier.markDeliverySettled, + humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: ReplyPayload) => { + await deliverMattermostReplyPayload({ + core, cfg, - dispatcher, - replyOptions: { - ...replyOptions, - disableBlockStreaming: - typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected, - }, - }), + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + replyToId: resolveMattermostReplyRootId({ + kind, + threadRootId: threadContext.effectiveReplyToId, + replyToId: payload.replyToId, + }), + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, + }); + runtime.log?.(`delivered button-click reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks?.onReplyStart, + }); + + await dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + onSettled: () => markDispatchIdle(), + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, }); }, log: (msg) => runtime.log?.(msg), @@ -632,7 +632,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} params.kind === "direct" ? `Mattermost DM from ${params.senderName}` : `Mattermost message in ${params.roomLabel} from ${params.senderName}`; - const ctxPayload = core.channel.reply.finalizeInboundContext({ + const ctxPayload = finalizeInboundContext({ Body: params.commandText, BodyForAgent: params.commandText, RawBody: params.commandText, @@ -707,67 +707,60 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isDirect: params.kind === "direct", dmRetryOptions: account.config.dmChannelRetry, }); - const { dispatcher, replyOptions, markDispatchIdle } = - core.channel.reply.createReplyDispatcherWithTyping({ - ...replyPipeline, - resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, - onDeliverySettled: deliveryBarrier.markDeliverySettled, - // Picker-triggered confirmations should stay immediate. - deliver: async (payload: ReplyPayload) => { - const trimmedPayload = { - ...payload, - text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(), - }; + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + ...replyPipeline, + resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, + onDeliverySettled: deliveryBarrier.markDeliverySettled, + // Picker-triggered confirmations should stay immediate. + deliver: async (payload: ReplyPayload) => { + const trimmedPayload = { + ...payload, + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(), + }; - if (!shouldDeliverReplies) { - if (trimmedPayload.text) { - capturedTexts.push(trimmedPayload.text); - } - return; + if (!shouldDeliverReplies) { + if (trimmedPayload.text) { + capturedTexts.push(trimmedPayload.text); } + return; + } - await deliverMattermostReplyPayload({ - core, - cfg, - payload: trimmedPayload, - to, - accountId: account.accountId, - agentId: params.route.agentId, - replyToId: resolveMattermostReplyRootId({ - kind: params.kind, - threadRootId: params.effectiveReplyToId, - replyToId: trimmedPayload.replyToId, - }), - textLimit, - // The picker path already converts and trims text before capture/delivery. - tableMode: "off", - sendMessage: sendMessageMattermost, - onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, - }); - }, - onError: (err, info) => { - runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`); - }, - onReplyStart: typingCallbacks?.onReplyStart, - }); - - await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, + await deliverMattermostReplyPayload({ + core, cfg, - dispatcher, - replyOptions: { - ...replyOptions, - disableBlockStreaming: - typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected, - }, - }), + payload: trimmedPayload, + to, + accountId: account.accountId, + agentId: params.route.agentId, + replyToId: resolveMattermostReplyRootId({ + kind: params.kind, + threadRootId: params.effectiveReplyToId, + replyToId: trimmedPayload.replyToId, + }), + textLimit, + // The picker path already converts and trims text before capture/delivery. + tableMode: "off", + sendMessage: sendMessageMattermost, + onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, + }); + }, + onError: (err, info) => { + runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks?.onReplyStart, + }); + + await dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + onSettled: () => markDispatchIdle(), + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, }); return capturedTexts.join("\n\n").trim(); @@ -1296,7 +1289,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`; - const body = core.channel.reply.formatInboundEnvelope({ + const body = formatInboundEnvelope({ channel: "Mattermost", from: fromLabel, timestamp: typeof post.create_at === "number" ? post.create_at : undefined, @@ -1312,7 +1305,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - core.channel.reply.formatInboundEnvelope({ + formatInboundEnvelope({ channel: "Mattermost", from: fromLabel, timestamp: entry.timestamp, @@ -1335,7 +1328,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} limit: historyLimit, }) : undefined; - const ctxPayload = core.channel.reply.finalizeInboundContext({ + const ctxPayload = finalizeInboundContext({ Body: combinedBody, BodyForAgent: bodyForAgent, InboundHistory: inboundHistory, @@ -1389,10 +1382,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }) : null; - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - const previewLine = truncateUtf16Safe(bodyText, 200).replace(/\n/g, "\\n"); logVerboseMessage( `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`, @@ -1591,11 +1580,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} dmRetryOptions: account.config.dmChannelRetry, }); const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = - core.channel.reply.createReplyDispatcherWithTyping({ + createReplyDispatcherWithTyping({ ...replyPipeline, resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, onDeliverySettled: deliveryBarrier.markDeliverySettled, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + humanDelay: resolveHumanDelayConfig(cfg, route.agentId), typingCallbacks, deliver: async (payloadEntry: ReplyPayload, info) => { if (info.kind === "final") { @@ -1715,12 +1704,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} raw: post, }), resolveTurn: () => ({ + cfg, channel: "mattermost", accountId: route.accountId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, record: { updateLastRoute: kind === "direct" @@ -1772,131 +1760,124 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); }, runDispatch: () => - core.channel.reply.withReplyDispatcher({ + dispatchInboundMessage({ + ctx: ctxPayload, + cfg, dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - allowProgressCallbacksWhenSourceDeliverySuppressed: - draftToolProgressEnabled ? true : undefined, - preserveProgressCallbackStartOrder: draftPreviewEnabled - ? true - : undefined, - onObservedReplyDelivery: draftToolProgressEnabled - ? () => draftStream.clear() - : undefined, - disableBlockStreaming: draftPreviewEnabled - ? true - : typeof account.blockStreaming === "boolean" - ? !account.blockStreaming + onSettled: () => markDispatchIdle(), + replyOptions: { + ...replyOptions, + allowProgressCallbacksWhenSourceDeliverySuppressed: draftToolProgressEnabled + ? true + : undefined, + preserveProgressCallbackStartOrder: draftPreviewEnabled ? true : undefined, + onObservedReplyDelivery: draftToolProgressEnabled + ? () => draftStream.clear() + : undefined, + disableBlockStreaming: draftPreviewEnabled + ? true + : typeof account.blockStreaming === "boolean" + ? !account.blockStreaming + : undefined, + ...(suppressDefaultToolProgressMessages + ? { suppressDefaultToolProgressMessages: true } + : {}), + onModelSelected, + onPartialReply: (payloadResult) => { + if (account.streamingMode !== "progress") { + return updateDraftFromPartial(payloadResult.text); + } + return undefined; + }, + onAssistantMessageStart: () => { + lastPartialText = ""; + progressDraft.resetReasoningProgress(); + if (account.streamingMode === "block") { + blockPreviewAssistantMessagePending = true; + return; + } + if (account.streamingMode !== "progress") { + progressDraft.reset(); + } + }, + onReasoningEnd: () => { + // Hidden reasoning has no visible boundary. Only transitions that + // actually render text, reasoning, or tools rotate preview posts. + lastPartialText = ""; + progressDraft.resetReasoningProgress(); + if ( + account.streamingMode !== "block" && + account.streamingMode !== "progress" + ) { + progressDraft.reset(); + } + }, + onReasoningStream: async (payloadResult) => { + if (account.streamingMode === "progress") { + await progressDraft.pushReasoningProgress( + payloadResult.text || "Thinking…", + { snapshot: payloadResult.isReasoningSnapshot === true }, + ); + return; + } + if (!lastPartialText) { + const boundarySettled = enterBlockPreviewActivity("reasoning"); + draftStream.update("Thinking…"); + previewBoundaryController.noteUpdate(); + await boundarySettled; + } + }, + onToolStart: async (payloadValue) => { + if (!draftToolProgressEnabled) { + return; + } + const boundarySettled = enterBlockPreviewActivity("tool"); + // Boundary detach and progress staging both happen synchronously before + // their first await; agent callbacks may be dispatched fire-and-forget. + const progressSettled = progressDraft.pushToolProgress( + buildChannelProgressDraftLineForEntry( + account.config, + { + event: "tool", + itemId: payloadValue.itemId, + toolCallId: payloadValue.toolCallId, + name: payloadValue.name, + phase: payloadValue.phase, + args: payloadValue.args, + }, + payloadValue.detailMode + ? { detailMode: payloadValue.detailMode } : undefined, - ...(suppressDefaultToolProgressMessages - ? { suppressDefaultToolProgressMessages: true } - : {}), - onModelSelected, - onPartialReply: (payloadResult) => { - if (account.streamingMode !== "progress") { - return updateDraftFromPartial(payloadResult.text); - } - return undefined; - }, - onAssistantMessageStart: () => { - lastPartialText = ""; - progressDraft.resetReasoningProgress(); - if (account.streamingMode === "block") { - blockPreviewAssistantMessagePending = true; - return; - } - if (account.streamingMode !== "progress") { - progressDraft.reset(); - } - }, - onReasoningEnd: () => { - // Hidden reasoning has no visible boundary. Only transitions that - // actually render text, reasoning, or tools rotate preview posts. - lastPartialText = ""; - progressDraft.resetReasoningProgress(); - if ( - account.streamingMode !== "block" && - account.streamingMode !== "progress" - ) { - progressDraft.reset(); - } - }, - onReasoningStream: async (payloadResult) => { - if (account.streamingMode === "progress") { - await progressDraft.pushReasoningProgress( - payloadResult.text || "Thinking…", - { snapshot: payloadResult.isReasoningSnapshot === true }, - ); - return; - } - if (!lastPartialText) { - const boundarySettled = enterBlockPreviewActivity("reasoning"); - draftStream.update("Thinking…"); - previewBoundaryController.noteUpdate(); - await boundarySettled; - } - }, - onToolStart: async (payloadValue) => { - if (!draftToolProgressEnabled) { - return; - } - const boundarySettled = enterBlockPreviewActivity("tool"); - // Boundary detach and progress staging both happen synchronously before - // their first await; agent callbacks may be dispatched fire-and-forget. - const progressSettled = progressDraft.pushToolProgress( - buildChannelProgressDraftLineForEntry( - account.config, - { - event: "tool", - itemId: payloadValue.itemId, - toolCallId: payloadValue.toolCallId, - name: payloadValue.name, - phase: payloadValue.phase, - args: payloadValue.args, - }, - payloadValue.detailMode - ? { detailMode: payloadValue.detailMode } - : undefined, - ), - { startImmediately: true }, - ); - previewBoundaryController.noteUpdate(); - await Promise.all([boundarySettled, progressSettled]); - }, - onItemEvent: async (payloadLocal) => { - if (!draftToolProgressEnabled) { - return; - } - const boundarySettled = enterBlockPreviewActivity("tool"); - const progressSettled = progressDraft.pushToolProgress( - buildChannelProgressDraftLineForEntry(account.config, { - event: "item", - itemId: payloadLocal.itemId, - itemKind: payloadLocal.kind, - title: payloadLocal.title, - name: payloadLocal.name, - phase: payloadLocal.phase, - status: payloadLocal.status, - summary: payloadLocal.summary, - progressText: payloadLocal.progressText, - meta: payloadLocal.meta, - }), - { startImmediately: true }, - ); - previewBoundaryController.noteUpdate(); - await Promise.all([boundarySettled, progressSettled]); - }, - }, - }), + ), + { startImmediately: true }, + ); + previewBoundaryController.noteUpdate(); + await Promise.all([boundarySettled, progressSettled]); + }, + onItemEvent: async (payloadLocal) => { + if (!draftToolProgressEnabled) { + return; + } + const boundarySettled = enterBlockPreviewActivity("tool"); + const progressSettled = progressDraft.pushToolProgress( + buildChannelProgressDraftLineForEntry(account.config, { + event: "item", + itemId: payloadLocal.itemId, + itemKind: payloadLocal.kind, + title: payloadLocal.title, + name: payloadLocal.name, + phase: payloadLocal.phase, + status: payloadLocal.status, + summary: payloadLocal.summary, + progressText: payloadLocal.progressText, + meta: payloadLocal.meta, + }), + { startImmediately: true }, + ); + previewBoundaryController.noteUpdate(); + await Promise.all([boundarySettled, progressSettled]); + }, + }, }), }), }, diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 51b36dd9e9bb..023c2a03e3c9 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -6,10 +6,16 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; +import { + createReplyDispatcherWithTyping, + dispatchInboundMessage, + finalizeInboundContext, +} from "openclaw/plugin-sdk/reply-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -830,7 +836,7 @@ async function handleSlashCommandAsync(params: { } // Build inbound context — the command text is the body - const ctxPayload = core.channel.reply.finalizeInboundContext({ + const ctxPayload = finalizeInboundContext({ Body: commandText, BodyForAgent: commandText, RawBody: commandText, @@ -886,58 +892,51 @@ async function handleSlashCommandAsync(params: { }, }, }); - const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); + const humanDelay = resolveHumanDelayConfig(cfg, route.agentId); const deliveryBarrier = createMattermostReplyDeliveryBarrier({ isDirect: kind === "direct", dmRetryOptions: account.config.dmChannelRetry, }); - const { dispatcher, replyOptions, markDispatchIdle } = - core.channel.reply.createReplyDispatcherWithTyping({ - ...replyPipeline, - resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, - onDeliverySettled: deliveryBarrier.markDeliverySettled, - humanDelay, - deliver: async (payload: ReplyPayload) => { - await deliverMattermostReplyPayload({ - core, - cfg, - payload, - to, - accountId: account.accountId, - agentId: route.agentId, - textLimit, - tableMode, - sendMessage: sendMessageMattermost, - onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, - }); - runtime.log?.(`delivered slash reply to ${to}`); - }, - onError: (err, info) => { - runtime.error?.( - `mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`, - ); - }, - onReplyStart: typingCallbacks?.onReplyStart, - }); - - await core.channel.reply.withReplyDispatcher({ - dispatcher, - onSettled: () => { - markDispatchIdle(); - }, - run: () => - core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + ...replyPipeline, + resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, + onDeliverySettled: deliveryBarrier.markDeliverySettled, + humanDelay, + deliver: async (payload: ReplyPayload) => { + await deliverMattermostReplyPayload({ + core, cfg, - dispatcher, - replyOptions: { - ...replyOptions, - disableBlockStreaming: - typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, - onModelSelected, - }, - }), + payload, + to, + accountId: account.accountId, + agentId: route.agentId, + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, + }); + runtime.log?.(`delivered slash reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.( + `mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(err)}`, + ); + }, + onReplyStart: typingCallbacks?.onReplyStart, + }); + + await dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + onSettled: () => markDispatchIdle(), + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected, + }, }); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 7f80a8fe5ff9..83c885d7c6af 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -6,7 +6,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { asRecord } from "./dreaming-shared.js"; import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; -function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record { +function resolveDreamingPluginConfig(cfg: OpenClawConfig): Record { const entry = asRecord(cfg.plugins?.entries?.["memory-core"]); return asRecord(entry?.config) ?? {}; } @@ -49,7 +49,7 @@ function formatPhaseGuide(): string { } function formatStatus(cfg: OpenClawConfig): string { - const pluginConfig = resolveMemoryCorePluginConfig(cfg); + const pluginConfig = resolveDreamingPluginConfig(cfg); const dreaming = resolveMemoryDreamingConfig({ pluginConfig, cfg, diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index 21f2c3113cf4..07012ab9f168 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -5,8 +5,10 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { RequestScopedSubagentRuntimeError } from "openclaw/plugin-sdk/error-runtime"; -import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; -import { resolveMemoryCorePluginConfig } from "openclaw/plugin-sdk/memory-core-host-status"; +import { + resolveMemoryDreamingPluginConfig, + resolveSessionTranscriptsDirForAgent, +} from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; @@ -236,7 +238,7 @@ function createHarness( }, }, }; - const pluginConfig = resolveMemoryCorePluginConfig(resolvedConfig) ?? {}; + const pluginConfig = resolveMemoryDreamingPluginConfig(resolvedConfig) ?? {}; const beforeAgentReply = async ( event: { cleanedBody: string }, ctx: { trigger?: string; workspaceDir?: string }, @@ -434,7 +436,7 @@ describe("memory-core dreaming phases", () => { await runDreamingSweepPhases({ workspaceDir, cfg: testConfig, - pluginConfig: resolveMemoryCorePluginConfig(testConfig), + pluginConfig: resolveMemoryDreamingPluginConfig(testConfig), logger, subagent, nowMs, @@ -501,7 +503,7 @@ describe("memory-core dreaming phases", () => { runDreamingSweepPhases({ workspaceDir, cfg: testConfig, - pluginConfig: resolveMemoryCorePluginConfig(testConfig), + pluginConfig: resolveMemoryDreamingPluginConfig(testConfig), logger, subagent, nowMs: Date.parse("2026-04-05T10:05:00.000Z"), @@ -737,7 +739,7 @@ describe("memory-core dreaming phases", () => { await runDreamingSweepPhases({ workspaceDir, cfg: testConfig, - pluginConfig: resolveMemoryCorePluginConfig(testConfig), + pluginConfig: resolveMemoryDreamingPluginConfig(testConfig), logger, subagent, nowMs, diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 35c3b1ab7217..2d0ac8d3cc0c 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; // Memory Core plugin module implements dreaming behavior. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; +import { resolveMemoryDreamingPluginConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import { DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS as DEFAULT_MEMORY_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS, DEFAULT_MEMORY_DEEP_DREAMING_RECENCY_HALF_LIFE_DAYS as DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS, @@ -13,7 +14,6 @@ import { MANAGED_MEMORY_DREAMING_CRON_NAME as MANAGED_DREAMING_CRON_NAME, MANAGED_MEMORY_DREAMING_CRON_TAG as MANAGED_DREAMING_CRON_TAG, MEMORY_DREAMING_SYSTEM_EVENT_TEXT as DREAMING_SYSTEM_EVENT_TEXT, - resolveMemoryCorePluginConfig, resolveMemoryDeepDreamingConfig, resolveMemoryDreamingWorkspaces, } from "openclaw/plugin-sdk/memory-core-host-status"; @@ -550,7 +550,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: { let totalCandidates = 0; let totalApplied = 0; let failedWorkspaces = 0; - const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined; + const pluginConfig = params.cfg ? resolveMemoryDreamingPluginConfig(params.cfg) : undefined; const detachNarratives = params.trigger === "cron"; const [ { writeDeepDreamingReport }, @@ -793,10 +793,10 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig(); const pluginConfig = params.reason === "startup" - ? (resolveMemoryCorePluginConfig(startupCfg) ?? - resolveMemoryCorePluginConfig(api.config) ?? + ? (resolveMemoryDreamingPluginConfig(startupCfg) ?? + resolveMemoryDreamingPluginConfig(api.config) ?? api.pluginConfig) - : resolveMemoryCorePluginConfig(startupCfg); + : resolveMemoryDreamingPluginConfig(startupCfg); const config = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg: startupCfg, diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 6335cf8d366a..deae592322ed 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -10,6 +10,7 @@ import { readFiniteNumberParam, readPositiveIntegerParam, readStringParam, + resolveMemoryDreamingPluginConfig, type MemoryCorpusSearchResult, type OpenClawConfig, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; @@ -18,7 +19,6 @@ import type { MemorySearchRuntimeDebug, } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { - resolveMemoryCorePluginConfig, resolveMemoryDreamingConfig, resolveMemoryDeepDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; @@ -551,7 +551,7 @@ export function createMemorySearchTool(options: { mode: citationsMode, sessionKey: options.agentSessionKey, }); - const pluginConfig = resolveMemoryCorePluginConfig(cfg); + const pluginConfig = resolveMemoryDreamingPluginConfig(cfg); const dreamingEnabled = resolveMemoryDreamingConfig({ pluginConfig, cfg, diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index 7ba2c8126ea1..8f4f45f4a078 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -6,7 +6,7 @@ import { TRUSTED_CLIENT_TOKEN, generateSecMsGecToken, } from "node-edge-tts/dist/drm.js"; -import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; +import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; import { assertOkOrThrowProviderError, readProviderJsonResponse, @@ -288,7 +288,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin { audioBuffer, outputFormat: format, fileExtension, - voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }), + voiceCompatible: isVoiceMessageCompatibleAudio({ fileName: outputPath }), }; }; diff --git a/extensions/msteams/src/delivery-trace.test.ts b/extensions/msteams/src/delivery-trace.test.ts index 5d1d12f98e35..f2cb16a7ee8e 100644 --- a/extensions/msteams/src/delivery-trace.test.ts +++ b/extensions/msteams/src/delivery-trace.test.ts @@ -24,12 +24,22 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { chunkMarkdownTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; -import { describe, it } from "vitest"; +import { describe, it, vi } from "vitest"; import type { OpenClawConfig, ReplyPayload } from "../runtime-api.js"; import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js"; import { setMSTeamsRuntime } from "./runtime.js"; import type { MSTeamsTurnContext } from "./sdk-types.js"; +const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, + }; +}); + /** Options msteams passes into core createReplyDispatcherWithTyping (capture seam). */ type CapturedDispatcherOptions = { onReplyStart?: () => Promise | void; @@ -230,11 +240,18 @@ const MSTEAMS_TRACE_CASES: readonly MSTeamsTraceCase[] = [ function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) { let captured: CapturedDispatcherOptions | undefined; - setMSTeamsRuntime( - createTraceRuntimeStub(recorder, (options) => { - captured = options; - }), - ); + setMSTeamsRuntime(createTraceRuntimeStub(recorder, () => undefined)); + createReplyDispatcherWithTypingMock.mockImplementation((options: CapturedDispatcherOptions) => { + captured = options; + return { + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => { + options.typingCallbacks?.onIdle?.(); + }, + markRunComplete: () => {}, + }; + }); const stream = createRecordingStream(recorder, traceCase.streamWriteFault); const context = createRecordingTurnContext({ recorder, diff --git a/extensions/msteams/src/feedback-invoke.ts b/extensions/msteams/src/feedback-invoke.ts index a19103fbc8a2..0d6eb7831390 100644 --- a/extensions/msteams/src/feedback-invoke.ts +++ b/extensions/msteams/src/feedback-invoke.ts @@ -1,7 +1,6 @@ // Msteams plugin module implements feedback invoke behavior. -import path from "node:path"; +import { recordChannelFeedbackEvent } from "openclaw/plugin-sdk/channel-inbound"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; -import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatUnknownError } from "./errors.js"; import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js"; @@ -131,19 +130,12 @@ export async function runMSTeamsFeedbackInvokeHandler( hasComment: Boolean(userComment), }); - // Write feedback event to session transcript try { - const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, { + await recordChannelFeedbackEvent({ + cfg: deps.cfg, agentId: route.agentId, - }); - const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - const transcriptFile = path.join(storePath, `${safeKey}.jsonl`); - await appendRegularFile({ - filePath: transcriptFile, - content: `${JSON.stringify(feedbackEvent)}\n`, - rejectSymlinkParents: true, - }).catch(() => { - // Best effort — transcript dir may not exist yet + sessionKey: route.sessionKey, + event: feedbackEvent, }); } catch { // Best effort @@ -181,12 +173,11 @@ export async function runMSTeamsFeedbackInvokeHandler( runFeedbackReflection({ cfg: deps.cfg, app: deps.app, - appId: deps.appId, conversationRef, sessionKey: route.sessionKey, agentId: route.agentId, conversationId, - feedbackMessageId: messageId, + conversationKind: isDirectMessage ? "direct" : isChannel ? "channel" : "group", userComment, log: deps.log, }).catch((err: unknown) => { diff --git a/extensions/msteams/src/feedback-reflection-prompt.ts b/extensions/msteams/src/feedback-reflection-prompt.ts deleted file mode 100644 index b663c948b5e5..000000000000 --- a/extensions/msteams/src/feedback-reflection-prompt.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Msteams plugin module implements feedback reflection prompt behavior. -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; - -/** Max chars of the thumbed-down response to include in the reflection prompt. */ -const MAX_RESPONSE_CHARS = 500; - -type ParsedReflectionResponse = { - learning: string; - followUp: boolean; - userMessage?: string; -}; - -export function buildReflectionPrompt(params: { - thumbedDownResponse?: string; - userComment?: string; -}): string { - const parts: string[] = ["A user indicated your previous response wasn't helpful."]; - - if (params.thumbedDownResponse) { - const truncated = - params.thumbedDownResponse.length > MAX_RESPONSE_CHARS - ? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...` - : params.thumbedDownResponse; - parts.push(`\nYour response was:\n> ${truncated}`); - } - - if (params.userComment) { - parts.push(`\nUser's comment: "${params.userComment}"`); - } - - parts.push( - "\nBriefly reflect: what could you improve? Consider tone, length, " + - "accuracy, relevance, and specificity. Reply with a single JSON object " + - 'only, no markdown or prose, using this exact shape:\n{"learning":"...",' + - '"followUp":false,"userMessage":""}\n' + - "- learning: a short internal adjustment note (1-2 sentences) for your " + - "future behavior in this conversation.\n" + - "- followUp: true only if the user needs a direct follow-up message.\n" + - "- userMessage: only the exact user-facing message to send; empty string " + - "when followUp is false.", - ); - - return parts.join("\n"); -} - -function parseBooleanLike(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value === "string") { - const normalized = normalizeOptionalLowercaseString(value); - if (normalized === "true" || normalized === "yes") { - return true; - } - if (normalized === "false" || normalized === "no") { - return false; - } - } - return undefined; -} - -function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null { - if (value == null || typeof value !== "object" || Array.isArray(value)) { - return null; - } - - const candidate = value as { - learning?: unknown; - followUp?: unknown; - userMessage?: unknown; - }; - const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined; - if (!learning) { - return null; - } - - return { - learning, - followUp: parseBooleanLike(candidate.followUp) ?? false, - userMessage: - typeof candidate.userMessage === "string" && candidate.userMessage.trim() - ? candidate.userMessage.trim() - : undefined, - }; -} - -export function parseReflectionResponse(text: string): ParsedReflectionResponse | null { - const trimmed = text.trim(); - if (!trimmed) { - return null; - } - - const candidates = [ - trimmed, - ...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []), - ]; - - for (const candidateText of candidates) { - const candidate = candidateText.trim(); - if (!candidate) { - continue; - } - try { - const parsed = parseStructuredReflectionValue(JSON.parse(candidate)); - if (parsed) { - return parsed; - } - } catch { - // Fall through to the next parse strategy. - } - } - - // Safe fallback: keep the internal learning, but never auto-message the user. - return { - learning: trimmed, - followUp: false, - }; -} diff --git a/extensions/msteams/src/feedback-reflection-store.ts b/extensions/msteams/src/feedback-reflection-store.ts index d39b40f2014f..b505c3c91076 100644 --- a/extensions/msteams/src/feedback-reflection-store.ts +++ b/extensions/msteams/src/feedback-reflection-store.ts @@ -1,15 +1,6 @@ -// Msteams plugin module implements feedback reflection store behavior. import crypto from "node:crypto"; import { getMSTeamsRuntime } from "./runtime.js"; -/** Default cooldown between reflections per session (5 minutes). */ -export const DEFAULT_COOLDOWN_MS = 300_000; - -/** Tracks last reflection time per session to enforce cooldown. */ -const lastReflectionBySession = new Map(); - -/** Maximum cooldown entries before pruning expired ones. */ -const MAX_COOLDOWN_ENTRIES = 500; const LEARNINGS_NAMESPACE = "feedback-learnings"; const MAX_LEARNING_ENTRIES = 10_000; @@ -23,59 +14,22 @@ function learningStoreKey(storePath: string, sessionKey: string): string { return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex"); } -function openLearningStore() { - return getMSTeamsRuntime().state.openKeyedStore({ - namespace: LEARNINGS_NAMESPACE, - maxEntries: MAX_LEARNING_ENTRIES, - }); -} - -/** Prune expired cooldown entries to prevent unbounded memory growth. */ -function pruneExpiredCooldowns(cooldownMs: number): void { - if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) { - return; - } - const now = Date.now(); - for (const [key, time] of lastReflectionBySession) { - if (now - time >= cooldownMs) { - lastReflectionBySession.delete(key); - } - } -} - -/** Check if a reflection is allowed (cooldown not active). */ -export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean { - const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS; - const lastTime = lastReflectionBySession.get(sessionKey); - if (lastTime == null) { - return true; - } - return Date.now() - lastTime >= cooldown; -} - -/** Record that a reflection was run for a session. */ -export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void { - lastReflectionBySession.set(sessionKey, Date.now()); - pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS); -} - -/** Store a learning derived from feedback reflection. */ export async function storeSessionLearning(params: { storePath: string; sessionKey: string; learning: string; }): Promise { - const store = openLearningStore(); - const key = learningStoreKey(params.storePath, params.sessionKey); - const existing = await store.lookup(key); - let learnings = existing?.learnings ?? []; - learnings.push(params.learning); - if (learnings.length > 10) { - learnings = learnings.slice(-10); - } - await store.register(key, { - sessionKey: params.sessionKey, - learnings, - updatedAt: Date.now(), + const store = getMSTeamsRuntime().state.openKeyedStore({ + namespace: LEARNINGS_NAMESPACE, + maxEntries: MAX_LEARNING_ENTRIES, }); + const key = learningStoreKey(params.storePath, params.sessionKey); + if (!store.update) { + throw new Error("plugin state atomic update is unavailable"); + } + await store.update(key, (existing) => ({ + sessionKey: params.sessionKey, + learnings: [...(existing?.learnings ?? []), params.learning].slice(-10), + updatedAt: Date.now(), + })); } diff --git a/extensions/msteams/src/feedback-reflection.test.ts b/extensions/msteams/src/feedback-reflection.test.ts deleted file mode 100644 index a8444db115d2..000000000000 --- a/extensions/msteams/src/feedback-reflection.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -// Msteams tests cover feedback reflection plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js"; -import { isReflectionAllowed, recordReflectionTime } from "./feedback-reflection-store.js"; -import { buildFeedbackEvent } from "./feedback-reflection.js"; - -// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying -// on the ES2024 String.prototype.isWellFormed() runtime API. -const UNPAIRED_SURROGATE_RE = - /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { - it("builds a well-formed custom event", () => { - const event = buildFeedbackEvent({ - messageId: "msg-123", - value: "negative", - comment: "too verbose", - sessionKey: "msteams:user1", - agentId: "default", - conversationId: "19:abc", - }); - - expect(event.type).toBe("custom"); - expect(event.event).toBe("feedback"); - expect(event.value).toBe("negative"); - expect(event.comment).toBe("too verbose"); - expect(event.messageId).toBe("msg-123"); - expect(event.ts).toBeGreaterThan(0); - }); - - it("omits comment when not provided", () => { - const event = buildFeedbackEvent({ - messageId: "msg-123", - value: "positive", - sessionKey: "msteams:user1", - agentId: "default", - conversationId: "19:abc", - }); - - expect(event.comment).toBeUndefined(); - expect(event.value).toBe("positive"); - }); -}); - -describe("buildReflectionPrompt", () => { - it("includes the thumbed-down response", () => { - const prompt = buildReflectionPrompt({ - thumbedDownResponse: "Here is a long explanation...", - }); - - expect(prompt).toContain("previous response wasn't helpful"); - expect(prompt).toContain("Here is a long explanation..."); - expect(prompt).toContain("reflect"); - }); - - it("truncates long responses", () => { - const longResponse = "x".repeat(600); - const prompt = buildReflectionPrompt({ - thumbedDownResponse: longResponse, - }); - - expect(prompt).toContain("..."); - expect(prompt.length).toBeLessThan(longResponse.length + 500); - }); - - it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => { - const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`; - - const prompt = buildReflectionPrompt({ thumbedDownResponse }); - - expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE); - expect(prompt).toContain(`${"a".repeat(499)}...`); - expect(prompt).not.toContain("\ud83e"); - expect(prompt).not.toContain("\udd9e"); - }); - - it("keeps a boundary emoji when it fully fits before the truncation cap", () => { - const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`; - - const prompt = buildReflectionPrompt({ thumbedDownResponse }); - - expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE); - expect(prompt).toContain(`${"a".repeat(498)}🦞...`); - }); - - it("includes user comment when provided", () => { - const prompt = buildReflectionPrompt({ - thumbedDownResponse: "Some response", - userComment: "Too wordy", - }); - - expect(prompt).toContain('User\'s comment: "Too wordy"'); - }); - - it("works without optional params", () => { - const prompt = buildReflectionPrompt({}); - expect(prompt).toContain("previous response wasn't helpful"); - expect(prompt).toContain('"followUp":false'); - }); -}); - -describe("parseReflectionResponse", () => { - it("parses strict JSON output", () => { - expect( - parseReflectionResponse( - '{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}', - ), - ).toEqual({ - learning: "Be more direct next time.", - followUp: true, - userMessage: "Sorry about that. I will keep it tighter.", - }); - }); - - it("parses JSON inside markdown fences", () => { - expect( - parseReflectionResponse( - '```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```', - ), - ).toEqual({ - learning: "Ask a clarifying question first.", - followUp: false, - userMessage: undefined, - }); - }); - - it("falls back to internal-only learning when parsing fails", () => { - expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({ - learning: "Be more concise.\nFollow up: yes.", - followUp: false, - }); - }); -}); - -describe("reflection cooldown", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("allows first reflection", () => { - expect(isReflectionAllowed("session-first")).toBe(true); - }); - - it("blocks reflection within cooldown", () => { - recordReflectionTime("session-blocked"); - expect(isReflectionAllowed("session-blocked", 60_000)).toBe(false); - }); - - it("allows reflection after cooldown expires", () => { - vi.spyOn(Date, "now").mockReturnValue(0); - recordReflectionTime("session-expired"); - vi.spyOn(Date, "now").mockReturnValue(2); - expect(isReflectionAllowed("session-expired", 1)).toBe(true); - }); - - it("tracks sessions independently", () => { - recordReflectionTime("session-tracked-1"); - expect(isReflectionAllowed("session-tracked-1", 60_000)).toBe(false); - expect(isReflectionAllowed("session-tracked-2", 60_000)).toBe(true); - }); - - it("keeps longer custom cooldown entries during pruning", () => { - vi.spyOn(Date, "now").mockReturnValue(0); - recordReflectionTime("prune-target", 600_000); - - vi.spyOn(Date, "now").mockReturnValue(301_000); - for (let index = 0; index <= 500; index += 1) { - recordReflectionTime(`prune-session-${index}`, 600_000); - } - - expect(isReflectionAllowed("prune-target", 600_000)).toBe(false); - }); -}); diff --git a/extensions/msteams/src/feedback-reflection.ts b/extensions/msteams/src/feedback-reflection.ts index 33d54e145a45..57e58536be5f 100644 --- a/extensions/msteams/src/feedback-reflection.ts +++ b/extensions/msteams/src/feedback-reflection.ts @@ -1,22 +1,16 @@ // Msteams plugin module implements feedback reflection behavior. -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { - dispatchReplyFromConfigWithSettledDispatcher, - type OpenClawConfig, -} from "../runtime-api.js"; + DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS, + runChannelFeedbackReflection, +} from "openclaw/plugin-sdk/channel-inbound"; +import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { OpenClawConfig } from "../runtime-api.js"; import { resolveMSTeamsSdkCloudOptions } from "./cloud.js"; import type { StoredConversationReference } from "./conversation-store.js"; import { formatUnknownError } from "./errors.js"; -import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js"; -import { - DEFAULT_COOLDOWN_MS, - isReflectionAllowed, - recordReflectionTime, - storeSessionLearning, -} from "./feedback-reflection-store.js"; +import { storeSessionLearning } from "./feedback-reflection-store.js"; import { buildConversationReference } from "./messenger.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; -import { getMSTeamsRuntime } from "./runtime.js"; import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js"; import type { MSTeamsApp } from "./sdk.js"; @@ -30,7 +24,6 @@ type FeedbackEvent = { sessionKey: string; agentId: string; conversationId: string; - reflectionLearning?: string; }; export function buildFeedbackEvent(params: { @@ -57,173 +50,65 @@ export function buildFeedbackEvent(params: { type RunFeedbackReflectionParams = { cfg: OpenClawConfig; app: MSTeamsApp; - appId: string; conversationRef: StoredConversationReference; sessionKey: string; agentId: string; conversationId: string; - feedbackMessageId: string; + conversationKind: "direct" | "group" | "channel"; thumbedDownResponse?: string; userComment?: string; log: MSTeamsMonitorLogger; }; -function buildReflectionContext(params: { - cfg: OpenClawConfig; - conversationId: string; - sessionKey: string; - reflectionPrompt: string; -}) { - const core = getMSTeamsRuntime(); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg); - const body = core.channel.reply.formatAgentEnvelope({ - channel: "Teams", - from: "system", - body: params.reflectionPrompt, - envelope: envelopeOptions, - }); - - return { - ctxPayload: core.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: params.reflectionPrompt, - RawBody: params.reflectionPrompt, - CommandBody: params.reflectionPrompt, - From: `msteams:system:${params.conversationId}`, - To: `conversation:${params.conversationId}`, - SessionKey: params.sessionKey, - ChatType: "direct" as const, - SenderName: "system", - SenderId: "system", - Provider: "msteams" as const, - Surface: "msteams" as const, - Timestamp: Date.now(), - WasMentioned: true, - CommandAuthorized: false, - OriginatingChannel: "msteams" as const, - OriginatingTo: `conversation:${params.conversationId}`, - }), - }; -} - -function createReflectionCaptureDispatcher(params: { - cfg: OpenClawConfig; - agentId: string; - log: MSTeamsMonitorLogger; -}) { - const core = getMSTeamsRuntime(); - let response = ""; - const noopTypingCallbacks = { - onReplyStart: async () => {}, - onIdle: () => {}, - onCleanup: () => {}, - }; - - const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({ - deliver: async (payload) => { - if (payload.text) { - response += (response ? "\n" : "") + payload.text; - } - }, - typingCallbacks: noopTypingCallbacks, - humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), - onError: (err) => { - params.log.debug?.("reflection reply error", { error: formatUnknownError(err) }); - }, - }); - - return { - dispatcher, - replyOptions, - readResponse: () => response, - }; -} - -async function sendReflectionFollowUp(params: { - cfg: OpenClawConfig; - app: MSTeamsApp; - conversationRef: StoredConversationReference; - userMessage: string; -}): Promise { - const baseRef = buildConversationReference(params.conversationRef); - await sendMSTeamsActivityWithReference( - params.app, - baseRef, - { type: "message", text: params.userMessage }, - { serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(params.cfg.channels?.msteams) }, - ); -} - /** * Run a background reflection after negative feedback. * This is designed to be called fire-and-forget (don't await in the invoke handler). */ export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise { const { cfg, log, sessionKey } = params; - const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS; - if (!isReflectionAllowed(sessionKey, cooldownMs)) { - log.debug?.("skipping reflection (cooldown active)", { sessionKey }); - return; - } - - const reflectionPrompt = buildReflectionPrompt({ - thumbedDownResponse: params.thumbedDownResponse, - userComment: params.userComment, - }); - const runtime = getMSTeamsRuntime(); - const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, { - agentId: params.agentId, - }); - const { ctxPayload } = buildReflectionContext({ - cfg, - conversationId: params.conversationId, - sessionKey: params.sessionKey, - reflectionPrompt, - }); - - const capture = createReflectionCaptureDispatcher({ - cfg, - agentId: params.agentId, - log, - }); - + const cooldownMs = + cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? + DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS; + let reflection; try { - await dispatchReplyFromConfigWithSettledDispatcher({ - ctxPayload, + reflection = await runChannelFeedbackReflection({ cfg, - dispatcher: capture.dispatcher, - onSettled: () => {}, - replyOptions: capture.replyOptions, + channel: "msteams", + channelLabel: "Teams", + agentId: params.agentId, + sessionKey, + conversationId: params.conversationId, + conversationKind: params.conversationKind, + thumbedDownResponse: params.thumbedDownResponse, + userComment: params.userComment, + cooldownMs, + onRecordError: (err) => + log.debug?.("reflection session record failed", { error: formatUnknownError(err) }), + onDispatchError: (err) => + log.debug?.("reflection reply error", { error: formatUnknownError(err) }), }); } catch (err) { log.error("reflection dispatch failed", { error: formatUnknownError(err) }); return; } - - const reflectionResponse = capture.readResponse().trim(); - if (!reflectionResponse) { + if (reflection.status === "cooldown") { + log.debug?.("skipping reflection (cooldown active)", { sessionKey }); + return; + } + if (reflection.status === "empty") { log.debug?.("reflection produced no output"); return; } - - const parsedReflection = parseReflectionResponse(reflectionResponse); - if (!parsedReflection) { - log.debug?.("reflection produced no structured output"); - return; - } - - recordReflectionTime(sessionKey, cooldownMs); log.info("reflection complete", { sessionKey, - responseLength: reflectionResponse.length, - followUp: parsedReflection.followUp, + responseLength: reflection.responseLength, + followUp: reflection.followUp, }); - try { await storeSessionLearning({ - storePath, - sessionKey: params.sessionKey, - learning: parsedReflection.learning, + storePath: reflection.storePath, + sessionKey, + learning: reflection.learning, }); } catch (err) { log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) }); @@ -233,12 +118,10 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams) params.conversationRef.conversation?.conversationType, ); const shouldNotify = - conversationType === "personal" && - parsedReflection.followUp && - Boolean(parsedReflection.userMessage); + conversationType === "personal" && reflection.followUp && Boolean(reflection.userMessage); if (!shouldNotify) { - if (parsedReflection.followUp && conversationType !== "personal") { + if (reflection.followUp && conversationType !== "personal") { log.debug?.("skipping reflection follow-up outside direct message", { sessionKey, conversationType, @@ -248,12 +131,12 @@ export async function runFeedbackReflection(params: RunFeedbackReflectionParams) } try { - await sendReflectionFollowUp({ - cfg, - app: params.app, - conversationRef: params.conversationRef, - userMessage: parsedReflection.userMessage!, - }); + await sendMSTeamsActivityWithReference( + params.app, + buildConversationReference(params.conversationRef), + { type: "message", text: reflection.userMessage! }, + { serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(cfg.channels?.msteams) }, + ); log.info("sent reflection follow-up", { sessionKey }); } catch (err) { log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) }); diff --git a/extensions/msteams/src/monitor-handler.feedback-authz.test.ts b/extensions/msteams/src/monitor-handler.feedback-authz.test.ts index e57d364531b4..3ac4c737d82d 100644 --- a/extensions/msteams/src/monitor-handler.feedback-authz.test.ts +++ b/extensions/msteams/src/monitor-handler.feedback-authz.test.ts @@ -1,7 +1,4 @@ // Msteams tests cover monitor handler.feedback authz plugin behavior. -import { access, mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { runMSTeamsFeedbackInvokeHandler } from "./feedback-invoke.js"; @@ -13,6 +10,14 @@ import type { MSTeamsTurnContext } from "./sdk-types.js"; const feedbackReflectionMockState = vi.hoisted(() => ({ runFeedbackReflection: vi.fn(), })); +const channelInboundMockState = vi.hoisted(() => ({ + recordChannelFeedbackEvent: vi.fn(async () => true), +})); + +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({ + ...(await importOriginal()), + recordChannelFeedbackEvent: channelInboundMockState.recordChannelFeedbackEvent, +})); vi.mock("./monitor-handler/message-handler.js", () => ({ createMSTeamsMessageHandler: () => async () => {}, @@ -57,7 +62,7 @@ function createRuntimeStub(readAllowFromStore: ReturnType): Plugin }), }, session: { - resolveStorePath: (storePath?: string) => storePath ?? tmpdir(), + resolveStorePath: (storePath?: string) => storePath ?? "/tmp", }, }, } as unknown as PluginRuntime; @@ -126,41 +131,21 @@ function createFeedbackInvokeContext(params: { } as unknown as MSTeamsTurnContext; } -async function expectFileMissing(filePath: string) { - let error: unknown; - try { - await access(filePath); - } catch (caught) { - error = caught; - } - expect(error).toBeInstanceOf(Error); - expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); -} - async function withFeedbackHandler(params: { cfg: OpenClawConfig; context: Parameters[0]; - assertResult: (args: { tmpDir: string }) => Promise; + assertResult: () => Promise; }) { - const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-")); - try { - const deps = createDeps({ - cfg: { - ...params.cfg, - session: { store: tmpDir }, - }, - }); - await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps); - await params.assertResult({ tmpDir }); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } + const deps = createDeps({ cfg: params.cfg }); + await runMSTeamsFeedbackInvokeHandler(createFeedbackInvokeContext(params.context), deps); + await params.assertResult(); } describe("msteams feedback invoke authz", () => { beforeEach(() => { feedbackReflectionMockState.runFeedbackReflection.mockReset(); feedbackReflectionMockState.runFeedbackReflection.mockResolvedValue(undefined); + channelInboundMockState.recordChannelFeedbackEvent.mockClear(); }); it("records feedback for an allowlisted DM sender", async () => { @@ -181,34 +166,22 @@ describe("msteams feedback invoke authz", () => { senderName: "Owner", comment: "allowed feedback", }, - assertResult: async ({ tmpDir }) => { - const transcript = await readFile( - path.join(tmpDir, "msteams_direct_owner-aad.jsonl"), - "utf-8", - ); - const event = JSON.parse(transcript.trim()) as Record; - expect(Object.keys(event).toSorted()).toEqual([ - "agentId", - "comment", - "conversationId", - "event", - "messageId", - "sessionKey", - "ts", - "type", - "value", - ]); - expect(typeof event.ts).toBe("number"); - expect({ ...event, ts: 0 }).toEqual({ - type: "custom", - event: "feedback", - ts: 0, - messageId: "bot-msg-1", - value: "positive", - comment: "allowed feedback", - sessionKey: "msteams:direct:owner-aad", + assertResult: async () => { + expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith({ + cfg: expect.any(Object), agentId: "default", - conversationId: "a:personal-chat", + sessionKey: "msteams:direct:owner-aad", + event: { + type: "custom", + event: "feedback", + ts: expect.any(Number), + messageId: "bot-msg-1", + value: "positive", + comment: "allowed feedback", + sessionKey: "msteams:direct:owner-aad", + agentId: "default", + conversationId: "a:personal-chat", + }, }); }, }); @@ -239,35 +212,14 @@ describe("msteams feedback invoke authz", () => { senderName: "Owner", comment: "allowed dm feedback", }, - assertResult: async ({ tmpDir }) => { - const transcript = await readFile( - path.join(tmpDir, "msteams_direct_owner-aad.jsonl"), - "utf-8", + assertResult: async () => { + expect(channelInboundMockState.recordChannelFeedbackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "default", + sessionKey: "msteams:direct:owner-aad", + event: expect.objectContaining({ comment: "allowed dm feedback" }), + }), ); - const event = JSON.parse(transcript.trim()) as Record; - expect(Object.keys(event).toSorted()).toEqual([ - "agentId", - "comment", - "conversationId", - "event", - "messageId", - "sessionKey", - "ts", - "type", - "value", - ]); - expect(typeof event.ts).toBe("number"); - expect({ ...event, ts: 0 }).toEqual({ - type: "custom", - event: "feedback", - ts: 0, - messageId: "bot-msg-1", - value: "positive", - comment: "allowed dm feedback", - sessionKey: "msteams:direct:owner-aad", - agentId: "default", - conversationId: "a:personal-chat", - }); }, }); }); @@ -290,47 +242,41 @@ describe("msteams feedback invoke authz", () => { senderName: "Attacker", comment: "blocked feedback", }, - assertResult: async ({ tmpDir }) => { - await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl")); + assertResult: async () => { + expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled(); expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled(); }, }); }); it("does not trigger reflection for a group sender outside groupAllowFrom", async () => { - const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-")); - try { - const deps = createDeps({ - cfg: { - session: { store: tmpDir }, - channels: { - msteams: { - groupPolicy: "allowlist", - groupAllowFrom: ["owner-aad"], - feedbackReflection: true, - }, + const deps = createDeps({ + cfg: { + channels: { + msteams: { + groupPolicy: "allowlist", + groupAllowFrom: ["owner-aad"], + feedbackReflection: true, }, - } as OpenClawConfig, - }); + }, + } as OpenClawConfig, + }); - await runMSTeamsFeedbackInvokeHandler( - createFeedbackInvokeContext({ - reaction: "dislike", - conversationId: "19:group@thread.tacv2;messageid=bot-msg-1", - conversationType: "groupChat", - senderId: "attacker-aad", - senderName: "Attacker", - teamId: "team-1", - channelName: "General", - comment: "blocked reflection", - }), - deps, - ); + await runMSTeamsFeedbackInvokeHandler( + createFeedbackInvokeContext({ + reaction: "dislike", + conversationId: "19:group@thread.tacv2;messageid=bot-msg-1", + conversationType: "groupChat", + senderId: "attacker-aad", + senderName: "Attacker", + teamId: "team-1", + channelName: "General", + comment: "blocked reflection", + }), + deps, + ); - await expectFileMissing(path.join(tmpDir, "msteams_group_19_group_thread_tacv2.jsonl")); - expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled(); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } + expect(channelInboundMockState.recordChannelFeedbackEvent).not.toHaveBeenCalled(); + expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled(); }); }); diff --git a/extensions/msteams/src/monitor-handler.test-helpers.ts b/extensions/msteams/src/monitor-handler.test-helpers.ts index 8df65e64c235..7b9740fffcdd 100644 --- a/extensions/msteams/src/monitor-handler.test-helpers.ts +++ b/extensions/msteams/src/monitor-handler.test-helpers.ts @@ -28,6 +28,8 @@ type MSTeamsTestRuntimeOptions = { }; export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void { + const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => undefined); + const resolveStorePath = options.resolveStorePath ?? (() => "/tmp/msteams-sessions.json"); const runPrepared = vi.fn(async (turn: PreparedInboundReply) => { await turn.recordInboundSession({ storePath: turn.storePath, @@ -63,7 +65,16 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = { : (preflightResult ?? {}); const turn = await params.adapter.resolveTurn(input, eventClass, preflight); if ("runDispatch" in turn) { - return await runPrepared(turn); + const preparedTurn = + "route" in turn + ? ({ + ...turn, + routeSessionKey: turn.route.sessionKey, + storePath: resolveStorePath(), + recordInboundSession, + } as PreparedInboundReply) + : turn; + return await runPrepared(preparedTurn); } throw new Error("msteams test runtime only supports prepared turn dispatch"); }); @@ -124,8 +135,8 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = { resolveHumanDelayConfig: () => undefined, }, session: { - recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined), - ...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}), + recordInboundSession, + resolveStorePath, }, inbound: { run: run as unknown as PluginRuntime["channel"]["inbound"]["run"], diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 482031032165..47de269c2c0d 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -2,9 +2,9 @@ import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from"; import { buildChannelInboundEventContext, + createChannelInboundEnvelopeBuilder, logInboundDrop, resolveInboundMentionDecision, - resolveInboundSessionEnvelopeContext, resolveInboundSupplementalSenderAllowed, } from "openclaw/plugin-sdk/channel-inbound"; import { @@ -788,17 +788,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { quoteSenderName ??= quoteInfo?.sender; const envelopeFrom = isDirectMessage ? senderName : conversationType; - const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({ - cfg, - agentId: route.agentId, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg, route }); + const body = buildEnvelope({ channel: "Teams", from: envelopeFrom, timestamp, - previousTimestamp, - envelope: envelopeOptions, body: agentBody, }); let combinedBody = body; @@ -811,12 +805,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - core.channel.reply.formatAgentEnvelope({ + buildEnvelope({ channel: "Teams", from: conversationType, timestamp: entry.timestamp, + previousTimestamp: null, body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, - envelope: envelopeOptions, }), }); } @@ -858,7 +852,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined; const ctxPayload = buildChannelInboundEventContext({ channel: "msteams", - finalize: core.channel.reply.finalizeInboundContext, contextVisibility: contextVisibilityMode, supplemental: { quote: quoteInfo @@ -975,12 +968,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { raw: activity, }), resolveTurn: () => ({ + cfg, channel: "msteams", accountId: route.accountId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, record: { onRecordError: (err) => { logVerboseMessage( diff --git a/extensions/msteams/src/reply-dispatcher.test.ts b/extensions/msteams/src/reply-dispatcher.test.ts index 0a72f7555a5d..dba4efc1ca1b 100644 --- a/extensions/msteams/src/reply-dispatcher.test.ts +++ b/extensions/msteams/src/reply-dispatcher.test.ts @@ -14,6 +14,14 @@ vi.mock("../runtime-api.js", () => ({ resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024), })); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, + }; +}); + vi.mock("./runtime.js", () => ({ getMSTeamsRuntime: getMSTeamsRuntimeMock, })); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 98f4d6353049..3cf8ec2ebf22 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,3 +1,4 @@ +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; // Msteams plugin module implements reply dispatcher behavior. import { buildChannelProgressDraftLine, @@ -8,6 +9,7 @@ import { resolveChannelStreamingPreviewToolProgress, resolveChannelStreamingSuppressDefaultToolProgressMessages, } from "openclaw/plugin-sdk/channel-outbound"; +import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createChannelMessageReplyPipeline, @@ -295,9 +297,9 @@ export function createMSTeamsReplyDispatcher(params: { dispatcher, replyOptions, markDispatchIdle: baseMarkDispatchIdle, - } = core.channel.reply.createReplyDispatcherWithTyping({ + } = createReplyDispatcherWithTyping({ ...replyPipeline, - humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId), onReplyStart: async () => { await streamController.onReplyStart(); // Always start the typing keepalive loop when typing is enabled and diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 423008add9f7..0c70b559944f 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,10 +1,13 @@ +import { + buildChannelInboundEventContext, + resolveChannelInboundRouteEnvelope, +} from "openclaw/plugin-sdk/channel-inbound"; // Nextcloud Talk plugin module implements inbound behavior. import { channelIngressRoutes, resolveStableChannelMessageIngress, } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; -import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { normalizeOptionalString, normalizeStringEntries, @@ -304,7 +307,7 @@ export async function handleNextcloudTalkInbound(params: { runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`); return; } - const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ + const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, @@ -312,14 +315,10 @@ export async function handleNextcloudTalkInbound(params: { kind: isGroup ? "group" : "direct", id: isGroup ? roomToken : senderId, }, - runtime: core.channel, - sessionStore: (config.session as Record | undefined)?.store as - | string - | undefined, }); const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`; - const { storePath, body } = buildEnvelope({ + const body = buildEnvelope({ channel: "Nextcloud Talk", from: fromLabel, timestamp: message.timestamp, @@ -329,42 +328,37 @@ export async function handleNextcloudTalkInbound(params: { const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt); const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config); - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: rawBody, - RawBody: rawBody, - CommandBody: rawBody, - From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`, - To: `nextcloud-talk:${roomToken}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isGroup ? "group" : "direct", - ConversationLabel: fromLabel, - SenderName: senderName || undefined, - SenderId: senderId, - GroupSubject: isGroup ? roomName || roomToken : undefined, - GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, - Provider: CHANNEL_ID, - Surface: CHANNEL_ID, - WasMentioned: isGroup ? wasMentioned : undefined, - MessageSid: message.messageId, - Timestamp: message.timestamp, - OriginatingChannel: CHANNEL_ID, - OriginatingTo: `nextcloud-talk:${roomToken}`, - CommandAuthorized: commandAuthorized, + const ctxPayload = buildChannelInboundEventContext({ + channel: CHANNEL_ID, + accountId: route.accountId, + messageId: message.messageId, + timestamp: message.timestamp, + from: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`, + sender: { id: senderId, name: senderName || undefined }, + conversation: { kind: isGroup ? "group" : "direct", id: roomToken, label: fromLabel }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: route.sessionKey, + }, + reply: { to: `nextcloud-talk:${roomToken}`, originatingTo: `nextcloud-talk:${roomToken}` }, + message: { body, bodyForAgent: rawBody, rawBody, commandBody: rawBody }, + access: { + commands: { authorized: commandAuthorized }, + mentions: { canDetectMention: isGroup, wasMentioned: isGroup && wasMentioned }, + }, + extra: { + GroupSubject: isGroup ? roomName || roomToken : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + }, }); - await core.channel.inbound.dispatchReply({ + await core.channel.inbound.dispatch({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { preparePayload: (payload) => payload.text === undefined diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index 0feefa867f6f..7db5aaf7d3e7 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -1,3 +1,4 @@ +import type { dispatchInboundDirectDm as DispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound"; // Nostr tests cover channel.inbound plugin behavior. import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -7,6 +8,7 @@ import { setNostrRuntime } from "./runtime.js"; import { buildResolvedNostrAccount } from "./test-fixtures.js"; const mocks = vi.hoisted(() => ({ + dispatchInboundDirectDm: vi.fn(), normalizePubkey: vi.fn((value: string) => value .trim() @@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({ startNostrBus: vi.fn(), })); +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => ({ + ...(await importOriginal()), + dispatchInboundDirectDm: mocks.dispatchInboundDirectDm, +})); vi.mock("./nostr-bus.js", () => ({ DEFAULT_RELAYS: ["wss://relay.example.com"], startNostrBus: mocks.startNostrBus, @@ -127,6 +133,7 @@ function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0 describe("nostr inbound gateway path", () => { afterEach(() => { + mocks.dispatchInboundDirectDm.mockReset(); mocks.normalizePubkey.mockClear(); mocks.startNostrBus.mockReset(); }); @@ -159,15 +166,19 @@ describe("nostr inbound gateway path", () => { }); it("routes allowed DMs through the standard reply pipeline", async () => { - const { harness, cleanup } = await startGatewayHarness({ + mocks.dispatchInboundDirectDm.mockImplementationOnce( + async (params: Parameters[0]) => { + await params.deliver({ text: "|a|b|" }); + }, + ); + const { cleanup } = await startGatewayHarness({ account: buildResolvedNostrAccount({ publicKey: "bot-pubkey", config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, }), cfg: { - session: { store: { type: "jsonl" } }, commands: { useAccessGroups: true }, - } as never, + }, }); const options = mockCallArg(mocks.startNostrBus) as { @@ -185,17 +196,18 @@ describe("nostr inbound gateway path", () => { createdAt: 1_710_000_000, }); - expect(harness.recordInboundSession).toHaveBeenCalledTimes(1); - expect(harness.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - const ctx = ( - mockCallArg(harness.dispatchReplyWithBufferedBlockDispatcher) as { - ctx?: Record; - } - ).ctx; - expect(ctx?.BodyForAgent).toBe("hello from nostr"); - expect(ctx?.SenderId).toBe("sender-pubkey"); - expect(ctx?.MessageSid).toBe("event-123"); - expect(ctx?.CommandAuthorized).toBe(true); + expect(mocks.dispatchInboundDirectDm).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "nostr", + accountId: "default", + peer: { kind: "direct", id: "sender-pubkey" }, + senderId: "sender-pubkey", + rawBody: "hello from nostr", + messageId: "event-123", + timestamp: 1_710_000_000_000, + commandAuthorized: true, + }), + ); expect(sendReply).toHaveBeenCalledWith("converted:|a|b|"); await cleanup.stop(); diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index e349bba37dde..b096cb6f0996 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -163,11 +163,9 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { return; } - const { dispatchInboundDirectDmWithRuntime } = - await import("./inbound-direct-dm-runtime.js"); - await dispatchInboundDirectDmWithRuntime({ + const { dispatchInboundDirectDm } = await import("./inbound-direct-dm-runtime.js"); + await dispatchInboundDirectDm({ cfg: ctx.cfg, - runtime, channel: "nostr", channelLabel: "Nostr", accountId: account.accountId, diff --git a/extensions/nostr/src/inbound-direct-dm-runtime.ts b/extensions/nostr/src/inbound-direct-dm-runtime.ts index 042aa68cc452..3f3b22b642ad 100644 --- a/extensions/nostr/src/inbound-direct-dm-runtime.ts +++ b/extensions/nostr/src/inbound-direct-dm-runtime.ts @@ -1,2 +1,2 @@ // Nostr plugin module implements inbound direct dm runtime behavior. -export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound"; +export { dispatchInboundDirectDm } from "openclaw/plugin-sdk/channel-inbound"; diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 83e8391a83a5..fba3e1321626 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -18,7 +18,7 @@ import { qaChannelPlugin, setQaChannelRuntime } from "../api.js"; import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js"; import type { ChannelMessageActionName } from "./runtime-api.js"; -type QaDispatchTurn = Parameters[0]; +type QaDispatchTurn = Parameters[0]; afterEach(() => { resetPluginRuntimeStateForTest(); @@ -66,7 +66,6 @@ function createMockQaRuntime(params?: { onDispatch?: (ctx: Record) => void; toolStarts?: Array<{ name?: string; phase?: string; args?: Record }>; }): PluginRuntime { - const sessionUpdatedAt = new Map(); return createPluginRuntimeMock({ channel: { mentions: { @@ -77,104 +76,24 @@ function createMockQaRuntime(params?: { return patterns.some((pattern) => pattern.test(text)); }, }, - routing: { - resolveAgentRoute({ - accountId, - peer, - }: { - accountId?: string | null; - peer?: { kind?: string; id?: string } | null; - }) { - return { - agentId: "qa-agent", - channel: "qa-channel", - accountId: accountId ?? "default", - sessionKey: `qa-agent:${peer?.kind ?? "direct"}:${peer?.id ?? "default"}`, - mainSessionKey: "qa-agent:main", - lastRoutePolicy: "session", - matchedBy: "default", - }; - }, - }, - session: { - resolveStorePath(_store: string | undefined, { agentId }: { agentId: string }) { - return agentId; - }, - readSessionUpdatedAt({ sessionKey }: { sessionKey: string }) { - return sessionUpdatedAt.get(sessionKey); - }, - recordInboundSession({ sessionKey }: { sessionKey: string }) { - sessionUpdatedAt.set(sessionKey, Date.now()); - }, - }, - reply: { - resolveEnvelopeFormatOptions() { - return {}; - }, - formatAgentEnvelope({ body }: { body: string }) { - return body; - }, - finalizeInboundContext(ctx: Record) { - return ctx as typeof ctx & { CommandAuthorized: boolean }; - }, - async dispatchReplyWithBufferedBlockDispatcher({ - ctx, - dispatcherOptions, - replyOptions, - }: { - ctx: { BodyForAgent?: string; Body?: string }; - dispatcherOptions: { - deliver: (payload: { text: string }, info: { kind: string }) => Promise; - }; - replyOptions?: { - onToolStart?: (payload: { - name?: string; - phase?: string; - args?: Record; - }) => Promise | void; - }; - }) { + inbound: { + async dispatch(turn: QaDispatchTurn) { for (const toolStart of params?.toolStarts ?? []) { - await replyOptions?.onToolStart?.(toolStart); + await turn.replyOptions?.onToolStart?.(toolStart); } - params?.onDispatch?.(ctx as Record); - await dispatcherOptions.deliver( + params?.onDispatch?.(turn.ctxPayload as Record); + await turn.delivery.deliver( { - text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`, + text: `qa-echo: ${turn.ctxPayload.BodyForAgent ?? turn.ctxPayload.Body ?? ""}`, }, { kind: "final" }, ); - }, - }, - inbound: { - async dispatchReply(turn: QaDispatchTurn) { - await turn.recordInboundSession({ - storePath: turn.storePath, - sessionKey: - typeof turn.ctxPayload.SessionKey === "string" - ? turn.ctxPayload.SessionKey - : turn.routeSessionKey, - ctx: turn.ctxPayload, - onRecordError: turn.record?.onRecordError ?? (() => undefined), - }); return { admission: turn.admission ?? { kind: "dispatch" as const }, dispatched: true, ctxPayload: turn.ctxPayload, - routeSessionKey: turn.routeSessionKey, - dispatchResult: await turn.dispatchReplyWithBufferedBlockDispatcher({ - ctx: turn.ctxPayload, - cfg: turn.cfg, - dispatcherOptions: { - ...turn.dispatcherOptions, - deliver: async (...args: Parameters) => { - await turn.delivery.deliver(...args); - }, - onError: turn.delivery.onError, - }, - replyOptions: turn.replyOptions, - replyResolver: turn.replyResolver, - }), + routeSessionKey: turn.route.sessionKey, + dispatchResult: undefined, }; }, }, @@ -505,7 +424,7 @@ describe("qa-channel plugin", () => { expect(ctx.ChatType).toBe("group"); expect(ctx.From).toBe("group:qa-room"); expect(ctx.To).toBe("group:qa-room"); - expect(ctx.SessionKey).toBe("qa-agent:group:group:qa-room"); + expect(ctx.SessionKey).toBe("agent:main:qa-channel:group:group:qa-room"); expect(ctx.SenderId).toBe("alice"); expect(ctx.GroupSubject).toBe("QA Room"); expect("conversation" in outbound).toBe(true); diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index 046af46a9b61..32576638d8a5 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -59,7 +59,7 @@ function createQaInboundParams( } function firstRunAssembledParams(runtime: ReturnType) { - const call = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]; + const call = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[0]; if (!call) { throw new Error("expected assembled turn call"); } @@ -262,7 +262,7 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); + expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1); const assembled = firstRunAssembledParams(runtime); expect(assembled.replyPipeline).toEqual({}); expect(assembled.ctxPayload.WasMentioned).toBe(true); @@ -280,7 +280,7 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); + expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); }); it("allows direct messages from configured senders", async () => { @@ -295,7 +295,7 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); + expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1); const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; expect(ctxPayload?.CommandAuthorized).toBe(true); expect(ctxPayload?.SenderId).toBe("alice"); @@ -318,14 +318,14 @@ describe("handleQaInbound", () => { expect(assembled.ctxPayload).toMatchObject({ CommandAuthorized: true, CommandSource: "native", - CommandTargetSessionKey: assembled.routeSessionKey, + CommandTargetSessionKey: assembled.route.sessionKey, CommandTurn: { body: "/stop", source: "native", }, }); expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice"); - expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.routeSessionKey); + expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.route.sessionKey); }); it("skips malformed inline attachment base64 without dropping the message", async () => { @@ -347,7 +347,7 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); + expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1); const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; expect(ctxPayload.MediaPath).toBeUndefined(); expect(ctxPayload.MediaPaths).toBeUndefined(); @@ -380,7 +380,7 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); + expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1); const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; expect(ctxPayload.MediaPath).toBeUndefined(); expect(ctxPayload.MediaPaths).toBeUndefined(); @@ -410,7 +410,7 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); + expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1); }); it("skips configured group messages that miss mention activation", async () => { @@ -438,6 +438,6 @@ describe("handleQaInbound", () => { }), ); - expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); + expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index 3df0a9f2de22..582fe22619b6 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -1,9 +1,12 @@ +import { + buildChannelInboundEventContext, + resolveChannelInboundRouteEnvelope, +} from "openclaw/plugin-sdk/channel-inbound"; // Qa Channel plugin module implements inbound behavior. import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { buildAgentMediaPayload, saveMediaBuffer, @@ -219,7 +222,7 @@ export async function handleQaInbound(params: { target, toolCalls, }); - const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ + const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg: params.config as OpenClawConfig, channel: params.channelId, accountId: params.account.accountId, @@ -232,8 +235,6 @@ export async function handleQaInbound(params: { : "channel", id: target, }, - runtime: runtime.channel, - sessionStore: params.config.session?.store, }); const isGroup = inbound.conversation.kind !== "direct"; const wasMentioned = isGroup @@ -286,7 +287,7 @@ export async function handleQaInbound(params: { if (access.ingress.admission !== "dispatch") { return; } - const { storePath, body } = buildEnvelope({ + const body = buildEnvelope({ channel: params.channelLabel, from: inbound.senderName || inbound.senderId, timestamp: inbound.timestamp, @@ -304,65 +305,64 @@ export async function handleQaInbound(params: { : undefined; const commandBody = nativeCommand ? `/${nativeCommand.name}` : inbound.text; - const ctxPayload = runtime.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: inbound.text, - RawBody: inbound.text, - CommandBody: commandBody, - From: target, - To: target, - SessionKey: commandTargets?.sessionKey ?? route.sessionKey, - CommandTargetSessionKey: commandTargets?.commandTargetSessionKey, - AccountId: route.accountId ?? params.account.accountId, - ChatType: inbound.conversation.kind === "direct" ? "direct" : "group", - WasMentioned: wasMentioned, - ConversationLabel: - inbound.threadTitle || - inbound.conversation.title || - inbound.senderName || - inbound.conversation.id, - GroupSubject: isGroup - ? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id + const sessionKey = commandTargets?.sessionKey ?? route.sessionKey; + const ctxPayload = buildChannelInboundEventContext({ + channel: params.channelId, + accountId: route.accountId ?? params.account.accountId, + messageId: inbound.id, + messageIdFull: inbound.id, + timestamp: inbound.timestamp, + from: target, + sender: { id: inbound.senderId, name: inbound.senderName }, + conversation: { + kind: inbound.conversation.kind === "direct" ? "direct" : "group", + id: inbound.conversation.id, + label: + inbound.threadTitle || + inbound.conversation.title || + inbound.senderName || + inbound.conversation.id, + threadId: inbound.threadId, + nativeChannelId: inbound.conversation.id, + }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: sessionKey, + dispatchSessionKey: sessionKey, + }, + reply: { + to: target, + originatingTo: target, + replyToId: inbound.replyToId, + messageThreadId: inbound.threadId, + threadParentId: inbound.threadId ? inbound.conversation.id : undefined, + }, + message: { body, bodyForAgent: inbound.text, rawBody: inbound.text, commandBody }, + access: { + commands: { authorized: true }, + mentions: { canDetectMention: isGroup, wasMentioned: Boolean(wasMentioned) }, + }, + command: nativeCommand + ? { kind: "native", name: nativeCommand.name, body: commandBody, authorized: true } : undefined, - GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined, - NativeChannelId: inbound.conversation.id, - MessageThreadId: inbound.threadId, - ThreadLabel: inbound.threadTitle, - ThreadParentId: inbound.threadId ? inbound.conversation.id : undefined, - SenderName: inbound.senderName, - SenderId: inbound.senderId, - Provider: params.channelId, - Surface: params.channelId, - MessageSid: inbound.id, - MessageSidFull: inbound.id, - ReplyToId: inbound.replyToId, - Timestamp: inbound.timestamp, - OriginatingChannel: params.channelId, - OriginatingTo: target, - CommandAuthorized: true, - CommandSource: nativeCommand ? "native" : undefined, - CommandTurn: nativeCommand - ? { - kind: "native", - source: "native", - authorized: true, - body: commandBody, - } - : undefined, - ...mediaPayload, + extra: { + CommandTargetSessionKey: commandTargets?.commandTargetSessionKey, + GroupSubject: isGroup + ? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id + : undefined, + GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined, + ThreadLabel: inbound.threadTitle, + ...mediaPayload, + }, }); - await runtime.channel.inbound.dispatchReply({ + await runtime.channel.inbound.dispatch({ cfg: params.config as OpenClawConfig, channel: params.channelId, accountId: params.account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: runtime.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { deliver: async (payload, info) => { const text = diff --git a/extensions/qqbot/src/engine/gateway/inbound-context.ts b/extensions/qqbot/src/engine/gateway/inbound-context.ts index 348ff4967cde..0dc44e593002 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-context.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-context.ts @@ -1,5 +1,6 @@ // Qqbot plugin module implements inbound context behavior. import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { EngineAdapters } from "../adapter/index.js"; import type { QQBotGroupCommandLevel } from "../config/group.js"; import type { GroupActivationMode } from "../group/activation.js"; @@ -69,7 +70,7 @@ export interface InboundContext { export interface InboundPipelineDeps { account: GatewayAccount; - cfg: unknown; + cfg: OpenClawConfig; log?: EngineLogger; runtime: GatewayPluginRuntime; startTyping: (event: QueuedMessage) => Promise<{ diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts index bdcce731ec5e..559105f9c946 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts @@ -421,10 +421,6 @@ export async function dispatchOutbound( }); } - const cfgWithSession = cfg as { session?: { store?: unknown } }; - const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { - agentId: routeAgentId, - }); const dispatchPromise = runtime.channel.inbound.run({ channel: "qqbot", accountId: inbound.route.accountId, @@ -438,12 +434,11 @@ export async function dispatchOutbound( raw: inbound, }), resolveTurn: () => ({ + cfg: openClawCfg, channel: "qqbot", accountId: inbound.route.accountId, - routeSessionKey: inbound.route.sessionKey, - storePath, + route: { agentId: routeAgentId, sessionKey: inbound.route.sessionKey }, ctxPayload, - recordInboundSession: runtime.channel.session.recordInboundSession, record: { onRecordError: (err: unknown) => { log?.error( @@ -773,7 +768,6 @@ async function buildCtxPayload( const commandSource = resolveCommandSource(inbound, runtime, cfg); const hasImageMedia = inbound.localMediaPaths.length > 0 || inbound.remoteMediaUrls.length > 0; return buildChannelInboundEventContext({ - finalize: runtime.channel.reply.finalizeInboundContext, channel: "qqbot", accountId: inbound.route.accountId, messageId: event.messageId, diff --git a/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts b/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts index 08a2cf5c4ec8..1ccd4d240226 100644 --- a/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts +++ b/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts @@ -80,13 +80,13 @@ function buildAllowAccess(): QQBotInboundAccess { } function buildDeps( - cfg: unknown, + cfg: StubCfg, runtime: GatewayPluginRuntime, account: GatewayAccount, ): InboundPipelineDeps { return { account, - cfg, + cfg: cfg as InboundPipelineDeps["cfg"], runtime, startTyping: vi.fn(), adapters: { diff --git a/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts b/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts index 1cfc25435db5..ca06923d1a96 100644 --- a/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts +++ b/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts @@ -15,6 +15,11 @@ * sees directly. */ +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, + type EnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { buildMergedMessageContext, @@ -103,13 +108,13 @@ export function buildAgentBody(input: BuildAgentBodyInput): string { return base; } - const envelopeOpts = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg); + const envelopeOpts = resolveEnvelopeFormatOptions(deps.cfg); return deps.adapters.history.buildPendingHistoryContext({ historyMap: deps.groupHistories, historyKey: event.groupOpenid, limit: groupInfo.historyLimit, currentMessage: base, - formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, deps, envelopeOpts), + formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, envelopeOpts), }); } @@ -139,19 +144,15 @@ function formatSenderLabelFrom(name: string | undefined, id: string): string { return name.includes(id) ? name : `${name} (${id})`; } -function formatHistoryEntry( - entry: HistoryEntry, - deps: InboundPipelineDeps, - envelopeOpts: unknown, -): string { +function formatHistoryEntry(entry: HistoryEntry, envelopeOpts: unknown): string { const attachmentDesc = formatAttachmentTags(entry.attachments); const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body; - return deps.runtime.channel.reply.formatInboundEnvelope({ + return formatInboundEnvelope({ channel: "qqbot", from: entry.sender, timestamp: entry.timestamp, body: bodyWithAttachments, chatType: "group", - envelope: envelopeOpts, + envelope: envelopeOpts as EnvelopeFormatOptions, }); } diff --git a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts b/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts index 4bb222e73fa9..e180d47e2ee8 100644 --- a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts +++ b/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts @@ -7,6 +7,11 @@ * dispatcher needs. No decisions / gating. */ +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ProcessedAttachments } from "../inbound-attachments.js"; import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js"; @@ -25,16 +30,16 @@ interface BuildBodyInput { /** Format the inbound envelope (Web UI body). */ export function buildBody(input: BuildBodyInput): string { const { event, deps, userContent, isGroupChat, imageUrls } = input; - const envelopeOptions = deps.runtime.channel.reply.resolveEnvelopeFormatOptions(deps.cfg); - return deps.runtime.channel.reply.formatInboundEnvelope({ + const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg as OpenClawConfig); + return formatInboundEnvelope({ channel: "qqbot", from: event.senderName ?? event.senderId, timestamp: new Date(event.timestamp).getTime(), body: userContent, + ...(imageUrls.length > 0 ? { imageUrls } : {}), chatType: isGroupChat ? "group" : "direct", sender: { id: event.senderId, name: event.senderName }, envelope: envelopeOptions, - ...(imageUrls.length > 0 ? { imageUrls } : {}), }); } diff --git a/extensions/raft/src/inbound.ts b/extensions/raft/src/inbound.ts index 3da5e2d9d295..c39a3e199d96 100644 --- a/extensions/raft/src/inbound.ts +++ b/extensions/raft/src/inbound.ts @@ -87,20 +87,12 @@ export async function dispatchRaftWake(params: { bodyForAgent: input.textForAgent, }, }); - const storePath = channelRuntime.session.resolveStorePath(ctx.cfg.session?.store, { - agentId: route.agentId, - }); return { cfg: ctx.cfg, channel: RAFT_CHANNEL_ID, accountId: ctx.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: channelRuntime.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher, // Raft's bridge only transports wake hints. The agent owns CLI delivery // after it reads the pending Raft messages, so OpenClaw must not emit a // duplicate synthetic reply through the channel dispatcher. diff --git a/extensions/reef/src/channel.ts b/extensions/reef/src/channel.ts index ee005f2aaa59..07ddb1c7434a 100644 --- a/extensions/reef/src/channel.ts +++ b/extensions/reef/src/channel.ts @@ -1,5 +1,5 @@ import { - dispatchInboundDirectDmWithRuntime, + dispatchInboundDirectDm, recordChannelBotPairLoopAndCheckSuppression, } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; @@ -235,9 +235,8 @@ export const reefPlugin: ChannelPlugin = { }); return; } - await dispatchInboundDirectDmWithRuntime({ + await dispatchInboundDirectDm({ cfg: ctx.cfg, - runtime, channel: "reef", channelLabel: "Reef", accountId: "default", @@ -293,9 +292,8 @@ export const reefPlugin: ChannelPlugin = { async (notice) => { let resendText = ""; let dispatchFailure: Error | undefined; - await dispatchInboundDirectDmWithRuntime({ + await dispatchInboundDirectDm({ cfg: ctx.cfg, - runtime, channel: "reef", channelLabel: "Reef", accountId: "default", diff --git a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index ff9f2a346863..b89ea4922241 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -79,6 +79,38 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { }; }); +vi.mock("openclaw/plugin-sdk/channel-inbound", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/channel-inbound", + ); + type RunParams = Parameters[0]; + return { + ...actual, + runChannelInboundEvent: (params: RunParams) => { + const resolveTurn = params.adapter.resolveTurn; + return actual.runChannelInboundEvent({ + ...params, + adapter: { + ...params.adapter, + resolveTurn: async (input, eventClass, preflight) => { + const resolved = await resolveTurn(input, eventClass, preflight); + if (!("route" in resolved) || !("runDispatch" in resolved)) { + return resolved; + } + const { route, ...turn } = resolved; + return { + ...turn, + routeSessionKey: route.sessionKey, + storePath: "/tmp/openclaw/signal-sessions.json", + recordInboundSession: recordInboundSessionMock, + }; + }, + }, + }); + }, + }; +}); + vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/conversation-runtime", diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index e0764bc8b242..ade69aeca086 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -33,7 +33,6 @@ import { resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/channel-policy"; import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection"; -import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { createInternalHookEvent, @@ -536,12 +535,11 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { raw: entry, }), resolveTurn: () => ({ + cfg: deps.cfg, channel: "signal", accountId: route.accountId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession, record: { updateLastRoute: !entry.isGroup ? { diff --git a/extensions/slack/src/authored-text.ts b/extensions/slack/src/authored-text.ts index 162b01583191..e68b8f465a40 100644 --- a/extensions/slack/src/authored-text.ts +++ b/extensions/slack/src/authored-text.ts @@ -1,5 +1,5 @@ // Slack-private authored text placement after block compilation. -import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; +import type { LegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export type SlackAuthoredTextPlacement = "none" | "blocks" | "outside-blocks"; @@ -10,7 +10,7 @@ function normalizeComparableSlackText(text: string): string { function isSlackAuthoredTextRepresentedInInteractive( text: string, - interactive?: InteractiveReply, + interactive?: LegacyInteractiveReply, ): boolean { return isSlackAuthoredTextRepresentedInFragments( text, @@ -43,7 +43,7 @@ function isSlackAuthoredTextRepresentedInFragments( /** Resolve placement from producer facts, before accessibility text changes the payload text. */ export function resolveSlackAuthoredTextPlacement(params: { text?: string; - interactive?: InteractiveReply; + interactive?: LegacyInteractiveReply; renderedInBlocks?: boolean; renderedTextFragments?: readonly string[]; }): SlackAuthoredTextPlacement { diff --git a/extensions/slack/src/blocks-render.ts b/extensions/slack/src/blocks-render.ts index d1cd9bbaaf27..81bf59aefbc1 100644 --- a/extensions/slack/src/blocks-render.ts +++ b/extensions/slack/src/blocks-render.ts @@ -2,12 +2,12 @@ import type { Block, KnownBlock } from "@slack/web-api"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; import { - reduceInteractiveReply, + reduceLegacyInteractiveReply, resolveMessagePresentationButtonAction, resolveMessagePresentationOptionAction, } from "openclaw/plugin-sdk/interactive-runtime"; import type { - InteractiveReply, + LegacyInteractiveReply, MessagePresentation, MessagePresentationAction, MessagePresentationButtonsBlock, @@ -228,7 +228,7 @@ export function resolveSlackBlockOffsets(blocks?: readonly SlackBlock[]): SlackB * @deprecated Use buildSlackPresentationBlocks with MessagePresentation. */ export function buildSlackInteractiveBlocks( - interactive?: InteractiveReply, + interactive?: LegacyInteractiveReply, options: SlackBlockRenderOptions = {}, ): SlackBlock[] { const initialState = { @@ -236,7 +236,7 @@ export function buildSlackInteractiveBlocks( buttonIndex: options.buttonIndexOffset ?? 0, selectIndex: options.selectIndexOffset ?? 0, }; - return reduceInteractiveReply(interactive, initialState, (state, block) => { + return reduceLegacyInteractiveReply(interactive, initialState, (state, block) => { if (block.type === "text") { const trimmed = block.text.trim(); if (!trimmed) { diff --git a/extensions/slack/src/delivery-trace.test.ts b/extensions/slack/src/delivery-trace.test.ts index 8e3420df1f15..d4ff882b2b64 100644 --- a/extensions/slack/src/delivery-trace.test.ts +++ b/extensions/slack/src/delivery-trace.test.ts @@ -2,7 +2,7 @@ // // Drives the real dispatch wiring (dispatchPreparedSlackMessage → deliverSlackPayload // → native stream / draft preview / preview finalize / deliverReplies → sendMessageSlack) -// with the core agent turn mocked at the dispatchReplyWithBufferedBlockDispatcher seam: +// with the core agent turn mocked at the channel-inbound dispatch seam: // the scripted steps stand in for the reply dispatcher callbacks (typing, partials, // tool progress, per-payload deliver). OUT events are the Slack Web API calls observed // at a recording WebClient stand-in. Native streaming runs through the REAL @@ -90,33 +90,35 @@ const traceState = vi.hoisted( // deliver/typing/replyOptions wiring (dedupe, thread plan, native stream ladder, // draft preview, preview finalize, deliverReplies chunking, sendMessageSlack) // stays the real production code. -vi.mock("./monitor/reply.runtime.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + type DispatchParams = Parameters[0]; return { ...actual, - dispatchReplyWithBufferedBlockDispatcher: async (params: { - dispatcherOptions: unknown; - replyOptions?: unknown; - }) => { + dispatchChannelInboundTurn: async (params: DispatchParams) => { traceState.turn = { - options: params.dispatcherOptions as CapturedDispatcherOptions, + options: { + ...params.dispatcherOptions, + deliver: params.delivery.deliver, + onError: params.delivery.onError, + } as CapturedDispatcherOptions, replyOptions: (params.replyOptions ?? {}) as CapturedReplyOptions, }; traceState.turnStarted?.resolve(); if (!traceState.turnOutcome) { throw new Error("trace turn outcome gate not initialized"); } - return await traceState.turnOutcome.promise; + return { + admission: { kind: "dispatch" }, + dispatched: true, + ctxPayload: params.ctxPayload, + routeSessionKey: params.route.sessionKey, + dispatchResult: await traceState.turnOutcome.promise, + }; }, }; }); -// Session-store recording is not wire behavior; keep the turn hermetic. -vi.mock("./monitor/conversation.runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, recordInboundSession: async () => {} }; -}); - // send.ts/actions.ts build their own WebClient from tokens; route every client // resolution to the scenario's recording client so all wire calls are captured. vi.mock("./client.js", async (importOriginal) => { @@ -138,8 +140,7 @@ vi.mock("./client.js", async (importOriginal) => { import { dispatchPreparedSlackMessage } from "./monitor/message-handler/dispatch.js"; afterAll(() => { - vi.doUnmock("./monitor/reply.runtime.js"); - vi.doUnmock("./monitor/conversation.runtime.js"); + vi.doUnmock("openclaw/plugin-sdk/channel-inbound"); vi.doUnmock("./client.js"); vi.resetModules(); }); diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 5c73645d972a..f1d45ac21d4e 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -5,7 +5,7 @@ import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { - normalizeInteractiveReply, + normalizeLegacyInteractiveReply, normalizeMessagePresentation, } from "openclaw/plugin-sdk/interactive-runtime"; import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; @@ -88,7 +88,7 @@ export async function handleSlackMessageAction(params: { }); const mediaUrl = readStringParam(actionParams, "media", { trim: false }); const presentation = normalizeMessagePresentation(actionParams.presentation); - const interactive = normalizeInteractiveReply(actionParams.interactive); + const interactive = normalizeLegacyInteractiveReply(actionParams.interactive); const hasStructuredContent = Boolean(presentation || interactive?.blocks.length); const resolution = resolveSlackReplyBlockResolution( { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index 828e1e3bc582..c1b4a556b71a 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -295,23 +295,16 @@ vi.mock("./monitor/config.runtime.js", async () => { }; }); -vi.mock("./monitor/reply.runtime.js", async () => { - const actual = await vi.importActual( - "./monitor/reply.runtime.js", - ); - type BufferedDispatchParams = Parameters< - typeof actual.dispatchReplyWithBufferedBlockDispatcher - >[0]; - type ReplyResolver = NonNullable; +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + type DispatchParams = Parameters[0]; + type ReplyResolver = NonNullable; const replyResolver: ReplyResolver = (...args) => slackTestState.replyMock(...args) as ReturnType; return { ...actual, - dispatchReplyWithBufferedBlockDispatcher: (params: BufferedDispatchParams) => - actual.dispatchReplyWithBufferedBlockDispatcher({ - ...params, - replyResolver, - }), + dispatchChannelInboundTurn: (params: DispatchParams) => + actual.dispatchChannelInboundTurn({ ...params, replyResolver }), }; }); @@ -349,7 +342,6 @@ vi.mock("./monitor/conversation.runtime.js", async () => { ...actual, readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args), - recordInboundSession: vi.fn().mockResolvedValue(undefined), upsertChannelPairingRequest: (...args: unknown[]) => slackTestState.upsertPairingRequestMock(...args), }; diff --git a/extensions/slack/src/monitor/conversation.runtime.ts b/extensions/slack/src/monitor/conversation.runtime.ts index d1a563519887..eb968a9ef5aa 100644 --- a/extensions/slack/src/monitor/conversation.runtime.ts +++ b/extensions/slack/src/monitor/conversation.runtime.ts @@ -2,7 +2,6 @@ export { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, - recordInboundSession, resolveConversationLabel, resolvePluginConversationBindingApproval, upsertChannelPairingRequest, 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 e41dced5fca4..f8d76440ad31 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 @@ -1,4 +1,5 @@ // Slack tests cover dispatch.preview fallback plugin behavior. +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const FINAL_REPLY_TEXT = "final answer"; @@ -13,7 +14,6 @@ const finalizeSlackPreviewEditMock = vi.fn(async () => {}); const normalizeSlackOutboundTextMock = vi.fn((value: string) => value.trim()); const postMessageMock = vi.fn(async () => ({ ok: true, ts: "171234.999" })); const chatUpdateMock = vi.fn(async () => ({ ok: true, ts: "171234.999" })); -const recordInboundSessionMock = vi.fn(async () => undefined); const recordSlackThreadParticipationMock = vi.fn(); const updateLastRouteMock = vi.fn(async () => {}); const appendSlackStreamMock = vi.fn(async () => {}); @@ -518,10 +518,6 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({ removeAckReactionAfterReply: () => {}, })); -vi.mock("../conversation.runtime.js", () => ({ - recordInboundSession: recordInboundSessionMock, -})); - vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => { const actual = await importOriginal(); return { @@ -1018,361 +1014,134 @@ vi.mock("../replies.js", () => ({ resolveSlackThreadTs: () => mockedReplyThreadTs, })); -vi.mock("../reply.runtime.js", () => ({ - createReplyDispatcherWithTyping: (params: { - transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null; - beforeDeliver?: ( - payload: TestReplyPayload, - info: { kind: TestReplyDispatchKind }, - ) => Promise | TestReplyPayload | null; - deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise; - }) => ({ - dispatcher: { - deliver: async (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => { - const transformed = params.transformReplyPayload - ? params.transformReplyPayload(payload) +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + type DispatchParams = Parameters[0]; + return { + ...actual, + dispatchChannelInboundTurn: async (params: DispatchParams) => { + capturedReplyOptions = params.replyOptions as typeof capturedReplyOptions; + if (mockedReplyOptionEvents.length > 0) { + for (const entry of mockedReplyOptionEvents) { + if (entry.kind === "item") { + await params.replyOptions?.onItemEvent?.({ + kind: entry.itemKind, + itemId: entry.itemId, + toolCallId: entry.toolCallId, + progressText: entry.progressText, + summary: entry.summary, + title: entry.title, + name: entry.name, + phase: entry.phase, + status: entry.status, + meta: entry.meta, + }); + } else if (entry.kind === "command_output") { + await params.replyOptions?.onCommandOutput?.({ + itemId: entry.itemId, + toolCallId: entry.toolCallId, + phase: entry.phase, + title: entry.title, + name: entry.name, + status: entry.status, + exitCode: entry.exitCode, + }); + } else if (entry.kind === "tool_start") { + await params.replyOptions?.onToolStart?.({ + itemId: entry.itemId, + toolCallId: entry.toolCallId, + name: entry.name, + phase: entry.phase, + args: entry.args, + detailMode: entry.detailMode, + }); + } else if (entry.kind === "patch") { + await params.replyOptions?.onPatchSummary?.({ + itemId: entry.itemId, + toolCallId: entry.toolCallId, + phase: entry.phase, + title: entry.title, + name: entry.name, + added: entry.added, + modified: entry.modified, + deleted: entry.deleted, + summary: entry.summary, + }); + } else if (entry.kind === "plan") { + await params.replyOptions?.onPlanUpdate?.({ + phase: entry.phase, + explanation: entry.explanation, + steps: entry.steps, + }); + } else if (entry.kind === "concurrent_items") { + await Promise.all( + entry.progressTexts.map((progressText) => + Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })), + ), + ); + } else if (entry.kind === "assistant_start") { + await params.replyOptions?.onAssistantMessageStart?.(); + } else if (entry.kind === "reasoning") { + await params.replyOptions?.onReasoningStream?.({ + text: entry.text, + isReasoningSnapshot: entry.isReasoningSnapshot, + }); + } else if (entry.kind === "reasoning_end") { + await params.replyOptions?.onReasoningEnd?.(); + } else { + await params.replyOptions?.onPartialReply?.({ text: entry.text }); + } + } + } else { + for (const progressText of mockedProgressEvents) { + await params.replyOptions?.onItemEvent?.({ progressText }); + } + } + for (const entry of mockedDispatchSequence) { + if (entry.kind === "queued_followup") { + await params.replyOptions?.onQueuedFollowupAdmitted?.(); + continue; + } + if (entry.kind === "item") { + await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText }); + continue; + } + const payload = entry.payload as ReplyPayload; + const transformed = params.dispatcherOptions?.transformReplyPayload + ? params.dispatcherOptions.transformReplyPayload(payload) : payload; if (!transformed) { - return; + continue; } - const deliverPayload = params.beforeDeliver - ? await params.beforeDeliver(transformed, info) + const deliverPayload = params.dispatcherOptions?.beforeDeliver + ? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind }) : transformed; if (!deliverPayload) { - return; + continue; } - mockedQueuedDispatchCounts[info.kind] += 1; - await params.deliver(deliverPayload, info); - }, + mockedQueuedDispatchCounts[entry.kind] += 1; + try { + await params.delivery.deliver(deliverPayload, { kind: entry.kind }); + } catch (error) { + if (!mockedDispatcherCapturesDeliveryErrors) { + throw error; + } + mockedQueuedDispatchCounts[entry.kind] -= 1; + } + } + return { + admission: { kind: "dispatch" } as const, + dispatched: true as const, + ctxPayload: params.ctxPayload, + routeSessionKey: params.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { ...mockedQueuedDispatchCounts }, + }, + }; }, - replyOptions: {}, - markDispatchIdle: () => {}, - }), - dispatchReplyWithBufferedBlockDispatcher: async (params: { - dispatcherOptions: { - transformReplyPayload?: (payload: TestReplyPayload) => TestReplyPayload | null; - beforeDeliver?: ( - payload: TestReplyPayload, - info: { kind: TestReplyDispatchKind }, - ) => Promise | TestReplyPayload | null; - deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise; - }; - replyOptions?: { - disableBlockStreaming?: boolean; - sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; - suppressTyping?: boolean; - suppressDefaultToolProgressMessages?: boolean; - onItemEvent?: (payload: { - kind?: string; - itemId?: string; - toolCallId?: string; - progressText?: string; - summary?: string; - title?: string; - name?: string; - phase?: string; - status?: string; - meta?: string; - }) => Promise | void; - onCommandOutput?: (payload: { - itemId?: string; - toolCallId?: string; - phase?: string; - title?: string; - name?: string; - status?: string; - exitCode?: number | null; - }) => Promise | void; - onToolStart?: (payload: { - itemId?: string; - toolCallId?: string; - name: string; - phase?: string; - args?: Record; - detailMode?: "explain" | "raw"; - }) => Promise | void; - onPatchSummary?: (payload: { - itemId?: string; - toolCallId?: string; - phase?: string; - title?: string; - name?: string; - added?: string[]; - modified?: string[]; - deleted?: string[]; - summary?: string; - }) => Promise | void; - onPlanUpdate?: (payload: { - phase?: string; - explanation?: string; - steps?: Array<{ - step: string; - status: "pending" | "in_progress" | "completed"; - }>; - }) => Promise | void; - onAssistantMessageStart?: () => Promise | void; - onReasoningEnd?: () => Promise | void; - onReasoningStream?: (payload?: { - text?: string; - isReasoningSnapshot?: boolean; - }) => Promise | void; - onPartialReply?: (payload: { text: string }) => Promise | void; - onQueuedFollowupAdmitted?: () => Promise | void; - }; - }) => { - capturedReplyOptions = params.replyOptions; - if (mockedReplyOptionEvents.length > 0) { - for (const entry of mockedReplyOptionEvents) { - if (entry.kind === "item") { - await params.replyOptions?.onItemEvent?.({ - kind: entry.itemKind, - itemId: entry.itemId, - toolCallId: entry.toolCallId, - progressText: entry.progressText, - summary: entry.summary, - title: entry.title, - name: entry.name, - phase: entry.phase, - status: entry.status, - meta: entry.meta, - }); - } else if (entry.kind === "command_output") { - await params.replyOptions?.onCommandOutput?.({ - itemId: entry.itemId, - toolCallId: entry.toolCallId, - phase: entry.phase, - title: entry.title, - name: entry.name, - status: entry.status, - exitCode: entry.exitCode, - }); - } else if (entry.kind === "tool_start") { - await params.replyOptions?.onToolStart?.({ - itemId: entry.itemId, - toolCallId: entry.toolCallId, - name: entry.name, - phase: entry.phase, - args: entry.args, - detailMode: entry.detailMode, - }); - } else if (entry.kind === "patch") { - await params.replyOptions?.onPatchSummary?.({ - itemId: entry.itemId, - toolCallId: entry.toolCallId, - phase: entry.phase, - title: entry.title, - name: entry.name, - added: entry.added, - modified: entry.modified, - deleted: entry.deleted, - summary: entry.summary, - }); - } else if (entry.kind === "plan") { - await params.replyOptions?.onPlanUpdate?.({ - phase: entry.phase, - explanation: entry.explanation, - steps: entry.steps, - }); - } else if (entry.kind === "concurrent_items") { - await Promise.all( - entry.progressTexts.map((progressText) => - Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })), - ), - ); - } else if (entry.kind === "assistant_start") { - await params.replyOptions?.onAssistantMessageStart?.(); - } else if (entry.kind === "reasoning") { - await params.replyOptions?.onReasoningStream?.({ - text: entry.text, - isReasoningSnapshot: entry.isReasoningSnapshot, - }); - } else if (entry.kind === "reasoning_end") { - await params.replyOptions?.onReasoningEnd?.(); - } else { - await params.replyOptions?.onPartialReply?.({ text: entry.text }); - } - } - } else { - for (const progressText of mockedProgressEvents) { - await params.replyOptions?.onItemEvent?.({ progressText }); - } - } - for (const entry of mockedDispatchSequence) { - if (entry.kind === "queued_followup") { - await params.replyOptions?.onQueuedFollowupAdmitted?.(); - continue; - } - if (entry.kind === "item") { - await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText }); - continue; - } - const transformed = params.dispatcherOptions.transformReplyPayload - ? params.dispatcherOptions.transformReplyPayload(entry.payload) - : entry.payload; - if (!transformed) { - continue; - } - const deliverPayload = params.dispatcherOptions.beforeDeliver - ? await params.dispatcherOptions.beforeDeliver(transformed, { kind: entry.kind }) - : transformed; - if (!deliverPayload) { - continue; - } - mockedQueuedDispatchCounts[entry.kind] += 1; - try { - await params.dispatcherOptions.deliver(deliverPayload, { kind: entry.kind }); - } catch (error) { - if (!mockedDispatcherCapturesDeliveryErrors) { - throw error; - } - mockedQueuedDispatchCounts[entry.kind] -= 1; - } - } - return { - queuedFinal: false, - counts: { ...mockedQueuedDispatchCounts }, - }; - }, - dispatchInboundMessage: async (params: { - replyOptions?: { - disableBlockStreaming?: boolean; - sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; - suppressTyping?: boolean; - suppressDefaultToolProgressMessages?: boolean; - onAssistantMessageStart?: () => Promise | void; - onReasoningEnd?: () => Promise | void; - onReasoningStream?: (payload?: { - text?: string; - isReasoningSnapshot?: boolean; - }) => Promise | void; - onItemEvent?: (payload: { - kind?: string; - itemId?: string; - progressText?: string; - summary?: string; - title?: string; - name?: string; - phase?: string; - status?: string; - meta?: string; - }) => Promise | void; - onToolStart?: (payload: { - itemId?: string; - toolCallId?: string; - name: string; - phase?: string; - args?: Record; - detailMode?: "explain" | "raw"; - }) => Promise | void; - onPatchSummary?: (payload: { - itemId?: string; - toolCallId?: string; - phase?: string; - title?: string; - name?: string; - added?: string[]; - modified?: string[]; - deleted?: string[]; - summary?: string; - }) => Promise | void; - onPlanUpdate?: (payload: { - phase?: string; - explanation?: string; - steps?: Array<{ - step: string; - status: "pending" | "in_progress" | "completed"; - }>; - }) => Promise | void; - onPartialReply?: (payload: { text: string }) => Promise | void; - onQueuedFollowupAdmitted?: () => Promise | void; - }; - dispatcher: { - deliver: (payload: TestReplyPayload, info: { kind: TestReplyDispatchKind }) => Promise; - }; - }) => { - capturedReplyOptions = params.replyOptions; - if (mockedReplyOptionEvents.length > 0) { - for (const entry of mockedReplyOptionEvents) { - if (entry.kind === "item") { - await params.replyOptions?.onItemEvent?.({ - kind: entry.itemKind, - itemId: entry.itemId, - progressText: entry.progressText, - summary: entry.summary, - title: entry.title, - name: entry.name, - phase: entry.phase, - status: entry.status, - meta: entry.meta, - }); - } else if (entry.kind === "tool_start") { - await params.replyOptions?.onToolStart?.({ - itemId: entry.itemId, - toolCallId: entry.toolCallId, - name: entry.name, - phase: entry.phase, - args: entry.args, - detailMode: entry.detailMode, - }); - } else if (entry.kind === "patch") { - await params.replyOptions?.onPatchSummary?.({ - itemId: entry.itemId, - toolCallId: entry.toolCallId, - phase: entry.phase, - title: entry.title, - name: entry.name, - added: entry.added, - modified: entry.modified, - deleted: entry.deleted, - summary: entry.summary, - }); - } else if (entry.kind === "plan") { - await params.replyOptions?.onPlanUpdate?.({ - phase: entry.phase, - explanation: entry.explanation, - steps: entry.steps, - }); - } else if (entry.kind === "concurrent_items") { - await Promise.all( - entry.progressTexts.map((progressText) => - Promise.resolve(params.replyOptions?.onItemEvent?.({ progressText })), - ), - ); - } else if (entry.kind === "partial") { - await params.replyOptions?.onPartialReply?.({ text: entry.text }); - } else if (entry.kind === "assistant_start") { - await params.replyOptions?.onAssistantMessageStart?.(); - } else if (entry.kind === "reasoning") { - await params.replyOptions?.onReasoningStream?.({ - text: entry.text, - isReasoningSnapshot: entry.isReasoningSnapshot, - }); - } else { - await params.replyOptions?.onReasoningEnd?.(); - } - } - } else { - for (const progressText of mockedProgressEvents) { - await params.replyOptions?.onItemEvent?.({ progressText }); - } - } - for (const entry of mockedDispatchSequence) { - if (entry.kind === "queued_followup") { - await params.replyOptions?.onQueuedFollowupAdmitted?.(); - continue; - } - if (entry.kind === "item") { - await params.replyOptions?.onItemEvent?.({ progressText: entry.progressText }); - continue; - } - await params.dispatcher.deliver(entry.payload, { kind: entry.kind }); - } - return { - queuedFinal: false, - counts: { ...mockedQueuedDispatchCounts }, - }; - }, -})); + }; +}); vi.mock("./preview-finalize.js", () => ({ finalizeSlackPreviewEdit: finalizeSlackPreviewEditMock, @@ -1392,7 +1161,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { normalizeSlackOutboundTextMock.mockClear(); postMessageMock.mockClear(); chatUpdateMock.mockClear(); - recordInboundSessionMock.mockReset(); recordSlackThreadParticipationMock.mockReset(); updateLastRouteMock.mockReset(); appendSlackStreamMock.mockReset(); @@ -1490,168 +1258,6 @@ describe("dispatchPreparedSlackMessage preview fallback", () => { expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { replyThreadTs: THREAD_TS }); }); - it("passes accepted Slack bot messages through the shared bot loop guard", async () => { - const base = { - cfg: { - channels: { - defaults: { - botLoopProtection: { - maxEventsPerWindow: 1, - windowSeconds: 60, - cooldownSeconds: 60, - }, - }, - }, - }, - accountConfig: { allowBots: true }, - message: { - channel: "C_LOOP_SLACK", - bot_id: "B_OTHER", - user: undefined, - }, - }; - - await dispatchPreparedSlackMessage( - createPreparedSlackMessage({ - ...base, - message: { - ...base.message, - ts: "900.001", - event_ts: "900.001", - }, - }), - ); - await dispatchPreparedSlackMessage( - createPreparedSlackMessage({ - ...base, - message: { - ...base.message, - ts: "900.002", - event_ts: "900.002", - }, - }), - ); - - expect(recordInboundSessionMock).toHaveBeenCalledTimes(1); - expect(deliverRepliesMock).toHaveBeenCalledTimes(1); - }); - - it("restores Slack status reactions when bot loop protection drops a turn", async () => { - const base = { - cfg: { - messages: { - statusReactions: { enabled: true }, - }, - channels: { - defaults: { - botLoopProtection: { - maxEventsPerWindow: 1, - windowSeconds: 60, - cooldownSeconds: 60, - }, - }, - }, - }, - accountConfig: { allowBots: true }, - message: { - channel: "C_LOOP_SLACK_STATUS", - bot_id: "B_OTHER", - user: undefined, - }, - }; - - await dispatchPreparedSlackMessage( - createPreparedSlackMessage({ - ...base, - message: { - ...base.message, - ts: "910.001", - event_ts: "910.001", - }, - }), - ); - - for (const value of Object.values(statusReactionControllerMock)) { - value.mockClear(); - } - - await dispatchPreparedSlackMessage( - createPreparedSlackMessage({ - ...base, - ackReactionMessageTs: "910.002", - ackReactionPromise: Promise.resolve(true), - message: { - ...base.message, - ts: "910.002", - event_ts: "910.002", - }, - }), - ); - - expect(recordInboundSessionMock).toHaveBeenCalledTimes(1); - expect(deliverRepliesMock).toHaveBeenCalledTimes(1); - expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1); - expect(statusReactionControllerMock.restoreInitial).toHaveBeenCalledTimes(1); - expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled(); - }); - - it("layers Slack channel bot loop overrides over account settings field-by-field", async () => { - const base = { - cfg: { - channels: { - defaults: { - botLoopProtection: { - maxEventsPerWindow: 20, - windowSeconds: 1, - cooldownSeconds: 60, - }, - }, - }, - }, - accountConfig: { - allowBots: true, - botLoopProtection: { - windowSeconds: 120, - cooldownSeconds: 240, - }, - }, - channelConfig: { - botLoopProtection: { - maxEventsPerWindow: 1, - }, - }, - message: { - channel: "C_LOOP_SLACK_LAYERED", - bot_id: "B_OTHER_LAYERED", - user: undefined, - }, - }; - - await dispatchPreparedSlackMessage( - createPreparedSlackMessage({ - ...base, - message: { - ...base.message, - ts: "900.001", - event_ts: "900.001", - }, - }), - ); - await dispatchPreparedSlackMessage( - createPreparedSlackMessage({ - ...base, - message: { - ...base.message, - ts: "961.001", - event_ts: "961.001", - }, - }), - ); - - expect(recordInboundSessionMock).toHaveBeenCalledTimes(1); - expect(deliverRepliesMock).toHaveBeenCalledTimes(1); - }); - it("updates non-main DM last-route metadata on the prepared direct session", async () => { mockedPinnedMainDmOwner = "U2"; await dispatchPreparedSlackMessage( diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 0731498e9180..466c018ba4f5 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -9,7 +9,7 @@ import { type StatusReactionAdapter, } from "openclaw/plugin-sdk/channel-feedback"; import { - dispatchChannelInboundReply, + dispatchChannelInboundTurn, type InboundReplyRecordOptions, } from "openclaw/plugin-sdk/channel-inbound"; import { @@ -89,7 +89,6 @@ import { resolveSlackThreadTargets } from "../../threading.js"; import type { SlackMessageEvent } from "../../types.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, @@ -98,7 +97,6 @@ import { resolveDeliveredSlackReplyThreadTs, resolveSlackThreadTs, } from "../replies.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../reply.runtime.js"; import { finalizeSlackPreviewEdit } from "./preview-finalize.js"; import { resolveSlackTimestampMs } from "./timestamp.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -2074,16 +2072,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag let queuedFinal = false; let counts: Partial> = {}; try { - const turnResult = await dispatchChannelInboundReply({ + const turnResult = await dispatchChannelInboundTurn({ cfg, channel: "slack", accountId: route.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath: prepared.turn.storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload: prepared.ctxPayload, - recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher, dispatcherOptions: { ...replyPipeline, humanDelay: resolveHumanDelayConfig(cfg, route.agentId), diff --git a/extensions/slack/src/monitor/reply.runtime.ts b/extensions/slack/src/monitor/reply.runtime.ts deleted file mode 100644 index f27dc5ee5c32..000000000000 --- a/extensions/slack/src/monitor/reply.runtime.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Slack plugin module implements reply behavior. -export { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime"; diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index fe23e07c4559..c294bcdc977b 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -8,7 +8,7 @@ import { } from "openclaw/plugin-sdk/channel-send-result"; import { normalizeMessagePresentation, - resolveInteractiveTextFallback, + resolveLegacyInteractiveTextFallback, } from "openclaw/plugin-sdk/interactive-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { @@ -299,7 +299,7 @@ export const slackOutbound: ChannelOutboundAdapter = { const payload = { ...ctx.payload, text: - resolveInteractiveTextFallback({ + resolveLegacyInteractiveTextFallback({ text: ctx.payload.text, interactive: ctx.payload.interactive, }) ?? "", diff --git a/extensions/sms/src/inbound.test.ts b/extensions/sms/src/inbound.test.ts index 45838fff21c8..4952248855c2 100644 --- a/extensions/sms/src/inbound.test.ts +++ b/extensions/sms/src/inbound.test.ts @@ -46,7 +46,9 @@ function createRuntime() { messageSid: string; accountSid: string; }) => unknown; - resolveTurn: (ingested: unknown) => Promise<{ routeSessionKey: string }>; + resolveTurn: ( + ingested: unknown, + ) => Promise<{ route: { agentId: string; sessionKey: string } }>; }; }) => void >(); @@ -172,6 +174,6 @@ describe("dispatchSmsInboundEvent", () => { }), }), ); - expect(turn.routeSessionKey).toBe("agent:main:sms:direct:+15551234567"); + expect(turn.route.sessionKey).toBe("agent:main:sms:direct:+15551234567"); }); }); diff --git a/extensions/sms/src/inbound.ts b/extensions/sms/src/inbound.ts index a0268943f4da..9033b7cb44f9 100644 --- a/extensions/sms/src/inbound.ts +++ b/extensions/sms/src/inbound.ts @@ -170,23 +170,12 @@ export async function dispatchSmsInboundEvent(params: { To: params.msg.to, }, }); - const storePath = params.channelRuntime.session.resolveStorePath( - params.cfg.session?.store, - { - agentId: route.agentId, - }, - ); return { cfg: params.cfg, channel: CHANNEL_ID, accountId: params.account.accountId, - agentId: route.agentId, - routeSessionKey: sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey }, ctxPayload, - recordInboundSession: params.channelRuntime.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { durable: () => ({ to: from, diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index 5ff5d61f66d4..fdc096fc07a9 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -14,7 +14,7 @@ export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () = ); export const dispatchReplyWithBufferedBlockDispatcher: Mock< - () => Promise<{ counts: Record }> + (_params: unknown) => Promise<{ counts: Record }> > = vi.fn().mockResolvedValue({ counts: {} }); export const finalizeInboundContextMock: Mock< (ctx: Record) => Record @@ -152,7 +152,7 @@ vi.mock("./runtime.js", () => ({ kind: "message", canStartAgentTurn: true, }); - const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({ + const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({ ctx: resolved.ctxPayload, cfg: mockRuntimeConfig, dispatcherOptions: { @@ -166,7 +166,7 @@ vi.mock("./runtime.js", () => ({ dispatched: true, dispatchResult, ctxPayload: resolved.ctxPayload, - routeSessionKey: resolved.routeSessionKey, + routeSessionKey: resolved.route.sessionKey, }; }), buildContext: buildChannelInboundEventContextMock, diff --git a/extensions/synology-chat/src/inbound-event.ts b/extensions/synology-chat/src/inbound-event.ts index 77719efd3f86..4e12b249410b 100644 --- a/extensions/synology-chat/src/inbound-event.ts +++ b/extensions/synology-chat/src/inbound-event.ts @@ -125,20 +125,15 @@ export async function dispatchSynologyChatInboundEvent(params: { CommandAuthorized: params.msg.commandAuthorized, }, }); - const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, { - agentId: resolved.route.agentId, - }); return { cfg: currentCfg, channel: CHANNEL_ID, accountId: params.account.accountId, - agentId: resolved.route.agentId, - routeSessionKey: resolved.route.sessionKey, - storePath, + route: { + agentId: resolved.route.agentId, + sessionKey: resolved.route.sessionKey, + }, ctxPayload: msgCtx, - recordInboundSession: resolved.rt.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { durable: () => ({ to: sendUserId, diff --git a/extensions/telegram/src/bot-message-dispatch-turn.ts b/extensions/telegram/src/bot-message-dispatch-turn.ts index 3bdf119b4481..637328a0680a 100644 --- a/extensions/telegram/src/bot-message-dispatch-turn.ts +++ b/extensions/telegram/src/bot-message-dispatch-turn.ts @@ -95,12 +95,14 @@ export async function runTelegramDispatchTurn(params: { raw: context, }), resolveTurn: () => ({ + cfg: params.cfg, channel: "telegram", accountId: context.route.accountId, - routeSessionKey: context.route.sessionKey, - storePath: context.turn.storePath, + route: { + agentId: context.route.agentId, + sessionKey: context.route.sessionKey, + }, ctxPayload: context.ctxPayload, - recordInboundSession: context.turn.recordInboundSession, record: context.turn.record, runDispatch: () => params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ diff --git a/extensions/telegram/src/bot-message-dispatch.test-harness.ts b/extensions/telegram/src/bot-message-dispatch.test-harness.ts index b4e23c5c4b73..1e0ad686ed47 100644 --- a/extensions/telegram/src/bot-message-dispatch.test-harness.ts +++ b/extensions/telegram/src/bot-message-dispatch.test-harness.ts @@ -165,6 +165,43 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => { }; }); +vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { + const actual = await importOriginal(); + type RunParams = Parameters[0]; + type TestTurn = { + storePath: string; + recordInboundSession: Parameters< + typeof actual.runPreparedInboundReply + >[0]["recordInboundSession"]; + }; + return { + ...actual, + runChannelInboundEvent: (params: RunParams) => { + const resolveTurn = params.adapter.resolveTurn; + return actual.runChannelInboundEvent({ + ...params, + adapter: { + ...params.adapter, + resolveTurn: async (input, eventClass, preflight) => { + const resolved = await resolveTurn(input, eventClass, preflight); + if (!("route" in resolved) || !("runDispatch" in resolved)) { + return resolved; + } + const { route, ...turn } = resolved; + const testTurn = (params.raw as { turn: TestTurn }).turn; + return { + ...turn, + routeSessionKey: route.sessionKey, + storePath: testTurn.storePath, + recordInboundSession: testTurn.recordInboundSession, + }; + }, + }, + }); + }, + }; +}); + vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => { const actual = await importOriginal(); diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index f64f6f87d71e..debe3350510b 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -1,12 +1,12 @@ // Telegram plugin module implements button types behavior. import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; -import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; +import { reduceLegacyInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; import { isMessagePresentationInteractiveBlock, normalizeMessagePresentation, - normalizeInteractiveReply, + normalizeLegacyInteractiveReply, resolveMessagePresentationButtonAction, - type InteractiveReply, + type LegacyInteractiveReply, type MessagePresentation, type MessagePresentationButton, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -100,9 +100,9 @@ function chunkInteractiveButtons( * @deprecated Use buildTelegramPresentationButtons with MessagePresentation. */ function buildTelegramInteractiveButtons( - interactive?: InteractiveReply, + interactive?: LegacyInteractiveReply, ): TelegramInlineButtons | undefined { - const rows = reduceInteractiveReply( + const rows = reduceLegacyInteractiveReply( interactive, [] as TelegramInlineButton[][], (state, block) => { @@ -159,7 +159,7 @@ export function resolveTelegramInlineButtons(params: { }): TelegramInlineButtons | undefined { return ( params.buttons ?? - buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) ?? + buildTelegramInteractiveButtons(normalizeLegacyInteractiveReply(params.interactive)) ?? buildTelegramPresentationButtons(normalizeMessagePresentation(params.presentation)) ); } diff --git a/extensions/telegram/src/interactive-fallback.ts b/extensions/telegram/src/interactive-fallback.ts index c637ab19fbd2..370a0ee92ad5 100644 --- a/extensions/telegram/src/interactive-fallback.ts +++ b/extensions/telegram/src/interactive-fallback.ts @@ -1,12 +1,12 @@ // Telegram plugin module implements interactive fallback behavior. import { adaptMessagePresentationForChannel, - interactiveReplyToPresentation, + legacyInteractiveReplyToPresentation, isMessagePresentationInteractiveBlock, normalizeMessagePresentation, - normalizeInteractiveReply, + normalizeLegacyInteractiveReply, renderMessagePresentationFallbackText, - resolveInteractiveTextFallback, + resolveLegacyInteractiveTextFallback, type MessagePresentation, type MessagePresentationInteractiveBlock, } from "openclaw/plugin-sdk/interactive-runtime"; @@ -122,7 +122,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload): capabilities: TELEGRAM_PRESENTATION_CAPABILITIES, }); - const interactive = normalizeInteractiveReply(payload.interactive); + const interactive = normalizeLegacyInteractiveReply(payload.interactive); const existingButtons = resolveTelegramInlineButtons({ buttons: telegramData?.buttons, interactive, @@ -141,7 +141,7 @@ export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload): presentation: { ...presentation, blocks: fallbackBlocks }, }); const currentText = - resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? ""; + resolveLegacyInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? ""; const hasFallback = fallbackText.length > 0 && (currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`)); @@ -168,8 +168,8 @@ export function resolveTelegramInteractiveTextFallback(params: { interactive?: unknown; presentation?: unknown; }): string | undefined { - const interactive = normalizeInteractiveReply(params.interactive); - const text = resolveInteractiveTextFallback({ + const interactive = normalizeLegacyInteractiveReply(params.interactive); + const text = resolveLegacyInteractiveTextFallback({ text: params.text ?? undefined, interactive, }); @@ -189,7 +189,7 @@ export function resolveTelegramInteractiveTextFallback(params: { if (!interactive) { return text; } - const interactivePresentation = interactiveReplyToPresentation(interactive); + const interactivePresentation = legacyInteractiveReplyToPresentation(interactive); if (!interactivePresentation) { return text; } diff --git a/extensions/telegram/src/voice.ts b/extensions/telegram/src/voice.ts index fae38b3973f9..eb3d96aeae19 100644 --- a/extensions/telegram/src/voice.ts +++ b/extensions/telegram/src/voice.ts @@ -1,5 +1,5 @@ // Telegram plugin module implements voice behavior. -import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; +import { isVoiceMessageCompatibleAudio } from "openclaw/plugin-sdk/media-runtime"; function resolveTelegramVoiceDecision(opts: { wantsVoice: boolean; @@ -9,7 +9,7 @@ function resolveTelegramVoiceDecision(opts: { if (!opts.wantsVoice) { return { useVoice: false }; } - if (isVoiceCompatibleAudio(opts)) { + if (isVoiceMessageCompatibleAudio(opts)) { return { useVoice: true }; } const contentType = opts.contentType ?? "unknown"; diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index c7508c7f6877..c767f1d0ea56 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,4 +1,5 @@ -// Tlon plugin entrypoint registers its OpenClaw integration. +import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; @@ -502,7 +503,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { @@ -596,17 +594,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise ({ to: `twitch:channel:${message.channel}`, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index eaa070526511..50860bd27237 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -5,7 +5,6 @@ import { type AckReactionHandle, } from "openclaw/plugin-sdk/channel-feedback"; import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound"; -import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { createInternalHookEvent, deriveInboundMessageHookContext, @@ -544,12 +543,11 @@ export async function processMessage(params: { }; }, resolveTurn: () => ({ + cfg: params.cfg, channel: "whatsapp", accountId: params.route.accountId, - routeSessionKey: params.route.sessionKey, - storePath, + route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey }, ctxPayload, - recordInboundSession, record: { onRecordError: (err) => { params.replyLogger.warn( diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 3dcaea3a0b1e..d6d5df33c568 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -53,7 +53,6 @@ export { type ReplyPayload, resolveClientIp, resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, resolveOpenProviderRuntimeGroupPolicy, resolveWebhookPath, resolveWebhookTargetWithAuthOrRejectSync, diff --git a/extensions/zalo/src/monitor.polling.media-reply.test-support.ts b/extensions/zalo/src/monitor.polling.media-reply.test-support.ts index c87e2e7bbaf8..6bbc0164e6b4 100644 --- a/extensions/zalo/src/monitor.polling.media-reply.test-support.ts +++ b/extensions/zalo/src/monitor.polling.media-reply.test-support.ts @@ -106,14 +106,6 @@ function countMatching(items: readonly T[], predicate: (item: T) => boolean): describe("Zalo polling media replies", () => { const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); const recordInboundSessionMock = vi.fn(async () => undefined); - const resolveAgentRouteMock = vi.fn(() => ({ - agentId: "main", - channel: "zalo", - accountId: "acct-zalo-polling-media", - sessionKey: "agent:main:zalo:direct:dm-chat-1", - mainSessionKey: "agent:main:main", - matchedBy: "default", - })); const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(); beforeAll(async () => { @@ -143,10 +135,6 @@ describe("Zalo polling media replies", () => { ); setLifecycleRuntimeCore( { - routing: { - resolveAgentRoute: - resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], - }, reply: { finalizeInboundContext: finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], diff --git a/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts b/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts index 64627de0548c..719e527f0f63 100644 --- a/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts +++ b/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts @@ -21,14 +21,6 @@ describe("Zalo reply-once lifecycle", () => { const recordInboundSessionMock = vi.fn( async (_input: { sessionKey?: string; ctx?: Record }) => undefined, ); - const resolveAgentRouteMock = vi.fn(() => ({ - agentId: "main", - channel: "zalo", - accountId: "acct-zalo-lifecycle", - sessionKey: "agent:main:zalo:direct:dm-chat-1", - mainSessionKey: "agent:main:main", - matchedBy: "default", - })); const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(); beforeAll(async () => { @@ -38,10 +30,6 @@ describe("Zalo reply-once lifecycle", () => { beforeEach(async () => { await resetLifecycleTestState(); setLifecycleRuntimeCore({ - routing: { - resolveAgentRoute: - resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], - }, reply: { finalizeInboundContext: finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], @@ -60,10 +48,17 @@ describe("Zalo reply-once lifecycle", () => { }); function createReplyOnceMonitorSetup() { - return createLifecycleMonitorSetup({ + const setup = createLifecycleMonitorSetup({ accountId: "acct-zalo-lifecycle", dmPolicy: "open", }); + return { + ...setup, + config: { + ...setup.config, + session: { dmScope: "per-channel-peer" as const }, + }, + }; } function requireRecordInboundSessionArgs() { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index ccf04543c7eb..f23d8d7a609e 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,15 +1,17 @@ // Zalo plugin module implements monitor behavior. import type { IncomingMessage, ServerResponse } from "node:http"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; -import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound"; +import { + formatInboundMediaUnavailableText, + resolveChannelInboundRouteEnvelope, +} from "openclaw/plugin-sdk/channel-inbound"; import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, type OutboundReplyPayload, } from "openclaw/plugin-sdk/reply-payload"; import { sleepWithAbort, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env"; @@ -568,7 +570,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization; const agentBody = agentBodyOverride ?? rawBody; - const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ + const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ cfg: config, channel: "zalo", accountId: account.accountId, @@ -576,8 +578,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr kind: isGroup ? ("group" as const) : ("direct" as const), id: chatId, }, - runtime: core.channel, - sessionStore: config.session?.store, }); if ( @@ -591,7 +591,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; const timestamp = resolveZaloTimestampMs(date); - const { storePath, body } = buildEnvelope({ + const body = buildEnvelope({ channel: "Zalo", from: fromLabel, timestamp, @@ -673,17 +673,12 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr }, }; - await core.channel.inbound.dispatchReply({ + await core.channel.inbound.dispatch({ cfg: config, channel: "zalo", accountId: account.accountId, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { preparePayload: (payload) => prepareZaloDurableReplyPayload({ diff --git a/extensions/zalo/src/runtime-api.ts b/extensions/zalo/src/runtime-api.ts index c6d41bb26af2..4d6def13a90a 100644 --- a/extensions/zalo/src/runtime-api.ts +++ b/extensions/zalo/src/runtime-api.ts @@ -63,7 +63,6 @@ export { isNumericTargetId, sendPayloadWithChunkedTextAndMedia, } from "./runtime-support.js"; -export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js"; export { waitForAbortSignal } from "./runtime-support.js"; export { WEBHOOK_ANOMALY_COUNTER_DEFAULTS, diff --git a/extensions/zalo/src/runtime-support.ts b/extensions/zalo/src/runtime-support.ts index 6caa3c0fd287..ccbcfc1a2bbf 100644 --- a/extensions/zalo/src/runtime-support.ts +++ b/extensions/zalo/src/runtime-support.ts @@ -64,7 +64,6 @@ export { isNumericTargetId, sendPayloadWithChunkedTextAndMedia, } from "openclaw/plugin-sdk/reply-payload"; -export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime"; export { applyBasicWebhookRequestGuards, diff --git a/extensions/zalo/src/test-support/lifecycle-test-support.ts b/extensions/zalo/src/test-support/lifecycle-test-support.ts index 445c6ab6e763..ec70ae015ef8 100644 --- a/extensions/zalo/src/test-support/lifecycle-test-support.ts +++ b/extensions/zalo/src/test-support/lifecycle-test-support.ts @@ -1,6 +1,9 @@ // Zalo plugin module implements lifecycle test support behavior. import { request as httpRequest } from "node:http"; -import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers"; +import { + createPluginRuntimeMediaMock, + createPluginRuntimeMock, +} from "openclaw/plugin-sdk/channel-test-helpers"; import { expect, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import type { ResolvedZaloAccount } from "../types.js"; @@ -190,7 +193,8 @@ export function createImageLifecycleCore() { })); const readAllowFromStoreMock = vi.fn(async () => [] as string[]); const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true })); - const core = { + const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(async () => undefined); + const core = createPluginRuntimeMock({ logging: { shouldLogVerbose: vi.fn( () => false, @@ -203,13 +207,6 @@ export function createImageLifecycleCore() { upsertPairingRequest: upsertPairingRequestMock as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"], }, - routing: { - resolveAgentRoute: vi.fn(() => ({ - agentId: "main", - accountId: "default", - sessionKey: "agent:main:zalo:direct:chat-123", - })) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], - }, session: { resolveStorePath: vi.fn( () => "/tmp/zalo-sessions.json", @@ -236,106 +233,10 @@ export function createImageLifecycleCore() { reply: { finalizeInboundContext: finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], - resolveEnvelopeFormatOptions: vi.fn(() => ({ - template: "channel+name+time", - })) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], - formatAgentEnvelope: vi.fn( - (opts: { body: string }) => opts.body, - ) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], - dispatchReplyWithBufferedBlockDispatcher: vi.fn( - async () => undefined, - ) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + dispatchReplyWithBufferedBlockDispatcher: + dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], }, inbound: { - run: 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 resolved = await params.adapter.resolveTurn( - input, - { - kind: "message", - canStartAgentTurn: true, - }, - {}, - ); - await resolved.recordInboundSession({ - storePath: resolved.storePath, - sessionKey: resolved.ctxPayload.SessionKey ?? resolved.routeSessionKey, - ctx: resolved.ctxPayload, - groupResolution: resolved.record?.groupResolution, - createIfMissing: resolved.record?.createIfMissing, - updateLastRoute: resolved.record?.updateLastRoute, - onRecordError: resolved.record?.onRecordError ?? (() => undefined), - }); - if ("runDispatch" in resolved) { - const dispatchResult = await resolved.runDispatch(); - return { - admission: { kind: "dispatch" as const }, - dispatched: true, - ctxPayload: resolved.ctxPayload, - routeSessionKey: resolved.routeSessionKey, - dispatchResult, - }; - } - const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({ - ctx: resolved.ctxPayload, - cfg: resolved.cfg, - dispatcherOptions: { - ...resolved.dispatcherOptions, - deliver: async (...args: Parameters) => { - await resolved.delivery.deliver(...args); - }, - onError: resolved.delivery.onError, - }, - replyOptions: resolved.replyOptions, - replyResolver: resolved.replyResolver, - }); - return { - admission: { kind: "dispatch" as const }, - dispatched: true, - ctxPayload: resolved.ctxPayload, - routeSessionKey: resolved.routeSessionKey, - dispatchResult, - }; - }) as unknown as PluginRuntime["channel"]["inbound"]["run"], - dispatchReply: 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), - }); - const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({ - ctx: params.ctxPayload, - cfg: params.cfg, - dispatcherOptions: { - ...params.dispatcherOptions, - deliver: async (...args: Parameters) => { - await params.delivery.deliver(...args); - }, - onError: params.delivery.onError, - }, - replyOptions: params.replyOptions, - replyResolver: params.replyResolver, - }); - return { - admission: params.admission ?? { kind: "dispatch" as const }, - dispatched: true, - ctxPayload: params.ctxPayload, - routeSessionKey: params.routeSessionKey, - dispatchResult, - }; - }, - ) as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"], buildContext: buildChannelInboundEventContextMock as unknown as PluginRuntime["channel"]["inbound"]["buildContext"], }, @@ -351,7 +252,7 @@ export function createImageLifecycleCore() { ) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"], }, }, - } as PluginRuntime; + }); return { core, finalizeInboundContextMock, diff --git a/extensions/zalouser/doctor-contract-api.test.ts b/extensions/zalouser/doctor-contract-api.test.ts index 3a3a44e0acfc..692349d2b3f4 100644 --- a/extensions/zalouser/doctor-contract-api.test.ts +++ b/extensions/zalouser/doctor-contract-api.test.ts @@ -1,4 +1,4 @@ -// Zalouser tests cover Doctor-owned credential migration. +// Zalouser tests cover Doctor-owned state migration. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -12,6 +12,7 @@ import type { OpenKeyedStoreOptions, PluginDoctorStateMigrationContext, } from "openclaw/plugin-sdk/runtime-doctor"; +import { listSessionEntries, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stateMigrations } from "./doctor-contract-api.js"; import { setZalouserRuntime } from "./src/runtime.js"; @@ -35,13 +36,23 @@ function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigration }; } +function findMigration(id: string) { + const migration = stateMigrations.find((entry) => entry.id === id); + if (!migration) { + throw new Error(`missing Zalouser state migration: ${id}`); + } + return migration; +} + describe("zalouser doctor state migration", () => { let stateDir = ""; + let storePath = ""; let env: NodeJS.ProcessEnv; beforeEach(async () => { resetPluginStateStoreForTests(); stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-doctor-")); + storePath = path.join(stateDir, "sessions.json"); env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; }); @@ -63,12 +74,7 @@ describe("zalouser doctor state migration", () => { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(legacy)); const createdAt = (await fs.stat(filePath)).mtime.toISOString(); - const migration = stateMigrations.find( - (entry) => entry.id === "zalouser-credentials-json-to-plugin-state", - ); - if (!migration) { - throw new Error("missing Zalouser credential migration"); - } + const migration = findMigration("zalouser-credentials-json-to-plugin-state"); const context = createDoctorContext(env); const params = { config: {}, @@ -131,9 +137,7 @@ describe("zalouser doctor state migration", () => { oauthDir: path.join(stateDir, "oauth"), context, }; - const migration = stateMigrations.find( - (entry) => entry.id === "zalouser-credentials-json-to-plugin-state", - )!; + const migration = findMigration("zalouser-credentials-json-to-plugin-state"); const result = await migration.migrateLegacyState(params); @@ -144,4 +148,90 @@ describe("zalouser doctor state migration", () => { ]); await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined(); }); + + it("moves legacy group-shaped DM sessions to canonical direct keys", async () => { + const legacyKey = "agent:main:zalouser:group:user-1"; + const canonicalKey = "agent:main:zalouser:direct:user-1"; + const config = { session: { store: storePath, dmScope: "per-channel-peer" as const } }; + await upsertSessionEntry({ + agentId: "main", + env, + storePath, + sessionKey: legacyKey, + entry: { + sessionId: "session-1", + updatedAt: 1, + chatType: "direct", + lastAccountId: "default", + }, + }); + await upsertSessionEntry({ + agentId: "main", + env, + storePath, + sessionKey: "agent:main:zalouser:group:room-1", + entry: { sessionId: "group-session", updatedAt: 2, chatType: "group" }, + }); + const migration = findMigration("zalouser-direct-session-keys"); + const context = createDoctorContext(env); + + expect( + await migration.detectLegacyState({ config, env, stateDir, oauthDir: stateDir, context }), + ).toMatchObject({ preview: [expect.stringContaining("1 legacy row")] }); + await expect( + migration.migrateLegacyState({ config, env, stateDir, oauthDir: stateDir, context }), + ).resolves.toMatchObject({ changes: [expect.stringContaining("Migrated 1")], warnings: [] }); + + const entries = new Map( + listSessionEntries({ agentId: "main", env, storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + entry, + ]), + ); + expect(entries.has(legacyKey)).toBe(false); + expect(entries.has("agent:main:zalouser:group:room-1")).toBe(true); + expect(entries.get(canonicalKey)).toMatchObject({ sessionId: "session-1", chatType: "direct" }); + }); + + it("keeps the freshest session when identity links collapse legacy peers", async () => { + const firstLegacyKey = "agent:main:zalouser:group:user-1"; + const secondLegacyKey = "agent:main:zalouser:group:user-2"; + const canonicalKey = "agent:main:zalouser:direct:alice"; + const config = { + session: { + store: storePath, + dmScope: "per-channel-peer" as const, + identityLinks: { alice: ["zalouser:user-1", "zalouser:user-2"] }, + }, + }; + for (const [sessionKey, sessionId, updatedAt] of [ + [firstLegacyKey, "freshest", 5], + [secondLegacyKey, "older", 2], + [canonicalKey, "canonical", 4], + ] as const) { + await upsertSessionEntry({ + agentId: "main", + env, + storePath, + sessionKey, + entry: { sessionId, updatedAt, chatType: "direct", lastAccountId: "default" }, + }); + } + const migration = findMigration("zalouser-direct-session-keys"); + const context = createDoctorContext(env); + + await expect( + migration.migrateLegacyState({ config, env, stateDir, oauthDir: stateDir, context }), + ).resolves.toMatchObject({ changes: [expect.stringContaining("Migrated 2")], warnings: [] }); + + const entries = new Map( + listSessionEntries({ agentId: "main", env, storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + entry, + ]), + ); + expect(entries.has(firstLegacyKey)).toBe(false); + expect(entries.has(secondLegacyKey)).toBe(false); + expect(entries.get(canonicalKey)).toMatchObject({ sessionId: "freshest", updatedAt: 5 }); + }); }); diff --git a/extensions/zalouser/doctor-contract-api.ts b/extensions/zalouser/doctor-contract-api.ts index 2a3bd12c51c3..fff4e4e961e7 100644 --- a/extensions/zalouser/doctor-contract-api.ts +++ b/extensions/zalouser/doctor-contract-api.ts @@ -2,11 +2,20 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { buildAgentSessionKey, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { archiveLegacyStateSource, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor"; +import { + deleteSessionEntry, + listSessionEntries, + resolveStorePath, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveZalouserDmSessionScope } from "./src/session-scope.js"; import { isZaloCredentialRevocation, normalizeStoredZaloCredentials, @@ -27,6 +36,16 @@ type LegacyZalouserCredentialSource = { profile: string; }; +type LegacyZalouserDmEntry = { + agentId: string; + canonicalKey: string; + entry: ReturnType[number]["entry"]; + legacyKeys: string[]; + storePath: string; +}; + +const LEGACY_ZALOUSER_DM_PREFIX = "zalouser:group:"; + async function collectLegacyZalouserCredentialSources( env: NodeJS.ProcessEnv, ): Promise { @@ -62,6 +81,58 @@ async function collectLegacyZalouserCredentialSources( .toSorted((left, right) => left.profile.localeCompare(right.profile)); } +function collectLegacyZalouserDmEntries( + config: OpenClawConfig, + env: NodeJS.ProcessEnv, +): LegacyZalouserDmEntry[] { + const entries = new Map(); + const fallbackAccountId = config.channels?.zalouser?.defaultAccount?.trim() || "default"; + const agentIds = new Set([ + "main", + ...(config.agents?.list ?? []).flatMap(({ id }) => (id?.trim() ? [id.trim()] : [])), + ]); + for (const agentId of agentIds) { + const storePath = resolveStorePath(config.session?.store, { agentId, env }); + const storedEntries = listSessionEntries({ agentId, storePath }); + const entryByKey = new Map(storedEntries.map(({ sessionKey, entry }) => [sessionKey, entry])); + for (const { sessionKey, entry } of storedEntries) { + const parsed = parseAgentSessionKey(sessionKey); + if (entry.chatType !== "direct" || !parsed?.rest.startsWith(LEGACY_ZALOUSER_DM_PREFIX)) { + continue; + } + const peerId = parsed.rest.slice(LEGACY_ZALOUSER_DM_PREFIX.length); + if (!peerId) { + continue; + } + const canonicalKey = buildAgentSessionKey({ + agentId: parsed.agentId, + channel: "zalouser", + accountId: entry.lastAccountId?.trim() || fallbackAccountId, + peer: { kind: "direct", id: peerId }, + dmScope: resolveZalouserDmSessionScope(config), + identityLinks: config.session?.identityLinks, + }); + const groupKey = `${storePath}\0${canonicalKey}`; + const canonicalEntry = entryByKey.get(canonicalKey); + const pending = entries.get(groupKey) ?? { + agentId, + canonicalKey, + // Identity links can collapse peers; preserve the freshest row, preferring canonical ties. + entry: + canonicalEntry && canonicalEntry.updatedAt >= entry.updatedAt ? canonicalEntry : entry, + legacyKeys: [], + storePath, + }; + pending.legacyKeys.push(sessionKey); + if (entry.updatedAt > pending.entry.updatedAt) { + pending.entry = entry; + } + entries.set(groupKey, pending); + } + } + return [...entries.values()]; +} + export const stateMigrations: PluginDoctorStateMigration[] = [ { id: "zalouser-credentials-json-to-plugin-state", @@ -154,4 +225,52 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ return { changes, warnings }; }, }, + { + id: "zalouser-direct-session-keys", + label: "Zalo Personal direct-message sessions", + detectLegacyState({ config, env }) { + const count = collectLegacyZalouserDmEntries(config, env).flatMap( + ({ legacyKeys }) => legacyKeys, + ).length; + return count > 0 + ? { preview: [`- Zalo Personal direct-message session keys: ${count} legacy row(s)`] } + : null; + }, + async migrateLegacyState({ config, env }) { + const pending = collectLegacyZalouserDmEntries(config, env); + const warnings: string[] = []; + let migrated = 0; + for (const entry of pending) { + try { + await upsertSessionEntry({ + agentId: entry.agentId, + env, + storePath: entry.storePath, + sessionKey: entry.canonicalKey, + entry: entry.entry, + }); + } catch (error) { + warnings.push(`Failed writing ${entry.canonicalKey}: ${String(error)}`); + continue; + } + for (const legacyKey of entry.legacyKeys) { + try { + await deleteSessionEntry({ + agentId: entry.agentId, + env, + storePath: entry.storePath, + sessionKey: legacyKey, + }); + migrated++; + } catch (error) { + warnings.push(`Failed removing ${legacyKey}: ${String(error)}`); + } + } + } + return { + changes: migrated > 0 ? [`Migrated ${migrated} Zalo Personal DM session key(s)`] : [], + warnings, + }; + }, + }, ]; diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index bd5823fe9ba5..1c9aaff13862 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -111,23 +111,33 @@ function installRuntime(params: { return params.commandAuthorized ?? false; }, ); - const resolveAgentRoute = vi.fn((input: { peer?: { kind?: string; id?: string } }) => { - const peerKind = input.peer?.kind === "direct" ? "direct" : "group"; - const peerId = input.peer?.id ?? "1"; - return { - agentId: "main", - sessionKey: - peerKind === "direct" ? "agent:main:main" : `agent:main:zalouser:${peerKind}:${peerId}`, - accountId: "default", - mainSessionKey: "agent:main:main", - }; - }); - const readAllowFromStore = vi.fn(async () => []); - const readSessionUpdatedAt = vi.fn( - (_params?: { storePath: string; sessionKey: string }): number | undefined => undefined, + const resolveAgentRoute = vi.fn( + (input: { dmScope?: string; peer?: { kind?: string; id?: string } }) => { + const peerKind = input.peer?.kind === "direct" ? "direct" : "group"; + const peerId = input.peer?.id ?? "1"; + return { + agentId: "main", + sessionKey: + peerKind === "direct" && input.dmScope === "main" + ? "agent:main:main" + : `agent:main:zalouser:${peerKind}:${peerId}`, + accountId: "default", + mainSessionKey: "agent:main:main", + }; + }, ); - type ResolvedTurn = Parameters[0]; - const dispatchAssembled = vi.fn(async (turn: ResolvedTurn) => { + const readAllowFromStore = vi.fn(async () => []); + type TurnPlan = Parameters[0]; + const recordInboundSession = vi.fn(async (_params: unknown) => {}); + const dispatch = vi.fn(async (plan: TurnPlan) => { + const turn = { + ...plan, + agentId: plan.route.agentId, + routeSessionKey: plan.route.sessionKey, + storePath: "/tmp", + recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher, + }; await turn.recordInboundSession({ storePath: turn.storePath, sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey, @@ -195,31 +205,6 @@ function installRuntime(params: { ...paramsLocal.extra, }) as Awaited>, ); - const buildAgentSessionKey = vi.fn( - (input: { - agentId: string; - channel: string; - accountId?: string; - peer?: { kind?: string; id?: string }; - dmScope?: string; - }) => { - const peerKind = input.peer?.kind === "direct" ? "direct" : "group"; - const peerId = input.peer?.id ?? "1"; - if (peerKind === "direct") { - if (input.dmScope === "per-account-channel-peer") { - return `agent:${input.agentId}:${input.channel}:${input.accountId ?? "default"}:direct:${peerId}`; - } - if (input.dmScope === "per-peer") { - return `agent:${input.agentId}:direct:${peerId}`; - } - if (input.dmScope === "main" || !input.dmScope) { - return "agent:main:main"; - } - } - return `agent:${input.agentId}:${input.channel}:${peerKind}:${peerId}`; - }, - ); - setZalouserRuntime({ logging: { shouldLogVerbose: () => false, @@ -259,13 +244,11 @@ function installRuntime(params: { }), }, routing: { - buildAgentSessionKey, resolveAgentRoute, }, session: { resolveStorePath: vi.fn(() => "/tmp"), - readSessionUpdatedAt, - recordInboundSession: vi.fn(async () => {}), + recordInboundSession, }, reply: { resolveEnvelopeFormatOptions: vi.fn(() => undefined), @@ -274,8 +257,7 @@ function installRuntime(params: { dispatchReplyWithBufferedBlockDispatcher, }, inbound: { - dispatchReply: - dispatchAssembled as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"], + dispatch, buildContext: buildContext as unknown as PluginRuntime["channel"]["inbound"]["buildContext"], }, @@ -294,8 +276,6 @@ function installRuntime(params: { resolveAgentRoute, resolveCommandAuthorizedFromAuthorizers, readAllowFromStore, - readSessionUpdatedAt, - buildAgentSessionKey, }; } @@ -489,19 +469,10 @@ describe("zalouser monitor group mention gating", () => { expect(callArg?.ctx?.CommandAuthorized).toBe(params.expectedCommandAuthorized); } - async function processOpenDmMessage(params?: { - message?: Partial; - readSessionUpdatedAt?: (input?: { - storePath: string; - sessionKey: string; - }) => number | undefined; - }) { + async function processOpenDmMessage(params?: { message?: Partial }) { const runtime = installRuntime({ commandAuthorized: false, }); - if (params?.readSessionUpdatedAt) { - runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt); - } const account = createAccount(); await processMessageWithDefaults({ message: createDmMessage(params?.message), @@ -874,19 +845,13 @@ describe("zalouser monitor group mention gating", () => { }); it("routes DM messages with direct peer kind", async () => { - const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } = + const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute } = await processOpenDmMessage(); const routeInput = mockCallArg(resolveAgentRoute, "resolve agent route") as { peer?: unknown; }; expect(routeInput?.peer).toEqual({ kind: "direct", id: "321" }); - const sessionKeyInput = mockCallArg(buildAgentSessionKey, "build agent session key") as { - dmScope?: string; - peer?: unknown; - }; - expect(sessionKeyInput?.peer).toEqual({ kind: "direct", id: "321" }); - expect(sessionKeyInput?.dmScope).toBe("per-channel-peer"); const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher); expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:direct:321"); }); @@ -906,16 +871,6 @@ describe("zalouser monitor group mention gating", () => { expect(callArg?.ctx?.ReplyToIsQuote).toBe(true); }); - it("reuses the legacy DM session key when only the old group-shaped session exists", async () => { - const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({ - readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) => - input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined, - }); - - const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher); - expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321"); - }); - it("skips pairing store read for open DM control commands", async () => { const { readAllowFromStore } = installRuntime({ commandAuthorized: false, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index cf1eeb2ae039..39be33374e91 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,5 +1,6 @@ import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk/allow-from"; import { + createChannelInboundEnvelopeBuilder, implicitMentionKindWhen, resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; @@ -45,6 +46,7 @@ import { sendSeenZalouser, sendTypingZalouser, } from "./send.js"; +import { resolveZalouserDmSessionScope } from "./session-scope.js"; import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js"; import { listZaloFriends, @@ -134,11 +136,6 @@ function resolveInboundQueueKey(message: ZaloInboundMessage): string { return `direct:${senderId || threadId}`; } -function resolveZalouserDmSessionScope(config: OpenClawConfig) { - const configured = config.session?.dmScope; - return configured === "main" || !configured ? "per-channel-peer" : configured; -} - function resolveZalouserRouteAccess(params: { groupPolicy: "open" | "disabled" | "allowlist"; configured: boolean; @@ -173,51 +170,6 @@ function senderScopedZalouserGroupPolicy(params: { return params.groupAllowFrom.length > 0 ? "allowlist" : "open"; } -function resolveZalouserInboundSessionKey(params: { - core: ZalouserCoreRuntime; - config: OpenClawConfig; - route: { agentId: string; accountId: string; sessionKey: string }; - storePath: string; - isGroup: boolean; - senderId: string; -}): string { - if (params.isGroup) { - return params.route.sessionKey; - } - - const directSessionKey = normalizeLowercaseStringOrEmpty( - params.core.channel.routing.buildAgentSessionKey({ - agentId: params.route.agentId, - channel: "zalouser", - accountId: params.route.accountId, - peer: { kind: "direct", id: params.senderId }, - dmScope: resolveZalouserDmSessionScope(params.config), - identityLinks: params.config.session?.identityLinks, - }), - ); - const legacySessionKey = normalizeLowercaseStringOrEmpty( - params.core.channel.routing.buildAgentSessionKey({ - agentId: params.route.agentId, - channel: "zalouser", - accountId: params.route.accountId, - peer: { kind: "group", id: params.senderId }, - }), - ); - const hasDirectSession = - params.core.channel.session.readSessionUpdatedAt({ - storePath: params.storePath, - sessionKey: directSessionKey, - }) !== undefined; - const hasLegacySession = - params.core.channel.session.readSessionUpdatedAt({ - storePath: params.storePath, - sessionKey: legacySessionKey, - }) !== undefined; - - // Keep existing DM history on upgrade, but use canonical direct keys for new sessions. - return hasLegacySession && !hasDirectSession ? legacySessionKey : directSessionKey; -} - function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { runtime.log(`[zalouser] ${message}`); @@ -481,8 +433,9 @@ async function processMessage( cfg: config, channel: "zalouser", accountId: account.accountId, + dmScope: resolveZalouserDmSessionScope(config), peer: { - // Keep DM peer kind as "direct" so session keys follow dmScope and UI labels stay DM-shaped. + // Doctor migrates retired group-shaped DM keys; runtime consumes only canonical direct keys. kind: peer.kind, id: peer.id, }, @@ -562,28 +515,11 @@ async function processMessage( } const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`; - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); - const inboundSessionKey = resolveZalouserInboundSessionKey({ - core, - config, - route, - storePath, - isGroup, - senderId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: inboundSessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ + const buildEnvelope = createChannelInboundEnvelopeBuilder({ cfg: config, route }); + const body = buildEnvelope({ channel: "Zalo Personal", from: fromLabel, timestamp: message.timestampMs, - previousTimestamp, - envelope: envelopeOptions, body: rawBody, }); const combinedBody = @@ -593,11 +529,11 @@ async function processMessage( limit: historyState.historyLimit, currentMessage: body, formatEntry: (entry) => - core.channel.reply.formatAgentEnvelope({ + buildEnvelope({ channel: "Zalo Personal", from: fromLabel, timestamp: entry.timestamp, - envelope: envelopeOptions, + previousTimestamp: null, body: `${entry.sender}: ${entry.body}${ entry.messageId ? ` [id:${entry.messageId}]` : "" }`, @@ -643,7 +579,7 @@ async function processMessage( agentId: route.agentId, accountId: route.accountId, routeSessionKey: route.sessionKey, - dispatchSessionKey: inboundSessionKey, + dispatchSessionKey: route.sessionKey, }, reply: { to: normalizedTo, @@ -686,17 +622,12 @@ async function processMessage( }, }; - await core.channel.inbound.dispatchReply({ + await core.channel.inbound.dispatch({ channel: "zalouser", accountId: account.accountId, cfg: config, - agentId: route.agentId, - routeSessionKey: route.sessionKey, - storePath, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, ctxPayload, - recordInboundSession: core.channel.session.recordInboundSession, - dispatchReplyWithBufferedBlockDispatcher: - core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { preparePayload: (payload) => { if (payload.text === undefined) { diff --git a/extensions/zalouser/src/session-scope.ts b/extensions/zalouser/src/session-scope.ts new file mode 100644 index 000000000000..178b9ca56e90 --- /dev/null +++ b/extensions/zalouser/src/session-scope.ts @@ -0,0 +1,6 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; + +export function resolveZalouserDmSessionScope(config: OpenClawConfig) { + const configured = config.session?.dmScope; + return configured === "main" || !configured ? "per-channel-peer" : configured; +} diff --git a/scripts/check-no-deprecated-channel-access.ts b/scripts/check-no-deprecated-channel-access.ts index 5b91b4dd95e6..7bcb26cb22ca 100644 --- a/scripts/check-no-deprecated-channel-access.ts +++ b/scripts/check-no-deprecated-channel-access.ts @@ -10,6 +10,11 @@ type Rule = { }; const RULES: Rule[] = [ + { + label: "deprecated channel runtime", + pattern: + /\.channel\.(?:reply\.(?:createReplyDispatcherWithTyping|resolveHumanDelayConfig|dispatchReplyFromConfig|finalizeInboundContext|formatInboundEnvelope)|session\.(?:resolveStorePath|recordInboundSession)|inbound\.(?:runPreparedReply|dispatchReply)|media\.fetchRemoteMedia)\b/u, + }, { label: "deprecated channel ingress resolver aliases", pattern: @@ -85,7 +90,7 @@ function main() { if (offenders.length > 0) { console.error( - "Bundled plugin production code must use modern channel access results, not deprecated compatibility seams.", + "Bundled plugin production code must use modern channel runtime and access seams.", ); for (const offender of offenders) { console.error(`- ${offender.file}:${offender.line}: ${offender.label}: ${offender.text}`); @@ -93,7 +98,9 @@ function main() { process.exit(1); } - console.log("OK: bundled plugin production code avoids deprecated channel access seams."); + console.log( + "OK: bundled plugin production code avoids deprecated channel runtime and access seams.", + ); } main(); diff --git a/scripts/lib/deprecated-plugin-sdk-usage.mjs b/scripts/lib/deprecated-plugin-sdk-usage.mjs index f2b84316e4e3..814d07f2289f 100644 --- a/scripts/lib/deprecated-plugin-sdk-usage.mjs +++ b/scripts/lib/deprecated-plugin-sdk-usage.mjs @@ -51,6 +51,10 @@ export const BANNED_INTERNAL_PLUGIN_SDK_FACADE_MODULES = [ modulePath: "src/plugin-sdk/inbound-reply-dispatch", canonical: "openclaw/plugin-sdk/channel-inbound", }, + { + modulePath: "src/plugin-sdk/inbound-envelope", + canonical: "openclaw/plugin-sdk/channel-inbound", + }, // Shared dispatch bridge backing the facades above; only the SDK seams may // consume it directly so channel code stays on channel-inbound/channel-outbound. { diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 32edadded6f8..8ae5ccb09c28 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -259,7 +259,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // after harvesting exports orphaned by the split-out WhatsApp adapter (#108656). // +10: supplemental sender helpers plus host-owned SQLite lease contracts. // Harvest: retired dual-field plan payload builder -1. - 8045, + // +23: core channel, envelope, direct-DM, feedback, legacy-payload, and memory contracts. + 8068, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -291,7 +292,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // WhatsApp-split harvest (#108656). // +3: supplemental sender helpers plus the PluginStateLeaseRunner callback. // Harvest: retired dual-field plan payload builder -1. - 4488, + // +13: core channel, envelope, direct-DM, feedback, legacy-payload, and memory operations. + 4501, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( @@ -317,7 +319,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS", // Used-union narrowing removes 103 wildcard re-exports. // Harvest: freeze the compat config-schema barrel to explicit exports -1. - 105, + 104, env, ), }; diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts index 134c9b8d2f56..9bffd4673116 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.ts @@ -237,8 +237,7 @@ export function prepareEmbeddedAttemptStream(input: { onAssistantMessageStart: attempt.onAssistantMessageStart, onExecutionPhase: attempt.onExecutionPhase, onAgentEvent: attempt.onAgentEvent, - terminalLifecyclePhase: - (attempt.deferTerminalLifecycle ?? attempt.deferTerminalLifecycleEnd) ? "finishing" : "end", + terminalLifecyclePhase: attempt.deferTerminalLifecycle ? "finishing" : "end", onToolStreamBoundary: attempt.onToolStreamBoundary, isTerminalAborted: () => input.getRunState().aborted, resolveTerminalStopReason: () => diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index 5ce6cda67bf9..a06438304c36 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -318,8 +318,8 @@ export async function dispatchEmbeddedRunAttempt(input: { onToolResult: control.onToolResult, onAgentToolResult: params.onAgentToolResult, onAgentEvent: control.onAgentEvent, + // Normalize the shipped harness alias once; attempt internals consume only the canonical flag. deferTerminalLifecycle: params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd, - deferTerminalLifecycleEnd: params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd, onExecutionPhase: params.onExecutionPhase, extraSystemPrompt: params.extraSystemPrompt, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, diff --git a/src/agents/embedded-agent-subscribe.tools.ts b/src/agents/embedded-agent-subscribe.tools.ts index 7d22c81157e1..6020a9c0cb4c 100644 --- a/src/agents/embedded-agent-subscribe.tools.ts +++ b/src/agents/embedded-agent-subscribe.tools.ts @@ -12,7 +12,10 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index. import type { ChannelMessageActionName } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; -import { normalizeInteractiveReply, normalizeMessagePresentation } from "../interactive/payload.js"; +import { + normalizeLegacyInteractiveReply, + normalizeMessagePresentation, +} from "../interactive/payload.js"; import { redactSecrets, redactSensitiveFieldValue, @@ -576,7 +579,7 @@ export function extractMessagingToolSourceReplyPayload( if (presentation) { payload.presentation = presentation; } - const interactive = normalizeInteractiveReply(sourceReply.interactive); + const interactive = normalizeLegacyInteractiveReply(sourceReply.interactive); if (interactive) { payload.interactive = interactive; } diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index dbd03e999619..55cb35bae3ab 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -133,9 +133,10 @@ export type AgentHarnessResultClassification = | NonNullable; export type AgentHarnessDeliveryDefaults = { + /** Default visible-reply policy when config does not override the harness. */ + visibleReplies?: "automatic" | "message_tool"; /** - * @deprecated Prefer `messages.visibleReplies` / `messages.groupChat.visibleReplies` - * config. Kept for existing harness plugins. + * @deprecated Use visibleReplies. Kept for existing harness plugins. */ sourceVisibleReplies?: "automatic" | "message_tool"; }; diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 8d328ab47819..2cbd8ce2365e 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -157,10 +157,13 @@ describe("withReplyDispatcher", () => { ctx: buildTestCtx(), cfg: {} as OpenClawConfig, dispatcher, + onSettled: () => { + order.push("onSettled"); + }, replyResolver: async () => ({ text: "ok" }), }); - expect(order).toEqual(["sendFinalReply", "markComplete", "waitForIdle"]); + expect(order).toEqual(["sendFinalReply", "markComplete", "waitForIdle", "onSettled"]); }); it("emits message.received diagnostics before dispatch", async () => { diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 4bac1808440a..92957c7f0a51 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -519,6 +519,7 @@ export async function dispatchInboundMessage(params: { replyResolver?: InternalGetReplyFromConfig; onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void; replyPayloadRunState?: ReplyPayloadRunState; + onSettled?: () => void | Promise; }): Promise { const replyOptions = applyRuntimeToolsAllow(params.replyOptions, params.toolsAllow); const replyPayloadRunState = params.replyPayloadRunState ?? { @@ -546,6 +547,7 @@ export async function dispatchInboundMessage(params: { installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, replyPayloadRunState); const result = await withReplyDispatcher({ dispatcher: params.dispatcher, + onSettled: params.onSettled, run: () => measureDiagnosticsTimelineSpan( "auto_reply.dispatch_reply_from_config", diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 43c2d0623d95..51b93c2dd295 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -14,7 +14,7 @@ import { } from "../infra/format-time/format-datetime.ts"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; -type AgentEnvelopeParams = { +export type AgentEnvelopeParams = { channel: string; from?: string; timestamp?: number | Date; diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index ee03f96b80d6..c4f8457932f6 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -7,7 +7,10 @@ import type { SessionEntry } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { ExecApprovalRequest } from "../../infra/exec-approvals.js"; -import type { InteractiveReply, MessagePresentationAction } from "../../interactive/payload.js"; +import type { + LegacyInteractiveReply, + MessagePresentationAction, +} from "../../interactive/payload.js"; import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; import type { PluginCommandDiagnosticsSession, PluginCommandResult } from "../../plugins/types.js"; import type { ReplyPayload } from "../types.js"; @@ -579,7 +582,7 @@ function rewriteCodexDiagnosticsResult(result: PluginCommandResult): PluginComma }; } -function rewriteInteractive(interactive: InteractiveReply): InteractiveReply { +function rewriteInteractive(interactive: LegacyInteractiveReply): LegacyInteractiveReply { return { blocks: interactive.blocks.map((block) => { if (block.type === "buttons") { diff --git a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts index eee9d35880cd..5ad56708dfbf 100644 --- a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts +++ b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts @@ -259,7 +259,9 @@ export function resolveHarnessSourceVisibleRepliesDefault(params: { params.entry?.modelSelectionLocked === true ? params.entry.agentHarnessId : undefined, agentHarnessRuntimeOverride, }); - return harness.deliveryDefaults?.sourceVisibleReplies; + return ( + harness.deliveryDefaults?.visibleReplies ?? harness.deliveryDefaults?.sourceVisibleReplies + ); }; const selectedModelCandidate = turnModelCandidate ?? storedModelCandidate ?? channelModelCandidate; diff --git a/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts index 9deec41cef32..b56802c863c6 100644 --- a/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts @@ -627,7 +627,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: () => ({ supported: true, priority: 100 }), runAttempt: vi.fn(async () => ({}) as never), }); @@ -664,7 +664,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -715,7 +715,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: () => ({ supported: true, priority: 100 }), runAttempt: vi.fn(async () => ({}) as never), }); @@ -749,7 +749,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -794,7 +794,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -847,7 +847,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -897,7 +897,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: () => ({ supported: true, priority: 100 }), runAttempt: vi.fn(async () => ({}) as never), }); @@ -934,7 +934,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -977,7 +977,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -1046,7 +1046,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "codex", label: "Codex", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } @@ -1092,7 +1092,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => registerAgentHarness({ id: "custom", label: "Custom", - deliveryDefaults: { sourceVisibleReplies: "message_tool" }, + deliveryDefaults: { visibleReplies: "message_tool" }, supports: (ctx) => ctx.provider === "custom" ? { supported: true, priority: 200 } diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts index 160470a65b57..163fe15ac6b4 100644 --- a/src/auto-reply/reply/history.ts +++ b/src/auto-reply/reply/history.ts @@ -48,7 +48,7 @@ export function buildHistoryContext(params: { } /** Appends one history entry, enforces per-session limit, and refreshes LRU key order. */ -function appendHistoryEntry(params: { +function recordChannelHistoryEntry(params: { historyMap: Map; historyKey: string; entry: T; @@ -78,32 +78,18 @@ function appendHistoryEntry(params: { * @deprecated Plugin message-turn code should use `createChannelHistoryWindow(...).record(...)`. * This helper remains for core internals and older plugin compatibility. */ -export function recordPendingHistoryEntry(params: { - historyMap: Map; - historyKey: string; - entry: T; - limit: number; -}): T[] { - return appendHistoryEntry(params); -} +export const recordPendingHistoryEntry = recordChannelHistoryEntry; -/** - * @deprecated Plugin message-turn code should use `createChannelHistoryWindow(...).record(...)`. - * This helper remains for core internals and older plugin compatibility. - */ -export function recordPendingHistoryEntryIfEnabled(params: { +export function recordChannelHistoryEntryIfEnabled(params: { historyMap: Map; historyKey: string; entry?: T | null; limit: number; }): T[] { - if (!params.entry) { + if (!params.entry || params.limit <= 0) { return []; } - if (params.limit <= 0) { - return []; - } - return recordPendingHistoryEntry({ + return recordChannelHistoryEntry({ historyMap: params.historyMap, historyKey: params.historyKey, entry: params.entry, @@ -111,6 +97,12 @@ export function recordPendingHistoryEntryIfEnabled(param }); } +/** + * @deprecated Plugin message-turn code should use `createChannelHistoryWindow(...).record(...)`. + * This helper remains for core internals and older plugin compatibility. + */ +export const recordPendingHistoryEntryIfEnabled = recordChannelHistoryEntryIfEnabled; + type MaybePromise = T | Promise; const DEFAULT_HISTORY_MEDIA_LIMIT = 4; @@ -165,12 +157,7 @@ export function normalizeHistoryMediaEntries(params: { return out; } -/** - * @deprecated Plugin message-turn code should use - * `createChannelHistoryWindow(...).recordWithMedia(...)`. This helper remains - * for core internals and older plugin compatibility. - */ -export async function recordPendingHistoryEntryWithMedia(params: { +export async function recordChannelHistoryEntryWithMedia(params: { historyMap: Map; historyKey: string; entry?: T | null; @@ -191,7 +178,7 @@ export async function recordPendingHistoryEntryWithMedia } if (typeof params.media === "function") { const recordedEntry = params.entry; - const history = recordPendingHistoryEntry({ + const history = recordChannelHistoryEntry({ historyMap: params.historyMap, historyKey: params.historyKey, entry: recordedEntry, @@ -227,7 +214,7 @@ export async function recordPendingHistoryEntryWithMedia messageId: params.messageId ?? params.entry.messageId, }); const entry = media.length > 0 ? ({ ...params.entry, media } as T) : params.entry; - return recordPendingHistoryEntry({ + return recordChannelHistoryEntry({ historyMap: params.historyMap, historyKey: params.historyKey, entry, @@ -237,10 +224,11 @@ export async function recordPendingHistoryEntryWithMedia /** * @deprecated Plugin message-turn code should use - * `createChannelHistoryWindow(...).buildPendingContext(...)`. This helper remains - * for core internals and older plugin compatibility. + * `createChannelHistoryWindow(...).recordWithMedia(...)`. */ -export function buildPendingHistoryContextFromMap(params: { +export const recordPendingHistoryEntryWithMedia = recordChannelHistoryEntryWithMedia; + +export function buildChannelPendingHistoryContext(params: { historyMap: Map; historyKey: string; limit: number; @@ -263,10 +251,11 @@ export function buildPendingHistoryContextFromMap(params: { /** * @deprecated Plugin message-turn code should use - * `createChannelHistoryWindow(...).buildInboundHistory(...)`. This helper remains - * for core internals and older plugin compatibility. + * `createChannelHistoryWindow(...).buildPendingContext(...)`. */ -export function buildInboundHistoryFromMap(params: { +export const buildPendingHistoryContextFromMap = buildChannelPendingHistoryContext; + +export function buildChannelInboundHistory(params: { historyMap: Map; historyKey: string; limit: number; @@ -277,6 +266,12 @@ export function buildInboundHistoryFromMap(params: { }); } +/** + * @deprecated Plugin message-turn code should use + * `createChannelHistoryWindow(...).buildInboundHistory(...)`. + */ +export const buildInboundHistoryFromMap = buildChannelInboundHistory; + /** Builds structured inbound history entries from an existing window. */ export function buildInboundHistoryFromEntries(params: { entries: readonly HistoryEntry[]; @@ -323,7 +318,7 @@ export function buildHistoryContextFromMap(params: { return params.currentMessage; } const entries = params.entry - ? appendHistoryEntry({ + ? recordChannelHistoryEntry({ historyMap: params.historyMap, historyKey: params.historyKey, entry: params.entry, @@ -339,11 +334,7 @@ export function buildHistoryContextFromMap(params: { }); } -/** - * @deprecated Plugin message-turn code should use `createChannelHistoryWindow(...).clear(...)`. - * This helper remains for core internals and older plugin compatibility. - */ -export function clearHistoryEntries(params: { +function clearChannelHistory(params: { historyMap: Map; historyKey: string; }): void { @@ -352,19 +343,25 @@ export function clearHistoryEntries(params: { /** * @deprecated Plugin message-turn code should use `createChannelHistoryWindow(...).clear(...)`. - * This helper remains for core internals and older plugin compatibility. */ -export function clearHistoryEntriesIfEnabled(params: { +export const clearHistoryEntries = clearChannelHistory; + +export function clearChannelHistoryIfEnabled(params: { historyMap: Map; historyKey: string; limit: number; }): void { - if (params.limit <= 0) { - return; + if (params.limit > 0) { + clearChannelHistory({ historyMap: params.historyMap, historyKey: params.historyKey }); } - clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey }); } +/** + * @deprecated Plugin message-turn code should use `createChannelHistoryWindow(...).clear(...)`. + * This helper remains for core internals and older plugin compatibility. + */ +export const clearHistoryEntriesIfEnabled = clearChannelHistoryIfEnabled; + /** Builds prompt text from already-recorded history entries. */ export function buildHistoryContextFromEntries(params: { entries: HistoryEntry[]; diff --git a/src/auto-reply/reply/session-init-conflict-retry.ts b/src/auto-reply/reply/session-init-conflict-retry.ts index 9a0e8aa83632..b8fc2adc842a 100644 --- a/src/auto-reply/reply/session-init-conflict-retry.ts +++ b/src/auto-reply/reply/session-init-conflict-retry.ts @@ -14,6 +14,15 @@ export class ReplySessionInitConflictError extends Error { } } +const SESSION_INIT_CONFLICT_MESSAGE_RE = /^reply session initialization conflicted for \S+$/u; + +function isReplySessionInitConflictError(error: unknown): boolean { + return ( + error instanceof ReplySessionInitConflictError || + SESSION_INIT_CONFLICT_MESSAGE_RE.test(error instanceof Error ? error.message : String(error)) + ); +} + const SESSION_INIT_CONFLICT_MAX_ATTEMPTS = 5; const SESSION_INIT_CONFLICT_BACKOFF_POLICY = { initialMs: 250, @@ -30,26 +39,34 @@ export async function runWithSessionInitConflictRetry( attempt: () => Promise, options?: { maxAttempts?: number; + retryDelaysMs?: readonly number[]; signal?: AbortSignal; sleep?: (ms: number, signal?: AbortSignal) => Promise; }, ): Promise { - const maxAttempts = options?.maxAttempts ?? SESSION_INIT_CONFLICT_MAX_ATTEMPTS; + const retryDelaysMs = options?.retryDelaysMs; + const maxRetries = Math.min( + (options?.maxAttempts ?? + (retryDelaysMs ? retryDelaysMs.length + 1 : SESSION_INIT_CONFLICT_MAX_ATTEMPTS)) - 1, + retryDelaysMs?.length ?? Number.POSITIVE_INFINITY, + ); const sleep = options?.sleep ?? sleepWithAbort; for (let attemptIndex = 0; ; attemptIndex += 1) { try { return await attempt(); } catch (error) { if ( - !(error instanceof ReplySessionInitConflictError) || - attemptIndex >= maxAttempts - 1 || + !isReplySessionInitConflictError(error) || + attemptIndex >= maxRetries || options?.signal?.aborted === true ) { throw error; } - const backoffMs = computeBackoff(SESSION_INIT_CONFLICT_BACKOFF_POLICY, attemptIndex + 1); + const backoffMs = + retryDelaysMs?.[attemptIndex] ?? + computeBackoff(SESSION_INIT_CONFLICT_BACKOFF_POLICY, attemptIndex + 1); log.debug( - `reply session initialization conflicted; retrying in ${backoffMs}ms (attempt ${attemptIndex + 2}/${maxAttempts})`, + `reply session initialization conflicted; retrying in ${backoffMs}ms (attempt ${attemptIndex + 2}/${maxRetries + 1})`, ); // Cancellation must interrupt the wait itself; otherwise shutdown can // sleep through the backoff and start one more session-init attempt. diff --git a/src/auto-reply/reply/session.init-conflict-retry.test.ts b/src/auto-reply/reply/session.init-conflict-retry.test.ts index e7b8ef69578c..73dd4f3b1d21 100644 --- a/src/auto-reply/reply/session.init-conflict-retry.test.ts +++ b/src/auto-reply/reply/session.init-conflict-retry.test.ts @@ -74,6 +74,18 @@ describe("runWithSessionInitConflictRetry", () => { expect(state.calls).toBe(4); }); + it("retries conflict messages rejected as strings", async () => { + const attempt = vi + .fn<() => Promise>() + .mockRejectedValueOnce(`reply session initialization conflicted for ${SESSION_KEY}`) + .mockResolvedValue("ok"); + + await expect(runWithSessionInitConflictRetry(attempt, { sleep: instantSleep })).resolves.toBe( + "ok", + ); + expect(attempt).toHaveBeenCalledTimes(2); + }); + it("rethrows the conflict after exhausting all attempts", async () => { const { attempt, state } = conflictingAttempt(Number.POSITIVE_INFINITY); await expect(runWithSessionInitConflictRetry(attempt, { sleep: instantSleep })).rejects.toThrow( @@ -90,6 +102,21 @@ describe("runWithSessionInitConflictRetry", () => { expect(state.calls).toBe(2); }); + it("executes an attempt after every caller-provided retry delay", async () => { + const { attempt, state } = conflictingAttempt(3); + const delays: number[] = []; + await expect( + runWithSessionInitConflictRetry(attempt, { + retryDelaysMs: [1, 2, 3], + sleep: async (ms) => { + delays.push(ms); + }, + }), + ).resolves.toBe("ok"); + expect(state.calls).toBe(4); + expect(delays).toEqual([1, 2, 3]); + }); + it("does not retry non-conflict errors", async () => { let calls = 0; const attempt = async () => { diff --git a/src/channels/direct-dm.test.ts b/src/channels/direct-dm.test.ts new file mode 100644 index 000000000000..3d1f589b4ed7 --- /dev/null +++ b/src/channels/direct-dm.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { dispatchInboundDirectDm } from "./direct-dm.js"; + +const mocks = vi.hoisted(() => ({ + dispatchChannelInboundTurn: vi.fn(async () => undefined), + onModelSelected: vi.fn(), +})); + +vi.mock("./inbound-event/context.js", () => ({ + buildChannelInboundEventContext: vi.fn(() => ({ Body: "envelope:hello" })), +})); + +vi.mock("./inbound-event/envelope.js", () => ({ + resolveChannelInboundRouteEnvelope: vi.fn(() => ({ + route: { + agentId: "agent-1", + accountId: "account-1", + sessionKey: "agent:agent-1:nostr:direct:peer-1", + }, + buildEnvelope: vi.fn(() => "envelope:hello"), + })), +})); + +vi.mock("./message/reply-pipeline.js", () => ({ + createChannelReplyPipeline: vi.fn(() => ({ + humanDelay: { minMs: 1, maxMs: 2 }, + onModelSelected: mocks.onModelSelected, + })), +})); + +vi.mock("./turn/kernel.js", () => ({ + dispatchChannelInboundTurn: mocks.dispatchChannelInboundTurn, + runPreparedInboundReply: vi.fn(), +})); + +describe("dispatchInboundDirectDm", () => { + it("forwards the canonical model-selection reply pipeline", async () => { + await dispatchInboundDirectDm({ + cfg: {} as OpenClawConfig, + channel: "nostr", + channelLabel: "Nostr", + accountId: "account-1", + peer: { kind: "direct", id: "peer-1" }, + senderId: "peer-1", + senderAddress: "nostr:peer-1", + recipientAddress: "nostr:bot-1", + conversationLabel: "peer-1", + rawBody: "hello", + messageId: "event-1", + deliver: async () => undefined, + onRecordError: vi.fn(), + onDispatchError: vi.fn(), + }); + + expect(mocks.dispatchChannelInboundTurn).toHaveBeenCalledWith( + expect.objectContaining({ + replyPipeline: { humanDelay: { minMs: 1, maxMs: 2 } }, + replyOptions: { onModelSelected: mocks.onModelSelected }, + }), + ); + }); +}); diff --git a/src/channels/direct-dm.ts b/src/channels/direct-dm.ts index df094dab6637..2f2e6da00530 100644 --- a/src/channels/direct-dm.ts +++ b/src/channels/direct-dm.ts @@ -1,18 +1,17 @@ -/** - * Direct-DM dispatch compatibility facade. - * - * Routes legacy direct-message ingress through the standard channel reply pipeline. - */ -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 { resolveInboundRouteEnvelopeBuilderWithRuntime } from "../plugin-sdk/inbound-envelope.js"; import { normalizeOutboundReplyPayload, type OutboundReplyPayload, } from "../plugin-sdk/reply-payload.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; +import { buildChannelInboundEventContext } from "./inbound-event/context.js"; +import { + resolveChannelInboundRouteEnvelope, + resolveInboundRouteEnvelopeBuilderWithRuntime, +} from "./inbound-event/envelope.js"; import { createChannelReplyPipeline } from "./message/reply-pipeline.js"; -import { runPreparedInboundReply } from "./turn/kernel.js"; +import { dispatchChannelInboundTurn, runPreparedInboundReply } from "./turn/kernel.js"; export { createPreCryptoDirectDmAuthorizer, resolveInboundDirectDmAccessWithRuntime, @@ -26,50 +25,11 @@ export { type DirectDmPreCryptoGuardPolicyOverrides, } from "./direct-dm-guard-policy.js"; -type DirectDmRoutePeer = { - kind: "direct"; - id: string; -}; +type DirectDmRoutePeer = { kind: "direct"; id: string }; +type DirectDmRoute = { agentId: string; sessionKey: string; accountId?: string }; -type DirectDmRoute = { - agentId: string; - sessionKey: string; - accountId?: string; -}; - -type DirectDmRuntime = { - channel: { - routing: { - resolveAgentRoute: (params: { - cfg: OpenClawConfig; - channel: string; - accountId: string; - peer: DirectDmRoutePeer; - }) => DirectDmRoute; - }; - session: { - resolveStorePath: typeof import("../config/sessions.js").resolveStorePath; - readSessionUpdatedAt: (params: { - storePath: string; - sessionKey: string; - }) => number | undefined; - recordInboundSession: typeof import("../channels/session.js").recordInboundSession; - }; - reply: { - resolveEnvelopeFormatOptions: ( - cfg: OpenClawConfig, - ) => ReturnType; - formatAgentEnvelope: typeof import("../auto-reply/envelope.js").formatAgentEnvelope; - finalizeInboundContext: typeof import("../auto-reply/reply/inbound-context.js").finalizeInboundContext; - dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher; - }; - }; -}; - -/** Route, envelope, record, and dispatch one direct-DM turn through the standard pipeline. */ -export async function dispatchInboundDirectDmWithRuntime(params: { +type DispatchInboundDirectDmParams = { cfg: OpenClawConfig; - runtime: DirectDmRuntime; channel: string; channelLabel: string; accountId: string; @@ -94,7 +54,100 @@ export async function dispatchInboundDirectDmWithRuntime(params: { deliver: (payload: OutboundReplyPayload) => Promise; onRecordError: (err: unknown) => void; onDispatchError: (err: unknown, info: { kind: string }) => void; -}): Promise<{ +}; + +function buildDirectDmContext( + params: DispatchInboundDirectDmParams, + route: DirectDmRoute, + body: string, +): FinalizedMsgContext { + const accountId = route.accountId ?? params.accountId; + return buildChannelInboundEventContext({ + channel: params.channel, + accountId, + provider: params.provider, + surface: params.surface, + messageId: params.messageId, + messageIdFull: params.messageId, + timestamp: params.timestamp, + from: params.senderAddress, + sender: { id: params.senderId, name: params.conversationLabel }, + conversation: { kind: "direct", id: params.peer.id, label: params.conversationLabel }, + route: { + agentId: route.agentId, + accountId: route.accountId, + routeSessionKey: route.sessionKey, + dispatchSessionKey: route.sessionKey, + }, + reply: { + to: params.recipientAddress, + originatingTo: params.originatingTo ?? params.recipientAddress, + }, + message: { + body, + bodyForAgent: params.bodyForAgent ?? params.rawBody, + rawBody: params.rawBody, + commandBody: params.commandBody ?? params.rawBody, + }, + access: { commands: { authorized: params.commandAuthorized === true } }, + extra: { + NativeDirectUserId: params.peer.id, + OriginatingChannel: params.originatingChannel ?? params.channel, + ...params.extraContext, + }, + }); +} + +export async function dispatchInboundDirectDm(params: DispatchInboundDirectDmParams): Promise<{ + route: DirectDmRoute; + ctxPayload: FinalizedMsgContext; +}> { + const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + peer: params.peer, + }); + const ctxPayload = buildDirectDmContext( + params, + route, + buildEnvelope({ + channel: params.channelLabel, + from: params.conversationLabel, + body: params.rawBody, + timestamp: params.timestamp, + }), + ); + const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + cfg: params.cfg, + agentId: route.agentId, + channel: params.channel, + accountId: route.accountId ?? params.accountId, + }); + + await dispatchChannelInboundTurn({ + cfg: params.cfg, + channel: params.channel, + accountId: route.accountId ?? params.accountId, + route: { agentId: route.agentId, sessionKey: route.sessionKey }, + ctxPayload, + record: { + onRecordError: params.onRecordError, + }, + delivery: { + deliver: async (payload) => await params.deliver(normalizeOutboundReplyPayload(payload)), + onError: params.onDispatchError, + }, + replyPipeline, + replyOptions: { onModelSelected }, + }); + + return { route, ctxPayload }; +} + +export async function dispatchInboundDirectDmWithRuntime( + params: DispatchInboundDirectDmParams & { runtime: PluginRuntime }, +): Promise<{ route: DirectDmRoute; storePath: string; ctxPayload: FinalizedMsgContext; @@ -107,14 +160,12 @@ export async function dispatchInboundDirectDmWithRuntime(params: { runtime: params.runtime.channel, sessionStore: params.cfg.session?.store, }); - const { storePath, body } = buildEnvelope({ channel: params.channelLabel, from: params.conversationLabel, body: params.rawBody, timestamp: params.timestamp, }); - const ctxPayload = params.runtime.channel.reply.finalizeInboundContext({ Body: body, BodyForAgent: params.bodyForAgent ?? params.rawBody, @@ -139,7 +190,6 @@ export async function dispatchInboundDirectDmWithRuntime(params: { NativeDirectUserId: params.peer.id, ...params.extraContext, }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ cfg: params.cfg, agentId: route.agentId, @@ -153,33 +203,23 @@ export async function dispatchInboundDirectDmWithRuntime(params: { storePath, ctxPayload, recordInboundSession: params.runtime.channel.session.recordInboundSession, - record: { - onRecordError: params.onRecordError, - }, - runDispatch: async () => - await params.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + record: { onRecordError: params.onRecordError }, + runDispatch: () => + params.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: params.cfg, dispatcherOptions: { ...replyPipeline, - deliver: async (payload: unknown) => { - const normalized = + deliver: (payload: unknown) => + params.deliver( payload && typeof payload === "object" ? normalizeOutboundReplyPayload(payload as Record) - : {}; - return await params.deliver(normalized); - }, + : {}, + ), onError: params.onDispatchError, }, - replyOptions: { - onModelSelected, - }, + replyOptions: { onModelSelected }, }), }); - - return { - route, - storePath, - ctxPayload, - }; + return { route, storePath, ctxPayload }; } diff --git a/src/channels/feedback-reflection.test.ts b/src/channels/feedback-reflection.test.ts new file mode 100644 index 000000000000..6e9b766e8292 --- /dev/null +++ b/src/channels/feedback-reflection.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { recordChannelFeedbackEvent, runChannelFeedbackReflection } from "./feedback-reflection.js"; + +const appendTranscriptEvent = vi.hoisted(() => vi.fn(async () => undefined)); +const dispatchChannelInboundTurn = vi.hoisted(() => vi.fn()); +const loadSessionEntry = vi.hoisted(() => vi.fn()); +const readSessionUpdatedAt = vi.hoisted(() => vi.fn()); +const resolveStorePath = vi.hoisted(() => vi.fn(() => "/state/main/sessions.json")); + +vi.mock("../config/sessions/paths.js", () => ({ resolveStorePath })); +vi.mock("../config/sessions/session-accessor.js", () => ({ + appendTranscriptEvent, + loadSessionEntry, + readSessionUpdatedAt, +})); +vi.mock("./turn/kernel.js", () => ({ dispatchChannelInboundTurn })); + +const cfg = {} as OpenClawConfig; + +describe("channel feedback reflection", () => { + beforeEach(() => vi.clearAllMocks()); + + it("runs reflection in the original session and enforces cooldown", async () => { + dispatchChannelInboundTurn.mockImplementationOnce(async (plan) => { + await plan.delivery.deliver({ + text: JSON.stringify({ + learning: "Answer the direct question first.", + followUp: true, + userMessage: "Want a shorter version?", + }), + }); + return { admission: { kind: "dispatch" }, dispatched: true }; + }); + const params = { + cfg, + channel: "msteams", + channelLabel: "Teams", + agentId: "main", + sessionKey: "agent:main:msteams:feedback-1", + conversationId: "conversation-1", + conversationKind: "group" as const, + thumbedDownResponse: "Too much detail", + userComment: "Be concise", + }; + + await expect(runChannelFeedbackReflection(params)).resolves.toEqual({ + status: "complete", + learning: "Answer the direct question first.", + storePath: "/state/main/sessions.json", + followUp: true, + userMessage: "Want a shorter version?", + responseLength: 104, + }); + expect(dispatchChannelInboundTurn).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + channel: "msteams", + route: { agentId: "main", sessionKey: params.sessionKey }, + ctxPayload: expect.objectContaining({ ChatType: "group" }), + }), + ); + await expect(runChannelFeedbackReflection(params)).resolves.toEqual({ status: "cooldown" }); + expect(dispatchChannelInboundTurn).toHaveBeenCalledTimes(1); + }); + + it("preserves a plain-text reflection as internal learning", async () => { + dispatchChannelInboundTurn.mockImplementationOnce(async (plan) => { + await plan.delivery.deliver({ text: "Answer the direct question first." }); + return { admission: { kind: "dispatch" }, dispatched: true }; + }); + + await expect( + runChannelFeedbackReflection({ + cfg, + channel: "msteams", + channelLabel: "Teams", + agentId: "main", + sessionKey: "agent:main:msteams:feedback-plain", + conversationId: "conversation-plain", + conversationKind: "direct", + }), + ).resolves.toEqual({ + status: "complete", + learning: "Answer the direct question first.", + storePath: "/state/main/sessions.json", + followUp: false, + userMessage: undefined, + responseLength: 33, + }); + }); + + it("does not treat structured follow-up values as directives", async () => { + dispatchChannelInboundTurn.mockImplementationOnce(async (plan) => { + await plan.delivery.deliver({ + text: JSON.stringify({ learning: "Be concise.", followUp: ["yes"] }), + }); + return { admission: { kind: "dispatch" }, dispatched: true }; + }); + + await expect( + runChannelFeedbackReflection({ + cfg, + channel: "msteams", + channelLabel: "Teams", + agentId: "main", + sessionKey: "agent:main:msteams:feedback-structured", + conversationId: "conversation-structured", + conversationKind: "direct", + }), + ).resolves.toMatchObject({ status: "complete", followUp: false }); + }); + + it("records feedback through the canonical transcript accessor", async () => { + loadSessionEntry.mockReturnValue({ sessionId: "session-1" }); + const event = { type: "custom", event: "feedback", ts: 1 }; + + await expect( + recordChannelFeedbackEvent({ + cfg, + agentId: "main", + sessionKey: "agent:main:msteams:feedback-2", + event, + }), + ).resolves.toBe(true); + expect(appendTranscriptEvent).toHaveBeenCalledWith( + { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:msteams:feedback-2", + storePath: "/state/main/sessions.json", + }, + event, + ); + }); +}); diff --git a/src/channels/feedback-reflection.ts b/src/channels/feedback-reflection.ts new file mode 100644 index 000000000000..b6db19035fee --- /dev/null +++ b/src/channels/feedback-reflection.ts @@ -0,0 +1,190 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { appendTranscriptEvent, loadSessionEntry } from "../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { buildChannelInboundEventContext } from "./inbound-event/context.js"; +import { createChannelInboundEnvelopeBuilder } from "./inbound-event/envelope.js"; +import { dispatchChannelInboundTurn } from "./turn/kernel.js"; + +export const DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS = 300_000; +const MAX_RESPONSE_CHARS = 500; +const MAX_COOLDOWN_ENTRIES = 500; +const lastReflectionBySession = new Map(); + +export async function recordChannelFeedbackEvent(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; + event: Parameters[1]; +}): Promise { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const entry = loadSessionEntry({ + agentId: params.agentId, + sessionKey: params.sessionKey, + storePath, + }); + if (!entry?.sessionId) { + return false; + } + await appendTranscriptEvent( + { + agentId: params.agentId, + sessionId: entry.sessionId, + sessionKey: params.sessionKey, + storePath, + }, + params.event, + ); + return true; +} + +export type ChannelFeedbackReflectionResult = + | { status: "cooldown" } + | { status: "empty" } + | { + status: "complete"; + learning: string; + storePath: string; + followUp: boolean; + userMessage?: string; + responseLength: number; + }; + +function buildReflectionPrompt(params: { + thumbedDownResponse?: string; + userComment?: string; +}): string { + const response = params.thumbedDownResponse; + const truncated = + response && response.length > MAX_RESPONSE_CHARS + ? `${truncateUtf16Safe(response, MAX_RESPONSE_CHARS)}...` + : response; + return [ + "A user indicated your previous response wasn't helpful.", + truncated ? `\nYour response was:\n> ${truncated}` : undefined, + params.userComment ? `\nUser's comment: "${params.userComment}"` : undefined, + "\nBriefly reflect: what could you improve? Consider tone, length, accuracy, relevance, and specificity. " + + 'Reply with one JSON object only: {"learning":"...","followUp":false,"userMessage":""}. ' + + "Keep learning to 1-2 sentences. Set followUp only when the user needs a direct reply.", + ] + .filter(Boolean) + .join("\n"); +} + +function parseReflectionResponse(text: string) { + const trimmed = text.trim(); + const candidates = [ + trimmed, + ...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []), + ]; + for (const candidate of candidates) { + try { + const value = JSON.parse(candidate.trim()) as Record; + const learning = typeof value.learning === "string" ? value.learning.trim() : ""; + if (!learning) { + continue; + } + const followUp = + value.followUp === true || + (typeof value.followUp === "string" && + ["true", "yes"].includes(value.followUp.trim().toLowerCase())); + const userMessage = typeof value.userMessage === "string" ? value.userMessage.trim() : ""; + return { learning, followUp, userMessage: userMessage || undefined }; + } catch {} + } + return trimmed ? { learning: trimmed, followUp: false } : null; +} + +export async function runChannelFeedbackReflection(params: { + cfg: OpenClawConfig; + channel: string; + channelLabel: string; + accountId?: string; + agentId: string; + sessionKey: string; + conversationId: string; + conversationKind: "direct" | "group" | "channel"; + thumbedDownResponse?: string; + userComment?: string; + cooldownMs?: number; + onRecordError?: (error: unknown) => void; + onDispatchError?: (error: unknown) => void; +}): Promise { + const cooldownMs = params.cooldownMs ?? DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS; + if ( + Date.now() - (lastReflectionBySession.get(params.sessionKey) ?? Number.NEGATIVE_INFINITY) < + cooldownMs + ) { + return { status: "cooldown" }; + } + const prompt = buildReflectionPrompt(params); + const timestamp = Date.now(); + const body = createChannelInboundEnvelopeBuilder({ + cfg: params.cfg, + route: { agentId: params.agentId, sessionKey: params.sessionKey }, + })({ + channel: params.channelLabel, + from: "system", + body: prompt, + timestamp, + }); + const target = `conversation:${params.conversationId}`; + const ctxPayload = buildChannelInboundEventContext({ + channel: params.channel, + accountId: params.accountId, + messageId: `feedback-reflection:${timestamp}`, + timestamp, + from: `${params.channel}:system:${params.conversationId}`, + sender: { id: "system", name: "system" }, + conversation: { kind: params.conversationKind, id: params.conversationId }, + route: { + agentId: params.agentId, + accountId: params.accountId, + routeSessionKey: params.sessionKey, + dispatchSessionKey: params.sessionKey, + }, + reply: { to: target, originatingTo: target }, + message: { body, bodyForAgent: prompt, rawBody: prompt, commandBody: prompt }, + access: { commands: { authorized: false } }, + }); + const responses: string[] = []; + await dispatchChannelInboundTurn({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + route: { agentId: params.agentId, sessionKey: params.sessionKey }, + ctxPayload, + record: { onRecordError: params.onRecordError }, + delivery: { + deliver: async (payload) => { + if (payload.text) { + responses.push(payload.text); + } + return { visibleReplySent: false }; + }, + onError: (error) => params.onDispatchError?.(error), + }, + replyPipeline: {}, + }); + const response = responses.join("\n"); + const parsed = parseReflectionResponse(response); + if (!parsed) { + return { status: "empty" }; + } + lastReflectionBySession.set(params.sessionKey, Date.now()); + if (lastReflectionBySession.size > MAX_COOLDOWN_ENTRIES) { + for (const [key, time] of lastReflectionBySession) { + if (Date.now() - time >= cooldownMs) { + lastReflectionBySession.delete(key); + } + } + } + return { + status: "complete", + learning: parsed.learning, + storePath: resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }), + followUp: parsed.followUp, + userMessage: parsed.userMessage, + responseLength: response.trim().length, + }; +} diff --git a/src/channels/inbound-event/context.ts b/src/channels/inbound-event/context.ts index 00a751c819d5..96ad103108be 100644 --- a/src/channels/inbound-event/context.ts +++ b/src/channels/inbound-event/context.ts @@ -377,17 +377,7 @@ function finalizePreparedChannelInboundContext }; } -/** - * @deprecated Public compatibility for callers that already prepared legacy - * prompt fields. New channel code should use `buildChannelInboundEventContext`. - */ -export function finalizeChannelInboundContext>( - params: FinalizeChannelInboundContextAsyncParams, -): Promise>; -export function finalizeChannelInboundContext>( - params: FinalizeChannelInboundContextParams, -): FinalizeChannelInboundContextResult; -export function finalizeChannelInboundContext>( +function finalizeChannelInboundContextValue>( params: FinalizeChannelInboundContextParams & Partial, ): MaybePromise> { @@ -414,6 +404,23 @@ export function finalizeChannelInboundContext> return isPromiseLike(prepared) ? prepared.then(finish) : finish(prepared); } +/** + * @deprecated Public compatibility for callers that already prepared legacy + * prompt fields. New channel code should use `buildChannelInboundEventContext`. + */ +export function finalizeChannelInboundContext>( + params: FinalizeChannelInboundContextAsyncParams, +): Promise>; +export function finalizeChannelInboundContext>( + params: FinalizeChannelInboundContextParams, +): FinalizeChannelInboundContextResult; +export function finalizeChannelInboundContext>( + params: FinalizeChannelInboundContextParams & + Partial, +): MaybePromise> { + return finalizeChannelInboundContextValue(params); +} + function resolveIngressCommandAuthorized( access: BuildChannelInboundEventAccess | undefined, ): boolean | undefined { @@ -560,13 +567,13 @@ export function buildChannelInboundEventContext( context, }; const result = params.resolveSupplementalMedia - ? finalizeChannelInboundContext({ + ? finalizeChannelInboundContextValue({ ...finalizeParams, resolveSupplementalMedia: true, suppressSelfQuoteBody: params.suppressSelfQuoteBody, suppressSelfQuoteMedia: params.suppressSelfQuoteMedia, }) - : finalizeChannelInboundContext(finalizeParams); + : finalizeChannelInboundContextValue(finalizeParams); return isPromiseLike(result) ? result.then((finalized) => finalized.context as BuiltChannelInboundEventContext) : (result.context as BuiltChannelInboundEventContext); diff --git a/src/channels/inbound-event/envelope.test.ts b/src/channels/inbound-event/envelope.test.ts new file mode 100644 index 000000000000..7b25f18aac96 --- /dev/null +++ b/src/channels/inbound-event/envelope.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + createChannelInboundEnvelopeBuilder, + resolveChannelInboundRouteEnvelope, +} from "./envelope.js"; + +const readSessionUpdatedAt = vi.hoisted(() => vi.fn(() => 60_000)); +const resolveStorePath = vi.hoisted(() => vi.fn(() => "/state/main/sessions.json")); +const resolveAgentRoute = vi.hoisted(() => + vi.fn(() => ({ + agentId: "main", + sessionKey: "agent:main:telegram:direct:peer", + accountId: "default", + })), +); + +vi.mock("../../config/sessions/paths.js", () => ({ resolveStorePath })); +vi.mock("../../config/sessions/session-accessor.js", () => ({ readSessionUpdatedAt })); +vi.mock("../../routing/resolve-route.js", () => ({ resolveAgentRoute })); + +const cfg = { + agents: { defaults: { envelopeTimestamp: "off" } }, + session: { store: "/state/{agentId}/sessions.json" }, +} as OpenClawConfig; + +describe("channel inbound envelope", () => { + beforeEach(() => vi.clearAllMocks()); + + it("owns session lookup and formatting for a resolved route", () => { + const buildEnvelope = createChannelInboundEnvelopeBuilder({ + cfg, + route: { agentId: "main", sessionKey: "agent:main:telegram:direct:peer" }, + }); + + expect( + buildEnvelope({ + channel: "Telegram", + from: "Alice", + body: "hello", + timestamp: 120_000, + }), + ).toBe("[Telegram Alice +1m] hello"); + expect(resolveStorePath).toHaveBeenCalledWith(cfg.session?.store, { agentId: "main" }); + expect(readSessionUpdatedAt).toHaveBeenCalledWith({ + storePath: "/state/main/sessions.json", + sessionKey: "agent:main:telegram:direct:peer", + }); + }); + + it("binds routing and envelope construction in one core operation", () => { + const params = { + cfg, + channel: "telegram", + accountId: "default", + peer: { kind: "direct" as const, id: "peer" }, + }; + const resolved = resolveChannelInboundRouteEnvelope(params); + + expect(resolveAgentRoute).toHaveBeenCalledWith(params); + expect(resolved.route.sessionKey).toBe("agent:main:telegram:direct:peer"); + expect(resolved.buildEnvelope({ channel: "Telegram", from: "Alice", body: "hello" })).toBe( + "[Telegram Alice] hello", + ); + }); + + it("formats buffered history without reading the live session timestamp", () => { + const buildEnvelope = createChannelInboundEnvelopeBuilder({ + cfg, + route: { agentId: "main", sessionKey: "agent:main:telegram:direct:peer" }, + }); + + expect( + buildEnvelope({ + channel: "Telegram", + from: "Alice", + body: "older", + timestamp: 30_000, + previousTimestamp: null, + }), + ).toBe("[Telegram Alice] older"); + expect(readSessionUpdatedAt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/inbound-event/envelope.ts b/src/channels/inbound-event/envelope.ts new file mode 100644 index 000000000000..66a0834c817c --- /dev/null +++ b/src/channels/inbound-event/envelope.ts @@ -0,0 +1,191 @@ +import { + formatAgentEnvelope, + resolveEnvelopeFormatOptions, + type AgentEnvelopeParams, +} from "../../auto-reply/envelope.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { readSessionUpdatedAt } from "../../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + resolveAgentRoute, + type ResolvedAgentRoute, + type ResolveAgentRouteInput, +} from "../../routing/resolve-route.js"; + +export type ChannelInboundEnvelopeInput = Omit & { + previousTimestamp?: AgentEnvelopeParams["previousTimestamp"] | null; +}; + +export function createChannelInboundEnvelopeBuilder(params: { + cfg: OpenClawConfig; + route: Pick; +}) { + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }); + const envelope = resolveEnvelopeFormatOptions(params.cfg); + return (input: ChannelInboundEnvelopeInput): string => { + const previousTimestamp = + input.previousTimestamp === null + ? undefined + : (input.previousTimestamp ?? + readSessionUpdatedAt({ storePath, sessionKey: params.route.sessionKey })); + return formatAgentEnvelope({ + ...input, + previousTimestamp, + envelope: input.envelope ?? envelope, + }); + }; +} + +export function resolveChannelInboundRouteEnvelope(params: ResolveAgentRouteInput) { + const route = resolveAgentRoute(params); + return { + route, + buildEnvelope: createChannelInboundEnvelopeBuilder({ cfg: params.cfg, route }), + }; +} + +type RouteLike = { + agentId: string; + sessionKey: string; +}; + +type RoutePeerLike = { + kind: "direct" | "group" | "channel"; + id: string | number; +}; + +type InboundEnvelopeFormatParams = { + channel: string; + from: string; + timestamp?: number; + previousTimestamp?: number; + envelope: TEnvelope; + body: string; +}; + +type InboundRouteResolveParams = { + cfg: TConfig; + channel: string; + accountId: string; + peer: TPeer; +}; + +export function createInboundEnvelopeBuilder(params: { + cfg: TConfig; + route: RouteLike; + sessionStore?: string; + resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; + readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; + resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; + formatAgentEnvelope: (params: InboundEnvelopeFormatParams) => string; +}) { + const storePath = params.resolveStorePath(params.sessionStore, { + agentId: params.route.agentId, + }); + const envelopeOptions = params.resolveEnvelopeFormatOptions(params.cfg); + return (input: { channel: string; from: string; body: string; timestamp?: number }) => { + const previousTimestamp = params.readSessionUpdatedAt({ + storePath, + sessionKey: params.route.sessionKey, + }); + const body = params.formatAgentEnvelope({ + channel: input.channel, + from: input.from, + timestamp: input.timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: input.body, + }); + return { storePath, body }; + }; +} + +export function resolveInboundRouteEnvelopeBuilder< + TConfig, + TEnvelope, + TRoute extends RouteLike, + TPeer extends RoutePeerLike, +>(params: { + cfg: TConfig; + channel: string; + accountId: string; + peer: TPeer; + resolveAgentRoute: (params: InboundRouteResolveParams) => TRoute; + sessionStore?: string; + resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; + readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; + resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; + formatAgentEnvelope: (params: InboundEnvelopeFormatParams) => string; +}): { + route: TRoute; + buildEnvelope: ReturnType>; +} { + const route = params.resolveAgentRoute({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + peer: params.peer, + }); + const buildEnvelope = createInboundEnvelopeBuilder({ + cfg: params.cfg, + route, + sessionStore: params.sessionStore, + resolveStorePath: params.resolveStorePath, + readSessionUpdatedAt: params.readSessionUpdatedAt, + resolveEnvelopeFormatOptions: params.resolveEnvelopeFormatOptions, + formatAgentEnvelope: params.formatAgentEnvelope, + }); + return { route, buildEnvelope }; +} + +type InboundRouteEnvelopeRuntime< + TConfig, + TEnvelope, + TRoute extends RouteLike, + TPeer extends RoutePeerLike, +> = { + routing: { + resolveAgentRoute: (params: InboundRouteResolveParams) => TRoute; + }; + session: { + resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; + readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; + }; + reply: { + resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; + formatAgentEnvelope: (params: InboundEnvelopeFormatParams) => string; + }; +}; + +/** Runtime-driven compatibility variant for shipped plugin SDK callers. */ +export function resolveInboundRouteEnvelopeBuilderWithRuntime< + TConfig, + TEnvelope, + TRoute extends RouteLike, + TPeer extends RoutePeerLike, +>(params: { + cfg: TConfig; + channel: string; + accountId: string; + peer: TPeer; + runtime: InboundRouteEnvelopeRuntime; + sessionStore?: string; +}): { + route: TRoute; + buildEnvelope: ReturnType>; +} { + return resolveInboundRouteEnvelopeBuilder({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + peer: params.peer, + resolveAgentRoute: (routeParams) => params.runtime.routing.resolveAgentRoute(routeParams), + sessionStore: params.sessionStore, + resolveStorePath: params.runtime.session.resolveStorePath, + readSessionUpdatedAt: params.runtime.session.readSessionUpdatedAt, + resolveEnvelopeFormatOptions: params.runtime.reply.resolveEnvelopeFormatOptions, + formatAgentEnvelope: params.runtime.reply.formatAgentEnvelope, + }); +} diff --git a/src/channels/message/inbound-reply-dispatch.ts b/src/channels/message/inbound-reply-dispatch.ts index 449bba2fb860..aecaf8331cf5 100644 --- a/src/channels/message/inbound-reply-dispatch.ts +++ b/src/channels/message/inbound-reply-dispatch.ts @@ -20,6 +20,7 @@ import { hasFinalChannelTurnDispatch, hasVisibleChannelTurnDispatch, deliverInboundReplyWithMessageSendContext, + dispatchChannelInboundTurn as dispatchChannelInboundTurnCore, dispatchChannelInboundReply as dispatchChannelInboundReplyCore, isDurableInboundReplyDeliveryHandled, resolveChannelTurnDispatchCounts, @@ -35,6 +36,7 @@ import type { } from "../turn/kernel.js"; import type { AssembledChannelTurn, + ChannelTurnPlan, PreparedChannelTurn, RunChannelTurnParams, } from "../turn/types.js"; @@ -62,6 +64,7 @@ export type ChannelInboundEventRunnerParams< > = RunChannelTurnParams; export type PreparedInboundReply = PreparedChannelTurn; export type AssembledInboundReply = AssembledChannelTurn; +export type ChannelInboundTurnPlan = ChannelTurnPlan; export type InboundReplyDispatchResult = ChannelTurnResult; /** Run an already prepared inbound reply through shared session-record + dispatch ordering. */ @@ -125,6 +128,10 @@ export async function dispatchChannelInboundReply(params: AssembledInboundReply) return await dispatchChannelInboundReplyCore(params); } +export async function dispatchChannelInboundTurn(params: ChannelInboundTurnPlan) { + return await dispatchChannelInboundTurnCore(params); +} + export { hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch, hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch, diff --git a/src/channels/plugins/outbound/interactive.ts b/src/channels/plugins/outbound/interactive.ts index 5863da40590c..8615cb3b6797 100644 --- a/src/channels/plugins/outbound/interactive.ts +++ b/src/channels/plugins/outbound/interactive.ts @@ -3,24 +3,12 @@ * * Re-exports presentation adapters and keeps the deprecated interactive reducer available. */ -import type { InteractiveReply, InteractiveReplyBlock } from "../../../interactive/payload.js"; +import { reduceLegacyInteractiveReply } from "../../../interactive/payload.js"; export { adaptMessagePresentationForChannel, applyPresentationActionLimits, presentationPageSize, } from "./presentation-limits.js"; -/** - * @deprecated Use MessagePresentation helpers for new rendering paths. - */ -export function reduceInteractiveReply( - interactive: InteractiveReply | undefined, - initialState: TState, - reduce: (state: TState, block: InteractiveReplyBlock, index: number) => TState, -): TState { - let state = initialState; - for (const [index, block] of (interactive?.blocks ?? []).entries()) { - state = reduce(state, block, index); - } - return state; -} +/** @deprecated Use MessagePresentation helpers for new rendering paths. */ +export const reduceInteractiveReply = reduceLegacyInteractiveReply; diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 4fa57ca4703d..02f50053c789 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -20,8 +20,6 @@ type SessionThreadBindingsConfigShape = { idleHours?: unknown; maxAgeHours?: unknown; spawnSessions?: unknown; - spawnSubagentSessions?: unknown; - spawnAcpSessions?: unknown; defaultSpawnContext?: unknown; }; @@ -170,12 +168,6 @@ function resolveChannelThreadBindings(params: { }; } -function resolveSpawnFlagKey( - kind: ThreadBindingSpawnKind, -): "spawnSubagentSessions" | "spawnAcpSessions" { - return kind === "subagent" ? "spawnSubagentSessions" : "spawnAcpSessions"; -} - function normalizeSpawnContext(value: unknown): ThreadBindingSpawnContext | undefined { return value === "isolated" || value === "fork" ? value : undefined; } @@ -199,11 +191,8 @@ export function resolveThreadBindingSpawnPolicy(params: { normalizeBoolean(root?.enabled) ?? normalizeBoolean(params.cfg.session?.threadBindings?.enabled) ?? true; - const spawnFlagKey = resolveSpawnFlagKey(params.kind); const spawnEnabledRaw = - normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(account?.spawnSessions) ?? - normalizeBoolean(root?.[spawnFlagKey]) ?? normalizeBoolean(root?.spawnSessions) ?? normalizeBoolean(params.cfg.session?.threadBindings?.spawnSessions); const spawnEnabled = spawnEnabledRaw ?? true; diff --git a/src/channels/turn/history-window.ts b/src/channels/turn/history-window.ts index 99d0bed37105..8a340d5ba927 100644 --- a/src/channels/turn/history-window.ts +++ b/src/channels/turn/history-window.ts @@ -1,10 +1,10 @@ // Windowed channel history facade over caller-owned pending-history maps. import { - buildInboundHistoryFromMap, - buildPendingHistoryContextFromMap, - clearHistoryEntriesIfEnabled, - recordPendingHistoryEntryIfEnabled, - recordPendingHistoryEntryWithMedia, + buildChannelInboundHistory, + buildChannelPendingHistoryContext, + clearChannelHistoryIfEnabled, + recordChannelHistoryEntryIfEnabled, + recordChannelHistoryEntryWithMedia, } from "../../auto-reply/reply/history.js"; import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/history.types.js"; @@ -46,14 +46,14 @@ export function createChannelHistoryWindow - recordPendingHistoryEntryIfEnabled({ + recordChannelHistoryEntryIfEnabled({ historyMap, historyKey: recordParams.historyKey, limit: recordParams.limit, entry: recordParams.entry, }), recordWithMedia: (recordParams) => - recordPendingHistoryEntryWithMedia({ + recordChannelHistoryEntryWithMedia({ historyMap, historyKey: recordParams.historyKey, limit: recordParams.limit, @@ -64,7 +64,7 @@ export function createChannelHistoryWindow - buildPendingHistoryContextFromMap({ + buildChannelPendingHistoryContext({ historyMap, historyKey: contextParams.historyKey, limit: contextParams.limit, @@ -73,13 +73,13 @@ export function createChannelHistoryWindow - buildInboundHistoryFromMap({ + buildChannelInboundHistory({ historyMap, historyKey: historyParams.historyKey, limit: historyParams.limit, }), clear: (clearParams) => - clearHistoryEntriesIfEnabled({ + clearChannelHistoryIfEnabled({ historyMap, historyKey: clearParams.historyKey, limit: clearParams.limit, diff --git a/src/channels/turn/kernel.ts b/src/channels/turn/kernel.ts index 6b97ab9e09e9..8dea0bcdb70a 100644 --- a/src/channels/turn/kernel.ts +++ b/src/channels/turn/kernel.ts @@ -1,10 +1,12 @@ -// Channel turn kernel for normalized inbound event dispatch, history, and delivery. import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import { - clearHistoryEntriesIfEnabled, - recordPendingHistoryEntryWithMedia, + clearChannelHistoryIfEnabled, + recordChannelHistoryEntryWithMedia, } from "../../auto-reply/reply/history.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +import { runWithSessionInitConflictRetry } from "../../auto-reply/reply/session-init-conflict-retry.js"; import type { FinalizedMsgContext } from "../../auto-reply/templating.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; import { createDiagnosticTraceContextFromActiveScope, runWithDiagnosticTraceContext, @@ -12,6 +14,7 @@ import { import { createSubsystemLogger } from "../../logging/subsystem.js"; import { toHistoryMediaEntries } from "../inbound-event/media.js"; import { createChannelReplyPipeline } from "../message/reply-pipeline.js"; +import { recordInboundSession } from "../session.js"; import { recordChannelBotPairLoopAndCheckSuppression } from "./bot-loop-protection.js"; import { EMPTY_CHANNEL_TURN_DISPATCH_COUNTS, @@ -42,13 +45,14 @@ export type { } from "./durable-delivery.js"; import type { AssembledChannelTurn, + ChannelTurnPlan, ChannelEventClass, ChannelTurnAdmission, ChannelEventDeliveryAdapter, ChannelTurnHistoryFinalizeOptions, ChannelTurnLogEvent, - ChannelTurnResolved, ChannelTurnResult, + ChannelTurnResolved, DispatchedChannelTurnResult, NormalizedTurnInput, PreparedChannelTurn, @@ -69,6 +73,33 @@ const DEFAULT_EVENT_CLASS: ChannelEventClass = { }; const log = createSubsystemLogger("channels/turn/kernel"); +function assembleResolvedChannelTurn( + value: ChannelTurnResolved, +): AssembledChannelTurn | PreparedChannelTurn { + if (!("route" in value)) { + return value; + } + if ("runDispatch" in value) { + const { cfg, route, ...turn } = value; + return { + ...turn, + routeSessionKey: route.sessionKey, + storePath: resolveStorePath(cfg.session?.store, { agentId: route.agentId }), + recordInboundSession, + }; + } + const { cfg, route, ...turn } = value; + return { + ...turn, + cfg, + agentId: route.agentId, + routeSessionKey: route.sessionKey, + storePath: resolveStorePath(cfg.session?.store, { agentId: route.agentId }), + recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher, + }; +} + function isAdmission(value: unknown): value is ChannelTurnAdmission { if (!value || typeof value !== "object") { return false; @@ -115,7 +146,7 @@ function clearPendingHistoryAfterTurn(params?: ChannelTurnHistoryFinalizeOptions if (!params?.isGroup || !params.historyKey || !params.historyMap || params.limit === undefined) { return; } - clearHistoryEntriesIfEnabled({ + clearChannelHistoryIfEnabled({ historyMap: params.historyMap, historyKey: params.historyKey, limit: params.limit, @@ -170,7 +201,7 @@ async function recordDroppedChannelTurnHistory(params: { } : null; const media = params.preflight.media; - await recordPendingHistoryEntryWithMedia({ + await recordChannelHistoryEntryWithMedia({ historyMap: history.historyMap, historyKey: history.key, limit: history.limit, @@ -384,56 +415,66 @@ export async function dispatchAssembledChannelTurn( log: params.log, messageId: params.messageId, runDispatch: async () => - await params.dispatchReplyWithBufferedBlockDispatcher({ - ctx: params.ctxPayload, - cfg: params.cfg, - dispatcherOptions: { - ...replyPipeline.dispatcherOptions, - deliver: async (payload: ReplyPayload, info) => { - const preparedPayload = params.delivery.preparePayload - ? await params.delivery.preparePayload(payload, info) - : payload; - const durableOptions = - typeof params.delivery.durable === "function" - ? await params.delivery.durable(preparedPayload, info) - : params.delivery.durable; - if (durableOptions) { - const durable = await deliverInboundReplyWithMessageSendContext({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - agentId: params.agentId, - ctxPayload: params.ctxPayload, - payload: preparedPayload, - info, - ...durableOptions, - }); - throwIfDurableInboundReplyDeliveryFailed(durable); - if (isDurableInboundReplyDeliveryHandled(durable)) { + await runWithSessionInitConflictRetry( + () => + params.dispatchReplyWithBufferedBlockDispatcher({ + ctx: params.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...replyPipeline.dispatcherOptions, + deliver: async (payload: ReplyPayload, info) => { + const preparedPayload = params.delivery.preparePayload + ? await params.delivery.preparePayload(payload, info) + : payload; + const durableOptions = + typeof params.delivery.durable === "function" + ? await params.delivery.durable(preparedPayload, info) + : params.delivery.durable; + if (durableOptions) { + const durable = await deliverInboundReplyWithMessageSendContext({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + agentId: params.agentId, + ctxPayload: params.ctxPayload, + payload: preparedPayload, + info, + ...durableOptions, + }); + throwIfDurableInboundReplyDeliveryFailed(durable); + if (isDurableInboundReplyDeliveryHandled(durable)) { + await runChannelDeliveryObserver({ + onDelivered: params.delivery.onDelivered, + payload: preparedPayload, + info, + result: durable.delivery, + }); + return durable.delivery; + } + } + const result = await params.delivery.deliver(preparedPayload, info); await runChannelDeliveryObserver({ onDelivered: params.delivery.onDelivered, payload: preparedPayload, info, - result: durable.delivery, + result, }); - return durable.delivery; - } + return result; + }, + onError: params.delivery.onError, + }, + toolsAllow: params.toolsAllow, + replyOptions: replyPipeline.replyOptions, + replyResolver: params.replyResolver, + }), + params.sessionInitRetry + ? { + retryDelaysMs: params.sessionInitRetry.delaysMs, + signal: params.sessionInitRetry.signal, + sleep: params.sessionInitRetry.sleep, } - const result = await params.delivery.deliver(preparedPayload, info); - await runChannelDeliveryObserver({ - onDelivered: params.delivery.onDelivered, - payload: preparedPayload, - info, - result, - }); - return result; - }, - onError: params.delivery.onError, - }, - toolsAllow: params.toolsAllow, - replyOptions: replyPipeline.replyOptions, - replyResolver: params.replyResolver, - }), + : undefined, + ), }, { suppressObserveOnlyDispatch: false }, ); @@ -441,8 +482,25 @@ export async function dispatchAssembledChannelTurn( export const dispatchChannelInboundReply = dispatchAssembledChannelTurn; +export function dispatchChannelInboundTurn( + plan: ChannelTurnPlan & { + botLoopProtection: NonNullable; + }, +): Promise; +export function dispatchChannelInboundTurn( + plan: Omit & { botLoopProtection?: undefined }, +): Promise; +export function dispatchChannelInboundTurn(plan: ChannelTurnPlan): Promise; +export async function dispatchChannelInboundTurn( + plan: ChannelTurnPlan, +): Promise { + return await dispatchAssembledChannelTurn( + assembleResolvedChannelTurn(plan) as AssembledChannelTurn, + ); +} + function isPreparedChannelTurn( - value: ChannelTurnResolved, + value: AssembledChannelTurn | PreparedChannelTurn, ): value is PreparedChannelTurn & { admission?: Extract; } { @@ -450,7 +508,7 @@ function isPreparedChannelTurn( } async function dispatchResolvedChannelTurn( - params: ChannelTurnResolved & { + params: (AssembledChannelTurn | PreparedChannelTurn) & { admission: Extract; log?: (event: ChannelTurnLogEvent) => void; messageId?: string; @@ -696,7 +754,9 @@ async function runChannelTurn< return { admission: preflightAdmission, dispatched: false }; } - const resolved = await params.adapter.resolveTurn(input, eventClass, preflight); + const resolved = assembleResolvedChannelTurn( + await params.adapter.resolveTurn(input, eventClass, preflight), + ); emit({ ...params, accountId: resolved.accountId ?? params.accountId, diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index 05f2e65fe07d..7c054aa09881 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -1,4 +1,3 @@ -// Type contracts for channel turn normalization, admission, dispatch, and delivery. import type { CommandTurnKind } from "../../auto-reply/command-turn-context.js"; import type { GetReplyOptions, @@ -272,6 +271,11 @@ export type AssembledChannelTurn = { toolsAllow?: string[]; replyOptions?: Omit; replyResolver?: GetReplyFromConfig; + sessionInitRetry?: { + delaysMs: readonly number[]; + signal?: AbortSignal; + sleep?: (ms: number, signal?: AbortSignal) => Promise; + }; record?: ChannelTurnRecordOptions; history?: ChannelTurnHistoryFinalizeOptions; admission?: Extract; @@ -302,8 +306,29 @@ export type PreparedChannelTurn = { messageId?: string; }; +type ChannelTurnRoute = { + agentId: string; + sessionKey: string; +}; + +type RoutedChannelTurn = Omit & { + route: ChannelTurnRoute; +}; + +export type ChannelTurnPlan = RoutedChannelTurn< + Omit +>; + +type PreparedChannelTurnPlan = RoutedChannelTurn< + PreparedChannelTurn +> & { + cfg: OpenClawConfig; +}; + /** Resolved turn shape returned by adapters before final run/dispatch handling. */ export type ChannelTurnResolved = + | ChannelTurnPlan + | PreparedChannelTurnPlan | (AssembledChannelTurn & { admission?: Extract; }) diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index ca99b2210c55..f8deded43434 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -56,8 +56,6 @@ const mocks = vi.hoisted(() => ({ })), probeGatewayMemoryStatus: vi.fn(async () => ({ checked: true, ready: true, skipped: false })), listHealthChecks: vi.fn(), - getHealthCheck: vi.fn(), - registerHealthCheck: vi.fn(), noteChromeMcpBrowserReadiness: vi.fn(), detectLegacyStateMigrations: vi.fn(), runLegacyStateMigrations: vi.fn(), @@ -313,8 +311,6 @@ vi.mock("./health-check-registry.js", async (importOriginal) => { } return registeredChecks.filter((check) => check.kind !== "core"); }, - getHealthCheck: mocks.getHealthCheck, - registerHealthCheck: mocks.registerHealthCheck, }; }); @@ -590,9 +586,6 @@ describe("doctor health contributions", () => { { id: "core/example/internal", kind: "core" }, { id: "plugin/example/unrelated", kind: "plugin" }, ]); - mocks.getHealthCheck.mockReset(); - mocks.getHealthCheck.mockReturnValue(undefined); - mocks.registerHealthCheck.mockReset(); mocks.noteChromeMcpBrowserReadiness.mockReset(); mocks.noteChromeMcpBrowserReadiness.mockResolvedValue(undefined); mocks.detectLegacyStateMigrations.mockReset(); @@ -2740,128 +2733,6 @@ describe("doctor health contributions", () => { expect(mocks.runDoctorHealthRepairs).not.toHaveBeenCalled(); }); - it("reports runtime tool schema blockers during normal doctor runs", async () => { - const contribution = requireDoctorContribution("doctor:runtime-tool-schemas"); - mocks.getHealthCheck.mockReturnValue({ - id: "core/doctor/runtime-tool-schemas", - detect: vi.fn(async () => [ - { - checkId: "core/doctor/runtime-tool-schemas", - severity: "error", - message: - "Tool fuzzplugin_move_angles from plugin fuzzplugin has an unsupported input schema for runtime projection.", - path: "plugins.entries.fuzzplugin", - target: "fuzzplugin_move_angles", - requirement: 'fuzzplugin_move_angles.parameters.type must be "object"', - fixHint: - "Disable or update the offending plugin/tool so its parameters are a JSON object schema, then rerun doctor.", - }, - ]), - }); - const ctx = { - cfg: {}, - configResult: { cfg: {} }, - sourceConfigValid: true, - prompter: buildDoctorPrompter(false), - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - options: {}, - cfgForPersistence: {}, - configPath: "/tmp/fake-openclaw.json", - env: {}, - } as unknown as Parameters<(typeof contribution)["run"]>[0]; - - await contribution.run(ctx); - - expect(ctx.healthOk).toBe(false); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Tool fuzzplugin_move_angles from plugin fuzzplugin"), - "Doctor warnings", - ); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining('issue: fuzzplugin_move_angles.parameters.type must be "object"'), - "Doctor warnings", - ); - }); - - it("reports provider catalog projection blockers during normal doctor runs", async () => { - const contribution = requireDoctorContribution("doctor:provider-catalog-projection"); - mocks.getHealthCheck.mockReturnValue({ - id: "core/doctor/provider-catalog-projection", - detect: vi.fn(async () => [ - { - checkId: "core/doctor/provider-catalog-projection", - severity: "error", - message: - "Provider catalog mockplugin cannot be projected into the unified text model catalog.", - path: "plugins.entries.mockplugin", - target: "mockplugin", - requirement: "provider catalog entry read failed", - fixHint: - "Fix the plugin provider catalog hook or disable the plugin, then rerun doctor before relying on model discovery.", - }, - ]), - }); - const ctx = { - cfg: {}, - configResult: { cfg: {} }, - sourceConfigValid: true, - prompter: buildDoctorPrompter(false), - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - options: {}, - cfgForPersistence: {}, - configPath: "/tmp/fake-openclaw.json", - env: {}, - } as Parameters<(typeof contribution)["run"]>[0]; - - await contribution.run(ctx); - - expect(ctx.healthOk).toBe(false); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Provider catalog mockplugin cannot be projected"), - "Doctor warnings", - ); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("issue: provider catalog entry read failed"), - "Doctor warnings", - ); - }); - - it("reports local audio acceleration as information without failing doctor health", async () => { - const contribution = requireDoctorContribution("doctor:local-audio-acceleration"); - mocks.getHealthCheck.mockReturnValue({ - id: "core/doctor/local-audio-acceleration", - detect: vi.fn(async () => [ - { - checkId: "core/doctor/local-audio-acceleration", - severity: "info", - message: "Local STT auto-selection: mlx-whisper is available.", - path: "tools.media.audio.models", - }, - ]), - }); - const ctx = { - cfg: {}, - configResult: { cfg: {} }, - sourceConfigValid: true, - prompter: buildDoctorPrompter(false), - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - options: {}, - cfgForPersistence: {}, - configPath: "/tmp/fake-openclaw.json", - env: {}, - healthOk: true, - } as Parameters<(typeof contribution)["run"]>[0]; - - await contribution.run(ctx); - - expect(ctx.healthOk).toBe(true); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Local STT auto-selection"), - "Doctor information", - ); - expect(mocks.note).not.toHaveBeenCalledWith(expect.anything(), "Doctor warnings"); - }); - it.each([false, true])( "reports default-account routing warnings during doctor runs (repair=%s)", async (shouldRepair) => { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index be2b18751431..01e6e0e0b9aa 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1502,13 +1502,11 @@ async function runCoreHealthFindingNote( ctx: DoctorHealthFlowContext, checkId: string, ): Promise { - const { registerCoreHealthChecks } = await loadDoctorCoreChecksModule(); - const { getHealthCheck } = await loadHealthCheckRegistryModule(); + const { CORE_HEALTH_CHECKS } = await loadDoctorCoreChecksModule(); const { resolveAgentWorkspaceDir, resolveDefaultAgentId } = await loadAgentScopeModule(); const { note } = await loadNoteModule(); - registerCoreHealthChecks(); - const check = getHealthCheck(checkId); + const check = CORE_HEALTH_CHECKS.find((candidate) => candidate.id === checkId); if (!check) { return; } diff --git a/src/infra/approval-view-model.types.ts b/src/infra/approval-view-model.types.ts index d6206879f669..8f75e300aa41 100644 --- a/src/infra/approval-view-model.types.ts +++ b/src/infra/approval-view-model.types.ts @@ -1,5 +1,8 @@ // Defines view-model shapes for approval prompts and resolutions. -import type { InteractiveReplyButton, MessagePresentationAction } from "../interactive/payload.js"; +import type { + MessagePresentationAction, + MessagePresentationButton, +} from "../interactive/payload.js"; import type { ChannelApprovalKind } from "./approval-types.js"; import type { CommandExplanationSummary } from "./command-analysis/explain.js"; import type { @@ -16,7 +19,7 @@ export type ApprovalActionView = { kind?: "command" | "decision"; decision: ExecApprovalDecision; label: string; - style: NonNullable; + style: NonNullable; action?: MessagePresentationAction; /** Copyable command fallback for non-interactive surfaces. */ command: string; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index f794549ac5c6..b4b3bb955a91 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -36,10 +36,10 @@ import type { import type { InternalChannelThreadingToolContext } from "../../channels/threading-tool-context-internal.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { - hasInteractiveReplyBlocks, + hasLegacyInteractiveReplyBlocks, hasMessagePresentationBlocks, hasReplyPayloadContent, - normalizeInteractiveReply, + normalizeLegacyInteractiveReply, normalizeMessagePresentation, type ReplyPayloadDelivery, } from "../../interactive/payload.js"; @@ -1126,7 +1126,7 @@ async function buildSendPayloadParts(params: { const hasMediaHint = Boolean(mediaHint) || mediaUrlHints.length > 0 || attachmentMediaHints.length > 0; const hasPresentation = hasMessagePresentationBlocks(actionParams.presentation); - const hasInteractive = hasInteractiveReplyBlocks(actionParams.interactive); + const hasInteractive = hasLegacyInteractiveReplyBlocks(actionParams.interactive); const location = normalizeOutboundLocation(actionParams.location); const caption = readStringParam(actionParams, "caption", { allowEmpty: true }) ?? ""; let message = @@ -1249,7 +1249,7 @@ async function buildSendPayloadParts(params: { ? (rawChannelData as Record) : undefined; const presentation = normalizeMessagePresentation(actionParams.presentation); - const interactive = normalizeInteractiveReply(actionParams.interactive); + const interactive = normalizeLegacyInteractiveReply(actionParams.interactive); return { message, payload: { diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 3ccd91631517..289d1d5c46ac 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -233,10 +233,8 @@ export function enforceCrossContextPolicy(params: { cfg: params.cfg, agentId: params.agentId, }); - if (messageConfig?.allowCrossContextSend) { - return; - } - + // Doctor moves the shipped allowCrossContextSend flag into this canonical policy. + // Runtime must not keep a second legacy interpretation path here. const currentProvider = params.toolContext?.currentChannelProvider; const allowWithinProvider = messageConfig?.crossContext?.allowWithinProvider !== false; const allowAcrossProviders = messageConfig?.crossContext?.allowAcrossProviders === true; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 25bb21352fe5..09885f34e027 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -13,15 +13,15 @@ import { import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { - hasInteractiveReplyBlocks, + hasLegacyInteractiveReplyBlocks, hasMessagePresentationBlocks, hasReplyChannelData, hasReplyPayloadContent, - normalizeInteractiveReply, + normalizeLegacyInteractiveReply, normalizeMessagePresentation, renderMessagePresentationChartFallbackText, renderMessagePresentationTableFallbackText, - type InteractiveReply, + type LegacyInteractiveReply, type MessagePresentation, type ReplyPayloadDelivery, } from "../../interactive/payload.js"; @@ -35,7 +35,7 @@ export type NormalizedOutboundPayload = { audioAsVoice?: boolean; presentation?: MessagePresentation; delivery?: ReplyPayloadDelivery; - interactive?: InteractiveReply; + interactive?: LegacyInteractiveReply; channelData?: Record; location?: ReplyPayload["location"]; /** Hook-only content for audio-only TTS payloads. Never used as channel text/caption. */ @@ -50,7 +50,7 @@ export type OutboundPayloadJson = { audioAsVoice?: boolean; presentation?: MessagePresentation; delivery?: ReplyPayloadDelivery; - interactive?: InteractiveReply; + interactive?: LegacyInteractiveReply; channelData?: Record; location?: ReplyPayload["location"]; }; @@ -79,7 +79,9 @@ type OutboundPayloadMirror = { mediaUrls: string[]; }; -type MirrorTextBlock = MessagePresentation["blocks"][number] | InteractiveReply["blocks"][number]; +type MirrorTextBlock = + | MessagePresentation["blocks"][number] + | LegacyInteractiveReply["blocks"][number]; function collectBlockMirrorText( blocks: readonly MirrorTextBlock[], @@ -136,7 +138,7 @@ function collectPresentationMirrorText(presentation: MessagePresentation | undef return lines; } -function collectInteractiveMirrorText(interactive: InteractiveReply | undefined): string[] { +function collectInteractiveMirrorText(interactive: LegacyInteractiveReply | undefined): string[] { if (!interactive) { return []; } @@ -154,7 +156,7 @@ function resolveOutboundMirrorText(entry: OutboundPayloadPlan): string { : []; return [text, ...structuredDataText].join("\n"); } - const interactive = normalizeInteractiveReply(entry.payload.interactive); + const interactive = normalizeLegacyInteractiveReply(entry.payload.interactive); return [ ...collectPresentationMirrorText(presentation), ...collectInteractiveMirrorText(interactive), @@ -269,7 +271,7 @@ function createOutboundPayloadPlanEntry( return { payload: normalizedPayload, hasPresentation: hasMessagePresentationBlocks(normalizedPayload.presentation), - hasInteractive: hasInteractiveReplyBlocks(normalizedPayload.interactive), + hasInteractive: hasLegacyInteractiveReplyBlocks(normalizedPayload.interactive), hasChannelData, isSilent, }; diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 089254a528aa..50bd007e0214 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -150,48 +150,60 @@ export function resolveMessagePresentationOptionAction( return option.value ? { type: "callback", value: option.value } : undefined; } -/** - * @deprecated Use MessagePresentationButton. - */ -export type InteractiveReplyButton = MessagePresentationButton; +export type LegacyInteractiveReplyButton = MessagePresentationButton; -/** - * @deprecated Use MessagePresentationOption. - */ -export type InteractiveReplyOption = MessagePresentationOption; +/** @deprecated Use MessagePresentationButton. */ +export type InteractiveReplyButton = LegacyInteractiveReplyButton; -/** - * @deprecated Use MessagePresentationTextBlock. - */ -export type InteractiveReplyTextBlock = { +export type LegacyInteractiveReplyOption = MessagePresentationOption; + +/** @deprecated Use MessagePresentationOption. */ +export type InteractiveReplyOption = LegacyInteractiveReplyOption; + +export type LegacyInteractiveReplyTextBlock = { type: "text"; text: string; }; -/** - * @deprecated Use MessagePresentationSelectBlock. - */ -export type InteractiveReplySelectBlock = { +/** @deprecated Use MessagePresentationTextBlock. */ +export type InteractiveReplyTextBlock = LegacyInteractiveReplyTextBlock; + +export type LegacyInteractiveReplySelectBlock = { type: "select"; placeholder?: string; - options: InteractiveReplyOption[]; + options: LegacyInteractiveReplyOption[]; }; -/** - * @deprecated Use MessagePresentationBlock. - */ -export type InteractiveReplyBlock = - | InteractiveReplyTextBlock +/** @deprecated Use MessagePresentationSelectBlock. */ +export type InteractiveReplySelectBlock = LegacyInteractiveReplySelectBlock; + +export type LegacyInteractiveReplyBlock = + | LegacyInteractiveReplyTextBlock | MessagePresentationButtonsBlock - | InteractiveReplySelectBlock; + | LegacyInteractiveReplySelectBlock; -/** - * @deprecated Use MessagePresentation. - */ -export type InteractiveReply = { - blocks: InteractiveReplyBlock[]; +/** @deprecated Use MessagePresentationBlock. */ +export type InteractiveReplyBlock = LegacyInteractiveReplyBlock; + +export type LegacyInteractiveReply = { + blocks: LegacyInteractiveReplyBlock[]; }; +export function reduceLegacyInteractiveReply( + interactive: LegacyInteractiveReply | undefined, + initialState: TState, + reduce: (state: TState, block: LegacyInteractiveReplyBlock, index: number) => TState, +): TState { + let state = initialState; + for (const [index, block] of (interactive?.blocks ?? []).entries()) { + state = reduce(state, block, index); + } + return state; +} + +/** @deprecated Use MessagePresentation. */ +export type InteractiveReply = LegacyInteractiveReply; + export type MessagePresentationTextBlock = { type: "text"; /** Primary markdown-ish text rendered in the message body. */ @@ -608,10 +620,7 @@ function normalizeTableBlock( }; } -/** - * @deprecated Use normalizeMessagePresentation. - */ -export function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined { +export function normalizeLegacyInteractiveReply(raw: unknown): LegacyInteractiveReply | undefined { const record = toRecord(raw); if (!record) { return undefined; @@ -620,6 +629,9 @@ export function normalizeInteractiveReply(raw: unknown): InteractiveReply | unde return blocks.length > 0 ? { blocks } : undefined; } +/** @deprecated Use normalizeMessagePresentation. */ +export const normalizeInteractiveReply = normalizeLegacyInteractiveReply; + function normalizePresentationBlock(raw: unknown): MessagePresentationBlock | undefined { const record = toRecord(raw); if (!record) { @@ -676,8 +688,10 @@ export function normalizeMessagePresentation(raw: unknown): MessagePresentation /** * @deprecated Use hasMessagePresentationBlocks. */ -export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply { - return Boolean(normalizeInteractiveReply(value)); +export const hasInteractiveReplyBlocks = hasLegacyInteractiveReplyBlocks; + +export function hasLegacyInteractiveReplyBlocks(value: unknown): value is LegacyInteractiveReply { + return Boolean(normalizeLegacyInteractiveReply(value)); } export function hasMessagePresentationBlocks(value: unknown): value is MessagePresentation { @@ -798,11 +812,8 @@ export function presentationToInteractiveControlsReply( }); } -/** - * @deprecated Legacy bridge for old InteractiveReply payloads. New producers should send MessagePresentation. - */ -export function interactiveReplyToPresentation( - interactive: InteractiveReply, +export function legacyInteractiveReplyToPresentation( + interactive: LegacyInteractiveReply, ): MessagePresentation | undefined { const blocks = interactive.blocks.map((block): MessagePresentationBlock => { if (block.type === "text") { @@ -820,6 +831,11 @@ export function interactiveReplyToPresentation( return blocks.length > 0 ? { blocks } : undefined; } +/** + * @deprecated Legacy bridge for old InteractiveReply payloads. New producers should send MessagePresentation. + */ +export const interactiveReplyToPresentation = legacyInteractiveReplyToPresentation; + /** * Render presentation blocks as plain-text fallback for channels that do not * support native interactive controls. @@ -965,7 +981,7 @@ export function hasReplyContent(params: { mediaUrl || params.mediaUrls?.some((entry) => Boolean(normalizeOptionalString(entry))) || hasMessagePresentationBlocks(params.presentation) || - hasInteractiveReplyBlocks(params.interactive) || + hasLegacyInteractiveReplyBlocks(params.interactive) || params.hasChannelData || params.extraContent, ); @@ -998,22 +1014,21 @@ export function hasReplyPayloadContent( }); } -/** - * @deprecated Use renderMessagePresentationFallbackText with MessagePresentation. - */ -export function resolveInteractiveTextFallback(params: { +export function resolveLegacyInteractiveTextFallback(params: { text?: string; - interactive?: InteractiveReply; + interactive?: LegacyInteractiveReply; }): string | undefined { const text = normalizeOptionalString(params.text); if (text) { return params.text; } const interactiveText = (params.interactive?.blocks ?? []) - .filter((block): block is InteractiveReplyTextBlock => block.type === "text") + .filter((block): block is LegacyInteractiveReplyTextBlock => block.type === "text") .map((block) => block.text.trim()) .filter(Boolean) .join("\n\n"); return interactiveText || params.text; } +/** @deprecated Use renderMessagePresentationFallbackText with MessagePresentation. */ +export const resolveInteractiveTextFallback = resolveLegacyInteractiveTextFallback; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index ca8cc86e1081..b4e98b58a72a 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -23,6 +23,7 @@ export { export { createDirectDmPreCryptoGuardPolicy, createPreCryptoDirectDmAuthorizer, + dispatchInboundDirectDm, dispatchInboundDirectDmWithRuntime, resolveInboundDirectDmAccessWithRuntime, type AccessGroupMembershipResolver, @@ -32,6 +33,7 @@ export { type ResolvedInboundDirectDmAccess, } from "../channels/direct-dm.js"; export { + formatAgentEnvelope, formatInboundEnvelope, formatInboundFromLabel, resolveEnvelopeFormatOptions, @@ -93,6 +95,17 @@ export { resolveUnmentionedGroupInboundPolicy, } from "../channels/inbound-event/classification.js"; export type { ClassifyChannelInboundEventParams } from "../channels/inbound-event/classification.js"; +export { + createChannelInboundEnvelopeBuilder, + resolveChannelInboundRouteEnvelope, + type ChannelInboundEnvelopeInput, +} from "../channels/inbound-event/envelope.js"; +export { + DEFAULT_CHANNEL_FEEDBACK_REFLECTION_COOLDOWN_MS, + recordChannelFeedbackEvent, + runChannelFeedbackReflection, + type ChannelFeedbackReflectionResult, +} from "../channels/feedback-reflection.js"; export { buildChannelInboundEventContext, // @deprecated Prefer `buildChannelInboundEventContext`. @@ -167,6 +180,7 @@ export const filterChannelTurnSupplementalContext = filterChannelInboundSuppleme export { runChannelInboundEvent, runPreparedInboundReply, + dispatchChannelInboundTurn, dispatchChannelInboundReply, recordDroppedChannelInboundHistory, dispatchReplyFromConfigWithSettledDispatcher, @@ -179,6 +193,7 @@ export type { AssembledInboundReply, ChannelBotLoopProtectionFacts, ChannelInboundEventRunnerParams, + ChannelInboundTurnPlan, ChannelInboundDroppedHistoryOptions, PreparedInboundReply, InboundReplyDispatchResult, diff --git a/src/plugin-sdk/direct-dm.ts b/src/plugin-sdk/direct-dm.ts index 2ede8732b664..ff9a3a439a4a 100644 --- a/src/plugin-sdk/direct-dm.ts +++ b/src/plugin-sdk/direct-dm.ts @@ -1,2 +1,12 @@ /** @deprecated Compatibility subpath. Use `openclaw/plugin-sdk/channel-inbound`. */ -export * from "../channels/direct-dm.js"; +export { + createDirectDmPreCryptoGuardPolicy, + createPreCryptoDirectDmAuthorizer, + dispatchInboundDirectDmWithRuntime, + resolveInboundDirectDmAccessWithRuntime, + type AccessGroupMembershipResolver, + type DirectDmCommandAuthorizationRuntime, + type DirectDmPreCryptoGuardPolicy, + type DirectDmPreCryptoGuardPolicyOverrides, + type ResolvedInboundDirectDmAccess, +} from "../channels/direct-dm.js"; diff --git a/src/plugin-sdk/inbound-envelope.ts b/src/plugin-sdk/inbound-envelope.ts index 69fd201b4638..316a5b1bb6f7 100644 --- a/src/plugin-sdk/inbound-envelope.ts +++ b/src/plugin-sdk/inbound-envelope.ts @@ -1,146 +1,6 @@ -// Shared inbound envelope helpers wire plugin route resolution to session-aware reply formatting. -type RouteLike = { - agentId: string; - sessionKey: string; -}; - -type RoutePeerLike = { - kind: "direct" | "group" | "channel"; - id: string | number; -}; - -type InboundEnvelopeFormatParams = { - channel: string; - from: string; - timestamp?: number; - previousTimestamp?: number; - envelope: TEnvelope; - body: string; -}; - -type InboundRouteResolveParams = { - cfg: TConfig; - channel: string; - accountId: string; - peer: TPeer; -}; - -/** Create an envelope formatter bound to one resolved route and session store. */ -export function createInboundEnvelopeBuilder(params: { - cfg: TConfig; - route: RouteLike; - sessionStore?: string; - resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; - readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; - resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; - formatAgentEnvelope: (params: InboundEnvelopeFormatParams) => string; -}) { - const storePath = params.resolveStorePath(params.sessionStore, { - agentId: params.route.agentId, - }); - const envelopeOptions = params.resolveEnvelopeFormatOptions(params.cfg); - return (input: { channel: string; from: string; body: string; timestamp?: number }) => { - const previousTimestamp = params.readSessionUpdatedAt({ - storePath, - sessionKey: params.route.sessionKey, - }); - const body = params.formatAgentEnvelope({ - channel: input.channel, - from: input.from, - timestamp: input.timestamp, - previousTimestamp, - envelope: envelopeOptions, - body: input.body, - }); - return { storePath, body }; - }; -} - -/** Resolve a route first, then return both the route and a formatter for future inbound messages. */ -export function resolveInboundRouteEnvelopeBuilder< - TConfig, - TEnvelope, - TRoute extends RouteLike, - TPeer extends RoutePeerLike, ->(params: { - cfg: TConfig; - channel: string; - accountId: string; - peer: TPeer; - resolveAgentRoute: (params: InboundRouteResolveParams) => TRoute; - sessionStore?: string; - resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; - readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; - resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; - formatAgentEnvelope: (params: InboundEnvelopeFormatParams) => string; -}): { - route: TRoute; - buildEnvelope: ReturnType>; -} { - const route = params.resolveAgentRoute({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - peer: params.peer, - }); - const buildEnvelope = createInboundEnvelopeBuilder({ - cfg: params.cfg, - route, - sessionStore: params.sessionStore, - resolveStorePath: params.resolveStorePath, - readSessionUpdatedAt: params.readSessionUpdatedAt, - resolveEnvelopeFormatOptions: params.resolveEnvelopeFormatOptions, - formatAgentEnvelope: params.formatAgentEnvelope, - }); - return { route, buildEnvelope }; -} - -type InboundRouteEnvelopeRuntime< - TConfig, - TEnvelope, - TRoute extends RouteLike, - TPeer extends RoutePeerLike, -> = { - routing: { - resolveAgentRoute: (params: InboundRouteResolveParams) => TRoute; - }; - session: { - resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string; - readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined; - }; - reply: { - resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope; - formatAgentEnvelope: (params: InboundEnvelopeFormatParams) => string; - }; -}; - -/** Runtime-driven variant of inbound envelope resolution for plugins that already expose grouped helpers. */ -export function resolveInboundRouteEnvelopeBuilderWithRuntime< - TConfig, - TEnvelope, - TRoute extends RouteLike, - TPeer extends RoutePeerLike, ->(params: { - cfg: TConfig; - channel: string; - accountId: string; - peer: TPeer; - runtime: InboundRouteEnvelopeRuntime; - sessionStore?: string; -}): { - route: TRoute; - buildEnvelope: ReturnType>; -} { - return resolveInboundRouteEnvelopeBuilder({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - peer: params.peer, - resolveAgentRoute: (routeParams) => params.runtime.routing.resolveAgentRoute(routeParams), - sessionStore: params.sessionStore, - resolveStorePath: params.runtime.session.resolveStorePath, - readSessionUpdatedAt: params.runtime.session.readSessionUpdatedAt, - resolveEnvelopeFormatOptions: params.runtime.reply.resolveEnvelopeFormatOptions, - formatAgentEnvelope: params.runtime.reply.formatAgentEnvelope, - }); -} +// Shared inbound envelope helpers exposed for shipped plugin compatibility. +export { + createInboundEnvelopeBuilder, + resolveInboundRouteEnvelopeBuilder, + resolveInboundRouteEnvelopeBuilderWithRuntime, +} from "../channels/inbound-event/envelope.js"; diff --git a/src/plugin-sdk/interactive-runtime.ts b/src/plugin-sdk/interactive-runtime.ts index d6466ab5dd57..ae31f408bc07 100644 --- a/src/plugin-sdk/interactive-runtime.ts +++ b/src/plugin-sdk/interactive-runtime.ts @@ -15,6 +15,12 @@ export type { InteractiveReplyOption, InteractiveReplySelectBlock, InteractiveReplyTextBlock, + LegacyInteractiveReply, + LegacyInteractiveReplyBlock, + LegacyInteractiveReplyButton, + LegacyInteractiveReplyOption, + LegacyInteractiveReplySelectBlock, + LegacyInteractiveReplyTextBlock, MessagePresentation, MessagePresentationAction, MessagePresentationBlock, @@ -38,13 +44,16 @@ export type { } from "../interactive/payload.js"; export { hasInteractiveReplyBlocks, + hasLegacyInteractiveReplyBlocks, hasMessagePresentationBlocks, hasReplyChannelData, hasReplyContent, interactiveReplyToPresentation, + legacyInteractiveReplyToPresentation, isMessagePresentationInteractiveBlock, normalizeMessagePresentation, normalizeInteractiveReply, + normalizeLegacyInteractiveReply, presentationToInteractiveControlsReply, presentationToInteractiveReply, renderMessagePresentationChartFallbackText, @@ -55,4 +64,6 @@ export { resolveMessagePresentationControlValue, resolveMessagePresentationOptionAction, resolveInteractiveTextFallback, + reduceLegacyInteractiveReply, + resolveLegacyInteractiveTextFallback, } from "../interactive/payload.js"; diff --git a/src/plugin-sdk/memory-core-host-runtime-core.ts b/src/plugin-sdk/memory-core-host-runtime-core.ts index 44d1e6a93eb7..2a731dd3cbf3 100644 --- a/src/plugin-sdk/memory-core-host-runtime-core.ts +++ b/src/plugin-sdk/memory-core-host-runtime-core.ts @@ -12,6 +12,7 @@ export type { AnyAgentTool } from "../agents/tools/common.js"; export { resolveCronStyleNow } from "../agents/current-time.js"; export { resolveDefaultAgentId, resolveSessionAgentIds } from "../agents/agent-scope.js"; export { resolveMemorySearchConfig } from "../agents/memory-search.js"; +export { resolveMemoryDreamingPluginConfig } from "../memory-host-sdk/dreaming.js"; export { parseNonNegativeByteSize } from "../config/byte-size.js"; export { getRuntimeConfig } from "../config/config.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts index 7b7930564cdc..4e5b515fcacf 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts @@ -141,6 +141,45 @@ describe("createPluginRuntimeMock", () => { ); }); + it("uses merged channel overrides when dispatching an inbound turn", async () => { + const resolveStorePath = vi.fn(() => "/tmp/override-sessions.json"); + const recordInboundSession = vi.fn(async () => undefined); + const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => ({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + })); + const runtime = createPluginRuntimeMock({ + channel: { + session: { resolveStorePath, recordInboundSession }, + reply: { dispatchReplyWithBufferedBlockDispatcher }, + }, + }); + + await runtime.channel.inbound.dispatch({ + cfg: {}, + channel: "test", + route: { + agentId: "main", + sessionKey: "agent:main:test:direct:u1", + }, + ctxPayload: { + Body: "hello", + CommandAuthorized: false, + SessionKey: "agent:main:test:direct:u1", + }, + delivery: { deliver: vi.fn(async () => undefined) }, + }); + + expect(resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "main" }); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + storePath: "/tmp/override-sessions.json", + sessionKey: "agent:main:test:direct:u1", + }), + ); + expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce(); + }); + it("routes untrusted group prompt facts into untrusted structured context", () => { const runtime = createPluginRuntimeMock(); diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index b4ee880830ea..6b74ef5196c0 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -254,6 +254,24 @@ export function createPluginRuntimeMock(overrides: DeepPartial = }; }, ) as unknown as PluginRuntime["channel"]["inbound"]["runPreparedReply"]; + const dispatchChannelTurnPlanMock = vi.fn( + async (params: Parameters[0]) => { + if (!mergedRuntime) { + throw new Error("plugin runtime mock dispatch used before initialization"); + } + return await dispatchAssembledChannelTurnMock({ + ...params, + agentId: params.route.agentId, + routeSessionKey: params.route.sessionKey, + storePath: mergedRuntime.channel.session.resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }), + recordInboundSession: mergedRuntime.channel.session.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: + mergedRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + }); + }, + ) as unknown as PluginRuntime["channel"]["inbound"]["dispatch"]; const runChannelTurnMock = vi.fn( async (params: Parameters[0]) => { const input = await params.adapter.ingest(params.raw); @@ -296,7 +314,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = ? await runPreparedChannelTurnMock({ ...resolved, admission, - }) + } as unknown as Parameters[0]) : await dispatchAssembledChannelTurnMock({ ...resolved, admission, @@ -767,6 +785,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = }, inbound: { run: runChannelTurnMock, + dispatch: dispatchChannelTurnPlanMock, dispatchReply: dispatchAssembledChannelTurnMock as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"], buildContext: buildChannelInboundEventContextMock, @@ -879,6 +898,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = }, }; - return mergeDeep(base, overrides); + const mergedRuntime = mergeDeep(base, overrides); + return mergedRuntime; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugins/capability-runtime-vitest-shims/media-runtime.ts b/src/plugins/capability-runtime-vitest-shims/media-runtime.ts index 4dafbbe736b2..bd373f11f9cc 100644 --- a/src/plugins/capability-runtime-vitest-shims/media-runtime.ts +++ b/src/plugins/capability-runtime-vitest-shims/media-runtime.ts @@ -1,2 +1,5 @@ /** Vitest shim re-export for media runtime compatibility in capability tests. */ -export { isVoiceCompatibleAudio } from "../../media/audio.js"; +export { + isVoiceMessageCompatibleAudio, + isVoiceMessageCompatibleAudio as isVoiceCompatibleAudio, +} from "../../media/audio.js"; diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index df02dc1f963a..d931c7c8911e 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -806,7 +806,7 @@ const PLUGIN_COMPAT_RECORDS = [ "WhatsApp monitorWebInbox onMessage callback", "WhatsApp monitorWebChannel listenerFactory injected messages", ], - diagnostics: ["TypeScript @deprecated WebInboundMessage flat field annotations"], + diagnostics: ["TypeScript deprecated WebInboundMessage flat field annotations"], tests: ["src/plugins/compat/registry.test.ts"], releaseNote: "WhatsApp WebInboundMessage flat fields remain wired as deprecated aliases while callbacks migrate to nested inbound contexts.", @@ -827,7 +827,7 @@ const PLUGIN_COMPAT_RECORDS = [ "WhatsApp monitorWebInbox onMessage callback", "WhatsApp monitorWebChannel listenerFactory injected messages", ], - diagnostics: ["TypeScript @deprecated WebInboundMessage admission field annotations"], + diagnostics: ["TypeScript deprecated WebInboundMessage admission field annotations"], tests: ["src/plugins/compat/registry.test.ts"], releaseNote: "WhatsApp WebInboundMessage top-level admission fields remain available while callbacks migrate to the admission envelope.", diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 4113c2e71336..ee0fe3b65872 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -53,7 +53,7 @@ const BUNDLED_LIVE_CONFIG_HOOK_GUARDS = { ], "extensions/memory-core/src/dreaming.ts": [ 'params.reason === "runtime"', - "resolveMemoryCorePluginConfig(startupCfg)", + "resolveMemoryDreamingPluginConfig(startupCfg)", "api.runtime.config?.current?.() ?? api.config", ], "extensions/memory-lancedb/index.ts": ["resolveLivePluginConfigObject(", '"memory-lancedb"'], diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index 38d69fdde97f..d42f8a90642d 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -102,7 +102,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export type { GoogleChatAccountConfig, GoogleChatConfig } from "openclaw/plugin-sdk/config-contracts";', 'export { extractToolSend } from "openclaw/plugin-sdk/tool-send";', 'export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";', - 'export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";', 'export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";', 'export { registerWebhookTargetWithPluginRoute, resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline } from "openclaw/plugin-sdk/webhook-targets";', 'export { createWebhookInFlightLimiter, readJsonWebhookBodyOrReject, type WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";', diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 99987ac16673..8f69ce4ea366 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -54,6 +54,7 @@ import { import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import { recordInboundSession } from "../../channels/session.js"; import { + dispatchChannelInboundTurn, dispatchChannelInboundReply, runChannelInboundEvent, runPreparedInboundReply, @@ -71,12 +72,7 @@ import { updateSessionLastRoute, } from "../../config/sessions/session-accessor.js"; import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js"; -import { - fetchRemoteMedia, - readRemoteMediaBuffer, - saveRemoteMedia, - saveResponseMedia, -} from "../../media/fetch.js"; +import { readRemoteMediaBuffer, saveRemoteMedia, saveResponseMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -154,7 +150,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { }, media: { readRemoteMediaBuffer, - fetchRemoteMedia, + fetchRemoteMedia: readRemoteMediaBuffer, saveRemoteMedia, saveResponseMedia, saveMediaBuffer, @@ -198,6 +194,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { buildContext: buildChannelInboundEventContext, run: runChannelInboundEvent, runPreparedReply: runPreparedInboundReply, + dispatch: dispatchChannelInboundTurn, dispatchReply: dispatchChannelInboundReply, }, threadBindings: { diff --git a/src/plugins/runtime/runtime-media.ts b/src/plugins/runtime/runtime-media.ts index bb20bcb9b925..8a4c070ecda5 100644 --- a/src/plugins/runtime/runtime-media.ts +++ b/src/plugins/runtime/runtime-media.ts @@ -1,7 +1,7 @@ // Runtime media helpers load and classify media attachments for plugin runtimes. import { mediaKindFromMime } from "@openclaw/media-core/constants"; import { detectMime } from "@openclaw/media-core/mime"; -import { isVoiceCompatibleAudio } from "../../media/audio.js"; +import { isVoiceMessageCompatibleAudio } from "../../media/audio.js"; import { getImageMetadata, resizeToJpeg } from "../../media/media-services.js"; import { loadWebMedia } from "../../media/web-media.js"; import type { PluginRuntime } from "./types.js"; @@ -12,7 +12,7 @@ export function createRuntimeMedia(): PluginRuntime["media"] { loadWebMedia, detectMime, mediaKindFromMime, - isVoiceCompatibleAudio, + isVoiceCompatibleAudio: isVoiceMessageCompatibleAudio, getImageMetadata, resizeToJpeg, }; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index c2b16785212f..88a90fb09875 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -189,6 +189,8 @@ export type PluginRuntimeChannel = { run: typeof import("../../channels/turn/kernel.js").runChannelInboundEvent; /** @deprecated Prefer `run` for raw inbound events or `dispatchReply` for assembled contexts. */ runPreparedReply: typeof import("../../channels/turn/kernel.js").runPreparedInboundReply; + dispatch: typeof import("../../channels/turn/kernel.js").dispatchChannelInboundTurn; + /** Compatibility escape hatch; prefer `dispatch`, which keeps session wiring in core. */ dispatchReply: typeof import("../../channels/turn/kernel.js").dispatchChannelInboundReply; }; threadBindings: { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 589a6c495551..d001b72c8070 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -134,6 +134,18 @@ describe("resolveAgentRoute", () => { expect(route.mainSessionKey).toBe("agent:main:work"); }); + test("allows a channel route to require a stronger direct-message scope", () => { + const route = resolveRoute({ + cfg: { session: { dmScope: "main" } }, + channel: "zalouser", + peer: { kind: "direct", id: "321" }, + dmScope: "per-channel-peer", + }); + + expect(route.sessionKey).toBe("agent:main:zalouser:direct:321"); + expect(route.dmScope).toBe("per-channel-peer"); + }); + test.each([ { dmScope: "per-peer" as const, expected: "agent:main:direct:+15551234567" }, { diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 1b4298709a39..700e5ba4ec7e 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -31,11 +31,12 @@ export type RoutePeer = { id: string; }; -type ResolveAgentRouteInput = { +export type ResolveAgentRouteInput = { cfg: OpenClawConfig; channel: string; accountId?: string | null; peer?: RoutePeer | null; + dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; /** Parent peer for threads — used for binding inheritance when peer doesn't match directly. */ parentPeer?: RoutePeer | null; guildId?: string | null; @@ -622,7 +623,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const teamId = normalizeId(input.teamId); const memberRoleIds = input.memberRoleIds ?? []; const memberRoleIdSet = new Set(memberRoleIds); - const dmScope = input.cfg.session?.dmScope ?? "main"; + const dmScope = input.dmScope ?? input.cfg.session?.dmScope ?? "main"; const identityLinks = input.cfg.session?.identityLinks; const shouldLogDebug = shouldLogVerbose(); const parentPeer = input.parentPeer