mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 03:52:42 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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." }],
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -341,6 +341,7 @@ function buildOpenClawCodingToolsOptions(
|
||||
workspaceDir,
|
||||
}),
|
||||
currentChannelId: a.currentChannelId,
|
||||
currentMessagingTarget: a.currentMessagingTarget,
|
||||
currentThreadTs: a.currentThreadTs,
|
||||
currentMessageId: a.currentMessageId,
|
||||
replyToMode: a.replyToMode,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
35
src/agents/embedded-agent-runner/tool-send-receipts.ts
Normal file
35
src/agents/embedded-agent-runner/tool-send-receipts.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() } : {}),
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -25,6 +25,7 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
|
||||
pluginToolAllowlist?: string[];
|
||||
pluginToolDenylist?: string[];
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
sandboxRoot?: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -51,6 +51,10 @@ const channelPluginMocks = vi.hoisted(() => ({
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
outbound: {
|
||||
shouldTreatDeliveredTextAsVisible: ({
|
||||
kind,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user