refactor(channels): route inbound turns through kernel

This commit is contained in:
Peter Steinberger
2026-04-30 04:08:44 +01:00
parent 6e73101df3
commit ffe67e9cdc
31 changed files with 1827 additions and 1389 deletions

View File

@@ -4,7 +4,7 @@ import {
resolveEnvelopeFormatOptions, resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound"; } from "openclaw/plugin-sdk/channel-inbound";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -270,83 +270,97 @@ export async function dispatchDiscordComponentEvent(params: {
startId: params.replyToId, startId: params.replyToId,
}); });
await runPreparedInboundReplyTurn({ await runInboundReplyTurn({
channel: "discord", channel: "discord",
accountId, accountId,
routeSessionKey: sessionKey, raw: interaction,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession, id: interaction.id,
record: { rawText: ctxPayload.RawBody ?? "",
updateLastRoute: interactionCtx.isDirectMessage textForAgent: ctxPayload.BodyForAgent,
? { textForCommands: ctxPayload.CommandBody,
sessionKey: route.mainSessionKey, raw: interaction,
channel: "discord", }),
to: resolveTurn: () => ({
resolveDiscordComponentOriginatingTo(interactionCtx) ?? channel: "discord",
`user:${interactionCtx.userId}`, accountId,
accountId, routeSessionKey: sessionKey,
mainDmOwnerPin: pinnedMainDmOwner storePath,
? { ctxPayload,
ownerRecipient: pinnedMainDmOwner, recordInboundSession,
senderRecipient: interactionCtx.userId, record: {
onSkip: ({ ownerRecipient, senderRecipient }) => { updateLastRoute: interactionCtx.isDirectMessage
logVerbose( ? {
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, sessionKey: route.mainSessionKey,
); channel: "discord",
}, to:
} resolveDiscordComponentOriginatingTo(interactionCtx) ??
: undefined, `user:${interactionCtx.userId}`,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
},
},
runDispatch: () =>
dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
replyOptions: { onModelSelected },
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
deliver: async (payload) => {
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg: ctx.cfg,
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: interaction.client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId, accountId,
}), mainDmOwnerPin: pinnedMainDmOwner
tableMode, ? {
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), ownerRecipient: pinnedMainDmOwner,
mediaLocalRoots, senderRecipient: interactionCtx.userId,
}); onSkip: ({ ownerRecipient, senderRecipient }) => {
replyReference.markSent(); logVerbose(
}, `discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
onReplyStart: async () => { );
try { },
const { sendTyping } = await loadTypingRuntime(); }
await sendTyping({ rest: feedbackRest, channelId: typingChannelId }); : undefined,
} catch (err) { }
logVerbose(`discord: typing failed for component reply: ${String(err)}`); : undefined,
} onRecordError: (err) => {
}, logVerbose(`discord: failed updating component session meta: ${String(err)}`);
onError: (err) => {
logError(`discord component dispatch failed: ${String(err)}`);
}, },
}, },
runDispatch: () =>
dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
replyOptions: { onModelSelected },
dispatcherOptions: {
...replyPipeline,
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
deliver: async (payload) => {
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg: ctx.cfg,
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: interaction.client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId,
}),
tableMode,
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
mediaLocalRoots,
});
replyReference.markSent();
},
onReplyStart: async () => {
try {
const { sendTyping } = await loadTypingRuntime();
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
} catch (err) {
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
}
},
onError: (err) => {
logError(`discord component dispatch failed: ${String(err)}`);
},
},
}),
}), }),
},
}); });
} }

View File

@@ -15,13 +15,12 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { import {
hasFinalInboundReplyDispatch, hasFinalInboundReplyDispatch,
runPreparedInboundReplyTurn, runInboundReplyTurn,
} from "openclaw/plugin-sdk/inbound-reply-dispatch"; } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
@@ -480,109 +479,135 @@ export async function processDiscordMessage(
await settleDispatchBeforeStart(); await settleDispatchBeforeStart();
return; return;
} }
const preparedResult = await runPreparedInboundReplyTurn({ const preparedResult = await runInboundReplyTurn({
channel: "discord", channel: "discord",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: persistedSessionKey, raw: ctx,
storePath: turn.storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession, id: message.id,
record: turn.record, timestamp: message.timestamp ? Date.parse(message.timestamp) : undefined,
onPreDispatchFailure: settleDispatchBeforeStart, rawText: text,
runDispatch: () => textForAgent: ctxPayload.BodyForAgent,
dispatchInboundMessage({ textForCommands: ctxPayload.CommandBody,
ctx: ctxPayload, raw: message,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
abortSignal,
skillFilter: channelConfig?.skills,
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
(typeof resolvedBlockStreamingEnabled === "boolean"
? !resolvedBlockStreamingEnabled
: undefined)),
onPartialReply: draftPreview.draftStream
? (payload) => draftPreview.updateFromPartial(payload.text)
: undefined,
onAssistantMessageStart: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onReasoningEnd: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled
? true
: undefined,
onReasoningStream: async () => {
await statusReactions.setThinking();
},
onToolStart: async (payload) => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
);
},
onItemEvent: async (payload) => {
draftPreview.pushToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
draftPreview.pushToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
draftPreview.pushToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
onCompactionStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setCompacting();
},
onCompactionEnd: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
statusReactions.cancelPending();
await statusReactions.setThinking();
},
},
}), }),
resolveTurn: () => ({
channel: "discord",
accountId: route.accountId,
routeSessionKey: persistedSessionKey,
storePath: turn.storePath,
ctxPayload,
recordInboundSession,
record: turn.record,
history: {
isGroup: isGuildMessage,
historyKey: messageChannelId,
historyMap: guildHistories,
limit: historyLimit,
},
onPreDispatchFailure: settleDispatchBeforeStart,
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
abortSignal,
skillFilter: channelConfig?.skills,
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
(typeof resolvedBlockStreamingEnabled === "boolean"
? !resolvedBlockStreamingEnabled
: undefined)),
onPartialReply: draftPreview.draftStream
? (payload) => draftPreview.updateFromPartial(payload.text)
: undefined,
onAssistantMessageStart: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onReasoningEnd: draftPreview.draftStream
? draftPreview.handleAssistantMessageBoundary
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages: draftPreview.previewToolProgressEnabled
? true
: undefined,
onReasoningStream: async () => {
await statusReactions.setThinking();
},
onToolStart: async (payload) => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
);
},
onItemEvent: async (payload) => {
draftPreview.pushToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
draftPreview.pushToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
draftPreview.pushToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
draftPreview.pushToolProgress(
payload.summary ?? payload.title ?? "patch applied",
);
},
onCompactionStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setCompacting();
},
onCompactionEnd: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
statusReactions.cancelPending();
await statusReactions.setThinking();
},
},
}),
}),
},
}); });
if (!preparedResult.dispatched) {
return;
}
dispatchResult = preparedResult.dispatchResult; dispatchResult = preparedResult.dispatchResult;
if (isProcessAborted(abortSignal)) { if (isProcessAborted(abortSignal)) {
dispatchAborted = true; dispatchAborted = true;
@@ -646,27 +671,14 @@ export async function processDiscordMessage(
return; return;
} }
if (!hasFinalInboundReplyDispatch(dispatchResult)) { const finalDispatchResult = dispatchResult;
if (isGuildMessage) { if (!finalDispatchResult || !hasFinalInboundReplyDispatch(finalDispatchResult)) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
});
}
return; return;
} }
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
const finalCount = dispatchResult.counts.final; const finalCount = finalDispatchResult.counts.final;
logVerbose( logVerbose(
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
); );
} }
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
});
}
} }

View File

@@ -111,6 +111,39 @@ describe("broadcast dispatch", () => {
saveMediaBuffer: mockSaveMediaBuffer, saveMediaBuffer: mockSaveMediaBuffer,
}, },
turn: { turn: {
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return {
admission: { kind: "drop" as const, reason: "ingest-null" },
dispatched: false,
};
}
const eventClass = {
kind: "message" as const,
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");
}
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
updateLastRoute: turn.record?.updateLastRoute,
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult: await turn.runDispatch(),
};
}),
runPrepared: vi.fn( runPrepared: vi.fn(
async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => { async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
await turn.recordInboundSession({ await turn.recordInboundSession({

View File

@@ -198,6 +198,16 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
buildPairingReply: vi.fn(), buildPairingReply: vi.fn(),
}, },
turn: { turn: {
run: vi.fn(async (params) => {
const input = await params.adapter.ingest(params.raw);
const turn = await params.adapter.resolveTurn(input, {
kind: "message",
canStartAgentTurn: true,
});
return {
dispatchResult: await turn.runDispatch(),
};
}),
runPrepared: vi.fn(async (params) => ({ runPrepared: vi.fn(async (params) => ({
dispatchResult: await params.runDispatch(), dispatchResult: await params.runDispatch(),
})), })),

View File

@@ -1312,31 +1312,46 @@ export async function handleFeishuMessage(params: {
log( log(
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`, `feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
); );
await core.channel.turn.runPrepared({ await core.channel.turn.run({
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: agentSessionKey, raw: ctx,
storePath: agentStorePath, adapter: {
ctxPayload: agentCtx, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: ctx.messageId,
record: agentRecord, timestamp: messageCreateTimeMs,
onPreDispatchFailure: () => rawText: ctx.content,
core.channel.reply.settleReplyDispatcher({ textForAgent: agentCtx.BodyForAgent,
dispatcher, textForCommands: agentCtx.CommandBody,
onSettled: () => markDispatchIdle(), raw: ctx,
}), }),
runDispatch: () => resolveTurn: () => ({
core.channel.reply.withReplyDispatcher({ channel: "feishu",
dispatcher, accountId: route.accountId,
onSettled: () => markDispatchIdle(), routeSessionKey: agentSessionKey,
run: () => storePath: agentStorePath,
core.channel.reply.dispatchReplyFromConfig({ ctxPayload: agentCtx,
ctx: agentCtx, recordInboundSession: core.channel.session.recordInboundSession,
cfg, record: agentRecord,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher, dispatcher,
replyOptions, onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher,
replyOptions,
}),
}), }),
}), }),
},
}); });
} else { } else {
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply). // Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
@@ -1356,24 +1371,39 @@ export async function handleFeishuMessage(params: {
log( log(
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`, `feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
); );
await core.channel.turn.runPrepared({ await core.channel.turn.run({
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: agentSessionKey, raw: ctx,
storePath: agentStorePath, adapter: {
ctxPayload: agentCtx, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: ctx.messageId,
record: agentRecord, timestamp: messageCreateTimeMs,
runDispatch: () => rawText: ctx.content,
core.channel.reply.withReplyDispatcher({ textForAgent: agentCtx.BodyForAgent,
dispatcher: noopDispatcher, textForCommands: agentCtx.CommandBody,
run: () => raw: ctx,
core.channel.reply.dispatchReplyFromConfig({ }),
ctx: agentCtx, resolveTurn: () => ({
cfg, channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher: noopDispatcher, dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher,
}),
}), }),
}), }),
},
}); });
} }
}; };
@@ -1445,49 +1475,66 @@ export async function handleFeishuMessage(params: {
}); });
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`); log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { dispatchResult } = await core.channel.turn.runPrepared({ const turnResult = await core.channel.turn.run({
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, raw: ctx,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: ctx.messageId,
record: { timestamp: messageCreateTimeMs,
onRecordError: (err) => { rawText: ctx.content,
log( textForAgent: ctxPayload.BodyForAgent,
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`, textForCommands: ctxPayload.CommandBody,
); raw: ctx,
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}), }),
runDispatch: () => resolveTurn: () => ({
core.channel.reply.withReplyDispatcher({ channel: "feishu",
dispatcher, accountId: route.accountId,
onSettled: () => { routeSessionKey: route.sessionKey,
markDispatchIdle(); storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
);
},
}, },
run: () => history: {
core.channel.reply.dispatchReplyFromConfig({ isGroup,
ctx: ctxPayload, historyKey,
cfg, historyMap: chatHistories,
limit: historyLimit,
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher, dispatcher,
replyOptions, onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
}), }),
}), }),
},
}); });
const { queuedFinal, counts } = dispatchResult; if (!turnResult.dispatched) {
return;
if (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({
historyMap: chatHistories,
historyKey,
limit: historyLimit,
});
} }
const { dispatchResult } = turnResult;
const { queuedFinal, counts } = dispatchResult;
log( log(
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`, `feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,

View File

@@ -134,6 +134,26 @@ function createTestRuntime(overrides?: {
recordInboundSession, recordInboundSession,
}, },
turn: { turn: {
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return {
admission: { kind: "drop" as const, reason: "ingest-null" },
dispatched: false,
};
}
const eventClass = {
kind: "message" as const,
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");
}
return await runPrepared(
turn as Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0],
);
}) as unknown as PluginRuntime["channel"]["turn"]["run"],
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"], runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
}, },
pairing: { pairing: {

View File

@@ -241,42 +241,58 @@ export async function handleFeishuCommentEvent(
`feishu[${account.accountId}]: dispatching drive comment to agent ` + `feishu[${account.accountId}]: dispatching drive comment to agent ` +
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`, `(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
); );
const { dispatchResult } = await core.channel.turn.runPrepared({ const turnResult = await core.channel.turn.run({
channel: "feishu", channel: "feishu",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: commentSessionKey, raw: turn,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: turn.messageId,
record: { timestamp: parseTimestampMs(turn.timestamp),
onRecordError: (err) => { rawText: ctxPayload.RawBody ?? "",
error( textForAgent: ctxPayload.BodyForAgent,
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`, textForCommands: ctxPayload.CommandBody,
); raw: turn,
}, }),
}, resolveTurn: () => ({
onPreDispatchFailure: async () => { channel: "feishu",
dispatchSettledBeforeStart = true; accountId: route.accountId,
await core.channel.reply.settleReplyDispatcher({ routeSessionKey: commentSessionKey,
dispatcher, storePath,
onSettled: () => { ctxPayload,
markRunComplete(); recordInboundSession: core.channel.session.recordInboundSession,
markDispatchIdle(); record: {
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
}, },
}); onPreDispatchFailure: async () => {
}, dispatchSettledBeforeStart = true;
runDispatch: () => await core.channel.reply.settleReplyDispatcher({
core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher, dispatcher,
replyOptions, onSettled: () => {
markRunComplete();
markDispatchIdle();
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
}), }),
}), }),
},
}); });
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
const queuedFinal = dispatchResult?.queuedFinal ?? false; const queuedFinal = dispatchResult?.queuedFinal ?? false;
const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 }; const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
log( log(

View File

@@ -12,7 +12,7 @@ import {
} from "openclaw/plugin-sdk/conversation-runtime"; } from "openclaw/plugin-sdk/conversation-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@@ -435,59 +435,74 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
}, },
}); });
await runPreparedInboundReplyTurn({ await runInboundReplyTurn({
channel: "imessage", channel: "imessage",
accountId: decision.route.accountId, accountId: decision.route.accountId,
routeSessionKey: decision.route.sessionKey, raw: decision,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession, id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
record: { timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined,
updateLastRoute: rawText: ctxPayload.RawBody ?? "",
!decision.isGroup && updateTarget textForAgent: ctxPayload.BodyForAgent,
? { textForCommands: ctxPayload.CommandBody,
sessionKey: decision.route.mainSessionKey, raw: decision,
channel: "imessage",
to: updateTarget,
accountId: decision.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && decision.senderNormalized
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: decision.senderNormalized,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
},
history: {
isGroup: decision.isGroup,
historyKey: decision.historyKey,
historyMap: groupHistories,
limit: historyLimit,
},
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected,
},
}), }),
resolveTurn: () => ({
channel: "imessage",
accountId: decision.route.accountId,
routeSessionKey: decision.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute:
!decision.isGroup && updateTarget
? {
sessionKey: decision.route.mainSessionKey,
channel: "imessage",
to: updateTarget,
accountId: decision.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && decision.senderNormalized
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: decision.senderNormalized,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`imessage: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
},
history: {
isGroup: decision.isGroup,
historyKey: decision.historyKey,
historyMap: groupHistories,
limit: historyLimit,
},
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected,
},
}),
}),
},
}); });
} }

View File

@@ -231,7 +231,7 @@ export async function monitorLineProvider(
}); });
const core = getLineRuntime(); const core = getLineRuntime();
const { dispatchResult } = await core.channel.turn.run({ const turnResult = await core.channel.turn.run({
channel: "line", channel: "line",
accountId: route.accountId, accountId: route.accountId,
raw: ctx, raw: ctx,
@@ -316,6 +316,7 @@ export async function monitorLineProvider(
}), }),
}, },
}); });
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
if (!hasFinalInboundReplyDispatch(dispatchResult)) { if (!hasFinalInboundReplyDispatch(dispatchResult)) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`); logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
} }

View File

@@ -142,6 +142,28 @@ export function createMatrixHandlerTestHarness(
}; };
}, },
); );
const run = vi.fn(
async (params: Parameters<MatrixMonitorHandlerParams["core"]["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
}
const eventClass = (await params.adapter.classify?.(input)) ?? {
kind: "message" as const,
canStartAgentTurn: true,
};
const preflightResult = await params.adapter.preflight?.(input, eventClass);
const preflight =
preflightResult && "kind" in preflightResult
? { admission: preflightResult }
: (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) {
return await runPrepared(turn);
}
throw new Error("matrix test helper only supports prepared turn dispatch");
},
);
const dmPolicy = options.dmPolicy ?? "open"; const dmPolicy = options.dmPolicy ?? "open";
const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []); const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []);
const cfgForHandler = const cfgForHandler =
@@ -229,6 +251,7 @@ export function createMatrixHandlerTestHarness(
}), }),
}, },
turn: { turn: {
run,
runPrepared, runPrepared,
}, },
reactions: { reactions: {

View File

@@ -1829,106 +1829,127 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onIdle: typingCallbacks.onIdle, onIdle: typingCallbacks.onIdle,
}); });
const { dispatchResult } = await core.channel.turn.runPrepared({ const turnResult = await core.channel.turn.run({
channel: "matrix", channel: "matrix",
accountId: _route.accountId, accountId: _route.accountId,
routeSessionKey: _route.sessionKey, raw: event,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: _messageId,
record: { rawText: bodyText,
updateLastRoute: isDirectMessage textForAgent: ctxPayload.BodyForAgent,
? { textForCommands: ctxPayload.CommandBody,
sessionKey: _route.mainSessionKey, raw: event,
channel: "matrix", }),
to: `room:${roomId}`, resolveTurn: () => ({
accountId: _route.accountId, channel: "matrix",
accountId: _route.accountId,
routeSessionKey: _route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute: isDirectMessage
? {
sessionKey: _route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: _route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn("failed updating session meta", {
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
});
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
}),
runDispatch: async () => {
if (
sharedDmContextNotice &&
markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)
) {
client
.sendMessage(roomId, {
msgtype: "m.notice",
body: sharedDmContextNotice,
})
.catch((err) => {
logVerboseMessage(
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
);
});
} }
: undefined,
onRecordError: (err) => { return await core.channel.reply.withReplyDispatcher({
logger.warn("failed updating session meta", { dispatcher,
error: String(err), onSettled: () => {
storePath, markDispatchIdle();
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey, },
}); run: async () => {
}, try {
}, return await core.channel.reply.dispatchReplyFromConfig({
onPreDispatchFailure: () => ctx: ctxPayload,
core.channel.reply.settleReplyDispatcher({ cfg,
dispatcher, dispatcher,
onSettled: () => { replyOptions: {
markRunComplete(); ...replyOptions,
markDispatchIdle(); skillFilter: roomConfig?.skills,
// Keep block streaming enabled when explicitly requested, even
// with draft previews on. The draft remains the live preview
// for the current assistant block, while block deliveries
// finalize completed blocks into their own preserved events.
disableBlockStreaming: !blockStreamingEnabled,
onPartialReply: draftStream
? (payload) => {
latestDraftFullText = payload.text ?? "";
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
updateDraftFromLatestFullText();
}
: undefined,
onBlockReplyQueued: draftStream
? (payload, context) => {
if (payload.isCompactionNotice === true) {
return;
}
queueDraftBlockBoundary(payload, context);
}
: undefined,
// Reset draft boundary bookkeeping on assistant message
// boundaries so post-tool blocks stream from a fresh
// cumulative payload (payload.text resets upstream).
onAssistantMessageStart: draftStream
? () => {
resetDraftBlockOffsets();
resetPreviewToolProgress();
}
: undefined,
...buildPreviewToolProgressReplyOptions(),
onModelSelected,
},
});
} finally {
markRunComplete();
}
},
});
}, },
}), }),
runDispatch: async () => {
if (sharedDmContextNotice && markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)) {
client
.sendMessage(roomId, {
msgtype: "m.notice",
body: sharedDmContextNotice,
})
.catch((err) => {
logVerboseMessage(
`matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
);
});
}
return await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: async () => {
try {
return await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
// Keep block streaming enabled when explicitly requested, even
// with draft previews on. The draft remains the live preview
// for the current assistant block, while block deliveries
// finalize completed blocks into their own preserved events.
disableBlockStreaming: !blockStreamingEnabled,
onPartialReply: draftStream
? (payload) => {
latestDraftFullText = payload.text ?? "";
suppressPreviewToolProgressForAnswerText(latestDraftFullText);
updateDraftFromLatestFullText();
}
: undefined,
onBlockReplyQueued: draftStream
? (payload, context) => {
if (payload.isCompactionNotice === true) {
return;
}
queueDraftBlockBoundary(payload, context);
}
: undefined,
// Reset draft boundary bookkeeping on assistant message
// boundaries so post-tool blocks stream from a fresh
// cumulative payload (payload.text resets upstream).
onAssistantMessageStart: draftStream
? () => {
resetDraftBlockOffsets();
resetPreviewToolProgress();
}
: undefined,
...buildPreviewToolProgressReplyOptions(),
onModelSelected,
},
});
} finally {
markRunComplete();
}
},
});
}, },
}); });
if (!turnResult.dispatched) {
return;
}
const { dispatchResult } = turnResult;
const { queuedFinal, counts } = dispatchResult; const { queuedFinal, counts } = dispatchResult;
if (finalReplyDeliveryFailed) { if (finalReplyDeliveryFailed) {
if (retryableReplyDeliveryFailed) { if (retryableReplyDeliveryFailed) {

View File

@@ -69,7 +69,6 @@ import {
buildAgentMediaPayload, buildAgentMediaPayload,
buildModelsProviderData, buildModelsProviderData,
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
createChannelPairingController, createChannelPairingController,
createChannelReplyPipeline, createChannelReplyPipeline,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
@@ -1721,74 +1720,95 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
let dispatchSettledBeforeStart = false; let dispatchSettledBeforeStart = false;
try { try {
await core.channel.turn.runPrepared({ await core.channel.turn.run({
channel: "mattermost", channel: "mattermost",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, raw: post,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: post.id ?? `${to}:${Date.now()}`,
record: { timestamp: post.create_at ?? undefined,
updateLastRoute: rawText,
kind === "direct" textForAgent: ctxPayload.BodyForAgent,
? { textForCommands: ctxPayload.CommandBody,
sessionKey: route.mainSessionKey, raw: post,
channel: "mattermost", }),
to, resolveTurn: () => ({
accountId: route.accountId, channel: "mattermost",
} accountId: route.accountId,
: undefined, routeSessionKey: route.sessionKey,
onRecordError: (err) => { storePath,
logVerboseMessage( ctxPayload,
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`, recordInboundSession: core.channel.session.recordInboundSession,
); record: {
}, updateLastRoute:
}, kind === "direct"
onPreDispatchFailure: async () => { ? {
dispatchSettledBeforeStart = true; sessionKey: route.mainSessionKey,
await core.channel.reply.settleReplyDispatcher({ channel: "mattermost",
dispatcher, to,
onSettled: () => { accountId: route.accountId,
markRunComplete();
markDispatchIdle();
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming: true,
onModelSelected,
onPartialReply: (payload) => {
updateDraftFromPartial(payload.text);
},
onAssistantMessageStart: () => {
lastPartialText = "";
},
onReasoningEnd: () => {
lastPartialText = "";
},
onReasoningStream: async () => {
if (!lastPartialText) {
draftStream.update("Thinking…");
} }
}, : undefined,
onToolStart: async (payload) => { onRecordError: (err) => {
draftStream.update(buildMattermostToolStatusText(payload)); logVerboseMessage(
}, `mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
);
},
},
history: {
isGroup: Boolean(historyKey),
historyKey: historyKey ?? undefined,
historyMap: channelHistories,
limit: historyLimit,
},
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
}, },
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming: true,
onModelSelected,
onPartialReply: (payload) => {
updateDraftFromPartial(payload.text);
},
onAssistantMessageStart: () => {
lastPartialText = "";
},
onReasoningEnd: () => {
lastPartialText = "";
},
onReasoningStream: async () => {
if (!lastPartialText) {
draftStream.update("Thinking…");
}
},
onToolStart: async (payload) => {
draftStream.update(buildMattermostToolStatusText(payload));
},
},
}),
}), }),
}), }),
},
}); });
} finally { } finally {
try { try {
@@ -1800,13 +1820,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
markRunComplete(); markRunComplete();
} }
} }
if (historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
});
}
}, },
}); });
if (replayResult === "duplicate") { if (replayResult === "duplicate") {

View File

@@ -40,6 +40,26 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
}; };
}, },
); );
const run = vi.fn(async (params: Parameters<PluginRuntime["channel"]["turn"]["run"]>[0]) => {
const input = await params.adapter.ingest(params.raw);
if (!input) {
return { admission: { kind: "drop" as const, reason: "ingest-null" }, dispatched: false };
}
const eventClass = (await params.adapter.classify?.(input)) ?? {
kind: "message" as const,
canStartAgentTurn: true,
};
const preflightResult = await params.adapter.preflight?.(input, eventClass);
const preflight =
preflightResult && "kind" in preflightResult
? { admission: preflightResult }
: (preflightResult ?? {});
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
if ("runDispatch" in turn) {
return await runPrepared(turn);
}
throw new Error("msteams test runtime only supports prepared turn dispatch");
});
setMSTeamsRuntime({ setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false }, logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() }, system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() },
@@ -90,6 +110,7 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}), ...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
}, },
turn: { turn: {
run: run as unknown as PluginRuntime["channel"]["turn"]["run"],
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"], runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
}, },
}, },

View File

@@ -18,7 +18,6 @@ import {
} from "openclaw/plugin-sdk/inbound-reply-dispatch"; } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
type HistoryEntry, type HistoryEntry,
@@ -840,33 +839,57 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey }); log.info("dispatching to agent", { sessionKey: route.sessionKey });
try { try {
const { dispatchResult } = await core.channel.turn.runPrepared({ const turnResult = await core.channel.turn.run({
channel: "msteams", channel: "msteams",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, raw: context,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: core.channel.session.recordInboundSession, id: activity.id ?? `${teamsFrom}:${Date.now()}`,
record: { timestamp: timestamp?.getTime(),
onRecordError: (err) => { rawText: rawBody,
logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`); textForAgent: bodyForAgent,
}, textForCommands: commandBody,
}, raw: activity,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}), }),
runDispatch: () => resolveTurn: () => ({
dispatchReplyFromConfigWithSettledDispatcher({ channel: "msteams",
cfg, accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload, ctxPayload,
dispatcher, recordInboundSession: core.channel.session.recordInboundSession,
onSettled: () => markDispatchIdle(), record: {
replyOptions, onRecordError: (err) => {
configOverride, logVerboseMessage(
`msteams: failed updating session meta: ${formatUnknownError(err)}`,
);
},
},
history: {
isGroup: isRoomish,
historyKey,
historyMap: conversationHistories,
limit: historyLimit,
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
dispatchReplyFromConfigWithSettledDispatcher({
cfg,
ctxPayload,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
configOverride,
}),
}), }),
},
}); });
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
const queuedFinal = dispatchResult?.queuedFinal ?? false; const queuedFinal = dispatchResult?.queuedFinal ?? false;
const counts = resolveInboundReplyDispatchCounts(dispatchResult); const counts = resolveInboundReplyDispatchCounts(dispatchResult);
const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult); const hasFinalResponse = hasFinalInboundReplyDispatch(dispatchResult);
@@ -874,26 +897,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatch complete", { queuedFinal, counts }); log.info("dispatch complete", { queuedFinal, counts });
if (!hasFinalResponse) { if (!hasFinalResponse) {
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
return; return;
} }
const finalCount = counts.final; const finalCount = counts.final;
logVerboseMessage( logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
); );
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
} catch (err) { } catch (err) {
log.error("dispatch failed", { error: formatUnknownError(err) }); log.error("dispatch failed", { error: formatUnknownError(err) });
runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`); runtime.error?.(`msteams dispatch failed: ${formatUnknownError(err)}`);

View File

@@ -69,9 +69,24 @@ function makeRuntime(): GatewayPluginRuntime {
recordInboundSession: vi.fn(async () => undefined), recordInboundSession: vi.fn(async () => undefined),
}, },
turn: { turn: {
runPrepared: vi.fn(async (rawParams: unknown) => { run: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> }; const params = rawParams as {
return { dispatchResult: await params.runDispatch() }; raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (...args: unknown[]) => unknown;
};
};
const input = await params.adapter.ingest(params.raw);
const turn = (await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
)) as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await turn.runDispatch() };
}), }),
}, },
text: { text: {

View File

@@ -141,9 +141,24 @@ function makeRuntime(params: {
recordInboundSession: vi.fn(async () => undefined), recordInboundSession: vi.fn(async () => undefined),
}, },
turn: { turn: {
runPrepared: vi.fn(async (rawParams: unknown) => { run: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> }; const params = rawParams as {
return { dispatchResult: await params.runDispatch() }; raw: unknown;
adapter: {
ingest: (raw: unknown) => unknown;
resolveTurn: (...args: unknown[]) => unknown;
};
};
const input = await params.adapter.ingest(params.raw);
const turn = (await params.adapter.resolveTurn(
input,
{
kind: "message",
canStartAgentTurn: true,
},
{},
)) as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await turn.runDispatch() };
}), }),
}, },
text: { text: {

View File

@@ -10,6 +10,7 @@
* Separated from gateway.ts for testability and to keep handleMessage thin. * Separated from gateway.ts for testability and to keep handleMessage thin.
*/ */
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { import {
parseAndSendMediaTags, parseAndSendMediaTags,
sendPlainReply, sendPlainReply,
@@ -224,238 +225,256 @@ export async function dispatchOutbound(
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
agentId, agentId,
}); });
const dispatchPromise = runtime.channel.turn.runPrepared({ const dispatchPromise = runtime.channel.turn.run({
channel: "qqbot", channel: "qqbot",
accountId: inbound.route.accountId, accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey, raw: inbound,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: runtime.channel.session.recordInboundSession, id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
record: { rawText: ctxPayload.RawBody ?? "",
onRecordError: (err: unknown) => { textForAgent: ctxPayload.BodyForAgent,
log?.error( textForCommands: ctxPayload.CommandBody,
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`, raw: inbound,
); }),
}, resolveTurn: () => ({
}, channel: "qqbot",
runDispatch: () => accountId: inbound.route.accountId,
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ routeSessionKey: inbound.route.sessionKey,
ctx: ctxPayload, storePath,
cfg, ctxPayload,
dispatcherOptions: { recordInboundSession: runtime.channel.session.recordInboundSession,
responsePrefix: messagesConfig.responsePrefix, record: {
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => { onRecordError: (err: unknown) => {
hasResponse = true; log?.error(
`Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`,
);
},
},
runDispatch: () =>
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
hasResponse = true;
// ---- Tool deliver ---- // ---- Tool deliver ----
if (info.kind === "tool") { if (info.kind === "tool") {
toolDeliverCount++; toolDeliverCount++;
const toolText = (payload.text ?? "").trim(); const toolText = (payload.text ?? "").trim();
if (toolText) { if (toolText) {
toolTexts.push(toolText); toolTexts.push(toolText);
} }
if (payload.mediaUrls?.length) { if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls); toolMediaUrls.push(...payload.mediaUrls);
} }
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl); toolMediaUrls.push(payload.mediaUrl);
} }
if (hasBlockResponse && toolMediaUrls.length > 0) { if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls]; const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0; toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) { for (const mediaUrl of urlsToSend) {
try { try {
await sendMedia({ await sendMedia({
to: qualifiedTarget, to: qualifiedTarget,
text: "", text: "",
mediaUrl, mediaUrl,
accountId: account.accountId, accountId: account.accountId,
replyToId: event.messageId, replyToId: event.messageId,
account, account,
}); });
} catch {} } catch {}
} }
return; return;
} }
if (toolFallbackSent) { if (toolFallbackSent) {
return; return;
} }
if (toolOnlyTimeoutId) { if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) { if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId); clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++; toolRenewalCount++;
} else { } else {
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
}
}, TOOL_ONLY_TIMEOUT);
return; return;
} }
}
toolOnlyTimeoutId = setTimeout(async () => { // ---- Block deliver ----
if (!hasBlockResponse && !toolFallbackSent) { hasBlockResponse = true;
toolFallbackSent = true; inbound.typing.keepAlive?.stop();
try { if (timeoutId) {
await sendToolFallback(); clearTimeout(timeoutId);
} catch {} timeoutId = null;
} }
}, TOOL_ONLY_TIMEOUT); if (toolOnlyTimeoutId) {
return; clearTimeout(toolOnlyTimeoutId);
} toolOnlyTimeoutId = null;
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onDeliver(payload);
} catch (err) {
log?.error(
`Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const replyPreview = (payload.text ?? "").trim();
if (
event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Voice-intent plain text
if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) {
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
if (sentVoice) {
recordOutbound();
return;
}
}
// 4. Plain text + images/media
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg =
streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
} }
return typeof s === "object" && s !== null && s.mode === "off";
})(), if (streamingController && !streamingController.isTerminalPhase) {
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
try { try {
await streamingController.onPartialReply(payload); await streamingController.onDeliver(payload);
} catch (partialErr) { } catch (err) {
log?.error( log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`, `Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`,
); );
} }
},
} const replyPreview = (payload.text ?? "").trim();
: {}), if (
}, event.type === "group" &&
(replyPreview === "NO_REPLY" || replyPreview === "[SKIP]")
) {
log?.info(
`Model decided to skip group message (${replyPreview}) from ${event.senderId}`,
);
return;
}
if (streamingController.shouldFallbackToStatic) {
log?.info("Streaming API unavailable, falling back to static for this deliver");
} else {
recordOutbound();
return;
}
}
const quoteRef = event.msgIdx;
let quoteRefUsed = false;
const consumeQuoteRef = (): string | undefined => {
if (quoteRef && !quoteRefUsed) {
quoteRefUsed = true;
return quoteRef;
}
return undefined;
};
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
};
const deliverActx = { account, qualifiedTarget, log };
// 1. Media tags
const mediaResult = await parseAndSendMediaTags(
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
deliverDeps,
);
if (mediaResult.handled) {
recordOutbound();
return;
}
replyText = mediaResult.normalizedText;
// 2. Structured payload (QQBOT_PAYLOAD:)
const handled = await handleStructuredPayload(
replyCtx,
replyText,
recordOutbound,
replyDeps,
);
if (handled) {
return;
}
// 3. Voice-intent plain text
if (
payload.audioAsVoice === true &&
!payload.mediaUrl &&
!payload.mediaUrls?.length
) {
const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps);
if (sentVoice) {
recordOutbound();
return;
}
}
// 4. Plain text + images/media
await sendPlainReply(
payload,
replyText,
deliverEvent,
deliverActx,
sendWithRetry,
consumeQuoteRef,
toolMediaUrls,
deliverDeps,
);
recordOutbound();
},
onError: async (err: unknown) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
const streamErrMsg =
streamErr instanceof Error ? streamErr.message : String(streamErr);
log?.error(`Streaming onError failed: ${streamErrMsg}`);
}
if (!streamingController.shouldFallbackToStatic) {
return;
}
}
const errMsg = err instanceof Error ? err.message : String(err);
log?.error(`Dispatch error: ${errMsg}`);
hasResponse = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
? true
: (() => {
const s = account.config?.streaming;
if (s === false) {
return true;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
log?.error(
`Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`,
);
}
},
}
: {}),
},
}),
}), }),
},
}); });
try { try {
@@ -493,7 +512,10 @@ export async function dispatchOutbound(
// ============ ctxPayload builder ============ // ============ ctxPayload builder ============
function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown { function buildCtxPayload(
inbound: InboundContext,
runtime: GatewayPluginRuntime,
): FinalizedMsgContext {
const { event } = inbound; const { event } = inbound;
return runtime.channel.reply.finalizeInboundContext({ return runtime.channel.reply.finalizeInboundContext({
Body: inbound.body, Body: inbound.body,
@@ -549,5 +571,5 @@ function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime)
ReplyToIsQuote: inbound.replyTo.isQuote, ReplyToIsQuote: inbound.replyTo.isQuote,
} }
: {}), : {}),
}); }) as FinalizedMsgContext;
} }

View File

@@ -57,7 +57,7 @@ export interface GatewayPluginRuntime {
recordInboundSession: (params: unknown) => Promise<unknown>; recordInboundSession: (params: unknown) => Promise<unknown>;
}; };
turn: { turn: {
runPrepared: (params: unknown) => Promise<unknown>; run: (params: unknown) => Promise<unknown>;
}; };
text: { text: {
chunkMarkdownText: (text: string, limit: number) => string[]; chunkMarkdownText: (text: string, limit: number) => string[];

View File

@@ -25,7 +25,7 @@ import {
toInternalMessageReceivedContext, toInternalMessageReceivedContext,
triggerInternalHook, triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime"; } from "openclaw/plugin-sdk/hook-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
@@ -288,72 +288,85 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
}, },
}); });
await runPreparedInboundReplyTurn({ await runInboundReplyTurn({
channel: "signal", channel: "signal",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, raw: entry,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession, id: entry.messageId ?? `${entry.timestamp ?? Date.now()}`,
record: { timestamp: entry.timestamp,
updateLastRoute: !entry.isGroup rawText: entry.bodyText,
? { raw: entry,
sessionKey: route.mainSessionKey,
channel: "signal",
to: entry.senderRecipient,
accountId: route.accountId,
mainDmOwnerPin: (() => {
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: deps.cfg.session?.dmScope,
allowFrom: deps.allowFrom,
normalizeEntry: normalizeSignalAllowRecipient,
});
if (!pinnedOwner) {
return undefined;
}
return {
ownerRecipient: pinnedOwner,
senderRecipient: entry.senderRecipient,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
};
})(),
}
: undefined,
onRecordError: (err) => {
logVerbose(`signal: failed updating session meta: ${String(err)}`);
},
},
history: {
isGroup: entry.isGroup,
historyKey,
historyMap: deps.groupHistories,
limit: deps.historyLimit,
},
onPreDispatchFailure: () =>
settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}), }),
runDispatch: async () => { resolveTurn: () => ({
try { channel: "signal",
return await dispatchInboundMessage({ accountId: route.accountId,
ctx: ctxPayload, routeSessionKey: route.sessionKey,
cfg: deps.cfg, storePath,
dispatcher, ctxPayload,
replyOptions: { recordInboundSession,
...replyOptions, record: {
disableBlockStreaming: updateLastRoute: !entry.isGroup
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined, ? {
onModelSelected, sessionKey: route.mainSessionKey,
channel: "signal",
to: entry.senderRecipient,
accountId: route.accountId,
mainDmOwnerPin: (() => {
const pinnedOwner = resolvePinnedMainDmOwnerFromAllowlist({
dmScope: deps.cfg.session?.dmScope,
allowFrom: deps.allowFrom,
normalizeEntry: normalizeSignalAllowRecipient,
});
if (!pinnedOwner) {
return undefined;
}
return {
ownerRecipient: pinnedOwner,
senderRecipient: entry.senderRecipient,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`signal: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
};
})(),
}
: undefined,
onRecordError: (err) => {
logVerbose(`signal: failed updating session meta: ${String(err)}`);
}, },
}); },
} finally { history: {
markDispatchIdle(); isGroup: entry.isGroup,
} historyKey,
historyMap: deps.groupHistories,
limit: deps.historyLimit,
},
onPreDispatchFailure: () =>
settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
runDispatch: async () => {
try {
return await dispatchInboundMessage({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected,
},
});
} finally {
markDispatchIdle();
}
},
}),
}, },
}); });
} }

View File

@@ -19,8 +19,9 @@ import {
} from "openclaw/plugin-sdk/channel-streaming"; } from "openclaw/plugin-sdk/channel-streaming";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { import {
type ChannelTurnRecordOptions,
hasVisibleInboundReplyDispatch, hasVisibleInboundReplyDispatch,
runPreparedInboundReplyTurn, runInboundReplyTurn,
} from "openclaw/plugin-sdk/inbound-reply-dispatch"; } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
@@ -987,93 +988,111 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
let counts: { final?: number; block?: number } = {}; let counts: { final?: number; block?: number } = {};
let dispatchSettledBeforeStart = false; let dispatchSettledBeforeStart = false;
try { try {
const { dispatchResult } = await runPreparedInboundReplyTurn({ const turnResult = await runInboundReplyTurn({
channel: "slack", channel: "slack",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, raw: prepared.message,
storePath: prepared.turn.storePath, adapter: {
ctxPayload: prepared.ctxPayload, ingest: () => ({
recordInboundSession, id: prepared.message.ts ?? `${prepared.ctxPayload.From}:${Date.now()}`,
record: prepared.turn.record as Parameters<typeof runPreparedInboundReplyTurn>[0]["record"], timestamp: prepared.message.ts ? Number(prepared.message.ts) * 1000 : undefined,
onPreDispatchFailure: async () => { rawText: prepared.ctxPayload.RawBody ?? "",
dispatchSettledBeforeStart = true; textForAgent: prepared.ctxPayload.BodyForAgent,
await settleReplyDispatcher({ textForCommands: prepared.ctxPayload.CommandBody,
dispatcher, raw: prepared.message,
onSettled: () => markDispatchIdle(),
});
},
runDispatch: () =>
dispatchInboundMessage({
ctx: prepared.ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: prepared.channelConfig?.skills,
sourceReplyDeliveryMode,
hasRepliedRef,
disableBlockStreaming,
onModelSelected,
suppressDefaultToolProgressMessages: previewToolProgressEnabled ? true : undefined,
onPartialReply: useStreaming
? undefined
: !previewStreamingEnabled
? undefined
: async (payload) => {
updateDraftFromPartial(payload.text);
},
onAssistantMessageStart: onDraftBoundary,
onReasoningEnd: onDraftBoundary,
onReasoningStream: statusReactionsEnabled
? async () => {
await statusReactions.setThinking();
}
: undefined,
onToolStart: async (payload) => {
if (statusReactionsEnabled) {
await statusReactions.setTool(payload.name);
}
pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running");
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
},
}), }),
resolveTurn: () => ({
channel: "slack",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath: prepared.turn.storePath,
ctxPayload: prepared.ctxPayload,
recordInboundSession,
record: prepared.turn.record as ChannelTurnRecordOptions,
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
});
},
runDispatch: () =>
dispatchInboundMessage({
ctx: prepared.ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: prepared.channelConfig?.skills,
sourceReplyDeliveryMode,
hasRepliedRef,
disableBlockStreaming,
onModelSelected,
suppressDefaultToolProgressMessages: previewToolProgressEnabled ? true : undefined,
onPartialReply: useStreaming
? undefined
: !previewStreamingEnabled
? undefined
: async (payload) => {
updateDraftFromPartial(payload.text);
},
onAssistantMessageStart: onDraftBoundary,
onReasoningEnd: onDraftBoundary,
onReasoningStream: statusReactionsEnabled
? async () => {
await statusReactions.setThinking();
}
: undefined,
onToolStart: async (payload) => {
if (statusReactionsEnabled) {
await statusReactions.setTool(payload.name);
}
pushPreviewToolProgress(payload.name ? `tool: ${payload.name}` : "tool running");
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
},
}),
}),
},
}); });
const result = dispatchResult; if (!turnResult.dispatched) {
return;
}
const result = turnResult.dispatchResult;
queuedFinal = result.queuedFinal; queuedFinal = result.queuedFinal;
counts = result.counts; counts = result.counts;
} catch (err) { } catch (err) {

View File

@@ -19,7 +19,7 @@ import type {
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { import {
hasFinalInboundReplyDispatch, hasFinalInboundReplyDispatch,
runPreparedInboundReplyTurn, runInboundReplyTurn,
} from "openclaw/plugin-sdk/inbound-reply-dispatch"; } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { import {
createOutboundPayloadPlan, createOutboundPayloadPlan,
@@ -844,313 +844,337 @@ export const dispatchTelegramMessage = async ({
}); });
try { try {
const { dispatchResult } = await runPreparedInboundReplyTurn({ const turnResult = await runInboundReplyTurn({
channel: "telegram", channel: "telegram",
accountId: route.accountId, accountId: route.accountId,
routeSessionKey: route.sessionKey, raw: context,
storePath: context.turn.storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession: context.turn.recordInboundSession, id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`,
record: context.turn.record, timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined,
runDispatch: () => rawText: ctxPayload.RawBody ?? "",
telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ textForAgent: ctxPayload.BodyForAgent,
ctx: ctxPayload, textForCommands: ctxPayload.CommandBody,
cfg, raw: context,
dispatcherOptions: {
...replyPipeline,
beforeDeliver: async (payload) => payload,
deliver: async (payload, info) => {
if (isDispatchSuperseded()) {
return;
}
const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (
didDeliver: boolean,
) => {
if (didDeliver && info.kind !== "final") {
pendingCompactionReplayBoundary = false;
}
};
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.kind === "final") {
await enqueueDraftLaneEvent(async () => {});
}
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
queuedFinal = true;
return;
}
const previewButtons = (
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
)?.buttons;
const split = splitTextIntoLaneSegments(payload.text);
const segments = split.segments;
const reply = resolveSendableOutboundReplyParts(payload);
const _hasMedia = reply.hasMedia;
const flushBufferedFinalAnswer = async () => {
const buffered = reasoningStepState.takeBufferedFinalAnswer();
if (!buffered) {
return;
}
const bufferedButtons = (
buffered.payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons }
| undefined
)?.buttons;
await deliverLaneText({
laneName: "answer",
text: buffered.text,
payload: buffered.payload,
infoKind: "final",
previewButtons: bufferedButtons,
});
reasoningStepState.resetForNextStep();
};
for (const segment of segments) {
if (
segment.lane === "answer" &&
info.kind === "final" &&
reasoningStepState.shouldBufferFinalAnswer()
) {
reasoningStepState.bufferFinalAnswer({
payload,
text: segment.text,
});
continue;
}
if (segment.lane === "reasoning") {
reasoningStepState.noteReasoningHint();
}
const result = await deliverLaneText({
laneName: segment.lane,
text: segment.text,
payload,
infoKind: info.kind,
previewButtons,
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
});
if (info.kind === "final") {
emitPreviewFinalizedHook(result);
}
if (segment.lane === "reasoning") {
if (result.kind !== "skipped") {
reasoningStepState.noteReasoningDelivered();
await flushBufferedFinalAnswer();
}
continue;
}
if (info.kind === "final") {
if (reasoningLane.hasStreamedMessage) {
activePreviewLifecycleByLane.reasoning = "complete";
retainPreviewOnCleanupByLane.reasoning = true;
}
reasoningStepState.resetForNextStep();
}
}
if (segments.length > 0) {
if (info.kind === "final") {
pendingCompactionReplayBoundary = false;
}
return;
}
if (split.suppressedReasoningOnly) {
if (reply.hasMedia) {
const payloadWithoutSuppressedReasoning =
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
clearPendingCompactionReplayBoundaryOnVisibleBoundary(
await sendPayload(payloadWithoutSuppressedReasoning),
);
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
pendingCompactionReplayBoundary = false;
}
return;
}
if (info.kind === "final") {
await answerLane.stream?.stop();
await reasoningLane.stream?.stop();
reasoningStepState.resetForNextStep();
}
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
if (!canSendAsIs) {
if (info.kind === "final") {
await flushBufferedFinalAnswer();
pendingCompactionReplayBoundary = false;
}
return;
}
clearPendingCompactionReplayBoundaryOnVisibleBoundary(await sendPayload(payload));
if (info.kind === "final") {
await flushBufferedFinalAnswer();
pendingCompactionReplayBoundary = false;
}
},
onSkip: (payload, info) => {
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.reason !== "silent") {
deliveryState.markNonSilentSkip();
}
},
onError: (err, info) => {
const errorPolicy = resolveTelegramErrorPolicy({
accountConfig: telegramCfg,
groupConfig,
topicConfig,
});
if (isSilentErrorPolicy(errorPolicy.policy)) {
return;
}
if (
errorPolicy.policy === "once" &&
shouldSuppressTelegramError({
scopeKey: buildTelegramErrorScopeKey({
accountId: route.accountId,
chatId,
threadId: threadSpec.id,
}),
cooldownMs: errorPolicy.cooldownMs,
errorMessage: String(err),
})
) {
return;
}
deliveryState.markNonSilentFailure();
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {
skillFilter,
disableBlockStreaming,
onPartialReply:
answerLane.stream || reasoningLane.stream
? (payload) =>
enqueueDraftLaneEvent(async () => {
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onReasoningStream: reasoningLane.stream
? (payload) =>
enqueueDraftLaneEvent(async () => {
if (splitReasoningOnNextStream) {
reasoningLane.stream?.forceNewMessage();
resetDraftLaneState(reasoningLane);
splitReasoningOnNextStream = false;
}
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onAssistantMessageStart: answerLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
if (skipNextAnswerMessageStartRotation) {
skipNextAnswerMessageStartRotation = false;
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
return;
}
if (pendingCompactionReplayBoundary) {
pendingCompactionReplayBoundary = false;
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
return;
}
await rotateAnswerLaneForNewAssistantMessage();
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
})
: undefined,
onReasoningEnd: reasoningLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
})
: undefined,
suppressDefaultToolProgressMessages:
!previewStreamingEnabled || Boolean(answerLane.stream),
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
if (statusReactionController && toolName) {
await statusReactionController.setTool(toolName);
}
pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running");
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(payload.explanation ?? payload.steps?.[0] ?? "planning");
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
onCompactionStart:
statusReactionController || answerLane.stream
? async () => {
if (
answerLane.hasStreamedMessage &&
activePreviewLifecycleByLane.answer === "transient"
) {
pendingCompactionReplayBoundary = true;
}
if (statusReactionController) {
await statusReactionController.setCompacting();
}
}
: undefined,
onCompactionEnd: statusReactionController
? async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
}
: undefined,
onModelSelected,
},
}), }),
resolveTurn: () => ({
channel: "telegram",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath: context.turn.storePath,
ctxPayload,
recordInboundSession: context.turn.recordInboundSession,
record: context.turn.record,
runDispatch: () =>
telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
...replyPipeline,
beforeDeliver: async (payload) => payload,
deliver: async (payload, info) => {
if (isDispatchSuperseded()) {
return;
}
const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (
didDeliver: boolean,
) => {
if (didDeliver && info.kind !== "final") {
pendingCompactionReplayBoundary = false;
}
};
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.kind === "final") {
await enqueueDraftLaneEvent(async () => {});
}
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
queuedFinal = true;
return;
}
const previewButtons = (
payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons }
| undefined
)?.buttons;
const split = splitTextIntoLaneSegments(payload.text);
const segments = split.segments;
const reply = resolveSendableOutboundReplyParts(payload);
const _hasMedia = reply.hasMedia;
const flushBufferedFinalAnswer = async () => {
const buffered = reasoningStepState.takeBufferedFinalAnswer();
if (!buffered) {
return;
}
const bufferedButtons = (
buffered.payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons }
| undefined
)?.buttons;
await deliverLaneText({
laneName: "answer",
text: buffered.text,
payload: buffered.payload,
infoKind: "final",
previewButtons: bufferedButtons,
});
reasoningStepState.resetForNextStep();
};
for (const segment of segments) {
if (
segment.lane === "answer" &&
info.kind === "final" &&
reasoningStepState.shouldBufferFinalAnswer()
) {
reasoningStepState.bufferFinalAnswer({
payload,
text: segment.text,
});
continue;
}
if (segment.lane === "reasoning") {
reasoningStepState.noteReasoningHint();
}
const result = await deliverLaneText({
laneName: segment.lane,
text: segment.text,
payload,
infoKind: info.kind,
previewButtons,
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
});
if (info.kind === "final") {
emitPreviewFinalizedHook(result);
}
if (segment.lane === "reasoning") {
if (result.kind !== "skipped") {
reasoningStepState.noteReasoningDelivered();
await flushBufferedFinalAnswer();
}
continue;
}
if (info.kind === "final") {
if (reasoningLane.hasStreamedMessage) {
activePreviewLifecycleByLane.reasoning = "complete";
retainPreviewOnCleanupByLane.reasoning = true;
}
reasoningStepState.resetForNextStep();
}
}
if (segments.length > 0) {
if (info.kind === "final") {
pendingCompactionReplayBoundary = false;
}
return;
}
if (split.suppressedReasoningOnly) {
if (reply.hasMedia) {
const payloadWithoutSuppressedReasoning =
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
clearPendingCompactionReplayBoundaryOnVisibleBoundary(
await sendPayload(payloadWithoutSuppressedReasoning),
);
}
if (info.kind === "final") {
await flushBufferedFinalAnswer();
pendingCompactionReplayBoundary = false;
}
return;
}
if (info.kind === "final") {
await answerLane.stream?.stop();
await reasoningLane.stream?.stop();
reasoningStepState.resetForNextStep();
}
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
if (!canSendAsIs) {
if (info.kind === "final") {
await flushBufferedFinalAnswer();
pendingCompactionReplayBoundary = false;
}
return;
}
clearPendingCompactionReplayBoundaryOnVisibleBoundary(
await sendPayload(payload),
);
if (info.kind === "final") {
await flushBufferedFinalAnswer();
pendingCompactionReplayBoundary = false;
}
},
onSkip: (payload, info) => {
if (payload.isError === true) {
hadErrorReplyFailureOrSkip = true;
}
if (info.reason !== "silent") {
deliveryState.markNonSilentSkip();
}
},
onError: (err, info) => {
const errorPolicy = resolveTelegramErrorPolicy({
accountConfig: telegramCfg,
groupConfig,
topicConfig,
});
if (isSilentErrorPolicy(errorPolicy.policy)) {
return;
}
if (
errorPolicy.policy === "once" &&
shouldSuppressTelegramError({
scopeKey: buildTelegramErrorScopeKey({
accountId: route.accountId,
chatId,
threadId: threadSpec.id,
}),
cooldownMs: errorPolicy.cooldownMs,
errorMessage: String(err),
})
) {
return;
}
deliveryState.markNonSilentFailure();
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {
skillFilter,
disableBlockStreaming,
onPartialReply:
answerLane.stream || reasoningLane.stream
? (payload) =>
enqueueDraftLaneEvent(async () => {
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onReasoningStream: reasoningLane.stream
? (payload) =>
enqueueDraftLaneEvent(async () => {
if (splitReasoningOnNextStream) {
reasoningLane.stream?.forceNewMessage();
resetDraftLaneState(reasoningLane);
splitReasoningOnNextStream = false;
}
await ingestDraftLaneSegments(payload.text);
})
: undefined,
onAssistantMessageStart: answerLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
if (skipNextAnswerMessageStartRotation) {
skipNextAnswerMessageStartRotation = false;
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
return;
}
if (pendingCompactionReplayBoundary) {
pendingCompactionReplayBoundary = false;
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
return;
}
await rotateAnswerLaneForNewAssistantMessage();
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = false;
})
: undefined,
onReasoningEnd: reasoningLane.stream
? () =>
enqueueDraftLaneEvent(async () => {
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
})
: undefined,
suppressDefaultToolProgressMessages:
!previewStreamingEnabled || Boolean(answerLane.stream),
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
if (statusReactionController && toolName) {
await statusReactionController.setTool(toolName);
}
pushPreviewToolProgress(toolName ? `tool: ${toolName}` : "tool running");
},
onItemEvent: async (payload) => {
pushPreviewToolProgress(
payload.progressText ?? payload.summary ?? payload.title ?? payload.name,
);
},
onPlanUpdate: async (payload) => {
if (payload.phase !== "update") {
return;
}
pushPreviewToolProgress(
payload.explanation ?? payload.steps?.[0] ?? "planning",
);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
pushPreviewToolProgress(
payload.command ? `approval: ${payload.command}` : "approval requested",
);
},
onCommandOutput: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(
payload.name
? `${payload.name}${payload.exitCode === 0 ? " ✓" : payload.exitCode != null ? ` (exit ${payload.exitCode})` : ""}`
: payload.title,
);
},
onPatchSummary: async (payload) => {
if (payload.phase !== "end") {
return;
}
pushPreviewToolProgress(payload.summary ?? payload.title ?? "patch applied");
},
onCompactionStart:
statusReactionController || answerLane.stream
? async () => {
if (
answerLane.hasStreamedMessage &&
activePreviewLifecycleByLane.answer === "transient"
) {
pendingCompactionReplayBoundary = true;
}
if (statusReactionController) {
await statusReactionController.setCompacting();
}
}
: undefined,
onCompactionEnd: statusReactionController
? async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
}
: undefined,
onModelSelected,
},
}),
}),
},
}); });
({ queuedFinal } = dispatchResult); if (!turnResult.dispatched) {
return;
}
({ queuedFinal } = turnResult.dispatchResult);
} catch (err) { } catch (err) {
dispatchError = err; dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`)); runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));

View File

@@ -13,7 +13,7 @@ import {
toPluginMessageReceivedEvent, toPluginMessageReceivedEvent,
triggerInternalHook, triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime"; } from "openclaw/plugin-sdk/hook-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { runInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference"; import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js"; import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
@@ -454,52 +454,68 @@ export async function processMessage(params: {
warn: params.replyLogger.warn.bind(params.replyLogger), warn: params.replyLogger.warn.bind(params.replyLogger),
}); });
const { dispatchResult: didSendReply } = await runPreparedInboundReplyTurn({ const turnResult = await runInboundReplyTurn({
channel: "whatsapp", channel: "whatsapp",
accountId: params.route.accountId, accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey, raw: params.msg,
storePath, adapter: {
ctxPayload, ingest: () => ({
recordInboundSession, id: params.msg.id ?? `${conversationId}:${Date.now()}`,
record: { timestamp: params.msg.timestamp,
onRecordError: (err) => { rawText: ctxPayload.RawBody ?? "",
params.replyLogger.warn( textForAgent: ctxPayload.BodyForAgent,
{ textForCommands: ctxPayload.CommandBody,
error: formatError(err), raw: params.msg,
storePath,
sessionKey: params.route.sessionKey,
},
"failed updating session meta",
);
},
trackSessionMetaTask: (task) => {
trackBackgroundTask(params.backgroundTasks, task);
},
},
runDispatch: () =>
dispatchWhatsAppBufferedReply({
cfg: params.cfg,
connectionId: params.connectionId,
context: ctxPayload,
conversationId,
deliverReply: deliverWebReply,
groupHistories: params.groupHistories,
groupHistoryKey: params.groupHistoryKey,
maxMediaBytes: params.maxMediaBytes,
maxMediaTextChunkLimit: params.maxMediaTextChunkLimit,
msg: params.msg,
onModelSelected,
rememberSentText: params.rememberSentText,
replyLogger: params.replyLogger,
replyPipeline: {
...replyPipeline,
responsePrefix,
},
replyResolver: params.replyResolver,
route: params.route,
shouldClearGroupHistory,
}), }),
resolveTurn: () => ({
channel: "whatsapp",
accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
onRecordError: (err) => {
params.replyLogger.warn(
{
error: formatError(err),
storePath,
sessionKey: params.route.sessionKey,
},
"failed updating session meta",
);
},
trackSessionMetaTask: (task) => {
trackBackgroundTask(params.backgroundTasks, task);
},
},
runDispatch: () =>
dispatchWhatsAppBufferedReply({
cfg: params.cfg,
connectionId: params.connectionId,
context: ctxPayload,
conversationId,
deliverReply: deliverWebReply,
groupHistories: params.groupHistories,
groupHistoryKey: params.groupHistoryKey,
maxMediaBytes: params.maxMediaBytes,
maxMediaTextChunkLimit: params.maxMediaTextChunkLimit,
msg: params.msg,
onModelSelected,
rememberSentText: params.rememberSentText,
replyLogger: params.replyLogger,
replyPipeline: {
...replyPipeline,
responsePrefix,
},
replyResolver: params.replyResolver,
route: params.route,
shouldClearGroupHistory,
}),
}),
},
}); });
const didSendReply = turnResult.dispatched ? turnResult.dispatchResult : false;
removeAckReactionHandleAfterReply({ removeAckReactionHandleAfterReply({
removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply), removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply),
ackReaction, ackReaction,

View File

@@ -257,13 +257,23 @@ export function createImageLifecycleCore() {
updateLastRoute: resolved.record?.updateLastRoute, updateLastRoute: resolved.record?.updateLastRoute,
onRecordError: resolved.record?.onRecordError ?? (() => undefined), onRecordError: resolved.record?.onRecordError ?? (() => undefined),
}); });
if ("runDispatch" in resolved) {
const dispatchResult = await resolved.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: resolved.ctxPayload,
routeSessionKey: resolved.routeSessionKey,
dispatchResult,
};
}
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({ const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
ctx: resolved.ctxPayload, ctx: resolved.ctxPayload,
cfg: resolved.cfg, cfg: resolved.cfg,
dispatcherOptions: { dispatcherOptions: {
...resolved.dispatcherOptions, ...resolved.dispatcherOptions,
deliver: async (payload, info) => { deliver: async (...args: Parameters<typeof resolved.delivery.deliver>) => {
await resolved.delivery.deliver(payload, info); await resolved.delivery.deliver(...args);
}, },
onError: resolved.delivery.onError, onError: resolved.delivery.onError,
}, },

View File

@@ -102,13 +102,23 @@ function installRuntime(params: {
updateLastRoute: turn.record?.updateLastRoute, updateLastRoute: turn.record?.updateLastRoute,
onRecordError: turn.record?.onRecordError ?? (() => undefined), onRecordError: turn.record?.onRecordError ?? (() => undefined),
}); });
if ("runDispatch" in turn) {
const dispatchResult = await turn.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
}
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({ const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
ctx: turn.ctxPayload, ctx: turn.ctxPayload,
cfg: turn.cfg, cfg: turn.cfg,
dispatcherOptions: { dispatcherOptions: {
...turn.dispatcherOptions, ...turn.dispatcherOptions,
deliver: async (payload, info) => { deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
await turn.delivery.deliver(payload, info); await turn.delivery.deliver(...args);
}, },
onError: turn.delivery.onError, onError: turn.delivery.onError,
}, },

View File

@@ -310,6 +310,38 @@ describe("channel turn kernel", () => {
); );
}); });
it("runs custom prepared dispatch from a full turn adapter", async () => {
const events: string[] = [];
const result = await runChannelTurn({
channel: "test",
raw: { id: "msg-1", text: "hello" },
adapter: {
ingest: () => ({ id: "msg-1", rawText: "hello" }),
resolveTurn: () => ({
channel: "test",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession: createRecordInboundSession(events),
runDispatch: async () => {
events.push("custom-dispatch");
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
}),
},
});
expect(events).toEqual(["record", "custom-dispatch"]);
expect(result.dispatched).toBe(true);
if (!result.dispatched) {
throw new Error("expected dispatch");
}
expect(result.dispatchResult.queuedFinal).toBe(true);
});
it("finalizes failed dispatches before rethrowing", async () => { it("finalizes failed dispatches before rethrowing", async () => {
const onFinalize = vi.fn(); const onFinalize = vi.fn();
const dispatchError = new Error("dispatch failed"); const dispatchError = new Error("dispatch failed");

View File

@@ -9,12 +9,12 @@ import type {
ChannelTurnDeliveryAdapter, ChannelTurnDeliveryAdapter,
ChannelTurnHistoryFinalizeOptions, ChannelTurnHistoryFinalizeOptions,
ChannelTurnLogEvent, ChannelTurnLogEvent,
ChannelTurnResolved,
ChannelTurnResult, ChannelTurnResult,
DispatchedChannelTurnResult, DispatchedChannelTurnResult,
PreparedChannelTurn, PreparedChannelTurn,
PreflightFacts, PreflightFacts,
RunChannelTurnParams, RunChannelTurnParams,
RunResolvedChannelTurnParams,
} from "./types.js"; } from "./types.js";
export { export {
EMPTY_CHANNEL_TURN_DISPATCH_COUNTS, EMPTY_CHANNEL_TURN_DISPATCH_COUNTS,
@@ -49,7 +49,6 @@ export type {
ReplyPlanFacts, ReplyPlanFacts,
RouteFacts, RouteFacts,
RunChannelTurnParams, RunChannelTurnParams,
RunResolvedChannelTurnParams,
SenderFacts, SenderFacts,
SupplementalContextFacts, SupplementalContextFacts,
} from "./types.js"; } from "./types.js";
@@ -143,6 +142,29 @@ export async function dispatchAssembledChannelTurn(
}); });
} }
function isPreparedChannelTurn<TDispatchResult>(
value: ChannelTurnResolved<TDispatchResult>,
): value is PreparedChannelTurn<TDispatchResult> & {
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
} {
return "runDispatch" in value;
}
async function dispatchResolvedChannelTurn<TDispatchResult>(
params: ChannelTurnResolved<TDispatchResult> & {
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
log?: (event: ChannelTurnLogEvent) => void;
messageId?: string;
},
): Promise<DispatchedChannelTurnResult<TDispatchResult>> {
if (isPreparedChannelTurn(params)) {
return await runPreparedChannelTurn(params);
}
return (await dispatchAssembledChannelTurn(
params,
)) as DispatchedChannelTurnResult<TDispatchResult>;
}
export async function runPreparedChannelTurn< export async function runPreparedChannelTurn<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"], TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>( >(
@@ -248,9 +270,12 @@ export async function runPreparedChannelTurn<
}; };
} }
export async function runChannelTurn<TRaw>( export async function runChannelTurn<
params: RunChannelTurnParams<TRaw>, TRaw,
): Promise<ChannelTurnResult> { TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(
params: RunChannelTurnParams<TRaw, TDispatchResult>,
): Promise<ChannelTurnResult<TDispatchResult>> {
emit({ emit({
...params, ...params,
event: { stage: "ingest", event: "start" }, event: { stage: "ingest", event: "start" },
@@ -327,9 +352,9 @@ export async function runChannelTurn<TRaw>(
}); });
const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const); const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const);
let result: ChannelTurnResult; let result: ChannelTurnResult<TDispatchResult>;
try { try {
const dispatchResult = await dispatchAssembledChannelTurn( const dispatchResult = await dispatchResolvedChannelTurn(
admission.kind === "observeOnly" admission.kind === "observeOnly"
? { ? {
...resolved, ...resolved,
@@ -350,7 +375,7 @@ export async function runChannelTurn<TRaw>(
admission, admission,
}; };
} catch (err) { } catch (err) {
const failedResult: ChannelTurnResult = { const failedResult: ChannelTurnResult<TDispatchResult> = {
admission, admission,
dispatched: false, dispatched: false,
ctxPayload: resolved.ctxPayload, ctxPayload: resolved.ctxPayload,
@@ -406,18 +431,3 @@ export async function runChannelTurn<TRaw>(
return result; return result;
} }
export async function runResolvedChannelTurn<TRaw>(
params: RunResolvedChannelTurnParams<TRaw>,
): Promise<ChannelTurnResult> {
return await runChannelTurn({
channel: params.channel,
accountId: params.accountId,
raw: params.raw,
log: params.log,
adapter: {
ingest: (raw) => (typeof params.input === "function" ? params.input(raw) : params.input),
resolveTurn: params.resolveTurn,
},
});
}

View File

@@ -240,9 +240,13 @@ export type PreparedChannelTurn<TDispatchResult = DispatchFromConfigResult> = {
messageId?: string; messageId?: string;
}; };
export type ChannelTurnResolved = AssembledChannelTurn & { export type ChannelTurnResolved<TDispatchResult = DispatchFromConfigResult> =
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>; | (AssembledChannelTurn & {
}; admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
})
| (PreparedChannelTurn<TDispatchResult> & {
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
});
export type ChannelTurnStage = export type ChannelTurnStage =
| "ingest" | "ingest"
@@ -267,13 +271,14 @@ export type ChannelTurnLogEvent = {
error?: unknown; error?: unknown;
}; };
export type ChannelTurnResult = { export type ChannelTurnResult<TDispatchResult = DispatchFromConfigResult> =
admission: ChannelTurnAdmission; | DispatchedChannelTurnResult<TDispatchResult>
dispatched: boolean; | {
ctxPayload?: MsgContext; admission: ChannelTurnAdmission;
routeSessionKey?: string; dispatched: false;
dispatchResult?: DispatchFromConfigResult; ctxPayload?: MsgContext;
}; routeSessionKey?: string;
};
export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResult> = { export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResult> = {
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>; admission: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
@@ -283,7 +288,7 @@ export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResu
dispatchResult: TDispatchResult; dispatchResult: TDispatchResult;
}; };
export type ChannelTurnAdapter<TRaw> = { export type ChannelTurnAdapter<TRaw, TDispatchResult = DispatchFromConfigResult> = {
ingest: (raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null; ingest: (raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
classify?: (input: NormalizedTurnInput) => Promise<ChannelEventClass> | ChannelEventClass; classify?: (input: NormalizedTurnInput) => Promise<ChannelEventClass> | ChannelEventClass;
preflight?: ( preflight?: (
@@ -299,29 +304,14 @@ export type ChannelTurnAdapter<TRaw> = {
input: NormalizedTurnInput, input: NormalizedTurnInput,
eventClass: ChannelEventClass, eventClass: ChannelEventClass,
preflight: PreflightFacts, preflight: PreflightFacts,
) => Promise<ChannelTurnResolved> | ChannelTurnResolved; ) => Promise<ChannelTurnResolved<TDispatchResult>> | ChannelTurnResolved<TDispatchResult>;
onFinalize?: (result: ChannelTurnResult) => Promise<void> | void; onFinalize?: (result: ChannelTurnResult<TDispatchResult>) => Promise<void> | void;
}; };
export type RunChannelTurnParams<TRaw> = { export type RunChannelTurnParams<TRaw, TDispatchResult = DispatchFromConfigResult> = {
channel: string; channel: string;
accountId?: string; accountId?: string;
raw: TRaw; raw: TRaw;
adapter: ChannelTurnAdapter<TRaw>; adapter: ChannelTurnAdapter<TRaw, TDispatchResult>;
log?: (event: ChannelTurnLogEvent) => void;
};
export type RunResolvedChannelTurnParams<TRaw> = {
channel: string;
accountId?: string;
raw: TRaw;
input:
| NormalizedTurnInput
| ((raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null);
resolveTurn: (
input: NormalizedTurnInput,
eventClass: ChannelEventClass,
preflight: PreflightFacts,
) => Promise<ChannelTurnResolved> | ChannelTurnResolved;
log?: (event: ChannelTurnLogEvent) => void; log?: (event: ChannelTurnLogEvent) => void;
}; };

View File

@@ -11,9 +11,11 @@ import {
hasFinalChannelTurnDispatch, hasFinalChannelTurnDispatch,
hasVisibleChannelTurnDispatch, hasVisibleChannelTurnDispatch,
resolveChannelTurnDispatchCounts, resolveChannelTurnDispatchCounts,
runChannelTurn,
runPreparedChannelTurn, runPreparedChannelTurn,
} from "../channels/turn/kernel.js"; } from "../channels/turn/kernel.js";
import type { PreparedChannelTurn } from "../channels/turn/types.js"; import type { PreparedChannelTurn, RunChannelTurnParams } from "../channels/turn/types.js";
export type { ChannelTurnRecordOptions } from "../channels/turn/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; import { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js"; import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
@@ -33,6 +35,13 @@ export async function runPreparedInboundReplyTurn<TDispatchResult>(
return await runPreparedChannelTurn(params); return await runPreparedChannelTurn(params);
} }
/** Run a channel turn through shared ingest, record, dispatch, and finalize ordering. */
export async function runInboundReplyTurn<TRaw, TDispatchResult = DispatchFromConfigResult>(
params: RunChannelTurnParams<TRaw, TDispatchResult>,
) {
return await runChannelTurn(params);
}
export { export {
hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch, hasFinalChannelTurnDispatch as hasFinalInboundReplyDispatch,
hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch, hasVisibleChannelTurnDispatch as hasVisibleInboundReplyDispatch,

View File

@@ -78,40 +78,64 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
createTaskFlowSessionMock, createTaskFlowSessionMock,
) as unknown as PluginRuntime["tasks"]["managedFlows"]["fromToolContext"], ) as unknown as PluginRuntime["tasks"]["managedFlows"]["fromToolContext"],
}; };
const dispatchAssembledChannelTurnMock = vi.fn( const dispatchAssembledChannelTurnMock = vi.fn(async (params: Record<string, unknown>) => {
async (params: Parameters<PluginRuntime["channel"]["turn"]["dispatchAssembled"]>[0]) => { const ctxPayload = params.ctxPayload as Record<string, unknown>;
await params.recordInboundSession({ const record = params.record as
storePath: params.storePath, | Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]["record"]
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey, | undefined;
ctx: params.ctxPayload, const recordInboundSession = params.recordInboundSession as Parameters<
groupResolution: params.record?.groupResolution, PluginRuntime["channel"]["turn"]["runPrepared"]
createIfMissing: params.record?.createIfMissing, >[0]["recordInboundSession"];
updateLastRoute: params.record?.updateLastRoute, const routeSessionKey = params.routeSessionKey as string;
onRecordError: params.record?.onRecordError ?? (() => undefined), const storePath = params.storePath as string;
trackSessionMetaTask: params.record?.trackSessionMetaTask, const delivery = params.delivery as {
}); deliver: (payload: unknown, info: unknown) => Promise<unknown>;
const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({ onError?: (err: unknown, info: { kind: string }) => void;
ctx: params.ctxPayload, };
cfg: params.cfg, const ctxSessionKey = ctxPayload.SessionKey;
const sessionKey = typeof ctxSessionKey === "string" ? ctxSessionKey : routeSessionKey;
const dispatchReplyWithBufferedBlockDispatcher =
params.dispatchReplyWithBufferedBlockDispatcher as (params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: { dispatcherOptions: {
...params.dispatcherOptions, deliver: (payload: unknown, info: unknown) => Promise<void>;
deliver: async (payload, info) => { onError?: (err: unknown, info: { kind: string }) => void;
await params.delivery.deliver(payload, info); };
}, replyOptions?: unknown;
onError: params.delivery.onError, replyResolver?: unknown;
}) => Promise<unknown>;
await recordInboundSession({
storePath,
sessionKey,
ctx: ctxPayload,
groupResolution: record?.groupResolution,
createIfMissing: record?.createIfMissing,
updateLastRoute: record?.updateLastRoute,
onRecordError: record?.onRecordError ?? (() => undefined),
trackSessionMetaTask: record?.trackSessionMetaTask,
});
const dispatchResult = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: params.cfg,
dispatcherOptions: {
...(params.dispatcherOptions as Record<string, unknown> | undefined),
deliver: async (payload, info) => {
await delivery.deliver(payload, info);
}, },
replyOptions: params.replyOptions, onError: delivery.onError,
replyResolver: params.replyResolver, },
}); replyOptions: params.replyOptions,
return { replyResolver: params.replyResolver,
admission: params.admission ?? { kind: "dispatch" as const }, });
dispatched: true, return {
ctxPayload: params.ctxPayload, admission: params.admission ?? { kind: "dispatch" },
routeSessionKey: params.routeSessionKey, dispatched: true,
dispatchResult, ctxPayload,
}; routeSessionKey,
}, dispatchResult,
) as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"]; };
});
const runPreparedChannelTurnMock = vi.fn( const runPreparedChannelTurnMock = vi.fn(
async (params: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => { async (params: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
try { try {
@@ -180,18 +204,24 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {}); const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {});
const admission = const admission =
resolved.admission ?? preflight.admission ?? ({ kind: "dispatch" } as const); resolved.admission ?? preflight.admission ?? ({ kind: "dispatch" } as const);
const dispatchResult = await dispatchAssembledChannelTurnMock({ const dispatchResult =
...resolved, "runDispatch" in resolved
admission, ? await runPreparedChannelTurnMock({
delivery: ...resolved,
admission.kind === "observeOnly" admission,
? { deliver: async () => ({ visibleReplySent: false }) } })
: resolved.delivery, : await dispatchAssembledChannelTurnMock({
}); ...resolved,
admission,
delivery:
admission.kind === "observeOnly"
? { deliver: async () => ({ visibleReplySent: false }) }
: resolved.delivery,
});
const result = { const result = {
...dispatchResult, ...dispatchResult,
admission, admission,
}; } as Parameters<NonNullable<typeof params.adapter.onFinalize>>[0];
await params.adapter.onFinalize?.(result); await params.adapter.onFinalize?.(result);
return result; return result;
}, },
@@ -233,28 +263,6 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
...params.extra, ...params.extra,
}) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>, }) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>,
) as unknown as PluginRuntime["channel"]["turn"]["buildContext"]; ) as unknown as PluginRuntime["channel"]["turn"]["buildContext"];
const runResolvedChannelTurnMock = vi.fn(
async (params: Parameters<PluginRuntime["channel"]["turn"]["runResolved"]>[0]) => {
const input =
typeof params.input === "function" ? await params.input(params.raw) : params.input;
if (!input) {
return {
admission: { kind: "drop" as const, reason: "ingest-null" },
dispatched: false,
};
}
return await runChannelTurnMock({
channel: params.channel,
accountId: params.accountId,
raw: params.raw,
log: params.log,
adapter: {
ingest: () => input,
resolveTurn: params.resolveTurn,
},
});
},
) as unknown as PluginRuntime["channel"]["turn"]["runResolved"];
const base: PluginRuntime = { const base: PluginRuntime = {
version: "1.0.0-test", version: "1.0.0-test",
config: { config: {
@@ -609,10 +617,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
}, },
turn: { turn: {
run: runChannelTurnMock, run: runChannelTurnMock,
runResolved: runResolvedChannelTurnMock,
buildContext: buildChannelTurnContextMock, buildContext: buildChannelTurnContextMock,
runPrepared: runPreparedChannelTurnMock, runPrepared: runPreparedChannelTurnMock,
dispatchAssembled: dispatchAssembledChannelTurnMock,
}, },
threadBindings: { threadBindings: {
setIdleTimeoutBySessionKey: setIdleTimeoutBySessionKey:

View File

@@ -52,10 +52,8 @@ import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load
import { recordInboundSession } from "../../channels/session.js"; import { recordInboundSession } from "../../channels/session.js";
import { import {
buildChannelTurnContext, buildChannelTurnContext,
dispatchAssembledChannelTurn,
runChannelTurn, runChannelTurn,
runPreparedChannelTurn, runPreparedChannelTurn,
runResolvedChannelTurn,
} from "../../channels/turn/kernel.js"; } from "../../channels/turn/kernel.js";
import { import {
resolveChannelGroupPolicy, resolveChannelGroupPolicy,
@@ -174,10 +172,8 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
}, },
turn: { turn: {
run: runChannelTurn, run: runChannelTurn,
runResolved: runResolvedChannelTurn,
buildContext: buildChannelTurnContext, buildContext: buildChannelTurnContext,
runPrepared: runPreparedChannelTurn, runPrepared: runPreparedChannelTurn,
dispatchAssembled: dispatchAssembledChannelTurn,
}, },
threadBindings: { threadBindings: {
setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) => setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) =>

View File

@@ -153,12 +153,8 @@ export type PluginRuntimeChannel = {
}; };
turn: { turn: {
run: typeof import("../../channels/turn/kernel.js").runChannelTurn; run: typeof import("../../channels/turn/kernel.js").runChannelTurn;
/** @deprecated Prefer `run(...)`. */
runResolved: typeof import("../../channels/turn/kernel.js").runResolvedChannelTurn;
buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext; buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext;
runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn; runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn;
/** @deprecated Prefer `run(...)` or `runPrepared(...)`. */
dispatchAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn;
}; };
threadBindings: { threadBindings: {
setIdleTimeoutBySessionKey: (params: { setIdleTimeoutBySessionKey: (params: {