diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 13260854ed2..15c5c2d455e 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -640,7 +640,6 @@ src/channels/plugins/setup-wizard-helpers.ts src/channels/plugins/types.adapters.ts src/channels/streaming.ts src/channels/turn/kernel.test.ts -src/channels/turn/kernel.ts src/cli/capability-cli.test.ts src/cli/command-secret-gateway.test.ts src/cli/command-secret-gateway.ts diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts index 537d606cb2f..b62f8036d17 100644 --- a/extensions/feishu/src/bot.broadcast.test.ts +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -10,30 +10,20 @@ const { builtInboundContextCalls, mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, - mockDispatchInboundMessage, + mockDispatchReply, 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(), - }, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback: vi.fn(), })), mockCreateFeishuClient: vi.fn(), - mockDispatchInboundMessage: vi - .fn() - .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }), + mockDispatchReply: vi.fn().mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }), mockRecordInboundSession: vi.fn().mockResolvedValue(undefined), mockResolveAgentRoute: vi.fn(), mockResolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"), @@ -58,13 +48,6 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => { }; }); -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", @@ -133,15 +116,12 @@ describe("broadcast dispatch", () => { canStartAgentTurn: true, }; const turn = await params.adapter.resolveTurn(input, eventClass, {}); - if (!("runDispatch" in turn)) { - throw new Error("feishu broadcast test runtime only supports prepared turns"); + if (!("route" in turn) || !("delivery" in turn)) { + throw new Error("expected assembled Feishu channel turn plan"); } - 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, + const routeSessionKey = turn.route.sessionKey; + await mockRecordInboundSession({ + storePath: mockResolveStorePath(), sessionKey: turn.ctxPayload.SessionKey ?? routeSessionKey, ctx: turn.ctxPayload, groupResolution: turn.record?.groupResolution, @@ -150,11 +130,15 @@ describe("broadcast dispatch", () => { onRecordError: turn.record?.onRecordError ?? (() => undefined), }); return { - admission: { kind: "dispatch" as const }, + admission: turn.admission ?? { kind: "dispatch" as const }, dispatched: true, ctxPayload: turn.ctxPayload, routeSessionKey, - dispatchResult: await turn.runDispatch(), + dispatchResult: await mockDispatchReply({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + replyOptions: turn.replyOptions, + }), }; }), }, @@ -224,7 +208,7 @@ describe("broadcast dispatch", () => { beforeEach(() => { vi.clearAllMocks(); - mockDispatchInboundMessage.mockReset().mockResolvedValue({ + mockDispatchReply.mockReset().mockResolvedValue({ queuedFinal: false, counts: { final: 1 }, }); @@ -240,17 +224,9 @@ describe("broadcast dispatch", () => { matchedBy: "default", }); mockCreateFeishuReplyDispatcher.mockReturnValue({ - 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(), - }, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback: vi.fn(), }); mockCreateFeishuClient.mockReturnValue({ @@ -286,7 +262,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2); + expect(mockDispatchReply).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"); @@ -356,7 +332,7 @@ describe("broadcast dispatch", () => { }); it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => { - mockDispatchInboundMessage + mockDispatchReply .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, @@ -365,17 +341,9 @@ describe("broadcast dispatch", () => { }); const ensureNoVisibleReplyFallback = vi.fn(); mockCreateFeishuReplyDispatcher.mockReturnValueOnce({ - 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(), - }, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback, }); const cfg = createBroadcastConfig(); @@ -398,25 +366,18 @@ describe("broadcast dispatch", () => { }); it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => { - mockDispatchInboundMessage + mockDispatchReply .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: true, counts: { final: 1 }, + failedCounts: { tool: 0, block: 0, final: 1 }, }); const ensureNoVisibleReplyFallback = vi.fn(); mockCreateFeishuReplyDispatcher.mockReturnValueOnce({ - 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: 1 })), - markComplete: vi.fn(), - }, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback, }); const cfg = createBroadcastConfig(); @@ -439,7 +400,7 @@ describe("broadcast dispatch", () => { }); it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => { - mockDispatchInboundMessage + mockDispatchReply .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ queuedFinal: false, @@ -449,17 +410,9 @@ describe("broadcast dispatch", () => { }); const ensureNoVisibleReplyFallback = vi.fn(); mockCreateFeishuReplyDispatcher.mockReturnValueOnce({ - 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(), - }, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback, }); const cfg = createBroadcastConfig(); @@ -493,7 +446,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchInboundMessage).not.toHaveBeenCalled(); + expect(mockDispatchReply).not.toHaveBeenCalled(); expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled(); }); @@ -511,7 +464,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchInboundMessage).not.toHaveBeenCalled(); + expect(mockDispatchReply).not.toHaveBeenCalled(); expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled(); }); @@ -548,7 +501,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1); + expect(mockDispatchReply).toHaveBeenCalledTimes(1); expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); expect(builtInboundContextCalls).toHaveLength(1); expect(builtInboundContextCalls[0]?.SessionKey).toBe( @@ -593,9 +546,9 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), accountId: "account-A", }); - expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(2); + expect(mockDispatchReply).toHaveBeenCalledTimes(2); - mockDispatchInboundMessage.mockClear(); + mockDispatchReply.mockClear(); mockGetChatInfo.mockClear(); builtInboundContextCalls.length = 0; @@ -605,7 +558,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), accountId: "account-B", }); - expect(mockDispatchInboundMessage).not.toHaveBeenCalled(); + expect(mockDispatchReply).not.toHaveBeenCalled(); expect(mockGetChatInfo).not.toHaveBeenCalled(); }); @@ -643,7 +596,7 @@ describe("broadcast dispatch", () => { runtime: createRuntimeEnv(), }); - expect(mockDispatchInboundMessage).toHaveBeenCalledTimes(1); + expect(mockDispatchReply).toHaveBeenCalledTimes(1); const sessionKey = typeof builtInboundContextCalls[0]?.SessionKey === "string" ? builtInboundContextCalls[0].SessionKey diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index afd398fce69..b171e7b7350 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -22,9 +22,6 @@ type BoundConversation = ReturnType< ReturnType["resolveByConversation"] >; type BindingReadiness = Awaited>; -type ReplyDispatcher = Parameters< - PluginRuntime["channel"]["reply"]["withReplyDispatcher"] ->[0]["dispatcher"]; type DeepPartial = { [K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] @@ -35,18 +32,6 @@ type DeepPartial = { : T[K]; }; -function createReplyDispatcher(): ReplyDispatcher { - return { - 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(), - }; -} - function createConfiguredFeishuRoute(): NonNullable { return { bindingResolution: { @@ -206,13 +191,20 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): Plu inbound: { run: vi.fn(async (params) => { const input = await params.adapter.ingest(params.raw); - const turn = await params.adapter.resolveTurn(input, { - kind: "message", - canStartAgentTurn: true, - }); - if (!("route" in turn) || !("runDispatch" in turn)) { - throw new Error("expected a prepared channel turn plan"); + if (!input) { + return { + admission: { kind: "drop" as const, reason: "ingest-null" }, + dispatched: false, + }; } + const turn = await params.adapter.resolveTurn( + input, + { + kind: "message", + canStartAgentTurn: true, + }, + {}, + ); await runtime.channel.session.recordInboundSession({ storePath: runtime.channel.session.resolveStorePath(turn.cfg.session?.store, { agentId: turn.route.agentId, @@ -225,8 +217,18 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): Plu onRecordError: turn.record?.onRecordError ?? (() => undefined), }); return { + admission: turn.admission ?? { kind: "dispatch" as const }, dispatched: true, - dispatchResult: await turn.runDispatch(), + ctxPayload: turn.ctxPayload, + routeSessionKey: turn.route.sessionKey, + dispatchResult: + turn.admission?.kind === "observeOnly" + ? { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } } + : await mockDispatchInboundMessage({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + replyOptions: turn.replyOptions, + }), }; }), }, @@ -310,9 +312,9 @@ const { mockResolveFeishuBotName, } = vi.hoisted(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({ - dispatcher: createReplyDispatcher(), + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback: vi.fn(), })), mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }), @@ -544,9 +546,9 @@ describe("handleFeishuMessage ACP routing", () => { .mockReset() .mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" }); mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({ - dispatcher: createReplyDispatcher(), + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback: vi.fn(), }); @@ -1225,9 +1227,9 @@ describe("handleFeishuMessage command authorization", () => { }); const ensureNoVisibleReplyFallback = vi.fn(); mockCreateFeishuReplyDispatcher.mockReturnValueOnce({ - dispatcher: createReplyDispatcher(), + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback, }); @@ -1262,14 +1264,13 @@ describe("handleFeishuMessage command authorization", () => { mockDispatchReplyFromConfig.mockResolvedValueOnce({ queuedFinal: true, counts: { tool: 0, block: 0, final: 1 }, + failedCounts: { tool: 0, block: 0, final: 1 }, }); const ensureNoVisibleReplyFallback = vi.fn(); - const dispatcher = createReplyDispatcher(); - vi.mocked(dispatcher.getFailedCounts).mockReturnValue({ tool: 0, block: 0, final: 1 }); mockCreateFeishuReplyDispatcher.mockReturnValueOnce({ - dispatcher, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), ensureNoVisibleReplyFallback, }); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 60c57440cd1..59ca992cce3 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -19,7 +19,6 @@ 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, @@ -1556,7 +1555,7 @@ export async function handleFeishuMessage(params: { if (agentId === activeAgentId) { // Active agent: real Feishu dispatcher (responds on Feishu) const identity = resolveAgentOutboundIdentity(cfg, agentId); - const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } = + const { dispatcherOptions, delivery, replyOptions, ensureNoVisibleReplyFallback } = createFeishuReplyDispatcher({ cfg, agentId, @@ -1601,28 +1600,15 @@ export async function handleFeishuMessage(params: { route: { agentId, sessionKey: agentSessionKey }, ctxPayload: agentCtx, record: agentRecord, - onPreDispatchFailure: () => - core.channel.reply.settleReplyDispatcher({ - dispatcher, - onSettled: () => markDispatchIdle(), - }), - runDispatch: () => - dispatchInboundMessage({ - ctx: agentCtx, - cfg, - dispatcher, - onSettled: () => markDispatchIdle(), - replyOptions, - }), + dispatcherOptions, + delivery, + replyOptions, }), }, }); if ( turnResult.dispatched && - shouldSendNoVisibleReplyFallback({ - ...turnResult.dispatchResult, - failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 }, - }) + shouldSendNoVisibleReplyFallback(turnResult.dispatchResult) ) { await ensureNoVisibleReplyFallback("broadcast-dispatch-complete-no-visible-reply"); } @@ -1631,16 +1617,6 @@ export async function handleFeishuMessage(params: { // Strip CommandAuthorized so slash commands (e.g. /reset) don't silently // mutate observer sessions — only the active agent should execute commands. delete (agentCtx as Record).CommandAuthorized; - const noopDispatcher = { - sendToolResult: () => false, - sendBlockReply: () => false, - sendFinalReply: () => false, - waitForIdle: async () => {}, - getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }), - getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }), - markComplete: () => {}, - }; - log( `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`, ); @@ -1664,12 +1640,10 @@ export async function handleFeishuMessage(params: { route: { agentId, sessionKey: agentSessionKey }, ctxPayload: agentCtx, record: agentRecord, - runDispatch: () => - dispatchInboundMessage({ - ctx: agentCtx, - cfg, - dispatcher: noopDispatcher, - }), + admission: { kind: "observeOnly", reason: "broadcast-observer" }, + delivery: { + deliver: async () => ({ visibleReplySent: false }), + }, }), }, }); @@ -1730,7 +1704,7 @@ export async function handleFeishuMessage(params: { storePath, sessionKey: route.sessionKey, }); - const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } = + const { dispatcherOptions, delivery, replyOptions, ensureNoVisibleReplyFallback } = createFeishuReplyDispatcher({ cfg: effectiveCfg, agentId: route.agentId, @@ -1789,19 +1763,9 @@ export async function handleFeishuMessage(params: { historyMap: chatHistories, limit: historyLimit, }, - onPreDispatchFailure: () => - core.channel.reply.settleReplyDispatcher({ - dispatcher, - onSettled: () => markDispatchIdle(), - }), - runDispatch: () => - dispatchInboundMessage({ - ctx: ctxPayload, - cfg: effectiveCfg, - dispatcher, - onSettled: () => markDispatchIdle(), - replyOptions, - }), + dispatcherOptions, + delivery, + replyOptions, }), }, }); @@ -1810,12 +1774,7 @@ export async function handleFeishuMessage(params: { } const { dispatchResult } = turnResult; const { queuedFinal, counts } = dispatchResult; - if ( - shouldSendNoVisibleReplyFallback({ - ...dispatchResult, - failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 }, - }) - ) { + if (shouldSendNoVisibleReplyFallback(dispatchResult)) { await ensureNoVisibleReplyFallback("dispatch-complete-no-visible-reply"); } diff --git a/extensions/feishu/src/comment-dispatcher.test.ts b/extensions/feishu/src/comment-dispatcher.test.ts index d97d64a19ff..e1a07bab579 100644 --- a/extensions/feishu/src/comment-dispatcher.test.ts +++ b/extensions/feishu/src/comment-dispatcher.test.ts @@ -6,7 +6,6 @@ const createFeishuClientMock = vi.hoisted(() => vi.fn()); const createReplyPrefixContextMock = vi.hoisted(() => vi.fn()); const createCommentTypingReactionLifecycleMock = vi.hoisted(() => vi.fn()); const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn()); -const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); const getFeishuRuntimeMock = vi.hoisted(() => vi.fn()); vi.mock("./accounts.js", () => ({ @@ -32,14 +31,6 @@ vi.mock("./drive.js", () => ({ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock, })); -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal>(); - return { - ...actual, - createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, - }; -}); - import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js"; async function raceWithNextMacrotask(promise: Promise): Promise { @@ -59,12 +50,11 @@ describe("createFeishuCommentReplyDispatcher", () => { vi.doUnmock("./comment-reaction.js"); vi.doUnmock("./drive.js"); vi.doUnmock("./runtime.js"); - vi.doUnmock("openclaw/plugin-sdk/reply-runtime"); vi.resetModules(); }); function createTestCommentReplyDispatcher() { - createFeishuCommentReplyDispatcher({ + return createFeishuCommentReplyDispatcher({ cfg: {} as never, agentId: "main", runtime: { log: vi.fn(), error: vi.fn() } as never, @@ -77,12 +67,11 @@ describe("createFeishuCommentReplyDispatcher", () => { }); } - function latestReplyDispatcherOptions() { - const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0]; - if (!options) { - throw new Error("expected reply dispatcher options"); - } - return options as { + function replyDispatcherOptions(created: ReturnType) { + return { + ...created.dispatcherOptions, + deliver: created.delivery.deliver, + } as { deliver: (payload: { text: string }, phase: { kind: string }) => Promise | void; onCleanup?: () => Promise | void; onReplyStart?: () => Promise | void; @@ -111,15 +100,6 @@ describe("createFeishuCommentReplyDispatcher", () => { start: vi.fn(async () => {}), cleanup: vi.fn(async () => {}), }); - createReplyDispatcherWithTypingMock.mockImplementation(() => ({ - dispatcher: { - markComplete: vi.fn(), - waitForIdle: vi.fn(async () => {}), - }, - replyOptions: {}, - markDispatchIdle: vi.fn(), - markRunComplete: vi.fn(), - })); getFeishuRuntimeMock.mockReturnValue({ channel: { text: { @@ -127,10 +107,7 @@ describe("createFeishuCommentReplyDispatcher", () => { resolveChunkMode: vi.fn(() => "line"), chunkTextWithMode: vi.fn((text: string) => [text]), }, - reply: { - createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, - resolveHumanDelayConfig: vi.fn(() => undefined), - }, + reply: { resolveHumanDelayConfig: vi.fn(() => undefined) }, }, }); }); @@ -148,9 +125,8 @@ describe("createFeishuCommentReplyDispatcher", () => { cleanup, }); - createTestCommentReplyDispatcher(); - - const options = latestReplyDispatcherOptions(); + const created = createTestCommentReplyDispatcher(); + const options = replyDispatcherOptions(created); const deliverPromise = Promise.resolve( options.deliver({ text: "hello world" }, { kind: "final" }), ); @@ -184,9 +160,8 @@ describe("createFeishuCommentReplyDispatcher", () => { cleanup: vi.fn(async () => {}), }); - createTestCommentReplyDispatcher(); - - const options = latestReplyDispatcherOptions(); + const created = createTestCommentReplyDispatcher(); + const options = replyDispatcherOptions(created); await options.onReplyStart?.(); expect(start).toHaveBeenCalledTimes(1); diff --git a/extensions/feishu/src/comment-dispatcher.ts b/extensions/feishu/src/comment-dispatcher.ts index 6297f345888..b8ca8066bee 100644 --- a/extensions/feishu/src/comment-dispatcher.ts +++ b/extensions/feishu/src/comment-dispatcher.ts @@ -1,7 +1,7 @@ // Feishu plugin module implements comment dispatcher behavior. import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; -import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { @@ -57,53 +57,52 @@ export function createFeishuCommentReplyDispatcher( runtime: params.runtime, }); - const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = - createReplyDispatcherWithTyping({ - responsePrefix: prefixContext.responsePrefix, - responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, - humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId), - onReplyStart: async () => { - await typingReaction.start(); - }, - deliver: async (payload: ReplyPayload, info) => { - if (info.kind !== "final") { - return; + const dispatcherOptions: NonNullable = { + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId), + onReplyStart: async () => { + await typingReaction.start(); + }, + onCleanup: () => { + void typingReaction.cleanup(); + }, + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { + deliver: async (payload: ReplyPayload, info) => { + if (info.kind !== "final") { + return; + } + const reply = resolveSendableOutboundReplyParts(payload); + if (!reply.hasText) { + if (reply.hasMedia) { + params.runtime.log?.( + `feishu[${params.accountId ?? "default"}]: comment reply ignored media-only payload for comment=${params.commentId}`, + ); } - const reply = resolveSendableOutboundReplyParts(payload); - if (!reply.hasText) { - if (reply.hasMedia) { - params.runtime.log?.( - `feishu[${params.accountId ?? "default"}]: comment reply ignored media-only payload for comment=${params.commentId}`, - ); - } - return; - } - const chunks = core.channel.text.chunkTextWithMode(reply.text, textChunkLimit, chunkMode); - for (const chunk of chunks) { - await deliverCommentThreadText(client, { - file_token: params.fileToken, - file_type: params.fileType, - comment_id: params.commentId, - content: chunk, - is_whole_comment: params.isWholeComment, - }); - } - }, - onError: (err, info) => { - params.runtime.error?.( - `feishu[${params.accountId ?? "default"}]: comment dispatcher failed kind=${info.kind} comment=${params.commentId}: ${String(err)}`, - ); - }, - onCleanup: () => { - void typingReaction.cleanup(); - }, - }); + return; + } + const chunks = core.channel.text.chunkTextWithMode(reply.text, textChunkLimit, chunkMode); + for (const chunk of chunks) { + await deliverCommentThreadText(client, { + file_token: params.fileToken, + file_type: params.fileType, + comment_id: params.commentId, + content: chunk, + is_whole_comment: params.isWholeComment, + }); + } + }, + onError: (err, info) => { + params.runtime.error?.( + `feishu[${params.accountId ?? "default"}]: comment dispatcher failed kind=${info.kind} comment=${params.commentId}: ${String(err)}`, + ); + }, + }; return { - dispatcher, - replyOptions, - markDispatchIdle, - markRunComplete, + dispatcherOptions, + delivery, startTypingReaction: typingReaction.start, cleanupTypingReaction: typingReaction.cleanup, }; diff --git a/extensions/feishu/src/comment-handler.test.ts b/extensions/feishu/src/comment-handler.test.ts index 178c3cdc2b1..23fcc32a25d 100644 --- a/extensions/feishu/src/comment-handler.test.ts +++ b/extensions/feishu/src/comment-handler.test.ts @@ -90,13 +90,8 @@ function createTestRuntime(overrides?: { resolveAgentRoute?: () => ReturnType; }) { 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) => { + type CommentTurnPlan = Parameters[0]; + const dispatchPlanForTest = vi.fn(async (turn: CommentTurnPlan) => { const storePath = "/tmp/feishu-session-store.json"; await recordInboundSession({ storePath, @@ -107,7 +102,11 @@ function createTestRuntime(overrides?: { updateLastRoute: turn.record?.updateLastRoute, onRecordError: turn.record?.onRecordError ?? (() => undefined), }); - const dispatchResult = await turn.runDispatch(); + const dispatchResult = await dispatchInboundMessageMock({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + replyOptions: turn.replyOptions, + }); return { admission: { kind: "dispatch" as const }, dispatched: true, @@ -161,10 +160,10 @@ function createTestRuntime(overrides?: { canStartAgentTurn: true, }; const turn = await params.adapter.resolveTurn(input, eventClass, {}); - if (!("runDispatch" in turn)) { - throw new Error("feishu comment test runtime only supports prepared turns"); + if (!("route" in turn) || !("delivery" in turn)) { + throw new Error("expected assembled Feishu comment turn plan"); } - return await dispatchPreparedForTest(turn as PreparedCommentTurnPlan); + return await dispatchPlanForTest(turn); }) as unknown as PluginRuntime["channel"]["inbound"]["run"], }, pairing: { @@ -231,13 +230,8 @@ describe("handleFeishuCommentEvent", () => { setFeishuRuntime(runtime); createFeishuCommentReplyDispatcherMock.mockReturnValue({ - dispatcher: { - markComplete: vi.fn(), - waitForIdle: vi.fn(async () => {}), - }, - replyOptions: {}, - markDispatchIdle: vi.fn(), - markRunComplete: vi.fn(), + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, startTypingReaction: vi.fn(async () => {}), cleanupTypingReaction: vi.fn(async () => {}), }); @@ -540,17 +534,10 @@ describe("handleFeishuCommentEvent", () => { dispatchInboundMessageMock.mockRejectedValueOnce(new Error("dispatch failed")); const runtime = createTestRuntime(); setFeishuRuntime(runtime); - const markRunComplete = vi.fn(); - const markDispatchIdle = vi.fn(); const cleanupTypingReaction = vi.fn(async () => {}); createFeishuCommentReplyDispatcherMock.mockReturnValue({ - dispatcher: { - markComplete: vi.fn(), - waitForIdle: vi.fn(async () => {}), - }, - replyOptions: {}, - markDispatchIdle, - markRunComplete, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, startTypingReaction: vi.fn(async () => {}), cleanupTypingReaction, }); @@ -568,8 +555,6 @@ describe("handleFeishuCommentEvent", () => { }), ).rejects.toThrow("dispatch failed"); - expect(markRunComplete).toHaveBeenCalledTimes(1); - expect(markDispatchIdle).toHaveBeenCalledTimes(1); expect(cleanupTypingReaction).toHaveBeenCalledTimes(1); }); @@ -582,13 +567,8 @@ describe("handleFeishuCommentEvent", () => { }), ); createFeishuCommentReplyDispatcherMock.mockReturnValue({ - dispatcher: { - markComplete: vi.fn(), - waitForIdle: vi.fn(async () => {}), - }, - replyOptions: {}, - markDispatchIdle: vi.fn(), - markRunComplete: vi.fn(), + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, startTypingReaction: vi.fn(async () => {}), cleanupTypingReaction, }); @@ -616,13 +596,8 @@ describe("handleFeishuCommentEvent", () => { it("does not start comment typing reaction before dispatch begins", async () => { const startTypingReaction = vi.fn(async () => {}); createFeishuCommentReplyDispatcherMock.mockReturnValue({ - dispatcher: { - markComplete: vi.fn(), - waitForIdle: vi.fn(async () => {}), - }, - replyOptions: {}, - markDispatchIdle: vi.fn(), - markRunComplete: vi.fn(), + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, startTypingReaction, cleanupTypingReaction: vi.fn(async () => {}), }); diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts index 43c979a74b5..0ebbc752a6b 100644 --- a/extensions/feishu/src/comment-handler.ts +++ b/extensions/feishu/src/comment-handler.ts @@ -1,7 +1,6 @@ // 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"; @@ -248,8 +247,8 @@ export async function handleFeishuCommentEvent( }, }); - const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } = - createFeishuCommentReplyDispatcher({ + const { dispatcherOptions, delivery, cleanupTypingReaction } = createFeishuCommentReplyDispatcher( + { cfg: effectiveCfg, agentId: route.agentId, runtime, @@ -259,9 +258,9 @@ export async function handleFeishuCommentEvent( commentId: turn.commentId, replyId: turn.replyId, isWholeComment: turn.isWholeComment, - }); + }, + ); - let dispatchSettledBeforeStart = false; try { log( `feishu[${account.accountId}]: dispatching drive comment to agent ` + @@ -293,23 +292,8 @@ export async function handleFeishuCommentEvent( ); }, }, - onPreDispatchFailure: async () => { - dispatchSettledBeforeStart = true; - await core.channel.reply.settleReplyDispatcher({ - dispatcher, - onSettled: () => { - markRunComplete(); - markDispatchIdle(); - }, - }); - }, - runDispatch: () => - dispatchInboundMessage({ - ctx: ctxPayload, - cfg: effectiveCfg, - dispatcher, - replyOptions, - }), + dispatcherOptions, + delivery, }), }, }); @@ -321,10 +305,6 @@ export async function handleFeishuCommentEvent( `(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`, ); } finally { - if (!dispatchSettledBeforeStart) { - markRunComplete(); - markDispatchIdle(); - } void cleanupTypingReaction(); } } diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index bc69c493727..827fa3fcc74 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -21,7 +21,6 @@ const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); -const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" }))); const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {})); const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []); @@ -97,13 +96,6 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock, }; }); -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal>(); - return { - ...actual, - createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, - }; -}); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock })); vi.mock("./typing.js", () => ({ @@ -151,27 +143,14 @@ afterAll(() => { vi.doUnmock("./typing.js"); vi.doUnmock("./streaming-card.js"); vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); - vi.doUnmock("openclaw/plugin-sdk/reply-runtime"); vi.resetModules(); }); describe("createFeishuReplyDispatcher streaming behavior", () => { type ReplyDispatcherArgs = Parameters[0]; - type TypingDispatcherOptions = { - onReplyStart?: () => Promise | void; - onIdle?: () => Promise | void; - deliver: ( - payload: { - text?: string; - mediaUrl?: string; - mediaUrls?: string[]; - audioAsVoice?: boolean; - ttsSupplement?: { spokenText: string; visibleTextAlreadyDelivered?: boolean }; - isError?: boolean; - }, - meta: { kind: string }, - ) => Promise | void; - }; + type ReplyDispatcherPlan = ReturnType; + type TypingDispatcherOptions = ReplyDispatcherPlan["dispatcherOptions"] & + ReplyDispatcherPlan["delivery"]; beforeEach(() => { vi.clearAllMocks(); @@ -195,13 +174,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { resolveReceiveIdTypeMock.mockReturnValue("chat_id"); createFeishuClientMock.mockReturnValue({}); - createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: vi.fn(), - _opts: opts, - })); - getFeishuRuntimeMock.mockReturnValue({ channel: { text: { @@ -213,7 +185,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { chunkMarkdownTextWithMode: vi.fn((text) => [text]), }, reply: { - createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, resolveHumanDelayConfig: vi.fn(() => undefined), }, }, @@ -249,7 +220,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { function setupNonStreamingAutoDispatcher() { useNonStreamingAutoAccount(); - createFeishuReplyDispatcher({ + const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: vi.fn() } as never, @@ -257,7 +228,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { sendTarget: "oc_chat", }); - return firstMockArg(createReplyDispatcherWithTypingMock, "reply dispatcher options"); + return toTypingDispatcherOptions(result); } function createRuntimeLogger() { @@ -276,10 +247,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { return { result, - options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0], + options: toTypingDispatcherOptions(result), }; } + function toTypingDispatcherOptions(result: ReplyDispatcherPlan): TypingDispatcherOptions { + return { ...result.dispatcherOptions, ...result.delivery }; + } + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -328,13 +303,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { return mockArg(mock, 0, argIndex, label); } - function firstTypingDispatcherOptions(): TypingDispatcherOptions { - return firstMockArg( - createReplyDispatcherWithTypingMock, - "reply dispatcher options", - ) as TypingDispatcherOptions; - } - function requireStreamingInstance(instanceIndex: number): StreamingSessionStub { const instance = streamingInstances[instanceIndex]; if (!instance) { @@ -391,7 +359,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }, }); - createFeishuReplyDispatcher({ + const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, @@ -400,14 +368,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { replyToMessageId: "om_parent", }); - const options = firstTypingDispatcherOptions(); + const options = toTypingDispatcherOptions(result); await options.onReplyStart?.(); expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); it("skips typing indicator for stale replayed messages", async () => { - createFeishuReplyDispatcher({ + const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, @@ -417,14 +385,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { messageCreateTimeMs: Date.now() - 3 * 60_000, }); - const options = firstTypingDispatcherOptions(); + const options = toTypingDispatcherOptions(result); await options.onReplyStart?.(); expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); it("treats second-based timestamps as stale for typing suppression", async () => { - createFeishuReplyDispatcher({ + const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, @@ -434,14 +402,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000), }); - const options = firstTypingDispatcherOptions(); + const options = toTypingDispatcherOptions(result); await options.onReplyStart?.(); expect(addTypingIndicatorMock).not.toHaveBeenCalled(); }); it("keeps typing indicator for fresh messages", async () => { - createFeishuReplyDispatcher({ + const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: {} as never, @@ -451,7 +419,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { messageCreateTimeMs: Date.now() - 30_000, }); - const options = firstTypingDispatcherOptions(); + const options = toTypingDispatcherOptions(result); await options.onReplyStart?.(); expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1); @@ -2036,7 +2004,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ), ).rejects.toThrow("media failed"); await Promise.all([ - options.onError?.(new Error("media failed"), { kind: "final" }), + Promise.resolve(options.onError?.(new Error("media failed"), { kind: "final" })), options.onIdle?.(), ]); await options.deliver({ text: "Second answer" }, { kind: "final" }); @@ -2078,7 +2046,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { { kind: "final" }, ), ).rejects.toThrow("media failed"); - await options.onError?.(new Error("media failed"), { kind: "final" }); + await Promise.resolve(options.onError?.(new Error("media failed"), { kind: "final" })); await options.deliver({ text: "Recovered answer" }, { kind: "final" }); await options.onIdle?.(); @@ -2372,7 +2340,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }; try { - createFeishuReplyDispatcher({ + const result = createFeishuReplyDispatcher({ cfg: {} as never, agentId: "agent", runtime: { log: vi.fn(), error: errorMock } as never, @@ -2380,7 +2348,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { sendTarget: "oc_chat", }); - const options = firstTypingDispatcherOptions(); + const options = toTypingDispatcherOptions(result); // First deliver with markdown triggers startStreaming - which will fail await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index fe0a37fd890..e8845a2b9e7 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,6 +1,7 @@ // Feishu plugin module implements reply dispatcher behavior. import { formatReasoningMessage, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound"; import { formatChannelProgressDraftLineForEntry, @@ -14,7 +15,6 @@ 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"; @@ -659,7 +659,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP return nextIdleSideEffects; }; - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + const dispatcherOptions: NonNullable = { responsePrefix: prefixContext.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: resolveHumanDelayConfig(cfg, agentId), @@ -689,6 +689,24 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } await Promise.resolve(typingCallbacks?.onReplyStart?.()); }, + onIdle: () => queueIdleSideEffects(), + onCleanup: () => { + typingCallbacks?.onCleanup?.(); + }, + }; + const handleDeliveryError = async (error: unknown, info: { kind: string }) => { + streamingCloseErroredForReply = true; + streamingClosedForReply = false; + params.runtime.error?.( + `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`, + ); + await queueIdleSideEffects({ markClosedForReply: false }).catch((cleanupError: unknown) => + params.runtime.error?.( + `feishu[${account.accountId}] reply error cleanup failed: ${String(cleanupError)}`, + ), + ); + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { deliver: async (payload: ReplyPayload, info) => { if (info?.kind === "final") { skippedFinalReason = null; @@ -884,24 +902,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP ); } }, - 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?.(); - }, - }); + // The shipped SDK declaration stays void; core still awaits the runtime promise. + onError: handleDeliveryError as NonNullable, + }; return { - dispatcher, + dispatcherOptions, + delivery, replyOptions: { - ...replyOptions, onModelSelected: prefixContext.onModelSelected, disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : true, @@ -977,7 +985,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } : undefined, }, - markDispatchIdle, ensureNoVisibleReplyFallback, getVisibleReplyState: () => ({ visibleReplySent, diff --git a/extensions/imessage/src/monitor/catchup.ts b/extensions/imessage/src/monitor/catchup.ts index 2cfdf04d111..e8f159656c2 100644 --- a/extensions/imessage/src/monitor/catchup.ts +++ b/extensions/imessage/src/monitor/catchup.ts @@ -11,7 +11,7 @@ import { getIMessageRuntime } from "../runtime.js"; // // This module keeps catchup on the same inbound evaluation and dispatch path // as live `imsg watch` notifications. The replay loop is pluggable via the -// `dispatch` callback so `evaluateIMessageInbound` + `dispatchInboundMessage` +// `dispatch` callback so `evaluateIMessageInbound` + `runChannelInboundEvent` // runs unchanged on replayed rows. // // See https://github.com/openclaw/openclaw/issues/78649 for design discussion. @@ -358,7 +358,7 @@ export async function advanceIMessageCatchupCursor( * The fetch and dispatch functions are injected so this loop is unit-testable * without standing up an `imsg` daemon. The wiring in `monitor-provider.ts` * passes the live `client.request("messages.history", ...)` adapter as - * `fetch` and the `evaluateIMessageInbound` + `dispatchInboundMessage` + * `fetch` and the `evaluateIMessageInbound` + `runChannelInboundEvent` * pipeline as `dispatch`. */ export async function performIMessageCatchup( diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index ae1e959541b..ad9ee335234 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -11,9 +11,9 @@ import { resolveEnvelopeFormatOptions, runChannelInboundEvent, shouldDebounceTextInbound, + type ChannelInboundTurnPlan, } from "openclaw/plugin-sdk/channel-inbound"; import { - deliverInboundReplyWithMessageSendContext, createChannelMessageReplyPipeline, resolveChannelStreamingBlockEnabled, } from "openclaw/plugin-sdk/channel-outbound"; @@ -29,9 +29,6 @@ import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-ru import type { ChannelReplayClaimHandle } from "openclaw/plugin-sdk/persistent-dedupe"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveTextChunkLimit, type GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; -import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -1356,41 +1353,28 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P : undefined, }); - const { - dispatcher, - replyOptions: typingReplyOptions, - markDispatchIdle, - } = createReplyDispatcherWithTyping({ + const dispatcherOptions = { ...replyPipeline, humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId), - deliver: async (payload, info) => { + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { + durable: ctxPayload.To + ? { + to: ctxPayload.To, + deps: { + imessage: createIMessageEchoCachingSend({ + accountId: accountInfo.accountId, + sentMessageCache, + }), + }, + } + : false, + deliver: async (payload: Parameters[0]["replies"][number]) => { const target = ctxPayload.To; if (!target) { runtime.error?.(danger("imessage: missing delivery target")); return; } - const durable = await deliverInboundReplyWithMessageSendContext({ - cfg, - channel: "imessage", - accountId: accountInfo.accountId, - agentId: decision.route.agentId, - ctxPayload, - payload, - info, - to: target, - deps: { - imessage: createIMessageEchoCachingSend({ - accountId: accountInfo.accountId, - sentMessageCache, - }), - }, - }); - if (durable.status === "failed") { - throw durable.error; - } - if (durable.status === "handled_visible" || durable.status === "handled_no_send") { - return; - } await deliverReplies({ cfg, replies: [payload], @@ -1405,7 +1389,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P onError: (err, info) => { runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`)); }, - }); + }; let directTypingController: IMessageTypingController | undefined; const directToolTypingOptions = shouldUseDirectToolTypingOptions ? ({ @@ -1419,7 +1403,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P allowProgressCallbacksWhenSourceDeliverySuppressed: true, onTypingController: (typing: IMessageTypingController) => { directTypingController = typing; - typingReplyOptions.onTypingController?.(typing); }, ...(supportsTyping ? { @@ -1492,35 +1475,19 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P historyMap: groupHistories, limit: historyLimit, }, - onPreDispatchFailure: () => { - stopEarlyDirectTyping?.(); - void settleReplyDispatcher({ - dispatcher, - onSettled: () => markDispatchIdle(), - }); + delivery, + dispatcherOptions: { + ...dispatcherOptions, + onSettled: () => stopEarlyDirectTyping?.(), }, - runDispatch: async () => { - try { - return await dispatchInboundMessage({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...typingReplyOptions, - disableBlockStreaming: - typeof configuredBlockStreaming === "boolean" - ? !configuredBlockStreaming - : undefined, - onModelSelected, - ...directToolTypingOptions, - }, - }); - } finally { - markDispatchIdle(); - stopEarlyDirectTyping?.(); - } + replyOptions: { + disableBlockStreaming: + typeof configuredBlockStreaming === "boolean" ? !configuredBlockStreaming : undefined, + onModelSelected, + ...directToolTypingOptions, }, }), + onFinalize: () => stopEarlyDirectTyping?.(), }, }); } diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 62d2c67bc6f..4fbe114e922 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -174,7 +174,7 @@ export function createMatrixHandlerTestHarness( prepared.markRunComplete(); prepared.markDispatchIdle(); } - }) as NonNullable; + }) as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchInboundMessageWithBufferedDispatcher; const createChannelInboundEnvelopeBuilder = (() => (input: { body: string }) => (options.formatAgentEnvelope ?? (({ body }: { body: string }) => body))({ body: input.body, @@ -192,6 +192,7 @@ export function createMatrixHandlerTestHarness( updateLastRoute: turn.record?.updateLastRoute, onRecordError: turn.record?.onRecordError ?? (() => undefined), }); + await turn.afterRecord?.(); const dispatchResult = await turn.runDispatch(); return { admission: { kind: "dispatch" as const }, @@ -219,19 +220,34 @@ export function createMatrixHandlerTestHarness( ? { admission: preflightResult } : (preflightResult ?? {}); const turn = await params.adapter.resolveTurn(input, eventClass, preflight); - if ("runDispatch" in turn) { - const preparedTurn = - "route" in turn - ? ({ - ...turn, - routeSessionKey: turn.route.sessionKey, - storePath: "/tmp/matrix-sessions.json", - recordInboundSession, - } as PreparedInboundReply) - : turn; - return await runPrepared(preparedTurn); + if (!("route" in turn) || !("delivery" in turn)) { + throw new Error("expected assembled Matrix channel turn plan"); } - throw new Error("matrix test helper only supports prepared turn dispatch"); + return await runPrepared({ + channel: turn.channel, + accountId: turn.accountId, + routeSessionKey: turn.route.sessionKey, + storePath: "/tmp/matrix-sessions.json", + ctxPayload: turn.ctxPayload, + recordInboundSession, + afterRecord: turn.afterRecord, + record: turn.record, + history: turn.history, + admission: turn.admission, + botLoopProtection: turn.botLoopProtection, + runDispatch: async () => + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcherOptions: { + ...turn.dispatcherOptions, + deliver: turn.delivery.deliver, + onError: turn.delivery.onError, + }, + replyOptions: turn.replyOptions, + replyResolver: turn.replyResolver, + }), + }); }, ); const dmPolicy = options.dmPolicy ?? "open"; @@ -353,7 +369,6 @@ export function createMatrixHandlerTestHarness( createChannelInboundEnvelopeBuilder, finalizeInboundContext, resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), - dispatchInboundMessageWithBufferedDispatcher, historyLimit: options.historyLimit ?? 0, }); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index c7c5e5ec6d4..64f434296f4 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -40,10 +40,7 @@ import { buildTtsSupplementMediaPayload, getReplyPayloadTtsSupplement, } from "openclaw/plugin-sdk/reply-payload"; -import { - dispatchInboundMessageWithBufferedDispatcher, - type GetReplyOptions, -} from "openclaw/plugin-sdk/reply-runtime"; +import 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, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; @@ -234,7 +231,6 @@ type MatrixMonitorHandlerParams = { createChannelInboundEnvelopeBuilder?: typeof createChannelInboundEnvelopeBuilder; finalizeInboundContext?: (ctx: Record) => unknown; resolveHumanDelayConfig?: typeof resolveHumanDelayConfig; - dispatchInboundMessageWithBufferedDispatcher?: typeof dispatchInboundMessageWithBufferedDispatcher; }; function resolveMatrixMentionPrecheckText(params: { @@ -484,8 +480,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam createChannelInboundEnvelopeBuilderImpl = createChannelInboundEnvelopeBuilder, finalizeInboundContext, resolveHumanDelayConfig: resolveHumanDelayConfigImpl = resolveHumanDelayConfig, - dispatchInboundMessageWithBufferedDispatcher: - dispatchInboundMessageWithBufferedDispatcherImpl = dispatchInboundMessageWithBufferedDispatcher, } = params; const contextVisibilityMode = resolveChannelContextVisibilityMode({ cfg, @@ -2385,6 +2379,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam onReplyStart: typingCallbacks.onReplyStart, onIdle: typingCallbacks.onIdle, }; + const { + deliver: deliverReply, + onError: onReplyError, + ...turnDispatcherOptions + } = dispatcherOptions; const pinnedMainDmOwner = isDirectMessage ? await (async () => { const livePinnedCfg = core.config.current() as CoreConfig; @@ -2468,7 +2467,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); }, }, - runDispatch: async () => { + afterRecord: async () => { if ( sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId) @@ -2484,53 +2483,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam ); } } - - return await dispatchInboundMessageWithBufferedDispatcherImpl({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - ...dispatcherOptions, - onSettled: () => progressDraftGate.cancel(), - }, - 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, - }, - }); + }, + delivery: { + deliver: deliverReply, + onError: (err, info) => onReplyError(err, info as Parameters[1]), + }, + dispatcherOptions: { + ...turnDispatcherOptions, + onSettled: () => progressDraftGate.cancel(), + }, + replyOptions: { + skillFilter: roomConfig?.skills, + // Preserve explicit block streaming with draft previews: drafts update the live + // block, while block deliveries finalize completed blocks as separate 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/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 0db353856ee..e4c479b422a 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -74,7 +74,6 @@ export { type LookupFn, type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-runtime"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/channel-inbound"; export { ensureConfiguredAcpBindingReady, resolveConfiguredAcpBindingRecord, 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 5352ddd5750..dcc3be00bd6 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -239,20 +239,27 @@ function createRuntimeCore( }; }; const recordInboundSession = vi.fn(async (_params: RecordInboundSessionInput) => {}); - const dispatchPreparedForTest = vi.fn( + const dispatchPlanForTest = vi.fn( async (turn: { + cfg: OpenClawConfig; + channel: string; route: { agentId: string; sessionKey: string }; ctxPayload: { SessionKey?: string }; + dispatcherOptions?: Record; + delivery: { + deliver: ( + payload: ReplyPayload, + info: { kind: "tool" | "block" | "final" }, + ) => Promise; + onError?: unknown; + }; + replyOptions?: Record; record?: { groupResolution?: unknown; createIfMissing?: boolean; updateLastRoute?: RecordInboundSessionInput["updateLastRoute"]; onRecordError?: (err: unknown) => void; }; - runDispatch: () => Promise<{ - queuedFinal: boolean; - counts: { tool: number; block: number; final: number }; - }>; }) => { await recordInboundSession({ storePath: "/tmp/openclaw-test-sessions.json", @@ -263,7 +270,18 @@ function createRuntimeCore( updateLastRoute: turn.record?.updateLastRoute, onRecordError: turn.record?.onRecordError ?? (() => undefined), }); - const dispatchResult = await turn.runDispatch(); + const prepared = mockState.createReplyDispatcherWithTyping({ + ...turn.dispatcherOptions, + deliver: turn.delivery.deliver, + onError: turn.delivery.onError, + }) as { dispatcher: unknown; replyOptions?: Record }; + const dispatchResult = await mockState.dispatchInboundMessage({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcher: prepared.dispatcher, + replyOptions: { ...prepared.replyOptions, ...turn.replyOptions }, + onSettled: turn.dispatcherOptions?.onSettled, + }); return { admission: { kind: "dispatch" as const }, dispatched: true, @@ -282,7 +300,7 @@ function createRuntimeCore( input: unknown, eventClass: { kind: "message"; canStartAgentTurn: true }, preflight: Record, - ) => Parameters[0]; + ) => Parameters[0]; }; }) => { const input = params.adapter.ingest(params.raw); @@ -291,7 +309,7 @@ function createRuntimeCore( { kind: "message", canStartAgentTurn: true }, {}, ); - return await dispatchPreparedForTest(turn); + return await dispatchPlanForTest(turn); }, ); return { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index d066944dd51..3b1bcb2e64d 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,17 +1,16 @@ // 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 { + formatInboundEnvelope, + implicitMentionKindWhen, + type ChannelInboundTurnPlan, +} 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 { 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"; @@ -504,44 +503,40 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isDirect: kind === "direct", dmRetryOptions: account.config.dmChannelRetry, }); - 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, - 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({ + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, - dispatcher, - onSettled: () => markDispatchIdle(), + dispatcherOptions: { + ...replyPipeline, + resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, + onDeliverySettled: deliveryBarrier.markDeliverySettled, + humanDelay: 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)}`); + }, + typingCallbacks, + }, replyOptions: { - ...replyOptions, disableBlockStreaming: typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, onModelSelected, @@ -707,56 +702,52 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isDirect: params.kind === "direct", dmRetryOptions: account.config.dmChannelRetry, }); - 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; - } - - 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 dispatchInboundMessage({ + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, - dispatcher, - onSettled: () => markDispatchIdle(), + dispatcherOptions: { + ...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; + } + + 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)}`); + }, + typingCallbacks, + }, replyOptions: { - ...replyOptions, disableBlockStreaming: typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, onModelSelected, @@ -1579,116 +1570,116 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} isDirect: kind === "direct", dmRetryOptions: account.config.dmChannelRetry, }); - const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = - createReplyDispatcherWithTyping({ - ...replyPipeline, - resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, - onDeliverySettled: deliveryBarrier.markDeliverySettled, - humanDelay: resolveHumanDelayConfig(cfg, route.agentId), - typingCallbacks, - deliver: async (payloadEntry: ReplyPayload, info) => { - if (info.kind === "final") { - await enterBlockPreviewActivity("text"); - // Final text resolution uses only generations confirmed visible. Join prior - // boundary work before the synchronous final-edit decision. - await draftStream.settleBoundaries(); - progressDraft.markFinalReplyStarted(); + const dispatcherOptions: NonNullable = { + ...replyPipeline, + resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy, + onDeliverySettled: deliveryBarrier.markDeliverySettled, + humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + typingCallbacks, + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { + deliver: async (payloadEntry: ReplyPayload, info) => { + if (info.kind === "final") { + await enterBlockPreviewActivity("text"); + // Final text resolution uses only generations confirmed visible. Join prior + // boundary work before the synchronous final-edit decision. + await draftStream.settleBoundaries(); + progressDraft.markFinalReplyStarted(); + } + // A visible same-thread final arrives either via a normal send or by editing + // the draft preview in place; record participation on whichever path fires. + const markThreadParticipation = () => { + if (kind !== "direct" && effectiveReplyToId) { + recordMattermostThreadParticipation( + account.accountId, + channelId, + effectiveReplyToId, + { agentId: route.agentId }, + ); } - // A visible same-thread final arrives either via a normal send or by editing - // the draft preview in place; record participation on whichever path fires. - const markThreadParticipation = () => { - if (kind !== "direct" && effectiveReplyToId) { - recordMattermostThreadParticipation( - account.accountId, - channelId, - effectiveReplyToId, - { agentId: route.agentId }, - ); + }; + await deliverMattermostReplyWithDraftPreview({ + payload: payloadEntry, + info, + kind, + client, + draftStream, + effectiveReplyToId, + resolvePreviewFinalText, + previewState, + logVerboseMessage, + recordThreadParticipation: markThreadParticipation, + deliverPayload: async (payloadToDeliver) => { + const finalTextResolution = + info.kind === "final" && + !payloadToDeliver.isError && + typeof payloadToDeliver.text === "string" + ? draftStream.resolveFinalText(payloadToDeliver.text) + : undefined; + const resolvedPayload = finalTextResolution + ? { + ...payloadToDeliver, + text: + finalTextResolution.kind === "already-delivered" + ? "" + : finalTextResolution.text, + } + : payloadToDeliver; + const outcome = await deliverMattermostReplyPayload({ + core, + cfg, + payload: resolvedPayload, + to, + accountId: account.accountId, + agentId: route.agentId, + replyToId: resolveMattermostReplyRootId({ + kind, + threadRootId: effectiveReplyToId, + replyToId: payloadToDeliver.replyToId, + }), + textLimit, + tableMode, + sendMessage: sendMessageMattermost, + onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, + }); + // Record only on a visible send so threads we merely observed + // (reasoning-only/empty/suppressed) do not auto-engage later. + if (outcome === "text" || outcome === "media") { + markThreadParticipation(); + } else if ( + outcome === "empty" && + finalTextResolution?.kind === "already-delivered" + ) { + // The terminal payload confirms the already-published assistant block as + // the visible final reply even though this delivery has no remaining text. + markThreadParticipation(); } - }; - await deliverMattermostReplyWithDraftPreview({ - payload: payloadEntry, - info, - kind, - client, - draftStream, - effectiveReplyToId, - resolvePreviewFinalText, - previewState, - logVerboseMessage, - recordThreadParticipation: markThreadParticipation, - deliverPayload: async (payloadToDeliver) => { - const finalTextResolution = - info.kind === "final" && - !payloadToDeliver.isError && - typeof payloadToDeliver.text === "string" - ? draftStream.resolveFinalText(payloadToDeliver.text) - : undefined; - const resolvedPayload = finalTextResolution - ? { - ...payloadToDeliver, - text: - finalTextResolution.kind === "already-delivered" - ? "" - : finalTextResolution.text, - } - : payloadToDeliver; - const outcome = await deliverMattermostReplyPayload({ - core, - cfg, - payload: resolvedPayload, - to, - accountId: account.accountId, - agentId: route.agentId, - replyToId: resolveMattermostReplyRootId({ - kind, - threadRootId: effectiveReplyToId, - replyToId: payloadToDeliver.replyToId, - }), - textLimit, - tableMode, - sendMessage: sendMessageMattermost, - onDmChannelResolution: deliveryBarrier.trackDmChannelResolution, - }); - // Record only on a visible send so threads we merely observed - // (reasoning-only/empty/suppressed) do not auto-engage later. - if (outcome === "text" || outcome === "media") { - markThreadParticipation(); - } else if ( - outcome === "empty" && - finalTextResolution?.kind === "already-delivered" - ) { - // The terminal payload confirms the already-published assistant block as - // the visible final reply even though this delivery has no remaining text. - markThreadParticipation(); - } - const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({ - outcome, - payload: resolvedPayload, - to, - accountId: account.accountId, - agentId: route.agentId, - }); - if (deliveryLog) { - runtime.log?.(deliveryLog); - } - }, - }); - if (info.kind === "final") { - progressDraft.markFinalReplyDelivered(); - } - }, - onError: (err, info) => { - runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); - }, - }); + const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({ + outcome, + payload: resolvedPayload, + to, + accountId: account.accountId, + agentId: route.agentId, + }); + if (deliveryLog) { + runtime.log?.(deliveryLog); + } + }, + }); + if (info.kind === "final") { + progressDraft.markFinalReplyDelivered(); + } + }, + onError: (err, info) => { + runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); + }, + }; const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({ route, sessionKey: route.sessionKey, }); - let dispatchSettledBeforeStart = false; try { await core.channel.inbound.run({ channel: "mattermost", @@ -1749,136 +1740,116 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} historyMap: channelHistories, limit: historyLimit, }, - onPreDispatchFailure: async () => { - dispatchSettledBeforeStart = true; - await core.channel.reply.settleReplyDispatcher({ - dispatcher, - onSettled: () => { - markRunComplete(); - markDispatchIdle(); - }, - }); - }, - runDispatch: () => - dispatchInboundMessage({ - ctx: ctxPayload, - cfg, - dispatcher, - 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 + dispatcherOptions, + delivery, + 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]); + }, + }, }), }, }); @@ -1888,9 +1859,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } catch (err) { logVerboseMessage(`mattermost draft preview cleanup failed: ${String(err)}`); } - if (!dispatchSettledBeforeStart) { - markRunComplete(); - } } }, }); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 023c2a03e3c..1f7f0452f5e 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -11,11 +11,7 @@ import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; -import { - createReplyDispatcherWithTyping, - dispatchInboundMessage, - finalizeInboundContext, -} from "openclaw/plugin-sdk/reply-runtime"; +import { 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"; @@ -898,41 +894,37 @@ async function handleSlashCommandAsync(params: { dmRetryOptions: account.config.dmChannelRetry, }); - const { dispatcher, replyOptions, markDispatchIdle } = 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 dispatchInboundMessage({ + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, - dispatcher, - onSettled: () => markDispatchIdle(), + dispatcherOptions: { + ...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)}`, + ); + }, + typingCallbacks, + }, replyOptions: { - ...replyOptions, disableBlockStreaming: typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, onModelSelected, diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index cf449310a46..e795e3519ce 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -53,7 +53,6 @@ export { getFileExtension, resolveChannelMediaMaxBytes, } from "openclaw/plugin-sdk/media-runtime"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/channel-inbound"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload"; export type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; diff --git a/extensions/msteams/src/delivery-trace.test.ts b/extensions/msteams/src/delivery-trace.test.ts index f2cb16a7ee8..1d032397f01 100644 --- a/extensions/msteams/src/delivery-trace.test.ts +++ b/extensions/msteams/src/delivery-trace.test.ts @@ -24,23 +24,13 @@ 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, vi } from "vitest"; +import { describe, it } 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). */ +/** Core-owned dispatcher options returned by the Teams delivery plan. */ type CapturedDispatcherOptions = { onReplyStart?: () => Promise | void; deliver: (payload: ReplyPayload, info: { kind: string }) => Promise | void; @@ -239,19 +229,7 @@ const MSTEAMS_TRACE_CASES: readonly MSTeamsTraceCase[] = [ ]; function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) { - let captured: CapturedDispatcherOptions | undefined; 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, @@ -286,10 +264,10 @@ function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) replyStyle: "thread", textLimit: 4000, }); - const options = captured; - if (!options) { - throw new Error("dispatcher options were not captured"); - } + const options = { + ...created.dispatcherOptions, + deliver: created.delivery.deliver, + } as CapturedDispatcherOptions; return async (step: DeliveryTraceInStep) => { switch (step.kind) { @@ -324,7 +302,7 @@ function setupMSTeamsTrace(recorder: WireRecorder, traceCase: MSTeamsTraceCase) // armed StreamCancelledError write fault, so this step maps to nothing. break; case "idle": - await created.markDispatchIdle(); + await created.dispatcherOptions.onSettled?.(); break; case "wire-fault": // The shared write-error fault vocabulary covers this shape, but a diff --git a/extensions/msteams/src/monitor-handler.adaptive-card.test.ts b/extensions/msteams/src/monitor-handler.adaptive-card.test.ts index 5ff39dd6081..4aca75213fb 100644 --- a/extensions/msteams/src/monitor-handler.adaptive-card.test.ts +++ b/extensions/msteams/src/monitor-handler.adaptive-card.test.ts @@ -5,33 +5,19 @@ import type { MSTeamsConversationStore } from "./conversation-store.js"; import { type MSTeamsActivityHandler, registerMSTeamsHandlers } from "./monitor-handler.js"; import { createActivityHandler, + getMSTeamsTestRuntimeState, installMSTeamsTestRuntime, } from "./monitor-handler.test-helpers.js"; import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js"; import type { MSTeamsTurnContext } from "./sdk-types.js"; -const runtimeApiMockState = vi.hoisted(() => ({ - dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({ - queuedFinal: false, - counts: {}, - capturedCtxPayload: params.ctxPayload, - })), -})); - -vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchReplyFromConfigWithSettledDispatcher: - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher, - }; -}); +const runtimeApiMockState = getMSTeamsTestRuntimeState(); vi.mock("./reply-dispatcher.js", () => ({ createMSTeamsReplyDispatcher: () => ({ - dispatcher: {}, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), }), })); @@ -152,18 +138,18 @@ async function runMessageActivity(params: { } function lastDispatchedCtxPayload(): Record { - const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at( + const dispatched = runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mock.calls.at( -1, - )?.[0] as { ctxPayload?: Record } | undefined; - if (!dispatched?.ctxPayload) { + )?.[0] as { ctx?: Record } | undefined; + if (!dispatched?.ctx) { throw new Error("expected dispatched context payload"); } - return dispatched.ctxPayload; + return dispatched.ctx; } describe("msteams adaptive card action invoke", () => { beforeEach(() => { - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear(); + runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mockClear(); }); it("forwards adaptive card submitted data to the agent as message text", async () => { @@ -187,9 +173,7 @@ describe("msteams adaptive card action invoke", () => { await runAdaptiveCardInvoke(registered, payload); expect(run).not.toHaveBeenCalled(); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes( - 1, - ); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const expectedBody = JSON.stringify(payload.action.data); const ctxPayload = lastDispatchedCtxPayload(); expect(ctxPayload.RawBody).toBe(expectedBody); diff --git a/extensions/msteams/src/monitor-handler.test-helpers.ts b/extensions/msteams/src/monitor-handler.test-helpers.ts index 7b9740fffcd..fa4fa234297 100644 --- a/extensions/msteams/src/monitor-handler.test-helpers.ts +++ b/extensions/msteams/src/monitor-handler.test-helpers.ts @@ -27,6 +27,21 @@ type MSTeamsTestRuntimeOptions = { resolveStorePath?: () => string; }; +const dispatchReplyWithBufferedBlockDispatcher = vi.fn( + async ( + params: Parameters< + PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"] + >[0], + ) => { + await params.dispatcherOptions.onSettled?.(); + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }, +); + +export function getMSTeamsTestRuntimeState() { + return { dispatchReplyWithBufferedBlockDispatcher }; +} + export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void { const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => undefined); const resolveStorePath = options.resolveStorePath ?? (() => "/tmp/msteams-sessions.json"); @@ -64,19 +79,36 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = { ? { admission: preflightResult } : (preflightResult ?? {}); const turn = await params.adapter.resolveTurn(input, eventClass, preflight); - if ("runDispatch" in turn) { - const preparedTurn = - "route" in turn - ? ({ - ...turn, - routeSessionKey: turn.route.sessionKey, - storePath: resolveStorePath(), - recordInboundSession, - } as PreparedInboundReply) - : turn; - return await runPrepared(preparedTurn); + if (!("route" in turn) || !("delivery" in turn)) { + throw new Error("expected assembled MSTeams channel turn plan"); } - throw new Error("msteams test runtime only supports prepared turn dispatch"); + const preparedTurn = { + channel: turn.channel, + accountId: turn.accountId, + routeSessionKey: turn.route.sessionKey, + storePath: resolveStorePath(), + ctxPayload: turn.ctxPayload, + recordInboundSession, + afterRecord: turn.afterRecord, + record: turn.record, + history: turn.history, + admission: turn.admission, + botLoopProtection: turn.botLoopProtection, + runDispatch: async () => + await dispatchReplyWithBufferedBlockDispatcher({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcherOptions: { + ...turn.dispatcherOptions, + deliver: turn.delivery.deliver, + onError: turn.delivery.onError, + }, + toolsAllow: turn.toolsAllow, + replyOptions: turn.replyOptions, + replyResolver: turn.replyResolver, + }), + } as PreparedInboundReply; + return await runPrepared(preparedTurn); }); setMSTeamsRuntime({ logging: { shouldLogVerbose: () => false }, @@ -125,6 +157,8 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = { })), }, reply: { + dispatchReplyWithBufferedBlockDispatcher: + dispatchReplyWithBufferedBlockDispatcher as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], createReplyDispatcherWithTyping: () => ({ dispatcher: {}, replyOptions: {}, diff --git a/extensions/msteams/src/monitor-handler/message-handler-mock-support.test-support.ts b/extensions/msteams/src/monitor-handler/message-handler-mock-support.test-support.ts index 094e4d13d8e..af3bae22cba 100644 --- a/extensions/msteams/src/monitor-handler/message-handler-mock-support.test-support.ts +++ b/extensions/msteams/src/monitor-handler/message-handler-mock-support.test-support.ts @@ -1,31 +1,15 @@ // Msteams plugin module implements message handler mock support support behavior. import { vi } from "vitest"; - -const runtimeApiMockState = vi.hoisted(() => ({ - dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({ - queuedFinal: false, - counts: {}, - capturedCtxPayload: params.ctxPayload, - })), -})); +import { getMSTeamsTestRuntimeState } from "../monitor-handler.test-helpers.js"; export function getRuntimeApiMockState() { - return runtimeApiMockState; + return getMSTeamsTestRuntimeState(); } -vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchReplyFromConfigWithSettledDispatcher: - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher, - }; -}); - vi.mock("../reply-dispatcher.js", () => ({ createMSTeamsReplyDispatcher: () => ({ - dispatcher: {}, + dispatcherOptions: {}, + delivery: { deliver: vi.fn(async () => undefined) }, replyOptions: {}, - markDispatchIdle: vi.fn(), }), })); diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 9b5796c1a1a..cb1cbac196a 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -127,7 +127,7 @@ describe("msteams monitor handler authz", () => { function resetThreadMocks() { currentParentMessageId = `parent-msg-${++parentMessageSequence}`; - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear(); + runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mockClear(); graphThreadMockState.resolveTeamGroupId.mockClear(); graphThreadMockState.fetchChannelMessage.mockReset(); graphThreadMockState.fetchThreadReplies.mockReset(); @@ -310,11 +310,11 @@ describe("msteams monitor handler authz", () => { function firstSettledDispatch(): { ctxPayload?: unknown } { const dispatched = mockCallArg( - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher, + runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher, 0, 0, ); - return recordFromMockCall(dispatched) as { ctxPayload?: unknown }; + return { ctxPayload: recordFromMockCall(dispatched).ctx }; } function logMeta(logFn: unknown, message: string): Record { @@ -452,7 +452,7 @@ describe("msteams monitor handler authz", () => { timezone: "America/New_York", }); expect(recordInboundSession).not.toHaveBeenCalled(); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); // Regression coverage for #58774: proactive sends fail with HTTP 403 when @@ -662,7 +662,7 @@ describe("msteams monitor handler authz", () => { expect(hasControlCommand).toHaveBeenCalledWith("/config set foo bar", deps.cfg); expect(conversationStore.upsert).not.toHaveBeenCalled(); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("does not drop inline command-looking group text from non-command-authorized senders", async () => { @@ -689,9 +689,7 @@ describe("msteams monitor handler authz", () => { await handler(createAttackerGroupActivity({ text: "hello /status" })); expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", deps.cfg); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes( - 1, - ); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const dispatched = firstSettledDispatch(); const ctxPayload = recordFromMockCall(dispatched.ctxPayload); expect(ctxPayload.BodyForAgent).toBe("hello /status"); @@ -726,13 +724,11 @@ describe("msteams monitor handler authz", () => { const handler = createMSTeamsMessageHandler(deps); await handler(createAttackerGroupActivity({ text: "pending text" })); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); await handler(createAttackerGroupActivity({ text: "abort" })); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes( - 1, - ); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const dispatched = firstSettledDispatch(); const ctxPayload = recordFromMockCall(dispatched.ctxPayload); expect(ctxPayload.BodyForAgent).toBe("abort"); @@ -771,7 +767,7 @@ describe("msteams monitor handler authz", () => { }), ); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); const systemEventCall = enqueueSystemEvent.mock.calls.find( ([text]) => text === "Teams message in channel from Member", ); @@ -811,10 +807,13 @@ describe("msteams monitor handler authz", () => { team: { id: "team123", name: "Team 123" }, channel: { name: "General" }, }, + extraActivity: { + entities: [{ type: "clientInfo", timezone: "America/New_York" }], + }, }), ); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const systemEventCall = enqueueSystemEvent.mock.calls.find( ([text]) => text === "Teams message in channel from Member", ); @@ -824,6 +823,13 @@ describe("msteams monitor handler authz", () => { expect(systemEventCall[0]).not.toContain("please check the build"); const dispatched = firstSettledDispatch(); expect(recordFromMockCall(dispatched.ctxPayload).BodyForAgent).toBe("please check the build"); + const dispatchParams = recordFromMockCall( + mockCallArg(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher, 0, 0), + ); + expect(dispatchParams.cfg).not.toBe(deps.cfg); + expect(recordFromMockCall(dispatchParams.cfg).agents).toEqual({ + defaults: { userTimezone: "America/New_York" }, + }); }); it("extracts message text from a mixed-case HTML attachment type", async () => { diff --git a/extensions/msteams/src/monitor-handler/message-handler.media.test.ts b/extensions/msteams/src/monitor-handler/message-handler.media.test.ts index 91967aaaa62..1b79e280607 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.media.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.media.test.ts @@ -27,12 +27,12 @@ const taglessHtmlAttachment = { }; function firstDispatchedContext(): Record { - const call = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls[0]; - const params = call?.[0] as { ctxPayload?: unknown } | undefined; - if (!params?.ctxPayload || typeof params.ctxPayload !== "object") { + const call = runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]; + const params = call?.[0] as { ctx?: unknown } | undefined; + if (!params?.ctx || typeof params.ctx !== "object") { throw new Error("expected dispatched Teams context"); } - return params.ctxPayload as Record; + return params.ctx as Record; } describe("msteams message handler Graph media recovery", () => { @@ -44,7 +44,7 @@ describe("msteams message handler Graph media recovery", () => { beforeEach(() => { inboundMediaMockState.resolve.mockReset(); - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear(); + runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mockClear(); }); it.each([ @@ -94,9 +94,7 @@ describe("msteams message handler Graph media recovery", () => { resolveTeamAadGroupId: expect.any(Function), }), ); - expect( - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher, - ).toHaveBeenCalledTimes(1); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); expect(firstDispatchedContext()).toMatchObject({ BodyForAgent: "Describe the attached image file", MediaPaths: ["/tmp/from-graph.pdf"], @@ -167,7 +165,7 @@ describe("msteams message handler Graph media recovery", () => { expect(inboundMediaMockState.resolve).toHaveBeenCalledTimes(1); expect(getTeamDetails).not.toHaveBeenCalled(); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); @@ -248,6 +246,6 @@ describe("msteams message handler Graph media recovery", () => { expect(getTeamDetails).not.toHaveBeenCalled(); expect(inboundMediaMockState.resolve).not.toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalled(); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled(); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts index 41bdf5a0ee7..a68abdb7800 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts @@ -81,7 +81,7 @@ describe("msteams thread parent context injection", () => { fetchThreadRepliesMock.mockImplementation(async () => []); resolveTeamGroupIdMock.mockReset(); resolveTeamGroupIdMock.mockImplementation(async () => "group-1"); - runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear(); + runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mockClear(); }); const cfg: OpenClawConfig = { @@ -220,9 +220,7 @@ describe("msteams thread parent context injection", () => { expect(fetchChannelMessageMock).not.toHaveBeenCalled(); expect(fetchThreadRepliesMock).not.toHaveBeenCalled(); - expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes( - 1, - ); + expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); }); it("does not fetch parent for DM replyToId", async () => { diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 47de269c2c0..f3e2783d9d4 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -8,7 +8,6 @@ import { resolveInboundSupplementalSenderAllowed, } from "openclaw/plugin-sdk/channel-inbound"; import { - dispatchReplyFromConfigWithSettledDispatcher, hasFinalInboundReplyDispatch, resolveInboundReplyDispatchCounts, } from "openclaw/plugin-sdk/channel-inbound"; @@ -914,7 +913,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); const sharePointSiteId = msteamsCfg?.sharePointSiteId; - const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ + const { dispatcherOptions, delivery, replyOptions } = createMSTeamsReplyDispatcher({ cfg, agentId: route.agentId, sessionKey: route.sessionKey, @@ -943,15 +942,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { | { timezone?: string } | undefined; const senderTimezone = activityClientInfo?.timezone || conversationRef.timezone; - const configOverride = + const turnConfig = senderTimezone && !cfg.agents?.defaults?.userTimezone ? { + ...cfg, agents: { + ...cfg.agents, defaults: { ...cfg.agents?.defaults, userTimezone: senderTimezone }, }, } - : undefined; - + : cfg; log.info("dispatching to agent", { sessionKey: route.sessionKey }); try { const turnResult = await core.channel.inbound.run({ @@ -968,7 +968,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { raw: activity, }), resolveTurn: () => ({ - cfg, + cfg: turnConfig, channel: "msteams", accountId: route.accountId, route: { agentId: route.agentId, sessionKey: route.sessionKey }, @@ -986,20 +986,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { historyMap: conversationHistories, limit: historyLimit, }, - onPreDispatchFailure: () => - core.channel.reply.settleReplyDispatcher({ - dispatcher, - onSettled: () => markDispatchIdle(), - }), - runDispatch: () => - dispatchReplyFromConfigWithSettledDispatcher({ - cfg, - ctxPayload, - dispatcher, - onSettled: () => markDispatchIdle(), - replyOptions, - configOverride, - }), + dispatcherOptions, + delivery, + replyOptions, }), }, }); diff --git a/extensions/msteams/src/reply-dispatcher.test.ts b/extensions/msteams/src/reply-dispatcher.test.ts index dba4efc1ca1..5fd2564977f 100644 --- a/extensions/msteams/src/reply-dispatcher.test.ts +++ b/extensions/msteams/src/reply-dispatcher.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const createChannelMessageReplyPipelineMock = vi.hoisted(() => vi.fn()); -const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); const getMSTeamsRuntimeMock = vi.hoisted(() => vi.fn()); const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); const renderReplyPayloadsToMessagesMock = vi.hoisted(() => vi.fn(() => [])); @@ -14,14 +13,6 @@ 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, })); @@ -90,13 +81,6 @@ describe("createMSTeamsReplyDispatcher", () => { typingCallbacks, }); - createReplyDispatcherWithTypingMock.mockImplementation((options) => ({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: vi.fn(), - _options: options, - })); - getMSTeamsRuntimeMock.mockReturnValue({ system: { enqueueSystemEvent: enqueueSystemEventMock, @@ -106,10 +90,7 @@ describe("createMSTeamsReplyDispatcher", () => { resolveChunkMode: vi.fn(() => "length"), resolveMarkdownTableMode: vi.fn(() => "code"), }, - reply: { - createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, - resolveHumanDelayConfig: vi.fn(() => undefined), - }, + reply: { resolveHumanDelayConfig: vi.fn(() => undefined) }, }, }); }); @@ -190,11 +171,16 @@ describe("createMSTeamsReplyDispatcher", () => { }; function dispatcherOptions(): DispatcherOptions { - const [call] = createReplyDispatcherWithTypingMock.mock.calls; - if (!call) { - throw new Error("expected reply dispatcher factory call"); + const created = lastCreatedDispatcher; + if (!created) { + throw new Error("createDispatcher must be called first"); } - return call[0] as DispatcherOptions; + return { + onReplyStart: created.dispatcherOptions.onReplyStart, + deliver: async (payload) => { + await created.delivery.deliver(payload, { kind: "final" }); + }, + }; } function pipelineArgs(): PipelineArgs { @@ -413,7 +399,7 @@ describe("createMSTeamsReplyDispatcher", () => { dispatcher.replyOptions.onPartialReply?.({ text: "streamed" }); await options.deliver({ text: "streamed final" }); - await dispatcher.markDispatchIdle(); + await dispatcher.dispatcherOptions.onSettled?.(); expect(renderReplyPayloadsToMessagesMock).toHaveBeenCalledWith( [{ text: "streamed final" }], @@ -637,7 +623,7 @@ describe("createMSTeamsReplyDispatcher", () => { const options = dispatcherOptions(); await options.deliver({ text: "block content" }); - await dispatcher.markDispatchIdle(); + await dispatcher.dispatcherOptions.onSettled?.(); expect(onSentMessageIds).toHaveBeenCalledWith(["id-1"]); expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1); @@ -660,7 +646,7 @@ describe("createMSTeamsReplyDispatcher", () => { const options = dispatcherOptions(); await options.deliver({ text: "block content" }); - await dispatcher.markDispatchIdle(); + await dispatcher.dispatcherOptions.onSettled?.(); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); }); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 3cf8ec2ebf2..1a37564f245 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -1,4 +1,5 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound"; // Msteams plugin module implements reply dispatcher behavior. import { buildChannelProgressDraftLine, @@ -9,7 +10,6 @@ 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, @@ -293,11 +293,7 @@ export function createMSTeamsReplyDispatcher(params: { } }; - const { - dispatcher, - replyOptions, - markDispatchIdle: baseMarkDispatchIdle, - } = createReplyDispatcherWithTyping({ + const dispatcherOptions: NonNullable = { ...replyPipeline, humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId), onReplyStart: async () => { @@ -314,6 +310,8 @@ export function createMSTeamsReplyDispatcher(params: { } }, typingCallbacks, + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { deliver: async (payload) => { const preparedPayload = streamController.preparePayload(payload); if (!preparedPayload) { @@ -342,9 +340,9 @@ export function createMSTeamsReplyDispatcher(params: { hint, }); }, - }); + }; - const markDispatchIdle = (): Promise => { + const settleDelivery = (): Promise => { return flushPendingMessages() .catch((err: unknown) => { const errMsg = formatUnknownError(err); @@ -366,9 +364,6 @@ export function createMSTeamsReplyDispatcher(params: { queueReplyPayload(fallbackPayload); await flushPendingMessages(); } - }) - .finally(() => { - baseMarkDispatchIdle(); }); }; @@ -534,9 +529,12 @@ export function createMSTeamsReplyDispatcher(params: { : {}; return { - dispatcher, + dispatcherOptions: { + ...dispatcherOptions, + onSettled: settleDelivery, + }, + delivery, replyOptions: { - ...replyOptions, ...(streamController.hasStream() ? { onPartialReply: (payload: { text?: string }) => @@ -557,6 +555,5 @@ export function createMSTeamsReplyDispatcher(params: { disableBlockStreaming: blockStreamingResolved == null ? undefined : !blockStreamingResolved, onModelSelected, }, - markDispatchIdle, }; } diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index 32576638d8a..7123626dafc 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -170,7 +170,9 @@ describe("handleQaInbound", () => { const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "unfinished preview" }); - assembled.delivery.onError?.(new Error("model failed"), { kind: "final" }); + await Promise.resolve( + assembled.delivery.onError?.(new Error("model failed"), { kind: "final" }), + ); await vi.waitFor(() => { expect(deleteQaBusMessage).toHaveBeenCalledWith( @@ -191,7 +193,9 @@ describe("handleQaInbound", () => { await expect( assembled.replyOptions?.onPartialReply?.({ text: "broken preview" }), ).rejects.toThrow("edit failed"); - assembled.delivery.onError?.(new Error("dispatch failed"), { kind: "final" }); + await Promise.resolve( + assembled.delivery.onError?.(new Error("dispatch failed"), { kind: "final" }), + ); await vi.waitFor(() => { expect(deleteQaBusMessage).toHaveBeenCalledWith( @@ -216,14 +220,16 @@ describe("handleQaInbound", () => { const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "unfinished preview" }); - assembled.delivery.onError?.(new Error(`dispatch\r\nforged${paragraphSeparator}next`), { - kind: "final", - }); + await Promise.resolve( + assembled.delivery.onError?.(new Error(`dispatch\r\nforged${paragraphSeparator}next`), { + kind: "final", + }), + ); await vi.waitFor(() => { expect(warn).toHaveBeenCalledTimes(2); }); - assembled.delivery.onError?.(undefined, { kind: "final" }); + await Promise.resolve(assembled.delivery.onError?.(undefined, { kind: "final" })); await vi.waitFor(() => { expect(warn).toHaveBeenCalledTimes(3); }); diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts index 99ae79d672c..85c9b1774cd 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts @@ -103,15 +103,18 @@ function makeRuntime(): GatewayPluginRuntime { }; }; const input = await params.adapter.ingest(params.raw); - const turn = (await params.adapter.resolveTurn( + await params.adapter.resolveTurn( input, { kind: "message", canStartAgentTurn: true, }, {}, - )) as { runDispatch: () => Promise }; - return { dispatchResult: await turn.runDispatch() }; + ); + return { + dispatched: true, + dispatchResult: { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }, + }; }), }, text: { diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts index 02a1b2a0ac3..869abbfd49c 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts @@ -115,6 +115,7 @@ function makeInbound(overrides: Partial = {}): InboundContext { } function makeInboundRuntime( + dispatchReplyWithBufferedBlockDispatcher: (params: unknown) => Promise, onResolvedContext?: (ctx: Record) => void, ): GatewayPluginRuntime["channel"]["inbound"] { return { @@ -134,9 +135,28 @@ function makeInboundRuntime( kind: "message", }, {}, - )) as { ctxPayload: Record; runDispatch: () => Promise }; + )) as { + cfg: unknown; + ctxPayload: Record; + dispatcherOptions?: Record; + delivery: { deliver: unknown; onError?: unknown }; + replyOptions?: unknown; + replyResolver?: unknown; + }; onResolvedContext?.(turn.ctxPayload); - return { dispatchResult: await turn.runDispatch() }; + return { + dispatchResult: await dispatchReplyWithBufferedBlockDispatcher({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcherOptions: { + ...turn.dispatcherOptions, + deliver: turn.delivery.deliver, + onError: turn.delivery.onError, + }, + replyOptions: turn.replyOptions, + replyResolver: turn.replyResolver, + }), + }; }), }; } @@ -164,6 +184,44 @@ function makeRuntime(params: { ) => Promise, ) => Promise; }): GatewayPluginRuntime { + const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (rawParams: unknown) => { + const dispatcherOptions = ( + rawParams as { + dispatcherOptions: { + deliver: ( + payload: { + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + audioAsVoice?: boolean; + }, + info: { kind: string }, + ) => Promise; + onSkip?: ( + payload: { + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + audioAsVoice?: boolean; + }, + info: { kind: string; reason: "empty" | "silent" | "heartbeat" }, + ) => void; + onSettled?: () => unknown; + onFreshSettledDelivery?: () => unknown; + }; + } + ).dispatcherOptions; + if (params.onDispatch) { + await params.onDispatch(dispatcherOptions); + } else { + await params.onDeliver?.(dispatcherOptions.deliver); + } + await dispatcherOptions.onSettled?.(); + if (!params.skipFreshSettledDelivery) { + await dispatcherOptions.onFreshSettledDelivery?.(); + } + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }); return { channel: { activity: { record: vi.fn() }, @@ -174,43 +232,7 @@ function makeRuntime(params: { })), }, reply: { - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (rawParams: unknown) => { - const dispatcherOptions = ( - rawParams as { - dispatcherOptions: { - deliver: ( - payload: { - text?: string; - mediaUrl?: string; - mediaUrls?: string[]; - audioAsVoice?: boolean; - }, - info: { kind: string }, - ) => Promise; - onSkip?: ( - payload: { - text?: string; - mediaUrl?: string; - mediaUrls?: string[]; - audioAsVoice?: boolean; - }, - info: { kind: string; reason: "empty" | "silent" | "heartbeat" }, - ) => void; - onSettled?: () => unknown; - onFreshSettledDelivery?: () => unknown; - }; - } - ).dispatcherOptions; - if (params.onDispatch) { - await params.onDispatch(dispatcherOptions); - } else { - await params.onDeliver?.(dispatcherOptions.deliver); - } - await dispatcherOptions.onSettled?.(); - if (!params.skipFreshSettledDelivery) { - await dispatcherOptions.onFreshSettledDelivery?.(); - } - }), + dispatchReplyWithBufferedBlockDispatcher, finalizeInboundContext: vi.fn((rawCtx: Record) => rawCtx), formatInboundEnvelope: vi.fn(() => "voice"), resolveEffectiveMessagesConfig: vi.fn(() => ({})), @@ -220,7 +242,7 @@ function makeRuntime(params: { resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"), recordInboundSession: vi.fn(async () => undefined), }, - inbound: makeInboundRuntime(params.onFinalize), + inbound: makeInboundRuntime(dispatchReplyWithBufferedBlockDispatcher, params.onFinalize), text: { chunkMarkdownText: (text: string) => [text], }, diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts index 559105f9c94..b2ba8fe158d 100644 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts @@ -446,257 +446,248 @@ export async function dispatchOutbound( ); }, }, - runDispatch: () => - runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - responsePrefix: messagesConfig.responsePrefix, - deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => { - hasResponse = true; + dispatcherOptions: { + responsePrefix: messagesConfig.responsePrefix, + onSkip: ( + _payload: ReplyDeliverPayload, + info: { kind: string; reason: "empty" | "silent" | "heartbeat" }, + ) => { + if ( + !streamingController && + (info.kind === "block" || info.kind === "final") && + (info.reason === "silent" || info.reason === "empty") + ) { + skippedSilentBlockResponse = true; + } + }, + onFreshSettledDelivery: async () => { + if (skippedSilentBlockResponse && !hasVisibleBlockResponse) { + markBlockResponse(); + if (await flushPendingToolDeliveriesOnce()) { + return { visibleReplySent: true }; + } + } + return undefined; + }, + }, + delivery: { + deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => { + hasResponse = true; - // ---- Tool deliver ---- - if (info.kind === "tool") { - toolDeliverCount++; - const toolText = (payload.text ?? "").trim(); - const textOnlyProgress = immediateToolProgressText(payload); - if (!hasBlockResponse && deliverToolProgressImmediately && textOnlyProgress) { - if (toolOnlyTimeoutId || hasPendingToolFallbackPayload()) { - renewToolOnlyFallback(); - } - await sendTextOnlyReply( - textOnlyProgress, - { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }, - { account, qualifiedTarget, log, ...gatewayMediaContext }, - sendWithRetry, - () => undefined, - deliverDeps, - ); - recordOutbound(); - return; - } - if (toolText) { - toolTexts.push(toolText); - } - if (payload.mediaUrls?.length) { - toolMediaUrls.push(...payload.mediaUrls); - } - if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { - toolMediaUrls.push(payload.mediaUrl); - } - - if (hasBlockResponse && toolMediaUrls.length > 0) { - const urlsToSend = [...toolMediaUrls]; - toolMediaUrls.length = 0; - for (const mediaUrl of urlsToSend) { - try { - await sendMedia({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - ...gatewayMediaContext, - }); - } catch {} - } - return; - } - if (toolFallbackSent) { - return; - } + if (info.kind === "tool") { + toolDeliverCount++; + const toolText = (payload.text ?? "").trim(); + const textOnlyProgress = immediateToolProgressText(payload); + if (!hasBlockResponse && deliverToolProgressImmediately && textOnlyProgress) { + if (toolOnlyTimeoutId || hasPendingToolFallbackPayload()) { renewToolOnlyFallback(); - return; } - - // ---- Block deliver ---- - markBlockResponse(); - - if (!streamingController && isSilentBlockReply(payload)) { - if (!(await flushPendingToolDeliveriesOnce()) && event.type === "group") { - log?.info( - `Model decided to skip group message (${(payload.text ?? "").trim() || "empty reply"}) from ${event.senderId}`, - ); - } - return; - } - hasVisibleBlockResponse = true; - - if ( - streamingController && - !streamingController.isTerminalPhase && - !isMediaOnlyBlockReply(payload) - ) { - try { - await streamingController.onDeliver(payload); - } catch (err) { - log?.error( - `Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - const replyPreview = (payload.text ?? "").trim(); - if ( - event.type === "group" && - (replyPreview === "NO_REPLY" || replyPreview === "[SKIP]") - ) { - log?.info( - `Model decided to skip group message (${replyPreview}) from ${event.senderId}`, - ); - return; - } - - if (streamingController.shouldFallbackToStatic) { - log?.info("Streaming API unavailable, falling back to static for this deliver"); - } else { - recordOutbound(); - return; - } - } - - const quoteRef = event.msgIdx; - let quoteRefUsed = false; - const consumeQuoteRef = (): string | undefined => { - if (quoteRef && !quoteRefUsed) { - quoteRefUsed = true; - return quoteRef; - } - return undefined; - }; - - let replyText = blockReplyTextForDelivery(payload); - const deliverEvent = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }; - const deliverActx = { account, qualifiedTarget, log, ...gatewayMediaContext }; - - // 1. Media tags - const mediaResult = await parseAndSendMediaTags( - replyText, - deliverEvent, - deliverActx, + await sendTextOnlyReply( + textOnlyProgress, + { + type: event.type, + senderId: event.senderId, + messageId: event.messageId, + channelId: event.channelId, + groupOpenid: event.groupOpenid, + msgIdx: event.msgIdx, + }, + { account, qualifiedTarget, log, ...gatewayMediaContext }, sendWithRetry, - consumeQuoteRef, - deliverDeps, - ); - if (mediaResult.handled) { - recordOutbound(); - return; - } - replyText = mediaResult.normalizedText; - - // 2. Structured payload (QQBOT_PAYLOAD:) - const handled = await handleStructuredPayload( - replyCtx, - replyText, - recordOutbound, - replyDeps, - ); - if (handled) { - return; - } - - // 3. Voice-intent plain text - if ( - payload.audioAsVoice === true && - !payload.mediaUrl && - !payload.mediaUrls?.length - ) { - const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps); - if (sentVoice) { - recordOutbound(); - return; - } - } - - // 4. Plain text + images/media - await sendPlainReply( - payload, - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - toolMediaUrls, + () => undefined, deliverDeps, ); recordOutbound(); - }, - onError: async (err: unknown) => { - if (streamingController && !streamingController.isTerminalPhase) { + return; + } + if (toolText) { + toolTexts.push(toolText); + } + if (payload.mediaUrls?.length) { + toolMediaUrls.push(...payload.mediaUrls); + } + if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { + toolMediaUrls.push(payload.mediaUrl); + } + + if (hasBlockResponse && toolMediaUrls.length > 0) { + const urlsToSend = [...toolMediaUrls]; + toolMediaUrls.length = 0; + for (const mediaUrl of urlsToSend) { try { - await streamingController.onError(err); - } catch (streamErr) { - const streamErrMsg = - streamErr instanceof Error ? streamErr.message : String(streamErr); - log?.error(`Streaming onError failed: ${streamErrMsg}`); - } - if (!streamingController.shouldFallbackToStatic) { - return; - } + await sendMedia({ + to: qualifiedTarget, + text: "", + mediaUrl, + accountId: account.accountId, + replyToId: event.messageId, + account, + ...gatewayMediaContext, + }); + } catch {} } - const errMsg = err instanceof Error ? err.message : String(err); - log?.error(`Dispatch error: ${errMsg}`); - hasResponse = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }, - onSkip: ( - _payload: ReplyDeliverPayload, - info: { kind: string; reason: "empty" | "silent" | "heartbeat" }, - ) => { - if ( - !streamingController && - (info.kind === "block" || info.kind === "final") && - (info.reason === "silent" || info.reason === "empty") - ) { - skippedSilentBlockResponse = true; - } - }, - onFreshSettledDelivery: async () => { - if (skippedSilentBlockResponse && !hasVisibleBlockResponse) { - markBlockResponse(); - if (await flushPendingToolDeliveriesOnce()) { - return { visibleReplySent: true }; + return; + } + if (toolFallbackSent) { + return; + } + renewToolOnlyFallback(); + return; + } + + markBlockResponse(); + + if (!streamingController && isSilentBlockReply(payload)) { + if (!(await flushPendingToolDeliveriesOnce()) && event.type === "group") { + log?.info( + `Model decided to skip group message (${(payload.text ?? "").trim() || "empty reply"}) from ${event.senderId}`, + ); + } + return; + } + hasVisibleBlockResponse = true; + + if ( + streamingController && + !streamingController.isTerminalPhase && + !isMediaOnlyBlockReply(payload) + ) { + try { + await streamingController.onDeliver(payload); + } catch (err) { + log?.error( + `Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const replyPreview = (payload.text ?? "").trim(); + if ( + event.type === "group" && + (replyPreview === "NO_REPLY" || replyPreview === "[SKIP]") + ) { + log?.info( + `Model decided to skip group message (${replyPreview}) from ${event.senderId}`, + ); + return; + } + + if (streamingController.shouldFallbackToStatic) { + log?.info("Streaming API unavailable, falling back to static for this deliver"); + } else { + recordOutbound(); + return; + } + } + + const quoteRef = event.msgIdx; + let quoteRefUsed = false; + const consumeQuoteRef = (): string | undefined => { + if (quoteRef && !quoteRefUsed) { + quoteRefUsed = true; + return quoteRef; + } + return undefined; + }; + + let replyText = blockReplyTextForDelivery(payload); + const deliverEvent = { + type: event.type, + senderId: event.senderId, + messageId: event.messageId, + channelId: event.channelId, + groupOpenid: event.groupOpenid, + msgIdx: event.msgIdx, + }; + const deliverActx = { account, qualifiedTarget, log, ...gatewayMediaContext }; + + // 1. Media tags + const mediaResult = await parseAndSendMediaTags( + replyText, + deliverEvent, + deliverActx, + sendWithRetry, + consumeQuoteRef, + deliverDeps, + ); + if (mediaResult.handled) { + recordOutbound(); + return; + } + replyText = mediaResult.normalizedText; + + // 2. Structured payload (QQBOT_PAYLOAD:) + const handled = await handleStructuredPayload( + replyCtx, + replyText, + recordOutbound, + replyDeps, + ); + if (handled) { + return; + } + + // 3. Voice-intent plain text + if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) { + const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps); + if (sentVoice) { + recordOutbound(); + return; + } + } + + // 4. Plain text + images/media + await sendPlainReply( + payload, + replyText, + deliverEvent, + deliverActx, + sendWithRetry, + consumeQuoteRef, + toolMediaUrls, + deliverDeps, + ); + recordOutbound(); + }, + onError: async (err: unknown) => { + if (streamingController && !streamingController.isTerminalPhase) { + try { + await streamingController.onError(err); + } catch (streamErr) { + const streamErrMsg = + streamErr instanceof Error ? streamErr.message : String(streamErr); + log?.error(`Streaming onError failed: ${streamErrMsg}`); + } + if (!streamingController.shouldFallbackToStatic) { + return; + } + } + const errMsg = err instanceof Error ? err.message : String(err); + log?.error(`Dispatch error: ${errMsg}`); + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }, + }, + replyOptions: { + disableBlockStreaming: useOfficialC2cStream + ? true + : account.config?.streaming?.mode === "off", + ...(streamingController + ? { + onPartialReply: async (payload: { text?: string }) => { + try { + await streamingController.onPartialReply(payload); + } catch (partialErr) { + log?.error( + `Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`, + ); } - } - return undefined; - }, - }, - replyOptions: { - disableBlockStreaming: useOfficialC2cStream - ? true - : account.config?.streaming?.mode === "off", - ...(streamingController - ? { - onPartialReply: async (payload: { text?: string }) => { - try { - await streamingController.onPartialReply(payload); - } catch (partialErr) { - log?.error( - `Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`, - ); - } - }, - } - : {}), - }, - }), + }, + } + : {}), + }, }), }, }); diff --git a/extensions/signal/src/monitor/event-handler.control-lane.test.ts b/extensions/signal/src/monitor/event-handler.control-lane.test.ts index 023bda88993..3e7c38baa57 100644 --- a/extensions/signal/src/monitor/event-handler.control-lane.test.ts +++ b/extensions/signal/src/monitor/event-handler.control-lane.test.ts @@ -33,6 +33,51 @@ 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: async (params: RunParams) => { + const input = await params.adapter.ingest(params.raw); + if (!input) { + return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false }; + } + const eventClass = (await params.adapter.classify?.(input)) ?? { + kind: "message" as const, + canStartAgentTurn: true, + }; + const preflight = (await params.adapter.preflight?.(input, eventClass)) ?? {}; + const resolved = await params.adapter.resolveTurn( + input, + eventClass, + "kind" in preflight ? { admission: preflight } : preflight, + ); + if (!("route" in resolved) || !("delivery" in resolved)) { + throw new Error("expected assembled Signal channel turn plan"); + } + const result = await actual.runPreparedInboundReply({ + channel: resolved.channel, + accountId: resolved.accountId, + routeSessionKey: resolved.route.sessionKey, + storePath: "/tmp/openclaw/signal-sessions.json", + ctxPayload: resolved.ctxPayload, + recordInboundSession: recordInboundSessionMock, + afterRecord: resolved.afterRecord, + record: resolved.record, + history: resolved.history, + admission: resolved.admission, + botLoopProtection: resolved.botLoopProtection, + runDispatch: async () => await dispatchInboundMessageMock({ ctx: resolved.ctxPayload }), + }); + await params.adapter.onFinalize?.(result); + return result; + }, + }; +}); + 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.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index c6d12c309c6..93b4f4a46cf 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -13,6 +13,12 @@ let createSignalEventHandler: typeof import("./event-handler.js").createSignalEv type DispatchInboundMessageMockParams = { ctx: MsgContext; + cfg?: OpenClawConfig; + dispatcher?: { + sendFinalReply: (payload: { text: string }) => void; + markComplete: () => void; + waitForIdle: () => Promise; + }; replyOptions?: { allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean; allowToolLifecycleWhenProgressHidden?: boolean; @@ -86,27 +92,75 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => { 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, - }; + runChannelInboundEvent: async (params: RunParams) => { + const input = await params.adapter.ingest(params.raw); + if (!input) { + return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false }; + } + const eventClass = (await params.adapter.classify?.(input)) ?? { + kind: "message" as const, + canStartAgentTurn: true, + }; + const preflight = (await params.adapter.preflight?.(input, eventClass)) ?? {}; + const resolved = await params.adapter.resolveTurn( + input, + eventClass, + "kind" in preflight ? { admission: preflight } : preflight, + ); + if (!("route" in resolved) || !("delivery" in resolved)) { + throw new Error("expected assembled Signal channel turn plan"); + } + const runPrepared = async () => { + const pendingDeliveries: Promise[] = []; + const dispatcher = { + sendFinalReply: (payload: { text: string }) => { + pendingDeliveries.push( + Promise.resolve(resolved.delivery.deliver(payload, { kind: "final" })), + ); }, - }, - }); + markComplete: () => {}, + waitForIdle: async () => { + await Promise.all(pendingDeliveries); + }, + }; + return await actual.runPreparedInboundReply({ + channel: resolved.channel, + accountId: resolved.accountId, + routeSessionKey: resolved.route.sessionKey, + storePath: "/tmp/openclaw/signal-sessions.json", + ctxPayload: resolved.ctxPayload, + recordInboundSession: recordInboundSessionMock, + afterRecord: resolved.afterRecord, + record: resolved.record, + history: resolved.history, + admission: resolved.admission, + botLoopProtection: resolved.botLoopProtection, + runDispatch: async () => + await dispatchInboundMessageMock({ + ctx: resolved.ctxPayload, + cfg: resolved.cfg, + dispatcher, + replyOptions: { + ...resolved.replyOptions, + onReplyStart: resolved.dispatcherOptions?.typingCallbacks?.onReplyStart, + }, + }), + }); + }; + let result; + try { + result = await runPrepared(); + } catch (err) { + await params.adapter.onFinalize?.({ + admission: resolved.admission ?? { kind: "dispatch" }, + dispatched: false, + ctxPayload: resolved.ctxPayload, + routeSessionKey: resolved.route.sessionKey, + }); + throw err; + } + await params.adapter.onFinalize?.(result); + return result; }, }; }); diff --git a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts index f708d8b8464..60caac4e70d 100644 --- a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts +++ b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts @@ -39,6 +39,54 @@ 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: async (params: RunParams) => { + const input = await params.adapter.ingest(params.raw); + if (!input) { + return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false }; + } + const eventClass = (await params.adapter.classify?.(input)) ?? { + kind: "message" as const, + canStartAgentTurn: true, + }; + const preflight = (await params.adapter.preflight?.(input, eventClass)) ?? {}; + const resolved = await params.adapter.resolveTurn( + input, + eventClass, + "kind" in preflight ? { admission: preflight } : preflight, + ); + if (!("route" in resolved) || !("delivery" in resolved)) { + throw new Error("expected assembled Signal channel turn plan"); + } + const result = await actual.runPreparedInboundReply({ + channel: resolved.channel, + accountId: resolved.accountId, + routeSessionKey: resolved.route.sessionKey, + storePath: "/tmp/openclaw/signal-sessions.json", + ctxPayload: resolved.ctxPayload, + recordInboundSession: async () => {}, + afterRecord: resolved.afterRecord, + record: resolved.record, + history: resolved.history, + admission: resolved.admission, + botLoopProtection: resolved.botLoopProtection, + runDispatch: async () => { + capturedCtx = resolved.ctxPayload as SignalMsgContext; + return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; + }, + }); + await params.adapter.onFinalize?.(result); + return result; + }, + }; +}); + const [ { createBaseSignalEventHandlerDeps, createSignalReceiveEvent }, { createSignalEventHandler }, diff --git a/extensions/signal/src/monitor/event-handler.reply-session-conflict.test.ts b/extensions/signal/src/monitor/event-handler.reply-session-conflict.test.ts index 49a0d40c23f..81b07900667 100644 --- a/extensions/signal/src/monitor/event-handler.reply-session-conflict.test.ts +++ b/extensions/signal/src/monitor/event-handler.reply-session-conflict.test.ts @@ -69,6 +69,51 @@ 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: async (params: RunParams) => { + const input = await params.adapter.ingest(params.raw); + if (!input) { + return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false }; + } + const eventClass = (await params.adapter.classify?.(input)) ?? { + kind: "message" as const, + canStartAgentTurn: true, + }; + const preflight = (await params.adapter.preflight?.(input, eventClass)) ?? {}; + const resolved = await params.adapter.resolveTurn( + input, + eventClass, + "kind" in preflight ? { admission: preflight } : preflight, + ); + if (!("route" in resolved) || !("delivery" in resolved)) { + throw new Error("expected assembled Signal channel turn plan"); + } + const result = await actual.runPreparedInboundReply({ + channel: resolved.channel, + accountId: resolved.accountId, + routeSessionKey: resolved.route.sessionKey, + storePath: "/tmp/openclaw/signal-sessions.json", + ctxPayload: resolved.ctxPayload, + recordInboundSession: recordInboundSessionMock, + afterRecord: resolved.afterRecord, + record: resolved.record, + history: resolved.history, + admission: resolved.admission, + botLoopProtection: resolved.botLoopProtection, + runDispatch: async () => await dispatchInboundMessageMock({ ctx: resolved.ctxPayload }), + }); + await params.adapter.onFinalize?.(result); + return result; + }, + }; +}); + 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 bca2a8777ed..e32e3e87b91 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -26,6 +26,7 @@ import { hasVisibleInboundReplyDispatch, runChannelInboundEvent, shouldDebounceTextInbound, + type ChannelInboundTurnPlan, } from "openclaw/plugin-sdk/channel-inbound"; import { bindIngressLifecycleToReplyOptions, @@ -46,9 +47,6 @@ import { import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history"; import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference"; -import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; -import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; -import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { danger, @@ -157,7 +155,9 @@ function resolveSignalStatusReactionTimestamp(params: { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } -type SignalStatusDispatchResult = Awaited>; +type SignalStatusDispatchResult = { + failedCounts?: Partial>; +}; function hasSignalStatusReplyDeliveryFailure(result: SignalStatusDispatchResult): boolean { const failedCounts = result.failedCounts; @@ -498,10 +498,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { replyToMode !== "off" && replyThreading?.implicitCurrentMessage !== "deny", state: { hasReplied: false }, }; - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + const dispatcherOptions: NonNullable = { ...replyPipeline, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), typingCallbacks, + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { deliver: async (payload, _info) => { await deps.deliverReplies({ cfg: deps.cfg, @@ -521,7 +523,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { onError: (err, info) => { deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`)); }, - }); + }; const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({ route, sessionKey: route.sessionKey, @@ -585,53 +587,40 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { historyMap: deps.groupHistories, limit: deps.historyLimit, }, - onPreDispatchFailure: () => - settleReplyDispatcher({ - dispatcher, - onSettled: () => markDispatchIdle(), - }), - runDispatch: async () => { - try { - if (statusReactionController) { - void statusReactionController.setThinking(); - } - return await dispatchInboundMessage({ - ctx: ctxPayload, - cfg: deps.cfg, - dispatcher, - replyOptions: { - ...replyOptions, - ...(entry.turnAdoptionLifecycle - ? bindIngressLifecycleToReplyOptions(entry.turnAdoptionLifecycle) - : {}), - disableBlockStreaming: - typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, - ...(statusReactionController - ? { - allowProgressCallbacksWhenSourceDeliverySuppressed: true, - allowToolLifecycleWhenProgressHidden: true, - onToolStart: async (payload: { name?: string }) => { - const toolName = payload.name?.trim(); - if (toolName) { - await statusReactionController.setTool(toolName); - } - }, - onCompactionStart: async () => { - await statusReactionController.setCompacting(); - }, - onCompactionEnd: async () => { - statusReactionController.cancelPending(); - await statusReactionController.setThinking(); - }, - } - : {}), - onModelSelected, - }, - }); - } finally { - markDispatchIdle(); + afterRecord: () => { + if (statusReactionController) { + void statusReactionController.setThinking(); } }, + dispatcherOptions, + delivery, + replyOptions: { + ...(entry.turnAdoptionLifecycle + ? bindIngressLifecycleToReplyOptions(entry.turnAdoptionLifecycle) + : {}), + disableBlockStreaming: + typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, + ...(statusReactionController + ? { + allowProgressCallbacksWhenSourceDeliverySuppressed: true, + allowToolLifecycleWhenProgressHidden: true, + onToolStart: async (payload: { name?: string }) => { + const toolName = payload.name?.trim(); + if (toolName) { + await statusReactionController.setTool(toolName); + } + }, + onCompactionStart: async () => { + await statusReactionController.setCompacting(); + }, + onCompactionEnd: async () => { + statusReactionController.cancelPending(); + await statusReactionController.setThinking(); + }, + } + : {}), + onModelSelected, + }, }), onFinalize: (result) => { if (!statusReactionController) { diff --git a/extensions/slack/src/delivery-trace.test.ts b/extensions/slack/src/delivery-trace.test.ts index d4ff882b2b6..ebafdad70fa 100644 --- a/extensions/slack/src/delivery-trace.test.ts +++ b/extensions/slack/src/delivery-trace.test.ts @@ -33,7 +33,7 @@ type RecordedWireCall = { type CapturedDispatcherOptions = { deliver: (payload: ReplyPayload, info: { kind: ReplyDispatchKind }) => Promise; - onError?: (err: unknown, info: { kind: string }) => void; + onError?: (err: unknown, info: { kind: string }) => Promise | void; typingCallbacks?: { onReplyStart?: () => Promise; onIdle?: () => void; @@ -548,7 +548,7 @@ async function setupSlackTrace( } catch (err) { // Mirrors the reply dispatcher: failed deliveries report onError and are // not counted as dispatched. - turn.options.onError?.(err, { kind }); + await turn.options.onError?.(err, { kind }); } }; diff --git a/extensions/telegram/src/bot-message-dispatch-turn.ts b/extensions/telegram/src/bot-message-dispatch-turn.ts index 637328a0680..5e534a07e16 100644 --- a/extensions/telegram/src/bot-message-dispatch-turn.ts +++ b/extensions/telegram/src/bot-message-dispatch-turn.ts @@ -1,5 +1,8 @@ import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; -import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound"; +import { + runChannelInboundEvent, + type ChannelInboundTurnPlan, +} from "openclaw/plugin-sdk/channel-inbound"; // Telegram plugin module wires inbound turn execution to Telegram delivery controllers. import { createChannelMessageReplyPipeline, @@ -78,6 +81,13 @@ export async function runTelegramDispatchTurn(params: { }, }, }); + const handleDeliveryError = async (err: unknown, info: { kind: string }) => { + await Promise.resolve( + params.reply.onError(err, info as Parameters[1]), + ).catch((callbackError: unknown) => { + logVerbose(`telegram reply error callback failed: ${String(callbackError)}`); + }); + }; const turnResult = await runChannelInboundEvent({ channel: "telegram", accountId: context.route.accountId, @@ -104,149 +114,152 @@ export async function runTelegramDispatchTurn(params: { }, ctxPayload: context.ctxPayload, record: context.turn.record, - runDispatch: () => - params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ - ctx: context.ctxPayload, - cfg: params.cfg, - dispatcherOptions: { - ...replyPipeline, - beforeDeliver: async (payload) => payload, - onBeforeDeliverCancelled: params.reply.onBeforeDeliverCancelled, - deliver: params.reply.deliver, - onSkip: params.reply.onSkip, - onError: params.reply.onError, - }, - replyOptions: { - skillFilter: context.skillFilter, - disableBlockStreaming: params.draft.disableBlockStreaming, - abortSignal: params.turnAdoptionLifecycle?.abortSignal, - turnAdoptionLifecycle: params.turnAdoptionLifecycle - ? { - admission: params.turnAdoptionLifecycle.admission ?? "exclusive", - onAdopted: params.turnAdoptionLifecycle.onAdopted, - onDeferred: params.turnAdoptionLifecycle.onDeferred, - onAbandoned: params.turnAdoptionLifecycle.onAbandoned, - abortSignal: params.turnAdoptionLifecycle.abortSignal, + delivery: { + deliver: async (payload, info) => + (await params.reply.deliver(payload, info)) as Awaited< + ReturnType + >, + // The shipped SDK declaration stays void; core still awaits the runtime promise. + onError: handleDeliveryError as NonNullable< + ChannelInboundTurnPlan["delivery"]["onError"] + >, + }, + dispatcherOptions: { + ...replyPipeline, + beforeDeliver: async (payload) => payload, + onBeforeDeliverCancelled: params.reply.onBeforeDeliverCancelled, + onSkip: params.reply.onSkip, + }, + replyOptions: { + skillFilter: context.skillFilter, + disableBlockStreaming: params.draft.disableBlockStreaming, + abortSignal: params.turnAdoptionLifecycle?.abortSignal, + turnAdoptionLifecycle: params.turnAdoptionLifecycle + ? { + admission: params.turnAdoptionLifecycle.admission ?? "exclusive", + onAdopted: params.turnAdoptionLifecycle.onAdopted, + onDeferred: params.turnAdoptionLifecycle.onDeferred, + onAbandoned: params.turnAdoptionLifecycle.onAbandoned, + abortSignal: params.turnAdoptionLifecycle.abortSignal, + } + : undefined, + sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined, + queuedDeliveryCorrelations: isRoomEvent + ? [{ begin: beginDeliveryCorrelation }] + : undefined, + suppressTyping: isRoomEvent, + onPartialReply: + params.draft.answerLane.stream || params.draft.reasoningLane.stream + ? (payload) => + params.draft.enqueueEvent(async () => { + await params.draft.ingestDraftLaneSegments(payload); + }) + : undefined, + onBlockReplyQueued: params.draft.answerLane.stream + ? (payload, blockContext) => + params.draft.enqueueEvent(async () => { + await params.draft.prepareQueuedAnswerBlock(payload, blockContext); + }) + : undefined, + onReasoningStream: params.draft.reasoningLane.stream + ? (payload) => + params.draft.enqueueEvent(async () => { + if (splitReasoningOnNextStream) { + params.draft.repositionLaneForNewMessage(params.draft.reasoningLane); + splitReasoningOnNextStream = false; } - : undefined, - sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined, - queuedDeliveryCorrelations: isRoomEvent - ? [{ begin: beginDeliveryCorrelation }] - : undefined, - suppressTyping: isRoomEvent, - onPartialReply: - params.draft.answerLane.stream || params.draft.reasoningLane.stream - ? (payload) => - params.draft.enqueueEvent(async () => { - await params.draft.ingestDraftLaneSegments(payload); - }) - : undefined, - onBlockReplyQueued: params.draft.answerLane.stream - ? (payload, blockContext) => - params.draft.enqueueEvent(async () => { - await params.draft.prepareQueuedAnswerBlock(payload, blockContext); - }) - : undefined, - onReasoningStream: params.draft.reasoningLane.stream - ? (payload) => - params.draft.enqueueEvent(async () => { - if (splitReasoningOnNextStream) { - params.draft.repositionLaneForNewMessage(params.draft.reasoningLane); - splitReasoningOnNextStream = false; - } - await params.draft.ingestDraftLaneSegments(payload, true); - }) - : params.draft.streamReasoningInProgressDraft - ? (payload) => - params.draft.enqueueEvent(async () => { - await params.progress.pushReasoningProgress(payload); - }) - : undefined, - onReasoningProgress: params.draft.answerLane.stream - ? (payload) => - params.draft.enqueueEvent(async () => { - await params.progress.pushThinkingTokenProgress(payload.progressTokens); - }) - : undefined, - onAssistantMessageStart: params.draft.answerLane.stream - ? () => - params.draft.enqueueEvent(async () => { - params.reply.reasoningStepState.resetForNextStep(); - params.progress.setFinalAnswerDelivered(false); - if (params.streamMode !== "progress") { - params.progress.reset(); - } - if (params.draft.answerLane.finalized) { - await params.draft.rotateLaneForNewMessage(params.draft.answerLane); - params.draft.setRotateWhenQueuedBlocksSettle(false); - } else if ( - params.draft.answerLane.hasStreamedMessage && - !params.draft.isAnswerToolProgressOnly() - ) { - params.draft.setRotateWhenQueuedBlocksSettle(true); - } - }) - : undefined, - onReasoningEnd: params.draft.reasoningLane.stream - ? () => - params.draft.enqueueEvent(async () => { - params.progress.closeReasoningBurst(); - splitReasoningOnNextStream = params.draft.reasoningLane.hasStreamedMessage; - params.progress.reset(); - }) - : () => params.progress.closeReasoningBurst(), - suppressDefaultToolProgressMessages: - !params.draft.streamDeliveryEnabled || Boolean(params.draft.answerLane.stream), - forceToolResultProgress: - params.streamMode === "progress" && - resolveChannelStreamingPreviewToolProgress(params.telegramCfg), - allowProgressCallbacksWhenSourceDeliverySuppressed: - !isRoomEvent && Boolean(params.draft.answerLane.stream), - onVerboseProgressVisibility: (isActive) => { - params.progress.setVerboseProgressActive(isActive); - }, - commentaryProgressEnabled: - params.streamMode === "progress" - ? params.progress.commentaryProgressEnabled - : undefined, - progressPreambleEnabled: params.progress.progressPreambleEnabled, - reasoningPayloadsEnabled: params.draft.durableReasoningPayloadsEnabled, - onToolStart: params.progress.handleToolStart, - onItemEvent: params.progress.handleItemEvent, - onPlanUpdate: params.progress.handlePlanUpdate, - onApprovalEvent: params.progress.handleApprovalEvent, - onToolResult: async (payload) => { - const text = payload.text?.trim(); - if (!text) { - return; - } - const updatedDraft = await params.progress.pushToolProgress(text, { - startImmediately: true, - }); - if ( - !updatedDraft && - isFastModeAutoProgressPayload(payload) && - !params.progress.canPushToolProgress() - ) { - await params.delivery.sendPayload(payload); - } - }, - onCommandOutput: params.progress.handleCommandOutput, - onPatchSummary: params.progress.handlePatchSummary, - onCompactionStart: params.statusReactionController - ? async () => { - await params.statusReactionController?.setCompacting(); + await params.draft.ingestDraftLaneSegments(payload, true); + }) + : params.draft.streamReasoningInProgressDraft + ? (payload) => + params.draft.enqueueEvent(async () => { + await params.progress.pushReasoningProgress(payload); + }) + : undefined, + onReasoningProgress: params.draft.answerLane.stream + ? (payload) => + params.draft.enqueueEvent(async () => { + await params.progress.pushThinkingTokenProgress(payload.progressTokens); + }) + : undefined, + onAssistantMessageStart: params.draft.answerLane.stream + ? () => + params.draft.enqueueEvent(async () => { + params.reply.reasoningStepState.resetForNextStep(); + params.progress.setFinalAnswerDelivered(false); + if (params.streamMode !== "progress") { + params.progress.reset(); } - : undefined, - onCompactionEnd: params.statusReactionController - ? async () => { - params.statusReactionController?.cancelPending(); - await params.statusReactionController?.setThinking(); + if (params.draft.answerLane.finalized) { + await params.draft.rotateLaneForNewMessage(params.draft.answerLane); + params.draft.setRotateWhenQueuedBlocksSettle(false); + } else if ( + params.draft.answerLane.hasStreamedMessage && + !params.draft.isAnswerToolProgressOnly() + ) { + params.draft.setRotateWhenQueuedBlocksSettle(true); } - : undefined, - onModelSelected, - }, - }), + }) + : undefined, + onReasoningEnd: params.draft.reasoningLane.stream + ? () => + params.draft.enqueueEvent(async () => { + params.progress.closeReasoningBurst(); + splitReasoningOnNextStream = params.draft.reasoningLane.hasStreamedMessage; + params.progress.reset(); + }) + : () => params.progress.closeReasoningBurst(), + suppressDefaultToolProgressMessages: + !params.draft.streamDeliveryEnabled || Boolean(params.draft.answerLane.stream), + forceToolResultProgress: + params.streamMode === "progress" && + resolveChannelStreamingPreviewToolProgress(params.telegramCfg), + allowProgressCallbacksWhenSourceDeliverySuppressed: + !isRoomEvent && Boolean(params.draft.answerLane.stream), + onVerboseProgressVisibility: (isActive) => { + params.progress.setVerboseProgressActive(isActive); + }, + commentaryProgressEnabled: + params.streamMode === "progress" + ? params.progress.commentaryProgressEnabled + : undefined, + progressPreambleEnabled: params.progress.progressPreambleEnabled, + reasoningPayloadsEnabled: params.draft.durableReasoningPayloadsEnabled, + onToolStart: params.progress.handleToolStart, + onItemEvent: params.progress.handleItemEvent, + onPlanUpdate: params.progress.handlePlanUpdate, + onApprovalEvent: params.progress.handleApprovalEvent, + onToolResult: async (payload) => { + const text = payload.text?.trim(); + if (!text) { + return; + } + const updatedDraft = await params.progress.pushToolProgress(text, { + startImmediately: true, + }); + if ( + !updatedDraft && + isFastModeAutoProgressPayload(payload) && + !params.progress.canPushToolProgress() + ) { + await params.delivery.sendPayload(payload); + } + }, + onCommandOutput: params.progress.handleCommandOutput, + onPatchSummary: params.progress.handlePatchSummary, + onCompactionStart: params.statusReactionController + ? async () => { + await params.statusReactionController?.setCompacting(); + } + : undefined, + onCompactionEnd: params.statusReactionController + ? async () => { + params.statusReactionController?.cancelPending(); + await params.statusReactionController?.setThinking(); + } + : undefined, + onModelSelected, + }, }), }, }); diff --git a/extensions/telegram/src/bot-message-dispatch.test-harness.ts b/extensions/telegram/src/bot-message-dispatch.test-harness.ts index 1e0ad686ed4..47c3f222109 100644 --- a/extensions/telegram/src/bot-message-dispatch.test-harness.ts +++ b/extensions/telegram/src/bot-message-dispatch.test-harness.ts @@ -176,28 +176,53 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { }; 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, - }; - }, - }, + runChannelInboundEvent: async (params: RunParams) => { + const input = await params.adapter.ingest(params.raw); + if (!input) { + return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false }; + } + const eventClass = (await params.adapter.classify?.(input)) ?? { + kind: "message" as const, + canStartAgentTurn: true, + }; + const preflight = (await params.adapter.preflight?.(input, eventClass)) ?? {}; + const resolved = await params.adapter.resolveTurn( + input, + eventClass, + "kind" in preflight ? { admission: preflight } : preflight, + ); + if (!("route" in resolved) || !("delivery" in resolved)) { + throw new Error("expected assembled Telegram channel turn plan"); + } + const testTurn = (params.raw as { turn: TestTurn }).turn; + const result = await actual.runPreparedInboundReply({ + channel: resolved.channel, + accountId: resolved.accountId, + routeSessionKey: resolved.route.sessionKey, + storePath: testTurn.storePath, + ctxPayload: resolved.ctxPayload, + recordInboundSession: testTurn.recordInboundSession, + afterRecord: resolved.afterRecord, + record: resolved.record, + history: resolved.history, + admission: resolved.admission, + botLoopProtection: resolved.botLoopProtection, + runDispatch: async () => + await dispatchReplyWithBufferedBlockDispatcherHoisted({ + ctx: resolved.ctxPayload, + cfg: resolved.cfg, + dispatcherOptions: { + ...resolved.dispatcherOptions, + deliver: resolved.delivery.deliver, + onError: resolved.delivery.onError, + }, + toolsAllow: resolved.toolsAllow, + replyOptions: resolved.replyOptions, + replyResolver: resolved.replyResolver, + }), }); + await params.adapter.onFinalize?.(result); + return result; }, }; }); diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts index d90502acee4..e1a64a39434 100644 --- a/extensions/telegram/src/bot-message.ts +++ b/extensions/telegram/src/bot-message.ts @@ -262,7 +262,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep if (!spooledReplay) { await turnContext.onDispatchStart?.(); } - const runDispatch = async (params: { + const runTelegramDispatch = async (params: { turnAdoptionLifecycle?: { admission?: "exclusive" | "cancel-only"; onAdopted: () => void | Promise; @@ -402,7 +402,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep } return AbortSignal.any([participant.abortSignal, ...extras]); })(); - const result = await runDispatch({ + const result = await runTelegramDispatch({ turnAdoptionLifecycle: { admission: "exclusive", abortSignal: turnAbortSignal, @@ -509,6 +509,6 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep return await participant.task; } - return await runDispatch({}); + return await runTelegramDispatch({}); }; }; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts index 02dbc6589fc..6f2920900a6 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts @@ -1,7 +1,6 @@ // Whatsapp plugin module implements inbound dispatch behavior. export { createChannelMessageReplyPipeline, - dispatchReplyWithBufferedBlockDispatcher, getAgentScopedMediaLocalRoots, jidToE164, logVerbose, diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index b1a0e7a665e..97bdec935d2 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -21,8 +21,8 @@ type CapturedDispatchParams = { payload: CapturedReplyPayload, info: { kind: "tool" | "block" | "final" }, ) => Promise; - onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void; - onSettled?: () => Promise; + onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => Promise | void; + onSettled?: () => unknown; }; replyOptions?: { disableBlockStreaming?: boolean; @@ -121,7 +121,7 @@ vi.mock("./runtime-api.js", async () => { import { buildWhatsAppInboundContext, - dispatchWhatsAppBufferedReply, + createWhatsAppReplyPlan, resolveWhatsAppDmRouteTarget, resolveWhatsAppResponsePrefix, updateWhatsAppMainLastRoute, @@ -275,7 +275,7 @@ function expectRememberSentContextFields( expectRecordFields(requireRecord(call?.[1], "remember sent context"), fields); } -type BufferedReplyParams = Parameters[0]; +type BufferedReplyParams = Parameters[0]; type BufferedReplyOverrides = Partial> & { context?: Partial; }; @@ -336,13 +336,29 @@ async function dispatchBufferedReply(overrides: BufferedReplyOverrides = {}) { shouldClearGroupHistory: false, }; - return dispatchWhatsAppBufferedReply({ + return runWhatsAppReplyPlan({ ...params, ...overrides, context: finalizedContext({ ...params.context, ...overrides.context }), }); } +async function runWhatsAppReplyPlan(params: BufferedReplyParams): Promise { + const plan = createWhatsAppReplyPlan(params); + const dispatchResult = await dispatchReplyWithBufferedBlockDispatcherMock({ + ctx: params.context, + dispatcherOptions: { + ...plan.dispatcherOptions, + deliver: plan.delivery.deliver as NonNullable< + NonNullable["deliver"] + >, + onError: plan.delivery.onError, + }, + replyOptions: plan.replyOptions, + }); + return plan.finalize(dispatchResult); +} + describe("whatsapp inbound dispatch", () => { beforeEach(() => { capturedDispatchParams = undefined; @@ -1400,7 +1416,7 @@ describe("whatsapp inbound dispatch", () => { ); await expect( - dispatchWhatsAppBufferedReply({ + runWhatsAppReplyPlan({ cfg: { channels: { whatsapp: { streaming: { block: { enabled: true } } } } } as never, connectionId: "conn", context: finalizedContext({ Body: "hi" }), @@ -1469,7 +1485,7 @@ describe("whatsapp inbound dispatch", () => { replyLogger, }); - getCapturedOnError()?.(error, { kind: "final" }); + await getCapturedOnError()?.(error, { kind: "final" }); expect(replyLogger["error"]).toHaveBeenCalledWith( { @@ -1519,7 +1535,7 @@ describe("whatsapp inbound dispatch", () => { replyLogger, }); - getCapturedOnError()?.(error, { kind: "final" }); + await getCapturedOnError()?.(error, { kind: "final" }); expect(replyLogger["error"]).toHaveBeenCalledWith( expect.objectContaining({ @@ -1558,7 +1574,7 @@ describe("whatsapp inbound dispatch", () => { replyLogger, }); - getCapturedOnError()?.("plain string rejection", { kind: "block" }); + await getCapturedOnError()?.("plain string rejection", { kind: "block" }); expect(replyLogger["error"]).toHaveBeenCalledWith( expect.objectContaining({ @@ -1596,7 +1612,7 @@ describe("whatsapp inbound dispatch", () => { attempt: 2, }; - getCapturedOnError()?.(objectRejection, { kind: "tool" }); + await getCapturedOnError()?.(objectRejection, { kind: "tool" }); expect(replyLogger["error"]).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 8e96c83db52..9f6e9460cc5 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -6,6 +6,7 @@ import { import { buildChannelInboundEventContext, type CommandFacts, + type ChannelInboundTurnPlan, toInboundMediaFacts, } from "openclaw/plugin-sdk/channel-inbound"; import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound"; @@ -29,7 +30,6 @@ import { formatGroupMembers } from "./group-members.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; import { createChannelMessageReplyPipeline, - dispatchReplyWithBufferedBlockDispatcher, getAgentScopedMediaLocalRoots, jidToE164, logVerbose, @@ -489,7 +489,7 @@ export function updateWhatsAppMainLastRoute(params: { } } -export async function dispatchWhatsAppBufferedReply(params: { +export function createWhatsAppReplyPlan(params: { cfg: ReturnType; connectionId: string; context: FinalizedMsgContext; @@ -615,195 +615,200 @@ export async function dispatchWhatsAppBufferedReply(params: { }, }); - if (statusReactionController) { - void statusReactionController.setThinking(); - } - - const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({ - ctx: params.context, - cfg: params.cfg, - replyResolver: params.replyResolver, - dispatcherOptions: { - ...params.replyPipeline, - onHeartbeatStrip: () => { - if (!didLogHeartbeatStrip) { - didLogHeartbeatStrip = true; - logVerbose("Stripped stray HEARTBEAT_OK token from web reply"); - } - }, - deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => { - const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info); - if (!deliveryPayload) { - return whatsAppReplyDeliveryVisibility(false); - } - const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, { - normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation, - }); - const normalizedDeliveryPayload = - deliveryPayload.text === undefined - ? { ...normalizedOutboundPayload, text: undefined } - : normalizedOutboundPayload; - const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload); - if (!reply.hasMedia && !reply.text.trim()) { - return whatsAppReplyDeliveryVisibility(false); - } - if (!reply.hasMedia) { - const flushResult = await mediaOnlyCoalescer.flushAll(); - logWhatsAppMediaOnlyFlushResult(flushResult); - try { - const durable = await deliverInboundReplyWithMessageSendContext({ - cfg: params.cfg, - channel: "whatsapp", - accountId: params.route.accountId, - agentId: params.route.agentId, - ctxPayload: params.context, - payload: normalizedDeliveryPayload, - info, - to: conversationId, - replyToId: resolveWhatsAppDurableReplyToId({ - context: params.context, - info, - msg: params.msg, - payload: normalizedDeliveryPayload, - }), - formatting: { - textLimit, - tableMode, - chunkMode, - }, - }); - if (durable.status === "failed") { - if (durable.sentBeforeError === true) { - throw markWhatsAppVisibleDeliveryError(durable.error); - } - throw durable.error; - } - if (durable.status === "handled_visible") { - didSendReply = true; - const shouldLog = normalizedDeliveryPayload.text ? true : undefined; - params.rememberSentText(normalizedDeliveryPayload.text, { - combinedBody: params.context.Body as string | undefined, - combinedBodySessionKey: params.route.sessionKey, - logVerboseMessage: shouldLog, - }); - return whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery); - } - if (durable.status === "handled_no_send") { - return flushResult.delivered > 0 - ? whatsAppReplyDeliveryVisibility(true) - : whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery); - } - const delivery = await deliverNormalizedPayload(normalizedDeliveryPayload, info); - return flushResult.delivered > 0 && !delivery.visibleReplySent - ? whatsAppReplyDeliveryVisibility(true) - : delivery; - } catch (error: unknown) { - throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult); - } - } - const mediaUrls = getWhatsAppPayloadMediaUrls(normalizedDeliveryPayload); - if (shouldDeferWhatsAppMediaOnlyPayload({ info, mediaUrls, reply })) { - mediaOnlyCoalescer.defer({ - info, - mediaUrls, - payload: normalizedDeliveryPayload, - }); - return whatsAppReplyDeliveryVisibility(false); - } - const flushResult = await mediaOnlyCoalescer.flushExceptDuplicateMedia(mediaUrls); + const dispatcherOptions: NonNullable = { + ...params.replyPipeline, + onHeartbeatStrip: () => { + if (!didLogHeartbeatStrip) { + didLogHeartbeatStrip = true; + logVerbose("Stripped stray HEARTBEAT_OK token from web reply"); + } + }, + onSettled: async () => { + const flushResult = await mediaOnlyCoalescer.flushAll(); + logWhatsAppMediaOnlyFlushResult(flushResult); + return whatsAppReplyDeliveryVisibility(flushResult.delivered > 0); + }, + onReplyStart: params.msg.platform.sendComposing, + }; + const delivery: ChannelInboundTurnPlan["delivery"] = { + deliver: async (payload: ReplyPayload, info: { kind: ReplyLifecycleKind }) => { + const deliveryPayload = resolveWhatsAppDeliverablePayload(payload, info); + if (!deliveryPayload) { + return whatsAppReplyDeliveryVisibility(false); + } + const normalizedOutboundPayload = normalizeWhatsAppOutboundPayload(deliveryPayload, { + normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation, + }); + const normalizedDeliveryPayload = + deliveryPayload.text === undefined + ? { ...normalizedOutboundPayload, text: undefined } + : normalizedOutboundPayload; + const reply = resolveSendableOutboundReplyParts(normalizedDeliveryPayload); + if (!reply.hasMedia && !reply.text.trim()) { + return whatsAppReplyDeliveryVisibility(false); + } + if (!reply.hasMedia) { + const flushResult = await mediaOnlyCoalescer.flushAll(); logWhatsAppMediaOnlyFlushResult(flushResult); try { - const delivery = await deliverNormalizedPayload(normalizedDeliveryPayload, info); - return flushResult.delivered > 0 && !delivery.visibleReplySent + const durable = await deliverInboundReplyWithMessageSendContext({ + cfg: params.cfg, + channel: "whatsapp", + accountId: params.route.accountId, + agentId: params.route.agentId, + ctxPayload: params.context, + payload: normalizedDeliveryPayload, + info, + to: conversationId, + replyToId: resolveWhatsAppDurableReplyToId({ + context: params.context, + info, + msg: params.msg, + payload: normalizedDeliveryPayload, + }), + formatting: { + textLimit, + tableMode, + chunkMode, + }, + }); + if (durable.status === "failed") { + if (durable.sentBeforeError === true) { + throw markWhatsAppVisibleDeliveryError(durable.error); + } + throw durable.error; + } + if (durable.status === "handled_visible") { + didSendReply = true; + const shouldLog = normalizedDeliveryPayload.text ? true : undefined; + params.rememberSentText(normalizedDeliveryPayload.text, { + combinedBody: params.context.Body as string | undefined, + combinedBodySessionKey: params.route.sessionKey, + logVerboseMessage: shouldLog, + }); + return whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery); + } + if (durable.status === "handled_no_send") { + return flushResult.delivered > 0 + ? whatsAppReplyDeliveryVisibility(true) + : whatsAppReplyDeliveryVisibilityFromDurableResult(durable.delivery); + } + const deliveryResult = await deliverNormalizedPayload(normalizedDeliveryPayload, info); + return flushResult.delivered > 0 && !deliveryResult.visibleReplySent ? whatsAppReplyDeliveryVisibility(true) - : delivery; + : deliveryResult; } catch (error: unknown) { throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult); } - }, - onSettled: async () => { - const flushResult = await mediaOnlyCoalescer.flushAll(); - logWhatsAppMediaOnlyFlushResult(flushResult); - return whatsAppReplyDeliveryVisibility(flushResult.delivered > 0); - }, - onReplyStart: params.msg.platform.sendComposing, - ...(statusReactionController - ? { - onCompactionStart: async () => { - await statusReactionController.setCompacting(); - }, - onCompactionEnd: async () => { - statusReactionController.cancelPending(); - await statusReactionController.setThinking(); - }, - } - : {}), - onError: (err, info) => { - logWhatsAppReplyDeliveryError({ - err, + } + const mediaUrls = getWhatsAppPayloadMediaUrls(normalizedDeliveryPayload); + if (shouldDeferWhatsAppMediaOnlyPayload({ info, mediaUrls, reply })) { + mediaOnlyCoalescer.defer({ info, - connectionId: params.connectionId, - msg: params.msg, - replyLogger: params.replyLogger, + mediaUrls, + payload: normalizedDeliveryPayload, }); - }, + return whatsAppReplyDeliveryVisibility(false); + } + const flushResult = await mediaOnlyCoalescer.flushExceptDuplicateMedia(mediaUrls); + logWhatsAppMediaOnlyFlushResult(flushResult); + try { + const deliveryResult = await deliverNormalizedPayload(normalizedDeliveryPayload, info); + return flushResult.delivered > 0 && !deliveryResult.visibleReplySent + ? whatsAppReplyDeliveryVisibility(true) + : deliveryResult; + } catch (error: unknown) { + throw markWhatsAppReplyDeliveryErrorVisibleAfterFlush(error, flushResult); + } }, - replyOptions: { - // Message-tool-only unmentioned group turns have no automatic visible reply. - // Suppress composing there so silent background runs do not leak presence. - suppressTyping: - sourceRepliesAreToolOnly && - conversationKind === "group" && - !(params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned), - disableBlockStreaming, - ...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}), - onModelSelected: params.onModelSelected, - ...(statusReactionController - ? { - onToolStart: async (payload: { name?: string }) => { - const toolName = payload.name?.trim(); - if (toolName) { - await statusReactionController.setTool(toolName); - } - }, - } - : {}), - }, - }); - const didQueueVisibleReply = hasVisibleInboundReplyDispatch(dispatchResult); - const didDeliverVisibleReply = didSendReply || dispatchResult.observedReplyDelivery === true; - if (!didQueueVisibleReply) { - if (statusReactionController) { - void finalizeWhatsAppStatusReaction({ - controller: statusReactionController, - outcome: "error", - hasFinalResponse: false, - removeAckAfterReply, - timing: statusReactionTiming, + onError: (err, info) => { + logWhatsAppReplyDeliveryError({ + err, + info: info as ReplyDeliveryInfo, + connectionId: params.connectionId, + msg: params.msg, + replyLogger: params.replyLogger, }); - } - if (params.shouldClearGroupHistory) { - params.groupHistories.set(params.groupHistoryKey, []); - } - logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver"); - return false; - } + }, + }; + const replyOptions = { + // Message-tool-only unmentioned group turns have no automatic visible reply. + // Suppress composing there so silent background runs do not leak presence. + suppressTyping: + sourceRepliesAreToolOnly && + conversationKind === "group" && + !(params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned), + disableBlockStreaming, + ...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}), + onModelSelected: params.onModelSelected, + ...(statusReactionController + ? { + onToolStart: async (payload: { name?: string }) => { + const toolName = payload.name?.trim(); + if (toolName) { + await statusReactionController.setTool(toolName); + } + }, + onCompactionStart: async () => { + await statusReactionController.setCompacting(); + }, + onCompactionEnd: async () => { + statusReactionController.cancelPending(); + await statusReactionController.setThinking(); + }, + } + : {}), + }; - if (statusReactionController) { - void finalizeWhatsAppStatusReaction({ - controller: statusReactionController, - outcome: didDeliverVisibleReply ? "done" : "error", - hasFinalResponse: didDeliverVisibleReply, - removeAckAfterReply, - timing: statusReactionTiming, - }); - } + return { + afterRecord: () => { + if (statusReactionController) { + void statusReactionController.setThinking(); + } + }, + dispatcherOptions, + delivery, + replyOptions, + replyResolver: params.replyResolver, + finalize: (dispatchResult: { + observedReplyDelivery?: boolean; + queuedFinal?: boolean; + counts?: Partial>; + }): boolean => { + const didQueueVisibleReply = hasVisibleInboundReplyDispatch(dispatchResult); + const didDeliverVisibleReply = didSendReply || dispatchResult.observedReplyDelivery === true; + if (!didQueueVisibleReply) { + if (statusReactionController) { + void finalizeWhatsAppStatusReaction({ + controller: statusReactionController, + outcome: "error", + hasFinalResponse: false, + removeAckAfterReply, + timing: statusReactionTiming, + }); + } + if (params.shouldClearGroupHistory) { + params.groupHistories.set(params.groupHistoryKey, []); + } + logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver"); + return false; + } - if (params.shouldClearGroupHistory) { - params.groupHistories.set(params.groupHistoryKey, []); - } - - return didDeliverVisibleReply; + if (statusReactionController) { + void finalizeWhatsAppStatusReaction({ + controller: statusReactionController, + outcome: didDeliverVisibleReply ? "done" : "error", + hasFinalResponse: didDeliverVisibleReply, + removeAckAfterReply, + timing: statusReactionTiming, + }); + } + if (params.shouldClearGroupHistory) { + params.groupHistories.set(params.groupHistoryKey, []); + } + return didDeliverVisibleReply; + }, + }; } async function finalizeWhatsAppStatusReaction(params: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts index 1854f2f086e..408f589768f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts @@ -110,13 +110,19 @@ vi.mock("./inbound-dispatch.js", () => ({ RawBody: params.rawBody ?? params.msg.payload.body, Transcript: params.transcript, }), - dispatchWhatsAppBufferedReply: vi.fn(async () => true), + createWhatsAppReplyPlan: vi.fn((params: { replyResolver?: unknown }) => ({ + dispatcherOptions: {}, + delivery: { deliver: async () => {} }, + replyOptions: {}, + replyResolver: params.replyResolver, + finalize: () => true, + })), resolveWhatsAppDmRouteTarget: () => "+15550000002", resolveWhatsAppResponsePrefix: () => undefined, updateWhatsAppMainLastRoute: () => {}, })); -import { dispatchWhatsAppBufferedReply } from "./inbound-dispatch.js"; +import { createWhatsAppReplyPlan } from "./inbound-dispatch.js"; import { processMessage } from "./process-message.js"; type WebInboundMsg = Parameters[0]["msg"]; @@ -227,7 +233,7 @@ function firstTranscriptionContext(): Record { } function firstDispatchContext(): Record { - const calls = vi.mocked(dispatchWhatsAppBufferedReply).mock.calls as unknown[][]; + const calls = vi.mocked(createWhatsAppReplyPlan).mock.calls as unknown[][]; const dispatch = calls[0]?.[0] as { context?: Record } | undefined; if (!dispatch?.context) { throw new Error("expected WhatsApp dispatch context"); @@ -248,7 +254,7 @@ describe("processMessage audio preflight transcription", () => { maybeSendAckReactionMock.mockResolvedValue(null); shouldComputeCommandResult = false; shouldComputeCommandBodies = []; - vi.mocked(dispatchWhatsAppBufferedReply).mockClear(); + vi.mocked(createWhatsAppReplyPlan).mockClear(); }); it("replaces body with transcript when transcription succeeds", async () => { @@ -419,7 +425,13 @@ describe("processMessage audio preflight transcription", () => { it("keeps ack when no visible reply was delivered", async () => { const ackReaction = makeAckReactionHandle(); maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction); - vi.mocked(dispatchWhatsAppBufferedReply).mockResolvedValueOnce(false); + vi.mocked(createWhatsAppReplyPlan).mockReturnValueOnce({ + dispatcherOptions: {}, + delivery: { deliver: async () => {} }, + replyOptions: {}, + replyResolver: vi.fn(), + finalize: () => false, + } as never); await processMessage(makeRemoveAckAfterReplyParams()); await flushMicrotasks(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index 8fa449dcb02..5426b47b8d9 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -16,7 +16,7 @@ const { resolvePolicyMock: vi.fn(), buildContextMock: vi.fn(), isControlCommandMessageMock: vi.fn(() => false), - dispatchBufferedReplyMock: vi.fn(async () => ({ + dispatchBufferedReplyMock: vi.fn(async (_params?: unknown) => ({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, })), @@ -39,7 +39,17 @@ vi.mock("./inbound-dispatch.js", async (importOriginal) => { return { ...actual, buildWhatsAppInboundContext: buildContextMock, - dispatchWhatsAppBufferedReply: dispatchBufferedReplyMock, + createWhatsAppReplyPlan: (...args: unknown[]) => { + const params = args[0] as { replyResolver?: unknown }; + void dispatchBufferedReplyMock(params); + return { + dispatcherOptions: {}, + delivery: { deliver: async () => {} }, + replyOptions: {}, + replyResolver: params.replyResolver, + finalize: () => true, + }; + }, resolveWhatsAppDmRouteTarget: () => null, resolveWhatsAppResponsePrefix: () => undefined, updateWhatsAppMainLastRoute: () => {}, @@ -422,6 +432,7 @@ describe("processMessage group system prompt wiring", () => { Timestamp: 1710000000, Provider: "whatsapp", Surface: "whatsapp", + SuppressMessageReceivedHooks: true, OriginatingChannel: "whatsapp", OriginatingTo: GROUP_JID, GroupSubject: "Test Group", diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 50860bd2723..07f178e1053 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -40,7 +40,7 @@ import { } from "./inbound-context.js"; import { buildWhatsAppInboundContext, - dispatchWhatsAppBufferedReply, + createWhatsAppReplyPlan, resolveWhatsAppDmRouteTarget, resolveWhatsAppResponsePrefix, updateWhatsAppMainLastRoute, @@ -509,6 +509,7 @@ export async function processMessage(params: { updateLastRoute: updateLastRouteInBackground, warn: params.replyLogger.warn.bind(params.replyLogger), }); + let finalizeReply: ReturnType["finalize"] | undefined; const turnResult = await runChannelInboundEvent({ channel: "whatsapp", @@ -542,54 +543,59 @@ export async function processMessage(params: { }, }; }, - resolveTurn: () => ({ - cfg: params.cfg, - channel: "whatsapp", - accountId: params.route.accountId, - route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey }, - ctxPayload, - record: { - onRecordError: (err) => { - params.replyLogger.warn( - { - error: formatError(err), - storePath, - sessionKey: params.route.sessionKey, - }, - "failed updating session meta", - ); + resolveTurn: () => { + const { finalize, ...replyPlan } = createWhatsAppReplyPlan({ + cfg: params.cfg, + connectionId: params.connectionId, + context: ctxPayload, + deliverReply: deliverWebReply, + groupHistories: params.groupHistories, + groupHistoryKey: params.groupHistoryKey, + maxMediaBytes: params.maxMediaBytes, + maxMediaTextChunkLimit: params.maxMediaTextChunkLimit, + msg: params.msg, + onModelSelected, + rememberSentText: params.rememberSentText, + replyLogger: params.replyLogger, + replyPipeline: { + ...replyPipeline, + responsePrefix, }, - trackSessionMetaTask: (task) => { - trackBackgroundTask(params.backgroundTasks, task); - }, - }, - runDispatch: () => - dispatchWhatsAppBufferedReply({ - cfg: params.cfg, - connectionId: params.connectionId, - context: ctxPayload, - deliverReply: deliverWebReply, - groupHistories: params.groupHistories, - groupHistoryKey: params.groupHistoryKey, - maxMediaBytes: params.maxMediaBytes, - maxMediaTextChunkLimit: params.maxMediaTextChunkLimit, - msg: params.msg, - onModelSelected, - rememberSentText: params.rememberSentText, - replyLogger: params.replyLogger, - replyPipeline: { - ...replyPipeline, - responsePrefix, + replyResolver: params.replyResolver, + route: params.route, + shouldClearGroupHistory, + statusReactionController, + }); + finalizeReply = finalize; + return { + cfg: params.cfg, + channel: "whatsapp", + accountId: params.route.accountId, + route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey }, + ctxPayload, + record: { + onRecordError: (err) => { + params.replyLogger.warn( + { + error: formatError(err), + storePath, + sessionKey: params.route.sessionKey, + }, + "failed updating session meta", + ); }, - replyResolver: params.replyResolver, - route: params.route, - shouldClearGroupHistory, - statusReactionController, - }), - }), + trackSessionMetaTask: (task) => { + trackBackgroundTask(params.backgroundTasks, task); + }, + }, + ...replyPlan, + }; + }, }, }); - const didSendReply = turnResult.dispatched ? turnResult.dispatchResult : false; + const didSendReply = turnResult.dispatched + ? (finalizeReply?.(turnResult.dispatchResult) ?? false) + : false; removeAckReactionHandleAfterReply({ removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply), ackReaction, diff --git a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts index 10ad4a2cd1f..40e260fd495 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts @@ -20,7 +20,6 @@ export { } from "openclaw/plugin-sdk/reply-history"; export { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; export { - dispatchReplyWithBufferedBlockDispatcher, resolveChunkMode, resolveTextChunkLimit, type getReplyFromConfig, diff --git a/scripts/check-no-deprecated-channel-access.ts b/scripts/check-no-deprecated-channel-access.ts index 7bcb26cb22c..7db43ffb914 100644 --- a/scripts/check-no-deprecated-channel-access.ts +++ b/scripts/check-no-deprecated-channel-access.ts @@ -15,6 +15,15 @@ const RULES: Rule[] = [ pattern: /\.channel\.(?:reply\.(?:createReplyDispatcherWithTyping|resolveHumanDelayConfig|dispatchReplyFromConfig|finalizeInboundContext|formatInboundEnvelope)|session\.(?:resolveStorePath|recordInboundSession)|inbound\.(?:runPreparedReply|dispatchReply)|media\.fetchRemoteMedia)\b/u, }, + { + label: "caller-owned prepared channel dispatch", + pattern: /\b(?:runDispatch|onPreDispatchFailure)\b/u, + }, + { + label: "caller-owned reply dispatcher lifecycle", + pattern: + /\b(?:createReplyDispatcherWithTyping|dispatchInboundMessage(?:WithBufferedDispatcher|WithDispatcher)?|dispatchReplyFromConfigWithSettledDispatcher|settleReplyDispatcher)\s*\(/u, + }, { label: "deprecated channel ingress resolver aliases", pattern: diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 2cbd8ce2365..678f9702896 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -280,6 +280,34 @@ describe("withReplyDispatcher", () => { expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); }); + it("composes channel and dispatcher typing-controller observers", async () => { + const dispatcherObserver = vi.fn(); + const channelObserver = vi.fn(); + hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ + dispatcher: createDispatcher([]), + replyOptions: { onTypingController: dispatcherObserver }, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + }); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }); + + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: buildTestCtx(), + cfg: {} as OpenClawConfig, + dispatcherOptions: { deliver: async () => undefined }, + replyOptions: { onTypingController: channelObserver }, + }); + + const typingController = {} as never; + const dispatchParams = hoisted.dispatchReplyFromConfigMock.mock.calls[0]?.[0]; + dispatchParams?.replyOptions?.onTypingController?.(typingController); + expect(dispatcherObserver).toHaveBeenCalledWith(typingController); + expect(channelObserver).toHaveBeenCalledWith(typingController); + }); + it("passes runtime toolsAllow from buffered dispatch into reply resolution", async () => { hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ dispatcher: createDispatcher([]), diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 92957c7f0a5..7d5daad4bf0 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -651,6 +651,12 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { beforeDeliver, silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, }); + const onTypingController = params.replyOptions?.onTypingController + ? (typing: Parameters>[0]) => { + replyOptions.onTypingController?.(typing); + params.replyOptions?.onTypingController?.(typing); + } + : replyOptions.onTypingController; markReplyPayloadSendingBeforeDeliverInstalled(dispatcher, replyPayloadBeforeDeliver); try { return await dispatchInboundMessage({ @@ -662,6 +668,7 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { replyOptions: { ...params.replyOptions, ...replyOptions, + onTypingController, onReplyAdmissionWaitChange: (waiting) => { // A turn waiting to own the lane cannot make the current owner's reply stale. // Suspend only that generation so independent newer turns still fence old replies. diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 6ae07db06db..c19198872a5 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -476,10 +476,14 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis await options.deliver(deliverPayload, dispatchInfo); deliveryOutcome = "delivered"; }) - .catch((err: unknown) => { + .catch(async (err: unknown) => { deliveryOutcome = deliveryStarted ? "failed-deliver" : "failed-before-deliver"; failedCounts[kind] += 1; - void options.onError?.(err, buildReplyDispatchRuntimeInfo(normalized, kind)); + // Error cleanup belongs to this send: idle/finalization must not race it. + // Observer failures stay isolated from later queued deliveries. + try { + await options.onError?.(err, buildReplyDispatchRuntimeInfo(normalized, kind)); + } catch {} }) .finally(() => { const dispatchInfo = buildReplyDispatchRuntimeInfo(normalized, kind); diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 566d0bbdbe2..ca7a990d510 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -166,6 +166,31 @@ describe("createReplyDispatcher", () => { expect(delivered).toEqual(["tool", "block", "final"]); }); + it("waits for asynchronous delivery error cleanup before becoming idle", async () => { + const cleanup = createDeferred(); + const order: string[] = []; + const dispatcher = createReplyDispatcher({ + deliver: async () => { + throw new Error("delivery failed"); + }, + onError: async () => { + order.push("cleanup-start"); + await cleanup.promise; + order.push("cleanup-end"); + }, + }); + + dispatcher.sendFinalReply({ text: "final" }); + const idle = dispatcher.waitForIdle().then(() => { + order.push("idle"); + }); + await vi.waitFor(() => expect(order).toEqual(["cleanup-start"])); + + cleanup.resolve(); + await idle; + expect(order).toEqual(["cleanup-start", "cleanup-end", "idle"]); + }); + it("releases the same dispatcher after a beforeDeliver timeout", async () => { vi.useFakeTimers(); try { diff --git a/src/channels/direct-dm.ts b/src/channels/direct-dm.ts index 2f2e6da0053..8eda0e82f5e 100644 --- a/src/channels/direct-dm.ts +++ b/src/channels/direct-dm.ts @@ -11,7 +11,8 @@ import { resolveInboundRouteEnvelopeBuilderWithRuntime, } from "./inbound-event/envelope.js"; import { createChannelReplyPipeline } from "./message/reply-pipeline.js"; -import { dispatchChannelInboundTurn, runPreparedInboundReply } from "./turn/kernel.js"; +import { dispatchChannelInboundTurn } from "./turn/kernel.js"; +import type { ChannelTurnPlan } from "./turn/types.js"; export { createPreCryptoDirectDmAuthorizer, resolveInboundDirectDmAccessWithRuntime, @@ -118,6 +119,16 @@ export async function dispatchInboundDirectDm(params: DispatchInboundDirectDmPar timestamp: params.timestamp, }), ); + await dispatchChannelInboundTurn(buildDirectDmTurnPlan(params, route, ctxPayload)); + + return { route, ctxPayload }; +} + +function buildDirectDmTurnPlan( + params: DispatchInboundDirectDmParams, + route: DirectDmRoute, + ctxPayload: FinalizedMsgContext, +): ChannelTurnPlan { const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ cfg: params.cfg, agentId: route.agentId, @@ -125,7 +136,7 @@ export async function dispatchInboundDirectDm(params: DispatchInboundDirectDmPar accountId: route.accountId ?? params.accountId, }); - await dispatchChannelInboundTurn({ + return { cfg: params.cfg, channel: params.channel, accountId: route.accountId ?? params.accountId, @@ -140,9 +151,7 @@ export async function dispatchInboundDirectDm(params: DispatchInboundDirectDmPar }, replyPipeline, replyOptions: { onModelSelected }, - }); - - return { route, ctxPayload }; + }; } export async function dispatchInboundDirectDmWithRuntime( @@ -190,36 +199,22 @@ export async function dispatchInboundDirectDmWithRuntime( NativeDirectUserId: params.peer.id, ...params.extraContext, }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg: params.cfg, - agentId: route.agentId, + const plan = buildDirectDmTurnPlan(params, route, ctxPayload); + await params.runtime.channel.inbound.run({ channel: params.channel, accountId: route.accountId ?? params.accountId, - }); - await runPreparedInboundReply({ - channel: params.channel, - accountId: route.accountId ?? params.accountId, - routeSessionKey: route.sessionKey, - storePath, - ctxPayload, - recordInboundSession: params.runtime.channel.session.recordInboundSession, - record: { onRecordError: params.onRecordError }, - runDispatch: () => - params.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: params.cfg, - dispatcherOptions: { - ...replyPipeline, - deliver: (payload: unknown) => - params.deliver( - payload && typeof payload === "object" - ? normalizeOutboundReplyPayload(payload as Record) - : {}, - ), - onError: params.onDispatchError, - }, - replyOptions: { onModelSelected }, + raw: ctxPayload, + adapter: { + ingest: () => ({ + id: params.messageId, + timestamp: params.timestamp, + rawText: params.rawBody, + textForAgent: params.bodyForAgent, + textForCommands: params.commandBody, + raw: ctxPayload, }), + resolveTurn: () => plan, + }, }); return { route, storePath, ctxPayload }; } diff --git a/src/channels/turn/execution.ts b/src/channels/turn/execution.ts new file mode 100644 index 00000000000..26253fb982a --- /dev/null +++ b/src/channels/turn/execution.ts @@ -0,0 +1,294 @@ +import { clearChannelHistoryIfEnabled } from "../../auto-reply/reply/history.js"; +import type { FinalizedMsgContext } from "../../auto-reply/templating.js"; +import { + createDiagnosticTraceContextFromActiveScope, + runWithDiagnosticTraceContext, +} from "../../infra/diagnostic-trace-context.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { recordChannelBotPairLoopAndCheckSuppression } from "./bot-loop-protection.js"; +import { + EMPTY_CHANNEL_TURN_DISPATCH_COUNTS, + hasVisibleChannelTurnDispatch, + type ChannelTurnDispatchResultLike, + type ChannelTurnVisibleDeliverySignals, +} from "./dispatch-result.js"; +import type { + ChannelTurnAdmission, + ChannelTurnHistoryFinalizeOptions, + ChannelTurnLogEvent, + ChannelTurnResult, + DispatchedChannelTurnResult, + PreparedChannelTurn, +} from "./types.js"; + +const NO_ADDITIONAL_DELIVERY_SIGNALS: ChannelTurnVisibleDeliverySignals = {}; +const log = createSubsystemLogger("channels/turn/execution"); + +function emit(params: { + log?: (event: ChannelTurnLogEvent) => void; + event: Omit; + channel: string; + accountId?: string; +}) { + params.log?.({ + channel: params.channel, + accountId: params.accountId, + ...params.event, + }); +} + +function clearPendingHistoryAfterTurn(params?: ChannelTurnHistoryFinalizeOptions): void { + if (!params?.isGroup || !params.historyKey || !params.historyMap || params.limit === undefined) { + return; + } + clearChannelHistoryIfEnabled({ + historyMap: params.historyMap, + historyKey: params.historyKey, + limit: params.limit, + }); +} + +function resolveObserveOnlyDispatchResult( + params: PreparedChannelTurn, +): TDispatchResult { + return (params.observeOnlyDispatchResult ?? { + queuedFinal: false, + counts: EMPTY_CHANNEL_TURN_DISPATCH_COUNTS, + }) as TDispatchResult; +} + +function isSystemChannelTurn(ctx: FinalizedMsgContext): boolean { + return ( + ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event" + ); +} + +function maybeWarnZeroCountVisibleDispatch( + params: Pick< + PreparedChannelTurn, + "admission" | "channel" | "ctxPayload" | "messageId" | "routeSessionKey" + > & { + dispatchResult: TDispatchResult; + log?: (event: ChannelTurnLogEvent) => void; + }, +): void { + if (params.admission?.kind === "observeOnly" || isSystemChannelTurn(params.ctxPayload)) { + return; + } + const dispatchResult = params.dispatchResult as ChannelTurnDispatchResultLike; + // The canonical visible signal includes observed delivery paths with zero queued counts. + if (hasVisibleChannelTurnDispatch(dispatchResult, NO_ADDITIONAL_DELIVERY_SIGNALS)) { + return; + } + log.warn( + `visible channel turn dispatched with no queued reply payloads: channel=${params.channel} ` + + `messageId=${params.messageId ?? "unknown"} sessionKey=${ + params.ctxPayload.SessionKey ?? params.routeSessionKey + }`, + ); + emit({ + ...params, + event: { + stage: "dispatch", + event: "warning", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: params.admission?.kind ?? "dispatch", + reason: "zero-count-visible-dispatch", + }, + }); +} + +function resolveBotLoopProtectionDrop( + params: PreparedChannelTurn, +): ChannelTurnResult | undefined { + if (!params.botLoopProtection) { + return undefined; + } + const botLoopResult = recordChannelBotPairLoopAndCheckSuppression(params.botLoopProtection); + if (!botLoopResult.suppressed) { + return undefined; + } + const admission: ChannelTurnAdmission = { kind: "drop", reason: "bot-loop-protection" }; + emit({ + ...params, + event: { + stage: "authorize", + event: "drop", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + reason: admission.reason, + }, + }); + return { + admission, + dispatched: false, + ctxPayload: params.ctxPayload, + routeSessionKey: params.routeSessionKey, + }; +} + +export async function runPreparedChannelTurnCore< + TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], +>( + params: PreparedChannelTurn, + options: { suppressObserveOnlyDispatch: boolean }, +): Promise> { + const trace = createDiagnosticTraceContextFromActiveScope(); + return await runWithDiagnosticTraceContext(trace, () => + runPreparedChannelTurnCoreInTrace(params, options), + ); +} + +async function runPreparedChannelTurnCoreInTrace< + TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], +>( + params: PreparedChannelTurn, + options: { suppressObserveOnlyDispatch: boolean }, +): Promise> { + const admission = params.admission ?? ({ kind: "dispatch" } as const); + const botLoopDrop = resolveBotLoopProtectionDrop(params); + if (botLoopDrop) { + clearPendingHistoryAfterTurn(params.history); + return botLoopDrop; + } + emit({ + ...params, + event: { + stage: "record", + event: "start", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + }, + }); + try { + await params.recordInboundSession({ + storePath: params.storePath, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + ctx: params.ctxPayload, + groupResolution: params.record?.groupResolution, + createIfMissing: params.record?.createIfMissing, + updateLastRoute: params.record?.updateLastRoute, + onRecordError: params.record?.onRecordError ?? (() => undefined), + trackSessionMetaTask: params.record?.trackSessionMetaTask, + }); + emit({ + ...params, + event: { + stage: "record", + event: "done", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + }, + }); + await params.afterRecord?.(); + } catch (err) { + emit({ + ...params, + event: { + stage: "record", + event: "error", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + error: err, + }, + }); + try { + await params.onPreDispatchFailure?.(err); + } catch { + // Preserve the original session-recording error. + } + throw err; + } + + emit({ + ...params, + event: { + stage: "dispatch", + event: "start", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + }, + }); + let dispatchResult: TDispatchResult; + try { + if (admission.kind === "observeOnly" && !options.suppressObserveOnlyDispatch) { + await params.runDispatch(); + } + dispatchResult = + admission.kind === "observeOnly" + ? resolveObserveOnlyDispatchResult(params) + : await params.runDispatch(); + maybeWarnZeroCountVisibleDispatch({ + ...params, + admission, + dispatchResult, + }); + } catch (err) { + emit({ + ...params, + event: { + stage: "dispatch", + event: "error", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + error: err, + }, + }); + throw err; + } + emit({ + ...params, + event: { + stage: "dispatch", + event: "done", + messageId: params.messageId, + sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, + admission: admission.kind, + }, + }); + clearPendingHistoryAfterTurn(params.history); + + return { + admission, + dispatched: true, + ctxPayload: params.ctxPayload, + routeSessionKey: params.routeSessionKey, + dispatchResult, + }; +} + +type PreparedChannelTurnWithBotLoopProtection = + PreparedChannelTurn & { + botLoopProtection: NonNullable["botLoopProtection"]>; + }; + +type PreparedChannelTurnWithoutBotLoopProtection = Omit< + PreparedChannelTurn, + "botLoopProtection" +> & { + botLoopProtection?: undefined; +}; + +function runPreparedChannelTurn( + params: PreparedChannelTurnWithBotLoopProtection, +): Promise>; +function runPreparedChannelTurn( + params: PreparedChannelTurnWithoutBotLoopProtection, +): Promise>; +function runPreparedChannelTurn( + params: PreparedChannelTurn, +): Promise>; +async function runPreparedChannelTurn< + TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], +>(params: PreparedChannelTurn): Promise> { + return await runPreparedChannelTurnCore(params, { suppressObserveOnlyDispatch: true }); +} + +export const runPreparedInboundReply = runPreparedChannelTurn; diff --git a/src/channels/turn/kernel.test.ts b/src/channels/turn/kernel.test.ts index e046eb37732..6a1b145b337 100644 --- a/src/channels/turn/kernel.test.ts +++ b/src/channels/turn/kernel.test.ts @@ -34,6 +34,17 @@ import type { PreparedChannelTurn } from "./types.js"; const deliverOutboundPayloads = vi.hoisted(() => vi.fn()); const resolveOutboundDurableFinalDeliverySupport = vi.hoisted(() => vi.fn()); const sendDurableMessageBatch = vi.hoisted(() => vi.fn()); +const recordInboundSessionCore = vi.hoisted(() => vi.fn(async () => undefined)); +const dispatchReplyWithBufferedBlockDispatcherCore = vi.hoisted(() => vi.fn()); + +vi.mock("../../auto-reply/reply/provider-dispatcher.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcherCore, + }; +}); vi.mock("../../infra/outbound/deliver.js", async (importOriginal) => { const actual = await importOriginal(); @@ -52,6 +63,11 @@ vi.mock("../message/send.js", async (importOriginal) => { }; }); +vi.mock("../session.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, recordInboundSession: recordInboundSessionCore }; +}); + const cfg = {} as OpenClawConfig; function createCtx(overrides: Partial = {}): FinalizedMsgContext { @@ -190,6 +206,8 @@ function loggedEvents(log: ReturnType): TurnLogEvent[] { describe("channel turn kernel", () => { beforeEach(() => { vi.clearAllMocks(); + recordInboundSessionCore.mockResolvedValue(undefined); + dispatchReplyWithBufferedBlockDispatcherCore.mockImplementation(createDispatch()); resetDiagnosticEventsForTest(); resetLogger(); setLoggerOverride({ level: "info" }); @@ -1285,7 +1303,11 @@ describe("channel turn kernel", () => { it("drops repeated bot-pair turns in the core turn kernel before record and dispatch", async () => { const events: string[] = []; + dispatchReplyWithBufferedBlockDispatcherCore.mockImplementation(createDispatch(events)); const onFinalize = vi.fn(); + recordInboundSessionCore.mockImplementation(async () => { + events.push("record"); + }); let nowMs = 1_000; const runOne = async (id: string) => await runChannelInboundEvent({ @@ -1295,12 +1317,11 @@ describe("channel turn kernel", () => { adapter: { ingest: () => ({ id, rawText: "hello" }), resolveTurn: () => ({ + cfg, channel: "test", accountId: "acct", - routeSessionKey: "agent:main:test:peer", - storePath: "/tmp/sessions.json", + route: { agentId: "main", sessionKey: "agent:main:test:peer" }, ctxPayload: createCtx(), - recordInboundSession: createRecordInboundSession(events), botLoopProtection: { scopeId: "acct", conversationId: "room", @@ -1310,13 +1331,7 @@ describe("channel turn kernel", () => { defaultEnabled: true, nowMs: nowMs++, }, - runDispatch: async () => { - events.push("custom-dispatch"); - return { - queuedFinal: true, - counts: { tool: 0, block: 0, final: 1 }, - }; - }, + delivery: { deliver: async () => ({ visibleReplySent: true }) }, }), onFinalize, }, @@ -1332,7 +1347,7 @@ describe("channel turn kernel", () => { ctxPayload: createCtx(), routeSessionKey: "agent:main:test:peer", }); - expect(events).toEqual(["record", "custom-dispatch"]); + expect(events).toEqual(["record", "dispatch"]); expect(onFinalize).toHaveBeenCalledTimes(2); const [, suppressed] = onFinalize.mock.calls; expect(suppressed?.[0]).toMatchObject({ @@ -1344,6 +1359,10 @@ describe("channel turn kernel", () => { it("runs observe-only preflights through resolve, record, dispatch, and finalize without visible delivery", async () => { const events: string[] = []; + dispatchReplyWithBufferedBlockDispatcherCore.mockImplementation(createDispatch(events)); + recordInboundSessionCore.mockImplementation(async () => { + events.push("record"); + }); const deliver = vi.fn(); const onFinalize = vi.fn(); const result = await runChannelInboundEvent({ @@ -1355,12 +1374,8 @@ describe("channel turn kernel", () => { resolveTurn: () => ({ cfg, channel: "test", - agentId: "observer", - routeSessionKey: "agent:observer:test:peer", - storePath: "/tmp/sessions.json", + route: { agentId: "observer", sessionKey: "agent:observer:test:peer" }, ctxPayload: createCtx({ SessionKey: "agent:observer:test:peer" }), - recordInboundSession: createRecordInboundSession(events), - dispatchReplyWithBufferedBlockDispatcher: createDispatch(events), delivery: { deliver }, record: { onRecordError: vi.fn(), @@ -1377,6 +1392,15 @@ describe("channel turn kernel", () => { expect(result.dispatched).toBe(true); expect(events).toEqual(["record", "dispatch"]); expect(deliver).not.toHaveBeenCalled(); + if (!result.dispatched) { + throw new Error("expected dispatch"); + } + expect(hasVisibleChannelTurnDispatch(result.dispatchResult)).toBe(false); + expect(resolveChannelTurnDispatchCounts(result.dispatchResult)).toEqual({ + tool: 0, + block: 0, + final: 0, + }); expect(onFinalize).toHaveBeenCalledTimes(1); const [finalized] = requireFirstMockCall(onFinalize, "finalize"); const finalizedResult = finalizeResult(finalized); @@ -1472,6 +1496,9 @@ describe("channel turn kernel", () => { const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => { throw dispatchError; }) as unknown as DispatchReplyWithBufferedBlockDispatcher; + dispatchReplyWithBufferedBlockDispatcherCore.mockImplementation( + dispatchReplyWithBufferedBlockDispatcher, + ); await expect( runChannelInboundEvent({ @@ -1482,12 +1509,8 @@ describe("channel turn kernel", () => { resolveTurn: () => ({ cfg, channel: "test", - agentId: "main", - routeSessionKey: "agent:main:test:peer", - storePath: "/tmp/sessions.json", + route: { agentId: "main", sessionKey: "agent:main:test:peer" }, ctxPayload: createCtx(), - recordInboundSession: createRecordInboundSession(), - dispatchReplyWithBufferedBlockDispatcher, delivery: { deliver: async () => ({ visibleReplySent: false }) }, record: { onRecordError: vi.fn(), diff --git a/src/channels/turn/kernel.ts b/src/channels/turn/kernel.ts index 8dea0bcdb70..39697f37e24 100644 --- a/src/channels/turn/kernel.ts +++ b/src/channels/turn/kernel.ts @@ -1,39 +1,15 @@ -import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; -import { - 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, -} from "../../infra/diagnostic-trace-context.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { recordChannelHistoryEntryWithMedia } from "../../auto-reply/reply/history.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, - hasVisibleChannelTurnDispatch, - type ChannelTurnDispatchResultLike, - type ChannelTurnVisibleDeliverySignals, -} from "./dispatch-result.js"; -import { - deliverInboundReplyWithMessageSendContext, - isDurableInboundReplyDeliveryHandled, - throwIfDurableInboundReplyDeliveryFailed, -} from "./durable-delivery.js"; + assembleResolvedChannelTurn, + dispatchAssembledChannelTurn as dispatchAssembledChannelTurnImpl, + runPreparedInboundReply as runPreparedInboundReplyImpl, +} from "./lifecycle.js"; export { recordChannelBotPairLoopAndCheckSuppression } from "./bot-loop-protection.js"; export type { ChannelBotLoopProtectionFacts } from "./bot-loop-protection.js"; -const NO_ADDITIONAL_DELIVERY_SIGNALS: ChannelTurnVisibleDeliverySignals = {}; - export { deliverInboundReplyWithMessageSendContext, isDurableInboundReplyDeliveryHandled, @@ -45,17 +21,14 @@ export type { } from "./durable-delivery.js"; import type { AssembledChannelTurn, - ChannelTurnPlan, ChannelEventClass, ChannelTurnAdmission, ChannelEventDeliveryAdapter, - ChannelTurnHistoryFinalizeOptions, ChannelTurnLogEvent, + ChannelTurnPlan, ChannelTurnResult, - ChannelTurnResolved, DispatchedChannelTurnResult, NormalizedTurnInput, - PreparedChannelTurn, PreflightFacts, RunChannelTurnParams, } from "./types.js"; @@ -67,38 +40,55 @@ export { } from "./dispatch-result.js"; export type { ChannelTurnResult, DispatchedChannelTurnResult } from "./types.js"; +type AssembledChannelTurnWithBotLoopProtection = AssembledChannelTurn & { + botLoopProtection: NonNullable; +}; + +type AssembledChannelTurnWithoutBotLoopProtection = Omit< + AssembledChannelTurn, + "botLoopProtection" +> & { + botLoopProtection?: undefined; +}; + +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurnWithBotLoopProtection, +): Promise; +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurnWithoutBotLoopProtection, +): Promise; +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurn, +): Promise; +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurn, +): Promise { + return dispatchAssembledChannelTurnImpl(params); +} + +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 function dispatchChannelInboundTurn(plan: ChannelTurnPlan): Promise { + return dispatchAssembledChannelTurnImpl( + assembleResolvedChannelTurn(plan) as AssembledChannelTurn, + ); +} + +export const runPreparedInboundReply = runPreparedInboundReplyImpl; + const DEFAULT_EVENT_CLASS: ChannelEventClass = { kind: "message", canStartAgentTurn: true, }; -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") { @@ -142,17 +132,6 @@ function createNoopChannelEventDeliveryAdapter(): ChannelEventDeliveryAdapter { }; } -function clearPendingHistoryAfterTurn(params?: ChannelTurnHistoryFinalizeOptions): void { - if (!params?.isGroup || !params.historyKey || !params.historyMap || params.limit === undefined) { - return; - } - clearChannelHistoryIfEnabled({ - historyMap: params.historyMap, - historyKey: params.historyKey, - limit: params.limit, - }); -} - function resolveDroppedHistorySender(input: NormalizedTurnInput, preflight: PreflightFacts) { return ( preflight.message?.senderLabel ?? @@ -218,469 +197,6 @@ async function recordDroppedChannelTurnHistory(params: { export const recordDroppedChannelInboundHistory = recordDroppedChannelTurnHistory; -function resolveAssembledReplyPipeline( - params: AssembledChannelTurn, -): Pick { - const turnAdoptionLifecycle = - params.turnAdoptionLifecycle ?? params.replyOptions?.turnAdoptionLifecycle; - if (!params.replyPipeline) { - return { - dispatcherOptions: params.dispatcherOptions, - replyOptions: turnAdoptionLifecycle - ? { ...params.replyOptions, turnAdoptionLifecycle } - : params.replyOptions, - }; - } - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg: params.cfg, - agentId: params.agentId, - channel: params.channel, - accountId: params.accountId, - ...params.replyPipeline, - }); - return { - dispatcherOptions: { - ...replyPipeline, - ...params.dispatcherOptions, - }, - replyOptions: { - onModelSelected, - ...params.replyOptions, - ...(turnAdoptionLifecycle ? { turnAdoptionLifecycle } : {}), - }, - }; -} - -function resolveObserveOnlyDispatchResult( - params: PreparedChannelTurn, -): TDispatchResult { - return (params.observeOnlyDispatchResult ?? { - queuedFinal: false, - counts: EMPTY_CHANNEL_TURN_DISPATCH_COUNTS, - }) as TDispatchResult; -} - -function isSystemChannelTurn(ctx: FinalizedMsgContext): boolean { - return ( - ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event" - ); -} - -function maybeWarnZeroCountVisibleDispatch( - params: Pick< - PreparedChannelTurn, - "admission" | "channel" | "ctxPayload" | "messageId" | "routeSessionKey" - > & { - dispatchResult: TDispatchResult; - log?: (event: ChannelTurnLogEvent) => void; - }, -): void { - if (params.admission?.kind === "observeOnly" || isSystemChannelTurn(params.ctxPayload)) { - return; - } - const dispatchResult = params.dispatchResult as ChannelTurnDispatchResultLike; - // Suppress the silent-drop warning using the canonical visible-delivery signal, which - // includes observedReplyDelivery and other non-count delivery paths. A partial count-only - // check would falsely flag observed-path deliveries (queuedFinal=false, zero counts) as drops. - if (hasVisibleChannelTurnDispatch(dispatchResult, NO_ADDITIONAL_DELIVERY_SIGNALS)) { - return; - } - log.warn( - `visible channel turn dispatched with no queued reply payloads: channel=${params.channel} ` + - `messageId=${params.messageId ?? "unknown"} sessionKey=${ - params.ctxPayload.SessionKey ?? params.routeSessionKey - }`, - ); - emit({ - ...params, - event: { - stage: "dispatch", - event: "warning", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: params.admission?.kind ?? "dispatch", - reason: "zero-count-visible-dispatch", - }, - }); -} - -function isExplicitlyNonVisibleChannelDelivery(result: unknown): boolean { - return ( - typeof result === "object" && - result !== null && - !Array.isArray(result) && - (result as { visibleReplySent?: unknown }).visibleReplySent === false - ); -} - -function markChannelDeliveryErrorVisible(error: unknown): unknown { - if (typeof error === "object" && error !== null && !Array.isArray(error)) { - try { - Object.assign(error, { sentBeforeError: true, visibleReplySent: true }); - return error; - } catch { - // Fall back to a wrapper when a platform error object is non-extensible. - } - } - const visibleError = new Error("visible channel reply delivery failed", { cause: error }); - Object.assign(visibleError, { sentBeforeError: true, visibleReplySent: true }); - return visibleError; -} - -async function runChannelDeliveryObserver(params: { - onDelivered: ChannelEventDeliveryAdapter["onDelivered"] | undefined; - payload: ReplyPayload; - info: Parameters>[1]; - result: Parameters>[2]; -}): Promise { - if (!params.onDelivered) { - return; - } - try { - await params.onDelivered(params.payload, params.info, params.result); - } catch (error: unknown) { - throw isExplicitlyNonVisibleChannelDelivery(params.result) - ? error - : markChannelDeliveryErrorVisible(error); - } -} - -function resolveBotLoopProtectionDrop( - params: PreparedChannelTurn, -): ChannelTurnResult | undefined { - if (!params.botLoopProtection) { - return undefined; - } - const botLoopResult = recordChannelBotPairLoopAndCheckSuppression(params.botLoopProtection); - if (!botLoopResult.suppressed) { - return undefined; - } - const admission: ChannelTurnAdmission = { kind: "drop", reason: "bot-loop-protection" }; - emit({ - ...params, - event: { - stage: "authorize", - event: "drop", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - reason: admission.reason, - }, - }); - return { - admission, - dispatched: false, - ctxPayload: params.ctxPayload, - routeSessionKey: params.routeSessionKey, - }; -} - -type AssembledChannelTurnWithBotLoopProtection = AssembledChannelTurn & { - botLoopProtection: NonNullable; -}; - -type AssembledChannelTurnWithoutBotLoopProtection = Omit< - AssembledChannelTurn, - "botLoopProtection" -> & { - botLoopProtection?: undefined; -}; - -export function dispatchAssembledChannelTurn( - params: AssembledChannelTurnWithBotLoopProtection, -): Promise; -export function dispatchAssembledChannelTurn( - params: AssembledChannelTurnWithoutBotLoopProtection, -): Promise; -export function dispatchAssembledChannelTurn( - params: AssembledChannelTurn, -): Promise; -export async function dispatchAssembledChannelTurn( - params: AssembledChannelTurn, -): Promise { - const replyPipeline = resolveAssembledReplyPipeline(params); - return await runPreparedChannelTurnCore( - { - channel: params.channel, - accountId: params.accountId, - routeSessionKey: params.routeSessionKey, - storePath: params.storePath, - ctxPayload: params.ctxPayload, - recordInboundSession: params.recordInboundSession, - afterRecord: params.afterRecord, - record: params.record, - history: params.history, - admission: params.admission, - botLoopProtection: params.botLoopProtection, - log: params.log, - messageId: params.messageId, - runDispatch: async () => - 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, - }); - 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, - } - : undefined, - ), - }, - { suppressObserveOnlyDispatch: false }, - ); -} - -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: AssembledChannelTurn | PreparedChannelTurn, -): value is PreparedChannelTurn & { - admission?: Extract; -} { - return "runDispatch" in value; -} - -async function dispatchResolvedChannelTurn( - params: (AssembledChannelTurn | PreparedChannelTurn) & { - admission: Extract; - log?: (event: ChannelTurnLogEvent) => void; - messageId?: string; - }, -): Promise> { - if (isPreparedChannelTurn(params)) { - return await runPreparedChannelTurn(params); - } - return (await dispatchAssembledChannelTurn(params)) as ChannelTurnResult; -} - -async function runPreparedChannelTurnCore< - TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], ->( - params: PreparedChannelTurn, - options: { suppressObserveOnlyDispatch: boolean }, -): Promise> { - const trace = createDiagnosticTraceContextFromActiveScope(); - return await runWithDiagnosticTraceContext(trace, () => - runPreparedChannelTurnCoreInTrace(params, options), - ); -} - -async function runPreparedChannelTurnCoreInTrace< - TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], ->( - params: PreparedChannelTurn, - options: { suppressObserveOnlyDispatch: boolean }, -): Promise> { - const admission = params.admission ?? ({ kind: "dispatch" } as const); - const botLoopDrop = resolveBotLoopProtectionDrop(params); - if (botLoopDrop) { - clearPendingHistoryAfterTurn(params.history); - return botLoopDrop; - } - emit({ - ...params, - event: { - stage: "record", - event: "start", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - }, - }); - try { - await params.recordInboundSession({ - storePath: params.storePath, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - ctx: params.ctxPayload, - groupResolution: params.record?.groupResolution, - createIfMissing: params.record?.createIfMissing, - updateLastRoute: params.record?.updateLastRoute, - onRecordError: params.record?.onRecordError ?? (() => undefined), - trackSessionMetaTask: params.record?.trackSessionMetaTask, - }); - emit({ - ...params, - event: { - stage: "record", - event: "done", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - }, - }); - await params.afterRecord?.(); - } catch (err) { - emit({ - ...params, - event: { - stage: "record", - event: "error", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - error: err, - }, - }); - try { - await params.onPreDispatchFailure?.(err); - } catch { - // Preserve the original session-recording error. - } - throw err; - } - - emit({ - ...params, - event: { - stage: "dispatch", - event: "start", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - }, - }); - let dispatchResult: TDispatchResult; - try { - dispatchResult = - options.suppressObserveOnlyDispatch && admission.kind === "observeOnly" - ? resolveObserveOnlyDispatchResult(params) - : await params.runDispatch(); - maybeWarnZeroCountVisibleDispatch({ - ...params, - admission, - dispatchResult, - }); - } catch (err) { - emit({ - ...params, - event: { - stage: "dispatch", - event: "error", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - error: err, - }, - }); - throw err; - } - emit({ - ...params, - event: { - stage: "dispatch", - event: "done", - messageId: params.messageId, - sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, - admission: admission.kind, - }, - }); - clearPendingHistoryAfterTurn(params.history); - - return { - admission, - dispatched: true, - ctxPayload: params.ctxPayload, - routeSessionKey: params.routeSessionKey, - dispatchResult, - }; -} - -type PreparedChannelTurnWithBotLoopProtection = - PreparedChannelTurn & { - botLoopProtection: NonNullable["botLoopProtection"]>; - }; - -type PreparedChannelTurnWithoutBotLoopProtection = Omit< - PreparedChannelTurn, - "botLoopProtection" -> & { - botLoopProtection?: undefined; -}; - -function runPreparedChannelTurn( - params: PreparedChannelTurnWithBotLoopProtection, -): Promise>; -function runPreparedChannelTurn( - params: PreparedChannelTurnWithoutBotLoopProtection, -): Promise>; -function runPreparedChannelTurn( - params: PreparedChannelTurn, -): Promise>; -async function runPreparedChannelTurn< - TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], ->(params: PreparedChannelTurn): Promise> { - return await runPreparedChannelTurnCore(params, { suppressObserveOnlyDispatch: true }); -} - -export const runPreparedInboundReply = runPreparedChannelTurn; - async function runChannelTurn< TRaw, TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], @@ -772,19 +288,15 @@ async function runChannelTurn< const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const); let result: ChannelTurnResult; try { - // Prepared runDispatch was assembled earlier and ignores late options. - const dispatchResult = await dispatchResolvedChannelTurn( + const dispatchResult = ( "runDispatch" in resolved - ? { + ? await runPreparedInboundReply({ ...resolved, - ...(admission.kind === "observeOnly" - ? { delivery: createNoopChannelEventDeliveryAdapter() } - : {}), admission, log: params.log, messageId: input.id, - } - : { + }) + : await dispatchAssembledChannelTurn({ ...resolved, ...(admission.kind === "observeOnly" ? { delivery: createNoopChannelEventDeliveryAdapter() } @@ -795,8 +307,8 @@ async function runChannelTurn< ...(params.turnAdoptionLifecycle ? { turnAdoptionLifecycle: params.turnAdoptionLifecycle } : {}), - }, - ); + }) + ) as ChannelTurnResult; result = dispatchResult.dispatched ? { ...dispatchResult, admission } : dispatchResult; } catch (err) { const failedResult: ChannelTurnResult = { @@ -857,4 +369,3 @@ async function runChannelTurn< } export const runChannelInboundEvent = runChannelTurn; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/channels/turn/lifecycle.ts b/src/channels/turn/lifecycle.ts new file mode 100644 index 00000000000..dac060dfd61 --- /dev/null +++ b/src/channels/turn/lifecycle.ts @@ -0,0 +1,228 @@ +import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +import { runWithSessionInitConflictRetry } from "../../auto-reply/reply/session-init-conflict-retry.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { createChannelReplyPipeline } from "../message/reply-pipeline.js"; +import { recordInboundSession } from "../session.js"; +import { + deliverInboundReplyWithMessageSendContext, + isDurableInboundReplyDeliveryHandled, + throwIfDurableInboundReplyDeliveryFailed, +} from "./durable-delivery.js"; +import { runPreparedChannelTurnCore } from "./execution.js"; +import type { + AssembledChannelTurn, + ChannelEventDeliveryAdapter, + ChannelTurnResolved, + ChannelTurnResult, + DispatchedChannelTurnResult, + PreparedChannelTurn, +} from "./types.js"; + +export 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 resolveAssembledReplyPipeline( + params: AssembledChannelTurn, +): Pick { + const turnAdoptionLifecycle = + params.turnAdoptionLifecycle ?? params.replyOptions?.turnAdoptionLifecycle; + if (!params.replyPipeline) { + return { + dispatcherOptions: params.dispatcherOptions, + replyOptions: turnAdoptionLifecycle + ? { ...params.replyOptions, turnAdoptionLifecycle } + : params.replyOptions, + }; + } + const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + cfg: params.cfg, + agentId: params.agentId, + channel: params.channel, + accountId: params.accountId, + ...params.replyPipeline, + }); + return { + dispatcherOptions: { + ...replyPipeline, + ...params.dispatcherOptions, + }, + replyOptions: { + onModelSelected, + ...params.replyOptions, + ...(turnAdoptionLifecycle ? { turnAdoptionLifecycle } : {}), + }, + }; +} + +function isExplicitlyNonVisibleChannelDelivery(result: unknown): boolean { + return ( + typeof result === "object" && + result !== null && + !Array.isArray(result) && + (result as { visibleReplySent?: unknown }).visibleReplySent === false + ); +} + +function markChannelDeliveryErrorVisible(error: unknown): unknown { + if (typeof error === "object" && error !== null && !Array.isArray(error)) { + try { + Object.assign(error, { sentBeforeError: true, visibleReplySent: true }); + return error; + } catch { + // Fall back to a wrapper when a platform error object is non-extensible. + } + } + const visibleError = new Error("visible channel reply delivery failed", { cause: error }); + Object.assign(visibleError, { sentBeforeError: true, visibleReplySent: true }); + return visibleError; +} + +async function runChannelDeliveryObserver(params: { + onDelivered: ChannelEventDeliveryAdapter["onDelivered"] | undefined; + payload: ReplyPayload; + info: Parameters>[1]; + result: Parameters>[2]; +}): Promise { + if (!params.onDelivered) { + return; + } + try { + await params.onDelivered(params.payload, params.info, params.result); + } catch (error: unknown) { + throw isExplicitlyNonVisibleChannelDelivery(params.result) + ? error + : markChannelDeliveryErrorVisible(error); + } +} + +type AssembledChannelTurnWithBotLoopProtection = AssembledChannelTurn & { + botLoopProtection: NonNullable; +}; + +type AssembledChannelTurnWithoutBotLoopProtection = Omit< + AssembledChannelTurn, + "botLoopProtection" +> & { + botLoopProtection?: undefined; +}; + +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurnWithBotLoopProtection, +): Promise; +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurnWithoutBotLoopProtection, +): Promise; +export function dispatchAssembledChannelTurn( + params: AssembledChannelTurn, +): Promise; +export async function dispatchAssembledChannelTurn( + params: AssembledChannelTurn, +): Promise { + const replyPipeline = resolveAssembledReplyPipeline(params); + return await runPreparedChannelTurnCore( + { + channel: params.channel, + accountId: params.accountId, + routeSessionKey: params.routeSessionKey, + storePath: params.storePath, + ctxPayload: params.ctxPayload, + recordInboundSession: params.recordInboundSession, + afterRecord: params.afterRecord, + record: params.record, + history: params.history, + admission: params.admission, + botLoopProtection: params.botLoopProtection, + log: params.log, + messageId: params.messageId, + runDispatch: async () => + 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, + }); + 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, + } + : undefined, + ), + }, + { suppressObserveOnlyDispatch: false }, + ); +} + +export { runPreparedInboundReply } from "./execution.js"; diff --git a/src/plugin-sdk/direct-dm.test.ts b/src/plugin-sdk/direct-dm.test.ts index 5a929d774e0..6d7e24e6e2d 100644 --- a/src/plugin-sdk/direct-dm.test.ts +++ b/src/plugin-sdk/direct-dm.test.ts @@ -15,10 +15,37 @@ const baseCfg = { } as unknown as OpenClawConfig; function createDirectDmRuntime() { - const recordInboundSessionMock = vi.fn(async () => {}); + const recordInboundSessionMock = vi.fn(async (_params: unknown) => {}); const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "reply text" }); }); + const runInbound = vi.fn(async ({ adapter, raw }) => { + const input = await adapter.ingest(raw); + const turn = await adapter.resolveTurn(input, { + kind: "message", + canStartAgentTurn: true, + }); + await recordInboundSessionMock({ + storePath: "/tmp/direct-dm-session-store", + sessionKey: turn.route.sessionKey, + ctx: turn.ctxPayload, + onRecordError: turn.record?.onRecordError ?? (() => undefined), + }); + return { + admission: { kind: "dispatch" }, + dispatched: true, + dispatchResult: await dispatchReplyWithBufferedBlockDispatcher({ + ctx: turn.ctxPayload, + cfg: turn.cfg, + dispatcherOptions: { + ...turn.dispatcherOptions, + deliver: turn.delivery.deliver, + onError: turn.delivery.onError, + }, + replyOptions: turn.replyOptions, + }), + }; + }); return { recordInboundSession: recordInboundSessionMock, dispatchReplyWithBufferedBlockDispatcher, @@ -42,6 +69,7 @@ function createDirectDmRuntime() { finalizeInboundContext: vi.fn((ctx) => ctx), dispatchReplyWithBufferedBlockDispatcher, }, + inbound: { run: runInbound }, }, } as never, }; 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 e04885d1469..179c72cbd8a 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.test.ts @@ -76,11 +76,8 @@ describe("createPluginRuntimeMock", () => { }); it("exposes channel inbound helpers without the removed turn aliases", async () => { - const runtime = createPluginRuntimeMock(); const channel = "test"; - expect("turn" in runtime.channel).toBe(false); - const input = vi.fn((raw: { id: string }) => ({ id: raw.id, rawText: "hello", @@ -92,22 +89,34 @@ describe("createPluginRuntimeMock", () => { const afterRecord = vi.fn(() => { events.push("afterRecord"); }); - const runDispatch = vi.fn(async () => { + const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => { events.push("dispatch"); - return { visibleReplySent: true }; + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; }); + const runtime = createPluginRuntimeMock({ + channel: { + session: { + resolveStorePath: () => "/tmp/openclaw-test", + recordInboundSession, + }, + reply: { dispatchReplyWithBufferedBlockDispatcher }, + }, + }); + expect("turn" in runtime.channel).toBe(false); const resolveTurn = vi.fn(async () => ({ + cfg: {}, channel, - storePath: "/tmp/openclaw-test", - routeSessionKey: "agent:main:test:direct:u1", + route: { + agentId: "main", + sessionKey: "agent:main:test:direct:u1", + }, ctxPayload: { Body: "hello", CommandAuthorized: false, SessionKey: "agent:main:test:direct:u1", }, - recordInboundSession, afterRecord, - runDispatch, + delivery: { deliver: vi.fn(async () => undefined) }, })); const result = await runtime.channel.inbound.run({ @@ -132,7 +141,7 @@ describe("createPluginRuntimeMock", () => { }), ); expect(events).toEqual(["record", "afterRecord", "dispatch"]); - expect(runDispatch).toHaveBeenCalled(); + expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(result).toEqual( expect.objectContaining({ admission: { kind: "dispatch" }, diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index aa77e65f6ff..da012f6db02 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -169,7 +169,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = const storePath = params.storePath as string; const delivery = params.delivery as { deliver: (payload: unknown, info: unknown) => Promise; - onError?: (err: unknown, info: { kind: string }) => void; + onDelivered?: (payload: unknown, info: unknown, result: unknown) => Promise | void; + onError?: (err: unknown, info: unknown) => void; }; const ctxSessionKey = ctxPayload.SessionKey; const sessionKey = typeof ctxSessionKey === "string" ? ctxSessionKey : routeSessionKey; @@ -178,8 +179,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = ctx: unknown; cfg: unknown; dispatcherOptions: { - deliver: (payload: unknown, info: unknown) => Promise; - onError?: (err: unknown, info: { kind: string }) => void; + deliver: (payload: unknown, info: unknown) => Promise; + onError?: (err: unknown, info: unknown) => void; }; replyOptions?: unknown; replyResolver?: unknown; @@ -215,7 +216,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial = ...dispatcherPipeline, ...(params.dispatcherOptions as Record | undefined), deliver: async (payload, info) => { - await delivery.deliver(payload, info); + const result = await delivery.deliver(payload, info); + await delivery.onDelivered?.(payload, info, result); + return result; }, onError: delivery.onError, }, @@ -325,48 +328,50 @@ export function createPluginRuntimeMock(overrides: DeepPartial = }; } const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {}); - const assembled = - "route" in resolved - ? (() => { - if (!mergedRuntime) { - throw new Error("plugin runtime mock turn used before initialization"); - } - const { cfg, route, ...turn } = resolved; - const routedTurn = { - ...turn, - routeSessionKey: route.sessionKey, - storePath: mergedRuntime.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }), - recordInboundSession: mergedRuntime.channel.session.recordInboundSession, - }; - return "runDispatch" in resolved - ? routedTurn - : { - ...routedTurn, - cfg, - agentId: route.agentId, - dispatchReplyWithBufferedBlockDispatcher: - mergedRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, - }; - })() - : resolved; const admission = - assembled.admission ?? preflight.admission ?? ({ kind: "dispatch" } as const); - const dispatchResult = - "runDispatch" in assembled - ? await runPreparedChannelTurnMock({ - ...assembled, - admission, - } as unknown as Parameters[0]) - : await dispatchAssembledChannelTurnMock({ - ...assembled, - admission, - delivery: - admission.kind === "observeOnly" - ? { deliver: async () => ({ visibleReplySent: false }) } - : assembled.delivery, - }); + resolved.admission ?? preflight.admission ?? ({ kind: "dispatch" } as const); + let dispatchResult; + if ("runDispatch" in resolved) { + const prepared = + "route" in resolved + ? (() => { + if (!mergedRuntime) { + throw new Error("plugin runtime mock run used before initialization"); + } + const { cfg, route, ...turn } = resolved; + return { + ...turn, + routeSessionKey: route.sessionKey, + storePath: mergedRuntime.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }), + recordInboundSession: mergedRuntime.channel.session.recordInboundSession, + }; + })() + : resolved; + dispatchResult = await runPreparedChannelTurnMock({ + ...prepared, + admission, + } as unknown as Parameters[0]); + } else { + const delivery = + admission.kind === "observeOnly" + ? { deliver: async () => ({ visibleReplySent: false }) } + : resolved.delivery; + if ("route" in resolved) { + dispatchResult = await dispatchChannelTurnPlanMock({ + ...resolved, + admission, + delivery, + }); + } else { + dispatchResult = await dispatchAssembledChannelTurnMock({ + ...resolved, + admission, + delivery, + }); + } + } const result = { ...dispatchResult, admission, 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 d42f8a90642..9ede10d9eb8 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -125,7 +125,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export { withFileLock } from "openclaw/plugin-sdk/file-lock";', 'export { keepHttpServerTaskAlive } from "openclaw/plugin-sdk/channel-outbound";', 'export { detectMime, extensionForMime, extractOriginalFilename, getFileExtension, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";', - 'export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/channel-inbound";', 'export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";', 'export { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";', 'export type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";',