mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 () => {}),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
+4
-20
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
@@ -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";',
|
||||
|
||||
Reference in New Issue
Block a user