fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread (#90943)

* fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread

Two core bugs caused composed replies to be silently dropped (no delivery,
no error) when a second message arrived in the same thread mid-run:

1. dispatch-from-config: ensureDispatchReplyOperation only kept the
   dispatch-owned operation authoritative while it had no result. Once
   runReplyAgent completed the operation to drain queued follow-ups, a
   second same-thread inbound could claim the session and the first final
   reply would try to re-acquire the lane instead of finishing delivery,
   deadlocking behind the queued work. Keep the dispatch-owned operation
   authoritative through final delivery.

2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the
   channel target, not the routed thread, so a send in one thread could
   suppress a later reply in a different thread. Thread the routed thread
   id through buildReplyPayloads + follow-up delivery and only fall back to
   channel-only matching for providers without a thread-aware suppression
   matcher when neither side carries thread evidence.

Adds regression tests; existing Telegram topic-suppression behavior is
preserved by gating the thread guard to providers lacking a plugin matcher.

* fix(reply): preserve threaded message delivery evidence

* fix(reply): dedupe final payloads by delivery route

* fix(slack): preserve native send thread evidence

* fix(reply): preserve explicit reply thread evidence

* fix(reply): align explicit reply route dedupe

* fix(reply): preserve delivery lane through final dispatch

* fix(mattermost): preserve threaded tool send routes

* chore(plugin-sdk): refresh API baseline

* fix(reply): align final delivery route dedupe

* fix(reply): gate followups on final delivery

* fix(reply): keep send receipts private

* fix(reply): infer implicit message provider

* fix(reply): align routed threading policy

* fix(reply): preserve queued delivery context

* fix(reply): hydrate queued system event routes

* fix(reply): hydrate queued execution routes

* fix(reply): scope final delivery barriers

* fix(slack): preserve DM target aliases

* fix(reply): mirror resolved source thread routes

* fix(mattermost): retain delayed delivery barrier

* fix(codex): separate message routing from tool policy

* fix(reply): consume normalized Slack DM targets once

* fix(slack): remove stale target alias

* style(reply): satisfy changed lint gates

* fix(mattermost): preserve explicit reply targets

* test: align Slack reply branch checks

* fix(reply): persist overflow summaries to admitted session

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sandieman2
2026-06-14 09:11:05 -07:00
committed by GitHub
parent e1744184b8
commit c67dc59b02
123 changed files with 8126 additions and 622 deletions

View File

@@ -1,2 +1,2 @@
40b3c841849fbc29938a3bbb990e28a5db30142941c8ef0c081a94cee4c78331 plugin-sdk-api-baseline.json
40ee8e1bbf112e768d4944776443f90b2441b02e3e950726e4112015cd106108 plugin-sdk-api-baseline.jsonl
b121079a0912b3051a9fc319a675ef920da9db23364ca0c0ccd3c9f0a05a3a49 plugin-sdk-api-baseline.json
61a0108da670e0f44ba4b861c002eb6eaa5cf63e392d4e7e7de42044cbe7d115 plugin-sdk-api-baseline.jsonl

View File

@@ -15,6 +15,7 @@ import {
includeForcedCodexDynamicToolAllow,
resetOpenClawCodingToolsFactoryForTests,
resolveOpenClawCodingToolsSessionKeys,
resolveCodexMessageToolProvider,
setOpenClawCodingToolsFactoryForTests,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
@@ -132,6 +133,15 @@ describe("Codex app-server dynamic tool build", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("uses the message tool channel before a differing ingress provider", () => {
expect(
resolveCodexMessageToolProvider({
messageChannel: "discord",
messageProvider: "discord-voice",
}),
).toBe("discord");
});
it("filters Codex-native dynamic tools from app-server tool exposure", () => {
const tools = [
"read",
@@ -549,6 +559,28 @@ describe("Codex app-server dynamic tool build", () => {
);
});
it("passes native and routable channel targets into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.currentChannelId = "D123";
params.currentMessagingTarget = "user:U123";
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [];
});
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
});
});
it("passes runtime config into Codex exec dynamic tool construction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -96,6 +96,13 @@ export function resolveOpenClawCodingToolsSessionKeys(
};
}
/** Returns the canonical channel used for Codex message routing and receipts. */
export function resolveCodexMessageToolProvider(
params: Pick<EmbeddedRunAttemptParams, "messageChannel" | "messageProvider">,
): string | undefined {
return params.messageChannel ?? params.messageProvider;
}
/** Resolves the channel id that hook events should target for this Codex app-server turn. */
export function resolveCodexAppServerHookChannelId(
params: EmbeddedRunAttemptParams,
@@ -209,7 +216,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
elevated: params.bashElevated,
},
sandbox: input.sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
messageProvider: resolveCodexMessageToolProvider(params),
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
@@ -258,6 +266,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
),
suppressManagedWebSearch: false,
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,

View File

@@ -18,6 +18,7 @@ import {
import {
createEmptyPluginRegistry,
createMockPluginRegistry,
createTestRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -798,6 +799,163 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("records the current provider and transport thread for implicit message sends", async () => {
const hasRepliedRef = { value: false };
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
plugin: {
id: "slack",
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
threading: {
resolveAutoThreadId: ({
to,
toolContext,
}: {
to: string;
toolContext?: {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}) => {
if (
to !== toolContext?.currentMessagingTarget &&
to !== toolContext?.currentChannelId
) {
return undefined;
}
if (
(toolContext?.replyToMode === "first" ||
toolContext?.replyToMode === "batched") &&
!toolContext.hasRepliedRef?.value
) {
return toolContext.currentThreadTs;
}
return undefined;
},
},
},
source: "test",
},
]),
);
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "message",
execute: vi.fn(async () => {
hasRepliedRef.value = true;
return textToolResult("Sent.");
}),
}),
],
signal: new AbortController().signal,
hookContext: {
currentChannelProvider: "slack",
currentChannelId: "D1",
currentMessagingTarget: "user:u1",
currentThreadId: "171.222",
replyToMode: "first",
hasRepliedRef,
},
});
await handleMessageToolCall(bridge, {
action: "send",
to: "user:U1",
text: "hello from Codex",
});
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
{
tool: "message",
provider: "slack",
to: "user:u1",
threadId: "171.222",
threadImplicit: true,
text: "hello from Codex",
},
]);
});
it("records the provider-confirmed route for successful message sends", async () => {
const registry = createTestRegistry([
{
pluginId: "mattermost",
plugin: {
id: "mattermost",
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
actions: {
extractToolSend: ({ args }: { args: Record<string, unknown> }) =>
args.action === "send" && typeof args.to === "string"
? { to: args.to, threadImplicit: true }
: null,
extractToolSendResult: ({ result }: { result: unknown }) => {
const details = requireRecord(
requireRecord(result, "message result").details,
"message details",
);
const toolSend = requireRecord(details.toolSend, "tool send details");
return {
to: String(toolSend.to),
threadId: String(toolSend.threadId),
};
},
},
},
source: "test",
},
]);
const middleware = vi.fn(async (event: { result: AgentToolResult<unknown> }) => {
const details = requireRecord(event.result.details, "middleware details");
const toolSend = requireRecord(details.toolSend, "middleware tool send");
toolSend.to = "channel:corrupted";
toolSend.threadId = "corrupted-root";
return undefined;
});
registry.agentToolResultMiddlewares.push({
pluginId: "route-details-stripper",
pluginName: "Route details stripper",
rawHandler: middleware,
handler: middleware,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
toolSend: {
to: "channel:resolved-id",
threadId: "root-post-id",
},
}),
);
await handleMessageToolCall(bridge, {
action: "send",
provider: "mattermost",
to: "town-square",
text: "hello from Codex",
});
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
{
tool: "message",
provider: "mattermost",
to: "channel:resolved-id",
threadId: "root-post-id",
threadImplicit: undefined,
threadSuppressed: undefined,
text: "hello from Codex",
},
]);
});
it("records message tool media attachment aliases as delivery evidence", async () => {
const toolResult = {
content: [{ type: "text", text: "Sent." }],

View File

@@ -6,6 +6,8 @@ import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
createAgentToolResultMiddlewareRunner,
createCodexAppServerToolResultExtensionRunner,
extractMessagingToolSend,
extractMessagingToolSendResult,
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
HEARTBEAT_RESPONSE_TOOL_NAME,
@@ -51,6 +53,12 @@ type CodexDynamicToolHookContext = {
sessionKey?: string;
runId?: string;
channelId?: string;
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadId?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
@@ -67,6 +75,22 @@ type CodexDynamicToolSchemaQuarantine = {
violations: readonly string[];
};
function applyCurrentMessageProvider(
toolName: string,
args: Record<string, unknown>,
currentProvider: string | undefined,
): Record<string, unknown> {
const hasProvider =
typeof args.provider === "string" && args.provider.trim().length > 0
? true
: typeof args.channel === "string" && args.channel.trim().length > 0;
const provider = currentProvider?.trim();
if (toolName !== "message" || hasProvider || !provider) {
return args;
}
return { ...args, provider };
}
/** Runtime bridge returned to Codex app-server attempt code. */
export type CodexDynamicToolBridge = {
availableSpecs: CodexDynamicToolSpec[];
@@ -213,9 +237,30 @@ export function createCodexDynamicToolBridge(params: {
// Prepare before marking side-effect evidence; argument preparation can
// fail without the target tool actually starting.
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
const telemetryArgs = isRecord(preparedArgs) ? preparedArgs : args;
const messagingTelemetryArgs = applyCurrentMessageProvider(
toolName,
telemetryArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, telemetryArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, {
config: params.hookContext?.config,
currentChannelId: params.hookContext?.currentChannelId,
currentMessagingTarget: params.hookContext?.currentMessagingTarget,
currentThreadId: params.hookContext?.currentThreadId,
replyToMode: params.hookContext?.replyToMode,
hasRepliedRef: params.hookContext?.hasRepliedRef,
})
: undefined;
didStartExecution = true;
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
const rawIsError = isCodexToolResultError(rawResult);
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, rawResult)
: messagingTarget;
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
threadId: call.threadId,
turnId: call.turnId,
@@ -237,11 +282,12 @@ export function createCodexDynamicToolBridge(params: {
notifyAgentToolResult(options?.onAgentToolResult, toolName, result, resultIsError);
collectToolTelemetry({
toolName,
args,
args: telemetryArgs,
result,
mediaTrustResult: rawResult,
telemetry,
isError: resultIsError,
messagingTarget: confirmedMessagingTarget,
});
void runAgentHarnessAfterToolCallHook({
toolName,
@@ -631,6 +677,7 @@ function collectToolTelemetry(params: {
mediaTrustResult?: AgentToolResult<unknown>;
telemetry: CodexDynamicToolBridge["telemetry"];
isError: boolean;
messagingTarget?: MessagingToolSend;
}): void {
if (params.isError) {
return;
@@ -683,11 +730,13 @@ function collectToolTelemetry(params: {
const mediaUrls = collectMediaUrls(params.args);
params.telemetry.messagingToolSentMediaUrls.push(...mediaUrls);
params.telemetry.messagingToolSentTargets.push({
tool: params.toolName,
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
accountId: readFirstString(params.args, ["accountId", "account_id"]),
to: readFirstString(params.args, ["to", "target", "recipient"]),
threadId: readFirstString(params.args, ["threadId", "thread_id", "messageThreadId"]),
...(params.messagingTarget ?? {
tool: params.toolName,
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
accountId: readFirstString(params.args, ["accountId", "account_id"]),
to: readFirstString(params.args, ["to", "target", "recipient"]),
threadId: readFirstString(params.args, ["threadId", "thread_id", "messageThreadId"]),
}),
...(text ? { text } : {}),
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
});

View File

@@ -149,6 +149,7 @@ import {
includeForcedCodexDynamicToolAllow,
isCodexNativeExecutionBlockedByNodeExecHost,
resolveCodexAppServerHookChannelId,
resolveCodexMessageToolProvider,
resolveOpenClawCodingToolsSessionKeys,
resetOpenClawCodingToolsFactoryForTests,
setOpenClawCodingToolsFactoryForTests,
@@ -724,6 +725,12 @@ export async function runCodexAppServerAttempt(
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
},
});
const hadSessionFile = await pathExists(activeSessionFile);

View File

@@ -478,7 +478,8 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolOptions).toHaveProperty("sessionId", "session-1");
expect(toolOptions).toHaveProperty("modelProvider", "openai");
expect(toolOptions).toHaveProperty("modelId", "gpt-5.5");
expect(toolOptions).toHaveProperty("messageProvider", "discord-voice");
expect(toolOptions).toHaveProperty("messageProvider", "discord");
expect(toolOptions).toHaveProperty("toolPolicyMessageProvider", "discord-voice");
expect(toolOptions).toHaveProperty("currentChannelId", "voice-room");
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
});

View File

@@ -31,6 +31,7 @@ import {
shouldAutoApproveCodexAppServerApprovals,
type CodexAppServerRuntimeOptions,
} from "./config.js";
import { resolveCodexMessageToolProvider } from "./dynamic-tool-build.js";
import {
emitDynamicToolErrorDiagnostic,
emitDynamicToolStartedDiagnostic,
@@ -596,6 +597,7 @@ async function createCodexSideToolBridge(input: {
const runtimeModel =
input.params.runtimeModel ??
({ id: input.params.model, provider: input.params.provider } as never);
const messageToolProvider = resolveCodexMessageToolProvider(input.params);
let tools: AnyAgentTool[] = [];
if (supportsModelTools(runtimeModel)) {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
@@ -637,7 +639,10 @@ async function createCodexSideToolBridge(input: {
workspaceDir: input.cwd,
}),
...(input.params.messageProvider || input.params.messageChannel
? { messageProvider: input.params.messageProvider ?? input.params.messageChannel }
? {
messageProvider: messageToolProvider,
toolPolicyMessageProvider: input.params.messageProvider ?? input.params.messageChannel,
}
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: buildAgentHookContextChannelFields({
@@ -673,6 +678,7 @@ async function createCodexSideToolBridge(input: {
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
});

View File

@@ -244,6 +244,7 @@ describe("createCopilotToolBridge", () => {
groupChannel: "#general",
groupSpace: "team-1",
currentChannelId: "C123",
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
@@ -277,6 +278,7 @@ describe("createCopilotToolBridge", () => {
groupChannel: "#general",
groupSpace: "team-1",
currentChannelId: "C123",
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",

View File

@@ -341,6 +341,7 @@ function buildOpenClawCodingToolsOptions(
workspaceDir,
}),
currentChannelId: a.currentChannelId,
currentMessagingTarget: a.currentMessagingTarget,
currentThreadTs: a.currentThreadTs,
currentMessageId: a.currentMessageId,
replyToMode: a.replyToMode,

View File

@@ -178,6 +178,270 @@ describe("mattermostPlugin", () => {
});
describe("threading", () => {
it("builds tool context from the effective Mattermost thread root", () => {
const buildToolContext = mattermostPlugin.threading?.buildToolContext;
if (!buildToolContext) {
throw new Error("mattermost threading.buildToolContext missing");
}
const hasRepliedRef = { value: false };
expect(
buildToolContext({
cfg: createMattermostTestConfig(),
accountId: "default",
context: {
To: "channel:C1",
ChatType: "channel",
CurrentMessageId: "child-1",
MessageThreadId: "root-1",
},
hasRepliedRef,
}),
).toEqual({
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "child-1",
replyToMode: "all",
hasRepliedRef,
sameChannelThreadRequired: true,
});
});
it.each(["first", "batched"] as const)(
"preserves %s mode when the current post starts the thread",
(replyToMode) => {
const buildToolContext = mattermostPlugin.threading?.buildToolContext;
if (!buildToolContext) {
throw new Error("mattermost threading.buildToolContext missing");
}
const context = buildToolContext({
cfg: {
channels: {
mattermost: {
replyToMode,
},
},
},
accountId: "default",
context: {
To: "channel:C1",
ChatType: "channel",
CurrentMessageId: "post-1",
MessageThreadId: "post-1",
},
});
expect(context?.replyToMode).toBe(replyToMode);
},
);
it("exposes the effective reply root as the transport thread", () => {
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
throw new Error("mattermost threading.resolveReplyTransport missing");
}
expect(
resolveReplyTransport({
cfg: {},
replyToId: "post-parent",
threadId: "other-thread",
}),
).toEqual({
replyToId: "post-parent",
threadId: "post-parent",
});
expect(
resolveReplyTransport({
cfg: {},
threadId: 42,
}),
).toEqual({
replyToId: "42",
threadId: "42",
});
});
it("matches final delivery routing for existing threads and direct messages", () => {
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
throw new Error("mattermost threading.resolveReplyTransport missing");
}
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
threadId: "root-post",
replyDelivery: {
chatType: "channel",
replyToMode: "all",
},
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "other-root",
replyToIsExplicit: true,
threadId: "ambient-root",
replyDelivery: {
chatType: "channel",
replyToMode: "all",
},
}),
).toEqual({
replyToId: "other-root",
threadId: "other-root",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "dm-post",
replyDelivery: {
chatType: "direct",
replyToMode: "all",
},
}),
).toEqual({
replyToId: null,
threadId: null,
});
});
it("extracts explicit and implicit send thread evidence", () => {
const extractToolSend = mattermostPlugin.actions?.extractToolSend;
if (!extractToolSend) {
throw new Error("mattermost actions.extractToolSend missing");
}
expect(
extractToolSend({
args: { action: "send", to: "channel:C1", replyTo: "root-1" },
}),
).toMatchObject({
to: "channel:C1",
threadId: "root-1",
});
expect(
extractToolSend({
args: { action: "send", to: "channel:C1" },
}),
).toMatchObject({
to: "channel:C1",
threadImplicit: true,
});
const extractToolSendResult = mattermostPlugin.actions?.extractToolSendResult;
if (!extractToolSendResult) {
throw new Error("mattermost actions.extractToolSendResult missing");
}
expect(
extractToolSendResult({
send: { to: "channel:C1" },
result: {
details: {
toolSend: {
to: "channel:C1",
threadId: "root-1",
},
},
},
}),
).toEqual({
to: "channel:C1",
threadId: "root-1",
});
expect(
extractToolSendResult({
send: { to: "user:U1" },
result: {
details: {
toolSend: {
to: "channel:DM1",
},
},
},
}),
).toEqual({
to: "user:U1",
});
});
it("resolves the active Mattermost root for same-channel sends", () => {
const resolveAutoThreadId = mattermostPlugin.threading?.resolveAutoThreadId;
if (!resolveAutoThreadId) {
throw new Error("mattermost threading.resolveAutoThreadId missing");
}
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
replyToId: "child-1",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "child-1",
replyToMode: "off",
},
}),
).toBe("root-1");
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C2",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
replyToMode: "all",
},
}),
).toBeUndefined();
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
replyToId: "other-root",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "child-1",
replyToMode: "all",
},
}),
).toBe("other-root");
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "root-1",
replyToMode: "first",
hasRepliedRef: { value: true },
},
}),
).toBeUndefined();
expect(
resolveAutoThreadId({
cfg: {},
to: "channel:C1",
toolContext: {
currentChannelId: "channel:C1",
currentThreadTs: "root-1",
currentMessageId: "root-1",
replyToMode: "batched",
},
}),
).toBeUndefined();
});
it("uses replyToMode for channel messages and keeps direct messages off", () => {
const resolveReplyToMode = requireMattermostReplyToModeResolver();
@@ -450,6 +714,27 @@ describe("mattermostPlugin", () => {
expect(options.replyToId).toBe("post-root");
});
it("keeps explicit reply precedence when threadId is also provided", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
threadId: "post-root",
replyTo: "child-post",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("child-post");
});
it("routes filePath send actions through Mattermost media upload options", async () => {
const cfg = createMattermostTestConfig();
const mediaReadFile = vi.fn(async () => Buffer.from("report"));

View File

@@ -3,6 +3,9 @@ import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
ChannelThreadingContext,
ChannelThreadingToolContext,
ChannelToolSend,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
@@ -13,6 +16,7 @@ import {
createAttachedChannelResultAdapter,
type ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import {
@@ -184,6 +188,135 @@ function hasConfiguredMattermostDirectoryAccount({
);
}
function extractMattermostToolSend(args: Record<string, unknown>): ChannelToolSend | null {
if (normalizeOptionalString(args.action) !== "send") {
return null;
}
const to = normalizeOptionalString(args.to) ?? normalizeOptionalString(args.target);
if (!to) {
return null;
}
const threadId =
normalizeOptionalString(args.threadId) ??
normalizeOptionalString(args.replyToId) ??
normalizeOptionalString(args.replyTo);
const threadSuppressed = args.topLevel === true || args.threadId === null;
return {
to,
accountId: normalizeOptionalString(args.accountId),
...(threadId ? { threadId } : {}),
...(!threadId && !threadSuppressed ? { threadImplicit: true } : {}),
...(threadSuppressed ? { threadSuppressed: true } : {}),
};
}
function extractMattermostToolSendResult(
result: unknown,
send: ChannelToolSend,
): ChannelToolSend | null {
if (!result || typeof result !== "object") {
return null;
}
const details = (result as { details?: unknown }).details;
if (!details || typeof details !== "object") {
return null;
}
const toolSend = (details as { toolSend?: unknown }).toolSend;
if (!toolSend || typeof toolSend !== "object") {
return null;
}
const record = toolSend as Record<string, unknown>;
const to = normalizeOptionalString(record.to);
if (!to) {
return null;
}
const threadId = normalizeOptionalString(record.threadId);
const originalTarget = normalizeOptionalString(send.to);
const preserveOriginalTarget =
originalTarget?.startsWith("user:") === true || originalTarget?.startsWith("@") === true;
return {
to: preserveOriginalTarget ? originalTarget : to,
...(threadId ? { threadId } : {}),
};
}
function resolveMattermostAutoThreadId(params: {
to: string;
replyToId?: string | null;
toolContext?: {
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}): string | undefined {
const replyToId = normalizeOptionalString(params.replyToId);
const context = params.toolContext;
const currentThreadId = normalizeOptionalString(context?.currentThreadTs);
const currentMessageId =
typeof context?.currentMessageId === "number"
? String(context.currentMessageId)
: normalizeOptionalString(context?.currentMessageId);
const currentTarget = context?.currentChannelId
? normalizeMattermostMessagingTarget(context.currentChannelId)
: undefined;
if (currentThreadId && currentTarget === normalizeMattermostMessagingTarget(params.to)) {
if (replyToId === currentMessageId) {
return currentThreadId;
}
if (!replyToId) {
const replyToMode = context?.replyToMode;
const canInheritThread =
replyToMode === "all" ||
(replyToMode === "first" && context?.hasRepliedRef?.value !== true);
return canInheritThread ? currentThreadId : undefined;
}
}
return replyToId;
}
function normalizeMattermostThreadId(value: string | number | undefined): string | undefined {
return typeof value === "number" ? String(value) : normalizeOptionalString(value);
}
function buildMattermostThreadingToolContext(params: {
cfg: OpenClawConfig;
accountId?: string | null;
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
});
const chatType =
params.context.ChatType === "direct" ||
params.context.ChatType === "group" ||
params.context.ChatType === "channel"
? params.context.ChatType
: "channel";
const configuredReplyToMode = resolveMattermostReplyToMode(account, chatType);
const currentThreadTs =
normalizeMattermostThreadId(params.context.MessageThreadId) ??
normalizeMattermostThreadId(params.context.TransportThreadId) ??
normalizeOptionalString(params.context.ReplyToId);
const currentMessageId = normalizeMattermostThreadId(params.context.CurrentMessageId);
const hasExistingThread =
Boolean(currentThreadTs) && (!currentMessageId || currentThreadTs !== currentMessageId);
const currentChannelId = params.context.To
? normalizeMattermostMessagingTarget(params.context.To)
: undefined;
return {
currentChannelId,
currentThreadTs,
currentMessageId: params.context.CurrentMessageId,
replyToMode: hasExistingThread ? "all" : configuredReplyToMode,
hasRepliedRef: params.hasRepliedRef,
sameChannelThreadRequired: Boolean(currentThreadTs),
};
}
async function listMattermostDirectoryGroups(params: MattermostDirectoryListParams) {
if (!hasConfiguredMattermostDirectoryAccount(params)) {
return [];
@@ -200,6 +333,8 @@ async function listMattermostDirectoryPeers(params: MattermostDirectoryListParam
const mattermostMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: describeMattermostMessageTool,
extractToolSend: ({ args }) => extractMattermostToolSend(args),
extractToolSendResult: ({ result, send }) => extractMattermostToolSendResult(result, send),
supportsAction: ({ action }) => {
return action === "send" || action === "react";
},
@@ -288,7 +423,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
// Match the shared runner semantics: trim empty reply IDs away before
// falling back from replyToId to replyTo on direct plugin calls.
const replyToId =
normalizeOptionalString(params.replyToId) ?? normalizeOptionalString(params.replyTo);
normalizeOptionalString(params.replyToId) ??
normalizeOptionalString(params.replyTo) ??
normalizeOptionalString(params.threadId);
const resolvedAccountId = accountId || undefined;
const attachmentMedia = collectMattermostAttachmentMedia(params);
@@ -333,7 +470,12 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
}),
},
],
details: {},
details: {
toolSend: {
to: `channel:${result.channelId}`,
...(replyToId ? { threadId: replyToId } : {}),
},
},
};
},
};
@@ -737,6 +879,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
},
},
threading: {
buildToolContext: (params) => buildMattermostThreadingToolContext(params),
scopedAccountReplyToMode: {
resolveAccount: (cfg, accountId) =>
resolveMattermostAccount({
@@ -751,10 +894,23 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
: "channel",
),
},
resolveReplyTransport: ({ threadId, replyToId }) => ({
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
threadId,
}),
resolveAutoThreadId: ({ to, replyToId, toolContext }) =>
resolveMattermostAutoThreadId({ to, replyToId, toolContext }),
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyToIsExplicit
? (replyToId ?? ambientThreadId)
: replyDelivery
? (ambientThreadId ?? replyToId ?? undefined)
: (replyToId ?? ambientThreadId);
return {
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
threadId: resolvedThreadId ?? null,
};
},
},
security: mattermostSecurityAdapter,
outbound: mattermostOutbound,

View File

@@ -1,7 +1,64 @@
// Mattermost tests cover client.retry plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js";
import {
createMattermostClient,
createMattermostDirectChannelWithRetry,
resolveMattermostReplyDeliveryBarrierTimeoutMs,
} from "./client.js";
describe("resolveMattermostReplyDeliveryBarrierTimeoutMs", () => {
it("uses the default barrier for non-DM deliveries", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: false,
queuedCounts: { tool: 1, block: 1, final: 1 },
}),
).toBeUndefined();
});
it("uses the default barrier when no deliveries were queued", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
queuedCounts: { tool: 0, block: 0, final: 0 },
}),
).toBeUndefined();
});
it("covers the default retry envelope plus scheduling slack", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
queuedCounts: { tool: 0, block: 0, final: 1 },
}),
).toBe(210_000);
});
it("covers one maximum retry envelope per queued delivery", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
dmRetryOptions: {
maxRetries: 10,
maxDelayMs: 60_000,
timeoutMs: 120_000,
},
queuedCounts: { tool: 1, block: 0, final: 1 },
}),
).toBe(3_960_000);
});
it("includes the configured inter-block delay budget", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
queuedCounts: { tool: 0, block: 2, final: 0 },
humanDelayBudgetMs: 180_000,
}),
).toBe(600_000);
});
});
describe("createMattermostDirectChannelWithRetry", () => {
const mockFetch = vi.fn<typeof fetch>();

View File

@@ -242,6 +242,35 @@ export type CreateDmChannelRetryOptions = {
onRetry?: (attempt: number, delayMs: number, error: Error) => void;
};
const DM_REPLY_DELIVERY_BARRIER_SLACK_MS = 60_000;
/** Covers DM creation retries without extending channel-delivery stalls. */
export function resolveMattermostReplyDeliveryBarrierTimeoutMs(params: {
isDirect: boolean;
dmRetryOptions?: CreateDmChannelRetryOptions;
queuedCounts: Readonly<Record<"tool" | "block" | "final", number>>;
humanDelayBudgetMs?: number;
}): number | undefined {
if (!params.isDirect) {
return undefined;
}
const deliveryCount = Object.values(params.queuedCounts).reduce((sum, count) => sum + count, 0);
if (deliveryCount === 0) {
return undefined;
}
const maxRetries = params.dmRetryOptions?.maxRetries ?? 3;
const maxDelayMs = params.dmRetryOptions?.maxDelayMs ?? 10_000;
const timeoutMs = params.dmRetryOptions?.timeoutMs ?? 30_000;
const perDeliveryTimeoutMs =
(maxRetries + 1) * timeoutMs + maxRetries * maxDelayMs + DM_REPLY_DELIVERY_BARRIER_SLACK_MS;
const totalTimeoutMs =
perDeliveryTimeoutMs * deliveryCount + Math.max(0, params.humanDelayBudgetMs ?? 0);
return resolveTimerTimeoutMs(
Number.isFinite(totalTimeoutMs) ? totalTimeoutMs : Number.MAX_SAFE_INTEGER,
perDeliveryTimeoutMs,
);
}
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",

View File

@@ -84,6 +84,7 @@ import {
} from "./no-visible-reply-diagnostic.js";
import { runWithReconnect } from "./reconnect.js";
import {
createMattermostReplyDeliveryBarrier,
deliverMattermostReplyPayload,
type MattermostReplyDeliveryOutcome,
} from "./reply-delivery.js";
@@ -794,9 +795,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
},
},
});
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
await deliverMattermostReplyPayload({
@@ -814,6 +821,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered button-click reply to ${to}`);
},
@@ -991,9 +999,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: undefined,
});
const capturedTexts: string[] = [];
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: params.kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
// Picker-triggered confirmations should stay immediate.
deliver: async (payload: ReplyPayload) => {
const trimmedPayload = {
@@ -1024,6 +1038,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
// The picker path already converts and trims text before capture/delivery.
tableMode: "off",
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
},
onError: (err, info) => {
@@ -1753,9 +1768,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
draftStream.update(cleaned);
};
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
typingCallbacks,
deliver: async (payloadEntry: ReplyPayload, info) => {
@@ -1788,6 +1809,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({
outcome,

View File

@@ -5,7 +5,10 @@ import path from "node:path";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import {
createMattermostReplyDeliveryBarrier,
deliverMattermostReplyPayload,
} from "./reply-delivery.js";
type DeliverMattermostReplyPayloadParams = Parameters<typeof deliverMattermostReplyPayload>[0];
type ReplyDeliveryMarkdownTableMode = Parameters<
@@ -38,6 +41,68 @@ function createReplyDeliveryCore(): DeliverMattermostReplyPayloadParams["core"]
} as unknown as PluginRuntime;
}
describe("createMattermostReplyDeliveryBarrier", () => {
it("extends while direct deliveries or DM resolution remain unsettled", async () => {
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: true });
const policy = barrier.resolveTimeoutPolicy({
queuedCounts: { tool: 1, block: 0, final: 1 },
humanDelayBudgetMs: 0,
});
expect(policy?.maxTimeoutMs).toBe(420_000);
expect(policy?.shouldExtend()).toBe(true);
let resolveResolution: () => void = () => {};
const resolution = new Promise<void>((resolve) => {
resolveResolution = resolve;
});
barrier.trackDmChannelResolution(resolution);
expect(policy?.shouldExtend()).toBe(true);
resolveResolution();
await resolution;
await Promise.resolve();
expect(policy?.shouldExtend()).toBe(true);
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(true);
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(false);
});
it("stays extended between failed retries until queued deliveries settle", async () => {
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: true });
const policy = barrier.resolveTimeoutPolicy({
queuedCounts: { tool: 1, block: 0, final: 1 },
humanDelayBudgetMs: 0,
});
let rejectResolution: (error: Error) => void = () => {};
const resolution = new Promise<void>((_resolve, reject) => {
rejectResolution = reject;
});
barrier.trackDmChannelResolution(resolution);
rejectResolution(new Error("DM creation failed"));
await expect(resolution).rejects.toThrow("DM creation failed");
await Promise.resolve();
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(true);
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(false);
});
it("does not extend non-DM delivery", () => {
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: false });
expect(
barrier.resolveTimeoutPolicy({
queuedCounts: { tool: 1, block: 1, final: 1 },
humanDelayBudgetMs: 0,
}),
).toBeUndefined();
});
});
describe("deliverMattermostReplyPayload", () => {
it("suppresses payloads flagged as reasoning", async () => {
const sendMessage = vi.fn(async () => undefined);

View File

@@ -6,7 +6,15 @@ import {
isReasoningReplyPayload,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type {
ReplyDispatchKind,
ReplyFollowupAdmissionBarrierTimeoutPolicy,
ReplyPayload,
} from "openclaw/plugin-sdk/reply-runtime";
import {
resolveMattermostReplyDeliveryBarrierTimeoutMs,
type CreateDmChannelRetryOptions,
} from "./client.js";
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
@@ -19,9 +27,59 @@ type SendMattermostMessage = (
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
replyToId?: string;
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
},
) => Promise<unknown>;
export function createMattermostReplyDeliveryBarrier(params: {
isDirect: boolean;
dmRetryOptions?: CreateDmChannelRetryOptions;
}) {
let activeDmChannelResolutions = 0;
let queuedDeliveryCount = 0;
let settledDeliveryCount = 0;
const trackDmChannelResolution = (resolution: PromiseLike<unknown>) => {
activeDmChannelResolutions += 1;
void Promise.resolve(resolution).then(
() => {
activeDmChannelResolutions -= 1;
},
() => {
activeDmChannelResolutions -= 1;
},
);
};
const markDeliverySettled = () => {
settledDeliveryCount += 1;
};
const resolveTimeoutPolicy = (context: {
queuedCounts: Readonly<Record<ReplyDispatchKind, number>>;
humanDelayBudgetMs: number;
}): ReplyFollowupAdmissionBarrierTimeoutPolicy | undefined => {
const { queuedCounts } = context;
queuedDeliveryCount = Object.values(queuedCounts).reduce((sum, count) => sum + count, 0);
const maxTimeoutMs = resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: params.isDirect,
dmRetryOptions: params.dmRetryOptions,
queuedCounts,
humanDelayBudgetMs: context.humanDelayBudgetMs,
});
if (maxTimeoutMs === undefined) {
return undefined;
}
return {
maxTimeoutMs,
shouldExtend: () =>
activeDmChannelResolutions > 0 || settledDeliveryCount < queuedDeliveryCount,
};
};
return {
trackDmChannelResolution,
markDeliverySettled,
resolveTimeoutPolicy,
};
}
/**
* Result of `deliverMattermostReplyPayload`. Callers in `monitor.ts` use this
* to distinguish a successful visible send from an intentionally suppressed
@@ -41,6 +99,7 @@ export async function deliverMattermostReplyPayload(params: {
textLimit: number;
tableMode: MarkdownTableMode;
sendMessage: SendMattermostMessage;
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
}): Promise<MattermostReplyDeliveryOutcome> {
if (isReasoningReplyPayload(params.payload)) {
return "reasoning_skipped";
@@ -67,6 +126,9 @@ export async function deliverMattermostReplyPayload(params: {
cfg: params.cfg,
accountId: params.accountId,
replyToId: params.replyToId,
...(params.onDmChannelResolution
? { onDmChannelResolution: params.onDmChannelResolution }
: {}),
});
},
sendMedia: async ({ mediaUrl, caption }) => {
@@ -76,6 +138,9 @@ export async function deliverMattermostReplyPayload(params: {
mediaUrl,
mediaLocalRoots,
replyToId: params.replyToId,
...(params.onDmChannelResolution
? { onDmChannelResolution: params.onDmChannelResolution }
: {}),
});
},
});

View File

@@ -624,6 +624,25 @@ describe("sendMessageMattermost user-first resolution", () => {
expect(res.channelId).toBe("dm-channel-id");
});
it("observes cache-miss DM resolution but not cached sends", async () => {
const userId = "iiiiii9999999999iiiiii9999"; // 26 chars
const onDmChannelResolution = vi.fn();
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-dm-observer-t9"));
await sendMessageMattermost(`user:${userId}`, "first", {
cfg: TEST_CFG,
onDmChannelResolution,
});
await sendMessageMattermost(`user:${userId}`, "second", {
cfg: TEST_CFG,
onDmChannelResolution,
});
expect(onDmChannelResolution).toHaveBeenCalledTimes(1);
expect(onDmChannelResolution).toHaveBeenCalledWith(expect.any(Promise));
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
});
it("does not apply user-first resolution for explicit channel: prefix", async () => {
// Unique token + id — explicit channel: prefix, no probe, no DM
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars

View File

@@ -53,6 +53,8 @@ export type MattermostSendOpts = {
attachmentText?: string;
/** Retry options for DM channel creation */
dmRetryOptions?: CreateDmChannelRetryOptions;
/** Observe the bounded cache-miss DM channel resolution lifecycle. */
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
};
export type MattermostSendResult = {
@@ -271,6 +273,7 @@ type ResolveTargetChannelIdParams = {
token: string;
allowPrivateNetwork?: boolean;
dmRetryOptions?: CreateDmChannelRetryOptions;
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void };
};
@@ -331,7 +334,7 @@ async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Pro
allowPrivateNetwork: params.allowPrivateNetwork,
});
const channel = await createMattermostDirectChannelWithRetry(client, [botUser.id, userId], {
const resolution = createMattermostDirectChannelWithRetry(client, [botUser.id, userId], {
...params.dmRetryOptions,
onRetry: (attempt, delayMs, error) => {
// Call user's onRetry if provided
@@ -344,6 +347,8 @@ async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Pro
}
},
});
params.onDmChannelResolution?.(resolution);
const channel = await resolution;
dmChannelCache.set(dmKey, channel.id);
return channel.id;
}
@@ -416,6 +421,7 @@ async function resolveMattermostSendContext(
token,
allowPrivateNetwork,
dmRetryOptions,
onDmChannelResolution: opts.onDmChannelResolution,
logger: core.logging.shouldLogVerbose() ? logger : undefined,
});

View File

@@ -31,7 +31,10 @@ import {
authorizeMattermostCommandInvocation,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import {
createMattermostReplyDeliveryBarrier,
deliverMattermostReplyPayload,
} from "./reply-delivery.js";
import {
buildModelsProviderData,
createChannelMessageReplyPipeline,
@@ -889,10 +892,16 @@ async function handleSlashCommandAsync(params: {
},
});
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
const deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay,
deliver: async (payload: ReplyPayload) => {
await deliverMattermostReplyPayload({
@@ -905,6 +914,7 @@ async function handleSlashCommandAsync(params: {
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered slash reply to ${to}`);
},

View File

@@ -688,6 +688,53 @@ describe("handleSlackAction", () => {
expectLastSlackSend("Threaded reply", cfg, "1111111111.111111");
});
it("auto-injects threadTs for matching DM user targets", async () => {
const cfg = slackConfig();
await handleSlackAction(
{
action: "sendMessage",
to: "user:U123",
content: "Threaded DM reply",
},
cfg,
{
currentChannelId: "slack:U123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expectSlackSendCall(0, "user:U123", "Threaded DM reply", {
cfg,
mediaUrl: undefined,
threadTs: "1111111111.111111",
blocks: undefined,
});
});
it("auto-injects threadTs for routable DM targets while retaining the native channel", async () => {
const cfg = slackConfig();
await handleSlackAction(
{
action: "sendMessage",
to: "user:U123",
content: "Threaded DM reply",
},
cfg,
{
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expectSlackSendCall(0, "user:U123", "Threaded DM reply", {
cfg,
mediaUrl: undefined,
threadTs: "1111111111.111111",
blocks: undefined,
});
});
it.each([
{ name: "topLevel true", patch: { topLevel: true } },
{ name: "threadTs null", patch: { threadTs: null } },
@@ -760,6 +807,42 @@ describe("handleSlackAction", () => {
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
it("replyToMode=first consumes a routable DM target with a native channel context", async () => {
const cfg = slackConfig();
const hasRepliedRef = { value: false };
const context = {
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
currentThreadTs: "1111111111.111111",
replyToMode: "first" as const,
hasRepliedRef,
};
await handleSlackAction(
{
action: "sendMessage",
to: "user:U123",
content: "Explicit",
threadTs: "9999999999.999999",
},
cfg,
context,
);
expect(hasRepliedRef.value).toBe(true);
await handleSlackAction(
{ action: "sendMessage", to: "user:U123", content: "Second" },
cfg,
context,
);
expectSlackSendCall(1, "user:U123", "Second", {
cfg,
mediaUrl: undefined,
threadTs: undefined,
blocks: undefined,
});
});
it("replyToMode=first without hasRepliedRef does not thread", async () => {
const cfg = slackConfig();
await handleSlackAction({ action: "sendMessage", to: "channel:C123", content: "No ref" }, cfg, {

View File

@@ -3,7 +3,6 @@ import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ResolvedSlackAccount } from "./accounts.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { resolveSlackChannelConfig } from "./monitor/channel-config.js";
@@ -18,7 +17,7 @@ import {
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
import { resolveSlackChannelId, slackContextTargetsMatch } from "./targets.js";
const messagingActions = new Set([
"sendMessage",
@@ -32,19 +31,6 @@ const messagingActions = new Set([
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
function sameSlackChannelTarget(targetChannel: string, currentChannelId: string): boolean {
const parsedTarget = parseSlackTarget(targetChannel, {
defaultKind: "channel",
});
if (!parsedTarget || parsedTarget.kind !== "channel") {
return false;
}
return (
normalizeLowercaseStringOrEmpty(parsedTarget.id) ===
normalizeLowercaseStringOrEmpty(currentChannelId)
);
}
type SlackActionsRuntimeModule = typeof import("./actions.runtime.js");
type SlackAccountsRuntimeModule = typeof import("./accounts.runtime.js");
@@ -92,6 +78,8 @@ export const slackActionRuntime = {
export type SlackActionContext = {
/** Current channel ID for auto-threading. */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the channel ID. */
currentMessagingTarget?: string;
/** Current thread timestamp for auto-threading. */
currentThreadTs?: string;
/** Reply-to mode for auto-threading. */
@@ -124,12 +112,12 @@ function resolveThreadTsFromContext(
if (opts?.suppressImplicitThread) {
return undefined;
}
if (!context?.currentChannelId) {
if (!context?.currentChannelId && !context?.currentMessagingTarget) {
return undefined;
}
// Different channel - don't inject
if (!sameSlackChannelTarget(targetChannel, context.currentChannelId)) {
if (!slackContextTargetsMatch(targetChannel, context)) {
return undefined;
}
if (!context.currentThreadTs) {
@@ -340,10 +328,8 @@ export async function handleSlackAction(
// Keep "first" mode consistent even when the agent explicitly provided
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && context.currentChannelId) {
if (sameSlackChannelTarget(to, context.currentChannelId)) {
context.hasRepliedRef.value = true;
}
if (context?.hasRepliedRef && slackContextTargetsMatch(to, context)) {
context.hasRepliedRef.value = true;
}
return jsonResult({ ok: true, result });
@@ -383,10 +369,8 @@ export async function handleSlackAction(
...(title ? { uploadTitle: title } : {}),
});
if (context?.hasRepliedRef && context.currentChannelId) {
if (sameSlackChannelTarget(to, context.currentChannelId)) {
context.hasRepliedRef.value = true;
}
if (context?.hasRepliedRef && slackContextTargetsMatch(to, context)) {
context.hasRepliedRef.value = true;
}
return jsonResult({ ok: true, result });

View File

@@ -4,6 +4,7 @@ import { resolveSlackAutoThreadId } from "./action-threading.js";
type SlackThreadingToolContext = {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
@@ -58,6 +59,29 @@ describe("resolveSlackAutoThreadId", () => {
expect(hasRepliedRef.value).toBe(false);
});
it("uses the active thread for matching user targets", () => {
expect(
resolveSlackAutoThreadId({
to: "user:U123",
toolContext: createToolContext({
currentChannelId: "slack:U123",
}),
}),
).toBe("thread-1");
});
it("matches either native or routable DM targets", () => {
const context = createToolContext({
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
});
expect(resolveSlackAutoThreadId({ to: "user:U123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "U123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "D123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "user:U999", toolContext: context })).toBeUndefined();
});
it("skips auto-threading when reply mode or thread context blocks it", () => {
expect(
resolveSlackAutoThreadId({

View File

@@ -1,12 +1,12 @@
// Slack plugin module implements action threading behavior.
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { parseSlackTarget } from "./targets.js";
import { slackContextTargetsMatch } from "./targets.js";
export function resolveSlackAutoThreadId(params: {
to: string;
toolContext?: {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
@@ -14,17 +14,10 @@ export function resolveSlackAutoThreadId(params: {
};
}): string | undefined {
const context = params.toolContext;
if (!context?.currentChannelId) {
if (!context?.currentChannelId && !context?.currentMessagingTarget) {
return undefined;
}
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
if (
normalizeLowercaseStringOrEmpty(parsedTarget.id) !==
normalizeLowercaseStringOrEmpty(context.currentChannelId)
) {
if (!slackContextTargetsMatch(params.to, context)) {
return undefined;
}
if (!context.currentThreadTs) {

View File

@@ -777,6 +777,35 @@ describe("slackPlugin outbound", () => {
expect(threadId).toBe("1712345678.123456");
});
it("auto-threads a DM target after target resolution strips its user prefix", async () => {
const resolveTarget = slackPlugin.messaging?.targetResolver?.resolveTarget;
const resolveAutoThreadId = slackPlugin.threading?.resolveAutoThreadId;
if (!resolveTarget || !resolveAutoThreadId) {
throw new Error("slack target resolution or auto-threading unavailable");
}
const resolved = await resolveTarget({
cfg,
accountId: "default",
input: "user:U123",
normalized: "user:u123",
});
expect(resolved).toMatchObject({ to: "U123", kind: "user" });
expect(
resolveAutoThreadId({
cfg,
to: resolved?.to ?? "",
toolContext: {
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
currentThreadTs: "1712345678.123456",
replyToMode: "all",
},
}),
).toBe("1712345678.123456");
});
it("does not recover invalid Slack auto-thread anchors", () => {
const resolveAutoThreadId = slackPlugin.threading?.resolveAutoThreadId;
if (!resolveAutoThreadId) {
@@ -830,6 +859,41 @@ describe("slackPlugin outbound", () => {
replyToId: "1712345678.123456",
threadId: null,
});
expect(
resolveReplyTransport({
cfg,
replyToId: "9999999999.999999",
replyDelivery: {
chatType: "channel",
replyToMode: "off",
},
}),
).toEqual({
replyToId: null,
threadId: null,
});
});
it("ignores explicit reply targets for off-mode final delivery", () => {
const resolveReplyTransport = slackPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
throw new Error("slack threading.resolveReplyTransport unavailable");
}
expect(
resolveReplyTransport({
cfg,
replyToId: "9999999999.999999",
threadId: "1712345678.123456",
replyDelivery: {
chatType: "channel",
replyToMode: "off",
},
}),
).toEqual({
replyToId: "1712345678.123456",
threadId: null,
});
});
it("forwards mediaLocalRoots for sendMedia", async () => {

View File

@@ -71,6 +71,7 @@ import {
slackConfigAdapter,
} from "./shared.js";
import { parseSlackTarget } from "./target-parsing.js";
import { slackContextTargetsMatch } from "./targets.js";
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
@@ -796,6 +797,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
},
security: slackSecurityAdapter,
threading: {
matchesToolContextTarget: ({ target, toolContext }) =>
slackContextTargetsMatch(target, toolContext),
scopedAccountReplyToMode: {
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType),
@@ -811,10 +814,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
toolContext,
}),
),
resolveReplyTransport: ({ threadId, replyToId }) => ({
replyToId: resolveSlackThreadTsValue({ replyToId, threadId }),
threadId: null,
}),
resolveReplyTransport: ({ threadId, replyToId, replyDelivery }) => {
const resolvedReplyToId = resolveSlackThreadTsValue({
replyToId: replyDelivery?.replyToMode === "off" ? undefined : replyToId,
threadId,
});
return {
replyToId:
replyDelivery?.replyToMode === "off" && !resolvedReplyToId ? null : resolvedReplyToId,
threadId: null,
};
},
},
outbound: slackChannelOutbound,
});

View File

@@ -1,6 +1,7 @@
// Slack tests cover message action dispatch plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
import { extractSlackToolSend } from "./message-actions.js";
function createInvokeSpy() {
return vi.fn(async (action: Record<string, unknown>, _cfg?: unknown, _toolContext?: unknown) => ({
@@ -548,3 +549,81 @@ describe("handleSlackMessageAction", () => {
).rejects.toThrow(/fileId/i);
});
});
describe("extractSlackToolSend", () => {
it("maps native thread and top-level fields into send telemetry", () => {
expect(
extractSlackToolSend({
action: "sendMessage",
to: "channel:C1",
threadTs: "171.222",
}),
).toMatchObject({
to: "channel:C1",
threadId: "171.222",
});
expect(
extractSlackToolSend({
action: "sendMessage",
to: "channel:C1",
}),
).toMatchObject({
to: "channel:C1",
threadImplicit: true,
});
expect(
extractSlackToolSend({
action: "uploadFile",
to: "channel:C1",
}),
).toMatchObject({
to: "channel:C1",
threadImplicit: true,
});
expect(
extractSlackToolSend({
action: "sendMessage",
to: "channel:C1",
threadTs: null,
}),
).toMatchObject({
to: "channel:C1",
threadSuppressed: true,
});
});
it("maps generic send and upload thread precedence into telemetry", () => {
expect(
extractSlackToolSend({
action: "send",
to: "channel:C1",
threadId: "111.000",
replyTo: "999.000",
}),
).toMatchObject({
to: "channel:C1",
threadId: "999.000",
});
expect(
extractSlackToolSend({
action: "upload-file",
to: "channel:C1",
threadId: "111.000",
replyTo: "999.000",
}),
).toMatchObject({
to: "channel:C1",
threadId: "111.000",
});
expect(
extractSlackToolSend({
action: "upload-file",
to: "channel:C1",
replyTo: "999.000",
}),
).toMatchObject({
to: "channel:C1",
threadId: "999.000",
});
});
});

View File

@@ -4,6 +4,7 @@ import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contr
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { extractToolSend, type ChannelToolSend } from "openclaw/plugin-sdk/tool-send";
import { listEnabledSlackAccounts, resolveSlackAccount } from "./accounts.js";
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
export function listSlackMessageActions(
cfg: OpenClawConfig,
@@ -55,5 +56,35 @@ export function listSlackMessageActions(
}
export function extractSlackToolSend(args: Record<string, unknown>): ChannelToolSend | null {
return extractToolSend(args, "sendMessage");
const action = args.action;
if (
action !== "sendMessage" &&
action !== "uploadFile" &&
action !== "send" &&
action !== "upload-file"
) {
return null;
}
const extracted = extractToolSend(args, action);
if (!extracted) {
return null;
}
const nativeThreadTs =
typeof args.threadTs === "string" ? normalizeSlackThreadTsCandidate(args.threadTs) : undefined;
const replyTo =
typeof args.replyTo === "string" ? normalizeSlackThreadTsCandidate(args.replyTo) : undefined;
const threadTs =
action === "send"
? resolveSlackThreadTsValue({ replyToId: replyTo, threadId: extracted.threadId })
: action === "upload-file"
? (normalizeSlackThreadTsCandidate(extracted.threadId) ?? replyTo)
: (nativeThreadTs ?? normalizeSlackThreadTsCandidate(extracted.threadId));
const threadSuppressed =
extracted.threadSuppressed === true || args.topLevel === true || args.threadTs === null;
return {
...extracted,
threadId: threadTs ?? extracted.threadId,
...(!threadTs && !extracted.threadId && !threadSuppressed ? { threadImplicit: true } : {}),
...(threadSuppressed ? { threadSuppressed: true } : {}),
};
}

View File

@@ -61,6 +61,12 @@ export function normalizeSlackMessagingTarget(raw: string): string | undefined {
return parseSlackTarget(raw, { defaultKind: "channel" })?.normalized;
}
export function slackTargetsMatch(left: string, right: string): boolean {
const leftTarget = normalizeSlackMessagingTarget(left);
const rightTarget = normalizeSlackMessagingTarget(right);
return Boolean(leftTarget && rightTarget && leftTarget === rightTarget);
}
export function looksLikeSlackTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {

View File

@@ -4,6 +4,8 @@ import {
normalizeSlackMessagingTarget,
parseSlackTarget,
resolveSlackChannelId,
slackContextTargetsMatch,
slackTargetsMatch,
} from "./targets.js";
describe("parseSlackTarget", () => {
@@ -67,3 +69,33 @@ describe("normalizeSlackMessagingTarget", () => {
expect(normalizeSlackMessagingTarget("C123")).toBe("channel:c123");
});
});
describe("slackTargetsMatch", () => {
it("matches equivalent channel and user targets", () => {
expect(slackTargetsMatch("channel:C123", "C123")).toBe(true);
expect(slackTargetsMatch("user:U123", "slack:U123")).toBe(true);
});
it("does not match different target kinds", () => {
expect(slackTargetsMatch("user:U123", "channel:U123")).toBe(false);
});
});
describe("slackContextTargetsMatch", () => {
it("matches resolved bare user ids against the routable DM target", () => {
const context = {
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
};
expect(slackContextTargetsMatch("U123", context)).toBe(true);
expect(
slackContextTargetsMatch("W123", {
...context,
currentMessagingTarget: "user:W123",
}),
).toBe(true);
expect(slackContextTargetsMatch("U999", context)).toBe(false);
expect(slackContextTargetsMatch("C123", context)).toBe(false);
});
});

View File

@@ -1,8 +1,38 @@
// Slack plugin module implements targets behavior.
import { parseSlackTarget, slackTargetsMatch } from "./target-parsing.js";
function matchesResolvedUserTarget(target: string, currentMessagingTarget: string): boolean {
const resolvedId = target.trim();
if (!/^[UW][A-Z0-9]+$/i.test(resolvedId)) {
return false;
}
const currentTarget = parseSlackTarget(currentMessagingTarget);
return (
currentTarget?.kind === "user" && currentTarget.id.toLowerCase() === resolvedId.toLowerCase()
);
}
export function slackContextTargetsMatch(
target: string,
context: {
currentChannelId?: string;
currentMessagingTarget?: string;
},
): boolean {
return Boolean(
(context.currentMessagingTarget &&
(slackTargetsMatch(target, context.currentMessagingTarget) ||
// Core target resolution removes the user: prefix before auto-thread selection.
matchesResolvedUserTarget(target, context.currentMessagingTarget))) ||
(context.currentChannelId && slackTargetsMatch(target, context.currentChannelId)),
);
}
export {
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
parseSlackTarget,
resolveSlackChannelId,
} from "./target-parsing.js";
export { slackTargetsMatch };
export type { SlackTarget, SlackTargetKind, SlackTargetParseOptions } from "./target-parsing.js";

View File

@@ -246,9 +246,10 @@ describe("buildSlackThreadingToolContext", () => {
context: { ChatType: "channel", To: "channel:C1234ABC" },
});
expect(result.currentChannelId).toBe("C1234ABC");
expect(result.currentMessagingTarget).toBe("channel:C1234ABC");
});
it("uses NativeChannelId for DM when To is user-prefixed", () => {
it("preserves native and routable DM targets", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
@@ -259,14 +260,16 @@ describe("buildSlackThreadingToolContext", () => {
},
});
expect(result.currentChannelId).toBe("D8SRXRDNF");
expect(result.currentMessagingTarget).toBe("user:U8SUVSVGS");
});
it("returns undefined currentChannelId when neither channel: To nor NativeChannelId is set", () => {
it("uses the user target for implicit DM sends when NativeChannelId is missing", () => {
const result = buildSlackThreadingToolContext({
cfg: emptyCfg,
accountId: null,
context: { ChatType: "direct", To: "user:U8SUVSVGS" },
});
expect(result.currentChannelId).toBeUndefined();
expect(result.currentChannelId).toBe("user:U8SUVSVGS");
expect(result.currentMessagingTarget).toBe("user:U8SUVSVGS");
});
});

View File

@@ -30,13 +30,15 @@ export function buildSlackThreadingToolContext(params: {
(replyToThreadTs != null && currentMessageTs != null && replyToThreadTs !== currentMessageTs);
const effectiveReplyToMode = hasExplicitThreadTarget ? "all" : configuredReplyToMode;
// For channel messages, To is "channel:C…" — extract the bare ID.
// For DMs, To is "user:U…" which can't be used for reactions; fall back
// to NativeChannelId (the raw Slack channel id, e.g. "D…").
const currentChannelId = params.context.To?.startsWith("channel:")
? params.context.To.slice("channel:".length)
: normalizeOptionalString(params.context.NativeChannelId);
// For DMs, prefer NativeChannelId for channel-scoped actions, but keep the
// user target as a valid implicit send destination when no D… id is known.
const currentMessagingTarget = normalizeOptionalString(params.context.To);
const currentChannelId = currentMessagingTarget?.startsWith("channel:")
? currentMessagingTarget.slice("channel:".length)
: (normalizeOptionalString(params.context.NativeChannelId) ?? currentMessagingTarget);
return {
currentChannelId,
currentMessagingTarget,
currentThreadTs,
replyToMode: effectiveReplyToMode,
hasRepliedRef: params.hasRepliedRef,

View File

@@ -71,6 +71,7 @@ export const AgentEventSchema = Type.Object(
export const MessageActionToolContextSchema = Type.Object(
{
currentChannelId: Type.Optional(Type.String()),
currentMessagingTarget: Type.Optional(Type.String()),
currentGraphChannelId: Type.Optional(Type.String()),
currentChannelProvider: Type.Optional(Type.String()),
currentThreadTs: Type.Optional(Type.String()),
@@ -91,6 +92,7 @@ export const MessageActionToolContextSchema = Type.Object(
{ additionalProperties: false },
),
),
sameChannelThreadRequired: Type.Optional(Type.Boolean()),
skipCrossContextDecoration: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },

View File

@@ -866,6 +866,28 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("whatsapp")).toBe(false);
});
it("separates the canonical message provider from transport tool policy", () => {
vi.mocked(createOpenClawTools).mockClear();
const tools = createOpenClawCodingTools({
config: {
tools: {
toolsBySender: {
"channel:discord:speaker-1": { deny: ["exec"] },
},
},
},
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
senderId: "speaker-1",
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("exec")).toBe(false);
expect(names.has("tts")).toBe(false);
expect(latestCreateOpenClawToolsOptions().agentChannel).toBe("discord");
});
it("filters session tools for sub-agent sessions by default", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",

View File

@@ -415,6 +415,8 @@ export function createOpenClawCodingTools(options?: {
agentId?: string;
exec?: ExecToolDefaults & ProcessToolDefaults;
messageProvider?: string;
/** Specific ingress provider used only for transport tool availability. */
toolPolicyMessageProvider?: string;
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
@@ -480,6 +482,8 @@ export function createOpenClawCodingTools(options?: {
modelAuthMode?: ModelAuthMode;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the native channel ID. */
currentMessagingTarget?: string;
/** Normalized conversation id exposed to tool hooks. Defaults to currentChannelId. */
hookChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
@@ -951,6 +955,7 @@ export function createOpenClawCodingTools(options?: {
pluginToolAllowlist,
pluginToolDenylist,
currentChannelId: options?.currentChannelId,
currentMessagingTarget: options?.currentMessagingTarget,
currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
modelProvider: options?.modelProvider,
@@ -1037,6 +1042,7 @@ export function createOpenClawCodingTools(options?: {
pluginToolAllowlist,
pluginToolDenylist,
currentChannelId: options?.currentChannelId,
currentMessagingTarget: options?.currentMessagingTarget,
currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
currentInboundAudio: options?.currentInboundAudio,
@@ -1098,7 +1104,7 @@ export function createOpenClawCodingTools(options?: {
: undefined;
const toolsForMessageProvider = filterToolsByMessageProvider(
toolsForMemoryFlush,
options?.messageProvider,
options?.toolPolicyMessageProvider ?? options?.messageProvider,
);
options?.recordToolPrepStage?.("message-provider-policy");
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { buildEmbeddedExtensionFactories } from "./embedded-agent-runner/extensions.js";
import { consumeEmbeddedToolSendReceipt } from "./embedded-agent-runner/tool-send-receipts.js";
import { cleanupTempPluginTestEnvironment } from "./test-helpers/temp-plugin-extension-fixtures.js";
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -168,6 +169,68 @@ describe("buildEmbeddedExtensionFactories", () => {
});
});
it("stores provider send receipts without overriding middleware details", async () => {
const registry = createEmptyPluginRegistry();
registry.agentToolResultMiddlewares.push({
pluginId: "redactor",
pluginName: "redactor",
rawHandler: () => undefined,
handler: (event) => ({
result: {
content: event.result.content,
details: { redacted: true },
},
}),
runtimes: ["openclaw"],
source: "test",
});
setActivePluginRegistry(registry);
const sessionManager = SessionManager.inMemory();
const factories = buildEmbeddedExtensionFactories({
cfg: undefined,
sessionManager,
provider: "openai",
modelId: "gpt-5.4",
model: undefined,
});
const handlers = new Map<string, Function>();
await factories[0]?.({
on(event: string, handler: Function) {
handlers.set(event, handler);
},
} as never);
const result = await handlers.get("tool_result")?.(
{
toolName: "message",
toolCallId: "call-message",
content: [{ type: "text", text: "Sent." }],
details: {
toolSend: {
to: "channel:resolved-id",
threadId: "root-1",
},
},
},
{ cwd: "/tmp" },
);
expect(result).toEqual({
content: [{ type: "text", text: "Sent." }],
details: { redacted: true },
});
expect(consumeEmbeddedToolSendReceipt(sessionManager, "call-message")).toEqual({
details: {
toolSend: {
to: "channel:resolved-id",
threadId: "root-1",
},
},
});
expect(consumeEmbeddedToolSendReceipt(sessionManager, "call-message")).toBeUndefined();
});
it("marks status-timeout tool results as model-visible failures", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());

View File

@@ -22,6 +22,7 @@ import type { AgentToolResult } from "../runtime/index.js";
import type { ExtensionFactory, SessionManager } from "../sessions/index.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js";
import { recordEmbeddedToolSendReceipt } from "./tool-send-receipts.js";
type AgentToolResultEvent = {
threadId?: string;
@@ -50,7 +51,14 @@ function hasErrorToolResultStatus(result: AgentToolResult<unknown>): boolean {
return status === "error" || status === "timeout";
}
function buildAgentToolResultMiddlewareFactory(): ExtensionFactory {
function snapshotToolSendReceipt(details: unknown): unknown {
const toolSend = recordFromUnknown(details).toolSend;
return toolSend && typeof toolSend === "object" && !Array.isArray(toolSend)
? { ...(toolSend as Record<string, unknown>) }
: toolSend;
}
function buildAgentToolResultMiddlewareFactory(sessionManager: SessionManager): ExtensionFactory {
const runner = createAgentToolResultMiddlewareRunner({ runtime: "openclaw" });
return (agent) => {
agent.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => {
@@ -58,15 +66,21 @@ function buildAgentToolResultMiddlewareFactory(): ExtensionFactory {
if (!event.toolName) {
return undefined;
}
const toolCallId =
const eventToolCallId =
typeof event.toolCallId === "string" && event.toolCallId.trim()
? event.toolCallId
: `openclaw-${randomUUID()}`;
: undefined;
const toolCallId = eventToolCallId ?? `openclaw-${randomUUID()}`;
const content = Array.isArray(event.content) ? event.content : [];
const current = {
content,
details: event.details,
} satisfies AgentToolResult<unknown>;
const rawToolSend = snapshotToolSendReceipt(current.details);
if (eventToolCallId && rawToolSend !== undefined) {
// Routing evidence stays private so middleware may fully replace result details.
recordEmbeddedToolSendReceipt(sessionManager, eventToolCallId, rawToolSend);
}
const inputHadErrorStatus = hasErrorToolResultStatus(current);
const result = await runner.applyToolResultMiddleware({
threadId: event.threadId,
@@ -184,7 +198,7 @@ export function buildEmbeddedExtensionFactories(params: {
if (pruningFactory) {
factories.push(pruningFactory);
}
factories.push(buildAgentToolResultMiddlewareFactory());
factories.push(buildAgentToolResultMiddlewareFactory(params.sessionManager));
return factories;
}

View File

@@ -1763,6 +1763,7 @@ async function runEmbeddedAgentInternal(
senderUsername: params.senderUsername,
senderE164: params.senderE164,
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
currentInboundAudio: params.currentInboundAudio,

View File

@@ -65,6 +65,27 @@ describe("runEmbeddedAttempt cwd/workspace split", () => {
expect(resourceLoaderInit?.cwd).toBe(taskRepo);
});
it("forwards native and routable channel targets into runtime tools", async () => {
await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey: "agent:main:slack:direct:U123",
tempPaths,
attemptOverrides: {
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
disableTools: false,
},
});
const toolsCall = hoisted.createOpenClawCodingToolsMock.mock.calls[0]?.[0] as
| { currentChannelId?: string; currentMessagingTarget?: string }
| undefined;
expect(toolsCall).toMatchObject({
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
});
});
it("rejects cwd overrides for sandboxed runs instead of silently ignoring them", async () => {
// Sandboxed attempts already remap the workspace; accepting an extra cwd
// override would make tool roots ambiguous.

View File

@@ -1279,6 +1279,7 @@ export async function runEmbeddedAttempt(
workspaceDir: effectiveWorkspace,
}),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
currentInboundAudio: params.currentInboundAudio,
@@ -3458,6 +3459,12 @@ export async function runEmbeddedAttempt(
silentExpected: params.silentExpected,
config: params.config,
sessionKey: sandboxSessionKey,
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentThreadId: params.currentThreadTs,
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sessionId: params.sessionId,
agentId: sessionAgentId,
builtinToolNames,

View File

@@ -85,6 +85,8 @@ export type RunEmbeddedAgentParams = {
senderIsOwner?: boolean;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the native channel ID. */
currentMessagingTarget?: string;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Current inbound message id for action fallbacks (e.g. Telegram react). */

View File

@@ -0,0 +1,35 @@
import { createSessionManagerRuntimeRegistry } from "../agent-hooks/session-manager-runtime-registry.js";
type ToolSendReceiptResult = {
details: {
toolSend: unknown;
};
};
const registry = createSessionManagerRuntimeRegistry<Map<string, ToolSendReceiptResult>>();
export function recordEmbeddedToolSendReceipt(
sessionManager: unknown,
toolCallId: string,
toolSend: unknown,
): void {
const receipts = registry.get(sessionManager) ?? new Map<string, ToolSendReceiptResult>();
receipts.set(toolCallId, { details: { toolSend } });
registry.set(sessionManager, receipts);
}
export function consumeEmbeddedToolSendReceipt(
sessionManager: unknown,
toolCallId: string,
): ToolSendReceiptResult | undefined {
const receipts = registry.get(sessionManager);
const receipt = receipts?.get(toolCallId);
if (!receipts || !receipt) {
return undefined;
}
receipts.delete(toolCallId);
if (receipts.size === 0) {
registry.set(sessionManager, null);
}
return receipt;
}

View File

@@ -6,6 +6,8 @@ import {
onAgentEvent as registerAgentEventListener,
resetAgentEventsForTest,
} from "../infra/agent-events.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import type { MessagingToolSend } from "./embedded-agent-messaging.types.js";
import {
handleToolExecutionEnd,
@@ -1515,6 +1517,136 @@ describe("handleToolExecutionEnd derived tool events", () => {
});
describe("messaging tool media URL tracking", () => {
afterEach(() => {
setActivePluginRegistry(createTestRegistry());
});
it("uses the current provider and thread for implicit message sends", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
plugin: {
...createChannelTestPluginBase({ id: "slack" }),
messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() },
threading: {
resolveAutoThreadId: ({
to,
toolContext,
}: {
to: string;
toolContext?: {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
};
}) =>
toolContext?.replyToMode === "all" &&
(to === toolContext.currentMessagingTarget || to === toolContext.currentChannelId)
? toolContext.currentThreadTs
: undefined,
},
},
source: "test",
},
]),
);
const { ctx } = createTestContext();
ctx.params.messageChannel = "slack";
ctx.params.currentChannelId = "D1";
ctx.params.currentMessagingTarget = "user:u1";
ctx.params.currentThreadId = "171.222";
ctx.params.replyToMode = "all";
await handleToolExecutionStart(ctx, {
type: "tool_execution_start",
toolName: "message",
toolCallId: "tool-threaded-message",
args: {
action: "send",
to: "user:U1",
content: "hi",
},
});
expect(ctx.state.pendingMessagingTargets.get("tool-threaded-message")).toMatchObject({
provider: "slack",
to: "user:u1",
threadId: "171.222",
threadImplicit: true,
});
});
it("reconciles unresolved send targets from successful provider results", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "mattermost",
plugin: {
...createChannelTestPluginBase({ id: "mattermost" }),
actions: {
extractToolSend: ({ args }: { args: Record<string, unknown> }) =>
args.action === "send" && typeof args.to === "string"
? { to: args.to, threadImplicit: true }
: null,
extractToolSendResult: ({ result }: { result: unknown }) => {
const providerResult = result as {
status?: string;
details?: { redacted?: boolean; toolSend?: unknown };
};
if (providerResult.status !== "sent" || providerResult.details?.redacted !== true) {
return null;
}
const details = providerResult.details;
return (details?.toolSend as { to: string; threadId?: string } | undefined) ?? null;
},
},
},
source: "test",
},
]),
);
const { ctx } = createTestContext();
ctx.consumeToolSendReceipt = () => ({
details: {
toolSend: {
to: "channel:resolved-id",
threadId: "root-1",
},
},
});
await handleToolExecutionStart(ctx, {
type: "tool_execution_start",
toolName: "message",
toolCallId: "tool-mattermost-name",
args: {
action: "send",
provider: "mattermost",
to: "town-square",
content: "hi",
},
});
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "message",
toolCallId: "tool-mattermost-name",
isError: false,
result: {
status: "sent",
details: { redacted: true },
},
});
expectRecordFields(requireSingleMessagingTarget(ctx), "messaging target", {
provider: "mattermost",
to: "channel:resolved-id",
threadId: "root-1",
text: "hi",
});
});
it("tracks media arg from messaging tool as pending", async () => {
const { ctx } = createTestContext();

View File

@@ -15,6 +15,7 @@ import {
HEARTBEAT_RESPONSE_TOOL_NAME,
normalizeHeartbeatToolResponse,
} from "../auto-reply/heartbeat-tool-response.js";
import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js";
import type {
AgentApprovalEventData,
AgentCommandOutputEventData,
@@ -52,6 +53,7 @@ import {
extractToolResultMediaArtifact,
extractToolErrorCode,
extractMessagingToolSend,
extractMessagingToolSendResult,
extractToolErrorMessage,
extractToolResultText,
filterToolResultMediaUrls,
@@ -291,6 +293,36 @@ function readToolResultDetailsRecord(result: unknown): Record<string, unknown> |
return readRecordField(asOptionalObjectRecord(result)?.details);
}
function applyCurrentMessageProvider(
toolName: string,
args: Record<string, unknown>,
currentProvider: string | undefined,
): Record<string, unknown> {
if (
toolName !== "message" ||
readStringValue(args.provider) ||
readStringValue(args.channel) ||
!currentProvider
) {
return args;
}
return { ...args, provider: currentProvider };
}
function applyToolSendReceiptForExtraction(result: unknown, receiptResult: unknown): unknown {
const toolSend = readToolResultDetailsRecord(receiptResult)?.toolSend;
if (toolSend === undefined) {
return result;
}
return {
...readRecordField(result),
details: {
...readToolResultDetailsRecord(result),
toolSend,
},
};
}
function isAsyncStartedToolResult(result: unknown): boolean {
const details = readToolResultDetailsRecord(result);
return details?.async === true && details.status === "started";
@@ -1031,7 +1063,22 @@ export function handleToolExecutionStart(
const argsRecord = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const isMessagingSend = isMessagingToolSendAction(toolName, argsRecord);
if (isMessagingSend) {
const sendTarget = extractMessagingToolSend(toolName, argsRecord);
const telemetryArgs = applyCurrentMessageProvider(
toolName,
argsRecord,
ctx.params.messageChannel,
);
const sendTarget = extractMessagingToolSend(toolName, telemetryArgs, {
config: ctx.params.config,
currentChannelId: ctx.params.currentChannelId,
currentMessagingTarget: ctx.params.currentMessagingTarget,
currentThreadId:
ctx.params.currentThreadId ??
parseSessionThreadInfoFast(ctx.params.sessionKey).threadId,
currentMessageId: ctx.params.currentMessageId,
replyToMode: ctx.params.replyToMode,
hasRepliedRef: ctx.params.hasRepliedRef,
});
if (sendTarget) {
ctx.state.pendingMessagingTargets.set(toolCallId, sendTarget);
}
@@ -1166,6 +1213,7 @@ export async function handleToolExecutionEnd(
const runId = ctx.params.runId;
const isError = evt.isError;
const result = evt.result;
const toolSendReceiptResult = ctx.consumeToolSendReceipt?.(toolCallId);
const observerIsError = isError || isToolResultError(result);
const sanitizedResult = sanitizeToolResult(result);
const approvalUnavailable =
@@ -1275,8 +1323,10 @@ export async function handleToolExecutionEnd(
if (pendingTarget) {
ctx.state.pendingMessagingTargets.delete(toolCallId);
if (!isToolError) {
const extractionResult = applyToolSendReceiptForExtraction(result, toolSendReceiptResult);
const confirmedTarget = extractMessagingToolSendResult(pendingTarget, extractionResult);
ctx.state.messagingToolSentTargets.push({
...pendingTarget,
...confirmedTarget,
...(pendingText ? { text: pendingText } : {}),
...(committedMediaUrls.length > 0 ? { mediaUrls: committedMediaUrls.slice() } : {}),
});

View File

@@ -237,6 +237,7 @@ export type EmbeddedAgentSubscribeContext = {
chunkerHasBuffered: boolean;
}) => void;
trimMessagingToolSent: () => void;
consumeToolSendReceipt: (toolCallId: string) => unknown;
ensureCompactionPromise: () => void;
noteCompactionRetry: () => void;
resolveCompactionRetry: () => void;
@@ -273,7 +274,15 @@ type ToolHandlerParams = Pick<
| "onHeartbeatToolResponse"
| "onAgentToolResult"
| "onToolResult"
| "config"
| "messageChannel"
| "sessionKey"
| "currentChannelId"
| "currentMessagingTarget"
| "currentThreadId"
| "currentMessageId"
| "replyToMode"
| "hasRepliedRef"
| "sessionId"
| "agentId"
| "toolResultFormat"
@@ -326,6 +335,7 @@ export type ToolHandlerContext = {
emitToolSummary: (toolName?: string, meta?: string) => void;
emitToolOutput: (toolName?: string, meta?: string, output?: string, result?: unknown) => void;
trimMessagingToolSent: () => void;
consumeToolSendReceipt?: (toolCallId: string) => unknown;
};
export type EmbeddedAgentSubscribeEvent =

View File

@@ -23,7 +23,21 @@ describe("extractMessagingToolSend", () => {
plugin: {
...createChannelTestPluginBase({ id: "telegram" }),
messaging: { normalizeTarget: normalizeTelegramMessagingTargetForTest },
threading: { resolveAutoThreadId: () => "456" },
actions: {
extractToolSend: ({ args }: { args: Record<string, unknown> }) =>
args.action === "sendMessage" && typeof args.to === "string"
? { to: args.to }
: null,
},
threading: {
resolveAutoThreadId: ({
to,
toolContext,
}: {
to: string;
toolContext?: { currentThreadTs?: string };
}) => (to.includes(":topic:") ? undefined : toolContext?.currentThreadTs),
},
},
source: "test",
},
@@ -35,15 +49,72 @@ describe("extractMessagingToolSend", () => {
actions: {
extractToolSend: (params: { args: Record<string, unknown> }) => {
const { args } = params;
return args.action === "sendMessage" && typeof args.to === "string"
? {
to: args.to,
accountId: typeof args.accountId === "string" ? args.accountId : undefined,
threadId: typeof args.threadId === "string" ? args.threadId : undefined,
}
: null;
if (
(args.action !== "sendMessage" &&
args.action !== "uploadFile" &&
args.action !== "send" &&
args.action !== "upload-file") ||
typeof args.to !== "string"
) {
return null;
}
const nativeThreadId =
typeof args.threadTs === "string"
? args.threadTs
: typeof args.threadId === "string"
? args.threadId
: undefined;
const replyTo = typeof args.replyTo === "string" ? args.replyTo : undefined;
const threadId =
args.action === "send"
? (replyTo ?? nativeThreadId)
: args.action === "upload-file"
? (nativeThreadId ?? replyTo)
: nativeThreadId;
const threadSuppressed =
args.topLevel === true || args.threadTs === null || args.threadId === null;
return {
to: args.to,
accountId: typeof args.accountId === "string" ? args.accountId : undefined,
threadId,
threadSuppressed,
threadImplicit: !threadId && !threadSuppressed,
};
},
},
threading: {
resolveAutoThreadId: ({
to,
toolContext,
replyToId,
}: {
to: string;
replyToId?: string | null;
toolContext?: {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}) => {
if (
replyToId ||
(to !== toolContext?.currentMessagingTarget &&
to !== toolContext?.currentChannelId) ||
toolContext.replyToMode === "off" ||
((toolContext.replyToMode === "first" || toolContext.replyToMode === "batched") &&
toolContext.hasRepliedRef?.value)
) {
return undefined;
}
return toolContext.currentThreadTs;
},
resolveReplyTransport: ({ replyToId }: { replyToId?: string | null }) => ({
replyToId,
threadId: null,
}),
},
},
source: "test",
},
@@ -52,6 +123,90 @@ describe("extractMessagingToolSend", () => {
plugin: createChannelTestPluginBase({ id: "discord" }),
source: "test",
},
{
pluginId: "mattermost",
plugin: {
...createChannelTestPluginBase({ id: "mattermost" }),
actions: {
extractToolSend: ({ args }: { args: Record<string, unknown> }) => {
if (args.action !== "send" || typeof args.to !== "string") {
return null;
}
const threadId =
typeof args.replyToId === "string"
? args.replyToId
: typeof args.replyTo === "string"
? args.replyTo
: undefined;
const threadSuppressed = args.topLevel === true || args.threadId === null;
return {
to: args.to,
threadId,
threadImplicit: !threadId && !threadSuppressed,
threadSuppressed,
};
},
},
threading: {
resolveAutoThreadId: ({
to,
replyToId,
toolContext,
}: {
to: string;
replyToId?: string | null;
toolContext?: {
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}) => {
if (replyToId) {
const currentMessageId =
typeof toolContext?.currentMessageId === "number"
? String(toolContext.currentMessageId)
: toolContext?.currentMessageId;
if (replyToId !== currentMessageId) {
return replyToId;
}
}
if (to !== toolContext?.currentChannelId || !toolContext.currentThreadTs) {
return undefined;
}
return toolContext.currentThreadTs;
},
resolveReplyTransport: ({
threadId,
replyToId,
}: {
threadId?: string | number | null;
replyToId?: string | null;
}) => {
const resolvedThreadId =
replyToId ?? (threadId != null ? String(threadId) : undefined);
return {
replyToId: resolvedThreadId,
threadId: resolvedThreadId,
};
},
},
},
source: "test",
},
{
pluginId: "numeric-thread",
plugin: {
...createChannelTestPluginBase({ id: "numeric-thread" }),
threading: {
resolveReplyTransport: () => ({
threadId: 42,
}),
},
},
source: "test",
},
]),
);
});
@@ -139,6 +294,18 @@ describe("extractMessagingToolSend", () => {
expect(result?.threadId).toBe("456");
});
it("keeps explicit thread evidence when the message provider is implicit", () => {
const result = extractMessagingToolSend("message", {
action: "send",
to: "channel:123",
threadId: "456",
content: "done",
});
expect(result?.provider).toBe("message");
expect(result?.threadId).toBe("456");
});
it("records when message sends can inherit the current thread", () => {
const result = extractMessagingToolSend("message", {
action: "send",
@@ -150,11 +317,252 @@ describe("extractMessagingToolSend", () => {
expect(result?.threadImplicit).toBe(true);
});
it("captures the active session thread for implicit threaded sends", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "telegram",
to: "123",
content: "done",
},
{
currentChannelId: "telegram:123",
currentThreadId: "456",
replyToMode: "all",
},
);
expect(result?.threadImplicit).toBe(true);
expect(result?.threadId).toBe("456");
});
it("captures the active Slack DM thread through its routable target", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "slack",
to: "user:U123",
content: "done",
},
{
currentChannelId: "D123",
currentMessagingTarget: "user:u123",
currentThreadId: "171.222",
replyToMode: "all",
},
);
expect(result).toMatchObject({
provider: "slack",
to: "user:u123",
threadId: "171.222",
threadImplicit: true,
});
});
it("does not attach the ambient thread to an explicit topic target", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "telegram",
to: "-1001:topic:99",
content: "done",
},
{
currentChannelId: "telegram:-1001:topic:77",
currentThreadId: "77",
},
);
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBeUndefined();
});
it("does not attach the ambient thread when reply mode disables auto-threading", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "slack",
to: "channel:C1",
content: "done",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
replyToMode: "off",
},
);
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBeUndefined();
});
it("defaults implicit threaded sends to all mode when reply mode is omitted", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "slack",
to: "channel:C1",
content: "done",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
},
);
expect(result?.threadImplicit).toBe(true);
expect(result?.threadId).toBe("171.222");
});
it("records an explicit Slack replyTo as the destination thread", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "slack",
to: "channel:C1",
replyTo: "999.000",
content: "done",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
replyToMode: "all",
},
);
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBe("999.000");
});
it("uses Slack transport precedence when threadId and replyTo are both present", () => {
const result = extractMessagingToolSend("message", {
action: "send",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
replyTo: "999.000",
content: "done",
});
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBe("999.000");
});
it("keeps plugin-action thread precedence outside normal sends", () => {
const result = extractMessagingToolSend("message", {
action: "upload-file",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
replyTo: "999.000",
path: "/tmp/report.pdf",
});
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBe("111.000");
});
it("records a plugin-dispatched upload reply target", () => {
const result = extractMessagingToolSend("message", {
action: "upload-file",
provider: "slack",
to: "channel:C1",
replyTo: "999.000",
path: "/tmp/report.pdf",
});
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBe("999.000");
});
it("records a plugin-dispatched upload reply target with the target alias", () => {
const result = extractMessagingToolSend("message", {
action: "upload-file",
provider: "slack",
target: "channel:C1",
replyTo: "999.000",
path: "/tmp/report.pdf",
});
expect(result?.to).toBe("channel:c1");
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBe("999.000");
});
it("does not treat a Discord replyTo as a destination thread", () => {
const result = extractMessagingToolSend("message", {
action: "send",
provider: "discord",
to: "channel:123",
replyTo: "native-message-1",
content: "done",
});
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBeUndefined();
});
it("records a Mattermost replyTo as the destination thread", () => {
const result = extractMessagingToolSend("message", {
action: "send",
provider: "mattermost",
to: "channel:123",
replyTo: "post-1",
content: "done",
});
expect(result?.threadId).toBe("post-1");
});
it("captures the active Mattermost root for implicit sends", () => {
const result = extractMessagingToolSend(
"message",
{
action: "send",
provider: "mattermost",
to: "channel:123",
content: "done",
},
{
currentChannelId: "channel:123",
currentThreadId: "root-1",
currentMessageId: "child-1",
replyToMode: "off",
},
);
expect(result).toMatchObject({
provider: "mattermost",
to: "channel:123",
threadId: "root-1",
threadImplicit: true,
});
});
it("preserves numeric thread ids returned by provider transport resolution", () => {
const result = extractMessagingToolSend("message", {
action: "send",
provider: "numeric-thread",
to: "channel:123",
replyTo: "post-1",
content: "done",
});
expect(result?.threadId).toBe("42");
});
it("keeps provider-tool extracted thread id evidence", () => {
const result = extractMessagingToolSend("slack", {
action: "sendMessage",
to: " Channel:C1 ",
threadId: "171.222",
threadTs: "171.222",
accountId: "bot-a",
content: "done",
});
@@ -168,6 +576,138 @@ describe("extractMessagingToolSend", () => {
});
});
it("captures the active thread for native provider sends", () => {
const result = extractMessagingToolSend(
"slack",
{
action: "sendMessage",
to: "Channel:C1",
content: "done",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
replyToMode: "all",
},
);
expect(result).toMatchObject({
provider: "slack",
to: "channel:c1",
threadId: "171.222",
threadImplicit: true,
});
});
it.each([
{ name: "missing reply mode", options: { currentThreadId: "171.222" } },
{
name: "single-use mode without reply state",
options: { currentThreadId: "171.222", replyToMode: "first" as const },
},
])("does not infer native provider threads with $name", ({ options }) => {
const result = extractMessagingToolSend(
"slack",
{
action: "sendMessage",
to: "Channel:C1",
content: "done",
},
{
currentChannelId: "channel:c1",
...options,
},
);
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBeUndefined();
});
it("infers a native first-mode thread when reply state is available", () => {
const hasRepliedRef = { value: false };
const result = extractMessagingToolSend(
"slack",
{
action: "sendMessage",
to: "Channel:C1",
content: "done",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
replyToMode: "first",
hasRepliedRef,
},
);
expect(result?.threadImplicit).toBe(true);
expect(result?.threadId).toBe("171.222");
expect(hasRepliedRef.value).toBe(false);
});
it("captures the active thread for native provider uploads", () => {
const result = extractMessagingToolSend(
"slack",
{
action: "uploadFile",
to: "Channel:C1",
filePath: "/tmp/report.png",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
replyToMode: "all",
},
);
expect(result).toMatchObject({
provider: "slack",
to: "channel:c1",
threadId: "171.222",
threadImplicit: true,
});
});
it("does not infer ambient threads for native providers that do not opt in", () => {
const result = extractMessagingToolSend(
"telegram",
{
action: "sendMessage",
to: "123",
content: "done",
},
{
currentChannelId: "telegram:123",
currentThreadId: "456",
replyToMode: "all",
},
);
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBeUndefined();
});
it("records native provider sends that suppress ambient threading", () => {
const result = extractMessagingToolSend(
"slack",
{
action: "sendMessage",
to: "Channel:C1",
topLevel: true,
content: "done",
},
{
currentChannelId: "channel:c1",
currentThreadId: "171.222",
replyToMode: "all",
},
);
expect(result?.threadSuppressed).toBe(true);
expect(result?.threadImplicit).toBeUndefined();
expect(result?.threadId).toBeUndefined();
});
it("records when message sends explicitly suppress implicit thread delivery", () => {
const topLevel = extractMessagingToolSend("message", {
action: "send",

View File

@@ -5,10 +5,12 @@ import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/rec
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
normalizeOptionalStringifiedId,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js";
import { truncateUtf16Safe } from "../utils.js";
@@ -638,9 +640,92 @@ function resolveMessageToolTarget(args: Record<string, unknown>): string | undef
return readStringValue(args.target);
}
function resolveMessagingToolThreadEvidence(params: {
providerId: string;
to: string;
accountId?: string;
threadId?: string;
replyToId?: string;
allowImplicitThread: boolean;
threadSuppressed: boolean;
options?: {
config?: OpenClawConfig;
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadId?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}): Pick<MessagingToolSend, "threadId" | "threadImplicit" | "threadSuppressed"> {
const threading = getChannelPlugin(params.providerId)?.threading;
const autoThreadResolver = params.allowImplicitThread
? threading?.resolveAutoThreadId
: undefined;
const replyTransport = params.replyToId
? threading?.resolveReplyTransport?.({
cfg: params.options?.config ?? {},
accountId: params.accountId,
threadId: params.threadId,
replyToId: params.replyToId,
})
: undefined;
const transportThreadId = normalizeOptionalStringifiedId(replyTransport?.threadId);
const replyToThreadId =
replyTransport?.threadId === null
? normalizeOptionalString(replyTransport.replyToId)
: undefined;
const explicitThreadId = transportThreadId ?? replyToThreadId ?? params.threadId;
const currentChannelId = normalizeOptionalString(params.options?.currentChannelId);
const currentMessagingTarget = normalizeOptionalString(params.options?.currentMessagingTarget);
const currentThreadId = normalizeOptionalString(params.options?.currentThreadId);
const replyToMode = params.options?.replyToMode ?? (currentThreadId ? "all" : undefined);
const canResolveCurrentThread = Boolean(
(currentChannelId || currentMessagingTarget) && currentThreadId,
);
const resolvedCurrentThreadId =
!explicitThreadId && !params.threadSuppressed && autoThreadResolver && canResolveCurrentThread
? autoThreadResolver({
cfg: params.options?.config ?? {},
accountId: params.accountId,
to: params.to,
replyToId: params.replyToId,
toolContext: {
currentChannelId,
currentMessagingTarget,
currentThreadTs: currentThreadId,
currentMessageId: params.options?.currentMessageId,
replyToMode,
hasRepliedRef: params.options?.hasRepliedRef,
},
})
: undefined;
const threadImplicit =
!explicitThreadId &&
!params.threadSuppressed &&
Boolean(autoThreadResolver) &&
(!canResolveCurrentThread || Boolean(resolvedCurrentThreadId));
return {
...((explicitThreadId ?? resolvedCurrentThreadId)
? { threadId: explicitThreadId ?? resolvedCurrentThreadId }
: {}),
...(threadImplicit ? { threadImplicit: true } : {}),
...(params.threadSuppressed ? { threadSuppressed: true } : {}),
};
}
export function extractMessagingToolSend(
toolName: string,
args: Record<string, unknown>,
options?: {
config?: OpenClawConfig;
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadId?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
},
): MessagingToolSend | undefined {
// Provider docking: new provider tools must implement plugin.actions.extractToolSend.
const action = normalizeOptionalString(args.action) ?? "";
@@ -659,24 +744,48 @@ export function extractMessagingToolSend(
const providerId = providerHint ? normalizeChannelId(providerHint) : null;
const provider = providerId ?? normalizeOptionalLowercaseString(providerHint) ?? "message";
const to = normalizeTargetForProvider(provider, toRaw);
const threadId = normalizeOptionalString(args.threadId);
const threadSuppressed = args.topLevel === true || args.threadId === null;
const threadImplicit =
!threadId &&
!threadSuppressed &&
Boolean(providerId && getChannelPlugin(providerId)?.threading?.resolveAutoThreadId);
const pluginExtractionArgs = readStringValue(args.to) ? args : { ...args, to: toRaw };
const pluginExtracted = providerId
? getChannelPlugin(providerId)?.actions?.extractToolSend?.({ args: pluginExtractionArgs })
: null;
const resolvedAccountId = normalizeOptionalString(pluginExtracted?.accountId) ?? accountId;
const threadId =
normalizeOptionalString(pluginExtracted?.threadId) ?? normalizeOptionalString(args.threadId);
const replyToId = normalizeOptionalString(args.replyTo);
// Normal sends use prepared core delivery, where provider transport owns
// reply/thread precedence. Other send-like actions use plugin dispatch.
const outboundReplyToId = action === "send" ? replyToId : undefined;
const threadSuppressed =
pluginExtracted?.threadSuppressed === true ||
args.topLevel === true ||
args.threadId === null;
return to
? {
tool: toolName,
provider,
accountId,
accountId: resolvedAccountId,
to,
...(threadId ? { threadId } : {}),
...(threadImplicit ? { threadImplicit: true } : {}),
...(threadSuppressed ? { threadSuppressed: true } : {}),
...(providerId
? resolveMessagingToolThreadEvidence({
providerId,
to,
accountId: resolvedAccountId,
threadId,
replyToId: outboundReplyToId,
allowImplicitThread: pluginExtracted
? pluginExtracted.threadImplicit === true
: true,
threadSuppressed,
options,
})
: {
...(threadId ? { threadId } : {}),
...(threadSuppressed ? { threadSuppressed: true } : {}),
}),
}
: undefined;
}
const providerId = normalizeChannelId(toolName);
if (!providerId) {
return undefined;
@@ -688,13 +797,61 @@ export function extractMessagingToolSend(
}
const to = normalizeTargetForProvider(providerId, extracted.to);
const threadId = normalizeOptionalString(extracted.threadId);
const threadSuppressed = extracted.threadSuppressed === true;
const extractedAccountId = normalizeOptionalString(extracted.accountId) ?? accountId;
const nativeReplyToMode = options?.replyToMode;
const nativeSingleUseMode = nativeReplyToMode === "first" || nativeReplyToMode === "batched";
const canResolveNativeImplicitThread =
extracted.threadImplicit === true &&
nativeReplyToMode !== undefined &&
(!nativeSingleUseMode || options?.hasRepliedRef !== undefined);
return to
? {
tool: toolName,
provider: providerId,
accountId: extracted.accountId ?? accountId,
accountId: extractedAccountId,
to,
...(threadId ? { threadId } : {}),
...resolveMessagingToolThreadEvidence({
providerId,
to,
accountId: extractedAccountId,
threadId,
allowImplicitThread: canResolveNativeImplicitThread,
threadSuppressed,
options,
}),
}
: undefined;
}
/** Reconciles pending send evidence with the provider's successful action result. */
export function extractMessagingToolSendResult(
pending: MessagingToolSend,
result: unknown,
): MessagingToolSend {
const providerId = normalizeChannelId(pending.provider);
const extracted = providerId
? getChannelPlugin(providerId)?.actions?.extractToolSendResult?.({
result,
send: {
to: pending.to ?? "",
accountId: pending.accountId,
threadId: pending.threadId,
threadImplicit: pending.threadImplicit,
threadSuppressed: pending.threadSuppressed,
},
})
: null;
if (!extracted?.to) {
return pending;
}
return {
...pending,
...extracted,
accountId: normalizeOptionalString(extracted.accountId) ?? pending.accountId,
to: normalizeTargetForProvider(providerId ?? pending.provider, extracted.to),
threadId: normalizeOptionalString(extracted.threadId),
threadImplicit: extracted.threadImplicit === true ? true : undefined,
threadSuppressed: extracted.threadSuppressed === true ? true : undefined,
};
}

View File

@@ -29,6 +29,7 @@ import {
createEmbeddedRunReplayState,
mergeEmbeddedRunReplayState,
} from "./embedded-agent-runner/replay-state.js";
import { consumeEmbeddedToolSendReceipt } from "./embedded-agent-runner/tool-send-receipts.js";
import type { EmbeddedRunLivenessState } from "./embedded-agent-runner/types.js";
import { createEmbeddedAgentSessionEventHandler } from "./embedded-agent-subscribe.handlers.js";
import {
@@ -1262,6 +1263,8 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
resetForCompactionRetry,
finalizeAssistantTexts,
trimMessagingToolSent,
consumeToolSendReceipt: (toolCallId) =>
consumeEmbeddedToolSendReceipt(params.session.sessionManager, toolCallId),
ensureCompactionPromise,
noteCompactionRetry,
resolveCompactionRetry,

View File

@@ -95,6 +95,18 @@ export type SubscribeEmbeddedAgentSessionParams = {
silentExpected?: boolean;
config?: OpenClawConfig;
sessionKey?: string;
/** Current transport channel resolved for this run. */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the native channel ID. */
currentMessagingTarget?: string;
/** Current transport thread resolved for this run. */
currentThreadId?: string;
/** Current inbound message id used to distinguish child replies from explicit roots. */
currentMessageId?: string | number;
/** Reply mode used by transport auto-threading. */
replyToMode?: "off" | "first" | "all" | "batched";
/** Shared one-shot reply state used by first/batched modes. */
hasRepliedRef?: { value: boolean };
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
/** Agent identity for hook context — resolved from session config in attempt.ts. */

View File

@@ -25,6 +25,7 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
pluginToolAllowlist?: string[];
pluginToolDenylist?: string[];
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
sandboxRoot?: string;

View File

@@ -111,6 +111,8 @@ export function createOpenClawTools(
pluginToolDenylist?: string[];
/** Current channel ID for auto-threading. */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the native channel ID. */
currentMessagingTarget?: string;
/** Current thread timestamp for auto-threading. */
currentThreadTs?: string;
/** Current inbound message id for action fallbacks. */
@@ -343,6 +345,7 @@ export function createOpenClawTools(
sessionId: options?.sessionId,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentMessagingTarget: options?.currentMessagingTarget,
currentChannelProvider: options?.agentChannel,
currentThreadTs: options?.currentThreadTs,
currentInboundAudio: options?.currentInboundAudio,

View File

@@ -126,6 +126,7 @@ type RunMessageActionInput = {
inboundAudio?: boolean;
toolContext?: {
currentChannelId?: string;
currentMessagingTarget?: string;
currentChannelProvider?: string;
currentThreadTs?: string;
replyToMode?: string;
@@ -1195,6 +1196,47 @@ describe("message tool agent routing", () => {
expect(call?.toolContext?.currentThreadTs).toBe("111.222");
expect(call?.toolContext?.replyToMode).toBe("all");
});
it("forwards the routable target through createOpenClawTools to the message tool", async () => {
mockSendResult({ channel: "slack", to: "user:U123" });
const plugin = createChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
blurb: "test",
actions: ["send"],
});
setActivePluginRegistry(createTestRegistry([{ pluginId: "slack", source: "test", plugin }]));
const tool = createOpenClawTools({
config: {} as never,
agentChannel: "slack",
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
currentThreadTs: "111.222",
replyToMode: "all",
}).find((candidate) => candidate.name === "message");
if (!tool) {
throw new Error("message tool not found");
}
await tool.execute("1", {
action: "send",
channel: "slack",
target: "user:U123",
message: "stay in DM thread",
});
const call = firstRunMessageActionInput();
expect(call?.toolContext).toMatchObject({
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
currentChannelProvider: "slack",
currentThreadTs: "111.222",
replyToMode: "all",
});
});
});
describe("message tool explicit target guard", () => {

View File

@@ -827,6 +827,7 @@ type MessageToolOptions = {
resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway;
runMessageAction?: typeof runMessageAction;
currentChannelId?: string;
currentMessagingTarget?: string;
currentChannelProvider?: string;
currentThreadTs?: string;
agentThreadId?: string | number;
@@ -925,6 +926,7 @@ function inferDeliveryFromSessionKey(
function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): {
accountId?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
currentChannelProvider?: string;
currentThreadTs?: string;
} {
@@ -939,12 +941,17 @@ function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): {
Boolean(sessionDelivery?.to);
if (!preferSessionDeliveryContext) {
return { currentChannelProvider, currentChannelId };
return {
currentChannelProvider,
currentChannelId,
currentMessagingTarget: options?.currentMessagingTarget,
};
}
return {
accountId: sessionDelivery?.accountId,
currentChannelProvider: sessionDeliveryChannel,
currentChannelId: sessionDelivery?.to,
currentMessagingTarget: sessionDelivery?.to,
currentThreadTs: sessionDelivery?.threadId,
};
}
@@ -1356,6 +1363,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const toolContext =
effectiveCurrentChannel.currentChannelId ||
effectiveCurrentChannel.currentChannelProvider ||
effectiveCurrentChannel.currentMessagingTarget ||
currentThreadTs ||
hasCurrentMessageId ||
replyToMode ||
@@ -1363,6 +1371,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
options?.sameChannelThreadRequired
? {
currentChannelId: effectiveCurrentChannel.currentChannelId,
currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget,
currentChannelProvider: effectiveCurrentChannel.currentChannelProvider,
currentThreadTs,
currentMessageId: options?.currentMessageId,

View File

@@ -1,4 +1,5 @@
/** Reply payload contracts and metadata helpers shared by dispatch and channel renderers. */
import type { ReplyToMode } from "../config/types.base.js";
import type {
InteractiveReply,
MessagePresentation,
@@ -67,6 +68,12 @@ export type ReplyPayloadTtsSupplement = {
visibleTextAlreadyDelivered?: boolean;
};
/** Reply policy facts that provider adapters use to resolve the final transport route. */
export type ReplyDeliveryContext = {
chatType?: "direct" | "group" | "channel" | null;
replyToMode: ReplyToMode;
};
export const REPLY_MEDIA_FAILURE_WARNING = "⚠️ Media failed.";
/** Appends the standard media failure warning without duplicating it. */
@@ -158,6 +165,15 @@ export type ReplyPayloadMetadata = {
assistantMessageIndex?: number;
/** The runtime owns the transcript decision for this assistant payload. */
assistantTranscriptOwned?: boolean;
/** replyToId existed before reply threading could inject an implicit target. */
replyToIdExplicit?: boolean;
/** Canonical reply policy used by both message-tool dedupe and final delivery routing. */
replyDelivery?: ReplyDeliveryContext;
/** Route identity that produced replyDelivery, used to reject stale cross-route policy. */
replyDeliverySource?: {
channel: string;
accountId?: string;
};
/**
* Internal OpenClaw notices generated after a runtime/provider failure are
* not assistant source replies. Dispatch may deliver them even when normal

View File

@@ -175,7 +175,7 @@ describe("runReplyAgent runtime config", () => {
enqueueFollowupRunMock.mockReset();
resolveQueuedReplyExecutionConfigMock.mockResolvedValue(freshCfg);
resolveReplyToModeMock.mockReturnValue("default");
resolveReplyToModeMock.mockReturnValue("all");
createReplyToModeFilterForChannelMock.mockReturnValue((payload: unknown) => payload);
createReplyMediaPathNormalizerMock.mockReturnValue((payload: unknown) => payload);
runPreflightCompactionIfNeededMock.mockRejectedValue(sentinelError);
@@ -284,6 +284,14 @@ describe("runReplyAgent runtime config", () => {
});
expect(getReplyPayloadMetadata(result)).toEqual({
deliverDespiteSourceReplySuppression: true,
replyDelivery: {
chatType: "direct",
replyToMode: "all",
},
replyDeliverySource: {
channel: "telegram",
accountId: "default",
},
});
});

View File

@@ -230,9 +230,31 @@ vi.mock("./agent-runner-utils.js", () => ({
buildEmbeddedRunExecutionParams: (params: {
provider: string;
model: string;
run: { provider?: string; authProfileId?: string; authProfileIdSource?: "auto" | "user" };
run: {
provider?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
agentAccountId?: string;
chatType?: string;
};
replyRoute?: {
originatingChannel?: string;
originatingTo?: string;
originatingAccountId?: string;
originatingChatType?: string;
};
sessionCtx: { AccountId?: string; ChatType?: string };
}) => ({
embeddedContext: {},
embeddedContext: {
messageProvider: params.replyRoute?.originatingChannel,
messageTo: params.replyRoute?.originatingTo,
agentAccountId:
params.replyRoute?.originatingAccountId ??
params.sessionCtx.AccountId ??
params.run.agentAccountId,
chatType:
params.replyRoute?.originatingChatType ?? params.sessionCtx.ChatType ?? params.run.chatType,
},
senderContext: {},
runBaseParams: {
provider: params.provider,
@@ -387,8 +409,10 @@ function createMockReplyOperation(): {
updateSessionId: updateSessionIdMock,
attachBackend: vi.fn(),
detachBackend: vi.fn(),
retainFailureUntilComplete: vi.fn(),
complete: vi.fn(),
completeThen: vi.fn((afterClear: () => void) => afterClear()),
completeWithAfterClearBarrier: vi.fn(),
fail: failMock,
abortByUser: vi.fn(),
abortForRestart: vi.fn(),
@@ -1253,6 +1277,36 @@ describe("runAgentTurnWithFallback", () => {
expect(embeddedCall.abortSignal).toBe(replyOperation.abortSignal);
});
it("passes the hydrated run account to embedded execution", async () => {
state.runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {},
});
const followupRun = createFollowupRun();
followupRun.run.agentAccountId = "work";
followupRun.originatingChannel = "slack";
followupRun.originatingTo = "user:U1";
followupRun.originatingAccountId = "work";
followupRun.originatingChatType = "direct";
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
followupRun,
sessionCtx: {
Provider: "cron-event",
},
}),
);
expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", {
messageProvider: "slack",
messageTo: "user:U1",
agentAccountId: "work",
chatType: "direct",
});
});
it("registers run ownership before asynchronous image preflight", async () => {
const agentEvents = await import("../../infra/agent-events.js");
const registerAgentRunContext = vi.mocked(agentEvents.registerAgentRunContext);

View File

@@ -2316,6 +2316,7 @@ export async function runAgentTurnWithFallback(params: {
const { embeddedContext, senderContext, runBaseParams } =
buildEmbeddedRunExecutionParams({
run: candidateRun,
replyRoute: params.followupRun,
sessionCtx: params.sessionCtx,
hasRepliedRef: params.opts?.hasRepliedRef,
provider,

View File

@@ -52,10 +52,12 @@ function createReplyOperation(): TestReplyOperation {
updateSessionId: vi.fn<ReplyOperation["updateSessionId"]>(),
attachBackend: vi.fn(),
detachBackend: vi.fn(),
retainFailureUntilComplete: vi.fn(),
complete: vi.fn(),
completeThen: vi.fn((afterClear: () => void) => {
afterClear();
}),
completeWithAfterClearBarrier: vi.fn(),
fail: vi.fn(),
abortByUser: vi.fn(),
abortForRestart: vi.fn(),

View File

@@ -1295,6 +1295,7 @@ export async function runMemoryFlushIfNeeded(params: {
run: async (provider, model, runOptions) => {
const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams({
run: params.followupRun.run,
replyRoute: params.followupRun,
sessionCtx: params.sessionCtx,
hasRepliedRef: params.opts?.hasRepliedRef,
provider,

View File

@@ -1,7 +1,11 @@
// Tests reply payload construction and metadata propagation from agent runs.
import { beforeEach, describe, expect, it } from "vitest";
import type { ChannelThreadingAdapter } from "../../channels/plugins/types.public.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import {
getReplyPayloadMetadata,
markReplyPayloadForSourceSuppressionDelivery,
@@ -18,6 +22,10 @@ const baseParams = {
replyToMode: "off" as const,
};
type ResolveReplyTransportParams = Parameters<
NonNullable<ChannelThreadingAdapter["resolveReplyTransport"]>
>[0];
function expectFields(value: unknown, expected: Record<string, unknown>): void {
if (!value || typeof value !== "object") {
throw new Error("expected fields object");
@@ -46,6 +54,75 @@ async function expectSameTargetRepliesDelivered(params: { provider: string; to:
describe("buildReplyPayloads media filter integration", () => {
beforeEach(() => {
resetPluginRuntimeStateForTest();
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
plugin: {
...createChannelTestPluginBase({ id: "slack" }),
threading: {
resolveReplyTransport: ({
threadId,
replyToId,
replyDelivery,
}: ResolveReplyTransportParams) => ({
replyToId:
replyDelivery?.replyToMode === "off"
? threadId != null
? String(threadId)
: undefined
: (replyToId ?? (threadId != null ? String(threadId) : undefined)),
threadId: null,
}),
},
},
source: "test",
},
{
pluginId: "mattermost",
plugin: {
...createChannelTestPluginBase({ id: "mattermost" }),
threading: {
resolveReplyTransport: ({
threadId,
replyToId,
replyToIsExplicit,
replyDelivery,
}: ResolveReplyTransportParams) => {
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyToIsExplicit
? (replyToId ?? ambientThreadId)
: replyDelivery
? (ambientThreadId ?? replyToId ?? undefined)
: (replyToId ?? ambientThreadId);
return {
replyToId: resolvedThreadId,
threadId: resolvedThreadId ?? null,
};
},
},
},
source: "test",
},
]),
);
});
it("records the reply policy used by dedupe and final delivery", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello" }],
replyToMode: "first",
originatingChatType: "dm",
});
expect(getReplyPayloadMetadata(replyPayloads[0])?.replyDelivery).toEqual({
chatType: "direct",
replyToMode: "first",
});
});
it("strips legacy bracket tool blocks from heartbeat replies", async () => {
@@ -398,6 +475,332 @@ describe("buildReplyPayloads media filter integration", () => {
expect(replyPayloads).toHaveLength(0);
});
it("keeps same-channel final text when the message tool sent it to another thread", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "thread reply" }],
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "222.000",
messagingToolSentTexts: ["thread reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "thread reply",
},
],
});
expect(replyPayloads).toHaveLength(1);
expect(replyPayloads[0]?.text).toBe("thread reply");
});
it("dedupes a top-level Slack reply that starts the same implicit thread", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "thread reply" }],
replyToMode: "first",
replyToChannel: "slack",
currentMessageId: "111.000",
messageProvider: "slack",
originatingTo: "channel:C1",
messagingToolSentTexts: ["thread reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "thread reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("dedupes an existing Slack thread by its root instead of the current child message", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "thread reply" }],
replyToMode: "all",
replyToChannel: "slack",
currentMessageId: "111.222",
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "111.000",
messagingToolSentTexts: ["thread reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "thread reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("keeps an explicit Slack reply when tool evidence only matches the ambient thread", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "thread reply", replyToId: "999.000" }],
replyToMode: "all",
replyToChannel: "slack",
currentMessageId: "111.222",
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "111.000",
messagingToolSentTexts: ["thread reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "thread reply",
},
],
});
expect(replyPayloads).toHaveLength(1);
});
it("dedupes an explicit Slack reply against tool evidence for that reply thread", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "thread reply", replyToId: "999.000", replyToTag: true }],
replyToMode: "all",
replyToChannel: "slack",
currentMessageId: "111.222",
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "111.000",
messagingToolSentTexts: ["thread reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "999.000",
text: "thread reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("keeps an unthreaded later Slack payload when only the first payload starts a thread", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "intro" }, { text: "result" }],
replyToMode: "first",
replyToChannel: "slack",
currentMessageId: "111.000",
messageProvider: "slack",
originatingTo: "channel:C1",
messagingToolSentTexts: ["result"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "result",
},
],
});
expect(replyPayloads.map((payload) => payload.text)).toEqual(["intro", "result"]);
});
it("does not treat a Discord native reply id as a thread route", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply" }],
replyToMode: "all",
replyToChannel: "discord",
currentMessageId: "native-message-1",
messageProvider: "discord",
originatingTo: "channel:C1",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "discord",
provider: "discord",
to: "channel:C1",
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("dedupes an explicit Mattermost DM reply against its top-level delivery route", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply", replyToId: "post-1", replyToTag: true }],
replyToMode: "off",
replyToChannel: "mattermost",
messageProvider: "mattermost",
originatingChatType: "direct",
originatingTo: "user:U1",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "mattermost",
provider: "mattermost",
to: "user:U1",
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("dedupes an implicit Mattermost send in the active thread", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply" }],
replyToMode: "all",
replyToChannel: "mattermost",
currentMessageId: "child-post",
messageProvider: "mattermost",
originatingTo: "channel:C1",
originatingThreadId: "root-post",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "mattermost",
provider: "mattermost",
to: "channel:C1",
threadId: "root-post",
threadImplicit: true,
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("does not dedupe an explicit Mattermost reply to another thread root", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply", replyToId: "other-root", replyToTag: true }],
replyToMode: "all",
replyToChannel: "mattermost",
messageProvider: "mattermost",
originatingChatType: "channel",
originatingTo: "channel:C1",
originatingThreadId: "root-post",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "mattermost",
provider: "mattermost",
to: "channel:C1",
threadId: "root-post",
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(1);
});
it("dedupes an explicit Mattermost reply to the same thread root", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply", replyToId: "root-post", replyToTag: true }],
replyToMode: "all",
replyToChannel: "mattermost",
messageProvider: "mattermost",
originatingChatType: "channel",
originatingTo: "channel:C1",
originatingThreadId: "ambient-root",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "mattermost",
provider: "mattermost",
to: "channel:C1",
threadId: "root-post",
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("dedupes an off-mode explicit Slack reply against its top-level delivery route", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply", replyToId: "111.000", replyToTag: true }],
replyToMode: "off",
replyToChannel: "slack",
messageProvider: "slack",
originatingTo: "channel:C1",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("dedupes an off-mode explicit Slack reply against the ambient thread route", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
config: {},
payloads: [{ text: "same reply", replyToId: "999.000", replyToTag: true }],
replyToMode: "off",
replyToChannel: "slack",
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "111.000",
messagingToolSentTexts: ["same reply"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "same reply",
},
],
});
expect(replyPayloads).toHaveLength(0);
});
it("does not dedupe short commentary that appears inside a longer same-target message", async () => {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,

View File

@@ -3,6 +3,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { stripLegacyBracketToolCallBlocks } from "../../shared/text/assistant-visible-text.js";
@@ -25,6 +26,7 @@ import {
} from "./origin-routing.js";
import { normalizeReplyPayloadDirectives } from "./reply-delivery.js";
import { applyReplyThreading, isRenderablePayload } from "./reply-payloads-base.js";
import { createReplyDeliveryContext } from "./reply-threading.js";
const replyPayloadsDedupeRuntimeLoader = createLazyImportLoader(
() => import("./reply-payloads-dedupe.runtime.js"),
@@ -159,6 +161,7 @@ function copyPayloadWithSanitizedText(
/** Builds final outbound payloads from agent output and message-tool delivery evidence. */
export async function buildReplyPayloads(params: {
config?: OpenClawConfig;
payloads: ReplyPayload[];
isHeartbeat: boolean;
didLogHeartbeatStrip: boolean;
@@ -178,7 +181,9 @@ export async function buildReplyPayloads(params: {
messagingToolSentMediaUrls?: string[];
messagingToolSentTargets?: MessagingToolSend[];
originatingChannel?: OriginatingChannelType;
originatingChatType?: string | null;
originatingTo?: string;
originatingThreadId?: string | number;
accountId?: string;
extractMarkdownImages?: boolean;
normalizeMediaPaths?: (payload: ReplyPayload) => Promise<ReplyPayload>;
@@ -214,6 +219,20 @@ export async function buildReplyPayloads(params: {
}
}
const messageProvider = resolveOriginMessageProvider({
originatingChannel: params.originatingChannel,
provider: params.messageProvider,
});
const accountId = resolveOriginAccountId({
originatingAccountId: params.accountId,
});
const replyDelivery = createReplyDeliveryContext(params.replyToMode, params.originatingChatType);
const replyDeliverySource = messageProvider
? {
channel: messageProvider,
...(accountId ? { accountId } : {}),
}
: undefined;
const replyTaggedPayloadCandidates = await Promise.all(
applyReplyThreading({
payloads: sanitizedPayloads,
@@ -237,7 +256,10 @@ export async function buildReplyPayloads(params: {
if (parsed.isSilent) {
mediaNormalizedPayload.text = undefined;
}
return mediaNormalizedPayload;
return setReplyPayloadMetadata(mediaNormalizedPayload, {
replyDelivery,
...(replyDeliverySource ? { replyDeliverySource } : {}),
});
}),
);
const replyTaggedPayloads: ReplyPayload[] = [];
@@ -269,69 +291,57 @@ export async function buildReplyPayloads(params: {
messagingToolSentTexts.length > 0 ||
(params.messagingToolSentMediaUrls?.length ?? 0) > 0 ||
messagingToolSentTargets.length > 0;
const dedupeRuntime = shouldCheckMessagingToolDedupe
? await loadReplyPayloadsDedupeRuntime()
: null;
const messagingToolPayloadDedupe = dedupeRuntime?.resolveMessagingToolPayloadDedupe({
messageProvider: resolveOriginMessageProvider({
originatingChannel: params.originatingChannel,
provider: params.messageProvider,
}),
messagingToolSentTargets,
originatingTo: resolveOriginMessageTo({
originatingTo: params.originatingTo,
}),
accountId: resolveOriginAccountId({
originatingAccountId: params.accountId,
}),
}) ?? {
shouldDedupePayloads: shouldCheckMessagingToolDedupe && messagingToolSentTargets.length === 0,
matchingRoute: false,
routeSentTexts: [],
routeSentMediaUrls: [],
useGlobalSentTextEvidenceFallback: false,
useGlobalSentMediaUrlEvidenceFallback: false,
};
const dedupeMessagingToolPayloads = messagingToolPayloadDedupe.shouldDedupePayloads;
const sentMediaUrlFallback = params.messagingToolSentMediaUrls ?? [];
const shouldUseGlobalSentMediaUrlEvidence =
messagingToolPayloadDedupe.matchingRoute &&
messagingToolPayloadDedupe.routeSentMediaUrls.length === 0 &&
messagingToolPayloadDedupe.useGlobalSentMediaUrlEvidenceFallback;
const shouldUseGlobalSentTextEvidence =
messagingToolPayloadDedupe.matchingRoute &&
messagingToolPayloadDedupe.routeSentTexts.length === 0 &&
messagingToolPayloadDedupe.useGlobalSentTextEvidenceFallback;
const sentMediaUrlsForDedupe = messagingToolPayloadDedupe.matchingRoute
? shouldUseGlobalSentMediaUrlEvidence
? sentMediaUrlFallback
: messagingToolPayloadDedupe.routeSentMediaUrls
: sentMediaUrlFallback;
const sentTextsForDedupe = messagingToolPayloadDedupe.matchingRoute
? shouldUseGlobalSentTextEvidence
? messagingToolSentTexts
: messagingToolPayloadDedupe.routeSentTexts
: messagingToolSentTexts;
const messagingToolSentMediaUrls = dedupeMessagingToolPayloads
? await normalizeSentMediaUrlsForDedupe({
sentMediaUrls: sentMediaUrlsForDedupe,
let dedupedPayloads = silentFilteredPayloads;
if (shouldCheckMessagingToolDedupe) {
const dedupeRuntime = await loadReplyPayloadsDedupeRuntime();
const originatingTo = resolveOriginMessageTo({
originatingTo: params.originatingTo,
});
dedupedPayloads = [];
for (const payload of silentFilteredPayloads) {
const decision = dedupeRuntime.resolveMessagingToolPayloadDedupe({
config: params.config,
messageProvider,
messagingToolSentTargets,
originatingTo,
originatingThreadId: params.originatingThreadId,
replyToId: payload.replyToId,
replyToIsExplicit: Boolean(
getReplyPayloadMetadata(payload)?.replyToIdExplicit ||
payload.replyToTag ||
payload.replyToCurrent,
),
replyDelivery: getReplyPayloadMetadata(payload)?.replyDelivery,
accountId,
});
if (!decision.shouldDedupePayloads) {
dedupedPayloads.push(payload);
continue;
}
const sentMediaUrls =
decision.matchingRoute && !decision.useGlobalSentMediaUrlEvidenceFallback
? decision.routeSentMediaUrls
: sentMediaUrlFallback;
const sentTexts =
decision.matchingRoute && !decision.useGlobalSentTextEvidenceFallback
? decision.routeSentTexts
: messagingToolSentTexts;
const normalizedSentMediaUrls = await normalizeSentMediaUrlsForDedupe({
sentMediaUrls,
normalizeMediaPaths: params.normalizeMediaPaths,
})
: sentMediaUrlsForDedupe;
const mediaFilteredPayloads = dedupeMessagingToolPayloads
? (
dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())
).filterMessagingToolMediaDuplicates({
payloads: silentFilteredPayloads,
sentMediaUrls: messagingToolSentMediaUrls,
})
: silentFilteredPayloads;
const dedupedPayloads = dedupeMessagingToolPayloads
? (dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())).filterMessagingToolDuplicates({
payloads: mediaFilteredPayloads,
sentTexts: sentTextsForDedupe,
})
: mediaFilteredPayloads;
});
const mediaFiltered = dedupeRuntime.filterMessagingToolMediaDuplicates({
payloads: [payload],
sentMediaUrls: normalizedSentMediaUrls,
});
const textFiltered = dedupeRuntime.filterMessagingToolDuplicates({
payloads: mediaFiltered,
sentTexts,
});
dedupedPayloads.push(...textFiltered);
}
}
const isDirectlySentBlockPayload = (payload: ReplyPayload) =>
Boolean(params.directlySentBlockKeys?.has(createBlockReplyContentKey(payload)));
const hasDirectlySentText = (payload: ReplyPayload): boolean => {
@@ -450,9 +460,7 @@ export async function buildReplyPayloads(params: {
});
const filteredPayloads =
blockSentMediaUrls.length > 0
? (
dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())
).filterMessagingToolMediaDuplicates({
? (await loadReplyPayloadsDedupeRuntime()).filterMessagingToolMediaDuplicates({
payloads: contentSuppressedPayloads,
sentMediaUrls: blockSentMediaUrls,
})

View File

@@ -284,7 +284,7 @@ describe("agent-runner-utils", () => {
});
it("prefers OriginatingChannel over Provider for messageProvider", () => {
const run = makeRun({ chatType: "group" });
const run = makeRun({ agentAccountId: "work", chatType: "group" });
const resolved = buildEmbeddedRunContexts({
run,
@@ -298,10 +298,54 @@ describe("agent-runner-utils", () => {
});
expect(resolved.embeddedContext.messageProvider).toBe("telegram");
expect(resolved.embeddedContext.agentAccountId).toBe("work");
expect(resolved.embeddedContext.chatType).toBe("group");
expect(resolved.embeddedContext.messageTo).toBe("268300329");
});
it("hydrates the queued route before resolving channel threading policy", () => {
hoisted.getChannelPluginMock.mockReturnValue({
threading: {
buildToolContext: ({
accountId,
context,
}: {
accountId?: string | null;
context: { ChatType?: string; NativeChannelId?: string; To?: string };
}) => ({
currentChannelId: context.NativeChannelId ?? context.To,
currentMessagingTarget: context.To,
replyToMode: accountId === "work" && context.ChatType === "direct" ? "off" : "all",
}),
},
});
const run = makeRun({ agentAccountId: "work", chatType: "direct" });
const resolved = buildEmbeddedRunContexts({
run,
sessionCtx: {
Provider: "cron-event",
NativeChannelId: "D1",
},
replyRoute: {
originatingChannel: "slack",
originatingTo: "user:U1",
originatingAccountId: "work",
originatingChatType: "direct",
},
hasRepliedRef: undefined,
provider: "openai",
});
expect(resolved.embeddedContext.messageProvider).toBe("slack");
expect(resolved.embeddedContext.messageTo).toBe("user:U1");
expect(resolved.embeddedContext.currentChannelId).toBe("D1");
expect(resolved.embeddedContext.currentMessagingTarget).toBe("user:U1");
expect(resolved.embeddedContext.agentAccountId).toBe("work");
expect(resolved.embeddedContext.chatType).toBe("direct");
expect(resolved.embeddedContext.replyToMode).toBe("off");
});
it("carries inbound audio context into embedded message tools", () => {
const run = makeRun();

View File

@@ -39,6 +39,15 @@ import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-r
import type { FollowupRun } from "./queue.js";
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
type EmbeddedReplyRoute = Pick<
FollowupRun,
| "originatingChannel"
| "originatingTo"
| "originatingAccountId"
| "originatingChatType"
| "originatingThreadId"
| "originatingReplyToId"
>;
/** Selects the freshest runtime config usable by queued reply execution. */
export function resolveQueuedReplyRuntimeConfig(config: OpenClawConfig): OpenClawConfig {
@@ -210,35 +219,51 @@ export function buildEmbeddedRunBaseParams(
function buildEmbeddedContextFromTemplate(params: {
run: FollowupRun["run"];
replyRoute?: EmbeddedReplyRoute;
sessionCtx: TemplateContext;
hasRepliedRef: { value: boolean } | undefined;
}) {
const config = params.run.config;
const chatType = normalizeChatType(params.sessionCtx.ChatType) ?? params.run.chatType;
const sessionCtx = {
...params.sessionCtx,
OriginatingChannel:
params.replyRoute?.originatingChannel ?? params.sessionCtx.OriginatingChannel,
OriginatingTo: params.replyRoute?.originatingTo ?? params.sessionCtx.OriginatingTo,
AccountId:
params.replyRoute?.originatingAccountId ??
params.sessionCtx.AccountId ??
params.run.agentAccountId,
ChatType:
normalizeChatType(params.replyRoute?.originatingChatType) ??
normalizeChatType(params.sessionCtx.ChatType) ??
params.run.chatType,
MessageThreadId: params.replyRoute?.originatingThreadId ?? params.sessionCtx.MessageThreadId,
ReplyToId: params.replyRoute?.originatingReplyToId ?? params.sessionCtx.ReplyToId,
};
return {
sessionId: params.run.sessionId,
sessionKey: params.run.sessionKey,
sandboxSessionKey: params.run.runtimePolicySessionKey,
agentId: params.run.agentId,
messageProvider: resolveOriginMessageProvider({
originatingChannel: params.sessionCtx.OriginatingChannel,
provider: params.sessionCtx.Provider,
originatingChannel: sessionCtx.OriginatingChannel,
provider: sessionCtx.Provider,
}),
...(chatType ? { chatType } : {}),
agentAccountId: params.sessionCtx.AccountId,
...(sessionCtx.ChatType ? { chatType: sessionCtx.ChatType } : {}),
agentAccountId: sessionCtx.AccountId,
messageTo: resolveOriginMessageTo({
originatingTo: params.sessionCtx.OriginatingTo,
to: params.sessionCtx.To,
originatingTo: sessionCtx.OriginatingTo,
to: sessionCtx.To,
}),
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
memberRoleIds: normalizeMemberRoleIds(params.sessionCtx.MemberRoleIds),
messageThreadId: sessionCtx.MessageThreadId ?? undefined,
memberRoleIds: normalizeMemberRoleIds(sessionCtx.MemberRoleIds),
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,
sessionCtx,
config,
hasRepliedRef: params.hasRepliedRef,
}),
currentInboundAudio: hasInboundAudio(params.sessionCtx),
currentInboundAudio: hasInboundAudio(sessionCtx),
};
}
@@ -263,6 +288,7 @@ function buildTemplateSenderContext(sessionCtx: TemplateContext) {
/** Builds extra context payloads for embedded run execution. */
export function buildEmbeddedRunContexts(params: {
run: FollowupRun["run"];
replyRoute?: EmbeddedReplyRoute;
sessionCtx: TemplateContext;
hasRepliedRef: { value: boolean } | undefined;
provider: string;
@@ -271,6 +297,7 @@ export function buildEmbeddedRunContexts(params: {
authProfile: resolveRunAuthProfile(params.run, params.provider),
embeddedContext: buildEmbeddedContextFromTemplate({
run: params.run,
replyRoute: params.replyRoute,
sessionCtx: params.sessionCtx,
hasRepliedRef: params.hasRepliedRef,
}),
@@ -281,6 +308,7 @@ export function buildEmbeddedRunContexts(params: {
/** Builds execution-specific embedded run params for queued reply dispatch. */
export function buildEmbeddedRunExecutionParams(params: {
run: FollowupRun["run"];
replyRoute?: EmbeddedReplyRoute;
sessionCtx: TemplateContext;
hasRepliedRef: { value: boolean } | undefined;
provider: string;

View File

@@ -28,7 +28,11 @@ import {
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { scheduleFollowupDrain } from "./queue.js";
import { testing as replyRunRegistryTesting, replyRunRegistry } from "./reply-run-registry.js";
import {
createReplyOperation,
testing as replyRunRegistryTesting,
replyRunRegistry,
} from "./reply-run-registry.js";
import { createMockTypingController } from "./test-helpers.js";
function createCliBackendTestConfig() {
@@ -490,6 +494,72 @@ describe("runReplyAgent auto-compaction token update", () => {
expect(scheduleFollowupDrain).toHaveBeenCalledTimes(1);
});
it("keeps a provided reply operation active until final delivery completes", async () => {
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 50_000,
};
const replyOperation = createReplyOperation({
sessionKey,
sessionId: sessionEntry.sessionId,
resetTriggered: false,
});
const deliveryOrder: string[] = [];
runEmbeddedAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { agentMeta: {} },
});
vi.mocked(scheduleFollowupDrain).mockImplementation((key) => {
expect(key).toBe(sessionKey);
expect(replyRunRegistry.get(sessionKey)).toBeUndefined();
deliveryOrder.push("followup");
});
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath: "",
sessionEntry,
});
const result = await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 200_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
replyOperation,
});
expectReplyText(result, "ok");
expect(replyRunRegistry.get(sessionKey)).toBe(replyOperation);
expect(replyOperation.result).toBeNull();
expect(scheduleFollowupDrain).not.toHaveBeenCalled();
deliveryOrder.push("final");
replyOperation.complete();
expect(deliveryOrder).toEqual(["final", "followup"]);
expect(scheduleFollowupDrain).toHaveBeenCalledTimes(1);
});
it("reports live diagnostic context from promptTokens, not provider usage totals", async () => {
const { usageEvent } = await runBaseReplyWithAgentMeta({
tmpPrefix: "openclaw-usage-diagnostic-",

View File

@@ -377,6 +377,25 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
});
it("cleans up typing when followup admission is rejected", async () => {
vi.mocked(enqueueFollowupRun).mockReturnValueOnce(false);
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: false },
isActive: true,
isRunActive: () => true,
shouldFollowup: true,
resolvedQueueMode: "collect",
});
const result = await run();
expect(result).toBeUndefined();
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1);
expect(vi.mocked(scheduleFollowupDrain)).not.toHaveBeenCalled();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
});
it("keeps typing alive when a followup is queued behind a live active run", async () => {
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: false },

View File

@@ -120,7 +120,11 @@ import {
type QueueSettings,
} from "./queue.js";
import { createReplyMediaContext } from "./reply-media-paths.js";
import { replyRunRegistry, type ReplyOperation } from "./reply-run-registry.js";
import {
replyRunRegistry,
runAfterReplyOperationClear,
type ReplyOperation,
} from "./reply-run-registry.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
import { recordReplyUsageState } from "./reply-usage-state.js";
@@ -1290,7 +1294,7 @@ export async function runReplyAgent(params: {
}
if (activeRunQueueAction === "enqueue-followup") {
enqueueFollowupRun(
const enqueued = enqueueFollowupRun(
queueKey,
followupRun,
resolvedQueue,
@@ -1298,6 +1302,10 @@ export async function runReplyAgent(params: {
queuedRunFollowupTurn,
false,
);
if (!enqueued) {
typing.cleanup();
return undefined;
}
// Re-check liveness after enqueue so a stale active snapshot cannot leave
// the followup queue idle if the original run already finished.
const queuedBehindActiveRun = isRunActive?.() === true;
@@ -1436,8 +1444,18 @@ export async function runReplyAgent(params: {
shouldDrainQueuedFollowupsAfterClear = true;
return value;
};
const drainQueuedFollowupsAfterClear = () => {
scheduleFollowupDrain(queueKey, runFollowupTurn);
const drainQueuedFollowupsAfterClear = (admissionSessionId: string) => {
const completedSessionId = replyOperation.sessionId;
const runFollowupAfterClear =
admissionSessionId === completedSessionId
? runFollowupTurn
: (queued: FollowupRun) =>
runFollowupTurn(
queued.run.sessionId === completedSessionId
? { ...queued, admissionSessionId }
: queued,
);
scheduleFollowupDrain(queueKey, runFollowupAfterClear);
};
const restartRecoveryDeliveryRunId = crypto.randomUUID();
let trackedRestartRecoveryDeliveryContext = false;
@@ -1559,6 +1577,7 @@ export async function runReplyAgent(params: {
if (visibleMemoryFlushErrorPayloads.length > 0) {
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
const payloadResult = await buildReplyPayloads({
config: cfg,
payloads: visibleMemoryFlushErrorPayloads,
isHeartbeat,
didLogHeartbeatStrip: false,
@@ -1571,10 +1590,12 @@ export async function runReplyAgent(params: {
replyThreading: replyThreadingOverride ?? sessionCtx.ReplyThreading,
messageProvider: followupRun.run.messageProvider,
originatingChannel: sessionCtx.OriginatingChannel,
originatingChatType: sessionCtx.ChatType,
originatingTo: resolveOriginMessageTo({
originatingTo: sessionCtx.OriginatingTo,
to: sessionCtx.To,
}),
originatingThreadId: replyRouteThreadId,
accountId: sessionCtx.AccountId,
normalizeMediaPaths: replyMediaContext.normalizePayload,
});
@@ -2034,6 +2055,7 @@ export async function runReplyAgent(params: {
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
const payloadResult = await buildReplyPayloads({
config: cfg,
payloads:
fallbackNoticePayloads.length > 0
? [...fallbackNoticePayloads, ...payloadArray]
@@ -2054,10 +2076,12 @@ export async function runReplyAgent(params: {
messagingToolSentMediaUrls: runResult.messagingToolSentMediaUrls,
messagingToolSentTargets: runResult.messagingToolSentTargets,
originatingChannel: sessionCtx.OriginatingChannel,
originatingChatType: sessionCtx.ChatType,
originatingTo: resolveOriginMessageTo({
originatingTo: sessionCtx.OriginatingTo,
to: sessionCtx.To,
}),
originatingThreadId: replyRouteThreadId,
accountId: sessionCtx.AccountId,
normalizeMediaPaths: replyMediaContext.normalizePayload,
});
@@ -2558,8 +2582,12 @@ export async function runReplyAgent(params: {
);
}
if (shouldDrainQueuedFollowupsAfterClear) {
replyOperation.completeThen(drainQueuedFollowupsAfterClear);
} else {
if (providedReplyOperation) {
runAfterReplyOperationClear(replyOperation, drainQueuedFollowupsAfterClear);
} else {
replyOperation.completeThen(() => drainQueuedFollowupsAfterClear(replyOperation.sessionId));
}
} else if (!providedReplyOperation) {
replyOperation.complete();
}
blockReplyPipeline?.stop();

View File

@@ -28,6 +28,11 @@ const deliveryMocks = vi.hoisted(() => ({
}));
const channelPluginMocks = vi.hoisted(() => ({
accountIds: ["default"] as string[],
defaultAccountId: undefined as string | undefined,
replyToModeForAccount: undefined as
| ((accountId: string | null | undefined) => "all" | "off")
| undefined,
shouldTreatDeliveredTextAsVisible: (({
kind,
text,
@@ -45,6 +50,21 @@ const channelPluginMocks = vi.hoisted(() => ({
return undefined;
}
return {
config: {
listAccountIds: () => channelPluginMocks.accountIds,
resolveAccount: () => ({}),
...(channelPluginMocks.defaultAccountId
? { defaultAccountId: () => channelPluginMocks.defaultAccountId ?? "default" }
: {}),
},
...(channelPluginMocks.replyToModeForAccount
? {
threading: {
resolveReplyToMode: ({ accountId }: { accountId?: string | null }) =>
channelPluginMocks.replyToModeForAccount?.(accountId) ?? "all",
},
}
: {}),
outbound: {
shouldTreatDeliveredTextAsVisible: channelPluginMocks.shouldTreatDeliveredTextAsVisible,
shouldTreatRoutedTextAsVisible: channelPluginMocks.shouldTreatRoutedTextAsVisible,
@@ -157,6 +177,9 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
deliveryMocks.runMessageAction.mockClear();
deliveryMocks.runMessageAction.mockResolvedValue({ ok: true as const });
channelPluginMocks.getChannelPlugin.mockClear();
channelPluginMocks.accountIds = ["default"];
channelPluginMocks.defaultAccountId = undefined;
channelPluginMocks.replyToModeForAccount = undefined;
channelPluginMocks.shouldTreatDeliveredTextAsVisible = ({
kind,
text,
@@ -776,9 +799,69 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
const [[routeParams]] = deliveryMocks.routeReply.mock.calls as unknown as Array<
[{ threadId?: string | number }]
[
{
threadId?: string | number;
replyDelivery?: { chatType?: string; replyToMode?: string };
},
]
>;
expect(routeParams.threadId).toBe("101.000");
expect(routeParams.replyDelivery).toEqual({
chatType: "direct",
replyToMode: "all",
});
});
it("uses the routed destination chat type instead of the source context", async () => {
const coordinator = createAcpDispatchDeliveryCoordinator({
cfg: createAcpTestConfig(),
ctx: buildTestCtx({
Provider: "webchat",
Surface: "webchat",
SessionKey: "agent:main:mattermost:channel:town-square",
ChatType: "direct",
}),
dispatcher: createDispatcher(),
inboundAudio: false,
shouldRouteToOriginating: true,
originatingChannel: "mattermost",
originatingTo: "channel:town-square",
originatingChatType: "channel",
});
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
const [[routeParams]] = deliveryMocks.routeReply.mock.calls as unknown as Array<
[{ replyDelivery?: { chatType?: string; replyToMode?: string } }]
>;
expect(routeParams.replyDelivery).toEqual({
chatType: "channel",
replyToMode: "all",
});
});
it("uses the routed channel's listed default account for reply policy", async () => {
channelPluginMocks.accountIds = ["work"];
channelPluginMocks.replyToModeForAccount = (accountId) =>
accountId === "work" ? "off" : "all";
const coordinator = createVisibleChatAcpCoordinator(createAcpTestConfig());
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
const [[routeParams]] = deliveryMocks.routeReply.mock.calls as unknown as Array<
[
{
accountId?: string;
replyDelivery?: { chatType?: string; replyToMode?: string };
},
]
>;
expect(routeParams.accountId).toBe("work");
expect(routeParams.replyDelivery).toEqual({
chatType: "direct",
replyToMode: "off",
});
});
it("uses inherited account and thread metadata for routed ACP replies", async () => {
@@ -808,7 +891,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
});
it("routes ACP replies when cfg.channels is missing", async () => {
await expectVisibleChatBlockRoutesToAccount({} as OpenClawConfig, undefined);
await expectVisibleChatBlockRoutesToAccount({} as OpenClawConfig, "default");
});
it("treats routed plugin-owned block text as visible", async () => {

View File

@@ -4,6 +4,7 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
import type { ChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { logVerbose } from "../../globals.js";
@@ -18,6 +19,11 @@ import type { ReplyPayload } from "../types.js";
import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js";
import type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.types.js";
import { readDispatcherFailedCounts } from "./reply-dispatcher.types.js";
import {
createReplyDeliveryContext,
resolveReplyDeliveryAccountId,
resolveReplyToMode,
} from "./reply-threading.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
const routeReplyRuntimeLoader = createLazyImportLoader(() => import("./route-reply.runtime.js"));
@@ -196,6 +202,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
originatingChatType?: ChatType;
onReplyStart?: () => Promise<void> | void;
abortSignal?: AbortSignal;
runId?: string;
@@ -206,13 +213,22 @@ export function createAcpDispatchDeliveryCoordinator(params: {
const explicitAccountId =
normalizeOptionalString(params.originatingAccountId) ??
normalizeOptionalString(params.ctx.AccountId);
const resolvedAccountId =
explicitAccountId ??
normalizeOptionalString(
(
params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined> | undefined
)?.[routedChannel ?? directChannel ?? ""]?.defaultAccount,
);
const resolvedAccountId = resolveReplyDeliveryAccountId(
params.cfg,
routedChannel ?? directChannel,
explicitAccountId,
);
const routedReplyDelivery = params.originatingChannel
? createReplyDeliveryContext(
resolveReplyToMode(
params.cfg,
params.originatingChannel,
resolvedAccountId,
params.originatingChatType ?? params.ctx.ChatType,
),
params.originatingChatType ?? params.ctx.ChatType,
)
: undefined;
const state: AcpDispatchDeliveryState = {
startedReplyLifecycle: false,
accumulatedBlockText: "",
@@ -430,6 +446,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
requesterSenderUsername: params.ctx.SenderUsername,
requesterSenderE164: params.ctx.SenderE164,
threadId,
replyDelivery: routedReplyDelivery,
cfg: params.cfg,
mirror: false,
replyKind: kind,

View File

@@ -51,6 +51,10 @@ const channelPluginMocks = vi.hoisted(() => ({
return undefined;
}
return {
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: {
shouldTreatDeliveredTextAsVisible: ({
kind,

View File

@@ -13,6 +13,7 @@ import {
import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js";
import { AcpRuntimeError, toAcpRuntimeError } from "../../acp/runtime/errors.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import type { ChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { logVerbose } from "../../globals.js";
@@ -382,6 +383,7 @@ export async function tryDispatchAcpReply(params: {
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
originatingChatType?: ChatType;
shouldSendToolSummaries: boolean;
shouldSendToolSummariesNow?: () => boolean;
bypassForCommand: boolean;
@@ -440,6 +442,7 @@ export async function tryDispatchAcpReply(params: {
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
originatingThreadId: params.originatingThreadId,
originatingChatType: params.originatingChatType,
onReplyStart: params.onReplyStart,
abortSignal: params.abortSignal,
runId: params.runId,

View File

@@ -20,9 +20,14 @@ import {
sessionStoreMocks,
setDiscordTestRegistry,
} from "./dispatch-from-config.shared.test-harness.js";
import { createReplyDispatcher } from "./reply-dispatcher.js";
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
let createReplyOperation: typeof import("./reply-run-registry.js").createReplyOperation;
let replyRunRegistry: typeof import("./reply-run-registry.js").replyRunRegistry;
let runAfterReplyOperationClear: typeof import("./reply-run-registry.js").runAfterReplyOperationClear;
let resetReplyRunRegistry: typeof import("./reply-run-registry.js").testing.resetReplyRunRegistry;
function firstRuntimeLoadCall() {
return runtimePluginMocks.ensureRuntimePluginsLoaded.mock.calls[0]?.[0] as
@@ -50,10 +55,16 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
beforeAll(async () => {
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
const replyRunRegistryModule = await import("./reply-run-registry.js");
createReplyOperation = replyRunRegistryModule.createReplyOperation;
replyRunRegistry = replyRunRegistryModule.replyRunRegistry;
runAfterReplyOperationClear = replyRunRegistryModule.runAfterReplyOperationClear;
resetReplyRunRegistry = () => replyRunRegistryModule.testing.resetReplyRunRegistry();
});
beforeEach(() => {
clearAgentHarnesses();
resetReplyRunRegistry();
setDiscordTestRegistry();
resetInboundDedupe();
mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" });
@@ -241,4 +252,127 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply");
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1);
});
it("delivers a generated final reply before queued follow-up admission", async () => {
hookMocks.runner.hasHooks.mockReturnValue(false);
const dispatcher = createDispatcher();
const deliveryOrder: string[] = [];
let queuedOperation: ReturnType<typeof createReplyOperation> | undefined;
vi.mocked(dispatcher.sendFinalReply).mockImplementation(() => {
deliveryOrder.push("final");
return true;
});
try {
const result = await dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyResolver: async () => {
const operation = replyRunRegistry.get("agent:test:session");
if (!operation) {
throw new Error("expected dispatch reply operation");
}
operation.fail("run_failed", new Error("provider failed"));
runAfterReplyOperationClear(operation, () => {
deliveryOrder.push("followup");
queuedOperation = createReplyOperation({
sessionKey: "agent:test:session",
sessionId: "queued-session",
resetTriggered: false,
});
});
return { text: "first reply" };
},
});
expect(result.queuedFinal).toBe(true);
expect(dispatcher.sendFinalReply).toHaveBeenCalledOnce();
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "first reply" });
await vi.waitFor(() => {
expect(queuedOperation).toBeDefined();
});
expect(deliveryOrder).toEqual(["final", "followup"]);
expect(replyRunRegistry.get("agent:test:session")).toBe(queuedOperation);
} finally {
queuedOperation?.complete();
}
});
it("clears the reply lane but defers follow-up admission until final delivery settles", async () => {
const deliveryOrder: string[] = [];
let startDelivery: () => void = () => {};
const deliveryStarted = new Promise<void>((resolve) => {
startDelivery = resolve;
});
let releaseDelivery: () => void = () => {};
const deliveryGate = new Promise<void>((resolve) => {
releaseDelivery = resolve;
});
const dispatcher = createReplyDispatcher({
deliver: async () => {
deliveryOrder.push("final-start");
startDelivery();
await deliveryGate;
deliveryOrder.push("final-end");
},
});
let queuedOperation: ReturnType<typeof createReplyOperation> | undefined;
const abortController = new AbortController();
hookMocks.runner.runReplyDispatch.mockImplementation(async (_event, contextValue) => {
const operation = replyRunRegistry.get("agent:test:session");
if (!operation) {
throw new Error("expected dispatch reply operation");
}
runAfterReplyOperationClear(operation, () => {
deliveryOrder.push("followup");
queuedOperation = createReplyOperation({
sessionKey: "agent:test:session",
sessionId: "queued-session",
resetTriggered: false,
});
});
const context = contextValue as { dispatcher: typeof dispatcher };
return {
handled: true,
queuedFinal: context.dispatcher.sendFinalReply({ text: "first reply" }),
counts: context.dispatcher.getQueuedCounts(),
};
});
try {
const dispatchPromise = dispatchReplyFromConfig({
ctx: createHookCtx(),
cfg: emptyConfig,
dispatcher,
replyOptions: { abortSignal: abortController.signal },
});
await deliveryStarted;
const result = await dispatchPromise;
expect(result.queuedFinal).toBe(true);
expect(replyRunRegistry.isActive("agent:test:session")).toBe(false);
expect(deliveryOrder).toEqual(["final-start"]);
expect(queuedOperation).toBeUndefined();
abortController.abort();
await Promise.resolve();
expect(queuedOperation).toBeUndefined();
releaseDelivery();
await dispatcher.waitForIdle();
await vi.waitFor(() => {
expect(queuedOperation).toBeDefined();
});
expect(deliveryOrder).toEqual(["final-start", "final-end", "followup"]);
expect(replyRunRegistry.get("agent:test:session")).toBe(queuedOperation);
} finally {
releaseDelivery();
dispatcher.markComplete();
await dispatcher.waitForIdle();
queuedOperation?.complete();
}
});
});

View File

@@ -1379,6 +1379,71 @@ describe("dispatchReplyFromConfig", () => {
);
});
it("passes reply policy to routed block delivery", async () => {
setNoAbort();
mocks.routeReply.mockClear();
const slackPlugin = createChannelTestPluginBase({ id: "slack" });
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: {
...slackPlugin,
config: {
...slackPlugin.config,
listAccountIds: () => ["work"],
defaultAccountId: () => "work",
},
threading: {
resolveReplyToMode: ({ accountId }: { accountId?: string | null }) =>
accountId === "work" ? "off" : "all",
},
},
},
]),
);
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
OriginatingChannel: "slack",
OriginatingTo: "channel:C123",
ChatType: "channel",
SessionKey: "agent:main:slack:channel:C123",
});
const cfg = {} as OpenClawConfig;
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
): Promise<ReplyPayload | undefined> => {
await opts?.onBlockReply?.({
text: "partial",
replyToId: "999.000",
replyToTag: true,
});
return undefined;
};
await dispatchReplyFromConfig({
ctx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "work",
channel: "slack",
replyKind: "block",
replyDelivery: {
chatType: "channel",
replyToMode: "off",
},
}),
);
});
it("mirrors the delivered ownerless Slack text after dispatcher hook rewrites", async () => {
setNoAbort();
const dispatcher = createDispatcher();
@@ -1663,9 +1728,10 @@ describe("dispatchReplyFromConfig", () => {
route: {
channel: "feishu",
accountId: "work",
target: { to: "user:ou_123" },
target: { to: "user:ou_123", chatType: "channel" },
thread: { id: "thread:om_123", source: "explicit" },
},
chatType: "channel",
deliveryContext: {
channel: "feishu",
to: "user:ou_123",
@@ -1685,6 +1751,7 @@ describe("dispatchReplyFromConfig", () => {
AccountId: undefined,
OriginatingChannel: "webchat",
OriginatingTo: "session:dashboard",
ChatType: "direct",
InputProvenance: {
kind: "inter_session",
sourceTool: "sessions_send",
@@ -1697,17 +1764,28 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
const routeCall = firstRouteReplyCall() as
| { accountId?: unknown; channel?: unknown; threadId?: unknown; to?: unknown }
| {
accountId?: unknown;
channel?: unknown;
replyDelivery?: unknown;
threadId?: unknown;
to?: unknown;
}
| undefined;
expect(routeCall?.channel).toBe("feishu");
expect(routeCall?.to).toBe("user:ou_123");
expect(routeCall?.accountId).toBe("work");
expect(routeCall?.threadId).toBe("thread:om_123");
expect(routeCall?.replyDelivery).toEqual({
chatType: "channel",
replyToMode: "all",
});
const replyDispatchCall = firstMockCall(hookMocks.runner.runReplyDispatch, "reply dispatch") as
| [
{
originatingAccountId?: unknown;
originatingChannel?: unknown;
originatingChatType?: unknown;
originatingThreadId?: unknown;
originatingTo?: unknown;
shouldRouteToOriginating?: unknown;
@@ -1720,6 +1798,7 @@ describe("dispatchReplyFromConfig", () => {
expect(replyDispatchCall?.[0]?.originatingTo).toBe("user:ou_123");
expect(replyDispatchCall?.[0]?.originatingAccountId).toBe("work");
expect(replyDispatchCall?.[0]?.originatingThreadId).toBe("thread:om_123");
expect(replyDispatchCall?.[0]?.originatingChatType).toBe("channel");
});
it("routes exec-event replies using last route fields when delivery context is missing", async () => {
@@ -9101,7 +9180,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(result.queuedFinal).toBe(true);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(sourceReply);
expect(dispatcher.waitForIdle).toHaveBeenCalledTimes(1);
expect(dispatcher.waitForIdle).toHaveBeenCalled();
expect(transcriptMocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
});

View File

@@ -159,6 +159,11 @@ import {
replyRunRegistry,
type ReplyOperation,
} from "./reply-run-registry.js";
import {
createReplyDeliveryContext,
resolveReplyDeliveryAccountId,
resolveReplyToMode,
} from "./reply-threading.js";
import { isReplyProfilerEnabled } from "./reply-timing-tracker.js";
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
@@ -1299,7 +1304,7 @@ export async function dispatchReplyFromConfig(
const ensureDispatchReplyOperation = async (
phase: "pre_dispatch" | "dispatch",
): Promise<DispatchReplyOperationAcquisition> => {
if (dispatchReplyOperation && !dispatchReplyOperation.result) {
if (dispatchReplyOperation) {
return { status: "ready" };
}
if (dispatchAbortOperation && !dispatchAbortOperation.result) {
@@ -1429,6 +1434,7 @@ export async function dispatchReplyFromConfig(
return { status: "busy" };
}
dispatchReplyOperation = admission.operation;
dispatchReplyOperation.retainFailureUntilComplete();
dispatchAbortOperation = admission.operation;
return { status: "ready" };
};
@@ -1499,13 +1505,23 @@ export async function dispatchReplyFromConfig(
};
const completeDispatchReplyOperation = () => {
if (dispatchReplyOperation) {
dispatchReplyOperation.complete();
dispatchReplyOperation.completeWithAfterClearBarrier(
waitForReplyDispatcherIdle(dispatcher),
dispatcher.resolveFollowupAdmissionBarrierTimeoutPolicy?.(),
);
}
};
const failDispatchReplyOperation = (error: unknown) => {
if (dispatchReplyOperation && !dispatchReplyOperation.result) {
if (!dispatchReplyOperation) {
return;
}
if (!dispatchReplyOperation.result) {
dispatchReplyOperation.fail("run_failed", error);
}
dispatchReplyOperation.completeWithAfterClearBarrier(
waitForReplyDispatcherIdle(dispatcher),
dispatcher.resolveFollowupAdmissionBarrierTimeoutPolicy?.(),
);
};
const isDispatchOperationAborted = () => getDispatchAbortSignal()?.aborted === true;
const isPreDispatchOperationAborted = () => getPreDispatchAbortSignal()?.aborted === true;
@@ -1589,6 +1605,15 @@ export async function dispatchReplyFromConfig(
});
const routeReplyTo = replyRoute.to;
const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface;
const routedReplyAccountId = routeReplyChannel
? resolveReplyDeliveryAccountId(cfg, routeReplyChannel, replyRoute.accountId)
: undefined;
const routedReplyDelivery = routeReplyChannel
? createReplyDeliveryContext(
resolveReplyToMode(cfg, routeReplyChannel, routedReplyAccountId, replyRoute.chatType),
replyRoute.chatType,
)
: undefined;
let normalizeReplyMediaPaths:
| ReturnType<
(typeof import("./reply-media-paths.runtime.js"))["createReplyMediaPathNormalizer"]
@@ -1604,7 +1629,7 @@ export async function dispatchReplyFromConfig(
sessionKey: acpDispatchSessionKey,
workspaceDir,
messageProvider: deliveryChannel,
accountId: replyRoute.accountId,
accountId: routedReplyAccountId,
groupId,
groupChannel: ctx.GroupChannel,
groupSpace: ctx.GroupSpace,
@@ -1645,12 +1670,13 @@ export async function dispatchReplyFromConfig(
sessionKey: agentRuntimeSessionKey,
policySessionKey: resolveCommandTurnTargetSessionKey(ctx) ?? ctx.SessionKey,
policyConversationType: resolveRoutedPolicyConversationType(ctx),
accountId: replyRoute.accountId,
accountId: routedReplyAccountId,
requesterSenderId: ctx.SenderId,
requesterSenderName: ctx.SenderName,
requesterSenderUsername: ctx.SenderUsername,
requesterSenderE164: ctx.SenderE164,
threadId: routeReplyThreadId,
replyDelivery: routedReplyDelivery,
cfg,
abortSignal: options?.abortSignal,
mirror: options?.mirror,
@@ -2473,8 +2499,9 @@ export async function dispatchReplyFromConfig(
shouldRouteToOriginating,
originatingChannel: routeReplyChannel,
originatingTo: routeReplyTo,
originatingAccountId: replyRoute.accountId,
originatingAccountId: routedReplyAccountId,
originatingThreadId: routeReplyThreadId,
originatingChatType: replyRoute.chatType,
shouldSendToolSummaries,
sendPolicy,
}),
@@ -3182,8 +3209,9 @@ export async function dispatchReplyFromConfig(
shouldRouteToOriginating,
originatingChannel: routeReplyChannel,
originatingTo: routeReplyTo,
originatingAccountId: replyRoute.accountId,
originatingAccountId: routedReplyAccountId,
originatingThreadId: routeReplyThreadId,
originatingChatType: replyRoute.chatType,
shouldSendToolSummaries,
sendPolicy,
isTailDispatch: true,

View File

@@ -19,6 +19,7 @@ describe("resolveEffectiveReplyRoute", () => {
OriginatingChannel: "discord",
OriginatingTo: "channel:live",
AccountId: "live-account",
ChatType: "channel",
}),
entry: entry({
deliveryContext: {
@@ -35,6 +36,7 @@ describe("resolveEffectiveReplyRoute", () => {
channel: "discord",
to: "channel:live",
accountId: "live-account",
chatType: "channel",
});
});
@@ -110,7 +112,7 @@ describe("resolveEffectiveReplyRoute", () => {
route: {
channel: "feishu",
accountId: "work",
target: { to: "user:ou_123" },
target: { to: "user:ou_123", chatType: "channel" },
thread: { id: "thread:om_123", source: "explicit" },
},
deliveryContext: {
@@ -126,6 +128,7 @@ describe("resolveEffectiveReplyRoute", () => {
to: "user:ou_123",
accountId: "work",
threadId: "thread:om_123",
chatType: "channel",
inheritedExternalRoute: true,
});
});
@@ -346,7 +349,7 @@ describe("resolveEffectiveReplyRoute", () => {
});
});
it("fills partial exec-event route from persisted context", () => {
it("does not inherit an account from a different persisted channel", () => {
expect(
resolveEffectiveReplyRoute({
ctx: ctx({
@@ -362,10 +365,35 @@ describe("resolveEffectiveReplyRoute", () => {
},
}),
}),
).toEqual({
channel: "telegram",
to: "chat:live",
accountId: undefined,
});
});
it("fills a partial exec-event route from the same persisted channel", () => {
expect(
resolveEffectiveReplyRoute({
ctx: ctx({
Provider: "exec-event",
OriginatingChannel: "telegram",
OriginatingTo: "chat:live",
}),
entry: entry({
chatType: "direct",
deliveryContext: {
channel: "telegram",
to: "chat:persisted",
accountId: "persisted-account",
},
}),
}),
).toEqual({
channel: "telegram",
to: "chat:live",
accountId: "persisted-account",
chatType: "direct",
});
});
});

View File

@@ -1,4 +1,5 @@
/** Resolves the effective reply route from current context and persisted session route. */
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
@@ -8,13 +9,19 @@ import type { FinalizedMsgContext } from "../templating.js";
/** Current finalized context fields used for reply route resolution. */
export type EffectiveReplyRouteContext = Pick<
FinalizedMsgContext,
"Provider" | "Surface" | "OriginatingChannel" | "OriginatingTo" | "AccountId" | "InputProvenance"
| "Provider"
| "Surface"
| "OriginatingChannel"
| "OriginatingTo"
| "AccountId"
| "InputProvenance"
| "ChatType"
>;
/** Persisted session fields used as route fallback/inheritance. */
export type EffectiveReplyRouteEntry = Pick<
SessionEntry,
"deliveryContext" | "lastChannel" | "lastTo" | "lastAccountId" | "route"
"deliveryContext" | "lastChannel" | "lastTo" | "lastAccountId" | "route" | "chatType" | "origin"
>;
/** Effective channel target selected for source reply delivery. */
@@ -23,6 +30,7 @@ export type EffectiveReplyRoute = {
to?: string;
accountId?: string;
threadId?: string | number;
chatType?: ChatType;
inheritedExternalRoute?: boolean;
};
@@ -69,6 +77,11 @@ export function resolveEffectiveReplyRoute(params: {
normalizeMessageChannel(params.ctx.OriginatingChannel);
const persistedDeliveryContext = params.entry?.deliveryContext;
const persistedDeliveryChannel = normalizeMessageChannel(persistedDeliveryContext?.channel);
const liveChatType = normalizeChatType(params.ctx.ChatType);
const persistedChatType =
params.entry?.route?.target?.chatType ??
params.entry?.chatType ??
normalizeChatType(params.entry?.origin?.chatType);
if (
isSessionsSendInterSessionHandoff(params.ctx.InputProvenance) &&
currentSurface === INTERNAL_MESSAGE_CHANNEL &&
@@ -82,6 +95,7 @@ export function resolveEffectiveReplyRoute(params: {
to: persistedDeliveryContext.to,
accountId: persistedDeliveryContext.accountId,
...(inheritedThreadId !== undefined ? { threadId: inheritedThreadId } : {}),
...(persistedChatType ? { chatType: persistedChatType } : {}),
inheritedExternalRoute: true,
};
}
@@ -90,15 +104,27 @@ export function resolveEffectiveReplyRoute(params: {
channel: params.ctx.OriginatingChannel,
to: params.ctx.OriginatingTo,
accountId: params.ctx.AccountId,
...(liveChatType ? { chatType: liveChatType } : {}),
};
}
const persistedChannel = persistedDeliveryContext?.channel ?? params.entry?.lastChannel;
const liveChannel = params.ctx.OriginatingChannel;
const canInheritPersistedTuple =
!liveChannel ||
normalizeMessageChannel(liveChannel) === normalizeMessageChannel(persistedChannel);
const chatType = liveChatType ?? (canInheritPersistedTuple ? persistedChatType : undefined);
return {
channel:
params.ctx.OriginatingChannel ??
persistedDeliveryContext?.channel ??
params.entry?.lastChannel,
to: params.ctx.OriginatingTo ?? persistedDeliveryContext?.to ?? params.entry?.lastTo,
channel: liveChannel ?? persistedChannel,
to:
params.ctx.OriginatingTo ??
(canInheritPersistedTuple
? (persistedDeliveryContext?.to ?? params.entry?.lastTo)
: undefined),
accountId:
params.ctx.AccountId ?? persistedDeliveryContext?.accountId ?? params.entry?.lastAccountId,
params.ctx.AccountId ??
(canInheritPersistedTuple
? (persistedDeliveryContext?.accountId ?? params.entry?.lastAccountId)
: undefined),
...(chatType ? { chatType } : {}),
};
}

View File

@@ -42,6 +42,30 @@ describe("resolveFollowupDeliveryPayloads", () => {
expect(getReplyPayloadMetadata(resolved ?? {})).toEqual({
assistantTranscriptOwned: true,
replyDelivery: {
replyToMode: "all",
},
});
});
it("uses the captured reply policy instead of reloading changed config", () => {
const [resolved] = resolveFollowupDeliveryPayloads({
cfg: {
channels: {
slack: {
replyToMode: "all",
},
},
} as OpenClawConfig,
payloads: [{ text: "queued reply" }],
originatingChannel: "slack",
originatingChatType: "channel",
originatingReplyToMode: "off",
});
expect(getReplyPayloadMetadata(resolved ?? {})?.replyDelivery).toEqual({
chatType: "channel",
replyToMode: "off",
});
});
@@ -112,6 +136,72 @@ describe("resolveFollowupDeliveryPayloads", () => {
).toEqual([{ text: "discord-only text" }]);
});
it("does not dedupe same-channel text sent to a different routed thread", () => {
expect(
resolveFollowupDeliveryPayloads({
cfg: baseConfig,
payloads: [{ text: "thread reply" }],
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "222.000",
sentTexts: ["thread reply"],
sentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "thread reply",
},
],
}),
).toEqual([{ text: "thread reply" }]);
});
it("dedupes same-channel text sent to the same routed thread", () => {
expect(
resolveFollowupDeliveryPayloads({
cfg: baseConfig,
payloads: [{ text: "thread reply" }],
messageProvider: "slack",
originatingTo: "channel:C1",
originatingThreadId: "111.000",
sentTexts: ["thread reply"],
sentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
threadId: "111.000",
text: "thread reply",
},
],
}),
).toStrictEqual([]);
});
it("dedupes a Slack DM tool send recorded through its routable target", () => {
expect(
resolveFollowupDeliveryPayloads({
cfg: baseConfig,
payloads: [{ text: "thread reply" }],
messageProvider: "slack",
originatingTo: "user:U123",
originatingThreadId: "171.222",
sentTexts: ["thread reply"],
sentTargets: [
{
tool: "message",
provider: "slack",
to: "user:U123",
threadId: "171.222",
text: "thread reply",
},
],
}),
).toStrictEqual([]);
});
it("falls back to global text dedupe for legacy multi-target messaging telemetry", () => {
expect(
resolveFollowupDeliveryPayloads({

View File

@@ -1,8 +1,13 @@
/** Prepares queued follow-up payloads for source-channel delivery. */
import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js";
import type { ReplyToMode } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import {
copyReplyPayloadMetadata,
getReplyPayloadMetadata,
setReplyPayloadMetadata,
} from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -16,7 +21,7 @@ import {
filterMessagingToolMediaDuplicates,
resolveMessagingToolPayloadDedupe,
} from "./reply-payloads.js";
import { resolveReplyToMode } from "./reply-threading.js";
import { createReplyDeliveryContext, resolveReplyToMode } from "./reply-threading.js";
function hasReplyPayloadMedia(payload: ReplyPayload): boolean {
if (typeof payload.mediaUrl === "string" && payload.mediaUrl.trim().length > 0) {
@@ -33,7 +38,9 @@ export function resolveFollowupDeliveryPayloads(params: {
originatingAccountId?: string;
originatingChannel?: string;
originatingChatType?: string | null;
originatingReplyToMode?: ReplyToMode;
originatingTo?: string;
originatingThreadId?: string | number;
sentMediaUrls?: string[];
sentTargets?: MessagingToolSend[];
sentTexts?: string[];
@@ -43,12 +50,24 @@ export function resolveFollowupDeliveryPayloads(params: {
provider: params.messageProvider,
});
const replyToChannel = replyMessageProvider as OriginatingChannelType | undefined;
const replyToMode = resolveReplyToMode(
params.cfg,
replyToChannel,
params.originatingAccountId,
params.originatingChatType,
);
const replyToMode =
params.originatingReplyToMode ??
resolveReplyToMode(
params.cfg,
replyToChannel,
params.originatingAccountId,
params.originatingChatType,
);
const accountId = resolveOriginAccountId({
originatingAccountId: params.originatingAccountId,
});
const replyDelivery = createReplyDeliveryContext(replyToMode, params.originatingChatType);
const replyDeliverySource = replyMessageProvider
? {
channel: replyMessageProvider,
...(accountId ? { accountId } : {}),
}
: undefined;
const sanitizedPayloads: ReplyPayload[] = [];
for (const payload of params.payloads) {
const text = payload.text;
@@ -67,48 +86,55 @@ export function resolveFollowupDeliveryPayloads(params: {
payloads: sanitizedPayloads,
replyToMode,
replyToChannel,
});
const messagingToolPayloadDedupe = resolveMessagingToolPayloadDedupe({
messageProvider: replyMessageProvider,
messagingToolSentTargets: params.sentTargets,
originatingTo: resolveOriginMessageTo({
originatingTo: params.originatingTo,
}).map((payload) =>
setReplyPayloadMetadata(payload, {
replyDelivery,
...(replyDeliverySource ? { replyDeliverySource } : {}),
}),
accountId: resolveOriginAccountId({
originatingAccountId: params.originatingAccountId,
}),
});
);
const sentMediaUrlFallback = params.sentMediaUrls ?? [];
const sentTextFallback = params.sentTexts ?? [];
const shouldUseGlobalSentMediaUrlEvidence =
messagingToolPayloadDedupe.matchingRoute &&
messagingToolPayloadDedupe.routeSentMediaUrls.length === 0 &&
messagingToolPayloadDedupe.useGlobalSentMediaUrlEvidenceFallback;
const shouldUseGlobalSentTextEvidence =
messagingToolPayloadDedupe.matchingRoute &&
messagingToolPayloadDedupe.routeSentTexts.length === 0 &&
messagingToolPayloadDedupe.useGlobalSentTextEvidenceFallback;
const sentMediaUrlsForDedupe = messagingToolPayloadDedupe.matchingRoute
? shouldUseGlobalSentMediaUrlEvidence
? sentMediaUrlFallback
: messagingToolPayloadDedupe.routeSentMediaUrls
: sentMediaUrlFallback;
const sentTextsForDedupe = messagingToolPayloadDedupe.matchingRoute
? shouldUseGlobalSentTextEvidence
? sentTextFallback
: messagingToolPayloadDedupe.routeSentTexts
: sentTextFallback;
const mediaFilteredPayloads = messagingToolPayloadDedupe.shouldDedupePayloads
? filterMessagingToolMediaDuplicates({
payloads: replyTaggedPayloads,
sentMediaUrls: sentMediaUrlsForDedupe,
})
: replyTaggedPayloads;
const dedupedPayloads = messagingToolPayloadDedupe.shouldDedupePayloads
? filterMessagingToolDuplicates({
payloads: mediaFilteredPayloads,
sentTexts: sentTextsForDedupe,
})
: mediaFilteredPayloads;
const originatingTo = resolveOriginMessageTo({
originatingTo: params.originatingTo,
});
const dedupedPayloads: ReplyPayload[] = [];
for (const payload of replyTaggedPayloads) {
const decision = resolveMessagingToolPayloadDedupe({
config: params.cfg,
messageProvider: replyMessageProvider,
messagingToolSentTargets: params.sentTargets,
originatingTo,
originatingThreadId: params.originatingThreadId,
replyToId: payload.replyToId,
replyToIsExplicit: Boolean(
getReplyPayloadMetadata(payload)?.replyToIdExplicit ||
payload.replyToTag ||
payload.replyToCurrent,
),
replyDelivery: getReplyPayloadMetadata(payload)?.replyDelivery,
accountId,
});
if (!decision.shouldDedupePayloads) {
dedupedPayloads.push(payload);
continue;
}
const sentMediaUrls =
decision.matchingRoute && !decision.useGlobalSentMediaUrlEvidenceFallback
? decision.routeSentMediaUrls
: sentMediaUrlFallback;
const sentTexts =
decision.matchingRoute && !decision.useGlobalSentTextEvidenceFallback
? decision.routeSentTexts
: sentTextFallback;
const mediaFiltered = filterMessagingToolMediaDuplicates({
payloads: [payload],
sentMediaUrls,
});
const textFiltered = filterMessagingToolDuplicates({
payloads: mediaFiltered,
sentTexts,
});
dedupedPayloads.push(...textFiltered);
}
return dedupedPayloads;
}

View File

@@ -613,7 +613,7 @@ describe("createFollowupRunner reply-lane admission", () => {
MediaType: "image/png",
} as never;
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "done" }],
payloads: [],
meta: {},
});
const runner = createFollowupRunner({
@@ -695,6 +695,45 @@ describe("createFollowupRunner reply-lane admission", () => {
expect(call.sessionFile).toBe("/tmp/post-compact.jsonl");
});
it("uses an admission session hint while refreshing the queued session file", async () => {
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [],
meta: { agentMeta: { provider: "anthropic", model: "claude" } },
});
const sessionStore = {
main: {
sessionId: "rotated-session",
sessionFile: "/tmp/rotated.jsonl",
updatedAt: Date.now(),
},
};
const runner = createFollowupRunner({
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry: sessionStore.main,
sessionStore,
sessionKey: "main",
defaultModel: "anthropic/claude",
});
await runner(
createQueuedRun({
admissionSessionId: "rotated-session",
run: {
sessionId: "queued-stale-session",
sessionFile: "/tmp/stale.jsonl",
sessionKey: "main",
provider: "anthropic",
model: "claude",
},
}),
);
const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent");
expect(call.sessionId).toBe("rotated-session");
expect(call.sessionFile).toBe("/tmp/rotated.jsonl");
});
it("registers the admitted session id when the local session store is stale", async () => {
const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
"../../infra/agent-events.js",

View File

@@ -562,7 +562,7 @@ export function createFollowupRunner(params: {
}
};
const admission = await admitReplyTurn({
sessionId: run.sessionId,
sessionId: effectiveQueued.admissionSessionId ?? run.sessionId,
sessionKey: replySessionKey ?? "",
kind: "queued_followup",
resetTriggered: false,
@@ -625,6 +625,7 @@ export function createFollowupRunner(params: {
originatingAccountId: queued.originatingAccountId ?? run.agentAccountId,
originatingChannel: queued.originatingChannel,
originatingChatType: queued.originatingChatType,
originatingReplyToMode: queued.originatingReplyToMode,
originatingTo: queued.originatingTo,
});
if (noticePayloads.length === 0) {
@@ -1257,7 +1258,9 @@ export function createFollowupRunner(params: {
originatingAccountId: queued.originatingAccountId ?? run.agentAccountId,
originatingChannel: queued.originatingChannel,
originatingChatType: queued.originatingChatType,
originatingReplyToMode: queued.originatingReplyToMode,
originatingTo: queued.originatingTo,
originatingThreadId: queued.originatingThreadId,
sentMediaUrls: runResult.messagingToolSentMediaUrls,
sentTargets: runResult.messagingToolSentTargets,
sentTexts: runResult.messagingToolSentTexts,

View File

@@ -2637,6 +2637,108 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.followupRun.originatingReplyToId).toBe("reply-24680");
});
it("captures the effective reply policy for queued Slack runs", async () => {
await runPreparedReply(
baseParams({
cfg: {
session: {},
channels: { slack: { replyToMode: "off" } },
agents: { defaults: {} },
},
ctx: {
Body: "",
RawBody: "",
CommandBody: "",
ThreadHistoryBody: "Earlier message in this thread",
Provider: "slack",
OriginatingChannel: undefined,
OriginatingTo: "C123",
ChatType: "group",
},
sessionCtx: {
Body: "",
BodyStripped: "",
ThreadHistoryBody: "Earlier message in this thread",
MediaPath: "/tmp/input.png",
Provider: "slack",
ChatType: "group",
OriginatingChannel: "slack",
OriginatingTo: "C123",
ReplyToId: "101.001",
},
}),
);
const call = requireRunReplyAgentCall();
expect(call?.followupRun.originatingReplyToId).toBe("101.001");
expect(call?.followupRun.originatingReplyToMode).toBe("off");
});
it("captures queued reply policy from hydrated system-event session context", async () => {
await runPreparedReply(
baseParams({
cfg: {
session: {},
channels: {
slack: {
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
},
},
agents: { defaults: {} },
},
opts: { isHeartbeat: true },
ctx: {
Body: "scheduled wake",
RawBody: "scheduled wake",
CommandBody: "scheduled wake",
Provider: "cron-event",
SessionKey: "agent:main:slack:direct:U1",
OriginatingChannel: "slack",
OriginatingTo: "user:U1",
},
sessionCtx: {
Body: "scheduled wake",
BodyStripped: "scheduled wake",
Provider: "cron-event",
OriginatingChannel: "slack",
OriginatingTo: "user:U1",
},
sessionEntry: {
sessionId: "session-1",
updatedAt: 1,
chatType: "direct",
channel: "matrix",
lastChannel: "slack",
lastTo: "user:U1",
lastAccountId: "work",
deliveryContext: {
channel: "slack",
to: "user:U1",
accountId: "work",
},
origin: {
provider: "matrix",
surface: "matrix",
chatType: "direct",
to: "room:origin",
accountId: "origin",
},
} as SessionEntry,
}),
);
const call = requireRunReplyAgentCall();
expect(call?.followupRun.originatingChannel).toBe("slack");
expect(call?.followupRun.originatingTo).toBe("user:U1");
expect(call?.followupRun.originatingAccountId).toBe("work");
expect(call?.followupRun.originatingChatType).toBe("direct");
expect(call?.followupRun.originatingReplyToMode).toBe("off");
expect(call?.followupRun.run.messageProvider).toBe("slack");
expect(call?.followupRun.run.agentAccountId).toBe("work");
expect(call?.followupRun.run.chatType).toBe("direct");
});
it("uses transport thread metadata for followup originatingThreadId", async () => {
await runPreparedReply(
baseParams({

View File

@@ -48,7 +48,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { hasControlCommand } from "../command-detection.js";
import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js";
import { resolveEnvelopeFormatOptions } from "../envelope.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type { MsgContext, OriginatingChannelType, TemplateContext } from "../templating.js";
import {
type ElevatedLevel,
formatThinkingLevels,
@@ -66,7 +66,7 @@ import { applySessionHints } from "./body.js";
import type { buildCommandContext } from "./commands.js";
import { resolveCurrentTurnImages } from "./current-turn-images.js";
import type { InlineDirectives } from "./directive-handling.js";
import { isSystemEventProvider } from "./effective-reply-route.js";
import { isSystemEventProvider, resolveEffectiveReplyRoute } from "./effective-reply-route.js";
import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js";
import { resolvePreparedReplyQueueState } from "./get-reply-run-queue.js";
import type { ReplySessionBinding } from "./get-reply.types.js";
@@ -97,6 +97,7 @@ import {
waitForReplyRunEndBySessionId,
type ReplyOperation,
} from "./reply-run-registry.js";
import { resolveReplyToMode } from "./reply-threading.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js";
import { resolveBareSessionResetPromptState } from "./session-reset-prompt.js";
@@ -1250,6 +1251,27 @@ export async function runPreparedReply(
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
})
: undefined);
const replyRoute = resolveEffectiveReplyRoute({
ctx: {
Provider: ctx.Provider ?? sessionCtx.Provider,
Surface: ctx.Surface ?? sessionCtx.Surface,
OriginatingChannel: ctx.OriginatingChannel ?? sessionCtx.OriginatingChannel,
OriginatingTo: ctx.OriginatingTo ?? sessionCtx.OriginatingTo,
AccountId: ctx.AccountId ?? sessionCtx.AccountId,
InputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance,
ChatType: ctx.ChatType ?? sessionCtx.ChatType,
},
entry: preparedSessionState.sessionEntry,
});
const messageProvider = resolveOriginMessageProvider({
originatingChannel: replyRoute.channel,
// Prefer Provider over Surface for fallback channel identity.
// Surface can carry relayed metadata while Provider owns reply routing.
provider: ctx.Provider ?? ctx.Surface ?? promptSessionCtx.Provider,
});
const replyPolicyChannel =
(replyRoute.channel as OriginatingChannelType | undefined) ??
(messageProvider as OriginatingChannelType | undefined);
const followupRun = {
prompt: queuedBody,
transcriptPrompt: transcriptCommandBody,
@@ -1266,27 +1288,27 @@ export async function runPreparedReply(
images: currentTurnImages.images,
imageOrder: currentTurnImages.imageOrder,
// Originating channel for reply routing.
originatingChannel: ctx.OriginatingChannel,
originatingTo: ctx.OriginatingTo,
originatingAccountId: sessionCtx.AccountId,
originatingThreadId,
originatingReplyToId: sessionCtx.ReplyToId,
originatingChatType: ctx.ChatType,
originatingChannel: replyRoute.channel,
originatingTo: replyRoute.to,
originatingAccountId: replyRoute.accountId,
originatingThreadId: replyRoute.threadId ?? originatingThreadId,
originatingReplyToId: promptSessionCtx.ReplyToId,
originatingReplyToMode: resolveReplyToMode(
cfg,
replyPolicyChannel,
replyRoute.accountId,
replyRoute.chatType,
),
originatingChatType: replyRoute.chatType,
run: {
agentId,
agentDir,
sessionId: preparedSessionState.sessionId,
sessionKey,
runtimePolicySessionKey,
messageProvider: resolveOriginMessageProvider({
originatingChannel: ctx.OriginatingChannel ?? sessionCtx.OriginatingChannel,
// Prefer Provider over Surface for fallback channel identity.
// Surface can carry relayed metadata (for example "webchat") while Provider
// still reflects the active channel that should own tool routing.
provider: ctx.Provider ?? ctx.Surface ?? sessionCtx.Provider,
}),
chatType: normalizeChatType(promptSessionCtx.ChatType),
agentAccountId: sessionCtx.AccountId,
messageProvider,
chatType: replyRoute.chatType,
agentAccountId: replyRoute.accountId,
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
groupChannel:
normalizeOptionalString(sessionCtx.GroupChannel) ??

File diff suppressed because it is too large Load Diff

View File

@@ -155,6 +155,47 @@ describe("followup queue deduplication", () => {
expect(calls).toHaveLength(1);
});
it("deduplicates redelivery after reply policy changes", async () => {
const key = `test-dedup-policy-change-${Date.now()}`;
const { calls, done, runFollowup } = createFollowupCollector();
expect(
enqueueFollowupRun(
key,
createRun({
prompt: "first",
messageId: "same-id",
originatingChannel: "slack",
originatingTo: "U123",
originatingReplyToId: "101.001",
originatingReplyToMode: "off",
originatingChatType: "direct",
}),
collectSettings,
),
).toBe(true);
scheduleFollowupDrain(key, runFollowup);
await done.promise;
expect(
enqueueFollowupRun(
key,
createRun({
prompt: "redelivery",
messageId: "same-id",
originatingChannel: "slack",
originatingTo: "U123",
originatingReplyToId: "101.001",
originatingReplyToMode: "first",
originatingChatType: "direct",
}),
collectSettings,
),
).toBe(false);
expect(calls).toHaveLength(1);
});
it("deduplicates same message_id across distinct enqueue module instances", async () => {
const enqueueA = await importFreshModule<typeof import("./queue/enqueue.js")>(
import.meta.url,

View File

@@ -274,6 +274,39 @@ describe("followup queue drain restart after idle window", () => {
expect(calls[1]?.prompt).toBe("wait-for-lane");
});
it("refreshes the callback used by a deferred active-drain retry", async () => {
const key = `test-active-drain-refreshes-retry-${Date.now()}`;
const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 };
const firstStarted = createDeferred<void>();
const releaseFirst = createDeferred<void>();
const retried = createDeferred<void>();
const staleCalls: FollowupRun[] = [];
const freshCalls: FollowupRun[] = [];
const staleFollowup = async (run: FollowupRun) => {
staleCalls.push(run);
firstStarted.resolve();
await releaseFirst.promise;
throw new FollowupRunDeferredError("reply lane busy");
};
const freshFollowup = async (run: FollowupRun) => {
freshCalls.push(run);
retried.resolve();
};
enqueueFollowupRun(key, createRun({ prompt: "wait-for-lane" }), settings);
scheduleFollowupDrain(key, staleFollowup);
await firstStarted.promise;
scheduleFollowupDrain(key, freshFollowup);
releaseFirst.resolve();
await retried.promise;
expect(staleCalls).toHaveLength(1);
expect(freshCalls).toHaveLength(1);
expect(freshCalls[0]?.prompt).toBe("wait-for-lane");
});
it("preserves overflow summaries across deferred retries", async () => {
const key = `test-deferred-summary-retry-${Date.now()}`;
const prompts: string[] = [];
@@ -339,8 +372,8 @@ describe("followup queue drain restart after idle window", () => {
expect(attempts).toBe(2);
expect(prompts[1]).toContain("Dropped 3 messages");
expect(prompts[1]).toContain("original dropped while busy");
expect(prompts[1]).toContain("newer dropped while waiting");
expect(prompts[1]).not.toContain("original dropped while busy");
});
it("does not process messages after clearSessionQueues clears the callback", async () => {

View File

@@ -23,6 +23,9 @@ export function createQueueTestRun(params: {
originatingTo?: string;
originatingAccountId?: string;
originatingThreadId?: string | number;
originatingReplyToId?: string;
originatingReplyToMode?: FollowupRun["originatingReplyToMode"];
originatingChatType?: string;
currentInboundEventKind?: FollowupRun["currentInboundEventKind"];
}): FollowupRun {
return {
@@ -33,6 +36,9 @@ export function createQueueTestRun(params: {
originatingTo: params.originatingTo,
originatingAccountId: params.originatingAccountId,
originatingThreadId: params.originatingThreadId,
originatingReplyToId: params.originatingReplyToId,
originatingReplyToMode: params.originatingReplyToMode,
originatingChatType: params.originatingChatType,
currentInboundEventKind: params.currentInboundEventKind,
run: {
agentId: "agent",

View File

@@ -1,6 +1,16 @@
import { createHash } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { resolveStorePath } from "../../../config/sessions.js";
import { readSessionEntry } from "../../../config/sessions/store-load.js";
// Drains queued follow-up runs while preserving route and session identity.
import { channelRouteCompactKey } from "../../../plugin-sdk/channel-route.js";
import {
channelRouteCompactKey,
channelRouteDedupeKey,
} from "../../../plugin-sdk/channel-route.js";
import { defaultRuntime } from "../../../runtime.js";
import { createUserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.js";
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
import {
buildCollectPrompt,
@@ -52,39 +62,40 @@ export function kickFollowupDrainIfIdle(key: string): void {
type OriginRoutingMetadata = Pick<
FollowupRun,
"originatingChannel" | "originatingTo" | "originatingAccountId" | "originatingThreadId"
| "originatingChannel"
| "originatingTo"
| "originatingAccountId"
| "originatingThreadId"
| "originatingReplyToId"
| "originatingReplyToMode"
| "originatingChatType"
>;
function resolveOriginRoutingMetadata(items: FollowupRun[]): OriginRoutingMetadata {
const metadata: OriginRoutingMetadata = {};
for (const item of items) {
if (!metadata.originatingChannel && item.originatingChannel) {
metadata.originatingChannel = item.originatingChannel;
}
if (!metadata.originatingTo && item.originatingTo) {
metadata.originatingTo = item.originatingTo;
}
if (!metadata.originatingAccountId && item.originatingAccountId) {
metadata.originatingAccountId = item.originatingAccountId;
}
// Support both number (Telegram topic) and string (Slack thread_ts) thread IDs.
if (
metadata.originatingThreadId == null &&
item.originatingThreadId != null &&
item.originatingThreadId !== ""
) {
metadata.originatingThreadId = item.originatingThreadId;
}
if (
metadata.originatingChannel &&
metadata.originatingTo &&
metadata.originatingAccountId &&
metadata.originatingThreadId != null
) {
break;
}
const source =
items.find((item) => item.originatingChannel && item.originatingTo) ??
items.find(
(item) =>
item.originatingChannel ||
item.originatingTo ||
item.originatingAccountId ||
item.originatingThreadId != null ||
item.originatingReplyToId ||
item.originatingReplyToMode ||
item.originatingChatType,
);
if (!source) {
return {};
}
return metadata;
return {
originatingChannel: source.originatingChannel,
originatingTo: source.originatingTo,
originatingAccountId: source.originatingAccountId,
originatingThreadId: source.originatingThreadId,
originatingReplyToId: source.originatingReplyToId,
originatingReplyToMode: source.originatingReplyToMode,
originatingChatType: source.originatingChatType,
};
}
// Keep this key aligned with the fields that affect per-message authorization or
@@ -108,7 +119,55 @@ export function resolveFollowupAuthorizationKey(run: FollowupRun["run"]): string
]);
}
function splitCollectItemsByAuthorization(items: FollowupRun[]): FollowupRun[][] {
export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
const execution = run.run;
const provenance = execution.inputProvenance;
return JSON.stringify([
channelRouteDedupeKey({
channel: run.originatingChannel,
to: run.originatingTo,
accountId: run.originatingAccountId,
threadId: run.originatingThreadId,
}),
resolveFollowupReplyAnchor(run) ?? "",
run.originatingReplyToMode ?? "",
normalizeChatType(run.originatingChatType) ?? "",
resolveFollowupAuthorizationKey(execution),
normalizeOptionalString(execution.runtimePolicySessionKey ?? execution.sessionKey) ?? "",
execution.messageProvider ?? "",
execution.chatType ?? "",
execution.agentAccountId ?? "",
execution.groupId ?? "",
execution.groupChannel ?? "",
execution.groupSpace ?? "",
execution.traceAuthorized === true,
execution.elevatedLevel ?? "",
provenance?.kind ?? "",
provenance?.originSessionId ?? "",
provenance?.sourceSessionKey ?? "",
provenance?.sourceChannel ?? "",
provenance?.sourceTool ?? "",
execution.extraSystemPrompt ?? "",
execution.extraSystemPromptStatic ?? "",
execution.sourceReplyDeliveryMode ?? "",
execution.silentReplyPromptMode ?? "",
execution.enforceFinalTag === true,
execution.skipProviderRuntimeHints === true,
execution.silentExpected === true,
execution.allowEmptyAssistantReplyAsSilent === true,
execution.suppressNextUserMessagePersistence === true,
execution.suppressTranscriptOnlyAssistantPersistence === true,
execution.blockReplyBreak,
]);
}
export function resolveFollowupReplyAnchor(run: FollowupRun): string | undefined {
return run.originatingReplyToMode === "off"
? undefined
: normalizeOptionalString(run.originatingReplyToId);
}
function splitCollectItemsByDeliveryContext(items: FollowupRun[]): FollowupRun[][] {
if (items.length <= 1) {
return items.length === 0 ? [] : [items];
}
@@ -118,7 +177,7 @@ function splitCollectItemsByAuthorization(items: FollowupRun[]): FollowupRun[][]
let currentKey: string | undefined;
for (const item of items) {
const itemKey = resolveFollowupAuthorizationKey(item.run);
const itemKey = resolveFollowupDeliveryContextKey(item);
if (currentGroup.length === 0 || itemKey === currentKey) {
currentGroup.push(item);
currentKey = itemKey;
@@ -240,17 +299,27 @@ function collectRuntimeMetadata(
};
}
function collectSummaryRuntimeMetadata(items: FollowupRun[]): FollowupRuntimeMetadata {
return collectRuntimeMetadata(items, items.length === 1 ? items[0] : undefined);
}
function clearFollowupQueueSummaryState(queue: {
type FollowupQueueSummaryState = {
dropPolicy: "summarize" | "old" | "new";
droppedCount: number;
summaryLines: string[];
summarySources?: FollowupRun[];
}): void {
summarySources: FollowupRun[];
summaryElisions: Array<{
contextKey: string;
count: number;
source: FollowupRun;
sourceRefs: WeakSet<FollowupRun>;
}>;
evictedSummaryCount: number;
};
function clearFollowupQueueSummaryState(queue: FollowupQueueSummaryState): void {
completeFollowupQueueSummarySources(queue);
for (const entry of queue.summaryElisions) {
completeFollowupRunLifecycle(entry.source);
}
queue.summaryElisions = [];
queue.evictedSummaryCount = 0;
clearQueueSummaryState(queue);
}
@@ -263,70 +332,99 @@ function completeFollowupQueueSummarySources(queue: { summarySources?: FollowupR
}
}
function previewRestorableQueueSummaryPrompt(params: {
state: {
dropPolicy: "summarize" | "old" | "new";
droppedCount: number;
summaryLines: string[];
};
noun: string;
}): { prompt?: string; restore?: () => void } {
const snapshot = {
droppedCount: params.state.droppedCount,
summaryLines: [...params.state.summaryLines],
};
const prompt = previewQueueSummaryPrompt(params);
type QueueSummaryDelivery = {
prompt: string;
droppedCount: number;
sources: FollowupRun[];
};
function createQueueSummaryDelivery(params: {
queue: FollowupQueueSummaryState;
sources?: FollowupRun[];
}): QueueSummaryDelivery | undefined {
const sources = params.sources ? [...params.sources] : [...params.queue.summarySources];
if (
params.sources &&
!sources.every((source, index) => params.queue.summarySources[index] === source)
) {
return undefined;
}
const droppedCount = params.sources ? sources.length : params.queue.droppedCount;
const summaryLines = params.sources
? params.queue.summaryLines.slice(0, sources.length)
: [...params.queue.summaryLines];
const prompt = previewQueueSummaryPrompt({
state: {
dropPolicy: params.queue.dropPolicy,
droppedCount,
summaryLines,
},
noun: "message",
});
if (!prompt) {
return {};
return undefined;
}
return {
prompt,
restore: () => {
const currentLines = params.state.summaryLines;
// previewQueueSummaryPrompt reads a snapshot clone; the live queue still
// contains this snapshot plus any newer drops that arrived before restore.
const hasSnapshotPrefix =
params.state.droppedCount >= snapshot.droppedCount &&
snapshot.summaryLines.every((line, index) => currentLines[index] === line);
if (hasSnapshotPrefix) {
return;
}
params.state.droppedCount =
params.state.droppedCount >= snapshot.droppedCount
? params.state.droppedCount
: params.state.droppedCount + snapshot.droppedCount;
params.state.summaryLines = [...snapshot.summaryLines, ...currentLines];
},
droppedCount,
sources,
};
}
async function runWithSummarySourceCleanup(
queue: { summarySources?: FollowupRun[] },
function consumeQueueSummaryDelivery(
queue: FollowupQueueSummaryState,
delivery: QueueSummaryDelivery,
): void {
let consumedCount = delivery.sources.length === 0 ? delivery.droppedCount : 0;
for (const source of delivery.sources) {
const sourceIndex = queue.summarySources.indexOf(source);
if (sourceIndex >= 0) {
queue.summarySources.splice(sourceIndex, 1);
queue.summaryLines.splice(sourceIndex, 1);
consumedCount += 1;
} else {
const elisionIndex = queue.summaryElisions.findIndex((entry) => entry.sourceRefs.has(source));
if (elisionIndex >= 0) {
const entry = queue.summaryElisions[elisionIndex];
entry.count = Math.max(0, entry.count - 1);
consumedCount += 1;
if (entry.count === 0) {
queue.summaryElisions.splice(elisionIndex, 1);
}
}
}
completeFollowupRunLifecycle(source);
}
queue.droppedCount = Math.max(0, queue.droppedCount - consumedCount);
}
function releaseQueueSummaryDeliveryForRetry(
queue: FollowupQueueSummaryState,
delivery: QueueSummaryDelivery,
): void {
for (const source of delivery.sources) {
const sourceIndex = queue.summarySources.indexOf(source);
if (sourceIndex >= 0) {
queue.summarySources[sourceIndex] = createOverflowSummaryRetrySource(source);
}
completeFollowupRunLifecycle(source);
}
}
async function runQueueSummaryDelivery(
queue: FollowupQueueSummaryState,
delivery: QueueSummaryDelivery,
run: () => Promise<void>,
): Promise<void> {
try {
await run();
} catch (err) {
if (!isFollowupRunDeferredError(err)) {
completeFollowupQueueSummarySources(queue);
}
throw err;
}
completeFollowupQueueSummarySources(queue);
}
async function runWithDeferredSummaryRestore<T>(
restore: (() => void) | undefined,
run: () => Promise<T>,
): Promise<T> {
try {
return await run();
} catch (err) {
if (isFollowupRunDeferredError(err)) {
restore?.();
releaseQueueSummaryDeliveryForRetry(queue, delivery);
}
throw err;
}
consumeQueueSummaryDelivery(queue, delivery);
}
async function dropAbortedFollowups(
@@ -349,20 +447,238 @@ async function dropAbortedFollowups(
function resolveCrossChannelKey(item: FollowupRun): { cross?: true; key?: string } {
const { originatingChannel: channel, originatingTo: to, originatingAccountId: accountId } = item;
const threadId = item.originatingThreadId;
if (!channel && !to && !accountId && (threadId == null || threadId === "")) {
return {};
const replyToId = resolveFollowupReplyAnchor(item);
const chatType = normalizeChatType(item.originatingChatType);
if (!channel && !to && !accountId && (threadId == null || threadId === "") && !replyToId) {
return chatType ? { key: JSON.stringify(["unresolved", chatType]) } : {};
}
if (!isRoutableChannel(channel) || !to) {
return { cross: true };
}
const key = channelRouteCompactKey({ channel, to, accountId, threadId });
return key ? { key } : { cross: true };
return key
? {
key: JSON.stringify([
key,
replyToId ?? "",
item.originatingReplyToMode ?? "",
chatType ?? "",
]),
}
: { cross: true };
}
function resolveOverflowSummarySourceGroup(queue: {
summarySources: FollowupRun[];
}): FollowupRun[] {
const source = queue.summarySources[0];
if (!source) {
return [];
}
const contextKey = resolveFollowupDeliveryContextKey(source);
const sources: FollowupRun[] = [];
for (const candidate of queue.summarySources) {
if (resolveFollowupDeliveryContextKey(candidate) !== contextKey) {
break;
}
sources.push(candidate);
}
return sources;
}
export function createOverflowSummaryRetrySource(source: FollowupRun): FollowupRun {
return {
prompt: source.prompt,
transcriptPrompt: source.transcriptPrompt,
messageId: source.messageId,
summaryLine: source.summaryLine,
enqueuedAt: source.enqueuedAt,
originatingChannel: source.originatingChannel,
originatingTo: source.originatingTo,
originatingAccountId: source.originatingAccountId,
originatingThreadId: source.originatingThreadId,
originatingReplyToId: source.originatingReplyToId,
originatingReplyToMode: source.originatingReplyToMode,
originatingChatType: source.originatingChatType,
run: source.run,
};
}
async function runSyntheticOverflowSummary(params: {
source: FollowupRun;
prompt: string;
runFollowup: (run: FollowupRun) => Promise<void>;
}): Promise<void> {
const promptHash = createHash("sha256").update(params.prompt).digest("hex");
const routeHash = createHash("sha256")
.update(
JSON.stringify([
channelRouteDedupeKey({
channel: params.source.originatingChannel,
to: params.source.originatingTo,
accountId: params.source.originatingAccountId,
threadId: params.source.originatingThreadId,
}),
resolveFollowupReplyAnchor(params.source) ?? "",
params.source.originatingReplyToMode ?? "",
normalizeChatType(params.source.originatingChatType) ?? "",
]),
)
.digest("hex");
const sessionKey = normalizeOptionalString(params.source.run.sessionKey);
const storePath = sessionKey
? resolveStorePath(params.source.run.config.session?.store, {
agentId: params.source.run.agentId,
})
: undefined;
const userTurnTranscriptRecorder = createUserTurnTranscriptRecorder({
input: {
text: params.prompt,
idempotencyKey: `followup-overflow:${params.source.run.sessionId}:${routeHash}:${params.source.messageId ?? params.source.enqueuedAt}:${promptHash}`,
provenance: params.source.run.inputProvenance,
},
target: () => {
if (!sessionKey || !storePath) {
return {
transcriptPath: params.source.run.sessionFile,
sessionId: params.source.run.sessionId,
agentId: params.source.run.agentId,
sessionKey: params.source.run.sessionId,
cwd: params.source.run.cwd ?? params.source.run.workspaceDir,
config: params.source.run.config,
};
}
const sessionEntry = readSessionEntry(storePath, sessionKey);
return {
sessionId: sessionEntry?.sessionId ?? params.source.run.sessionId,
sessionKey,
sessionEntry,
storePath,
agentId: params.source.run.agentId,
cwd: params.source.run.cwd ?? params.source.run.workspaceDir,
config: params.source.run.config,
};
},
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
errorContext: "followup overflow summary transcript",
});
await params.runFollowup({
prompt: params.prompt,
transcriptPrompt: params.prompt,
messageId: params.source.messageId,
userTurnTranscriptRecorder,
run: params.source.run,
enqueuedAt: Date.now(),
...resolveOriginRoutingMetadata([params.source]),
});
}
async function drainElidedOverflowSummary(params: {
queue: FollowupQueueSummaryState;
runFollowup: (run: FollowupRun) => Promise<void>;
}): Promise<boolean> {
const entry = params.queue.summaryElisions[0];
if (!entry) {
return false;
}
const retainedSources =
params.queue.summaryElisions.length === 1
? resolveOverflowSummarySourceGroup(params.queue).filter(
(source) => resolveFollowupDeliveryContextKey(source) === entry.contextKey,
)
: [];
const source = retainedSources.at(-1) ?? entry.source;
const elidedCount = entry.count;
const droppedCount = elidedCount + retainedSources.length;
const summaryLines = params.queue.summaryLines.slice(0, retainedSources.length);
const prompt = previewQueueSummaryPrompt({
state: {
dropPolicy: params.queue.dropPolicy,
droppedCount,
summaryLines,
},
noun: "message",
});
if (!prompt) {
return false;
}
await runQueueSummaryDelivery(
params.queue,
{
prompt,
droppedCount: retainedSources.length,
sources: retainedSources,
},
async () => {
await runSyntheticOverflowSummary({
source,
prompt,
runFollowup: params.runFollowup,
});
},
);
const entryIndex = params.queue.summaryElisions.indexOf(entry);
if (entryIndex < 0) {
return true;
}
const consumedCount = Math.min(elidedCount, entry.count);
entry.count -= consumedCount;
params.queue.droppedCount = Math.max(0, params.queue.droppedCount - consumedCount);
if (entry.count === 0) {
params.queue.summaryElisions.splice(entryIndex, 1);
}
return true;
}
async function drainOverflowSummaryGroup(params: {
queue: FollowupQueueSummaryState;
runFollowup: (run: FollowupRun) => Promise<void>;
}): Promise<boolean> {
if (params.queue.evictedSummaryCount > 0) {
const evictedCount = params.queue.evictedSummaryCount;
params.queue.evictedSummaryCount = 0;
params.queue.droppedCount = Math.max(0, params.queue.droppedCount - evictedCount);
defaultRuntime.error?.(
`followup queue omitted ${evictedCount} route-isolated overflow summar${evictedCount === 1 ? "y" : "ies"} after reaching the summary context cap`,
);
return true;
}
if (await drainElidedOverflowSummary(params)) {
return true;
}
const sources = resolveOverflowSummarySourceGroup(params.queue);
const source = sources.at(-1);
if (!source) {
return false;
}
const delivery = createQueueSummaryDelivery({
queue: params.queue,
sources,
});
if (!delivery) {
return false;
}
await runQueueSummaryDelivery(params.queue, delivery, async () => {
await runSyntheticOverflowSummary({
source,
prompt: delivery.prompt,
runFollowup: params.runFollowup,
});
});
return true;
}
export function scheduleFollowupDrain(
key: string,
runFollowup: (run: FollowupRun) => Promise<void>,
): void {
const existingQueue = FOLLOWUP_QUEUES.get(key);
if (existingQueue?.draining) {
// The active drain keeps its current callback, but deferred retries must
// use the latest session/runtime context supplied by the finishing run.
rememberFollowupDrainCallback(key, runFollowup);
return;
}
const queue = beginQueueDrain(FOLLOWUP_QUEUES, key);
if (!queue) {
return;
@@ -391,6 +707,15 @@ export function scheduleFollowupDrain(
if (queue.items.length === 0 && queue.droppedCount === 0) {
break;
}
if (
queue.droppedCount > 0 &&
(await drainOverflowSummaryGroup({
queue,
runFollowup: effectiveRunFollowup,
}))
) {
continue;
}
if (queue.mode === "collect") {
// Once the batch is mixed, never collect again within this drain.
// Prevents “collect after shift” collapsing different targets.
@@ -412,28 +737,6 @@ export function scheduleFollowupDrain(
run: effectiveRunFollowup,
});
if (collectDrainResult === "empty") {
const summaryOnly = previewRestorableQueueSummaryPrompt({
state: queue,
noun: "message",
});
const summaryOnlyPrompt = summaryOnly.prompt;
const run = queue.lastRun;
if (summaryOnlyPrompt && run) {
await runWithDeferredSummaryRestore(summaryOnly.restore, async () => {
await runWithSummarySourceCleanup(queue, async () => {
await effectiveRunFollowup({
prompt: summaryOnlyPrompt,
run,
enqueuedAt: Date.now(),
...collectSummaryRuntimeMetadata([]),
...collectQueuedImages(queue.items),
});
});
});
clearFollowupQueueSummaryState(queue);
continue;
}
summaryOnly.restore?.();
break;
}
if (collectDrainResult === "drained") {
@@ -441,35 +744,14 @@ export function scheduleFollowupDrain(
}
const items = queue.items.slice();
const summaryResult = previewRestorableQueueSummaryPrompt({
state: queue,
noun: "message",
});
const summary = summaryResult.prompt;
const authGroups = splitCollectItemsByAuthorization(items);
if (authGroups.length === 0) {
const run = queue.lastRun;
if (!summary || !run) {
summaryResult.restore?.();
break;
}
await runWithDeferredSummaryRestore(summaryResult.restore, async () => {
await runWithSummarySourceCleanup(queue, async () => {
await effectiveRunFollowup({
prompt: summary,
run,
enqueuedAt: Date.now(),
...collectSummaryRuntimeMetadata([]),
});
});
});
clearFollowupQueueSummaryState(queue);
continue;
const contextGroups = splitCollectItemsByDeliveryContext(items);
if (contextGroups.length === 0) {
break;
}
let pendingSummary = summary;
for (const groupItems of authGroups) {
const run = groupItems.at(-1)?.run ?? queue.lastRun;
for (const groupItems of contextGroups) {
const groupSource = groupItems.at(-1);
const run = groupSource?.run ?? queue.lastRun;
if (!run) {
break;
}
@@ -478,71 +760,27 @@ export function scheduleFollowupDrain(
const prompt = buildCollectPrompt({
title: "[Queued messages while agent was busy]",
items: groupItems,
summary: pendingSummary,
renderItem: renderCollectItem,
});
const drainGroup = async () => {
await effectiveRunFollowup({
prompt,
run,
messageId:
groupSource?.messageId ??
(groupSource ? resolveFollowupReplyAnchor(groupSource) : undefined),
enqueuedAt: Date.now(),
...routing,
...collectRuntimeMetadata(groupItems),
...collectQueuedImages(groupItems),
});
};
if (pendingSummary) {
await runWithDeferredSummaryRestore(summaryResult.restore, async () => {
await runWithSummarySourceCleanup(queue, drainGroup);
});
} else {
await drainGroup();
}
await drainGroup();
removeQueuedItemsByRef(queue.items, groupItems);
if (pendingSummary) {
clearFollowupQueueSummaryState(queue);
pendingSummary = undefined;
}
}
continue;
}
const summaryResult = previewRestorableQueueSummaryPrompt({
state: queue,
noun: "message",
});
const summaryPrompt = summaryResult.prompt;
if (summaryPrompt) {
const run = queue.lastRun;
if (!run) {
summaryResult.restore?.();
break;
}
if (
!(await runWithDeferredSummaryRestore(summaryResult.restore, async () =>
drainNextQueueItem(queue.items, async (item) => {
await runWithSummarySourceCleanup(queue, async () => {
await effectiveRunFollowup({
prompt: summaryPrompt,
run,
enqueuedAt: Date.now(),
originatingChannel: item.originatingChannel,
originatingTo: item.originatingTo,
originatingAccountId: item.originatingAccountId,
originatingThreadId: item.originatingThreadId,
...collectSummaryRuntimeMetadata([item]),
...collectQueuedImages([item]),
});
});
}),
))
) {
break;
}
clearFollowupQueueSummaryState(queue);
continue;
}
if (!(await drainNextQueueItem(queue.items, effectiveRunFollowup))) {
break;
}

View File

@@ -1,9 +1,16 @@
// Enqueues follow-up reply runs and schedules queue drains.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { resolveGlobalDedupeCache } from "../../../infra/dedupe.js";
import { channelRouteDedupeKey } from "../../../plugin-sdk/channel-route.js";
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
import { kickFollowupDrainIfIdle, rememberFollowupDrainCallback } from "./drain.js";
import {
createOverflowSummaryRetrySource,
kickFollowupDrainIfIdle,
rememberFollowupDrainCallback,
resolveFollowupDeliveryContextKey,
resolveFollowupReplyAnchor,
} from "./drain.js";
import { getExistingFollowupQueue, getFollowupQueue } from "./state.js";
import {
completeFollowupRunLifecycle,
@@ -26,12 +33,29 @@ const RECENT_QUEUE_MESSAGE_IDS = resolveGlobalDedupeCache(RECENT_QUEUE_MESSAGE_I
});
function followupRouteIdentityKey(run: FollowupRun): string {
return channelRouteDedupeKey({
channel: run.originatingChannel,
to: run.originatingTo,
accountId: run.originatingAccountId,
threadId: run.originatingThreadId,
});
return JSON.stringify([
channelRouteDedupeKey({
channel: run.originatingChannel,
to: run.originatingTo,
accountId: run.originatingAccountId,
threadId: run.originatingThreadId,
}),
resolveFollowupReplyAnchor(run) ?? "",
run.originatingReplyToMode ?? "",
normalizeChatType(run.originatingChatType) ?? "",
]);
}
function followupMessageRouteIdentityKey(run: FollowupRun): string {
return JSON.stringify([
channelRouteDedupeKey({
channel: run.originatingChannel,
to: run.originatingTo,
accountId: run.originatingAccountId,
threadId: run.originatingThreadId,
}),
normalizeChatType(run.originatingChatType) ?? "",
]);
}
function buildRecentMessageIdKey(run: FollowupRun, queueKey: string): string | undefined {
@@ -41,7 +65,7 @@ function buildRecentMessageIdKey(run: FollowupRun, queueKey: string): string | u
}
// Use JSON tuple serialization to avoid delimiter-collision edge cases when
// channel/to/account values contain "|" characters.
return JSON.stringify(["queue", queueKey, followupRouteIdentityKey(run), messageId]);
return JSON.stringify(["queue", queueKey, followupMessageRouteIdentityKey(run), messageId]);
}
function isRunAlreadyQueued(
@@ -49,19 +73,22 @@ function isRunAlreadyQueued(
items: FollowupRun[],
allowPromptFallback = false,
): boolean {
const routeKey = followupRouteIdentityKey(run);
const hasSameRouting = (item: FollowupRun) => followupRouteIdentityKey(item) === routeKey;
const messageId = normalizeOptionalString(run.messageId);
if (messageId) {
const messageRouteKey = followupMessageRouteIdentityKey(run);
return items.some(
(item) => normalizeOptionalString(item.messageId) === messageId && hasSameRouting(item),
(item) =>
normalizeOptionalString(item.messageId) === messageId &&
followupMessageRouteIdentityKey(item) === messageRouteKey,
);
}
if (!allowPromptFallback) {
return false;
}
return items.some((item) => item.prompt === run.prompt && hasSameRouting(item));
const routeKey = followupRouteIdentityKey(run);
return items.some(
(item) => item.prompt === run.prompt && followupRouteIdentityKey(item) === routeKey,
);
}
export function enqueueFollowupRun(
@@ -91,7 +118,6 @@ export function enqueueFollowupRun(
if (shouldSkipQueueItem({ item: run, items: queue.items, dedupe })) {
return false;
}
queue.lastEnqueuedAt = Date.now();
queue.lastRun = run.run;
@@ -113,6 +139,27 @@ export function enqueueFollowupRun(
if (overflow > 0) {
const removed = queue.summarySources.splice(0, overflow);
for (const item of removed) {
const contextKey = resolveFollowupDeliveryContextKey(item);
const lastElision = queue.summaryElisions.at(-1);
if (lastElision?.contextKey === contextKey) {
lastElision.count += 1;
lastElision.source = createOverflowSummaryRetrySource(item);
lastElision.sourceRefs.add(item);
} else {
if (queue.summaryElisions.length >= queue.cap) {
const evicted = queue.summaryElisions.shift();
if (evicted) {
queue.evictedSummaryCount += evicted.count;
completeFollowupRunLifecycle(evicted.source);
}
}
queue.summaryElisions.push({
contextKey,
count: 1,
source: createOverflowSummaryRetrySource(item),
sourceRefs: new WeakSet([item]),
});
}
completeFollowupRunLifecycle(item);
}
}

View File

@@ -36,8 +36,24 @@ describe("refreshQueuedFollowupSession", () => {
enqueuedAt: Date.now(),
run: makeRun(),
};
const summarizedRun: FollowupRun = {
prompt: "summarized message",
enqueuedAt: Date.now(),
run: makeRun(),
};
queue.lastRun = lastRun;
queue.items.push(queuedRun);
queue.summarySources.push(summarizedRun);
queue.summaryElisions.push({
contextKey: "context",
count: 2,
source: {
prompt: "elided summary",
enqueuedAt: Date.now(),
run: makeRun(),
},
sourceRefs: new WeakSet(),
});
refreshQueuedFollowupSession({
key: QUEUE_KEY,
@@ -61,6 +77,20 @@ describe("refreshQueuedFollowupSession", () => {
authProfileId: undefined,
authProfileIdSource: undefined,
});
expect(queue.summarySources[0]?.run).toEqual({
...makeRun(),
provider: "openai",
model: "gpt-4o",
authProfileId: undefined,
authProfileIdSource: undefined,
});
expect(queue.summaryElisions[0]?.source.run).toEqual({
...makeRun(),
provider: "openai",
model: "gpt-4o",
authProfileId: undefined,
authProfileIdSource: undefined,
});
});
it("retargets queued runs with user model override source", () => {
@@ -88,3 +118,31 @@ describe("refreshQueuedFollowupSession", () => {
});
});
});
describe("getFollowupQueue", () => {
it("trims overflow metadata when a live queue cap shrinks", () => {
const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup", cap: 3 });
for (const [contextKey, count] of [
["oldest", 2],
["middle", 3],
["newest", 4],
] as const) {
queue.summaryElisions.push({
contextKey,
count,
source: {
prompt: contextKey,
enqueuedAt: Date.now(),
run: makeRun(),
},
sourceRefs: new WeakSet(),
});
}
queue.evictedSummaryCount = 5;
const updated = getFollowupQueue(QUEUE_KEY, { mode: "followup", cap: 1 });
expect(updated.summaryElisions.map((entry) => entry.contextKey)).toEqual(["newest"]);
expect(updated.evictedSummaryCount).toBe(10);
});
});

View File

@@ -21,6 +21,13 @@ export type FollowupQueueState = {
droppedCount: number;
summaryLines: string[];
summarySources: FollowupRun[];
summaryElisions: Array<{
contextKey: string;
count: number;
source: FollowupRun;
sourceRefs: WeakSet<FollowupRun>;
}>;
evictedSummaryCount: number;
lastRun?: FollowupRun["run"];
};
@@ -44,6 +51,17 @@ export function getExistingFollowupQueue(key: string): FollowupQueueState | unde
return FOLLOWUP_QUEUES.get(cleaned);
}
function trimSummaryElisionsToCap(queue: FollowupQueueState): void {
while (queue.summaryElisions.length > queue.cap) {
const evicted = queue.summaryElisions.shift();
if (!evicted) {
return;
}
queue.evictedSummaryCount += evicted.count;
completeFollowupRunLifecycle(evicted.source);
}
}
export function getFollowupQueue(key: string, settings: QueueSettings): FollowupQueueState {
const existing = FOLLOWUP_QUEUES.get(key);
if (existing) {
@@ -51,6 +69,7 @@ export function getFollowupQueue(key: string, settings: QueueSettings): Followup
target: existing,
settings,
});
trimSummaryElisionsToCap(existing);
return existing;
}
@@ -71,6 +90,8 @@ export function getFollowupQueue(key: string, settings: QueueSettings): Followup
droppedCount: 0,
summaryLines: [],
summarySources: [],
summaryElisions: [],
evictedSummaryCount: 0,
};
applyQueueRuntimeSettings({
target: created,
@@ -93,10 +114,15 @@ export function clearFollowupQueue(key: string): number {
for (const item of queue.summarySources) {
completeFollowupRunLifecycle(item);
}
for (const entry of queue.summaryElisions) {
completeFollowupRunLifecycle(entry.source);
}
queue.items.length = 0;
queue.droppedCount = 0;
queue.summaryLines = [];
queue.summarySources = [];
queue.summaryElisions = [];
queue.evictedSummaryCount = 0;
queue.lastRun = undefined;
queue.lastEnqueuedAt = 0;
FOLLOWUP_QUEUES.delete(cleaned);
@@ -176,4 +202,10 @@ export function refreshQueuedFollowupSession(params: {
for (const item of queue.items) {
rewriteRun(item.run);
}
for (const item of queue.summarySources) {
rewriteRun(item.run);
}
for (const entry of queue.summaryElisions) {
rewriteRun(entry.source.run);
}
}

View File

@@ -6,6 +6,7 @@ import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types.
import type { ChatType } from "../../../channels/chat-type.js";
import type { InboundEventKind } from "../../../channels/inbound-event/kind.js";
import type { SessionEntry } from "../../../config/sessions.js";
import type { ReplyToMode } from "../../../config/types.base.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
@@ -45,6 +46,8 @@ export function isFollowupRunDeferredError(error: unknown): error is FollowupRun
export type FollowupRun = {
prompt: string;
/** Latest session to claim without rewriting the queued run before store refresh. */
admissionSessionId?: string;
/** User-visible prompt body persisted to transcript; excludes runtime-only prompt context. */
transcriptPrompt?: string;
/** Shared lifecycle owner for the current user-turn transcript append. */
@@ -81,6 +84,8 @@ export type FollowupRun = {
originatingThreadId?: string | number;
/** Provider reply target for transports that model threads as message replies. */
originatingReplyToId?: string;
/** Effective reply policy for deciding whether the reply target affects queued delivery. */
originatingReplyToMode?: ReplyToMode;
/** Chat type for context-aware threading (e.g., DM vs channel). */
originatingChatType?: string;
run: {

View File

@@ -16,6 +16,7 @@ import type {
ReplyDispatchKind,
ReplyDispatchRuntimeInfo,
ReplyDispatcher,
ReplyFollowupAdmissionBarrierTimeoutPolicy,
} from "./reply-dispatcher.types.js";
import type { ResponsePrefixContext } from "./response-prefix-template.js";
import type { TypingController } from "./typing.js";
@@ -75,6 +76,18 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number {
return min + generateSecureInt(max - min + 1);
}
function getHumanDelayMax(config: HumanDelayConfig | undefined): number {
const mode = config?.mode ?? "off";
if (mode === "off") {
return 0;
}
const min =
mode === "custom" ? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS) : DEFAULT_HUMAN_DELAY_MIN_MS;
const max =
mode === "custom" ? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS) : DEFAULT_HUMAN_DELAY_MAX_MS;
return max <= min ? min : max;
}
export type ReplyDispatcherOptions = {
deliver: ReplyDispatchDeliverer;
silentReplyContext?: {
@@ -99,6 +112,13 @@ export type ReplyDispatcherOptions = {
humanDelay?: HumanDelayConfig;
beforeDeliver?: ReplyDispatchBeforeDeliver;
onBeforeDeliverCancelled?: ReplyDispatchCancelHandler;
/** Observe each queued payload settling, including cancellation and delivery failure. */
onDeliverySettled?: (info: ReplyDispatchRuntimeInfo) => void;
/** Resolve an owner activity policy for holding queued follow-ups behind delivery. */
resolveFollowupAdmissionBarrierTimeoutPolicy?: (context: {
queuedCounts: Readonly<Record<ReplyDispatchKind, number>>;
humanDelayBudgetMs: number;
}) => ReplyFollowupAdmissionBarrierTimeoutPolicy | undefined;
};
export type ReplyDispatcherWithTypingOptions = Omit<ReplyDispatcherOptions, "onIdle"> & {
@@ -252,6 +272,12 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
void options.onError?.(err, buildReplyDispatchRuntimeInfo(normalized, kind));
})
.finally(() => {
const dispatchInfo = buildReplyDispatchRuntimeInfo(normalized, kind);
try {
options.onDeliverySettled?.(dispatchInfo);
} catch (err: unknown) {
void options.onError?.(err, dispatchInfo);
}
pending -= 1;
// Clear reservation if:
// 1. pending is now 1 (just the reservation left)
@@ -309,6 +335,15 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
getCancelledCounts: () => ({ ...cancelledCounts }),
getFailedCounts: () => ({ ...failedCounts }),
markComplete,
resolveFollowupAdmissionBarrierTimeoutPolicy:
options.resolveFollowupAdmissionBarrierTimeoutPolicy
? () =>
options.resolveFollowupAdmissionBarrierTimeoutPolicy?.({
queuedCounts: { ...queuedCounts },
humanDelayBudgetMs:
Math.max(0, queuedCounts.block - 1) * getHumanDelayMax(options.humanDelay),
})
: undefined,
};
}

View File

@@ -3,6 +3,13 @@ import type { ReplyPayload } from "../types.js";
export type ReplyDispatchKind = "tool" | "block" | "final";
export type ReplyFollowupAdmissionBarrierTimeoutPolicy = {
/** Absolute failsafe for owner activity that never settles. */
maxTimeoutMs: number;
/** Extend by another default settle interval while bounded owner work remains active. */
shouldExtend: () => boolean;
};
export type ReplyDispatchRuntimeInfo = {
kind: ReplyDispatchKind;
assistantMessageIndex?: number;
@@ -23,6 +30,10 @@ export type ReplyDispatcher = {
getCancelledCounts?: () => Record<ReplyDispatchKind, number>;
getFailedCounts: () => Record<ReplyDispatchKind, number>;
markComplete: () => void;
/** Owner-declared deadline for holding queued follow-ups behind all queued deliveries. */
resolveFollowupAdmissionBarrierTimeoutPolicy?: () =>
| ReplyFollowupAdmissionBarrierTimeoutPolicy
| undefined;
};
/**

View File

@@ -169,6 +169,51 @@ describe("createReplyDispatcher", () => {
expect(onIdle).toHaveBeenCalledTimes(1);
});
it("resolves an owner-declared follow-up admission barrier policy from queued deliveries", async () => {
vi.useFakeTimers();
try {
const dispatcher = createReplyDispatcher({
deliver: async () => {},
resolveFollowupAdmissionBarrierTimeoutPolicy: ({ queuedCounts, humanDelayBudgetMs }) => ({
maxTimeoutMs:
Object.values(queuedCounts).reduce((sum, count) => sum + count, 0) * 35 * 60_000 +
humanDelayBudgetMs,
shouldExtend: () => true,
}),
humanDelay: { mode: "custom", minMs: 10_000, maxMs: 20_000 },
});
dispatcher.sendToolResult({ text: "tool" });
dispatcher.sendBlockReply({ text: "block one" });
dispatcher.sendBlockReply({ text: "block two" });
dispatcher.sendFinalReply({ text: "final" });
dispatcher.markComplete();
const policy = dispatcher.resolveFollowupAdmissionBarrierTimeoutPolicy?.();
expect(policy?.maxTimeoutMs).toBe(140 * 60_000 + 20_000);
expect(policy?.shouldExtend()).toBe(true);
await vi.runAllTimersAsync();
await dispatcher.waitForIdle();
} finally {
vi.useRealTimers();
}
});
it("reports each queued delivery settlement", async () => {
const onDeliverySettled = vi.fn();
const dispatcher = createReplyDispatcher({
deliver: vi.fn().mockRejectedValueOnce(new Error("send failed")).mockResolvedValue(undefined),
onDeliverySettled,
});
dispatcher.sendToolResult({ text: "tool" });
dispatcher.sendFinalReply({ text: "final" });
dispatcher.markComplete();
await dispatcher.waitForIdle();
expect(onDeliverySettled).toHaveBeenCalledTimes(2);
expect(onDeliverySettled).toHaveBeenNthCalledWith(1, { kind: "tool" });
expect(onDeliverySettled).toHaveBeenNthCalledWith(2, { kind: "final" });
});
it("delays block replies after the first when humanDelay is natural", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue(undefined);

View File

@@ -2,7 +2,7 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { ReplyToMode } from "../../config/types.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import { copyReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload, ReplyThreadingPolicy } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
@@ -32,6 +32,11 @@ function resolveReplyThreadingForPayload(params: {
currentMessageId?: string;
replyThreading?: ReplyThreadingPolicy;
}): ReplyPayload {
const payload = normalizeOptionalString(params.payload.replyToId)
? setReplyPayloadMetadata(copyReplyPayloadMetadata(params.payload, { ...params.payload }), {
replyToIdExplicit: true,
})
: params.payload;
const implicitReplyToId = normalizeOptionalString(params.implicitReplyToId);
const currentMessageId = normalizeOptionalString(params.currentMessageId);
const allowImplicitReplyToCurrentMessage = resolveImplicitCurrentMessageReplyAllowance(
@@ -40,13 +45,13 @@ function resolveReplyThreadingForPayload(params: {
);
let resolved: ReplyPayload =
params.payload.replyToId ||
params.payload.replyToCurrent === false ||
payload.replyToId ||
payload.replyToCurrent === false ||
!implicitReplyToId ||
!allowImplicitReplyToCurrentMessage
? params.payload
: copyReplyPayloadMetadata(params.payload, {
...params.payload,
? payload
: copyReplyPayloadMetadata(payload, {
...payload,
replyToId: implicitReplyToId,
});

View File

@@ -8,13 +8,14 @@ import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.ty
import { getChannelPlugin } from "../../channels/plugins/index.js";
import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
channelRouteTargetsMatchExact,
stringifyRouteThreadId,
type ChannelRouteTargetInput,
} from "../../plugin-sdk/channel-route.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import { copyReplyPayloadMetadata } from "../reply-payload.js";
import { copyReplyPayloadMetadata, type ReplyDeliveryContext } from "../reply-payload.js";
import type { ReplyPayload } from "../types.js";
/** Removes text payloads already sent by message tools. */
@@ -132,7 +133,7 @@ function normalizeProviderForComparison(value?: string): string | undefined {
return lowered;
}
function normalizeThreadIdForComparison(value?: string): string | undefined {
function normalizeThreadIdForComparison(value?: string | number | null): string | undefined {
return stringifyRouteThreadId(value);
}
@@ -199,11 +200,53 @@ function targetsMatchForDedupe(params: {
return params.targetKey === params.originTarget;
}
function resolveOriginThreadIdForPayload(params: {
provider: string;
config?: OpenClawConfig;
accountId?: string;
originatingThreadId?: string | number;
replyToId?: string;
replyToIsExplicit?: boolean;
replyDelivery?: ReplyDeliveryContext;
}): string | undefined {
const originThreadId = normalizeThreadIdForComparison(params.originatingThreadId);
if (originThreadId && !params.replyToIsExplicit) {
return originThreadId;
}
const replyToId = normalizeThreadIdForComparison(params.replyToId);
const resolveReplyTransport = getChannelPlugin(params.provider)?.threading?.resolveReplyTransport;
if (!replyToId || !params.config || !resolveReplyTransport) {
return originThreadId;
}
const transport = resolveReplyTransport({
cfg: params.config,
accountId: params.accountId,
threadId: originThreadId,
replyToId,
replyToIsExplicit: params.replyToIsExplicit,
replyDelivery: params.replyDelivery,
});
if (transport?.threadId != null) {
return normalizeThreadIdForComparison(transport.threadId) ?? originThreadId;
}
// An explicit null means the provider transports its conversation thread
// through replyToId. Undefined reply ids remain native message references.
if (transport?.threadId === null) {
return normalizeThreadIdForComparison(transport.replyToId);
}
return originThreadId;
}
/** Returns true when message-tool route evidence says source replies should be deduped. */
export function shouldDedupeMessagingToolRepliesForRoute(params: {
config?: OpenClawConfig;
messageProvider?: string;
messagingToolSentTargets?: MessagingToolSend[];
originatingTo?: string;
originatingThreadId?: string | number;
replyToId?: string;
replyToIsExplicit?: boolean;
replyDelivery?: ReplyDeliveryContext;
accountId?: string;
}): boolean {
return getMatchingMessagingToolReplyTargets(params).length > 0;
@@ -211,9 +254,14 @@ export function shouldDedupeMessagingToolRepliesForRoute(params: {
/** Finds message-tool sends that target the same channel/account/thread as the source reply. */
export function getMatchingMessagingToolReplyTargets(params: {
config?: OpenClawConfig;
messageProvider?: string;
messagingToolSentTargets?: MessagingToolSend[];
originatingTo?: string;
originatingThreadId?: string | number;
replyToId?: string;
replyToIsExplicit?: boolean;
replyDelivery?: ReplyDeliveryContext;
accountId?: string;
}): MessagingToolSend[] {
const provider = normalizeProviderForComparison(params.messageProvider);
@@ -226,6 +274,15 @@ export function getMatchingMessagingToolReplyTargets(params: {
if (sentTargets.length === 0) {
return [];
}
const originThreadId = resolveOriginThreadIdForPayload({
provider,
config: params.config,
accountId: originAccount,
originatingThreadId: params.originatingThreadId,
replyToId: params.replyToId,
replyToIsExplicit: params.replyToIsExplicit,
replyDelivery: params.replyDelivery,
});
return sentTargets.filter((target) => {
const targetProvider = resolveTargetProviderForComparison({
currentProvider: provider,
@@ -244,6 +301,7 @@ export function getMatchingMessagingToolReplyTargets(params: {
provider,
rawTarget: originRawTarget,
accountId: routeAccount,
threadId: originThreadId,
});
if (!originRoute) {
return false;
@@ -252,7 +310,7 @@ export function getMatchingMessagingToolReplyTargets(params: {
provider: targetProvider,
rawTarget: targetRaw,
accountId: routeAccount,
threadId: target.threadId,
threadId: target.threadId ?? (target.threadImplicit ? originThreadId : undefined),
});
if (!targetRoute) {
return false;
@@ -260,6 +318,18 @@ export function getMatchingMessagingToolReplyTargets(params: {
if (channelRouteTargetsMatchExact({ left: originRoute, right: targetRoute })) {
return true;
}
// For providers without a thread-aware suppression matcher (e.g. Slack), a
// structured thread id on either side means the routes are NOT the same
// conversation, so do not fall back to channel-only matching (which would
// collapse distinct threads together and suppress a real reply). Providers
// that encode the thread/topic inside the target string carry their own
// matcher and must still run it.
const hasPluginThreadMatcher = Boolean(
getChannelPlugin(provider)?.outbound?.targetsMatchForReplySuppression,
);
if (!hasPluginThreadMatcher && (originRoute.threadId != null || targetRoute.threadId != null)) {
return false;
}
return targetsMatchForDedupe({
provider,
originTarget: originRoute.to,
@@ -281,16 +351,26 @@ export type MessagingToolPayloadDedupeDecision = {
/** Resolves whether and how to dedupe final payloads against message-tool sends. */
export function resolveMessagingToolPayloadDedupe(params: {
config?: OpenClawConfig;
messageProvider?: string;
messagingToolSentTargets?: MessagingToolSend[];
originatingTo?: string;
originatingThreadId?: string | number;
replyToId?: string;
replyToIsExplicit?: boolean;
replyDelivery?: ReplyDeliveryContext;
accountId?: string;
}): MessagingToolPayloadDedupeDecision {
const sentTargets = params.messagingToolSentTargets ?? [];
const matchingTargets = getMatchingMessagingToolReplyTargets({
config: params.config,
messageProvider: params.messageProvider,
messagingToolSentTargets: sentTargets,
originatingTo: params.originatingTo,
originatingThreadId: params.originatingThreadId,
replyToId: params.replyToId,
replyToIsExplicit: params.replyToIsExplicit,
replyDelivery: params.replyDelivery,
accountId: params.accountId,
});
const matchingRoute = matchingTargets.length > 0;

View File

@@ -13,7 +13,9 @@ import {
isReplyRunActiveForSessionId,
isReplyRunAbortableForCompaction,
queueReplyRunMessage,
REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
replyRunRegistry,
runAfterReplyOperationClear,
resolveActiveReplyRunSessionId,
waitForReplyRunEndBySessionId,
} from "./reply-run-registry.js";
@@ -139,6 +141,260 @@ describe("reply run registry", () => {
expect(afterClear).toHaveBeenCalledTimes(1);
});
it("clears active state before a deferred after-clear barrier settles", async () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-deferred",
resetTriggered: false,
});
let releaseBarrier: () => void = () => {};
const barrier = new Promise<void>((resolve) => {
releaseBarrier = resolve;
});
const afterClear = vi.fn();
runAfterReplyOperationClear(operation, afterClear);
operation.completeWithAfterClearBarrier(barrier);
expect(operation.result).toEqual({ kind: "completed" });
expect(replyRunRegistry.isActive("agent:main:main")).toBe(false);
expect(afterClear).not.toHaveBeenCalled();
releaseBarrier();
await barrier;
await vi.waitFor(() => {
expect(afterClear).toHaveBeenCalledTimes(1);
});
});
it("keeps later after-clear work behind earlier delivery barriers", async () => {
const first = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "first-session",
resetTriggered: false,
});
let releaseFirst: () => void = () => {};
const firstBarrier = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const firstAfterClear = vi.fn();
runAfterReplyOperationClear(first, firstAfterClear);
first.completeWithAfterClearBarrier(firstBarrier);
const second = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "second-session",
resetTriggered: false,
});
let releaseSecond: () => void = () => {};
const secondBarrier = new Promise<void>((resolve) => {
releaseSecond = resolve;
});
const secondAfterClear = vi.fn();
runAfterReplyOperationClear(second, secondAfterClear);
second.completeWithAfterClearBarrier(secondBarrier);
releaseSecond();
await secondBarrier;
expect(secondAfterClear).not.toHaveBeenCalled();
releaseFirst();
await firstBarrier;
await vi.waitFor(() => {
expect(firstAfterClear).toHaveBeenCalledWith("first-session");
expect(secondAfterClear).toHaveBeenCalledWith("second-session");
});
});
it("keeps follow-up admission blocked until slow delivery settles", async () => {
vi.useFakeTimers();
try {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "hung-session",
resetTriggered: false,
});
let releaseBarrier: () => void = () => {};
const barrier = new Promise<void>((resolve) => {
releaseBarrier = resolve;
});
const afterClear = vi.fn();
runAfterReplyOperationClear(operation, afterClear);
operation.completeWithAfterClearBarrier(barrier, 35 * 60_000);
await vi.advanceTimersByTimeAsync(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS);
expect(afterClear).not.toHaveBeenCalled();
expect(() =>
createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "blocked-session",
resetTriggered: false,
respectFollowupAdmissionBarrier: true,
}),
).toThrow("Reply follow-up admission is blocked");
releaseBarrier();
await barrier;
await vi.waitFor(() => {
expect(afterClear).toHaveBeenCalledWith("hung-session");
});
const next = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "next-session",
resetTriggered: false,
respectFollowupAdmissionBarrier: true,
});
next.complete();
} finally {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
}
});
it("extends a hung delivery barrier only while bounded owner work remains active", async () => {
vi.useFakeTimers();
try {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "active-owner-session",
resetTriggered: false,
});
let ownerActive = true;
const afterClear = vi.fn();
runAfterReplyOperationClear(operation, afterClear);
operation.completeWithAfterClearBarrier(new Promise<void>(() => {}), {
maxTimeoutMs: REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS * 3,
shouldExtend: () => ownerActive,
});
await vi.advanceTimersByTimeAsync(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS);
expect(afterClear).not.toHaveBeenCalled();
ownerActive = false;
await vi.advanceTimersByTimeAsync(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS);
await vi.waitFor(() => {
expect(afterClear).toHaveBeenCalledWith("active-owner-session");
});
} finally {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
}
});
it("keeps follow-up admission blocked during an unsettled inter-block delay", async () => {
vi.useFakeTimers();
try {
const operation = createReplyOperation({
sessionKey: "agent:main:mattermost:direct:user-1",
sessionId: "mattermost-delivery-session",
resetTriggered: false,
});
let settledDeliveryCount = 1;
const queuedDeliveryCount = 2;
const afterClear = vi.fn();
runAfterReplyOperationClear(operation, afterClear);
operation.completeWithAfterClearBarrier(new Promise<void>(() => {}), {
maxTimeoutMs: REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS * 3,
shouldExtend: () => settledDeliveryCount < queuedDeliveryCount,
});
await vi.advanceTimersByTimeAsync(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS);
expect(afterClear).not.toHaveBeenCalled();
expect(() =>
createReplyOperation({
sessionKey: "agent:main:mattermost:direct:user-1",
sessionId: "queued-followup",
resetTriggered: false,
respectFollowupAdmissionBarrier: true,
}),
).toThrow();
settledDeliveryCount = 2;
await vi.advanceTimersByTimeAsync(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS);
await vi.waitFor(() => {
expect(afterClear).toHaveBeenCalledWith("mattermost-delivery-session");
});
const followup = createReplyOperation({
sessionKey: "agent:main:mattermost:direct:user-1",
sessionId: "admitted-followup",
resetTriggered: false,
respectFollowupAdmissionBarrier: true,
});
followup.complete();
} finally {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
}
});
it("eventually releases a permanently hung delivery barrier at the default timeout", async () => {
vi.useFakeTimers();
try {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "hung-session",
resetTriggered: false,
});
const afterClear = vi.fn();
runAfterReplyOperationClear(operation, afterClear);
operation.completeWithAfterClearBarrier(new Promise<void>(() => {}));
await vi.advanceTimersByTimeAsync(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS - 1);
expect(afterClear).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await vi.waitFor(() => {
expect(afterClear).toHaveBeenCalledWith("hung-session");
});
const next = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "next-session",
resetTriggered: false,
respectFollowupAdmissionBarrier: true,
});
next.complete();
} finally {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
}
});
it("retains failed operations until final delivery completes", () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-failed",
resetTriggered: false,
});
const afterClear = vi.fn();
operation.retainFailureUntilComplete();
runAfterReplyOperationClear(operation, afterClear);
operation.fail("run_failed", new Error("provider failed"));
expect(operation.result).toMatchObject({ kind: "failed", code: "run_failed" });
expect(replyRunRegistry.get("agent:main:main")).toBe(operation);
expect(afterClear).not.toHaveBeenCalled();
operation.complete();
expect(replyRunRegistry.isActive("agent:main:main")).toBe(false);
expect(afterClear).toHaveBeenCalledTimes(1);
});
it("force-clears retained failed operations", () => {
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-retained",
resetTriggered: false,
});
operation.retainFailureUntilComplete();
expect(forceClearReplyRunBySessionId("session-retained", new Error("stuck"))).toBe(true);
expect(operation.result).toMatchObject({ kind: "failed", code: "run_failed" });
expect(replyRunRegistry.isActive("agent:main:main")).toBe(false);
});
it("force-clears a running operation after abort without backend cleanup", async () => {
vi.useFakeTimers();
try {

View File

@@ -7,6 +7,7 @@ import {
} from "../../logging/diagnostic-run-activity.js";
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js";
import type { ReplyFollowupAdmissionBarrierTimeoutPolicy } from "./reply-dispatcher.types.js";
export type ReplyRunKey = string;
@@ -61,12 +62,25 @@ export type ReplyOperation = {
updateSessionId(nextSessionId: string): void;
attachBackend(handle: ReplyBackendHandle): void;
detachBackend(handle: ReplyBackendHandle): void;
/**
* Keep a failed operation active until complete() releases the session lane.
* Dispatch uses this while a user-visible failure payload still needs delivery.
*/
retainFailureUntilComplete(): void;
complete(): void;
/**
* Complete the operation, clear active-run state, then run follow-up work.
* Use when the follow-up can create another ReplyOperation for this session.
*/
completeThen(afterClear: () => void): void;
/**
* Clear active-run state immediately, but delay registered after-clear work
* until delivery or another external barrier settles.
*/
completeWithAfterClearBarrier(
barrier: PromiseLike<unknown>,
timeout?: number | ReplyFollowupAdmissionBarrierTimeoutPolicy,
): void;
fail(code: Exclude<ReplyOperationFailureCode, "aborted_by_user">, cause?: unknown): void;
abortByUser(): void;
abortForRestart(): void;
@@ -97,12 +111,18 @@ type ReplyRunWaiter = {
timer?: NodeJS.Timeout;
};
type ReplyRunFollowupAdmissionBarrier = {
settled: Promise<void>;
sessionId: string;
};
type ReplyRunState = {
activeRunsByKey: Map<string, ReplyOperation>;
activeSessionIdsByKey: Map<string, string>;
activeKeysBySessionId: Map<string, string>;
waitKeysBySessionId: Map<string, string>;
waitersByKey: Map<string, Set<ReplyRunWaiter>>;
followupAdmissionBarriersByKey: Map<string, ReplyRunFollowupAdmissionBarrier>;
};
const REPLY_RUN_STATE_KEY = Symbol.for("openclaw.replyRunRegistry");
@@ -113,7 +133,9 @@ const replyRunState = resolveGlobalSingleton<ReplyRunState>(REPLY_RUN_STATE_KEY,
activeKeysBySessionId: new Map<string, string>(),
waitKeysBySessionId: new Map<string, string>(),
waitersByKey: new Map<string, Set<ReplyRunWaiter>>(),
followupAdmissionBarriersByKey: new Map<string, ReplyRunFollowupAdmissionBarrier>(),
}));
replyRunState.followupAdmissionBarriersByKey ??= new Map();
export const REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS = 15_000;
@@ -124,6 +146,13 @@ export class ReplyRunAlreadyActiveError extends Error {
}
}
export class ReplyRunFollowupAdmissionBlockedError extends Error {
constructor(sessionKey: string) {
super(`Reply follow-up admission is blocked for ${sessionKey}`);
this.name = "ReplyRunFollowupAdmissionBlockedError";
}
}
function createUserAbortError(): Error {
const err = new Error("Reply operation aborted by user");
err.name = "AbortError";
@@ -188,11 +217,114 @@ function isReplyRunCompacting(operation: ReplyOperation): boolean {
}
const attachedBackendByOperation = new WeakMap<ReplyOperation, ReplyBackendHandle>();
const afterClearCallbacksByOperation = new WeakMap<
ReplyOperation,
Set<(sessionId: string) => void>
>();
function getAttachedBackend(operation: ReplyOperation): ReplyBackendHandle | undefined {
return attachedBackendByOperation.get(operation);
}
/** Run work after an operation no longer owns its session lane. */
export function runAfterReplyOperationClear(
operation: ReplyOperation,
afterClear: (sessionId: string) => void,
): void {
if (replyRunState.activeRunsByKey.get(operation.key) !== operation) {
afterClear(operation.sessionId);
return;
}
const callbacks =
afterClearCallbacksByOperation.get(operation) ?? new Set<(sessionId: string) => void>();
callbacks.add(afterClear);
afterClearCallbacksByOperation.set(operation, callbacks);
}
function flushReplyOperationAfterClear(operation: ReplyOperation, sessionId: string): void {
const callbacks = afterClearCallbacksByOperation.get(operation);
if (!callbacks) {
return;
}
afterClearCallbacksByOperation.delete(operation);
for (const callback of callbacks) {
callback(sessionId);
}
}
function registerFollowupAdmissionBarrier(
sessionKey: string,
sessionId: string,
barrier: PromiseLike<unknown>,
timeout: number | ReplyFollowupAdmissionBarrierTimeoutPolicy = REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
): ReplyRunFollowupAdmissionBarrier {
const barriersByKey = replyRunState.followupAdmissionBarriersByKey;
const previous = barriersByKey.get(sessionKey)?.settled;
// Owners may extend this for bounded retry envelopes; all barriers retain a failsafe.
const current = new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout>;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve();
};
const schedule = (delayMs: number, callback: () => void) => {
timer = setTimeout(callback, delayMs);
timer.unref?.();
};
if (typeof timeout === "number") {
schedule(resolveTimerTimeoutMs(timeout, REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS), finish);
} else {
const startedAt = Date.now();
const maxTimeoutMs = resolveTimerTimeoutMs(
timeout.maxTimeoutMs,
REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
);
const checkOwnerActivity = () => {
const remainingMs = maxTimeoutMs - (Date.now() - startedAt);
if (remainingMs <= 0) {
finish();
return;
}
let shouldExtend: boolean;
try {
shouldExtend = timeout.shouldExtend();
} catch {
finish();
return;
}
if (!shouldExtend) {
finish();
return;
}
schedule(Math.min(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS, remainingMs), checkOwnerActivity);
};
schedule(Math.min(REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS, maxTimeoutMs), checkOwnerActivity);
}
void Promise.resolve(barrier).then(finish, finish);
});
const settled = previous ? Promise.all([previous, current]).then(() => undefined) : current;
const entry = { settled, sessionId };
barriersByKey.set(sessionKey, entry);
void settled.then(() => {
if (barriersByKey.get(sessionKey) === entry) {
barriersByKey.delete(sessionKey);
}
});
return entry;
}
function updateFollowupAdmissionSessionId(sessionKey: string, sessionId: string): void {
const barrier = replyRunState.followupAdmissionBarriersByKey.get(sessionKey);
if (barrier) {
barrier.sessionId = sessionId;
}
}
function clearReplyRunState(params: { sessionKey: string; sessionId: string }): void {
replyRunState.activeRunsByKey.delete(params.sessionKey);
replyRunState.activeSessionIdsByKey.delete(params.sessionKey);
@@ -233,6 +365,7 @@ export function createReplyOperation(params: {
resetTriggered: boolean;
routeThreadId?: string | number;
upstreamAbortSignal?: AbortSignal;
respectFollowupAdmissionBarrier?: boolean;
}): ReplyOperation {
const sessionKey = normalizeOptionalString(params.sessionKey);
const sessionId = normalizeOptionalString(params.sessionId);
@@ -242,6 +375,12 @@ export function createReplyOperation(params: {
if (!sessionId) {
throw new Error("Reply operations require a sessionId");
}
if (
params.respectFollowupAdmissionBarrier &&
replyRunState.followupAdmissionBarriersByKey.has(sessionKey)
) {
throw new ReplyRunFollowupAdmissionBlockedError(sessionKey);
}
if (replyRunState.activeRunsByKey.has(sessionKey)) {
throw new ReplyRunAlreadyActiveError(sessionKey);
}
@@ -251,17 +390,37 @@ export function createReplyOperation(params: {
let phase: ReplyOperationPhase = "queued";
let result: ReplyOperationResult | null = null;
let stateCleared = false;
let retainFailureUntilComplete = false;
const clearState = () => {
const clearState = (
afterClearBarrier?: PromiseLike<unknown>,
followupAdmissionBarrierTimeout?: number | ReplyFollowupAdmissionBarrierTimeoutPolicy,
) => {
if (stateCleared) {
return;
}
stateCleared = true;
const registeredBarrier = afterClearBarrier
? registerFollowupAdmissionBarrier(
sessionKey,
currentSessionId,
afterClearBarrier,
followupAdmissionBarrierTimeout,
)
: undefined;
updateFollowupAdmissionSessionId(sessionKey, currentSessionId);
markReplyRunDiagnosticWorkEnded({ sessionKey, sessionId: currentSessionId });
clearReplyRunState({
sessionKey,
sessionId: currentSessionId,
});
if (!registeredBarrier) {
flushReplyOperationAfterClear(operation, currentSessionId);
return;
}
void registeredBarrier.settled.then(() =>
flushReplyOperationAfterClear(operation, registeredBarrier.sessionId),
);
};
const abortInternally = (reason?: unknown) => {
@@ -344,6 +503,7 @@ export function createReplyOperation(params: {
replyRunState.activeKeysBySessionId.delete(currentSessionId);
registerWaitSessionId(sessionKey, currentSessionId);
currentSessionId = normalizedNextSessionId;
updateFollowupAdmissionSessionId(sessionKey, currentSessionId);
replyRunState.activeSessionIdsByKey.set(sessionKey, currentSessionId);
replyRunState.activeKeysBySessionId.set(currentSessionId, sessionKey);
registerWaitSessionId(sessionKey, currentSessionId);
@@ -370,6 +530,9 @@ export function createReplyOperation(params: {
attachedBackendByOperation.delete(operation);
}
},
retainFailureUntilComplete() {
retainFailureUntilComplete = true;
},
complete() {
if (!result) {
result = { kind: "completed" };
@@ -378,15 +541,24 @@ export function createReplyOperation(params: {
clearState();
},
completeThen(afterClear) {
runAfterReplyOperationClear(operation, afterClear);
operation.complete();
afterClear();
},
completeWithAfterClearBarrier(barrier, timeoutMs) {
if (!result) {
result = { kind: "completed" };
phase = "completed";
}
clearState(barrier, timeoutMs);
},
fail(code, cause) {
if (!result) {
result = { kind: "failed", code, cause };
phase = "failed";
}
clearState();
if (!retainFailureUntilComplete) {
clearState();
}
},
abortByUser() {
const phaseBeforeAbort = phase;
@@ -560,6 +732,7 @@ export function forceClearReplyRunBySessionId(sessionId: string, cause?: unknown
return false;
}
operation.fail("run_failed", cause);
operation.complete();
return true;
}
@@ -574,6 +747,60 @@ export function waitForReplyRunEndBySessionId(
return replyRunRegistry.waitForIdle(waitKey, timeoutMs);
}
export async function waitForReplyRunFollowupAdmission(
sessionKey: string,
timeoutMs: number,
opts?: { signal?: AbortSignal },
): Promise<{ settled: boolean; sessionId?: string }> {
const normalizedSessionKey = normalizeOptionalString(sessionKey);
if (!normalizedSessionKey) {
return { settled: true };
}
const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 100, 100);
const deadline = Date.now() + resolvedTimeoutMs;
let sessionId: string | undefined;
while (true) {
if (opts?.signal?.aborted) {
return { settled: false };
}
const barrier = replyRunState.followupAdmissionBarriersByKey.get(normalizedSessionKey);
if (!barrier) {
return { settled: true, sessionId };
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return { settled: false };
}
let timer: NodeJS.Timeout | undefined;
let abortHandler: (() => void) | undefined;
const outcome = await Promise.race([
barrier.settled.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), remainingMs);
timer.unref?.();
}),
...(opts?.signal
? [
new Promise<boolean>((resolve) => {
abortHandler = () => resolve(false);
opts.signal?.addEventListener("abort", abortHandler, { once: true });
}),
]
: []),
]);
if (timer) {
clearTimeout(timer);
}
if (abortHandler) {
opts?.signal?.removeEventListener("abort", abortHandler);
}
if (!outcome) {
return { settled: false };
}
sessionId = barrier.sessionId;
}
}
export function abortActiveReplyRuns(opts: { mode: "all" | "compacting" }): boolean {
let aborted = false;
for (const operation of replyRunState.activeRunsByKey.values()) {
@@ -613,6 +840,7 @@ export const testing = {
}
}
replyRunState.waitersByKey.clear();
replyRunState.followupAdmissionBarriersByKey.clear();
},
};
export { testing as __testing };

View File

@@ -5,6 +5,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
resolveConfiguredReplyToMode,
resolveReplyDeliveryAccountId,
resolveReplyToMode,
resolveReplyToModeWithThreading,
} from "./reply-threading.js";
@@ -135,6 +136,35 @@ describe("resolveReplyToMode", () => {
expect(resolveReplyToMode({} as OpenClawConfig, "whatsapp", "work", "group")).toBe("first");
expect(resolveReplyToMode({} as OpenClawConfig, "whatsapp", "default", "group")).toBe("all");
});
it("resolves the same listed default account used by routed delivery", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: {
id: "whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["work"],
resolveAccount: () => ({}),
},
},
},
]),
);
expect(resolveReplyDeliveryAccountId(emptyCfg, "whatsapp")).toBe("work");
expect(resolveReplyDeliveryAccountId(emptyCfg, "whatsapp", "personal")).toBe("personal");
});
});
describe("resolveConfiguredReplyToMode", () => {

View File

@@ -1,11 +1,17 @@
/** Reply threading policy helpers for channel replies and status notices. */
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatType } from "../../channels/chat-type.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelThreadingAdapter } from "../../channels/plugins/types.core.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { copyReplyPayloadMetadata, isReplyPayloadStatusNotice } from "../reply-payload.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/account-id.js";
import {
copyReplyPayloadMetadata,
isReplyPayloadStatusNotice,
type ReplyDeliveryContext,
} from "../reply-payload.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload, ReplyThreadingPolicy } from "../types.js";
import { isSingleUseReplyToMode } from "./reply-reference.js";
@@ -93,6 +99,55 @@ export function resolveReplyToMode(
});
}
/** Resolve the account that routed reply delivery will use when none is explicit. */
export function resolveReplyDeliveryAccountId(
cfg: OpenClawConfig,
channel?: OriginatingChannelType,
accountId?: string | null,
): string | undefined {
const explicitAccountId = normalizeOptionalLowercaseString(accountId);
if (explicitAccountId) {
return explicitAccountId;
}
const provider = normalizeAnyChannelId(channel) ?? normalizeOptionalLowercaseString(channel);
if (!provider) {
return undefined;
}
const plugin = getChannelPlugin(provider);
if (!plugin) {
return undefined;
}
const configuredDefault = normalizeOptionalLowercaseString(plugin.config.defaultAccountId?.(cfg));
if (configuredDefault) {
return configuredDefault;
}
const channelConfiguredDefault = normalizeOptionalLowercaseString(
(cfg.channels as Record<string, { defaultAccount?: string | null } | undefined> | undefined)?.[
provider
]?.defaultAccount,
);
if (channelConfiguredDefault) {
return channelConfiguredDefault;
}
const listedDefault = plugin.config
.listAccountIds(cfg)
.map((listedAccountId) => normalizeOptionalLowercaseString(listedAccountId))
.find((listedAccountId): listedAccountId is string => Boolean(listedAccountId));
return listedDefault ?? DEFAULT_ACCOUNT_ID;
}
/** Build the canonical reply policy context consumed by delivery adapters. */
export function createReplyDeliveryContext(
replyToMode: ReplyToMode,
chatType?: string | null,
): ReplyDeliveryContext {
const normalizedChatType = normalizeChatType(chatType ?? undefined);
return {
...(normalizedChatType ? { chatType: normalizedChatType } : {}),
replyToMode,
};
}
/** Create a payload filter that strips reply targets according to reply-to mode. */
export function createReplyToModeFilter(
mode: ReplyToMode,

Some files were not shown because too many files have changed in this diff Show More