refactor(channels): move inbound turn execution into core (#110095)

* refactor(channels): centralize turn execution lifecycle

* refactor(extensions): adopt core channel turn plans

* test(channels): ratchet modern dispatch ownership

* chore(channels): drop stale max-lines exemption

* fix(channels): allow async delivery error cleanup

* test(channels): await async delivery cleanup

* test(channels): await delivery error callbacks

* fix(channels): preserve delivery error contract

* fix(plugin-sdk): preserve channel inbound declarations

* test(channels): exercise core delivery plans

* fix(channels): await delivery error cleanup

* fix(channels): preserve async cleanup contract

* refactor(channels): trim dispatch boilerplate

* fix(channels): mask observe-only dispatch results

* refactor(channels): delete unused dispatch exports

* fix(msteams): preserve resolver config overrides

* refactor(channels): keep Teams turn config internal

* test(plugins): drop retired Teams runtime export guard
This commit is contained in:
Peter Steinberger
2026-07-18 00:55:46 +01:00
committed by GitHub
parent 9a3231547b
commit 601ffa2530
63 changed files with 2677 additions and 2499 deletions
-1
View File
@@ -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
+36 -83
View File
@@ -10,30 +10,20 @@ const {
builtInboundContextCalls,
mockCreateFeishuReplyDispatcher,
mockCreateFeishuClient,
mockDispatchInboundMessage,
mockDispatchReply,
mockRecordInboundSession,
mockResolveAgentRoute,
mockResolveStorePath,
} = vi.hoisted(() => ({
builtInboundContextCalls: [] as Array<Record<string, unknown>>,
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<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return { ...actual, dispatchInboundMessage: mockDispatchInboundMessage };
});
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"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
+33 -32
View File
@@ -22,9 +22,6 @@ type BoundConversation = ReturnType<
ReturnType<typeof getSessionBindingService>["resolveByConversation"]
>;
type BindingReadiness = Awaited<ReturnType<typeof ensureConfiguredBindingRouteReady>>;
type ReplyDispatcher = Parameters<
PluginRuntime["channel"]["reply"]["withReplyDispatcher"]
>[0]["dispatcher"];
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
? T[K]
@@ -35,18 +32,6 @@ type DeepPartial<T> = {
: 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<ConfiguredBindingRoute> {
return {
bindingResolution: {
@@ -206,13 +191,20 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): 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<PluginRuntime> = {}): 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,
});
+14 -55
View File
@@ -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<string, unknown>).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");
}
@@ -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<Record<string, unknown>>();
return {
...actual,
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
};
});
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
async function raceWithNextMacrotask<T>(promise: Promise<T>): Promise<T | "pending"> {
@@ -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<typeof createFeishuCommentReplyDispatcher>) {
return {
...created.dispatcherOptions,
deliver: created.delivery.deliver,
} as {
deliver: (payload: { text: string }, phase: { kind: string }) => Promise<void> | void;
onCleanup?: () => Promise<void> | void;
onReplyStart?: () => Promise<void> | 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);
+44 -45
View File
@@ -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<ChannelInboundTurnPlan["dispatcherOptions"]> = {
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,
};
+18 -43
View File
@@ -90,13 +90,8 @@ function createTestRuntime(overrides?: {
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
}) {
const recordInboundSession = vi.fn(async (_params: unknown) => {});
type PreparedCommentTurnPlan = {
route: { agentId: string; sessionKey: string };
ctxPayload: { SessionKey?: string };
record?: Record<string, unknown> & { onRecordError?: (error: unknown) => void };
runDispatch: () => Promise<unknown>;
};
const dispatchPreparedForTest = vi.fn(async (turn: PreparedCommentTurnPlan) => {
type CommentTurnPlan = Parameters<PluginRuntime["channel"]["inbound"]["dispatch"]>[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 () => {}),
});
+6 -26
View File
@@ -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();
}
}
+22 -54
View File
@@ -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<Record<string, unknown>>();
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<typeof createFeishuReplyDispatcher>[0];
type TypingDispatcherOptions = {
onReplyStart?: () => Promise<void> | void;
onIdle?: () => Promise<void> | void;
deliver: (
payload: {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
ttsSupplement?: { spokenText: string; visibleTextAlreadyDelivered?: boolean };
isError?: boolean;
},
meta: { kind: string },
) => Promise<void> | void;
};
type ReplyDispatcherPlan = ReturnType<typeof createFeishuReplyDispatcher>;
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<string, unknown> {
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" });
+25 -18
View File
@@ -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<ChannelInboundTurnPlan["dispatcherOptions"]> = {
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<ChannelInboundTurnPlan["delivery"]["onError"]>,
};
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,
+2 -2
View File
@@ -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(
@@ -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<typeof deliverReplies>[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?.(),
},
});
}
@@ -174,7 +174,7 @@ export function createMatrixHandlerTestHarness(
prepared.markRunComplete();
prepared.markDispatchIdle();
}
}) as NonNullable<MatrixMonitorHandlerParams["dispatchInboundMessageWithBufferedDispatcher"]>;
}) 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<unknown>)
: 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,
});
+51 -55
View File
@@ -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<string, unknown>) => 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<typeof onReplyError>[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,
},
}),
},
-1
View File
@@ -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,
@@ -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<string, unknown>;
delivery: {
deliver: (
payload: ReplyPayload,
info: { kind: "tool" | "block" | "final" },
) => Promise<unknown>;
onError?: unknown;
};
replyOptions?: Record<string, unknown>;
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<string, unknown> };
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<string, never>,
) => Parameters<typeof dispatchPreparedForTest>[0];
) => Parameters<typeof dispatchPlanForTest>[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 {
+291 -323
View File
@@ -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<ChannelInboundTurnPlan["dispatcherOptions"]> = {
...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();
}
}
},
});
@@ -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,
-1
View File
@@ -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";
+7 -29
View File
@@ -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<typeof import("openclaw/plugin-sdk/reply-runtime")>();
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> | void;
deliver: (payload: ReplyPayload, info: { kind: string }) => Promise<void> | 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
@@ -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<typeof import("openclaw/plugin-sdk/channel-inbound")>();
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<string, unknown> {
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at(
const dispatched = runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mock.calls.at(
-1,
)?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
if (!dispatched?.ctxPayload) {
)?.[0] as { ctx?: Record<string, unknown> } | 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);
@@ -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<unknown>)
: 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<unknown>;
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: {},
@@ -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<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
dispatchReplyFromConfigWithSettledDispatcher:
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher,
};
});
vi.mock("../reply-dispatcher.js", () => ({
createMSTeamsReplyDispatcher: () => ({
dispatcher: {},
dispatcherOptions: {},
delivery: { deliver: vi.fn(async () => undefined) },
replyOptions: {},
markDispatchIdle: vi.fn(),
}),
}));
@@ -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<string, unknown> {
@@ -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 () => {
@@ -27,12 +27,12 @@ const taglessHtmlAttachment = {
};
function firstDispatchedContext(): Record<string, unknown> {
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<string, unknown>;
return params.ctx as Record<string, unknown>;
}
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();
});
});
@@ -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 () => {
@@ -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,
}),
},
});
+13 -27
View File
@@ -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<typeof import("openclaw/plugin-sdk/reply-runtime")>();
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();
});
+11 -14
View File
@@ -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<ChannelInboundTurnPlan["dispatcherOptions"]> = {
...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<void> => {
const settleDelivery = (): Promise<void> => {
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,
};
}
+12 -6
View File
@@ -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);
});
@@ -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<unknown> };
return { dispatchResult: await turn.runDispatch() };
);
return {
dispatched: true,
dispatchResult: { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } },
};
}),
},
text: {
@@ -115,6 +115,7 @@ function makeInbound(overrides: Partial<InboundContext> = {}): InboundContext {
}
function makeInboundRuntime(
dispatchReplyWithBufferedBlockDispatcher: (params: unknown) => Promise<unknown>,
onResolvedContext?: (ctx: Record<string, unknown>) => void,
): GatewayPluginRuntime["channel"]["inbound"] {
return {
@@ -134,9 +135,28 @@ function makeInboundRuntime(
kind: "message",
},
{},
)) as { ctxPayload: Record<string, unknown>; runDispatch: () => Promise<unknown> };
)) as {
cfg: unknown;
ctxPayload: Record<string, unknown>;
dispatcherOptions?: Record<string, unknown>;
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<void>,
) => Promise<void>;
}): 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<void>;
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<void>;
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<string, unknown>) => 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],
},
@@ -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)}`,
);
}
},
}
: {}),
},
}),
},
}
: {}),
},
}),
},
});
@@ -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<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[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<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
@@ -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<void>;
};
replyOptions?: {
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
allowToolLifecycleWhenProgressHidden?: boolean;
@@ -86,27 +92,75 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[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<unknown>[] = [];
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;
},
};
});
@@ -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<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[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 },
@@ -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<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
type RunParams = Parameters<typeof actual.runChannelInboundEvent>[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<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
+40 -51
View File
@@ -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<ReturnType<typeof dispatchInboundMessage>>;
type SignalStatusDispatchResult = {
failedCounts?: Partial<Record<"tool" | "block" | "final", number>>;
};
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<ChannelInboundTurnPlan["dispatcherOptions"]> = {
...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) {
+2 -2
View File
@@ -33,7 +33,7 @@ type RecordedWireCall = {
type CapturedDispatcherOptions = {
deliver: (payload: ReplyPayload, info: { kind: ReplyDispatchKind }) => Promise<unknown>;
onError?: (err: unknown, info: { kind: string }) => void;
onError?: (err: unknown, info: { kind: string }) => Promise<void> | void;
typingCallbacks?: {
onReplyStart?: () => Promise<void>;
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 });
}
};
@@ -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<typeof params.reply.onError>[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<ChannelInboundTurnPlan["delivery"]["deliver"]>
>,
// 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,
},
}),
},
});
@@ -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;
},
};
});
+3 -3
View File
@@ -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<void>;
@@ -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({});
};
};
@@ -1,7 +1,6 @@
// Whatsapp plugin module implements inbound dispatch behavior.
export {
createChannelMessageReplyPipeline,
dispatchReplyWithBufferedBlockDispatcher,
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
@@ -21,8 +21,8 @@ type CapturedDispatchParams = {
payload: CapturedReplyPayload,
info: { kind: "tool" | "block" | "final" },
) => Promise<unknown>;
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
onSettled?: () => Promise<unknown>;
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => Promise<void> | 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<typeof dispatchWhatsAppBufferedReply>[0];
type BufferedReplyParams = Parameters<typeof createWhatsAppReplyPlan>[0];
type BufferedReplyOverrides = Partial<Omit<BufferedReplyParams, "context">> & {
context?: Partial<BufferedReplyParams["context"]>;
};
@@ -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<boolean> {
const plan = createWhatsAppReplyPlan(params);
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcherMock({
ctx: params.context,
dispatcherOptions: {
...plan.dispatcherOptions,
deliver: plan.delivery.deliver as NonNullable<
NonNullable<CapturedDispatchParams["dispatcherOptions"]>["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({
@@ -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<LoadConfigFn>;
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<ChannelInboundTurnPlan["dispatcherOptions"]> = {
...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<Record<ReplyLifecycleKind, number>>;
}): 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: {
@@ -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<typeof processMessage>[0]["msg"];
@@ -227,7 +233,7 @@ function firstTranscriptionContext(): Record<string, unknown> {
}
function firstDispatchContext(): Record<string, unknown> {
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<string, unknown> } | 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 <media:audio> 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();
@@ -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",
@@ -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<typeof createWhatsAppReplyPlan>["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,
@@ -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,
@@ -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:
+28
View File
@@ -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([]),
+7
View File
@@ -651,6 +651,12 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
beforeDeliver,
silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext,
});
const onTypingController = params.replyOptions?.onTypingController
? (typing: Parameters<NonNullable<typeof params.replyOptions.onTypingController>>[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.
+6 -2
View File
@@ -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);
+25
View File
@@ -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<void>();
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 {
+27 -32
View File
@@ -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<string, unknown>)
: {},
),
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 };
}
+294
View File
@@ -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<ChannelTurnLogEvent, "channel" | "accountId">;
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<TDispatchResult>(
params: PreparedChannelTurn<TDispatchResult>,
): 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<TDispatchResult>(
params: Pick<
PreparedChannelTurn<TDispatchResult>,
"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<TDispatchResult>(
params: PreparedChannelTurn<TDispatchResult>,
): ChannelTurnResult<TDispatchResult> | 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<TDispatchResult>,
options: { suppressObserveOnlyDispatch: boolean },
): Promise<ChannelTurnResult<TDispatchResult>> {
const trace = createDiagnosticTraceContextFromActiveScope();
return await runWithDiagnosticTraceContext(trace, () =>
runPreparedChannelTurnCoreInTrace(params, options),
);
}
async function runPreparedChannelTurnCoreInTrace<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(
params: PreparedChannelTurn<TDispatchResult>,
options: { suppressObserveOnlyDispatch: boolean },
): Promise<ChannelTurnResult<TDispatchResult>> {
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<TDispatchResult> =
PreparedChannelTurn<TDispatchResult> & {
botLoopProtection: NonNullable<PreparedChannelTurn<TDispatchResult>["botLoopProtection"]>;
};
type PreparedChannelTurnWithoutBotLoopProtection<TDispatchResult> = Omit<
PreparedChannelTurn<TDispatchResult>,
"botLoopProtection"
> & {
botLoopProtection?: undefined;
};
function runPreparedChannelTurn<TDispatchResult = DispatchedChannelTurnResult["dispatchResult"]>(
params: PreparedChannelTurnWithBotLoopProtection<TDispatchResult>,
): Promise<ChannelTurnResult<TDispatchResult>>;
function runPreparedChannelTurn<TDispatchResult = DispatchedChannelTurnResult["dispatchResult"]>(
params: PreparedChannelTurnWithoutBotLoopProtection<TDispatchResult>,
): Promise<DispatchedChannelTurnResult<TDispatchResult>>;
function runPreparedChannelTurn<TDispatchResult = DispatchedChannelTurnResult["dispatchResult"]>(
params: PreparedChannelTurn<TDispatchResult>,
): Promise<ChannelTurnResult<TDispatchResult>>;
async function runPreparedChannelTurn<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(params: PreparedChannelTurn<TDispatchResult>): Promise<ChannelTurnResult<TDispatchResult>> {
return await runPreparedChannelTurnCore(params, { suppressObserveOnlyDispatch: true });
}
export const runPreparedInboundReply = runPreparedChannelTurn;
+44 -21
View File
@@ -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<typeof import("../../auto-reply/reply/provider-dispatcher.js")>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcherCore,
};
});
vi.mock("../../infra/outbound/deliver.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/outbound/deliver.js")>();
@@ -52,6 +63,11 @@ vi.mock("../message/send.js", async (importOriginal) => {
};
});
vi.mock("../session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../session.js")>();
return { ...actual, recordInboundSession: recordInboundSessionCore };
});
const cfg = {} as OpenClawConfig;
function createCtx(overrides: Partial<FinalizedMsgContext> = {}): FinalizedMsgContext {
@@ -190,6 +206,8 @@ function loggedEvents(log: ReturnType<typeof vi.fn>): 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(),
+57 -546
View File
@@ -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<AssembledChannelTurn["botLoopProtection"]>;
};
type AssembledChannelTurnWithoutBotLoopProtection = Omit<
AssembledChannelTurn,
"botLoopProtection"
> & {
botLoopProtection?: undefined;
};
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurnWithBotLoopProtection,
): Promise<ChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurnWithoutBotLoopProtection,
): Promise<DispatchedChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<ChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<ChannelTurnResult> {
return dispatchAssembledChannelTurnImpl(params);
}
export const dispatchChannelInboundReply = dispatchAssembledChannelTurn;
export function dispatchChannelInboundTurn(
plan: ChannelTurnPlan & {
botLoopProtection: NonNullable<ChannelTurnPlan["botLoopProtection"]>;
},
): Promise<ChannelTurnResult>;
export function dispatchChannelInboundTurn(
plan: Omit<ChannelTurnPlan, "botLoopProtection"> & { botLoopProtection?: undefined },
): Promise<DispatchedChannelTurnResult>;
export function dispatchChannelInboundTurn(plan: ChannelTurnPlan): Promise<ChannelTurnResult>;
export function dispatchChannelInboundTurn(plan: ChannelTurnPlan): Promise<ChannelTurnResult> {
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<TDispatchResult>(
value: ChannelTurnResolved<TDispatchResult>,
): AssembledChannelTurn | PreparedChannelTurn<TDispatchResult> {
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<AssembledChannelTurn, "dispatcherOptions" | "replyOptions"> {
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<TDispatchResult>(
params: PreparedChannelTurn<TDispatchResult>,
): 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<TDispatchResult>(
params: Pick<
PreparedChannelTurn<TDispatchResult>,
"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<NonNullable<ChannelEventDeliveryAdapter["onDelivered"]>>[1];
result: Parameters<NonNullable<ChannelEventDeliveryAdapter["onDelivered"]>>[2];
}): Promise<void> {
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<TDispatchResult>(
params: PreparedChannelTurn<TDispatchResult>,
): ChannelTurnResult<TDispatchResult> | 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<AssembledChannelTurn["botLoopProtection"]>;
};
type AssembledChannelTurnWithoutBotLoopProtection = Omit<
AssembledChannelTurn,
"botLoopProtection"
> & {
botLoopProtection?: undefined;
};
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurnWithBotLoopProtection,
): Promise<ChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurnWithoutBotLoopProtection,
): Promise<DispatchedChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<ChannelTurnResult>;
export async function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<ChannelTurnResult> {
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<ChannelTurnPlan["botLoopProtection"]>;
},
): Promise<ChannelTurnResult>;
export function dispatchChannelInboundTurn(
plan: Omit<ChannelTurnPlan, "botLoopProtection"> & { botLoopProtection?: undefined },
): Promise<DispatchedChannelTurnResult>;
export function dispatchChannelInboundTurn(plan: ChannelTurnPlan): Promise<ChannelTurnResult>;
export async function dispatchChannelInboundTurn(
plan: ChannelTurnPlan,
): Promise<ChannelTurnResult> {
return await dispatchAssembledChannelTurn(
assembleResolvedChannelTurn(plan) as AssembledChannelTurn,
);
}
function isPreparedChannelTurn<TDispatchResult>(
value: AssembledChannelTurn | PreparedChannelTurn<TDispatchResult>,
): value is PreparedChannelTurn<TDispatchResult> & {
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
} {
return "runDispatch" in value;
}
async function dispatchResolvedChannelTurn<TDispatchResult>(
params: (AssembledChannelTurn | PreparedChannelTurn<TDispatchResult>) & {
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
log?: (event: ChannelTurnLogEvent) => void;
messageId?: string;
},
): Promise<ChannelTurnResult<TDispatchResult>> {
if (isPreparedChannelTurn(params)) {
return await runPreparedChannelTurn(params);
}
return (await dispatchAssembledChannelTurn(params)) as ChannelTurnResult<TDispatchResult>;
}
async function runPreparedChannelTurnCore<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(
params: PreparedChannelTurn<TDispatchResult>,
options: { suppressObserveOnlyDispatch: boolean },
): Promise<ChannelTurnResult<TDispatchResult>> {
const trace = createDiagnosticTraceContextFromActiveScope();
return await runWithDiagnosticTraceContext(trace, () =>
runPreparedChannelTurnCoreInTrace(params, options),
);
}
async function runPreparedChannelTurnCoreInTrace<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(
params: PreparedChannelTurn<TDispatchResult>,
options: { suppressObserveOnlyDispatch: boolean },
): Promise<ChannelTurnResult<TDispatchResult>> {
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<TDispatchResult> =
PreparedChannelTurn<TDispatchResult> & {
botLoopProtection: NonNullable<PreparedChannelTurn<TDispatchResult>["botLoopProtection"]>;
};
type PreparedChannelTurnWithoutBotLoopProtection<TDispatchResult> = Omit<
PreparedChannelTurn<TDispatchResult>,
"botLoopProtection"
> & {
botLoopProtection?: undefined;
};
function runPreparedChannelTurn<TDispatchResult = DispatchedChannelTurnResult["dispatchResult"]>(
params: PreparedChannelTurnWithBotLoopProtection<TDispatchResult>,
): Promise<ChannelTurnResult<TDispatchResult>>;
function runPreparedChannelTurn<TDispatchResult = DispatchedChannelTurnResult["dispatchResult"]>(
params: PreparedChannelTurnWithoutBotLoopProtection<TDispatchResult>,
): Promise<DispatchedChannelTurnResult<TDispatchResult>>;
function runPreparedChannelTurn<TDispatchResult = DispatchedChannelTurnResult["dispatchResult"]>(
params: PreparedChannelTurn<TDispatchResult>,
): Promise<ChannelTurnResult<TDispatchResult>>;
async function runPreparedChannelTurn<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(params: PreparedChannelTurn<TDispatchResult>): Promise<ChannelTurnResult<TDispatchResult>> {
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<TDispatchResult>;
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<TDispatchResult>;
result = dispatchResult.dispatched ? { ...dispatchResult, admission } : dispatchResult;
} catch (err) {
const failedResult: ChannelTurnResult<TDispatchResult> = {
@@ -857,4 +369,3 @@ async function runChannelTurn<
}
export const runChannelInboundEvent = runChannelTurn;
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+228
View File
@@ -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<TDispatchResult>(
value: ChannelTurnResolved<TDispatchResult>,
): AssembledChannelTurn | PreparedChannelTurn<TDispatchResult> {
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<AssembledChannelTurn, "dispatcherOptions" | "replyOptions"> {
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<NonNullable<ChannelEventDeliveryAdapter["onDelivered"]>>[1];
result: Parameters<NonNullable<ChannelEventDeliveryAdapter["onDelivered"]>>[2];
}): Promise<void> {
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<AssembledChannelTurn["botLoopProtection"]>;
};
type AssembledChannelTurnWithoutBotLoopProtection = Omit<
AssembledChannelTurn,
"botLoopProtection"
> & {
botLoopProtection?: undefined;
};
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurnWithBotLoopProtection,
): Promise<ChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurnWithoutBotLoopProtection,
): Promise<DispatchedChannelTurnResult>;
export function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<ChannelTurnResult>;
export async function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<ChannelTurnResult> {
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";
+29 -1
View File
@@ -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,
};
@@ -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" },
@@ -169,7 +169,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
const storePath = params.storePath as string;
const delivery = params.delivery as {
deliver: (payload: unknown, info: unknown) => Promise<unknown>;
onError?: (err: unknown, info: { kind: string }) => void;
onDelivered?: (payload: unknown, info: unknown, result: unknown) => Promise<void> | 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<PluginRuntime> =
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: unknown, info: unknown) => Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
deliver: (payload: unknown, info: unknown) => Promise<unknown>;
onError?: (err: unknown, info: unknown) => void;
};
replyOptions?: unknown;
replyResolver?: unknown;
@@ -215,7 +216,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
...dispatcherPipeline,
...(params.dispatcherOptions as Record<string, unknown> | 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<PluginRuntime> =
};
}
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<PluginRuntime["channel"]["inbound"]["runPreparedReply"]>[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<PluginRuntime["channel"]["inbound"]["runPreparedReply"]>[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,
@@ -125,7 +125,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'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";',