refactor(channels): add shared turn kernel

This commit is contained in:
Peter Steinberger
2026-04-29 22:28:47 +01:00
parent 4396361f35
commit 9a9cd0c0ab
62 changed files with 3449 additions and 1333 deletions

View File

@@ -1,2 +1,2 @@
cb1975fe65fcab0d50f4bf368118e61640d870a13bb8d9a44a9abb0f79f3c729 plugin-sdk-api-baseline.json
c8e2ebe7dc13d170b83b96109dd46fc33057e6f4200f981dc5ea9623e73affab plugin-sdk-api-baseline.jsonl
6e8aa3634daa81d054c339d2a8b6a526ec22b93e737980d21191ff7d53449eda plugin-sdk-api-baseline.json
6bb635a9d95b671c24251406d098ac052a6773551a1db30529bdc97caf1bb735 plugin-sdk-api-baseline.jsonl

View File

@@ -1714,11 +1714,18 @@ async function processMessageAfterDedupe(
},
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
await core.channel.turn.dispatchAssembled({
cfg: config,
dispatcherOptions: {
...replyPipeline,
channel: "bluebubbles",
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload, info) => {
const rawReplyToId =
privateApiEnabled && typeof payload.replyToId === "string"
@@ -1845,8 +1852,6 @@ async function processMessageAfterDedupe(
}
}
},
onReplyStart: typingCallbacks?.onReplyStart,
onIdle: typingCallbacks?.onIdle,
onError: (err, info) => {
// Flag the outer dedupe wrapper so it releases the claim instead
// of committing. Without this, a transient BlueBubbles send failure
@@ -1864,6 +1869,11 @@ async function processMessageAfterDedupe(
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`);
},
},
dispatcherOptions: {
...replyPipeline,
onReplyStart: typingCallbacks?.onReplyStart,
onIdle: typingCallbacks?.onIdle,
},
replyOptions: {
onModelSelected,
disableBlockStreaming:
@@ -1871,6 +1881,11 @@ async function processMessageAfterDedupe(
? !account.config.blockStreaming
: undefined,
},
record: {
onRecordError: (err) => {
runtime.error?.(`[bluebubbles] failed updating session meta: ${sanitizeForLog(err)}`);
},
},
});
} finally {
const shouldStopTyping =

View File

@@ -4,6 +4,7 @@ import {
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { createNonExitingRuntime, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -238,35 +239,6 @@ export async function dispatchDiscordComponentEvent(params: {
resolveDiscordComponentOriginatingTo(interactionCtx) ?? `channel:${interactionCtx.channelId}`,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? sessionKey,
ctx: ctxPayload,
updateLastRoute: interactionCtx.isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to:
resolveDiscordComponentOriginatingTo(interactionCtx) ?? `user:${interactionCtx.userId}`,
accountId,
mainDmOwnerPin: pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: interactionCtx.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
},
});
const deliverTarget = `channel:${interactionCtx.channelId}`;
const typingChannelId = interactionCtx.channelId;
const { createChannelReplyPipeline } = await loadReplyPipelineRuntime();
@@ -298,48 +270,83 @@ export async function dispatchDiscordComponentEvent(params: {
startId: params.replyToId,
});
await 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,
await runPreparedInboundReplyTurn({
channel: "discord",
accountId,
routeSessionKey: sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: interactionCtx.isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to:
resolveDiscordComponentOriginatingTo(interactionCtx) ??
`user:${interactionCtx.userId}`,
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)}`);
mainDmOwnerPin: pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: interactionCtx.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: 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,
}),
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

@@ -3,7 +3,6 @@ import {
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { buildPendingHistoryContextFromMap } from "openclaw/plugin-sdk/reply-history";
@@ -330,21 +329,6 @@ export async function buildDiscordMessageProcessContext(params: {
});
const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
await recordInboundSession({
storePath,
sessionKey: persistedSessionKey,
ctx: ctxPayload,
updateLastRoute: {
sessionKey: persistedSessionKey,
channel: "discord",
to: lastRouteTo,
accountId: route.accountId,
},
onRecordError: (err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
@@ -355,6 +339,20 @@ export async function buildDiscordMessageProcessContext(params: {
return {
ctxPayload,
persistedSessionKey,
turn: {
storePath,
record: {
updateLastRoute: {
sessionKey: persistedSessionKey,
channel: "discord",
to: lastRouteTo,
accountId: route.accountId,
},
onRecordError: (err: unknown) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
},
},
replyPlan,
deliverTarget,
replyTarget,

View File

@@ -162,6 +162,17 @@ let processDiscordMessage: typeof import("./message-handler.process.js").process
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
dispatchInboundMessage: (params: DispatchInboundParams) => dispatchInboundMessage(params),
settleReplyDispatcher: async (params: {
dispatcher: { markComplete: () => void; waitForIdle: () => Promise<void> };
onSettled?: () => void | Promise<void>;
}) => {
params.dispatcher.markComplete();
try {
await params.dispatcher.waitForIdle();
} finally {
await params.onSettled?.();
}
},
createReplyDispatcherWithTyping: (opts: {
deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void;
}) => ({

View File

@@ -12,6 +12,8 @@ import {
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-chunking";
@@ -145,7 +147,8 @@ export async function processDiscordMessage(
if (boundThreadId && typeof threadBindings.touchThread === "function") {
threadBindings.touchThread({ threadId: boundThreadId });
}
const { createReplyDispatcherWithTyping, dispatchInboundMessage } = await loadReplyRuntime();
const { createReplyDispatcherWithTyping, dispatchInboundMessage, settleReplyDispatcher } =
await loadReplyRuntime();
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
cfg,
ctx: { ChatType: isGuildMessage ? "channel" : undefined },
@@ -226,8 +229,15 @@ export async function processDiscordMessage(
if (!processContext) {
return;
}
const { ctxPayload, persistedSessionKey, replyPlan, deliverTarget, replyTarget, replyReference } =
processContext;
const {
ctxPayload,
persistedSessionKey,
turn,
replyPlan,
deliverTarget,
replyTarget,
replyReference,
} = processContext;
observer?.onReplyPlanResolved?.({
createdThreadId: replyPlan.createdThreadId,
sessionKey: persistedSessionKey,
@@ -450,99 +460,127 @@ export async function processDiscordMessage(
let dispatchResult: Awaited<ReturnType<typeof dispatchInboundMessage>> | null = null;
let dispatchError = false;
let dispatchAborted = false;
let dispatchSettledBeforeStart = false;
const settleDispatchBeforeStart = async () => {
dispatchSettledBeforeStart = true;
await settleReplyDispatcher({
dispatcher,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
});
};
try {
if (isProcessAborted(abortSignal)) {
dispatchAborted = true;
await settleDispatchBeforeStart();
return;
}
dispatchResult = await 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();
},
},
const preparedResult = await runPreparedInboundReplyTurn({
channel: "discord",
accountId: route.accountId,
routeSessionKey: persistedSessionKey,
storePath: turn.storePath,
ctxPayload,
recordInboundSession,
record: turn.record,
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();
},
},
}),
});
dispatchResult = preparedResult.dispatchResult;
if (isProcessAborted(abortSignal)) {
dispatchAborted = true;
return;
@@ -558,8 +596,10 @@ export async function processDiscordMessage(
try {
await draftPreview.cleanup();
} finally {
markRunComplete();
markDispatchIdle();
if (!dispatchSettledBeforeStart) {
markRunComplete();
markDispatchIdle();
}
}
if (statusReactionsEnabled) {
if (dispatchAborted) {

View File

@@ -175,6 +175,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
session: {
readSessionUpdatedAt: readSessionUpdatedAtMock,
resolveStorePath: resolveStorePathMock,
recordInboundSession: vi.fn(async () => undefined),
},
reply: {
resolveEnvelopeFormatOptions:
@@ -196,6 +197,11 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
upsertPairingRequest: vi.fn(),
buildPairingReply: vi.fn(),
},
turn: {
runPrepared: vi.fn(async (params) => ({
dispatchResult: await params.runDispatch(),
})),
},
...overrides.channel,
},
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),

View File

@@ -1268,8 +1268,18 @@ export async function handleFeishuMessage(params: {
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId,
});
const agentRecord = {
onRecordError: (err: unknown) => {
log(
`feishu[${account.accountId}]: failed to record broadcast inbound session ${agentSessionKey}: ${String(err)}`,
);
},
};
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
storePath: core.channel.session.resolveStorePath(cfg.session?.store, { agentId }),
storePath: agentStorePath,
sessionKey: agentSessionKey,
});
const agentCtx = await buildCtxPayloadForAgent(
@@ -1302,15 +1312,30 @@ export async function handleFeishuMessage(params: {
log(
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
await core.channel.turn.runPrepared({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: agentSessionKey,
storePath: agentStorePath,
ctxPayload: agentCtx,
recordInboundSession: core.channel.session.recordInboundSession,
record: agentRecord,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
replyOptions,
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher,
replyOptions,
}),
}),
});
} else {
@@ -1331,13 +1356,23 @@ export async function handleFeishuMessage(params: {
log(
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
);
await core.channel.reply.withReplyDispatcher({
dispatcher: noopDispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
await core.channel.turn.runPrepared({
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,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: agentCtx,
cfg,
dispatcher: noopDispatcher,
}),
}),
});
}
@@ -1385,10 +1420,11 @@ export async function handleFeishuMessage(params: {
);
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
storePath: core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
}),
storePath,
sessionKey: route.sessionKey,
});
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
@@ -1409,19 +1445,41 @@ export async function handleFeishuMessage(params: {
});
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
const { dispatchResult } = await core.channel.turn.runPrepared({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
);
},
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
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 (isGroup && historyKey && chatHistories) {
clearHistoryEntriesIfEnabled({

View File

@@ -86,6 +86,27 @@ function createTestRuntime(overrides?: {
},
);
const recordInboundSession = vi.fn(async () => {});
const runPrepared = vi.fn(
async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
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),
});
const dispatchResult = await turn.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
},
);
return {
channel: {
@@ -112,6 +133,11 @@ function createTestRuntime(overrides?: {
resolveStorePath: vi.fn(() => "/tmp/feishu-session-store.json"),
recordInboundSession,
},
turn: {
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
dispatchAssembled:
vi.fn() as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"],
},
pairing: {
readAllowFromStore: vi.fn(overrides?.readAllowFromStore ?? (async () => [])),
upsertPairingRequest: vi.fn(

View File

@@ -221,16 +221,6 @@ export async function handleFeishuCommentEvent(
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
agentId: route.agentId,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: commentSessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
createFeishuCommentReplyDispatcher({
@@ -245,28 +235,59 @@ export async function handleFeishuCommentEvent(
isWholeComment: turn.isWholeComment,
});
let dispatchSettledBeforeStart = false;
try {
log(
`feishu[${account.accountId}]: dispatching drive comment to agent ` +
`(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
);
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
const { dispatchResult } = await core.channel.turn.runPrepared({
channel: "feishu",
accountId: route.accountId,
routeSessionKey: commentSessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
error(
`feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
);
},
},
onPreDispatchFailure: async () => {
dispatchSettledBeforeStart = true;
await core.channel.reply.settleReplyDispatcher({
dispatcher,
replyOptions,
onSettled: () => {
markRunComplete();
markDispatchIdle();
},
});
},
runDispatch: () =>
core.channel.reply.withReplyDispatcher({
dispatcher,
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),
}),
});
const queuedFinal = dispatchResult?.queuedFinal ?? false;
const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
log(
`feishu[${account.accountId}]: drive comment dispatch complete ` +
`(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`,
);
} finally {
markRunComplete();
markDispatchIdle();
if (!dispatchSettledBeforeStart) {
markRunComplete();
markDispatchIdle();
}
void cleanupTypingReaction();
}
}

View File

@@ -226,16 +226,6 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `googlechat:${spaceId}`,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
});
// Typing indicator setup
// Note: Reaction mode requires user OAuth, not available with service account auth.
// If reaction is configured, we fall back to message mode with a warning.
@@ -275,11 +265,18 @@ async function processMessageWithPipeline(params: {
accountId: route.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
await core.channel.turn.dispatchAssembled({
cfg: config,
dispatcherOptions: {
...replyPipeline,
channel: "googlechat",
accountId: route.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverGoogleChatReply({
payload,
@@ -300,9 +297,15 @@ async function processMessageWithPipeline(params: {
);
},
},
dispatcherOptions: replyPipeline,
replyOptions: {
onModelSelected,
},
record: {
onRecordError: (err) => {
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
},
},
});
}

View File

@@ -12,6 +12,7 @@ import {
} from "openclaw/plugin-sdk/conversation-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
clearHistoryEntriesIfEnabled,
@@ -21,6 +22,7 @@ import {
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import {
@@ -395,36 +397,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
allowFrom,
normalizeEntry: normalizeIMessageHandle,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? decision.route.sessionKey,
ctx: ctxPayload,
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)}`);
},
});
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(ctxPayload.Body ?? "", 200).replace(/\n/g, "\\n");
logVerbose(
@@ -467,18 +439,55 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
const { dispatchResult } = await runPreparedInboundReplyTurn({
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,
onModelSelected,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
},
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
runDispatch: () =>
dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected,
},
}),
});
const queuedFinal = dispatchResult.queuedFinal;
if (!queuedFinal) {
if (decision.isGroup && decision.historyKey) {

View File

@@ -9,7 +9,6 @@ import {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
ensureConfiguredBindingRouteReady,
recordInboundSession,
resolvePinnedMainDmOwnerFromAllowlist,
resolveConfiguredBindingRoute,
resolveRuntimeConversationBindingRoute,
@@ -378,35 +377,6 @@ async function finalizeLineInboundContext(params: {
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
})
: null;
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey,
ctx: ctxPayload,
updateLastRoute: !params.source.isGroup
? {
sessionKey: params.route.mainSessionKey,
channel: "line",
to: params.source.userId ?? params.source.peerId,
accountId: params.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && params.source.userId
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: params.source.userId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo =
@@ -419,7 +389,44 @@ async function finalizeLineInboundContext(params: {
);
}
return { ctxPayload, replyToken: (params.event as { replyToken: string }).replyToken };
return {
ctxPayload,
replyToken: (params.event as { replyToken: string }).replyToken,
turn: {
storePath,
record: {
updateLastRoute: !params.source.isGroup
? {
sessionKey: params.route.mainSessionKey,
channel: "line",
to: params.source.userId ?? params.source.peerId,
accountId: params.route.accountId,
mainDmOwnerPin:
pinnedMainDmOwner && params.source.userId
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: params.source.userId,
onSkip: ({
ownerRecipient,
senderRecipient,
}: {
ownerRecipient: string;
senderRecipient: string;
}) => {
logVerbose(
`line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err: unknown) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
},
},
},
};
}
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
@@ -469,7 +476,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
}))
: undefined;
const { ctxPayload } = await finalizeLineInboundContext({
const finalized = await finalizeLineInboundContext({
cfg,
account,
event,
@@ -494,7 +501,8 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
});
return {
ctxPayload,
ctxPayload: finalized.ctxPayload,
turn: finalized.turn,
event,
userId,
groupId,
@@ -535,7 +543,7 @@ export async function buildLinePostbackContext(params: {
}
const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
const { ctxPayload } = await finalizeLineInboundContext({
const finalized = await finalizeLineInboundContext({
cfg,
account,
event,
@@ -555,7 +563,8 @@ export async function buildLinePostbackContext(params: {
});
return {
ctxPayload,
ctxPayload: finalized.ctxPayload,
turn: finalized.turn,
event,
userId,
groupId,

View File

@@ -1,6 +1,8 @@
import type { webhook } from "@line/bot-sdk";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import {
dispatchReplyWithBufferedBlockDispatcher,
chunkMarkdownText,
@@ -231,69 +233,80 @@ export async function monitorLineProvider(
accountId: route.accountId,
});
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...replyPipeline,
deliver: async (payload, _info) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const { dispatchResult } = await runPreparedInboundReplyTurn({
channel: "line",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath: ctx.turn.storePath,
ctxPayload,
recordInboundSession,
record: ctx.turn.record,
runDispatch: () =>
dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...replyPipeline,
deliver: async (payload, _info) => {
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
if (ctx.userId && !ctx.isGroup) {
void showLoadingAnimation(ctx.userId, {
cfg: config,
accountId: ctx.accountId,
}).catch(() => {});
}
if (ctx.userId && !ctx.isGroup) {
void showLoadingAnimation(ctx.userId, {
cfg: config,
accountId: ctx.accountId,
}).catch(() => {});
}
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
payload,
lineData,
to: ctxPayload.From,
replyToken,
replyTokenUsed,
accountId: ctx.accountId,
cfg: config,
textLimit,
deps: {
buildTemplateMessageFromPayload,
processLineMessage,
chunkMarkdownText,
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
createTextMessageWithQuickReplies,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
onReplyError: (replyErr) => {
logVerbose(
`line: reply token failed, falling back to push: ${String(replyErr)}`,
);
},
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
payload,
lineData,
to: ctxPayload.From,
replyToken,
replyTokenUsed,
accountId: ctx.accountId,
cfg: config,
textLimit,
deps: {
buildTemplateMessageFromPayload,
processLineMessage,
chunkMarkdownText,
sendLineReplyChunks,
replyMessageLine,
pushMessageLine,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
createTextMessageWithQuickReplies,
pushMessagesLine,
createFlexMessage,
createImageMessage,
createLocationMessage,
onReplyError: (replyErr) => {
logVerbose(
`line: reply token failed, falling back to push: ${String(replyErr)}`,
);
},
},
});
replyTokenUsed = nextReplyTokenUsed;
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastOutboundAt: Date.now(),
},
});
},
});
replyTokenUsed = nextReplyTokenUsed;
recordChannelRuntimeState({
channel: "line",
accountId: resolvedAccountId,
state: {
lastOutboundAt: Date.now(),
onError: (err, info) => {
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
},
});
},
onError: (err, info) => {
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: {
onModelSelected,
},
},
replyOptions: {
onModelSelected,
},
}),
});
const queuedFinal = dispatchResult.queuedFinal;
if (!queuedFinal) {
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);

View File

@@ -119,6 +119,29 @@ export function createMatrixHandlerTestHarness(
counts: { final: 0, block: 0, tool: 0 },
}));
const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn();
const runPrepared = vi.fn(
async (
turn: Parameters<MatrixMonitorHandlerParams["core"]["channel"]["turn"]["runPrepared"]>[0],
) => {
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),
});
const dispatchResult = await turn.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
},
);
const dmPolicy = options.dmPolicy ?? "open";
const allowFrom = options.allowFrom ?? (dmPolicy === "open" ? ["*"] : []);
const cfgForHandler =
@@ -205,6 +228,10 @@ export function createMatrixHandlerTestHarness(
}
}),
},
turn: {
runPrepared,
dispatchAssembled: vi.fn(),
},
reactions: {
shouldAckReaction: options.shouldAckReaction ?? (() => false),
},

View File

@@ -1352,40 +1352,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
ctx: ctxPayload,
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,
});
},
});
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)}`,
);
});
}
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
@@ -1862,58 +1828,107 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onIdle: typingCallbacks.onIdle,
});
const { queuedFinal, counts } = 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,
},
const { dispatchResult } = await core.channel.turn.runPrepared({
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,
});
} finally {
markRunComplete();
},
},
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)}`,
);
});
}
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();
}
},
});
},
});
const { queuedFinal, counts } = dispatchResult;
if (finalReplyDeliveryFailed) {
if (retryableReplyDeliveryFailed) {
logVerboseMessage(

View File

@@ -132,6 +132,42 @@ vi.mock("./runtime-api.js", async () => {
});
function createRuntimeCore(cfg: OpenClawConfig) {
const runPrepared = vi.fn(
async (turn: {
storePath: string;
routeSessionKey: string;
ctxPayload: { SessionKey?: string };
recordInboundSession: (params: unknown) => Promise<void>;
record?: {
groupResolution?: unknown;
createIfMissing?: boolean;
updateLastRoute?: unknown;
onRecordError?: (err: unknown) => void;
};
runDispatch: () => Promise<{
queuedFinal: boolean;
counts: { tool: number; block: number; final: number };
}>;
}) => {
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),
});
const dispatchResult = await turn.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
},
);
return {
config: {
current: () => cfg,
@@ -212,8 +248,13 @@ function createRuntimeCore(cfg: OpenClawConfig) {
},
session: {
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
recordInboundSession: vi.fn(async () => {}),
updateLastRoute: vi.fn(async () => {}),
},
turn: {
runPrepared,
dispatchAssembled: vi.fn(),
},
text: {
chunkMarkdownTextWithMode: (text: string) => [text],
convertMarkdownTables: (text: string) => text,

View File

@@ -1570,21 +1570,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...mediaPayload,
});
if (kind === "direct") {
const sessionCfg = cfg.session;
const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "mattermost",
to,
accountId: route.accountId,
},
});
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(
@@ -1731,39 +1719,75 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
});
let dispatchSettledBeforeStart = false;
try {
await 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…");
await core.channel.turn.runPrepared({
channel: "mattermost",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute:
kind === "direct"
? {
sessionKey: route.mainSessionKey,
channel: "mattermost",
to,
accountId: route.accountId,
}
},
onToolStart: async (payload) => {
draftStream.update(buildMattermostToolStatusText(payload));
},
: undefined,
onRecordError: (err) => {
logVerboseMessage(
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
);
},
},
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 {
@@ -1772,7 +1796,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
} catch (err) {
logVerboseMessage(`mattermost draft preview cleanup failed: ${String(err)}`);
}
markRunComplete();
if (!dispatchSettledBeforeStart) {
markRunComplete();
}
}
if (historyKey) {
clearHistoryEntriesIfEnabled({

View File

@@ -19,6 +19,27 @@ type MSTeamsTestRuntimeOptions = {
};
export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {}): void {
const runPrepared = vi.fn(
async (turn: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
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),
});
const dispatchResult = await turn.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
},
);
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() },
@@ -68,6 +89,11 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
recordInboundSession: options.recordInboundSession ?? vi.fn(async () => undefined),
...(options.resolveStorePath ? { resolveStorePath: options.resolveStorePath } : {}),
},
turn: {
runPrepared: runPrepared as unknown as PluginRuntime["channel"]["turn"]["runPrepared"],
dispatchAssembled:
vi.fn() as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"],
},
},
} as unknown as PluginRuntime);
}

View File

@@ -793,15 +793,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`);
},
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
@@ -845,14 +836,35 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
cfg,
const { dispatchResult } = await core.channel.turn.runPrepared({
channel: "msteams",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
configOverride,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${formatUnknownError(err)}`);
},
},
onPreDispatchFailure: () =>
core.channel.reply.settleReplyDispatcher({
dispatcher,
onSettled: () => markDispatchIdle(),
}),
runDispatch: () =>
dispatchReplyFromConfigWithSettledDispatcher({
cfg,
ctxPayload,
dispatcher,
onSettled: () => markDispatchIdle(),
replyOptions,
configOverride,
}),
});
const queuedFinal = dispatchResult?.queuedFinal ?? false;
const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
log.info("dispatch complete", { queuedFinal, counts });

View File

@@ -64,6 +64,16 @@ function makeRuntime(): GatewayPluginRuntime {
resolveEffectiveMessagesConfig: vi.fn(() => ({})),
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"),
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
runPrepared: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await params.runDispatch() };
}),
},
text: {
chunkMarkdownText: (text: string) => [text],
},

View File

@@ -136,6 +136,16 @@ function makeRuntime(params: {
resolveEffectiveMessagesConfig: vi.fn(() => ({})),
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
},
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"),
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
runPrepared: vi.fn(async (rawParams: unknown) => {
const params = rawParams as { runDispatch: () => Promise<unknown> };
return { dispatchResult: await params.runDispatch() };
}),
},
text: {
chunkMarkdownText: (text: string) => [text],
},

View File

@@ -219,221 +219,243 @@ export async function dispatchOutbound(
});
}
const dispatchPromise = runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: messagesConfig.responsePrefix,
deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => {
hasResponse = true;
const cfgWithSession = cfg as { session?: { store?: unknown } };
const agentId = inbound.route.agentId ?? "default";
const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, {
agentId,
});
const dispatchPromise = runtime.channel.turn.runPrepared({
channel: "qqbot",
accountId: inbound.route.accountId,
routeSessionKey: inbound.route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
record: {
onRecordError: (err: unknown) => {
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 ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
// ---- Tool deliver ----
if (info.kind === "tool") {
toolDeliverCount++;
const toolText = (payload.text ?? "").trim();
if (toolText) {
toolTexts.push(toolText);
}
if (payload.mediaUrls?.length) {
toolMediaUrls.push(...payload.mediaUrls);
}
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
toolMediaUrls.push(payload.mediaUrl);
}
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
if (hasBlockResponse && toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
try {
await sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
} catch {}
}
return;
}
if (toolFallbackSent) {
return;
}
if (toolOnlyTimeoutId) {
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
} else {
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
}
}, TOOL_ONLY_TIMEOUT);
return;
}
}
toolOnlyTimeoutId = setTimeout(async () => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
// ---- Block deliver ----
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
}, TOOL_ONLY_TIMEOUT);
return;
}
// ---- 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;
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
return typeof s === "object" && s !== null && s.mode === "off";
})(),
...(streamingController
? {
onPartialReply: async (payload: { text?: string }) => {
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onPartialReply(payload);
} catch (partialErr) {
await streamingController.onDeliver(payload);
} catch (err) {
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 {

View File

@@ -52,6 +52,13 @@ export interface GatewayPluginRuntime {
formatInboundEnvelope: (params: unknown) => string;
resolveEnvelopeFormatOptions: (cfg: unknown) => unknown;
};
session: {
resolveStorePath: (store: unknown, params: { agentId: string }) => string;
recordInboundSession: (params: unknown) => Promise<unknown>;
};
turn: {
runPrepared: (params: unknown) => Promise<unknown>;
};
text: {
chunkMarkdownText: (text: string, limit: number) => string[];
};

View File

@@ -25,6 +25,7 @@ import {
toInternalMessageReceivedContext,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
buildPendingHistoryContextFromMap,
@@ -34,6 +35,7 @@ import {
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
@@ -232,42 +234,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
OriginatingTo: signalTo,
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: !entry.isGroup
? {
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)}`);
},
});
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n");
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
@@ -323,18 +289,69 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
});
const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected,
const { dispatchResult } = await runPreparedInboundReplyTurn({
channel: "signal",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession,
record: {
updateLastRoute: !entry.isGroup
? {
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)}`);
},
},
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();
}
},
});
markDispatchIdle();
const queuedFinal = dispatchResult?.queuedFinal ?? false;
if (!queuedFinal) {
if (entry.isGroup && historyKey) {
clearHistoryEntriesIfEnabled({

View File

@@ -100,12 +100,17 @@ function createPreparedSlackMessage(params?: {
agentId: "agent-1",
accountId: "default",
mainSessionKey: "main",
sessionKey: "agent:agent-1:slack:C123",
},
channelConfig: null,
replyTarget: "channel:C123",
ctxPayload: {
MessageThreadId: THREAD_TS,
},
turn: {
storePath: "/tmp/slack-sessions.json",
record: {},
},
replyToMode: params?.replyToMode ?? "all",
isDirectMessage: false,
isRoomish: false,
@@ -139,6 +144,10 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
removeAckReactionAfterReply: () => {},
}));
vi.mock("../conversation.runtime.js", () => ({
recordInboundSession: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
createChannelReplyPipeline: () => ({
typingCallbacks: {

View File

@@ -18,6 +18,7 @@ import {
resolveChannelStreamingPreviewToolProgress,
} from "openclaw/plugin-sdk/channel-streaming";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
@@ -50,6 +51,7 @@ import {
import { resolveSlackThreadTargets } from "../../threading.js";
import { normalizeSlackAllowOwnerEntry } from "../allow-list.js";
import { resolveStorePath, updateLastRoute } from "../config.runtime.js";
import { recordInboundSession } from "../conversation.runtime.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
import {
createSlackReplyDeliveryPlan,
@@ -58,7 +60,11 @@ import {
resolveDeliveredSlackReplyThreadTs,
resolveSlackThreadTs,
} from "../replies.js";
import { createReplyDispatcherWithTyping, dispatchInboundMessage } from "../reply.runtime.js";
import {
createReplyDispatcherWithTyping,
dispatchInboundMessage,
settleReplyDispatcher,
} from "../reply.runtime.js";
import { finalizeSlackPreviewEdit } from "./preview-finalize.js";
import type { PreparedSlackMessage } from "./types.js";
@@ -976,83 +982,104 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
let dispatchError: unknown;
let queuedFinal = false;
let counts: { final?: number; block?: number } = {};
let dispatchSettledBeforeStart = false;
try {
const result = await 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 { dispatchResult } = await runPreparedInboundReplyTurn({
channel: "slack",
accountId: route.accountId,
routeSessionKey: route.sessionKey,
storePath: prepared.turn.storePath,
ctxPayload: prepared.ctxPayload,
recordInboundSession,
record: prepared.turn.record as Parameters<typeof runPreparedInboundReplyTurn>[0]["record"],
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;
queuedFinal = result.queuedFinal;
counts = result.counts;
} catch (err) {
dispatchError = err;
} finally {
await draftStream?.discardPending();
markDispatchIdle();
if (!dispatchSettledBeforeStart) {
markDispatchIdle();
}
}
// -----------------------------------------------------------------------

View File

@@ -54,7 +54,7 @@ import {
resolveSlackChatType,
type SlackMonitorContext,
} from "../context.js";
import { recordInboundSession, resolveConversationLabel } from "../conversation.runtime.js";
import { resolveConversationLabel } from "../conversation.runtime.js";
import { authorizeSlackDirectMessage } from "../dm-auth.js";
import { resolveSlackRoomContextHints } from "../room-context.js";
import { sendMessageSlack } from "../send.runtime.js";
@@ -746,43 +746,6 @@ export async function prepareSlackMessage(params: {
})
: null;
await recordInboundSession({
storePath,
sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: threadContext.messageThreadId,
mainDmOwnerPin:
pinnedMainDmOwner && message.user
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: normalizeLowercaseStringOrEmpty(message.user),
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
ctx.logger.warn(
{
error: formatErrorMessage(err),
storePath,
sessionKey,
},
"failed updating session meta",
);
},
});
// Live DM replies should target the concrete Slack DM channel id we just
// received on. This avoids depending on a follow-up conversations.open
// round-trip for the normal reply path while keeping persisted routing
@@ -804,6 +767,48 @@ export async function prepareSlackMessage(params: {
channelConfig,
replyTarget,
ctxPayload,
turn: {
storePath,
record: {
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "slack",
to: `user:${message.user}`,
accountId: route.accountId,
threadId: threadContext.messageThreadId,
mainDmOwnerPin:
pinnedMainDmOwner && message.user
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: normalizeLowercaseStringOrEmpty(message.user),
onSkip: ({
ownerRecipient,
senderRecipient,
}: {
ownerRecipient: string;
senderRecipient: string;
}) => {
logVerbose(
`slack: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err: unknown) => {
ctx.logger.warn(
{
error: formatErrorMessage(err),
storePath,
sessionKey,
},
"failed updating session meta",
);
},
},
},
replyToMode,
isDirectMessage,
isRoomish,

View File

@@ -13,6 +13,10 @@ export type PreparedSlackMessage = {
channelConfig: SlackChannelConfigResolved | null;
replyTarget: string;
ctxPayload: FinalizedMsgContext;
turn: {
storePath: string;
record: unknown;
};
replyToMode: "off" | "first" | "all" | "batched";
isDirectMessage: boolean;
isRoomish: boolean;

View File

@@ -7,5 +7,6 @@ export {
getReplyFromConfig,
isSilentReplyText,
resolveTextChunkLimit,
settleReplyDispatcher,
SILENT_REPLY_TOKEN,
} from "openclaw/plugin-sdk/reply-runtime";

View File

@@ -95,6 +95,23 @@ vi.mock("./runtime.js", () => ({
finalizeInboundContext: finalizeInboundContextMock,
dispatchReplyWithBufferedBlockDispatcher,
},
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw/synology-chat-sessions.json"),
recordInboundSession: vi.fn(async () => undefined),
},
turn: {
dispatchAssembled: vi.fn(async (params) => ({
dispatchResult: await params.dispatchReplyWithBufferedBlockDispatcher({
ctx: params.ctxPayload,
cfg: mockRuntimeConfig,
dispatcherOptions: {
...params.dispatcherOptions,
deliver: params.delivery.deliver,
onError: params.delivery.onError,
},
}),
})),
},
},
})),
setSynologyRuntime: vi.fn(),

View File

@@ -78,21 +78,40 @@ export async function dispatchSynologyChatInboundTurn(params: {
sessionKey: resolved.sessionKey,
});
await resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: msgCtx,
const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
agentId: resolved.route.agentId,
});
await resolved.rt.channel.turn.dispatchAssembled({
cfg: currentCfg,
dispatcherOptions: {
deliver: async (payload: { text?: string; body?: string }) => {
channel: CHANNEL_ID,
accountId: params.account.accountId,
agentId: resolved.route.agentId,
routeSessionKey: resolved.route.sessionKey,
storePath,
ctxPayload: msgCtx,
recordInboundSession: resolved.rt.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverSynologyChatReply({
account: params.account,
sendUserId,
payload,
});
},
},
dispatcherOptions: {
onReplyStart: () => {
params.log?.info?.(`Agent reply started for ${params.msg.from}`);
},
},
record: {
onRecordError: (err) => {
params.log?.info?.(`Session metadata update failed for ${params.msg.from}`, err);
},
},
});
return null;

View File

@@ -169,7 +169,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({
expect(ctx?.turn.record).toMatchObject({
updateLastRoute: undefined,
});
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);

View File

@@ -40,16 +40,23 @@ export const telegramRouteTestSessionRuntime = {
export async function loadTelegramMessageContextRouteHarness() {
const { buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js");
const buildTelegramMessageContextForRouteTest = (
const buildTelegramMessageContextForRouteTest = async (
params: BuildTelegramMessageContextForTestParams,
) =>
buildTelegramMessageContextForTest({
) => {
const ctx = await buildTelegramMessageContextForTest({
...params,
sessionRuntime: {
...telegramRouteTestSessionRuntime,
...params.sessionRuntime,
},
});
if (ctx) {
await recordInboundSessionMock({
updateLastRoute: ctx.turn.record.updateLastRoute,
});
}
return ctx;
};
return {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,

View File

@@ -122,6 +122,16 @@ export async function buildTelegramInboundContextPayload(params: {
}): Promise<{
ctxPayload: FinalizedTelegramInboundContext;
skillFilter: string[] | undefined;
turn: {
storePath: string;
recordInboundSession: TelegramMessageContextSessionRuntime["recordInboundSession"];
record: {
updateLastRoute?: Parameters<
TelegramMessageContextSessionRuntime["recordInboundSession"]
>[0]["updateLastRoute"];
onRecordError: (err: unknown) => void;
};
};
}> {
const {
cfg,
@@ -415,42 +425,34 @@ export async function buildTelegramInboundContextPayload(params: {
? String(dmThreadId)
: undefined;
await sessionRuntime.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute:
!isGroup || updateLastRouteThreadId != null
? {
sessionKey: updateLastRouteSessionKey,
channel: "telegram",
to:
isGroup && updateLastRouteThreadId != null
? `telegram:${chatId}:topic:${updateLastRouteThreadId}`
: `telegram:${chatId}`,
accountId: route.accountId,
threadId: updateLastRouteThreadId,
mainDmOwnerPin:
!isGroup &&
updateLastRouteSessionKey === route.mainSessionKey &&
pinnedMainDmOwner &&
senderId
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: senderId,
onSkip: ({ ownerRecipient, senderRecipient }) => {
logVerbose(
`telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
);
},
}
: undefined,
}
: undefined,
onRecordError: (err) => {
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
},
});
const updateLastRoute =
!isGroup || updateLastRouteThreadId != null
? {
sessionKey: updateLastRouteSessionKey,
channel: "telegram" as const,
to:
isGroup && updateLastRouteThreadId != null
? `telegram:${chatId}:topic:${updateLastRouteThreadId}`
: `telegram:${chatId}`,
accountId: route.accountId,
threadId: updateLastRouteThreadId,
mainDmOwnerPin:
!isGroup &&
updateLastRouteSessionKey === route.mainSessionKey &&
pinnedMainDmOwner &&
senderId
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: senderId,
onSkip: (skipParams: { ownerRecipient: string; senderRecipient: string }) => {
logVerbose(
`telegram: skip main-session last route for ${skipParams.senderRecipient} (pinned owner ${skipParams.ownerRecipient})`,
);
},
}
: undefined,
}
: undefined;
if (visibleReplyTarget && shouldLogVerbose()) {
const preview = (visibleReplyTarget.body ?? "").replace(/\s+/g, " ").slice(0, 120);
@@ -477,5 +479,15 @@ export async function buildTelegramInboundContextPayload(params: {
return {
ctxPayload,
skillFilter,
turn: {
storePath,
recordInboundSession: sessionRuntime.recordInboundSession,
record: {
updateLastRoute,
onRecordError: (err) => {
logVerbose(`telegram: failed updating session meta: ${String(err)}`);
},
},
},
};
}

View File

@@ -93,7 +93,7 @@ describe("buildTelegramMessageContext thread binding override", () => {
}),
);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-1");
expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({
expect(ctx?.turn.record).toMatchObject({
updateLastRoute: undefined,
});
});

View File

@@ -73,6 +73,7 @@ type TelegramStatusReactionController = {
export type TelegramMessageContext = {
ctxPayload: TelegramMessageContextPayload["ctxPayload"];
turn: TelegramMessageContextPayload["turn"];
primaryCtx: BuildTelegramMessageContextParams["primaryCtx"];
msg: BuildTelegramMessageContextParams["primaryCtx"]["message"];
chatId: BuildTelegramMessageContextParams["primaryCtx"]["message"]["chat"]["id"];
@@ -554,7 +555,7 @@ export const buildTelegramMessageContext = async ({
)
: null;
const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({
const { ctxPayload, skillFilter, turn } = await buildTelegramInboundContextPayload({
cfg,
primaryCtx,
msg,
@@ -592,6 +593,7 @@ export const buildTelegramMessageContext = async ({
return {
ctxPayload,
turn,
primaryCtx,
msg,
chatId,

View File

@@ -290,6 +290,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
reactionApi: null,
removeAckAfterReply: false,
} as unknown as TelegramMessageContext;
base.turn = {
storePath: "/tmp/openclaw/telegram-sessions.json",
recordInboundSession: vi.fn(async () => undefined),
record: {
onRecordError: vi.fn(),
},
} as unknown as TelegramMessageContext["turn"];
return {
...base,

View File

@@ -17,6 +17,7 @@ import type {
TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
@@ -840,300 +841,313 @@ export const dispatchTelegramMessage = async ({
});
try {
({ queuedFinal } = await 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();
const { dispatchResult } = await runPreparedInboundReplyTurn({
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;
}
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) {
const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (
didDeliver: boolean,
) => {
if (didDeliver && info.kind !== "final") {
pendingCompactionReplayBoundary = false;
activePreviewLifecycleByLane.answer = "transient";
retainPreviewOnCleanupByLane.answer = 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;
}
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 () => {
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 (
answerLane.hasStreamedMessage &&
activePreviewLifecycleByLane.answer === "transient"
segment.lane === "answer" &&
info.kind === "final" &&
reasoningStepState.shouldBufferFinalAnswer()
) {
pendingCompactionReplayBoundary = true;
reasoningStepState.bufferFinalAnswer({
payload,
text: segment.text,
});
continue;
}
if (statusReactionController) {
await statusReactionController.setCompacting();
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();
}
}
: undefined,
onCompactionEnd: statusReactionController
? async () => {
statusReactionController.cancelPending();
await statusReactionController.setThinking();
}
: undefined,
onModelSelected,
},
}));
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);
} catch (err) {
dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));

View File

@@ -550,13 +550,22 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
route.agentId,
).responsePrefix;
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
await core.channel.turn.dispatchAssembled({
cfg,
dispatcherOptions: {
responsePrefix,
humanDelay,
channel: "tlon",
accountId: route.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload: ReplyPayload) => {
let replyText = payload.text;
if (!replyText) {
@@ -607,6 +616,15 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
);
},
},
dispatcherOptions: {
responsePrefix,
humanDelay,
},
record: {
onRecordError: (err) => {
runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`);
},
},
});
};

View File

@@ -62,6 +62,7 @@ async function processTwitchMessage(params: {
});
const rawBody = message.message;
const senderId = message.userId ?? message.username;
const body = core.channel.reply.formatAgentEnvelope({
channel: "Twitch",
from: message.displayName ?? message.username,
@@ -70,38 +71,47 @@ async function processTwitchMessage(params: {
body: rawBody,
});
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
RawBody: rawBody,
CommandBody: rawBody,
From: `twitch:user:${message.userId}`,
To: `twitch:channel:${message.channel}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: "group",
ConversationLabel: message.channel,
SenderName: message.displayName ?? message.username,
SenderId: message.userId,
SenderUsername: message.username,
Provider: "twitch",
Surface: "twitch",
MessageSid: message.id,
OriginatingChannel: "twitch",
OriginatingTo: `twitch:channel:${message.channel}`,
const ctxPayload = core.channel.turn.buildContext({
channel: "twitch",
accountId,
messageId: message.id,
timestamp: message.timestamp?.getTime(),
from: `twitch:user:${senderId}`,
sender: {
id: senderId,
name: message.displayName ?? message.username,
username: message.username,
},
conversation: {
kind: "group",
id: message.channel,
label: message.channel,
routePeer: {
kind: "group",
id: message.channel,
},
},
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: route.sessionKey,
},
reply: {
to: `twitch:channel:${message.channel}`,
originatingTo: `twitch:channel:${message.channel}`,
},
message: {
body,
rawBody,
bodyForAgent: rawBody,
commandBody: rawBody,
envelopeFrom: message.displayName ?? message.username,
},
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`Failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
@@ -115,11 +125,18 @@ async function processTwitchMessage(params: {
accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
await core.channel.turn.dispatchAssembled({
cfg,
dispatcherOptions: {
...replyPipeline,
channel: "twitch",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverTwitchReply({
payload,
@@ -132,10 +149,19 @@ async function processTwitchMessage(params: {
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
},
},
dispatcherOptions: replyPipeline,
replyOptions: {
onModelSelected,
},
record: {
onRecordError: (err) => {
runtime.error?.(`Failed updating session meta: ${String(err)}`);
},
},
});
}

View File

@@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted mocks used across tests so vi.mock factories can reference them.
const { resolvePolicyMock, buildContextMock, runMessageReceivedMock } = vi.hoisted(() => ({
resolvePolicyMock: vi.fn(),
buildContextMock: vi.fn(),
runMessageReceivedMock: vi.fn(async () => undefined),
}));
const { resolvePolicyMock, buildContextMock, runMessageReceivedMock, trackBackgroundTaskMock } =
vi.hoisted(() => ({
resolvePolicyMock: vi.fn(),
buildContextMock: vi.fn(),
runMessageReceivedMock: vi.fn(async () => undefined),
trackBackgroundTaskMock: vi.fn(),
}));
vi.mock("../../inbound-policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
@@ -89,7 +91,7 @@ vi.mock("./last-route.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./last-route.js")>();
return {
...actual,
trackBackgroundTask: () => {},
trackBackgroundTask: trackBackgroundTaskMock,
updateLastRouteInBackground: () => {},
};
});
@@ -211,6 +213,7 @@ describe("processMessage group system prompt wiring", () => {
buildContextMock.mockReset();
resolvePolicyMock.mockReset();
runMessageReceivedMock.mockClear();
trackBackgroundTaskMock.mockClear();
clearInternalHooks();
buildContextMock.mockImplementation(
(params: { groupSystemPrompt?: string; combinedBody?: string }) => ({
@@ -320,4 +323,22 @@ describe("processMessage group system prompt wiring", () => {
expect(runMessageReceivedMock).not.toHaveBeenCalled();
expect(internalReceived).not.toHaveBeenCalled();
});
it("tracks session metadata writes as connection background tasks", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({
Body: "hi",
RawBody: "hi",
CommandBody: "hi",
SessionKey: baseRoute.sessionKey,
Provider: "whatsapp",
Surface: "whatsapp",
}));
await callProcessMessage();
expect(trackBackgroundTaskMock).toHaveBeenCalledTimes(1);
expect(trackBackgroundTaskMock.mock.calls[0]?.[0]).toBeInstanceOf(Set);
expect(trackBackgroundTaskMock.mock.calls[0]?.[1]).toBeInstanceOf(Promise);
});
});

View File

@@ -3,6 +3,7 @@ import {
removeAckReactionHandleAfterReply,
type AckReactionHandle,
} from "openclaw/plugin-sdk/channel-feedback";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
createInternalHookEvent,
deriveInboundMessageHookContext,
@@ -12,6 +13,7 @@ import {
toPluginMessageReceivedEvent,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import { runPreparedInboundReplyTurn } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
@@ -51,7 +53,6 @@ import {
formatInboundEnvelope,
logVerbose,
normalizeE164,
recordSessionMetaFromInbound,
resolveChannelContextVisibilityMode,
resolveInboundSessionEnvelopeContext,
resolvePinnedMainDmOwnerFromAllowlist,
@@ -453,43 +454,51 @@ export async function processMessage(params: {
warn: params.replyLogger.warn.bind(params.replyLogger),
});
const metaTask = recordSessionMetaFromInbound({
const { dispatchResult: didSendReply } = await runPreparedInboundReplyTurn({
channel: "whatsapp",
accountId: params.route.accountId,
routeSessionKey: params.route.sessionKey,
storePath,
sessionKey: params.route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
params.replyLogger.warn(
{
error: formatError(err),
storePath,
sessionKey: params.route.sessionKey,
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);
},
"failed updating session meta",
);
});
trackBackgroundTask(params.backgroundTasks, metaTask);
const didSendReply = await 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,
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,
}),
});
removeAckReactionHandleAfterReply({
removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply),

View File

@@ -606,15 +606,6 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
OriginatingTo: `zalo:${chatId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
@@ -649,11 +640,18 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
await core.channel.turn.dispatchAssembled({
cfg: config,
dispatcherOptions: {
...replyPipeline,
channel: "zalo",
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverZaloReply({
payload,
@@ -677,9 +675,15 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
},
},
dispatcherOptions: replyPipeline,
replyOptions: {
onModelSelected,
},
record: {
onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
},
});
}

View File

@@ -187,6 +187,41 @@ export function createImageLifecycleCore() {
async () => undefined,
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
},
turn: {
dispatchAssembled: vi.fn(
async (turn: Parameters<PluginRuntime["channel"]["turn"]["dispatchAssembled"]>[0]) => {
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),
});
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
ctx: turn.ctxPayload,
cfg: turn.cfg,
dispatcherOptions: {
...turn.dispatcherOptions,
deliver: async (payload, info) => {
await turn.delivery.deliver(payload, info);
},
onError: turn.delivery.onError,
},
replyOptions: turn.replyOptions,
replyResolver: turn.replyResolver,
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
},
) as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"],
},
commands: {
shouldComputeCommandAuthorized: vi.fn(
() => false,

View File

@@ -89,6 +89,39 @@ function installRuntime(params: {
const readSessionUpdatedAt = vi.fn(
(_params?: { storePath: string; sessionKey: string }): number | undefined => undefined,
);
const dispatchAssembled = vi.fn(
async (turn: Parameters<PluginRuntime["channel"]["turn"]["dispatchAssembled"]>[0]) => {
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),
});
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
ctx: turn.ctxPayload,
cfg: turn.cfg,
dispatcherOptions: {
...turn.dispatcherOptions,
deliver: async (payload, info) => {
await turn.delivery.deliver(payload, info);
},
onError: turn.delivery.onError,
},
replyOptions: turn.replyOptions,
replyResolver: turn.replyResolver,
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
},
);
const buildAgentSessionKey = vi.fn(
(input: {
agentId: string;
@@ -167,6 +200,10 @@ function installRuntime(params: {
finalizeInboundContext: vi.fn((ctx) => ctx),
dispatchReplyWithBufferedBlockDispatcher,
},
turn: {
dispatchAssembled:
dispatchAssembled as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"],
},
text: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),

View File

@@ -628,15 +628,6 @@ async function processMessage(
OriginatingTo: normalizedTo,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
},
});
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: config,
agentId: route.agentId,
@@ -658,11 +649,18 @@ async function processMessage(
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
await core.channel.turn.dispatchAssembled({
cfg: config,
dispatcherOptions: {
...replyPipeline,
channel: "zalouser",
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverZalouserReply({
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
@@ -685,9 +683,15 @@ async function processMessage(
runtime.error(`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`);
},
},
dispatcherOptions: replyPipeline,
replyOptions: {
onModelSelected,
},
record: {
onRecordError: (err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
},
},
});
if (isGroup && historyKey) {
clearHistoryEntriesIfEnabled({

View File

@@ -1,5 +1,17 @@
import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js";
export async function settleReplyDispatcher(params: {
dispatcher: ReplyDispatcher;
onSettled?: () => void | Promise<void>;
}): Promise<void> {
params.dispatcher.markComplete();
try {
await params.dispatcher.waitForIdle();
} finally {
await params.onSettled?.();
}
}
export async function withReplyDispatcher<T>(params: {
dispatcher: ReplyDispatcher;
run: () => Promise<T>;
@@ -8,12 +20,6 @@ export async function withReplyDispatcher<T>(params: {
try {
return await params.run();
} finally {
// Ensure dispatcher reservations are always released on every exit path.
params.dispatcher.markComplete();
try {
await params.dispatcher.waitForIdle();
} finally {
await params.onSettled?.();
}
await settleReplyDispatcher(params);
}
}

View File

@@ -96,7 +96,7 @@ function buildMessageSendingBeforeDeliver(
}
export type DispatchInboundResult = DispatchFromConfigResult;
export { withReplyDispatcher } from "./dispatch-dispatcher.js";
export { settleReplyDispatcher, withReplyDispatcher } from "./dispatch-dispatcher.js";
function finalizeDispatchResult(
result: DispatchFromConfigResult,

View File

@@ -36,11 +36,12 @@ export async function recordInboundSession(params: {
createIfMissing?: boolean;
updateLastRoute?: InboundLastRouteUpdate;
onRecordError: (err: unknown) => void;
trackSessionMetaTask?: (task: Promise<unknown>) => void;
}): Promise<void> {
const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
const canonicalSessionKey = normalizeLowercaseStringOrEmpty(sessionKey);
const runtime = await loadInboundSessionRuntime();
void runtime
const metaTask = runtime
.recordSessionMetaFromInbound({
storePath,
sessionKey: canonicalSessionKey,
@@ -49,6 +50,8 @@ export async function recordInboundSession(params: {
createIfMissing,
})
.catch(params.onRecordError);
params.trackSessionMetaTask?.(metaTask);
void metaTask;
const update = params.updateLastRoute;
if (!update) {

View File

@@ -22,4 +22,5 @@ export type RecordInboundSession = (params: {
createIfMissing?: boolean;
updateLastRoute?: InboundLastRouteUpdate;
onRecordError: (err: unknown) => void;
trackSessionMetaTask?: (task: Promise<unknown>) => void;
}) => Promise<void>;

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { buildChannelTurnContext } from "./context.js";
describe("buildChannelTurnContext", () => {
it("maps normalized turn facts into a finalized message context", () => {
const ctx = buildChannelTurnContext({
channel: "test",
accountId: "acct",
provider: "test-provider",
surface: "test-surface",
messageId: "msg-1",
timestamp: 123,
from: "test:user:u1",
sender: {
id: "u1",
name: "User One",
username: "userone",
tag: "User#0001",
roles: ["admin"],
},
conversation: {
kind: "group",
id: "room-1",
label: "Room One",
spaceId: "workspace",
threadId: "thread-1",
routePeer: {
kind: "group",
id: "room-1",
},
},
route: {
agentId: "main",
accountId: "acct",
routeSessionKey: "agent:main:test:group:room-1",
parentSessionKey: "agent:main:test:group",
modelParentSessionKey: "agent:main:test:model",
},
reply: {
to: "test:room:room-1",
originatingTo: "test:room:room-1",
replyToId: "root-1",
nativeChannelId: "native-room-1",
},
message: {
body: "[User One] hello",
rawBody: "hello",
bodyForAgent: "hello",
commandBody: "/status",
envelopeFrom: "User One",
inboundHistory: [{ sender: "Other", body: "previous", timestamp: 100 }],
},
access: {
commands: {
allowTextCommands: true,
useAccessGroups: true,
authorizers: [{ configured: true, allowed: true }],
},
mentions: {
canDetectMention: true,
wasMentioned: true,
},
},
media: [
{
path: "/tmp/image.png",
contentType: "image/png",
kind: "image",
},
{
url: "https://example.test/audio.mp3",
contentType: "audio/mpeg",
kind: "audio",
transcribed: true,
},
],
supplemental: {
quote: {
id: "quote-1",
body: "quoted",
sender: "Quoted User",
isQuote: true,
},
thread: {
starterBody: "thread starter",
historyBody: "thread history",
label: "thread label",
},
groupSystemPrompt: "group prompt",
},
});
expect(ctx).toEqual(
expect.objectContaining({
Body: "[User One] hello",
BodyForAgent: "hello",
RawBody: "hello",
CommandBody: "/status",
BodyForCommands: "/status",
From: "test:user:u1",
To: "test:room:room-1",
SessionKey: "agent:main:test:group:room-1",
AccountId: "acct",
ParentSessionKey: "agent:main:test:group",
ModelParentSessionKey: "agent:main:test:model",
MessageSid: "msg-1",
ReplyToId: "root-1",
ReplyToBody: "quoted",
ReplyToSender: "Quoted User",
MediaPath: "/tmp/image.png",
MediaUrl: "/tmp/image.png",
MediaType: "image/png",
MediaPaths: ["/tmp/image.png"],
MediaUrls: ["/tmp/image.png", "https://example.test/audio.mp3"],
MediaTypes: ["image/png", "audio/mpeg"],
MediaTranscribedIndexes: [1],
ChatType: "group",
ConversationLabel: "Room One",
GroupSubject: "Room One",
GroupSpace: "workspace",
GroupSystemPrompt: "group prompt",
SenderName: "User One",
SenderId: "u1",
SenderUsername: "userone",
SenderTag: "User#0001",
MemberRoleIds: ["admin"],
Timestamp: 123,
Provider: "test-provider",
Surface: "test-surface",
WasMentioned: true,
CommandAuthorized: true,
MessageThreadId: "thread-1",
NativeChannelId: "native-room-1",
OriginatingChannel: "test",
OriginatingTo: "test:room:room-1",
ThreadStarterBody: "thread starter",
ThreadHistoryBody: "thread history",
ThreadLabel: "thread label",
}),
);
});
});

View File

@@ -0,0 +1,123 @@
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js";
import type {
AccessFacts,
ConversationFacts,
InboundMediaFacts,
MessageFacts,
ReplyPlanFacts,
RouteFacts,
SenderFacts,
SupplementalContextFacts,
} from "./types.js";
export type BuildChannelTurnContextParams = {
channel: string;
accountId?: string;
provider?: string;
surface?: string;
messageId?: string;
messageIdFull?: string;
timestamp?: number;
from: string;
sender: SenderFacts;
conversation: ConversationFacts;
route: RouteFacts;
reply: ReplyPlanFacts;
message: MessageFacts;
access?: AccessFacts;
media?: InboundMediaFacts[];
supplemental?: SupplementalContextFacts;
extra?: MsgContext;
};
function compactStrings(values: Array<string | undefined>): string[] | undefined {
const compacted = values.filter((value): value is string => Boolean(value));
return compacted.length > 0 ? compacted : undefined;
}
function mediaTranscribedIndexes(media: InboundMediaFacts[]): number[] | undefined {
const indexes = media
.map((item, index) => (item.transcribed ? index : undefined))
.filter((index): index is number => index !== undefined);
return indexes.length > 0 ? indexes : undefined;
}
function commandAuthorized(access: AccessFacts | undefined): boolean | undefined {
const commands = access?.commands;
if (!commands) {
return undefined;
}
return commands.authorizers.some((entry) => entry.allowed);
}
export function buildChannelTurnContext(
params: BuildChannelTurnContextParams,
): FinalizedMsgContext {
const media = params.media ?? [];
const supplemental = params.supplemental;
const body = params.message.body ?? params.message.rawBody;
return finalizeInboundContext({
Body: body,
BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
InboundHistory: params.message.inboundHistory,
RawBody: params.message.rawBody,
CommandBody: params.message.commandBody ?? params.message.rawBody,
BodyForCommands: params.message.commandBody ?? params.message.rawBody,
From: params.from,
To: params.reply.to,
SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
AccountId: params.route.accountId ?? params.accountId,
ParentSessionKey: params.route.parentSessionKey,
ModelParentSessionKey: params.route.modelParentSessionKey,
MessageSid: params.messageId,
MessageSidFull: params.messageIdFull,
ReplyToId: params.reply.replyToId ?? supplemental?.quote?.id,
ReplyToIdFull: params.reply.replyToIdFull ?? supplemental?.quote?.fullId,
ReplyToBody: supplemental?.quote?.body,
ReplyToSender: supplemental?.quote?.sender,
ReplyToIsQuote: supplemental?.quote?.isQuote,
ForwardedFrom: supplemental?.forwarded?.from,
ForwardedFromType: supplemental?.forwarded?.fromType,
ForwardedFromId: supplemental?.forwarded?.fromId,
ForwardedDate: supplemental?.forwarded?.date,
ThreadStarterBody: supplemental?.thread?.starterBody,
ThreadHistoryBody: supplemental?.thread?.historyBody,
ThreadLabel: supplemental?.thread?.label,
MediaPath: media[0]?.path,
MediaUrl: media[0]?.url ?? media[0]?.path,
MediaType: media[0]?.contentType ?? media[0]?.kind,
MediaPaths: compactStrings(media.map((item) => item.path)),
MediaUrls: compactStrings(media.map((item) => item.url ?? item.path)),
MediaTypes: compactStrings(media.map((item) => item.contentType ?? item.kind)),
MediaTranscribedIndexes: mediaTranscribedIndexes(media),
ChatType: params.conversation.kind,
ConversationLabel: params.conversation.label,
GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
GroupSpace: params.conversation.spaceId,
GroupSystemPrompt: supplemental?.groupSystemPrompt,
UntrustedStructuredContext: Array.isArray(supplemental?.untrustedContext)
? supplemental.untrustedContext.map((payload, index) => ({
label: `context ${index + 1}`,
payload,
}))
: undefined,
SenderName: params.sender.name ?? params.sender.displayLabel,
SenderId: params.sender.id,
SenderUsername: params.sender.username,
SenderTag: params.sender.tag,
MemberRoleIds: params.sender.roles,
Timestamp: params.timestamp,
Provider: params.provider ?? params.channel,
Surface: params.surface ?? params.provider ?? params.channel,
WasMentioned: params.access?.mentions?.wasMentioned,
CommandAuthorized: commandAuthorized(params.access),
MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId,
NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId,
OriginatingChannel: params.channel,
OriginatingTo: params.reply.originatingTo,
ThreadParentId: params.reply.threadParentId ?? params.conversation.parentId,
...params.extra,
});
}

View File

@@ -0,0 +1,280 @@
import { describe, expect, it, vi } from "vitest";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { RecordInboundSession } from "../session.types.js";
import {
createNoopChannelTurnDeliveryAdapter,
dispatchAssembledChannelTurn,
runPreparedChannelTurn,
runChannelTurn,
} from "./kernel.js";
const cfg = {} as OpenClawConfig;
function createCtx(overrides: Partial<FinalizedMsgContext> = {}): FinalizedMsgContext {
return {
Body: "hello",
RawBody: "hello",
CommandBody: "hello",
From: "sender",
To: "target",
SessionKey: "agent:main:test:peer",
Provider: "test",
Surface: "test",
...overrides,
} as FinalizedMsgContext;
}
function createRecordInboundSession(events: string[] = []): RecordInboundSession {
return vi.fn(async () => {
events.push("record");
}) as unknown as RecordInboundSession;
}
function createDispatch(
events: string[] = [],
deliverPayload: { text: string } = { text: "reply" },
): DispatchReplyWithBufferedBlockDispatcher {
return vi.fn(async (params) => {
events.push("dispatch");
await params.dispatcherOptions.deliver(deliverPayload, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
}) as DispatchReplyWithBufferedBlockDispatcher;
}
describe("channel turn kernel", () => {
it("records inbound session before dispatching delivery", async () => {
const events: string[] = [];
const deliver = vi.fn(async () => {
events.push("deliver");
});
const recordInboundSession = createRecordInboundSession(events);
const dispatchReplyWithBufferedBlockDispatcher = createDispatch(events);
const result = await dispatchAssembledChannelTurn({
cfg,
channel: "test",
agentId: "main",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
delivery: { deliver },
record: {
onRecordError: vi.fn(),
},
});
expect(result.dispatched).toBe(true);
expect(result.dispatchResult?.counts.final).toBe(1);
expect(events).toEqual(["record", "dispatch", "deliver"]);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
}),
);
expect(deliver).toHaveBeenCalledWith({ text: "reply" }, { kind: "final" });
});
it("runs prepared dispatches after recording session metadata", async () => {
const events: string[] = [];
const recordInboundSession = createRecordInboundSession(events);
const runDispatch = vi.fn(async () => {
events.push("dispatch");
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
});
const result = await runPreparedChannelTurn({
channel: "test",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession,
runDispatch,
record: {
onRecordError: vi.fn(),
},
});
expect(events).toEqual(["record", "dispatch"]);
expect(result.dispatchResult?.queuedFinal).toBe(true);
});
it("cleans up pre-created dispatchers when session recording fails", async () => {
const events: string[] = [];
const recordError = new Error("session store failed");
const recordInboundSession = vi.fn(async () => {
events.push("record");
throw recordError;
}) as unknown as RecordInboundSession;
const runDispatch = vi.fn();
const onPreDispatchFailure = vi.fn(async () => {
events.push("cleanup");
});
await expect(
runPreparedChannelTurn({
channel: "test",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession,
onPreDispatchFailure,
runDispatch,
record: {
onRecordError: vi.fn(),
},
}),
).rejects.toThrow(recordError);
expect(events).toEqual(["record", "cleanup"]);
expect(runDispatch).not.toHaveBeenCalled();
expect(onPreDispatchFailure).toHaveBeenCalledWith(recordError);
});
it("drops when ingest returns null", async () => {
const result = await runChannelTurn({
channel: "test",
raw: {},
adapter: {
ingest: () => null,
resolveTurn: vi.fn(),
},
});
expect(result).toEqual({
admission: { kind: "drop", reason: "ingest-null" },
dispatched: false,
});
});
it("handles non-turn event classes without dispatch", async () => {
const resolveTurn = vi.fn();
const result = await runChannelTurn({
channel: "test",
raw: {},
adapter: {
ingest: () => ({ id: "evt-1", rawText: "" }),
classify: () => ({ kind: "reaction", canStartAgentTurn: false }),
resolveTurn,
},
});
expect(result.admission).toEqual({ kind: "handled", reason: "event:reaction" });
expect(result.dispatched).toBe(false);
expect(resolveTurn).not.toHaveBeenCalled();
});
it("stops on preflight admission drops", async () => {
const resolveTurn = vi.fn();
const result = await runChannelTurn({
channel: "test",
raw: {},
adapter: {
ingest: () => ({ id: "msg-1", rawText: "hello" }),
preflight: () => ({ kind: "drop", reason: "missing-mention", recordHistory: true }),
resolveTurn,
},
});
expect(result.admission).toEqual({
kind: "drop",
reason: "missing-mention",
recordHistory: true,
});
expect(result.dispatched).toBe(false);
expect(resolveTurn).not.toHaveBeenCalled();
});
it("runs observe-only preflights through resolve, record, dispatch, and finalize", async () => {
const events: string[] = [];
const onFinalize = vi.fn();
const result = await runChannelTurn({
channel: "test",
raw: {},
adapter: {
ingest: () => ({ id: "msg-1", rawText: "observe" }),
preflight: () => ({ kind: "observeOnly", reason: "broadcast-observer" }),
resolveTurn: () => ({
cfg,
channel: "test",
agentId: "observer",
routeSessionKey: "agent:observer:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx({ SessionKey: "agent:observer:test:peer" }),
recordInboundSession: createRecordInboundSession(events),
dispatchReplyWithBufferedBlockDispatcher: createDispatch(events),
delivery: createNoopChannelTurnDeliveryAdapter(),
record: {
onRecordError: vi.fn(),
},
}),
onFinalize,
},
});
expect(result.admission).toEqual({
kind: "observeOnly",
reason: "broadcast-observer",
});
expect(result.dispatched).toBe(true);
expect(events).toEqual(["record", "dispatch"]);
expect(onFinalize).toHaveBeenCalledWith(
expect.objectContaining({
admission: { kind: "observeOnly", reason: "broadcast-observer" },
dispatched: true,
routeSessionKey: "agent:observer:test:peer",
}),
);
});
it("finalizes failed dispatches before rethrowing", async () => {
const onFinalize = vi.fn();
const dispatchError = new Error("dispatch failed");
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => {
throw dispatchError;
}) as unknown as DispatchReplyWithBufferedBlockDispatcher;
await expect(
runChannelTurn({
channel: "test",
raw: {},
adapter: {
ingest: () => ({ id: "msg-1", rawText: "hello" }),
resolveTurn: () => ({
cfg,
channel: "test",
agentId: "main",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession: createRecordInboundSession(),
dispatchReplyWithBufferedBlockDispatcher,
delivery: createNoopChannelTurnDeliveryAdapter(),
record: {
onRecordError: vi.fn(),
},
}),
onFinalize,
},
}),
).rejects.toThrow(dispatchError);
expect(onFinalize).toHaveBeenCalledWith(
expect.objectContaining({
admission: { kind: "dispatch" },
dispatched: false,
routeSessionKey: "agent:main:test:peer",
}),
);
});
});

309
src/channels/turn/kernel.ts Normal file
View File

@@ -0,0 +1,309 @@
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
export { buildChannelTurnContext } from "./context.js";
export type { BuildChannelTurnContextParams } from "./context.js";
import type {
AssembledChannelTurn,
ChannelEventClass,
ChannelTurnAdmission,
ChannelTurnDeliveryAdapter,
ChannelTurnLogEvent,
ChannelTurnResult,
DispatchedChannelTurnResult,
PreparedChannelTurn,
PreflightFacts,
RunChannelTurnParams,
} from "./types.js";
export type {
AccessFacts,
AssembledChannelTurn,
ChannelDeliveryInfo,
ChannelDeliveryResult,
ChannelEventClass,
ChannelTurnAdapter,
ChannelTurnAdmission,
ChannelTurnDeliveryAdapter,
ChannelTurnDispatcherOptions,
ChannelTurnLogEvent,
ChannelTurnRecordOptions,
ChannelTurnResolved,
ChannelTurnResult,
DispatchedChannelTurnResult,
ConversationFacts,
InboundMediaFacts,
MessageFacts,
NormalizedTurnInput,
PreflightFacts,
PreparedChannelTurn,
ReplyPlanFacts,
RouteFacts,
RunChannelTurnParams,
SenderFacts,
SupplementalContextFacts,
} from "./types.js";
const DEFAULT_EVENT_CLASS: ChannelEventClass = {
kind: "message",
canStartAgentTurn: true,
};
function isAdmission(value: unknown): value is ChannelTurnAdmission {
if (!value || typeof value !== "object") {
return false;
}
const kind = (value as { kind?: unknown }).kind;
return kind === "dispatch" || kind === "observeOnly" || kind === "handled" || kind === "drop";
}
function normalizePreflight(
value: PreflightFacts | ChannelTurnAdmission | null | undefined,
): PreflightFacts {
if (!value) {
return {};
}
if (isAdmission(value)) {
return { admission: value };
}
return value;
}
function emit(params: {
log?: (event: ChannelTurnLogEvent) => void;
event: Omit<ChannelTurnLogEvent, "channel" | "accountId">;
channel: string;
accountId?: string;
}) {
params.log?.({
channel: params.channel,
accountId: params.accountId,
...params.event,
});
}
export function createNoopChannelTurnDeliveryAdapter(): ChannelTurnDeliveryAdapter {
return {
deliver: async () => ({
visibleReplySent: false,
}),
};
}
export async function dispatchAssembledChannelTurn(
params: AssembledChannelTurn,
): Promise<DispatchedChannelTurnResult> {
return await runPreparedChannelTurn({
channel: params.channel,
accountId: params.accountId,
routeSessionKey: params.routeSessionKey,
storePath: params.storePath,
ctxPayload: params.ctxPayload,
recordInboundSession: params.recordInboundSession,
record: params.record,
runDispatch: async () =>
await params.dispatchReplyWithBufferedBlockDispatcher({
ctx: params.ctxPayload,
cfg: params.cfg,
dispatcherOptions: {
...params.dispatcherOptions,
deliver: async (payload: ReplyPayload, info) => {
await params.delivery.deliver(payload, info);
},
onError: params.delivery.onError,
},
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
}),
});
}
export async function runPreparedChannelTurn<
TDispatchResult = DispatchedChannelTurnResult["dispatchResult"],
>(
params: PreparedChannelTurn<TDispatchResult>,
): Promise<DispatchedChannelTurnResult<TDispatchResult>> {
try {
await params.recordInboundSession({
storePath: params.storePath,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
ctx: params.ctxPayload,
groupResolution: params.record?.groupResolution,
createIfMissing: params.record?.createIfMissing,
updateLastRoute: params.record?.updateLastRoute,
onRecordError: params.record?.onRecordError ?? (() => undefined),
trackSessionMetaTask: params.record?.trackSessionMetaTask,
});
} catch (err) {
try {
await params.onPreDispatchFailure?.(err);
} catch {
// Preserve the original session-recording error.
}
throw err;
}
const dispatchResult = await params.runDispatch();
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: params.ctxPayload,
routeSessionKey: params.routeSessionKey,
dispatchResult,
};
}
export async function runChannelTurn<TRaw>(
params: RunChannelTurnParams<TRaw>,
): Promise<ChannelTurnResult> {
emit({
...params,
event: { stage: "ingest", event: "start" },
});
const input = await params.adapter.ingest(params.raw);
if (!input) {
const admission: ChannelTurnAdmission = { kind: "drop", reason: "ingest-null" };
emit({
...params,
event: {
stage: "ingest",
event: "drop",
admission: admission.kind,
reason: admission.reason,
},
});
return { admission, dispatched: false };
}
emit({
...params,
event: { stage: "ingest", event: "done", messageId: input.id },
});
const eventClass = (await params.adapter.classify?.(input)) ?? DEFAULT_EVENT_CLASS;
if (!eventClass.canStartAgentTurn) {
const admission: ChannelTurnAdmission = {
kind: "handled",
reason: `event:${eventClass.kind}`,
};
emit({
...params,
event: {
stage: "classify",
event: "handled",
messageId: input.id,
admission: admission.kind,
reason: admission.reason,
},
});
return { admission, dispatched: false };
}
const preflight = normalizePreflight(await params.adapter.preflight?.(input, eventClass));
const preflightAdmission = preflight.admission;
if (
preflightAdmission &&
preflightAdmission.kind !== "dispatch" &&
preflightAdmission.kind !== "observeOnly"
) {
emit({
...params,
event: {
stage: "preflight",
event: preflightAdmission.kind === "handled" ? "handled" : "drop",
messageId: input.id,
admission: preflightAdmission.kind,
reason: preflightAdmission.reason,
},
});
return { admission: preflightAdmission, dispatched: false };
}
const resolved = await params.adapter.resolveTurn(input, eventClass, preflight);
emit({
...params,
accountId: resolved.accountId ?? params.accountId,
event: {
stage: "assemble",
event: "done",
messageId: input.id,
sessionKey: resolved.routeSessionKey,
admission: resolved.admission?.kind ?? "dispatch",
},
});
const admission = resolved.admission ?? preflightAdmission ?? ({ kind: "dispatch" } as const);
let result: ChannelTurnResult;
try {
const dispatchResult = await dispatchAssembledChannelTurn(resolved);
result = {
...dispatchResult,
admission,
};
emit({
...params,
accountId: resolved.accountId ?? params.accountId,
event: {
stage: "dispatch",
event: "done",
messageId: input.id,
sessionKey: resolved.routeSessionKey,
admission: admission.kind,
},
});
} catch (err) {
const failedResult: ChannelTurnResult = {
admission,
dispatched: false,
ctxPayload: resolved.ctxPayload,
routeSessionKey: resolved.routeSessionKey,
};
try {
await params.adapter.onFinalize?.(failedResult);
} catch {
// Preserve the original dispatch error.
}
emit({
...params,
accountId: resolved.accountId ?? params.accountId,
event: {
stage: "dispatch",
event: "error",
messageId: input.id,
sessionKey: resolved.routeSessionKey,
admission: admission.kind,
error: err,
},
});
throw err;
}
try {
await params.adapter.onFinalize?.(result);
emit({
...params,
accountId: resolved.accountId ?? params.accountId,
event: {
stage: "finalize",
event: "done",
messageId: input.id,
sessionKey: resolved.routeSessionKey,
admission: admission.kind,
},
});
} catch (err) {
emit({
...params,
accountId: resolved.accountId ?? params.accountId,
event: {
stage: "finalize",
event: "error",
messageId: input.id,
sessionKey: resolved.routeSessionKey,
admission: admission.kind,
error: err,
},
});
throw err;
}
return result;
}

295
src/channels/turn/types.ts Normal file
View File

@@ -0,0 +1,295 @@
import type { GetReplyOptions } from "../../auto-reply/get-reply-options.types.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { DispatchFromConfigResult } from "../../auto-reply/reply/dispatch-from-config.types.js";
import type { GetReplyFromConfig } from "../../auto-reply/reply/get-reply.types.js";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { ReplyDispatcherWithTypingOptions } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js";
import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js";
import type { GroupKeyResolution } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { InboundLastRouteUpdate, RecordInboundSession } from "../session.types.js";
export type ChannelTurnAdmission =
| { kind: "dispatch"; reason?: string }
| { kind: "observeOnly"; reason: string }
| { kind: "handled"; reason: string }
| { kind: "drop"; reason: string; recordHistory?: boolean };
export type ChannelEventClass = {
kind: "message" | "command" | "interaction" | "reaction" | "lifecycle" | "unknown";
canStartAgentTurn: boolean;
requiresImmediateAck?: boolean;
};
export type NormalizedTurnInput = {
id: string;
timestamp?: number;
rawText: string;
textForAgent?: string;
textForCommands?: string;
raw?: unknown;
};
export type SenderFacts = {
id: string;
name?: string;
username?: string;
tag?: string;
roles?: string[];
isBot?: boolean;
isSelf?: boolean;
displayLabel?: string;
};
export type ConversationFacts = {
kind: "direct" | "group" | "channel";
id: string;
label?: string;
spaceId?: string;
parentId?: string;
threadId?: string;
nativeChannelId?: string;
routePeer: {
kind: "direct" | "group" | "channel";
id: string;
};
};
export type RouteFacts = {
agentId: string;
accountId?: string;
routeSessionKey: string;
dispatchSessionKey?: string;
persistedSessionKey?: string;
parentSessionKey?: string;
modelParentSessionKey?: string;
mainSessionKey?: string;
createIfMissing?: boolean;
};
export type ReplyPlanFacts = {
to: string;
originatingTo: string;
nativeChannelId?: string;
replyTarget?: string;
deliveryTarget?: string;
replyToId?: string;
replyToIdFull?: string;
messageThreadId?: string;
threadParentId?: string;
sourceReplyDeliveryMode?: "thread" | "reply" | "channel" | "direct" | "none";
};
export type AccessFacts = {
dm?: {
decision: "allow" | "pairing" | "deny";
reason?: string;
allowFrom: string[];
};
group?: {
policy: "open" | "allowlist" | "disabled";
routeAllowed: boolean;
senderAllowed: boolean;
allowFrom: string[];
requireMention: boolean;
};
commands?: {
useAccessGroups: boolean;
allowTextCommands: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
};
mentions?: {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
implicitMentionKinds?: Array<"reply_to_bot" | "bot_thread_participant" | "native">;
};
};
export type MessageFacts = {
body?: string;
rawBody: string;
bodyForAgent?: string;
commandBody?: string;
envelopeFrom: string;
senderLabel?: string;
preview?: string;
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>;
};
export type SupplementalContextFacts = {
quote?: {
id?: string;
fullId?: string;
body?: string;
sender?: string;
senderAllowed?: boolean;
isExternal?: boolean;
isQuote?: boolean;
};
forwarded?: {
from?: string;
fromType?: string;
fromId?: string;
date?: number;
};
thread?: {
id?: string;
starterBody?: string;
historyBody?: string;
label?: string;
parentSessionKey?: string;
modelParentSessionKey?: string;
senderAllowed?: boolean;
};
untrustedContext?: unknown[];
groupSystemPrompt?: string;
};
export type InboundMediaFacts = {
path?: string;
url?: string;
contentType?: string;
kind?: "image" | "video" | "audio" | "document" | "unknown";
transcribed?: boolean;
};
export type PreflightFacts = {
admission?: ChannelTurnAdmission;
message?: Partial<MessageFacts>;
media?: InboundMediaFacts[];
supplemental?: SupplementalContextFacts;
};
export type ChannelDeliveryInfo = {
kind: ReplyDispatchKind;
};
export type ChannelDeliveryResult = {
messageIds?: string[];
threadId?: string;
replyToId?: string;
visibleReplySent?: boolean;
};
export type ChannelTurnDeliveryAdapter = {
deliver: (
payload: ReplyPayload,
info: ChannelDeliveryInfo,
) => Promise<ChannelDeliveryResult | void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
export type ChannelTurnRecordOptions = {
groupResolution?: GroupKeyResolution | null;
createIfMissing?: boolean;
updateLastRoute?: InboundLastRouteUpdate;
onRecordError?: (err: unknown) => void;
trackSessionMetaTask?: (task: Promise<unknown>) => void;
};
export type ChannelTurnDispatcherOptions = Omit<
ReplyDispatcherWithTypingOptions,
"deliver" | "onError"
>;
export type AssembledChannelTurn = {
cfg: OpenClawConfig;
channel: string;
accountId?: string;
agentId: string;
routeSessionKey: string;
storePath: string;
ctxPayload: FinalizedMsgContext;
recordInboundSession: RecordInboundSession;
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
delivery: ChannelTurnDeliveryAdapter;
dispatcherOptions?: ChannelTurnDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
record?: ChannelTurnRecordOptions;
};
export type PreparedChannelTurn<TDispatchResult = DispatchFromConfigResult> = {
channel: string;
accountId?: string;
routeSessionKey: string;
storePath: string;
ctxPayload: FinalizedMsgContext;
recordInboundSession: RecordInboundSession;
record?: ChannelTurnRecordOptions;
onPreDispatchFailure?: (err: unknown) => void | Promise<void>;
runDispatch: () => Promise<TDispatchResult>;
};
export type ChannelTurnResolved = AssembledChannelTurn & {
admission?: Extract<ChannelTurnAdmission, { kind: "dispatch" | "observeOnly" }>;
};
export type ChannelTurnStage =
| "ingest"
| "classify"
| "preflight"
| "resolve"
| "authorize"
| "assemble"
| "record"
| "dispatch"
| "finalize";
export type ChannelTurnLogEvent = {
stage: ChannelTurnStage;
event: "start" | "done" | "drop" | "handled" | "error";
channel: string;
accountId?: string;
messageId?: string;
sessionKey?: string;
admission?: ChannelTurnAdmission["kind"];
reason?: string;
error?: unknown;
};
export type ChannelTurnResult = {
admission: ChannelTurnAdmission;
dispatched: boolean;
ctxPayload?: MsgContext;
routeSessionKey?: string;
dispatchResult?: DispatchFromConfigResult;
};
export type DispatchedChannelTurnResult<TDispatchResult = DispatchFromConfigResult> = {
admission: Extract<ChannelTurnAdmission, { kind: "dispatch" }>;
dispatched: true;
ctxPayload: MsgContext;
routeSessionKey: string;
dispatchResult: TDispatchResult;
};
export type ChannelTurnAdapter<TRaw> = {
ingest: (raw: TRaw) => Promise<NormalizedTurnInput | null> | NormalizedTurnInput | null;
classify?: (input: NormalizedTurnInput) => Promise<ChannelEventClass> | ChannelEventClass;
preflight?: (
input: NormalizedTurnInput,
eventClass: ChannelEventClass,
) =>
| Promise<PreflightFacts | ChannelTurnAdmission | null | undefined>
| PreflightFacts
| ChannelTurnAdmission
| null
| undefined;
resolveTurn: (
input: NormalizedTurnInput,
eventClass: ChannelEventClass,
preflight: PreflightFacts,
) => Promise<ChannelTurnResolved> | ChannelTurnResolved;
onFinalize?: (result: ChannelTurnResult) => Promise<void> | void;
};
export type RunChannelTurnParams<TRaw> = {
channel: string;
accountId?: string;
raw: TRaw;
adapter: ChannelTurnAdapter<TRaw>;
log?: (event: ChannelTurnLogEvent) => void;
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import type { DispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.types.js";
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
import type { RecordInboundSession } from "../channels/session.types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { recordInboundSessionAndDispatchReply } from "./inbound-reply-dispatch.js";
describe("recordInboundSessionAndDispatchReply", () => {
it("delegates record and dispatch through the channel turn kernel once", async () => {
const recordInboundSession = vi.fn(async () => undefined) as unknown as RecordInboundSession;
const deliver = vi.fn(async () => undefined);
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
await params.dispatcherOptions.deliver(
{
text: "hello",
mediaUrls: ["https://example.com/a.png"],
},
{ kind: "final" },
);
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
}) as DispatchReplyWithBufferedBlockDispatcher;
const ctxPayload = {
Body: "body",
RawBody: "body",
CommandBody: "body",
From: "sender",
To: "target",
SessionKey: "agent:main:test:peer",
Provider: "test",
Surface: "test",
} as FinalizedMsgContext;
await recordInboundSessionAndDispatchReply({
cfg: {} as OpenClawConfig,
channel: "test",
accountId: "default",
agentId: "main",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
deliver,
onRecordError: vi.fn(),
onDispatchError: vi.fn(),
});
expect(recordInboundSession).toHaveBeenCalledTimes(1);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:test:peer",
ctx: ctxPayload,
}),
);
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
expect(deliver).toHaveBeenCalledWith({
text: "hello",
mediaUrls: ["https://example.com/a.png"],
mediaUrl: undefined,
sensitiveMedia: undefined,
replyToId: undefined,
});
});
});

View File

@@ -7,6 +7,8 @@ import {
import type { DispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.types.js";
import type { ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.types.js";
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
import { dispatchAssembledChannelTurn, runPreparedChannelTurn } from "../channels/turn/kernel.js";
import type { PreparedChannelTurn } from "../channels/turn/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
@@ -19,6 +21,13 @@ type RecordInboundSessionFn = typeof import("../channels/session.js").recordInbo
type ReplyDispatchFromConfigOptions = Omit<GetReplyOptions, "onBlockReply">;
/** Run an already assembled channel turn through shared session-record + dispatch ordering. */
export async function runPreparedInboundReplyTurn<TDispatchResult>(
params: PreparedChannelTurn<TDispatchResult>,
) {
return await runPreparedChannelTurn(params);
}
/** Run `dispatchReplyFromConfig` with a dispatcher that always gets its settled callback. */
export async function dispatchReplyFromConfigWithSettledDispatcher(params: {
cfg: OpenClawConfig;
@@ -117,13 +126,6 @@ export async function recordInboundSessionAndDispatchReply(params: {
onDispatchError: (err: unknown, info: { kind: string }) => void;
replyOptions?: ReplyOptionsWithoutModelSelected;
}): Promise<void> {
await params.recordInboundSession({
storePath: params.storePath,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
ctx: params.ctxPayload,
onRecordError: params.onRecordError,
});
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: params.cfg,
agentId: params.agentId,
@@ -132,17 +134,27 @@ export async function recordInboundSessionAndDispatchReply(params: {
});
const deliver = createNormalizedOutboundDeliverer(params.deliver);
await params.dispatchReplyWithBufferedBlockDispatcher({
ctx: params.ctxPayload,
await dispatchAssembledChannelTurn({
cfg: params.cfg,
dispatcherOptions: {
...replyPipeline,
channel: params.channel,
accountId: params.accountId,
agentId: params.agentId,
routeSessionKey: params.routeSessionKey,
storePath: params.storePath,
ctxPayload: params.ctxPayload,
recordInboundSession: params.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher: params.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver,
onError: params.onDispatchError,
},
dispatcherOptions: replyPipeline,
replyOptions: {
...params.replyOptions,
onModelSelected,
},
record: {
onRecordError: params.onRecordError,
},
});
}

View File

@@ -14,6 +14,7 @@ export {
dispatchInboundMessage,
dispatchInboundMessageWithBufferedDispatcher,
dispatchInboundMessageWithDispatcher,
settleReplyDispatcher,
} from "../auto-reply/dispatch.js";
export {
normalizeGroupActivation,

View File

@@ -78,6 +78,142 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
createTaskFlowSessionMock,
) as unknown as PluginRuntime["tasks"]["managedFlows"]["fromToolContext"],
};
const dispatchAssembledChannelTurnMock = vi.fn(
async (params: Parameters<PluginRuntime["channel"]["turn"]["dispatchAssembled"]>[0]) => {
await params.recordInboundSession({
storePath: params.storePath,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
ctx: params.ctxPayload,
groupResolution: params.record?.groupResolution,
createIfMissing: params.record?.createIfMissing,
updateLastRoute: params.record?.updateLastRoute,
onRecordError: params.record?.onRecordError ?? (() => undefined),
trackSessionMetaTask: params.record?.trackSessionMetaTask,
});
const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({
ctx: params.ctxPayload,
cfg: params.cfg,
dispatcherOptions: {
...params.dispatcherOptions,
deliver: async (payload, info) => {
await params.delivery.deliver(payload, info);
},
onError: params.delivery.onError,
},
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: params.ctxPayload,
routeSessionKey: params.routeSessionKey,
dispatchResult,
};
},
) as unknown as PluginRuntime["channel"]["turn"]["dispatchAssembled"];
const runPreparedChannelTurnMock = vi.fn(
async (params: Parameters<PluginRuntime["channel"]["turn"]["runPrepared"]>[0]) => {
try {
await params.recordInboundSession({
storePath: params.storePath,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
ctx: params.ctxPayload,
groupResolution: params.record?.groupResolution,
createIfMissing: params.record?.createIfMissing,
updateLastRoute: params.record?.updateLastRoute,
onRecordError: params.record?.onRecordError ?? (() => undefined),
trackSessionMetaTask: params.record?.trackSessionMetaTask,
});
} catch (err) {
try {
await params.onPreDispatchFailure?.(err);
} catch {
// Preserve the original session-recording error.
}
throw err;
}
const dispatchResult = await params.runDispatch();
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: params.ctxPayload,
routeSessionKey: params.routeSessionKey,
dispatchResult,
};
},
) as unknown as PluginRuntime["channel"]["turn"]["runPrepared"];
const runChannelTurnMock = 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,
};
if (!eventClass.canStartAgentTurn) {
return {
admission: { kind: "handled" as const, reason: `event:${eventClass.kind}` },
dispatched: false,
};
}
const preflightValue = await params.adapter.preflight?.(input, eventClass);
const preflight =
preflightValue && "kind" in preflightValue
? { admission: preflightValue }
: (preflightValue ?? {});
if (
preflight.admission &&
preflight.admission.kind !== "dispatch" &&
preflight.admission.kind !== "observeOnly"
) {
return {
admission: preflight.admission,
dispatched: false,
};
}
const resolved = await params.adapter.resolveTurn(input, eventClass, preflight ?? {});
const dispatchResult = await dispatchAssembledChannelTurnMock(resolved);
const result = {
...dispatchResult,
admission: resolved.admission ?? preflight.admission ?? dispatchResult.admission,
};
await params.adapter.onFinalize?.(result);
return result;
},
) as unknown as PluginRuntime["channel"]["turn"]["run"];
const buildChannelTurnContextMock = vi.fn(
(params: Parameters<PluginRuntime["channel"]["turn"]["buildContext"]>[0]) =>
({
Body: params.message.body ?? params.message.rawBody,
BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
RawBody: params.message.rawBody,
CommandBody: params.message.commandBody ?? params.message.rawBody,
BodyForCommands: params.message.commandBody ?? params.message.rawBody,
From: params.from,
To: params.reply.to,
SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
AccountId: params.route.accountId ?? params.accountId,
ChatType: params.conversation.kind,
ConversationLabel: params.conversation.label,
SenderName: params.sender.name ?? params.sender.displayLabel,
SenderId: params.sender.id,
SenderUsername: params.sender.username,
Provider: params.provider ?? params.channel,
Surface: params.surface ?? params.provider ?? params.channel,
OriginatingChannel: params.channel,
OriginatingTo: params.reply.originatingTo,
CommandAuthorized: params.access?.commands
? params.access.commands.authorizers.some((entry) => entry.allowed)
: false,
...params.extra,
}) as ReturnType<PluginRuntime["channel"]["turn"]["buildContext"]>,
) as unknown as PluginRuntime["channel"]["turn"]["buildContext"];
const base: PluginRuntime = {
version: "1.0.0-test",
config: {
@@ -251,6 +387,14 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
settleReplyDispatcher: vi.fn(async ({ dispatcher, onSettled }) => {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}) as unknown as PluginRuntime["channel"]["reply"]["settleReplyDispatcher"],
withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => {
try {
return await run();
@@ -422,6 +566,12 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
outbound: {
loadAdapter: vi.fn() as unknown as PluginRuntime["channel"]["outbound"]["loadAdapter"],
},
turn: {
run: runChannelTurnMock,
buildContext: buildChannelTurnContextMock,
runPrepared: runPreparedChannelTurnMock,
dispatchAssembled: dispatchAssembledChannelTurnMock,
},
threadBindings: {
setIdleTimeoutBySessionKey:
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setIdleTimeoutBySessionKey"],

View File

@@ -14,7 +14,7 @@ import {
shouldComputeCommandAuthorized,
} from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import { settleReplyDispatcher, withReplyDispatcher } from "../../auto-reply/dispatch.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
@@ -50,6 +50,12 @@ import {
} from "../../channels/plugins/conversation-bindings.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import { recordInboundSession } from "../../channels/session.js";
import {
buildChannelTurnContext,
dispatchAssembledChannelTurn,
runChannelTurn,
runPreparedChannelTurn,
} from "../../channels/turn/kernel.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
@@ -95,6 +101,7 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
resolveHumanDelayConfig,
dispatchReplyFromConfig,
withReplyDispatcher,
settleReplyDispatcher,
finalizeInboundContext,
formatAgentEnvelope,
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
@@ -164,6 +171,12 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
outbound: {
loadAdapter: loadChannelOutboundAdapter,
},
turn: {
run: runChannelTurn,
buildContext: buildChannelTurnContext,
runPrepared: runPreparedChannelTurn,
dispatchAssembled: dispatchAssembledChannelTurn,
},
threadBindings: {
setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) =>
setChannelConversationBindingIdleTimeoutBySessionKey({

View File

@@ -90,6 +90,7 @@ export type PluginRuntimeChannel = {
resolveHumanDelayConfig: typeof import("../../agents/identity.js").resolveHumanDelayConfig;
dispatchReplyFromConfig: import("../../auto-reply/reply/dispatch-from-config.types.js").DispatchReplyFromConfig;
withReplyDispatcher: typeof import("../../auto-reply/dispatch-dispatcher.js").withReplyDispatcher;
settleReplyDispatcher: typeof import("../../auto-reply/dispatch-dispatcher.js").settleReplyDispatcher;
finalizeInboundContext: typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
formatAgentEnvelope: typeof import("../../auto-reply/envelope.js").formatAgentEnvelope;
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
@@ -150,6 +151,12 @@ export type PluginRuntimeChannel = {
outbound: {
loadAdapter: import("../../channels/plugins/outbound/load.types.js").LoadChannelOutboundAdapter;
};
turn: {
run: typeof import("../../channels/turn/kernel.js").runChannelTurn;
buildContext: typeof import("../../channels/turn/kernel.js").buildChannelTurnContext;
runPrepared: typeof import("../../channels/turn/kernel.js").runPreparedChannelTurn;
dispatchAssembled: typeof import("../../channels/turn/kernel.js").dispatchAssembledChannelTurn;
};
threadBindings: {
setIdleTimeoutBySessionKey: (params: {
channelId: string;