mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:00:44 +00:00
refactor(channels): add shared turn kernel
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}) => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"] } : {}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,5 +7,6 @@ export {
|
||||
getReplyFromConfig,
|
||||
isSilentReplyText,
|
||||
resolveTextChunkLimit,
|
||||
settleReplyDispatcher,
|
||||
SILENT_REPLY_TOKEN,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`));
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,4 +22,5 @@ export type RecordInboundSession = (params: {
|
||||
createIfMissing?: boolean;
|
||||
updateLastRoute?: InboundLastRouteUpdate;
|
||||
onRecordError: (err: unknown) => void;
|
||||
trackSessionMetaTask?: (task: Promise<unknown>) => void;
|
||||
}) => Promise<void>;
|
||||
|
||||
142
src/channels/turn/context.test.ts
Normal file
142
src/channels/turn/context.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
123
src/channels/turn/context.ts
Normal file
123
src/channels/turn/context.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
280
src/channels/turn/kernel.test.ts
Normal file
280
src/channels/turn/kernel.test.ts
Normal 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
309
src/channels/turn/kernel.ts
Normal 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
295
src/channels/turn/types.ts
Normal 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;
|
||||
};
|
||||
67
src/plugin-sdk/inbound-reply-dispatch.test.ts
Normal file
67
src/plugin-sdk/inbound-reply-dispatch.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
dispatchInboundMessage,
|
||||
dispatchInboundMessageWithBufferedDispatcher,
|
||||
dispatchInboundMessageWithDispatcher,
|
||||
settleReplyDispatcher,
|
||||
} from "../auto-reply/dispatch.js";
|
||||
export {
|
||||
normalizeGroupActivation,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user