diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 00e0adfff86f..79f94af2078e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 61294690ef76..d02efbd50725 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -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"); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 326f471f8a27..ba10b8ae96e4 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -96,6 +96,13 @@ export function resolveOpenClawCodingToolsSessionKeys( }; } +/** Returns the canonical channel used for Codex message routing and receipts. */ +export function resolveCodexMessageToolProvider( + params: Pick, +): 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, diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 67c8de15dc96..fc6a580556a4 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -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 }) => + 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 }) => { + 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." }], diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index a15796c6cce9..26eb45a80e0b 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -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; @@ -67,6 +75,22 @@ type CodexDynamicToolSchemaQuarantine = { violations: readonly string[]; }; +function applyCurrentMessageProvider( + toolName: string, + args: Record, + currentProvider: string | undefined, +): Record { + 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; 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 } : {}), }); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index aeb7f452c6a7..d4de5a021279 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -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); diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index a972a40b7a55..7a3f0d5e3211 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -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); }); diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 15d334d035fd..97971b79f9bc 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -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, }, }); diff --git a/extensions/copilot/src/tool-bridge.test.ts b/extensions/copilot/src/tool-bridge.test.ts index d9715574d1fc..41ff40d92e57 100644 --- a/extensions/copilot/src/tool-bridge.test.ts +++ b/extensions/copilot/src/tool-bridge.test.ts @@ -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", diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index ec02aab4d6f5..5f7ac5a5fe8a 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -341,6 +341,7 @@ function buildOpenClawCodingToolsOptions( workspaceDir, }), currentChannelId: a.currentChannelId, + currentMessagingTarget: a.currentMessagingTarget, currentThreadTs: a.currentThreadTs, currentMessageId: a.currentMessageId, replyToMode: a.replyToMode, diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index a00caec86f55..1a2102b3c5d2 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -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")); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 33563a5e73d3..4319bc34eba4 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -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): 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; + 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 = create }, }, threading: { + buildToolContext: (params) => buildMattermostThreadingToolContext(params), scopedAccountReplyToMode: { resolveAccount: (cfg, accountId) => resolveMattermostAccount({ @@ -751,10 +894,23 @@ export const mattermostPlugin: ChannelPlugin = 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, diff --git a/extensions/mattermost/src/mattermost/client.retry.test.ts b/extensions/mattermost/src/mattermost/client.retry.test.ts index 6a21ec3dddb4..456313def160 100644 --- a/extensions/mattermost/src/mattermost/client.retry.test.ts +++ b/extensions/mattermost/src/mattermost/client.retry.test.ts @@ -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(); diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 50d60d666404..f58363685d0c 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -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>; + 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", diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index a6618870357c..8e789cd75b8d 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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, diff --git a/extensions/mattermost/src/mattermost/reply-delivery.test.ts b/extensions/mattermost/src/mattermost/reply-delivery.test.ts index 7a490eb76260..d33f7cb91e21 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.test.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.test.ts @@ -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[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((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((_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); diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 1c994ac839cc..f3ad43da7996 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -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[1]; @@ -19,9 +27,59 @@ type SendMattermostMessage = ( mediaUrl?: string; mediaLocalRoots?: readonly string[]; replyToId?: string; + onDmChannelResolution?: (resolution: PromiseLike) => void; }, ) => Promise; +export function createMattermostReplyDeliveryBarrier(params: { + isDirect: boolean; + dmRetryOptions?: CreateDmChannelRetryOptions; +}) { + let activeDmChannelResolutions = 0; + let queuedDeliveryCount = 0; + let settledDeliveryCount = 0; + const trackDmChannelResolution = (resolution: PromiseLike) => { + activeDmChannelResolutions += 1; + void Promise.resolve(resolution).then( + () => { + activeDmChannelResolutions -= 1; + }, + () => { + activeDmChannelResolutions -= 1; + }, + ); + }; + const markDeliverySettled = () => { + settledDeliveryCount += 1; + }; + const resolveTimeoutPolicy = (context: { + queuedCounts: Readonly>; + 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) => void; }): Promise { 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 } + : {}), }); }, }); diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 04f2c4ac4f45..e4b594bb0f9f 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -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 diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 5f6ed5351437..1ea64db680c1 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -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) => void; }; export type MattermostSendResult = { @@ -271,6 +273,7 @@ type ResolveTargetChannelIdParams = { token: string; allowPrivateNetwork?: boolean; dmRetryOptions?: CreateDmChannelRetryOptions; + onDmChannelResolution?: (resolution: PromiseLike) => 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, }); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 4be8c756def9..4a41ddaefee7 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -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}`); }, diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 47422127614a..654c805007ec 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -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, { diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 988c32c5d150..1a3a987f5c6f 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -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 }); diff --git a/extensions/slack/src/action-threading.test.ts b/extensions/slack/src/action-threading.test.ts index c74ff903ad8a..4046645b9b9f 100644 --- a/extensions/slack/src/action-threading.test.ts +++ b/extensions/slack/src/action-threading.test.ts @@ -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({ diff --git a/extensions/slack/src/action-threading.ts b/extensions/slack/src/action-threading.ts index 97293823af8f..8db6eba6df4d 100644 --- a/extensions/slack/src/action-threading.ts +++ b/extensions/slack/src/action-threading.ts @@ -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) { diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 54c539fc4181..ce659cd80c42 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -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 () => { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index fe2133b3e1ec..a6824e9ade15 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -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 = 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 = 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, }); diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 11b140a9c070..2e12ffa7e2ab 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -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, _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", + }); + }); +}); diff --git a/extensions/slack/src/message-actions.ts b/extensions/slack/src/message-actions.ts index 12961eb8bbfa..9ea922936894 100644 --- a/extensions/slack/src/message-actions.ts +++ b/extensions/slack/src/message-actions.ts @@ -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): 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 } : {}), + }; } diff --git a/extensions/slack/src/target-parsing.ts b/extensions/slack/src/target-parsing.ts index c4fc5c4226bc..7d62c4a0185f 100644 --- a/extensions/slack/src/target-parsing.ts +++ b/extensions/slack/src/target-parsing.ts @@ -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) { diff --git a/extensions/slack/src/targets.test.ts b/extensions/slack/src/targets.test.ts index f54868ebc239..af3ee72b24df 100644 --- a/extensions/slack/src/targets.test.ts +++ b/extensions/slack/src/targets.test.ts @@ -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); + }); +}); diff --git a/extensions/slack/src/targets.ts b/extensions/slack/src/targets.ts index fcd29e2c094b..b374df759450 100644 --- a/extensions/slack/src/targets.ts +++ b/extensions/slack/src/targets.ts @@ -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"; diff --git a/extensions/slack/src/threading-tool-context.test.ts b/extensions/slack/src/threading-tool-context.test.ts index 6f220f984e75..304775975e0d 100644 --- a/extensions/slack/src/threading-tool-context.test.ts +++ b/extensions/slack/src/threading-tool-context.test.ts @@ -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"); }); }); diff --git a/extensions/slack/src/threading-tool-context.ts b/extensions/slack/src/threading-tool-context.ts index 54d175845a1b..94b708c85b03 100644 --- a/extensions/slack/src/threading-tool-context.ts +++ b/extensions/slack/src/threading-tool-context.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index 5f3afb72fa8a..4d50086a7d19 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -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 }, diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 8cca2782a398..9fb89c6caef3 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -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", diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index c552319f235a..c5bb043c0cfd 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -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, { diff --git a/src/agents/embedded-agent-runner.extensions.test.ts b/src/agents/embedded-agent-runner.extensions.test.ts index 31cd5c0825a4..ac5aa61f9658 100644 --- a/src/agents/embedded-agent-runner.extensions.test.ts +++ b/src/agents/embedded-agent-runner.extensions.test.ts @@ -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(); + 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()); diff --git a/src/agents/embedded-agent-runner/extensions.ts b/src/agents/embedded-agent-runner/extensions.ts index e499fa1d9b8b..a960bc375853 100644 --- a/src/agents/embedded-agent-runner/extensions.ts +++ b/src/agents/embedded-agent-runner/extensions.ts @@ -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): 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) } + : 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; + 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; } diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 333c7250de76..25de572f0e0d 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts b/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts index 7c369021aba7..924eeb813607 100644 --- a/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts @@ -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. diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 816d8b364ca4..6f6190405130 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index bf43f2ad801d..543569e996ee 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -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). */ diff --git a/src/agents/embedded-agent-runner/tool-send-receipts.ts b/src/agents/embedded-agent-runner/tool-send-receipts.ts new file mode 100644 index 000000000000..27c2474e1a6c --- /dev/null +++ b/src/agents/embedded-agent-runner/tool-send-receipts.ts @@ -0,0 +1,35 @@ +import { createSessionManagerRuntimeRegistry } from "../agent-hooks/session-manager-runtime-registry.js"; + +type ToolSendReceiptResult = { + details: { + toolSend: unknown; + }; +}; + +const registry = createSessionManagerRuntimeRegistry>(); + +export function recordEmbeddedToolSendReceipt( + sessionManager: unknown, + toolCallId: string, + toolSend: unknown, +): void { + const receipts = registry.get(sessionManager) ?? new Map(); + 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; +} diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts index aa9d94b65278..b7940c4e5d4a 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -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 }) => + 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(); diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.ts b/src/agents/embedded-agent-subscribe.handlers.tools.ts index db73ae8d8e74..3886f76b59ed 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.ts @@ -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 | return readRecordField(asOptionalObjectRecord(result)?.details); } +function applyCurrentMessageProvider( + toolName: string, + args: Record, + currentProvider: string | undefined, +): Record { + 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) : {}; 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() } : {}), }); diff --git a/src/agents/embedded-agent-subscribe.handlers.types.ts b/src/agents/embedded-agent-subscribe.handlers.types.ts index c2ba842652b2..18fe47412389 100644 --- a/src/agents/embedded-agent-subscribe.handlers.types.ts +++ b/src/agents/embedded-agent-subscribe.handlers.types.ts @@ -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 = diff --git a/src/agents/embedded-agent-subscribe.tools.extract.test.ts b/src/agents/embedded-agent-subscribe.tools.extract.test.ts index 0d3ca8435b55..87c8159d778d 100644 --- a/src/agents/embedded-agent-subscribe.tools.extract.test.ts +++ b/src/agents/embedded-agent-subscribe.tools.extract.test.ts @@ -23,7 +23,21 @@ describe("extractMessagingToolSend", () => { plugin: { ...createChannelTestPluginBase({ id: "telegram" }), messaging: { normalizeTarget: normalizeTelegramMessagingTargetForTest }, - threading: { resolveAutoThreadId: () => "456" }, + actions: { + extractToolSend: ({ args }: { args: Record }) => + 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 }) => { 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 }) => { + 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", diff --git a/src/agents/embedded-agent-subscribe.tools.ts b/src/agents/embedded-agent-subscribe.tools.ts index ed48b8ff3994..ab1c396ba7e5 100644 --- a/src/agents/embedded-agent-subscribe.tools.ts +++ b/src/agents/embedded-agent-subscribe.tools.ts @@ -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 | 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 { + 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, + 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, + }; +} diff --git a/src/agents/embedded-agent-subscribe.ts b/src/agents/embedded-agent-subscribe.ts index cf9b16913c8f..207037736a40 100644 --- a/src/agents/embedded-agent-subscribe.ts +++ b/src/agents/embedded-agent-subscribe.ts @@ -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, diff --git a/src/agents/embedded-agent-subscribe.types.ts b/src/agents/embedded-agent-subscribe.types.ts index 15f0d75c1e19..6079efae49eb 100644 --- a/src/agents/embedded-agent-subscribe.types.ts +++ b/src/agents/embedded-agent-subscribe.types.ts @@ -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. */ diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 10a7bd3796e6..089d82d7ce79 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -25,6 +25,7 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & { pluginToolAllowlist?: string[]; pluginToolDenylist?: string[]; currentChannelId?: string; + currentMessagingTarget?: string; currentThreadTs?: string; currentMessageId?: string | number; sandboxRoot?: string; diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index d5cc5a643062..fb81b4b0ac37 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 45cdd8309fa1..1a87e74ca0ec 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 1f8475980c46..447698fe5ce1 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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, diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 42a5a1a58e86..d7ac7ec8ec8a 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -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 diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index bc277c234d95..4d503c6984b5 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -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", + }, }); }); diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 9eaa8aa561d8..e6da6f060e13 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -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); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9d0bf010e9f1..8881d6309f2e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 6b68c2281529..48c465e9d0a8 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -52,10 +52,12 @@ function createReplyOperation(): TestReplyOperation { updateSessionId: vi.fn(), 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(), diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 311cf38a6414..f0959c11cc9b 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-payloads.test.ts b/src/auto-reply/reply/agent-runner-payloads.test.ts index 32140c0c3bf1..a8926c79bf42 100644 --- a/src/auto-reply/reply/agent-runner-payloads.test.ts +++ b/src/auto-reply/reply/agent-runner-payloads.test.ts @@ -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 +>[0]; + function expectFields(value: unknown, expected: Record): 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, diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 63a0c9c06c40..c1f57d7d38f1 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -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; @@ -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, }) diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 73ed6c43ae5e..c15c1ab52e7c 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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(); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 106b1d38765c..b07458177e13 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index dee0fc77e035..3c09791dc47c 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -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-", diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index b08bc165448f..1f195d75e6c9 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -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 }, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index d10476128ab4..fb696f930f74 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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(); diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts index 7b3079e4d30d..833f9446c3b6 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index 9bb6400bb477..61b6da2f2230 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -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; 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 | 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, diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 82c49e7e2f4f..635362e727e1 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -51,6 +51,10 @@ const channelPluginMocks = vi.hoisted(() => ({ return undefined; } return { + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, outbound: { shouldTreatDeliveredTextAsVisible: ({ kind, diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index da688044fa1d..2d8a7fd4bbe2 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -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, diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index b843c34df364..149f3021fe59 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -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 | 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((resolve) => { + startDelivery = resolve; + }); + let releaseDelivery: () => void = () => {}; + const deliveryGate = new Promise((resolve) => { + releaseDelivery = resolve; + }); + const dispatcher = createReplyDispatcher({ + deliver: async () => { + deliveryOrder.push("final-start"); + startDelivery(); + await deliveryGate; + deliveryOrder.push("final-end"); + }, + }); + let queuedOperation: ReturnType | 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(); + } + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index eba55335ac3f..08713e9db830 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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 => { + 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(); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index c88002d4f3bd..e41d53e25b4d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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 => { - 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, diff --git a/src/auto-reply/reply/effective-reply-route.test.ts b/src/auto-reply/reply/effective-reply-route.test.ts index 20533c61c933..70d82574d753 100644 --- a/src/auto-reply/reply/effective-reply-route.test.ts +++ b/src/auto-reply/reply/effective-reply-route.test.ts @@ -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", }); }); }); diff --git a/src/auto-reply/reply/effective-reply-route.ts b/src/auto-reply/reply/effective-reply-route.ts index ea68d1be6a42..04eede504b64 100644 --- a/src/auto-reply/reply/effective-reply-route.ts +++ b/src/auto-reply/reply/effective-reply-route.ts @@ -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 } : {}), }; } diff --git a/src/auto-reply/reply/followup-delivery.test.ts b/src/auto-reply/reply/followup-delivery.test.ts index 027b656d95f8..00f1a9a25699 100644 --- a/src/auto-reply/reply/followup-delivery.test.ts +++ b/src/auto-reply/reply/followup-delivery.test.ts @@ -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({ diff --git a/src/auto-reply/reply/followup-delivery.ts b/src/auto-reply/reply/followup-delivery.ts index 3a8155de7fed..487d32071aeb 100644 --- a/src/auto-reply/reply/followup-delivery.ts +++ b/src/auto-reply/reply/followup-delivery.ts @@ -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; } diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 2502b6afd53f..1c20cf7b5c57 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -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( "../../infra/agent-events.js", diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 62b92d9b6b04..faed3c7acd78 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 55c65ca0d11d..74f3fbe709a7 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -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({ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 57901cb0ab1b..77789ec9abcb 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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) ?? diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index dae7f01f48a7..c469603136c8 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -1,7 +1,16 @@ // Tests collect-mode queue behavior, debounce, and drain semantics. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { FollowupRun, QueueSettings } from "./queue.js"; -import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; +import { + clearFollowupQueue, + enqueueFollowupRun, + FollowupRunDeferredError, + refreshQueuedFollowupSession, + scheduleFollowupDrain, +} from "./queue.js"; import { createDeferred, createQueueTestRun as createRun, @@ -77,6 +86,7 @@ describe("followup queue collect routing", () => { prompt: "one", originatingChannel: "slack", originatingTo: "channel:A", + originatingChatType: "channel", }), settings, ); @@ -86,6 +96,7 @@ describe("followup queue collect routing", () => { prompt: "two", originatingChannel: "slack", originatingTo: "channel:A", + originatingChatType: "channel", }), settings, ); @@ -95,6 +106,1261 @@ describe("followup queue collect routing", () => { expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); expect(calls[0]?.originatingChannel).toBe("slack"); expect(calls[0]?.originatingTo).toBe("channel:A"); + expect(calls[0]?.originatingChatType).toBe("channel"); + }); + + it("collects Slack top-level messages when reply anchors are disabled", async () => { + const key = `test-collect-slack-reply-off-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + for (const [prompt, replyToId] of [ + ["one", "101.001"], + ["two", "101.002"], + ] as const) { + enqueueFollowupRun( + key, + createRun({ + prompt, + messageId: replyToId, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingReplyToId: replyToId, + originatingReplyToMode: "off", + originatingChatType: "channel", + }), + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + done.resolve(); + }); + await done.promise; + + expect(calls).toHaveLength(1); + expect(calls[0]?.prompt).toContain("Queued #1\none"); + expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); + }); + + it("splits collect batches when enabled reply anchors differ", async () => { + const key = `test-collect-slack-reply-all-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + for (const [prompt, replyToId] of [ + ["one", "101.001"], + ["two", "101.002"], + ] as const) { + enqueueFollowupRun( + key, + createRun({ + prompt, + messageId: replyToId, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingReplyToId: replyToId, + originatingReplyToMode: "all", + originatingChatType: "channel", + }), + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length === 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls.map((call) => call.prompt)).toEqual(["one", "two"]); + expect(calls.map((call) => call.messageId)).toEqual(["101.001", "101.002"]); + }); + + it("does not collect when captured reply modes differ on the same anchor", async () => { + const key = `test-collect-slack-reply-mode-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + for (const [prompt, messageId, replyToMode] of [ + ["first", "message-1", "first"], + ["all", "message-2", "all"], + ] as const) { + enqueueFollowupRun( + key, + createRun({ + prompt, + messageId, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingReplyToId: "101.001", + originatingReplyToMode: replyToMode, + originatingChatType: "channel", + }), + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length === 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls.map((call) => call.prompt)).toEqual(["first", "all"]); + expect(calls.map((call) => call.originatingReplyToMode)).toEqual(["first", "all"]); + }); + + it("does not collect when chat types differ on the same destination", async () => { + const key = `test-collect-diff-chat-type-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "direct", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "channel", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls.map((call) => call.prompt)).toEqual(["direct", "channel"]); + expect(calls.map((call) => call.originatingChatType)).toEqual(["direct", "channel"]); + }); + + it("does not collect when source delivery policy differs", async () => { + const key = `test-collect-diff-delivery-policy-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + const createPolicyRun = ( + prompt: string, + sourceReplyDeliveryMode: NonNullable, + ) => { + const base = createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + return { + ...base, + run: { + ...base.run, + sourceReplyDeliveryMode, + }, + }; + }; + + enqueueFollowupRun(key, createPolicyRun("automatic", "automatic"), settings); + enqueueFollowupRun(key, createPolicyRun("private", "message_tool_only"), settings); + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls.map((call) => call.prompt)).toEqual([ + "[Queued messages while agent was busy]\n\n---\nQueued #1\nautomatic", + "[Queued messages while agent was busy]\n\n---\nQueued #1\nprivate", + ]); + expect(calls.map((call) => call.run.sourceReplyDeliveryMode)).toEqual([ + "automatic", + "message_tool_only", + ]); + }); + + it("keeps overflow summaries on the dropped source chat type", async () => { + const key = `test-collect-overflow-chat-type-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "private direct content", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "public channel content", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.prompt).toContain("- private direct content"); + expect(calls[0]?.originatingChatType).toBe("direct"); + expect(calls[1]?.prompt).toContain("public channel content"); + expect(calls[1]?.originatingChatType).toBe("channel"); + }); + + it("keeps overflow summaries on the dropped source route", async () => { + const key = `test-collect-overflow-route-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "channel A content", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "channel B content", + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.prompt).toContain("- channel A content"); + expect(calls[0]?.originatingTo).toBe("channel:A"); + expect(calls[1]?.prompt).toContain("channel B content"); + expect(calls[1]?.prompt).not.toContain("channel A content"); + expect(calls[1]?.originatingTo).toBe("channel:B"); + }); + + it("does not attribute elided private drops to a public summary", async () => { + const key = `test-collect-overflow-elided-context-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "private direct content", + originatingChannel: "slack", + originatingTo: "direct:A", + originatingChatType: "direct", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "older public content", + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "newer public content", + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.prompt).not.toContain("older public content"); + expect(calls[0]?.prompt).not.toContain("private direct content"); + expect(calls[0]?.originatingTo).toBe("direct:A"); + expect(calls[1]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[1]?.prompt).toContain("- older public content"); + expect(calls[1]?.prompt).not.toContain("private direct content"); + expect(calls[1]?.originatingTo).toBe("channel:B"); + expect(calls[2]?.prompt).toContain("newer public content"); + }); + + it("evicts oldest overflow context metadata when the item cap is reached", () => { + const key = `test-collect-overflow-elision-bound-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 2, + dropPolicy: "summarize", + }; + + const accepted = ["A", "B", "A", "B", "A", "B", "survivor"].map((target, index) => + enqueueFollowupRun( + key, + createRun({ + prompt: `message ${index}`, + originatingChannel: "slack", + originatingTo: `channel:${target}`, + originatingChatType: "channel", + }), + settings, + ), + ); + + const queue = getExistingFollowupQueue(key); + expect(accepted).toEqual([true, true, true, true, true, true, true]); + expect(queue?.summaryElisions).toHaveLength(2); + expect(queue?.summaryElisions.map((entry) => entry.source.originatingTo)).toEqual([ + "channel:B", + "channel:A", + ]); + expect(queue?.evictedSummaryCount).toBe(1); + expect(queue?.items.map((item) => item.originatingTo)).toEqual([ + "channel:B", + "channel:survivor", + ]); + clearFollowupQueue(key); + }); + + it("keeps retained excess contexts isolated after evicting the oldest metadata", async () => { + const key = `test-collect-overflow-evicted-context-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + const accepted = ["A", "B", "C", "D"].map((target) => + enqueueFollowupRun( + key, + createRun({ + prompt: `content ${target}`, + originatingChannel: "slack", + originatingTo: `channel:${target}`, + originatingChatType: "channel", + }), + settings, + ), + ); + expect(accepted).toEqual([true, true, true, true]); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls).toHaveLength(3); + expect(calls.map((call) => call.originatingTo)).toEqual([ + "channel:B", + "channel:C", + "channel:D", + ]); + expect(calls[0]?.prompt).toContain("Dropped 1 message"); + expect(calls[0]?.prompt).not.toContain("content B"); + expect(calls[1]?.prompt).toContain("- content C"); + expect(calls[2]?.prompt).toContain("content D"); + expect(calls.every((call) => !call.prompt.includes("content A"))).toBe(true); + }); + + it("keeps overflow summaries under the dropped sender authorization", async () => { + const key = `test-collect-overflow-auth-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + const dropped = createRun({ + prompt: "guest content", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + const survivor = createRun({ + prompt: "owner content", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + + enqueueFollowupRun( + key, + { + ...dropped, + run: { + ...dropped.run, + senderId: "guest", + senderIsOwner: false, + }, + }, + settings, + ); + enqueueFollowupRun( + key, + { + ...survivor, + run: { + ...survivor.run, + senderId: "owner", + senderIsOwner: true, + }, + }, + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.prompt).toContain("- guest content"); + expect(calls[0]?.run.senderId).toBe("guest"); + expect(calls[0]?.run.senderIsOwner).toBe(false); + expect(calls[1]?.prompt).toContain("owner content"); + expect(calls[1]?.prompt).not.toContain("guest content"); + expect(calls[1]?.run.senderId).toBe("owner"); + expect(calls[1]?.run.senderIsOwner).toBe(true); + }); + + it("uses the head item authorization for non-collect overflow delivery", async () => { + const key = `test-followup-overflow-auth-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 2, + dropPolicy: "summarize", + }; + const guestRun = (prompt: string) => { + const base = createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + return { + ...base, + run: { + ...base.run, + senderId: "guest", + senderIsOwner: false, + }, + }; + }; + const owner = createRun({ + prompt: "owner content", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + + enqueueFollowupRun(key, guestRun("dropped guest"), settings); + enqueueFollowupRun(key, guestRun("surviving guest"), settings); + enqueueFollowupRun( + key, + { + ...owner, + run: { + ...owner.run, + senderId: "owner", + senderIsOwner: true, + }, + }, + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls).toHaveLength(3); + expect(calls[0]?.prompt).toContain("- dropped guest"); + expect(calls[0]?.run.senderId).toBe("guest"); + expect(calls[0]?.run.senderIsOwner).toBe(false); + expect(calls[1]?.prompt).toBe("surviving guest"); + expect(calls[1]?.run.senderId).toBe("guest"); + expect(calls[1]?.run.senderIsOwner).toBe(false); + expect(calls[2]?.prompt).toBe("owner content"); + expect(calls[2]?.run.senderId).toBe("owner"); + expect(calls[2]?.run.senderIsOwner).toBe(true); + }); + + it("batches compatible overflow sources into one summary run", async () => { + const key = `test-collect-overflow-group-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 3, + dropPolicy: "summarize", + }; + + for (const [prompt, model] of [ + ["direct A", "model-a"], + ["direct B", "model-b"], + ["direct C", "model-c"], + ] as const) { + const source = createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }); + enqueueFollowupRun( + key, + { + ...source, + run: { + ...source.run, + model, + }, + }, + settings, + ); + } + for (const prompt of ["channel D", "channel E", "channel F"]) { + enqueueFollowupRun( + key, + createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls).toHaveLength(2); + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 3 messages due to cap."); + expect(calls[0]?.prompt).toContain("- direct A"); + expect(calls[0]?.prompt).toContain("- direct B"); + expect(calls[0]?.prompt).toContain("- direct C"); + expect(calls[0]?.originatingChatType).toBe("direct"); + expect(calls[0]?.run.model).toBe("model-c"); + expect(calls[0]?.run.suppressNextUserMessagePersistence).toBeUndefined(); + expect(calls[0]?.run.suppressTranscriptOnlyAssistantPersistence).toBeUndefined(); + expect(calls[0]?.userTurnTranscriptRecorder?.isBlocked()).toBe(false); + expect(JSON.stringify(calls[0]?.userTurnTranscriptRecorder?.message)).toContain( + "[Queue overflow]", + ); + expect(calls[1]?.prompt).toContain("channel D"); + expect(calls[1]?.prompt).toContain("channel E"); + expect(calls[1]?.prompt).toContain("channel F"); + expect(calls[1]?.originatingChatType).toBe("channel"); + }); + + it("scopes overflow transcript idempotency to the source route", async () => { + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + const drainRoute = async (to: string): Promise => { + const key = `test-collect-overflow-route-key-${to}-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + for (const [prompt, messageId] of [ + ["dropped", "provider-local-id"], + ["survivor", "survivor-id"], + ] as const) { + enqueueFollowupRun( + key, + createRun({ + prompt, + messageId, + originatingChannel: "slack", + originatingTo: to, + originatingAccountId: "workspace", + originatingThreadId: "thread", + originatingReplyToId: "reply", + originatingReplyToMode: "all", + originatingChatType: "channel", + }), + settings, + ); + } + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + return calls; + }; + + const firstCalls = await drainRoute("channel:A"); + const secondCalls = await drainRoute("channel:B"); + const firstMessage = firstCalls[0]?.userTurnTranscriptRecorder?.message as + | { idempotencyKey?: string } + | undefined; + const secondMessage = secondCalls[0]?.userTurnTranscriptRecorder?.message as + | { idempotencyKey?: string } + | undefined; + + expect(firstCalls[0]?.prompt).toBe(secondCalls[0]?.prompt); + expect(firstMessage?.idempotencyKey).toMatch(/^followup-overflow:/); + expect(secondMessage?.idempotencyKey).toMatch(/^followup-overflow:/); + expect(firstMessage?.idempotencyKey).not.toBe(secondMessage?.idempotencyKey); + }); + + it("uses the newest run for a fully elided overflow segment", async () => { + const key = `test-collect-overflow-elided-latest-run-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + for (const [prompt, model, authProfileId, chatType] of [ + ["first", "model-a", "auth-a", "direct"], + ["second", "model-b", "auth-b", "direct"], + ["retained", "model-c", "auth-c", "channel"], + ["survivor", "model-d", "auth-d", "channel"], + ] as const) { + const source = createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: chatType, + }); + enqueueFollowupRun( + key, + { + ...source, + run: { + ...source.run, + model, + authProfileId, + }, + }, + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.prompt).toContain("Dropped 2 messages"); + expect(calls[0]?.run.model).toBe("model-b"); + expect(calls[0]?.run.authProfileId).toBe("auth-b"); + expect(calls[1]?.prompt).toContain("- retained"); + expect(calls[2]?.prompt).toContain("survivor"); + }); + + it("splits overflow groups when source delivery policy changes", async () => { + const key = `test-collect-overflow-delivery-policy-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 2, + dropPolicy: "summarize", + }; + const createSource = ( + prompt: string, + sourceReplyDeliveryMode: NonNullable, + ) => { + const base = createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + return { + ...base, + run: { + ...base.run, + sourceReplyDeliveryMode, + }, + }; + }; + + enqueueFollowupRun(key, createSource("automatic source", "automatic"), settings); + enqueueFollowupRun(key, createSource("private source", "message_tool_only"), settings); + for (const prompt of ["survivor one", "survivor two"]) { + enqueueFollowupRun( + key, + createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls).toHaveLength(3); + expect(calls[0]?.prompt).toContain("- automatic source"); + expect(calls[0]?.run.sourceReplyDeliveryMode).toBe("automatic"); + expect(calls[1]?.prompt).toContain("- private source"); + expect(calls[1]?.run.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(calls[2]?.prompt).toContain("survivor one"); + expect(calls[2]?.prompt).toContain("survivor two"); + }); + + it("splits overflow groups when runtime policy identity changes", async () => { + const key = `test-collect-overflow-runtime-policy-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 2, + dropPolicy: "summarize", + }; + const createSource = (prompt: string, runtimePolicySessionKey: string) => { + const base = createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }); + return { + ...base, + run: { + ...base.run, + runtimePolicySessionKey, + }, + }; + }; + + enqueueFollowupRun(key, createSource("policy one", "policy:one"), settings); + enqueueFollowupRun(key, createSource("policy two", "policy:two"), settings); + for (const prompt of ["survivor one", "survivor two"]) { + enqueueFollowupRun( + key, + createRun({ + prompt, + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + } + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls).toHaveLength(3); + expect(calls[0]?.prompt).toContain("- policy one"); + expect(calls[0]?.run.runtimePolicySessionKey).toBe("policy:one"); + expect(calls[1]?.prompt).toContain("- policy two"); + expect(calls[1]?.run.runtimePolicySessionKey).toBe("policy:two"); + expect(calls[2]?.prompt).toContain("survivor one"); + expect(calls[2]?.prompt).toContain("survivor two"); + }); + + it("preserves the source message id for standalone overflow summaries", async () => { + const key = `test-collect-overflow-message-id-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "dropped source", + messageId: "message-42", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingChatType: "channel", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "survivor", + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.prompt).toContain("- dropped source"); + expect(calls[0]?.messageId).toBe("message-42"); + expect(calls[1]?.prompt).toContain("survivor"); + }); + + it.each([ + ["dropped", undefined, "channel"], + ["surviving", "direct", undefined], + ] as const)( + "separates overflow when the %s chat type is missing", + async (_missingSide, droppedChatType, survivingChatType) => { + const key = `test-collect-overflow-missing-chat-${_missingSide}-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "dropped content", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: droppedChatType, + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "surviving content", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: survivingChatType, + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls[0]?.prompt).toContain("- dropped content"); + expect(calls[0]?.originatingChatType).toBe(droppedChatType); + expect(calls[1]?.prompt).toContain("surviving content"); + expect(calls[1]?.originatingChatType).toBe(survivingChatType); + }, + ); + + it("uses current run settings without dropped runtime context for split summaries", async () => { + const key = `test-collect-overflow-current-run-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const controller = new AbortController(); + const droppedBase = createRun({ + prompt: "private direct content", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }); + const survivingBase = createRun({ + prompt: "public channel content", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + { + ...droppedBase, + abortSignal: controller.signal, + currentInboundContext: { text: "private runtime context" }, + run: { + ...droppedBase.run, + model: "old-model", + senderId: "guest", + senderIsOwner: false, + }, + }, + settings, + ); + enqueueFollowupRun( + key, + { + ...survivingBase, + run: { + ...survivingBase.run, + model: "old-model", + senderId: "owner", + senderIsOwner: true, + }, + }, + settings, + ); + controller.abort(); + refreshQueuedFollowupSession({ + key, + nextModel: "current-model", + }); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.run.model).toBe("current-model"); + expect(calls[0]?.abortSignal).toBeUndefined(); + expect(calls[0]?.currentInboundContext).toBeUndefined(); + expect(calls[0]?.originatingChatType).toBe("direct"); + expect(calls[0]?.run.senderId).toBe("guest"); + expect(calls[0]?.run.senderIsOwner).toBe(false); + }); + + it("removes a delivered split summary by source identity after concurrent enqueue", async () => { + const key = `test-collect-overflow-concurrent-source-${Date.now()}`; + const calls: FollowupRun[] = []; + const firstStarted = createDeferred(); + const releaseFirst = createDeferred(); + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "source A", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "source B", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + return; + } + if (calls.length >= 3) { + done.resolve(); + } + }); + await firstStarted.promise; + + enqueueFollowupRun( + key, + createRun({ + prompt: "surviving C", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + releaseFirst.resolve(); + await done.promise; + + expect(calls[0]?.prompt).toContain("- source A"); + expect(calls[0]?.originatingChatType).toBe("direct"); + expect(calls[1]?.prompt).toContain("- source B"); + expect(calls[1]?.prompt).not.toContain("source A"); + expect(calls[1]?.originatingChatType).toBe("channel"); + expect(calls[2]?.prompt).toContain("surviving C"); + expect(calls[2]?.prompt).not.toContain("source A"); + expect(calls[2]?.prompt).not.toContain("source B"); + expect(calls[2]?.originatingChatType).toBe("channel"); + }); + + it("retries split overflow summaries after transient failure", async () => { + const key = `test-collect-overflow-split-retry-${Date.now()}`; + const prompts: string[] = []; + const done = createDeferred(); + const onComplete = vi.fn(); + let attempt = 0; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + { + ...createRun({ + prompt: "private source", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }), + queuedLifecycle: { onComplete }, + }, + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "public survivor", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + attempt += 1; + prompts.push(run.prompt); + if (attempt === 1) { + throw new Error("transient summary failure"); + } + if (attempt >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(prompts).toHaveLength(3); + expect(prompts[0]).toContain("- private source"); + expect(prompts[1]).toContain("- private source"); + expect(prompts[2]).toContain("public survivor"); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("keeps deferred overflow summary text paired with its source route", async () => { + const key = `test-collect-overflow-deferred-pairs-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "source A", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "source B", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "direct", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length === 1) { + enqueueFollowupRun( + key, + createRun({ + prompt: "surviving C", + originatingChannel: "slack", + originatingTo: "same-target", + originatingChatType: "channel", + }), + settings, + ); + throw new FollowupRunDeferredError(); + } + if (calls.length >= 3) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[1]?.prompt).toContain("- source B"); + expect(calls[1]?.prompt).not.toContain("source A"); + expect(calls[1]?.originatingChatType).toBe("direct"); + expect(calls[2]?.prompt).toContain("surviving C"); + expect(calls[2]?.prompt).not.toContain("source A"); + expect(calls[2]?.prompt).not.toContain("source B"); + expect(calls[2]?.originatingChatType).toBe("channel"); }); it("collects compatible items after one cross-channel drain", async () => { @@ -154,13 +1420,15 @@ describe("followup queue collect routing", () => { expect(calls[1]?.originatingTo).toBe("channel:B"); }); - it("collects unresolved-origin items with an otherwise single route", async () => { + it("drains unresolved-origin items separately from a routed batch", async () => { const key = `test-collect-unresolved-origin-${Date.now()}`; const calls: FollowupRun[] = []; const done = createDeferred(); const runFollowup = async (run: FollowupRun) => { calls.push(run); - done.resolve(); + if (calls.length >= 2) { + done.resolve(); + } }; const settings: QueueSettings = { mode: "collect", @@ -176,6 +1444,7 @@ describe("followup queue collect routing", () => { prompt: "keyed one", originatingChannel: "slack", originatingTo: "channel:B", + originatingChatType: "channel", }), settings, ); @@ -185,6 +1454,7 @@ describe("followup queue collect routing", () => { prompt: "keyed two", originatingChannel: "slack", originatingTo: "channel:B", + originatingChatType: "channel", }), settings, ); @@ -192,13 +1462,72 @@ describe("followup queue collect routing", () => { scheduleFollowupDrain(key, runFollowup); await done.promise; - expect(calls).toHaveLength(1); + expect(calls).toHaveLength(2); expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); expect(calls[0]?.prompt).toContain("Queued #1\nunresolved origin"); - expect(calls[0]?.prompt).toContain("Queued #2\nkeyed one"); - expect(calls[0]?.prompt).toContain("Queued #3\nkeyed two"); - expect(calls[0]?.originatingChannel).toBe("slack"); - expect(calls[0]?.originatingTo).toBe("channel:B"); + expect(calls[0]?.prompt).not.toContain("keyed one"); + expect(calls[0]?.originatingChannel).toBeUndefined(); + expect(calls[1]?.prompt).toContain("Queued #1\nkeyed one"); + expect(calls[1]?.prompt).toContain("Queued #2\nkeyed two"); + expect(calls[1]?.originatingChannel).toBe("slack"); + expect(calls[1]?.originatingTo).toBe("channel:B"); + expect(calls[1]?.originatingChatType).toBe("channel"); + }); + + it("does not collect known route-less chat types into another destination", async () => { + const key = `test-collect-known-chat-without-route-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "unresolved direct", + originatingChatType: "direct", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "channel one", + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "channel two", + originatingChannel: "slack", + originatingTo: "channel:B", + originatingChatType: "channel", + }), + settings, + ); + + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + if (calls.length >= 2) { + done.resolve(); + } + }); + await done.promise; + + expect(calls[0]?.prompt).toBe("unresolved direct"); + expect(calls[0]?.originatingChatType).toBe("direct"); + expect(calls[1]?.prompt).toContain("channel one"); + expect(calls[1]?.prompt).toContain("channel two"); + expect(calls[1]?.prompt).not.toContain("unresolved direct"); + expect(calls[1]?.originatingChatType).toBe("channel"); }); it("collects ordinary user-request followups with current turn kind", async () => { @@ -682,7 +2011,7 @@ describe("followup queue collect routing", () => { expect(calls[0]?.run.model).toBe("sonnet-4.6"); }); - it("delivers and clears summary-only collect drains after cross-channel items", async () => { + it("delivers summary-only collect work under its source route", async () => { const key = `test-collect-summary-only-${Date.now()}`; const calls: FollowupRun[] = []; const done = createDeferred(); @@ -732,10 +2061,11 @@ describe("followup queue collect routing", () => { await done.promise; expect(calls).toHaveLength(3); - expect(calls[0]?.prompt).toBe("second"); - expect(calls[1]?.prompt).toBe("third"); - expect(calls[2]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); - expect(calls[2]?.prompt).toContain("- first"); + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.prompt).toContain("- first"); + expect(calls[0]?.originatingTo).toBe("channel:A"); + expect(calls[1]?.prompt).toBe("second"); + expect(calls[2]?.prompt).toBe("third"); }); it("preserves collect order when authorization changes more than once", async () => { @@ -1063,6 +2393,64 @@ describe("followup queue collect routing", () => { expect(calls[0]?.prompt).toContain("- first"); }); + it("persists overflow summaries to the session selected after queue admission", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-overflow-session-")); + const storePath = path.join(tempDir, "sessions.json"); + const oldTranscriptPath = path.join(tempDir, "old-session.jsonl"); + const newTranscriptPath = path.join(tempDir, "new-session.jsonl"); + const key = `test-overflow-summary-session-rotation-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + try { + await fs.writeFile( + storePath, + JSON.stringify({ + "agent:agent:main": { + sessionId: "new-session", + sessionFile: newTranscriptPath, + updatedAt: Date.now(), + }, + }), + ); + const first = createRun({ prompt: "first" }); + first.run.sessionId = "old-session"; + first.run.sessionKey = "agent:agent:main"; + first.run.sessionFile = oldTranscriptPath; + first.run.config = { session: { store: storePath } }; + const second = createRun({ prompt: "second" }); + second.run = first.run; + + enqueueFollowupRun(key, first, settings); + enqueueFollowupRun(key, second, settings); + scheduleFollowupDrain(key, async (run) => { + calls.push(run); + done.resolve(); + }); + await done.promise; + + const recorder = calls[0]?.userTurnTranscriptRecorder; + expect(recorder).toBeDefined(); + const persisted = await recorder?.persistFallback(); + expect(await fs.realpath(persisted?.sessionFile ?? "")).toBe( + await fs.realpath(newTranscriptPath), + ); + await expect(fs.readFile(newTranscriptPath, "utf8")).resolves.toContain( + "[Queue overflow] Dropped 1 message due to cap.", + ); + await expect(fs.stat(oldTranscriptPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + clearFollowupQueue(key); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("clears overflow summaries when aborts empty the queue", async () => { const key = `test-overflow-summary-aborted-${Date.now()}`; const calls: FollowupRun[] = []; @@ -1105,11 +2493,11 @@ describe("followup queue collect routing", () => { expect(getExistingFollowupQueue(key)).toBeUndefined(); }); - it("includes the overflow summary only in the first split auth group", async () => { + it("delivers the overflow summary before split auth groups", async () => { const key = `test-collect-overflow-summary-once-${Date.now()}`; const calls: FollowupRun[] = []; const done = createDeferred(); - const expectedCalls = 2; + const expectedCalls = 3; const runFollowup = async (run: FollowupRun) => { calls.push(run); if (calls.length >= expectedCalls) { @@ -1182,11 +2570,13 @@ describe("followup queue collect routing", () => { scheduleFollowupDrain(key, runFollowup); await done.promise; - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[0]?.prompt).toContain("- dropped guest message"); expect(calls[1]?.prompt).not.toContain("[Queue overflow]"); expect(calls[1]?.prompt).not.toContain("dropped guest message"); + expect(calls[1]?.prompt).toContain("guest message"); + expect(calls[2]?.prompt).toContain("owner message"); }); it("does not re-deliver overflow summary on partial auth group failure retry", async () => { @@ -1196,13 +2586,13 @@ describe("followup queue collect routing", () => { let attempt = 0; const runFollowup = async (run: FollowupRun) => { attempt += 1; - // First group succeeds (attempt 1), second group fails (attempt 2), - // then second group succeeds on retry (attempt 3). + // Summary succeeds (attempt 1), first group fails (attempt 2), then + // both retained authorization groups succeed on retry. if (attempt === 2) { throw new Error("transient failure"); } calls.push(run); - if (calls.length >= 2) { + if (calls.length >= 3) { done.resolve(); } }; @@ -1272,14 +2662,14 @@ describe("followup queue collect routing", () => { scheduleFollowupDrain(key, runFollowup); await done.promise; - expect(calls).toHaveLength(2); - // First group got the overflow summary + expect(calls).toHaveLength(3); expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[0]?.prompt).toContain("- dropped guest message"); - // Second group (retried after failure) must NOT get the overflow summary again expect(calls[1]?.prompt).not.toContain("[Queue overflow]"); expect(calls[1]?.prompt).not.toContain("dropped guest message"); - expect(calls[1]?.prompt).toContain("owner message"); + expect(calls[1]?.prompt).toContain("guest message"); + expect(calls[2]?.prompt).not.toContain("[Queue overflow]"); + expect(calls[2]?.prompt).toContain("owner message"); }); it("preserves routing metadata on overflow summary followups", async () => { @@ -1330,7 +2720,7 @@ describe("followup queue collect routing", () => { expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); }); - it("preserves live item abort metadata on overflow summary followups", async () => { + it("keeps live item runtime metadata out of standalone overflow summaries", async () => { const key = `test-overflow-summary-runtime-${Date.now()}`; const calls: FollowupRun[] = []; const done = createDeferred(); @@ -1339,7 +2729,9 @@ describe("followup queue collect routing", () => { const begin = vi.fn(() => () => undefined); const runFollowup = async (run: FollowupRun) => { calls.push(run); - done.resolve(); + if (calls.length >= 2) { + done.resolve(); + } }; const settings: QueueSettings = { mode: "followup", @@ -1374,14 +2766,18 @@ describe("followup queue collect routing", () => { scheduleFollowupDrain(key, runFollowup); await done.promise; - expect(calls).toHaveLength(1); + expect(calls).toHaveLength(2); expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); - expect(calls[0]?.currentInboundEventKind).toBe("room_event"); - expect(calls[0]?.currentInboundAudio).toBe(true); - expect(calls[0]?.currentInboundContext?.text).toBe("live context"); - expect(calls[0]?.abortSignal).toBe(controller.signal); - expect(calls[0]?.queuedLifecycle?.onComplete).toBe(onComplete); - expect(calls[0]?.deliveryCorrelations?.[0]?.begin).toBe(begin); + expect(calls[0]?.currentInboundEventKind).toBeUndefined(); + expect(calls[0]?.currentInboundContext).toBeUndefined(); + expect(calls[0]?.abortSignal).toBeUndefined(); + expect(calls[1]?.prompt).toBe("live ambient"); + expect(calls[1]?.currentInboundEventKind).toBe("room_event"); + expect(calls[1]?.currentInboundAudio).toBe(true); + expect(calls[1]?.currentInboundContext?.text).toBe("live context"); + expect(calls[1]?.abortSignal).toBe(controller.signal); + expect(calls[1]?.queuedLifecycle?.onComplete).toBe(onComplete); + expect(calls[1]?.deliveryCorrelations?.[0]?.begin).toBe(begin); }); it("keeps summarized room-event lifecycle until the overflow summary drains", async () => { @@ -1392,7 +2788,9 @@ describe("followup queue collect routing", () => { const onComplete = vi.fn(); const runFollowup = async (run: FollowupRun) => { calls.push(run); - done.resolve(); + if (calls.length >= 2) { + done.resolve(); + } }; const settings: QueueSettings = { mode: "followup", @@ -1419,15 +2817,13 @@ describe("followup queue collect routing", () => { scheduleFollowupDrain(key, runFollowup); await done.promise; - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - expect(calls).toHaveLength(1); + expect(calls).toHaveLength(2); expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); expect(calls[0]?.currentInboundEventKind).toBeUndefined(); expect(calls[0]?.currentInboundContext).toBeUndefined(); expect(calls[0]?.abortSignal).toBeUndefined(); + expect(calls[1]?.prompt).toBe("live followup"); expect(onComplete).toHaveBeenCalledTimes(1); }); @@ -1475,7 +2871,9 @@ describe("followup queue collect routing", () => { }); expect(onComplete).toHaveBeenCalledTimes(1); - expect(getExistingFollowupQueue(key)?.summarySources).toHaveLength(0); + expect(getExistingFollowupQueue(key)?.summarySources).toHaveLength(1); + expect(getExistingFollowupQueue(key)?.summarySources[0]?.queuedLifecycle).toBeUndefined(); + expect(getExistingFollowupQueue(key)?.summarySources[0]?.currentInboundContext).toBeUndefined(); releaseRetry.resolve(); await done.promise; diff --git a/src/auto-reply/reply/queue.dedupe.test.ts b/src/auto-reply/reply/queue.dedupe.test.ts index 122d0e10138d..a1f90dea65c8 100644 --- a/src/auto-reply/reply/queue.dedupe.test.ts +++ b/src/auto-reply/reply/queue.dedupe.test.ts @@ -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( import.meta.url, diff --git a/src/auto-reply/reply/queue.drain-restart.test.ts b/src/auto-reply/reply/queue.drain-restart.test.ts index 153d01a29942..aae0568f921f 100644 --- a/src/auto-reply/reply/queue.drain-restart.test.ts +++ b/src/auto-reply/reply/queue.drain-restart.test.ts @@ -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(); + const releaseFirst = createDeferred(); + const retried = createDeferred(); + 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 () => { diff --git a/src/auto-reply/reply/queue.test-helpers.ts b/src/auto-reply/reply/queue.test-helpers.ts index 6c2d9d21f37f..17b5bdb0afb9 100644 --- a/src/auto-reply/reply/queue.test-helpers.ts +++ b/src/auto-reply/reply/queue.test-helpers.ts @@ -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", diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index e6cfeee2de79..60282f005fff 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -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; + }>; + 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, ): Promise { try { await run(); } catch (err) { if (!isFollowupRunDeferredError(err)) { - completeFollowupQueueSummarySources(queue); - } - throw err; - } - completeFollowupQueueSummarySources(queue); -} - -async function runWithDeferredSummaryRestore( - restore: (() => void) | undefined, - run: () => Promise, -): Promise { - 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; +}): Promise { + 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; +}): Promise { + 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; +}): Promise { + 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 { + 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; } diff --git a/src/auto-reply/reply/queue/enqueue.ts b/src/auto-reply/reply/queue/enqueue.ts index 246584af0349..37aea5406ca7 100644 --- a/src/auto-reply/reply/queue/enqueue.ts +++ b/src/auto-reply/reply/queue/enqueue.ts @@ -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); } } diff --git a/src/auto-reply/reply/queue/state.test.ts b/src/auto-reply/reply/queue/state.test.ts index 0631cda45c50..e7b4f7e4867e 100644 --- a/src/auto-reply/reply/queue/state.test.ts +++ b/src/auto-reply/reply/queue/state.test.ts @@ -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); + }); +}); diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts index 454cb322851e..b29ea638789b 100644 --- a/src/auto-reply/reply/queue/state.ts +++ b/src/auto-reply/reply/queue/state.ts @@ -21,6 +21,13 @@ export type FollowupQueueState = { droppedCount: number; summaryLines: string[]; summarySources: FollowupRun[]; + summaryElisions: Array<{ + contextKey: string; + count: number; + source: FollowupRun; + sourceRefs: WeakSet; + }>; + 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); + } } diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 21cf2fc07fc7..d3cd32b9b933 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -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: { diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index d03358d2d539..965089fda6c7 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -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>; + humanDelayBudgetMs: number; + }) => ReplyFollowupAdmissionBarrierTimeoutPolicy | undefined; }; export type ReplyDispatcherWithTypingOptions = Omit & { @@ -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, }; } diff --git a/src/auto-reply/reply/reply-dispatcher.types.ts b/src/auto-reply/reply/reply-dispatcher.types.ts index b870997913e3..f71429a11e72 100644 --- a/src/auto-reply/reply/reply-dispatcher.types.ts +++ b/src/auto-reply/reply/reply-dispatcher.types.ts @@ -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; getFailedCounts: () => Record; markComplete: () => void; + /** Owner-declared deadline for holding queued follow-ups behind all queued deliveries. */ + resolveFollowupAdmissionBarrierTimeoutPolicy?: () => + | ReplyFollowupAdmissionBarrierTimeoutPolicy + | undefined; }; /** diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index a1f05d812d7e..f75f210c2567 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -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); diff --git a/src/auto-reply/reply/reply-payloads-base.ts b/src/auto-reply/reply/reply-payloads-base.ts index 38a7777c2dfb..df1c6a70bd40 100644 --- a/src/auto-reply/reply/reply-payloads-base.ts +++ b/src/auto-reply/reply/reply-payloads-base.ts @@ -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, }); diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts index 8e69719126ea..1822f0f96bd1 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -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; diff --git a/src/auto-reply/reply/reply-run-registry.test.ts b/src/auto-reply/reply/reply-run-registry.test.ts index d8a13facb2aa..697e86897130 100644 --- a/src/auto-reply/reply/reply-run-registry.test.ts +++ b/src/auto-reply/reply/reply-run-registry.test.ts @@ -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((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((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((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((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(() => {}), { + 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(() => {}), { + 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(() => {})); + + 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 { diff --git a/src/auto-reply/reply/reply-run-registry.ts b/src/auto-reply/reply/reply-run-registry.ts index 1361fa2b03c9..0521888f2e33 100644 --- a/src/auto-reply/reply/reply-run-registry.ts +++ b/src/auto-reply/reply/reply-run-registry.ts @@ -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, + timeout?: number | ReplyFollowupAdmissionBarrierTimeoutPolicy, + ): void; fail(code: Exclude, cause?: unknown): void; abortByUser(): void; abortForRestart(): void; @@ -97,12 +111,18 @@ type ReplyRunWaiter = { timer?: NodeJS.Timeout; }; +type ReplyRunFollowupAdmissionBarrier = { + settled: Promise; + sessionId: string; +}; + type ReplyRunState = { activeRunsByKey: Map; activeSessionIdsByKey: Map; activeKeysBySessionId: Map; waitKeysBySessionId: Map; waitersByKey: Map>; + followupAdmissionBarriersByKey: Map; }; const REPLY_RUN_STATE_KEY = Symbol.for("openclaw.replyRunRegistry"); @@ -113,7 +133,9 @@ const replyRunState = resolveGlobalSingleton(REPLY_RUN_STATE_KEY, activeKeysBySessionId: new Map(), waitKeysBySessionId: new Map(), waitersByKey: new Map>(), + followupAdmissionBarriersByKey: new Map(), })); +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(); +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, + 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((resolve) => { + let settled = false; + let timer: ReturnType; + 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, + 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((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + timer.unref?.(); + }), + ...(opts?.signal + ? [ + new Promise((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 }; diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts index b9acec0f4d16..ed61dcb83ea7 100644 --- a/src/auto-reply/reply/reply-threading.test.ts +++ b/src/auto-reply/reply/reply-threading.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index 80147402ae99..0ddbb85e37fa 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -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 | 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, diff --git a/src/auto-reply/reply/reply-turn-admission.test.ts b/src/auto-reply/reply/reply-turn-admission.test.ts index d9315444d7b1..8b148456ca74 100644 --- a/src/auto-reply/reply/reply-turn-admission.test.ts +++ b/src/auto-reply/reply/reply-turn-admission.test.ts @@ -1,6 +1,10 @@ // Tests reply turn admission decisions for active, queued, and aborted runs. import { afterEach, describe, expect, it, vi } from "vitest"; -import { createReplyOperation, testing } from "./reply-run-registry.js"; +import { + createReplyOperation, + runAfterReplyOperationClear, + testing, +} from "./reply-run-registry.js"; import { admitReplyTurn } from "./reply-turn-admission.js"; describe("reply turn admission", () => { @@ -108,6 +112,138 @@ describe("reply turn admission", () => { } }); + it("keeps an already-waiting follow-up behind the delivery barrier", async () => { + const active = createReplyOperation({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "active-session", + resetTriggered: false, + }); + let releaseBarrier: () => void = () => {}; + const barrier = new Promise((resolve) => { + releaseBarrier = resolve; + }); + const admitted = admitReplyTurn({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "queued-session", + kind: "queued_followup", + resetTriggered: false, + }); + let settled = false; + void admitted.then(() => { + settled = true; + }); + + await Promise.resolve(); + active.completeWithAfterClearBarrier(barrier); + await Promise.resolve(); + + expect(settled).toBe(false); + + releaseBarrier(); + const result = await admitted; + expect(result.status).toBe("owned"); + if (result.status === "owned") { + result.operation.complete(); + } + }); + + it("allows a visible turn to claim the lane while delivery settles", async () => { + const active = createReplyOperation({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "active-session", + resetTriggered: false, + }); + let releaseBarrier: () => void = () => {}; + const barrier = new Promise((resolve) => { + releaseBarrier = resolve; + }); + + active.completeWithAfterClearBarrier(barrier); + const result = await admitReplyTurn({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "visible-session", + kind: "visible", + resetTriggered: false, + }); + + expect(result.status).toBe("owned"); + if (result.status === "owned") { + result.operation.complete(); + } + releaseBarrier(); + await barrier; + }); + + it("skips heartbeat turns while delivery settles", async () => { + const active = createReplyOperation({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "active-session", + resetTriggered: false, + }); + let releaseBarrier: () => void = () => {}; + const barrier = new Promise((resolve) => { + releaseBarrier = resolve; + }); + + active.completeWithAfterClearBarrier(barrier); + const result = await admitReplyTurn({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "heartbeat-session", + kind: "heartbeat", + resetTriggered: false, + }); + + expect(result).toEqual({ status: "skipped", reason: "active-run" }); + releaseBarrier(); + await barrier; + }); + + it("passes a visible turn's rotated session to after-clear work", async () => { + const active = createReplyOperation({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "active-session", + resetTriggered: false, + }); + let releaseBarrier: () => void = () => {}; + const barrier = new Promise((resolve) => { + releaseBarrier = resolve; + }); + let admissionSessionId: string | undefined; + runAfterReplyOperationClear(active, (sessionId) => { + admissionSessionId = sessionId; + }); + + active.completeWithAfterClearBarrier(barrier); + const visibleAdmission = await admitReplyTurn({ + sessionKey: "agent:main:discord:channel:42", + sessionId: "visible-session", + kind: "visible", + resetTriggered: false, + }); + expect(visibleAdmission.status).toBe("owned"); + if (visibleAdmission.status === "owned") { + visibleAdmission.operation.updateSessionId("rotated-session"); + visibleAdmission.operation.complete(); + } + + releaseBarrier(); + await barrier; + await vi.waitFor(() => { + expect(admissionSessionId).toBe("rotated-session"); + }); + const queuedResult = await admitReplyTurn({ + sessionKey: "agent:main:discord:channel:42", + sessionId: admissionSessionId ?? "queued-session", + kind: "queued_followup", + resetTriggered: false, + }); + expect(queuedResult.status).toBe("owned"); + if (queuedResult.status === "owned") { + expect(queuedResult.operation.sessionId).toBe("rotated-session"); + queuedResult.operation.complete(); + } + }); + it("uses the active run's final session id after waiting", async () => { const active = createReplyOperation({ sessionKey: "agent:main:telegram:topic:42", diff --git a/src/auto-reply/reply/reply-turn-admission.ts b/src/auto-reply/reply/reply-turn-admission.ts index ef88a953856d..e4bcf8be94c5 100644 --- a/src/auto-reply/reply/reply-turn-admission.ts +++ b/src/auto-reply/reply/reply-turn-admission.ts @@ -4,7 +4,9 @@ import { REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS, replyRunRegistry, ReplyRunAlreadyActiveError, + ReplyRunFollowupAdmissionBlockedError, type ReplyOperation, + waitForReplyRunFollowupAdmission, } from "./reply-run-registry.js"; /** Kinds of turns that compete for one reply run slot per session. */ @@ -35,6 +37,9 @@ export async function admitReplyTurn(params: { waitForActive?: boolean; }): Promise { let sessionId = params.sessionId; + const waitTimeoutMs = + params.waitTimeoutMs ?? + (params.kind === "queued_followup" ? REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS : undefined); while (true) { if (isAbortSignalAborted(params.upstreamAbortSignal)) { return { status: "skipped", reason: "aborted" }; @@ -48,9 +53,29 @@ export async function admitReplyTurn(params: { resetTriggered: params.resetTriggered, routeThreadId: params.routeThreadId, upstreamAbortSignal: params.upstreamAbortSignal, + respectFollowupAdmissionBarrier: + params.kind === "queued_followup" || params.kind === "heartbeat", }), }; } catch (error) { + if (error instanceof ReplyRunFollowupAdmissionBlockedError) { + if (params.kind === "heartbeat") { + return { status: "skipped", reason: "active-run" }; + } + const followupAdmission = await waitForReplyRunFollowupAdmission( + params.sessionKey, + waitTimeoutMs ?? REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS, + { signal: params.upstreamAbortSignal }, + ); + if (!followupAdmission.settled) { + return { + status: "skipped", + reason: isAbortSignalAborted(params.upstreamAbortSignal) ? "aborted" : "active-run", + }; + } + sessionId = followupAdmission.sessionId ?? sessionId; + continue; + } if (!(error instanceof ReplyRunAlreadyActiveError)) { throw error; } @@ -62,9 +87,6 @@ export async function admitReplyTurn(params: { if (params.waitForActive === false) { return { status: "skipped", reason: "active-run", activeOperation }; } - const waitTimeoutMs = - params.waitTimeoutMs ?? - (params.kind === "queued_followup" ? REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS : undefined); const ended = await replyRunRegistry.waitForIdle(params.sessionKey, waitTimeoutMs, { signal: params.upstreamAbortSignal, }); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 3499e6af603a..652f73cca910 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -11,6 +11,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; +import { setReplyPayloadMetadata } from "../reply-payload.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; const mocks = vi.hoisted(() => ({ @@ -82,10 +83,16 @@ const slackMessaging: ChannelMessagingAdapter = { }; const slackThreading: ChannelThreadingAdapter = { - resolveReplyTransport: ({ threadId, replyToId }) => ({ - replyToId: resolveSlackThreadTsCandidate(replyToId) ?? resolveSlackThreadTsCandidate(threadId), - threadId: null, - }), + resolveReplyTransport: ({ threadId, replyToId, replyDelivery }) => { + const resolvedReplyToId = + resolveSlackThreadTsCandidate(replyDelivery?.replyToMode === "off" ? undefined : replyToId) ?? + resolveSlackThreadTsCandidate(threadId); + return { + replyToId: + replyDelivery?.replyToMode === "off" && !resolvedReplyToId ? null : resolvedReplyToId, + threadId: null, + }; + }, }; function resolveSlackThreadTsCandidate(value?: string | number | null): string | undefined { @@ -97,10 +104,21 @@ function resolveSlackThreadTsCandidate(value?: string | number | null): string | } const mattermostThreading: ChannelThreadingAdapter = { - resolveReplyTransport: ({ threadId, replyToId }) => ({ - replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined), - threadId, - }), + resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => { + const ambientThreadId = threadId != null && threadId !== "" ? 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, + }; + }, }; function createChannelPlugin( @@ -304,6 +322,204 @@ describe("routeReply", () => { expect(lastDelivery()).not.toHaveProperty("skipMessageSendingHooks"); }); + it("uses payload reply policy when resolving the final Slack transport", async () => { + const payload = setReplyPayloadMetadata( + { text: "hello", replyToId: "999.000" }, + { + replyDelivery: { + chatType: "channel", + replyToMode: "off", + }, + }, + ); + + const res = await routeReply({ + payload, + channel: "slack", + to: "channel:C123", + threadId: "111.000", + cfg: {} as never, + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: "111.000", + threadId: null, + }); + expect(lastDeliveryPayload().replyToId).toBe("111.000"); + }); + + it("keeps fresh payload reply mode for the same destination policy route", async () => { + const payload = setReplyPayloadMetadata( + { text: "hello", replyToId: "999.000" }, + { + replyDelivery: { + chatType: "channel", + replyToMode: "all", + }, + replyDeliverySource: { + channel: "slack", + accountId: "primary", + }, + }, + ); + + const res = await routeReply({ + payload, + channel: "slack", + to: "channel:C123", + accountId: "primary", + replyDelivery: { + chatType: "channel", + replyToMode: "off", + }, + cfg: {} as never, + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: "999.000", + threadId: null, + }); + expect(lastDeliveryPayload().replyToId).toBe("999.000"); + }); + + it("uses destination reply policy when payload policy came from another route", async () => { + const payload = setReplyPayloadMetadata( + { text: "hello", replyToId: "999.000" }, + { + replyDelivery: { + chatType: "direct", + replyToMode: "all", + }, + replyDeliverySource: { + channel: "webchat", + }, + }, + ); + + const res = await routeReply({ + payload, + channel: "slack", + to: "channel:C123", + replyDelivery: { + chatType: "channel", + replyToMode: "off", + }, + cfg: {} as never, + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: null, + threadId: null, + }); + expect(lastDeliveryPayload().replyToId).toBeUndefined(); + }); + + it("uses explicit reply policy when routed blocks have no payload metadata", async () => { + const res = await routeReply({ + payload: { text: "hello", replyToId: "999.000" }, + channel: "slack", + to: "channel:C123", + threadId: "111.000", + replyDelivery: { + chatType: "channel", + replyToMode: "off", + }, + cfg: {} as never, + replyKind: "block", + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: "111.000", + threadId: null, + }); + }); + + it("honors Slack policy that clears a top-level reply target", async () => { + const res = await routeReply({ + payload: { text: "hello", replyToId: "999.000" }, + channel: "slack", + to: "channel:C123", + replyDelivery: { + chatType: "channel", + replyToMode: "off", + }, + cfg: {} as never, + replyKind: "block", + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: null, + threadId: null, + }); + expect(lastDeliveryPayload().replyToId).toBeUndefined(); + }); + + it("honors Mattermost policy that clears direct-message reply targets", async () => { + const res = await routeReply({ + payload: { text: "hello", replyToId: "post-1" }, + channel: "mattermost", + to: "user:U123", + replyDelivery: { + chatType: "direct", + replyToMode: "all", + }, + cfg: {} as never, + replyKind: "block", + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: null, + threadId: null, + }); + expect(lastDeliveryPayload().replyToId).toBeUndefined(); + }); + + it("preserves explicit Mattermost reply targets over the ambient thread", async () => { + const res = await routeReply({ + payload: { + text: "hello", + replyToId: "other-root", + replyToTag: true, + }, + channel: "mattermost", + to: "channel:C123", + threadId: "ambient-root", + replyDelivery: { + chatType: "channel", + replyToMode: "all", + }, + cfg: {} as never, + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: "other-root", + threadId: "other-root", + }); + expect(lastDeliveryPayload().replyToId).toBe("other-root"); + }); + + it("preserves reply targets when an adapter returns undefined", async () => { + const res = await routeReply({ + payload: { text: "hello", replyToId: "msg-internal-1" }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + + expect(res.ok).toBe(true); + expectLastDeliveryFields({ + replyToId: "msg-internal-1", + threadId: null, + }); + }); + it("leaves message_sending enforcement to routed durable delivery", async () => { const res = await routeReply({ payload: { text: "secret" }, diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 9a526ba86017..177b37b2968f 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -10,6 +10,7 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js"; import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { normalizeChatChannelId } from "../../channels/registry.js"; @@ -17,9 +18,11 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; +import { normalizeAccountId } from "../../routing/account-id.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; +import { getReplyPayloadMetadata, type ReplyDeliveryContext } from "../reply-payload.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; @@ -37,6 +40,28 @@ function loadDeliverRuntime() { return messageRuntimeLoader.load(); } +function replyDeliverySourceMatchesRoute(params: { + source: NonNullable< + NonNullable>["replyDeliverySource"] + >; + payloadDelivery: ReplyDeliveryContext; + routeDelivery: ReplyDeliveryContext; + channel: string; + accountId?: string; +}): boolean { + const sourceChannel = + normalizeMessageChannel(params.source.channel) ?? + normalizeOptionalLowercaseString(params.source.channel); + const routeChannel = + normalizeMessageChannel(params.channel) ?? normalizeOptionalLowercaseString(params.channel); + return ( + sourceChannel === routeChannel && + normalizeAccountId(params.source.accountId) === normalizeAccountId(params.accountId) && + normalizeChatType(params.payloadDelivery.chatType ?? undefined) === + normalizeChatType(params.routeDelivery.chatType ?? undefined) + ); +} + export type RouteReplyParams = { /** The reply payload to send. */ payload: ReplyPayload; @@ -62,6 +87,8 @@ export type RouteReplyParams = { requesterSenderE164?: string; /** Thread id for replies (Telegram topic id or Matrix thread event id). */ threadId?: string | number; + /** Reply policy fallback for delivery kinds that do not carry payload metadata. */ + replyDelivery?: ReplyDeliveryContext; /** Config for provider-specific settings. */ cfg: OpenClawConfig; /** Optional abort signal for cooperative cancellation. */ @@ -192,18 +219,44 @@ export async function routeReply(params: RouteReplyParams): Promise boolean; resolveReplyToMode?: (params: { cfg: OpenClawConfig; accountId?: string | null; @@ -436,6 +440,9 @@ export type ChannelThreadingAdapter = { accountId?: string | null; threadId?: string | number | null; replyToId?: string | null; + /** True when replyToId came from an explicit payload target or reply tag. */ + replyToIsExplicit?: boolean; + replyDelivery?: ReplyDeliveryContext; }) => ChannelReplyTransport | null; resolveFocusedBinding?: (params: { cfg: OpenClawConfig; @@ -461,6 +468,8 @@ export type ChannelThreadingContext = { export type ChannelThreadingToolContext = { currentChannelId?: string; + /** Routable messaging target when it differs from the platform-native channel id. */ + currentMessagingTarget?: string; currentGraphChannelId?: string; currentChannelProvider?: ChannelId; currentThreadTs?: string; @@ -711,6 +720,9 @@ export type ChannelToolSend = { to: string; accountId?: string | null; threadId?: string | null; + /** True when the native provider send may inherit the active conversation thread. */ + threadImplicit?: boolean; + threadSuppressed?: boolean; }; export type ChannelMessagePreparedSendPayloadContext = { @@ -754,6 +766,11 @@ export type ChannelMessageActionAdapter = { toolContext?: ChannelThreadingToolContext; }) => boolean; extractToolSend?: (params: { args: Record }) => ChannelToolSend | null; + /** Recover the actual resolved send route from a successful action result. */ + extractToolSendResult?: (params: { + result: unknown; + send: ChannelToolSend; + }) => ChannelToolSend | null; /** * Translate generic `message(action=send)` arguments into the payload core * should persist, retry, recover, and ack. Return null to keep the legacy diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index ccdf9d8e7ae4..2243d26a7216 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -1540,9 +1540,11 @@ describe("gateway send mirroring", () => { messageId: params.messageId, requesterSenderId, currentMessageId: toolContext?.currentMessageId, + currentMessagingTarget: toolContext?.currentMessagingTarget, currentGraphChannelId: toolContext?.currentGraphChannelId, replyToMode: toolContext?.replyToMode, hasRepliedRef: toolContext?.hasRepliedRef?.value, + sameChannelThreadRequired: toolContext?.sameChannelThreadRequired, skipCrossContextDecoration: toolContext?.skipCrossContextDecoration, }), }, @@ -1564,9 +1566,11 @@ describe("gateway send mirroring", () => { messageId: "wamid.1", requesterSenderId: "trusted-user", currentMessageId: "wamid.1", + currentMessagingTarget: "user:15551234567", currentGraphChannelId: "graph:team/chan", replyToMode: "first", hasRepliedRef: true, + sameChannelThreadRequired: true, skipCrossContextDecoration: true, }), ); @@ -1582,11 +1586,13 @@ describe("gateway send mirroring", () => { requesterSenderId: "trusted-user", inboundTurnKind: "room_event", toolContext: { + currentMessagingTarget: "user:15551234567", currentGraphChannelId: "graph:team/chan", currentChannelProvider: "whatsapp", currentMessageId: "wamid.1", replyToMode: "first", hasRepliedRef: { value: true }, + sameChannelThreadRequired: true, skipCrossContextDecoration: true, }, idempotencyKey: "idem-message-action", @@ -1599,9 +1605,11 @@ describe("gateway send mirroring", () => { messageId: "wamid.1", requesterSenderId: "trusted-user", currentMessageId: "wamid.1", + currentMessagingTarget: "user:15551234567", currentGraphChannelId: "graph:team/chan", replyToMode: "first", hasRepliedRef: true, + sameChannelThreadRequired: true, skipCrossContextDecoration: true, }, undefined, @@ -1674,6 +1682,153 @@ describe("gateway send mirroring", () => { }); }); + it("mirrors a Slack DM send after target resolution strips its user prefix", async () => { + const slackPlugin: ChannelPlugin = { + id: "slack", + meta: { + id: "slack", + label: "Slack", + selectionLabel: "Slack", + docsPath: "/channels/slack", + blurb: "Slack DM transcript mirror test plugin.", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => true, + }, + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + supportsAction: ({ action }) => action === "send", + handleAction: async () => jsonResult({ ok: true, messageId: "slack-1" }), + }, + threading: { + matchesToolContextTarget: ({ target, toolContext }) => + target.toLowerCase() === + toolContext.currentMessagingTarget?.replace(/^user:/i, "").toLowerCase(), + }, + }; + mocks.getChannelPlugin.mockReturnValue(slackPlugin); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackPlugin }]), + "send-test-slack-dm-source-message-action-mirror", + ); + mocks.dispatchChannelMessageAction.mockImplementationOnce(async ({ params }) => { + params.to = "U123"; + return jsonResult({ + ok: true, + result: { + messageId: "slack-1", + receipt: { threadId: "171.222" }, + }, + }); + }); + + const { respond } = await runMessageActionRequest({ + channel: "slack", + action: "send", + params: { + to: "user:U123", + message: "visible Slack DM reply", + }, + sessionKey: "agent:main:slack:direct:U123:thread:171.222", + agentId: "main", + toolContext: { + currentChannelProvider: "slack", + currentChannelId: "D123", + currentMessagingTarget: "user:U123", + currentThreadTs: "171.222", + replyToMode: "all", + }, + idempotencyKey: "idem-slack-dm-source-message-action", + }); + + expect(firstRespondCall(respond)[0]).toBe(true); + expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({ + agentId: "main", + sessionKey: "agent:main:slack:direct:U123:thread:171.222", + text: "visible Slack DM reply", + mediaUrls: undefined, + idempotencyKey: "idem-slack-dm-source-message-action", + config: {}, + }); + + mocks.appendAssistantMessageToSessionTranscript.mockClear(); + for (const testCase of [ + { + name: "top-level", + placement: { topLevel: true }, + deliveredThreadId: undefined, + replyToMode: "all" as const, + hasRepliedRef: undefined, + }, + { + name: "null thread", + placement: { threadId: null }, + deliveredThreadId: undefined, + replyToMode: "all" as const, + hasRepliedRef: undefined, + }, + { + name: "different thread", + placement: { threadId: "999.888" }, + deliveredThreadId: "999.888", + replyToMode: "all" as const, + hasRepliedRef: undefined, + }, + { + name: "reply mode off", + placement: {}, + deliveredThreadId: undefined, + replyToMode: "off" as const, + hasRepliedRef: undefined, + }, + { + name: "consumed first reply", + placement: {}, + deliveredThreadId: undefined, + replyToMode: "first" as const, + hasRepliedRef: { value: true }, + }, + ] as const) { + mocks.dispatchChannelMessageAction.mockImplementationOnce(async ({ params }) => { + params.to = "U123"; + return jsonResult({ + ok: true, + result: { + messageId: `slack-${testCase.name}`, + receipt: testCase.deliveredThreadId ? { threadId: testCase.deliveredThreadId } : {}, + }, + }); + }); + + const redirected = await runMessageActionRequest({ + channel: "slack", + action: "send", + params: { + to: "user:U123", + message: `visible Slack DM ${testCase.name} reply`, + ...testCase.placement, + }, + sessionKey: "agent:main:slack:direct:U123:thread:171.222", + agentId: "main", + toolContext: { + currentChannelProvider: "slack", + currentChannelId: "D123", + currentMessagingTarget: "user:U123", + currentThreadTs: "171.222", + replyToMode: testCase.replyToMode, + ...(testCase.hasRepliedRef ? { hasRepliedRef: testCase.hasRepliedRef } : {}), + }, + idempotencyKey: `idem-slack-dm-source-message-action-${testCase.name}`, + }); + + expect(firstRespondCall(redirected.respond)[0]).toBe(true); + expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled(); + } + }); + it("mirrors accepted source send text aliases", async () => { mocks.dispatchChannelMessageAction.mockResolvedValueOnce( jsonResult({ ok: true, messageId: "tg-content-1" }), diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 83119bce32e8..ec78566c63ac 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -470,9 +470,15 @@ export const sendHandlers: GatewayRequestHandlers = { agentId?: string; toolContext?: { currentChannelId?: string; + currentMessagingTarget?: string; + currentGraphChannelId?: string; currentChannelProvider?: string; currentThreadTs?: string; currentMessageId?: string | number; + replyToMode?: "off" | "first" | "all" | "batched"; + hasRepliedRef?: { value: boolean }; + sameChannelThreadRequired?: boolean; + skipCrossContextDecoration?: boolean; }; idempotencyKey: string; }; diff --git a/src/infra/outbound/message-action-normalization.test.ts b/src/infra/outbound/message-action-normalization.test.ts index 1edcb841cfa2..47e6894eca1b 100644 --- a/src/infra/outbound/message-action-normalization.test.ts +++ b/src/infra/outbound/message-action-normalization.test.ts @@ -66,6 +66,28 @@ describe("normalizeMessageActionInput", () => { }, expectedFields: { target: "channel:C1", to: "channel:C1" }, }, + { + input: { + action: "send", + args: {}, + toolContext: { + currentChannelId: "user:U1", + currentChannelProvider: "slack", + }, + }, + expectedFields: { target: "user:U1", to: "user:U1" }, + }, + { + input: { + action: "send", + args: {}, + toolContext: { + currentMessagingTarget: "user:U1", + currentChannelProvider: "slack", + }, + }, + expectedFields: { target: "user:U1", to: "user:U1" }, + }, { input: { action: "send", diff --git a/src/infra/outbound/message-action-normalization.ts b/src/infra/outbound/message-action-normalization.ts index 4e8f8f1d6421..5b20d421306b 100644 --- a/src/infra/outbound/message-action-normalization.ts +++ b/src/infra/outbound/message-action-normalization.ts @@ -43,7 +43,9 @@ export function normalizeMessageActionInput(params: { actionRequiresTarget(action) && !actionHasTarget(action, normalizedArgs, { channel: inferredChannel }) ) { - const inferredTarget = normalizeOptionalString(toolContext?.currentChannelId); + const inferredTarget = + normalizeOptionalString(toolContext?.currentChannelId) ?? + normalizeOptionalString(toolContext?.currentMessagingTarget); if (inferredTarget) { normalizedArgs.target = inferredTarget; } diff --git a/src/infra/outbound/message-action-runner.context.test.ts b/src/infra/outbound/message-action-runner.context.test.ts index 79dcdb54aa3b..bfbbc2c4a9d4 100644 --- a/src/infra/outbound/message-action-runner.context.test.ts +++ b/src/infra/outbound/message-action-runner.context.test.ts @@ -44,6 +44,42 @@ const localChatTestPlugin: ChannelPlugin = { }, }; +const resolvedDmTestPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "slackdm", + label: "Resolved DM", + capabilities: { chatTypes: ["direct"], media: true }, + }), + outbound: directOutbound, + messaging: { + normalizeTarget: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const userId = trimmed.replace(/^user:/i, ""); + return /^user:/i.test(trimmed) + ? `user:${userId.toLowerCase()}` + : `channel:${trimmed.toLowerCase()}`; + }, + targetResolver: { + looksLikeId: (raw) => /^(?:user:)?[UW][A-Z0-9]+$/i.test(raw.trim()), + hint: "", + resolveTarget: async ({ input }) => { + const userId = input.trim().replace(/^user:/i, ""); + return /^[UW][A-Z0-9]+$/i.test(userId) + ? { to: userId, kind: "user", source: "normalized" } + : null; + }, + }, + }, + threading: { + matchesToolContextTarget: ({ target, toolContext }) => + target.toLowerCase() === + toolContext.currentMessagingTarget?.replace(/^user:/i, "").toLowerCase(), + }, +}; + describe("runMessageAction context isolation", () => { beforeEach(() => { setActivePluginRegistry( @@ -68,6 +104,11 @@ describe("runMessageAction context isolation", () => { source: "test", plugin: localChatTestPlugin, }, + { + pluginId: "slackdm", + source: "test", + plugin: resolvedDmTestPlugin, + }, ]), ); }); @@ -138,6 +179,33 @@ describe("runMessageAction context isolation", () => { expect(result.kind).toBe("send"); }); + it("allows the active DM after target resolution strips its user prefix", async () => { + const result = await runDrySend({ + cfg: { + channels: { slackdm: {} }, + tools: { + message: { + crossContext: { + allowWithinProvider: false, + }, + }, + }, + } as OpenClawConfig, + actionParams: { + channel: "slackdm", + target: "user:U123", + message: "hi", + }, + toolContext: { + currentChannelId: "D123", + currentMessagingTarget: "user:U123", + currentChannelProvider: "slackdm", + }, + }); + + expect(result).toMatchObject({ kind: "send", to: "U123" }); + }); + it.each([ { name: "send when target differs from current workspace channel", diff --git a/src/infra/outbound/message-action-runner.core-send.test.ts b/src/infra/outbound/message-action-runner.core-send.test.ts index f19ebcb2c5c1..342b54586499 100644 --- a/src/infra/outbound/message-action-runner.core-send.test.ts +++ b/src/infra/outbound/message-action-runner.core-send.test.ts @@ -237,6 +237,86 @@ describe("runMessageAction core send routing", () => { expect(payload.dryRun).toBe(true); }); + it("sends with the provider-canonical reply root", async () => { + const sendText = vi.fn().mockResolvedValue({ + channel: "testchat", + messageId: "m1", + chatId: "C1", + }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "testchat", + source: "test", + plugin: { + ...createOutboundTestPlugin({ + id: "testchat", + outbound: { + deliveryMode: "direct", + sendText, + }, + }), + threading: { + resolveAutoThreadId: ({ + toolContext, + replyToId, + }: { + toolContext?: { + currentMessageId?: string | number; + currentThreadTs?: string; + }; + replyToId?: string | null; + }) => + replyToId === toolContext?.currentMessageId + ? toolContext?.currentThreadTs + : undefined, + resolveReplyTransport: ({ + threadId, + replyToId, + }: { + threadId?: string | number | null; + replyToId?: string | null; + }) => { + const root = replyToId ?? (threadId == null ? undefined : String(threadId)); + return { replyToId: root, threadId: root }; + }, + }, + }, + }, + ]), + ); + + await runMessageAction({ + cfg: { + channels: { + testchat: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "send", + params: { + channel: "testchat", + target: "channel:C1", + message: "threaded", + replyTo: "child-1", + }, + toolContext: { + currentChannelProvider: "testchat", + currentChannelId: "channel:C1", + currentThreadTs: "root-1", + currentMessageId: "child-1", + replyToMode: "all", + }, + dryRun: false, + }); + + expect(firstMockArg(sendText, "send text")).toMatchObject({ + replyToId: "root-1", + threadId: "root-1", + }); + }); + it("uses best-effort delivery for implicit message-tool-only source replies", async () => { const sendText = registerSlackTextPlugin(); diff --git a/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index 7832aeb02da2..2391202554da 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -101,6 +101,38 @@ describe("message action threading helpers", () => { expect(ensureOutboundSessionEntry).toHaveBeenCalledTimes(1); }); + it("prepares the outbound route with a canonicalized reply root", async () => { + const actionParams: Record = { + channel: "forum", + target: "forum:123", + message: "hi", + replyTo: "child-777", + }; + resolveOutboundSessionRoute.mockResolvedValue(null); + + await prepareOutboundMirrorRoute({ + cfg: forumConfig, + channel: "forum", + to: "forum:123", + actionParams, + toolContext: defaultForumToolContext, + agentId: "main", + resolveAutoThreadId: () => "root-42", + resolveReplyTransport: ({ replyToId }) => ({ + replyToId, + threadId: replyToId, + }), + resolveOutboundSessionRoute, + ensureOutboundSessionEntry, + }); + + expect(resolveOutboundSessionRoute).toHaveBeenCalledOnce(); + expect(firstMockArg(resolveOutboundSessionRoute)).toMatchObject({ + replyToId: "root-42", + threadId: "root-42", + }); + }); + it.each([ { name: "injects threadId for matching target", @@ -136,23 +168,27 @@ describe("message action threading helpers", () => { expect(resolved).toBe(testCase.expectedThreadId); }); - it("uses explicit forum threadId when provided", () => { + it("uses explicit forum threadId without rewriting replyTo", () => { const actionParams: Record = { channel: "forum", target: "forum:123", message: "hi", threadId: "999", + replyTo: "777", }; + const resolveAutoThreadId = vi.fn(() => "42"); const resolved = resolveAndApplyOutboundThreadId(actionParams, { cfg: forumConfig, to: "forum:123", toolContext: defaultForumToolContext, - resolveAutoThreadId: () => "42", + resolveAutoThreadId, }); expect(actionParams.threadId).toBe("999"); + expect(actionParams.replyTo).toBe("777"); expect(resolved).toBe("999"); + expect(resolveAutoThreadId).not.toHaveBeenCalled(); }); it.each([ @@ -178,7 +214,7 @@ describe("message action threading helpers", () => { expect(resolveAutoThreadId).not.toHaveBeenCalled(); }); - it("passes explicit replyTo into auto-thread resolution", () => { + it("preserves explicit replyTo when the provider keeps reply and thread distinct", () => { const resolveAutoThreadId = vi.fn((_params: { replyToId?: string | null }) => "thread-777"); const actionParams: Record = { channel: "forum", @@ -198,6 +234,30 @@ describe("message action threading helpers", () => { expect(firstMockArg(resolveAutoThreadId).replyToId).toBe("777"); expect(resolved).toBe("thread-777"); expect(actionParams.threadId).toBe("thread-777"); + expect(actionParams.replyTo).toBe("777"); + }); + + it("canonicalizes replyTo when the provider maps reply and thread to one root", () => { + const actionParams: Record = { + channel: "forum", + target: "forum:123", + message: "hi", + replyTo: "child-777", + }; + + resolveAndApplyOutboundThreadId(actionParams, { + cfg: forumConfig, + to: "forum:123", + toolContext: defaultForumToolContext, + resolveAutoThreadId: () => "root-42", + resolveReplyTransport: ({ replyToId }) => ({ + replyToId, + threadId: replyToId, + }), + }); + + expect(actionParams.threadId).toBe("root-42"); + expect(actionParams.replyTo).toBe("root-42"); }); it("inherits currentMessageId for same-target sends when replyToMode=all", () => { @@ -220,6 +280,24 @@ describe("message action threading helpers", () => { expect(actionParams.replyTo).toBe("msg-42"); }); + it("inherits currentMessageId for a routable alias of the native channel", () => { + const actionParams: Record = { + to: "user:U123", + }; + + resolveAndApplyOutboundReplyToId(actionParams, { + channel: "slack", + toolContext: { + currentChannelId: "D123", + currentMessagingTarget: "user:U123", + currentMessageId: "msg-42", + replyToMode: "all", + }, + }); + + expect(actionParams.replyTo).toBe("msg-42"); + }); + it("skips inherited reply ids for explicit top-level sends", () => { const actionParams: Record = { channel: "workspace", @@ -301,6 +379,48 @@ describe("message action threading helpers", () => { expect(hasRepliedRef.value).toBe(true); }); + it("consumes first-mode threading once for a channel-normalized target alias", () => { + const hasRepliedRef = { value: false }; + const toolContext = { + currentChannelId: "D123", + currentMessagingTarget: "user:U123", + currentMessageId: "msg-42", + replyToMode: "first" as const, + hasRepliedRef, + }; + const matchesToolContextTarget = vi.fn(({ target }: { target: string }) => target === "U123"); + + const firstResolved = resolveAndApplyOutboundReplyToId( + { + channel: "slack", + target: "U123", + message: "first", + }, + { + channel: "slack", + toolContext, + matchesToolContextTarget, + }, + ); + const secondResolved = resolveAndApplyOutboundReplyToId( + { + channel: "slack", + target: "U123", + message: "followup", + }, + { + channel: "slack", + toolContext, + matchesToolContextTarget, + }, + ); + + expect(firstResolved).toBe("msg-42"); + expect(secondResolved).toBeUndefined(); + expect(hasRepliedRef.value).toBe(true); + expect(matchesToolContextTarget).toHaveBeenCalledTimes(2); + }); + it("consumes first-mode when the first send uses an explicit replyTo", () => { const hasRepliedRef = { value: false }; const explicitResolved = resolveAndApplyOutboundReplyToId( diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 339d29ce67a7..42a759d47528 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -551,7 +551,8 @@ function isCurrentSourceTargetParam( params: Record, ): boolean { const currentChannelId = normalizeOptionalString(input.toolContext?.currentChannelId); - if (!currentChannelId) { + const currentMessagingTarget = normalizeOptionalString(input.toolContext?.currentMessagingTarget); + if (!currentChannelId && !currentMessagingTarget) { return false; } const currentChannelProvider = normalizeOptionalLowercaseString( @@ -572,12 +573,17 @@ function isCurrentSourceTargetParam( const provider = explicitChannel ?? currentChannelProvider; const currentCandidates = new Set(); - addCandidateAndUnprefixedAlias(currentCandidates, currentChannelId); - if (provider) { - addCandidateAndUnprefixedAlias( - currentCandidates, - normalizeTargetForAccountBinding(provider, currentChannelId), - ); + for (const currentTarget of [currentMessagingTarget, currentChannelId]) { + if (!currentTarget) { + continue; + } + addCandidateAndUnprefixedAlias(currentCandidates, currentTarget); + if (provider) { + addCandidateAndUnprefixedAlias( + currentCandidates, + normalizeTargetForAccountBinding(provider, currentTarget), + ); + } } const explicitCandidates = new Set(); @@ -632,6 +638,7 @@ function hasCurrentSourceReplyContext(input: RunMessageActionParams): boolean { const currentMessageId = input.toolContext?.currentMessageId; return Boolean( normalizeOptionalString(input.toolContext?.currentChannelId) || + normalizeOptionalString(input.toolContext?.currentMessagingTarget) || normalizeOptionalString(input.toolContext?.currentThreadTs) || (typeof currentMessageId === "number" && Number.isFinite(currentMessageId)) || normalizeOptionalString(currentMessageId), @@ -668,7 +675,10 @@ async function shouldUseInternalSourceReplySink( if (!hasImplicitCurrentSourceRoute) { return false; } - if (!normalizeOptionalString(input.toolContext?.currentChannelId)) { + if ( + !normalizeOptionalString(input.toolContext?.currentChannelId) && + !normalizeOptionalString(input.toolContext?.currentMessagingTarget) + ) { return true; } // Configured current-source channels can infer the target and deliver through @@ -1100,9 +1110,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise; +type ResolveReplyTransport = NonNullable; +type MatchesToolContextTarget = NonNullable; function suppressesImplicitThreading(actionParams: Record): boolean { return actionParams.topLevel === true || actionParams.threadId === null; @@ -28,6 +30,7 @@ export function resolveAndApplyOutboundThreadId( accountId?: string | null; toolContext?: ChannelThreadingToolContext; resolveAutoThreadId?: ResolveAutoThreadId; + resolveReplyTransport?: ResolveReplyTransport; }, ): string | undefined { const threadId = readStringParam(actionParams, "threadId"); @@ -35,28 +38,43 @@ export function resolveAndApplyOutboundThreadId( if (!threadId && suppressesImplicitThreading(actionParams)) { return undefined; } - const resolved = - threadId ?? - context.resolveAutoThreadId?.({ + const replyToId = readStringParam(actionParams, "replyTo"); + const autoResolvedThreadId = threadId + ? undefined + : context.resolveAutoThreadId?.({ + cfg: context.cfg, + accountId: context.accountId, + to: context.to, + toolContext: context.toolContext, + replyToId, + }); + const resolvedThreadId = threadId ?? autoResolvedThreadId; + if (autoResolvedThreadId && !actionParams.threadId) { + actionParams.threadId = autoResolvedThreadId; + const canonicalReplyToId = context.resolveReplyTransport?.({ cfg: context.cfg, accountId: context.accountId, - to: context.to, - toolContext: context.toolContext, - replyToId: readStringParam(actionParams, "replyTo"), - }); - if (resolved && !actionParams.threadId) { - actionParams.threadId = resolved; + threadId: autoResolvedThreadId, + replyToId: autoResolvedThreadId, + })?.replyToId; + // Providers that use one canonical root for reply and thread routing opt in + // through resolveReplyTransport. Other transports keep message replies intact. + if (replyToId && canonicalReplyToId && replyToId !== canonicalReplyToId) { + actionParams.replyTo = canonicalReplyToId; + } } - return resolved ?? undefined; + return resolvedThreadId ?? undefined; } function isSameConversationTarget( actionParams: Record, channel: ChannelId, toolContext?: ChannelThreadingToolContext, + matchesToolContextTarget?: MatchesToolContextTarget, ): boolean { const currentChannelId = toolContext?.currentChannelId?.trim(); - if (!currentChannelId) { + const currentMessagingTarget = toolContext?.currentMessagingTarget?.trim(); + if (!currentChannelId && !currentMessagingTarget) { return false; } const currentChannelProvider = toolContext?.currentChannelProvider?.trim(); @@ -70,7 +88,11 @@ function isSameConversationTarget( if (!explicitTarget) { return true; } - return explicitTarget.trim() === currentChannelId; + const target = explicitTarget.trim(); + if (toolContext && matchesToolContextTarget?.({ target, toolContext })) { + return true; + } + return target === currentMessagingTarget || target === currentChannelId; } /** Resolves and writes reply-to metadata for same-conversation message-action sends. */ @@ -79,6 +101,7 @@ export function resolveAndApplyOutboundReplyToId( context: { channel: ChannelId; toolContext?: ChannelThreadingToolContext; + matchesToolContextTarget?: MatchesToolContextTarget; }, ): string | undefined { const explicitReplyToId = readStringParam(actionParams, "replyTo"); @@ -94,7 +117,14 @@ export function resolveAndApplyOutboundReplyToId( if (suppressesImplicitThreading(actionParams)) { return undefined; } - if (!isSameConversationTarget(actionParams, context.channel, context.toolContext)) { + if ( + !isSameConversationTarget( + actionParams, + context.channel, + context.toolContext, + context.matchesToolContextTarget, + ) + ) { return undefined; } @@ -141,6 +171,7 @@ export async function prepareOutboundMirrorRoute(params: { dryRun?: boolean; resolvedTarget?: ResolvedMessagingTarget; resolveAutoThreadId?: ResolveAutoThreadId; + resolveReplyTransport?: ResolveReplyTransport; resolveOutboundSessionRoute: ( params: ResolveOutboundSessionRouteParams, ) => Promise; @@ -154,14 +185,15 @@ export async function prepareOutboundMirrorRoute(params: { resolvedThreadId?: string; outboundRoute: OutboundSessionRoute | null; }> { - const replyToId = readStringParam(params.actionParams, "replyTo"); const resolvedThreadId = resolveAndApplyOutboundThreadId(params.actionParams, { cfg: params.cfg, to: params.to, accountId: params.accountId, toolContext: params.toolContext, resolveAutoThreadId: params.resolveAutoThreadId, + resolveReplyTransport: params.resolveReplyTransport, }); + const replyToId = readStringParam(params.actionParams, "replyTo"); const outboundRoute = params.agentId && !params.dryRun ? await params.resolveOutboundSessionRoute({ diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts index 19c555b5586e..b1a9680e4e33 100644 --- a/src/infra/outbound/outbound-policy.test.ts +++ b/src/infra/outbound/outbound-policy.test.ts @@ -21,29 +21,43 @@ function expectCrossContextDecoration( } const mocks = vi.hoisted(() => ({ - getChannelPlugin: vi.fn((channel: string) => - channel === "richchat" - ? { - messaging: { - buildCrossContextPresentation: ({ - originLabel, - message, - }: { - originLabel: string; - message: string; - }) => { - const trimmed = message.trim(); - return { - blocks: [ - ...(trimmed ? [{ type: "text" as const, text: message }] : []), - { type: "context" as const, text: `From ${originLabel}` }, - ], - }; - }, + getChannelPlugin: vi.fn((channel: string) => { + if (channel === "slack") { + return { + threading: { + matchesToolContextTarget: ({ + target, + toolContext, + }: { + target: string; + toolContext: { currentMessagingTarget?: string }; + }) => target === "U123" && toolContext.currentMessagingTarget === "user:U123", + }, + }; + } + if (channel === "richchat") { + return { + messaging: { + buildCrossContextPresentation: ({ + originLabel, + message, + }: { + originLabel: string; + message: string; + }) => { + const trimmed = message.trim(); + return { + blocks: [ + ...(trimmed ? [{ type: "text" as const, text: message }] : []), + { type: "context" as const, text: `From ${originLabel}` }, + ], + }; }, - } - : undefined, - ), + }, + }; + } + return undefined; + }), normalizeTargetForProvider: vi.fn((channel: string, raw: string) => { const trimmed = raw.trim(); if (!trimmed) { @@ -215,6 +229,26 @@ describe("outbound policy helpers", () => { expectCrossContextPolicyResult(params); }); + it("allows a routable alias of the native current channel", () => { + expect(() => + enforceCrossContextPolicy({ + channel: "slack", + action: "send", + args: { to: "U123" }, + toolContext: { + currentChannelId: "D123", + currentMessagingTarget: "user:U123", + currentChannelProvider: "slack", + }, + cfg: { + tools: { + message: { crossContext: { allowWithinProvider: false } }, + }, + }, + }), + ).not.toThrow(); + }); + it("uses presentation when available and preferred", async () => { const decoration = await buildCrossContextDecoration({ cfg: richChatConfig, diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 936da0bc213a..f37669e5b26a 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -86,16 +86,29 @@ function isCrossContextTarget(params: { target: string; toolContext?: ChannelThreadingToolContext; }): boolean { - const currentTarget = params.toolContext?.currentChannelId?.trim(); - if (!currentTarget) { + if ( + params.toolContext && + getChannelPlugin(params.channel)?.threading?.matchesToolContextTarget?.({ + target: params.target, + toolContext: params.toolContext, + }) + ) { + return false; + } + const currentTargets = [ + params.toolContext?.currentMessagingTarget?.trim(), + params.toolContext?.currentChannelId?.trim(), + ].filter((target): target is string => Boolean(target)); + if (currentTargets.length === 0) { return false; } const normalizedTarget = normalizeTarget(params.channel, params.target); - const normalizedCurrent = normalizeTarget(params.channel, currentTarget); - if (!normalizedTarget || !normalizedCurrent) { + if (!normalizedTarget) { return false; } - return normalizedTarget !== normalizedCurrent; + return !currentTargets.some( + (currentTarget) => normalizeTarget(params.channel, currentTarget) === normalizedTarget, + ); } function resolveAgentMessageToolsConfig( @@ -198,7 +211,9 @@ export function enforceCrossContextPolicy(params: { cfg: OpenClawConfig; agentId?: string | null; }): void { - const currentTarget = params.toolContext?.currentChannelId?.trim(); + const currentTarget = + params.toolContext?.currentChannelId?.trim() ?? + params.toolContext?.currentMessagingTarget?.trim(); if (!currentTarget) { return; } @@ -257,11 +272,13 @@ export async function buildCrossContextDecoration(params: { accountId?: string | null; agentId?: string | null; }): Promise { - if (!params.toolContext?.currentChannelId) { + const currentTarget = + params.toolContext?.currentChannelId ?? params.toolContext?.currentMessagingTarget; + if (!currentTarget) { return null; } // Direct tool sends are authored for their destination, not forwarded from a bound context. - if (params.toolContext.skipCrossContextDecoration) { + if (params.toolContext?.skipCrossContextDecoration) { return null; } if (!isCrossContextTarget(params)) { @@ -280,13 +297,13 @@ export async function buildCrossContextDecoration(params: { (await lookupDirectoryDisplay({ cfg: params.cfg, channel: params.channel, - targetId: params.toolContext.currentChannelId, + targetId: currentTarget, accountId: params.accountId ?? undefined, - })) ?? params.toolContext.currentChannelId; + })) ?? currentTarget; // Don't force group formatting here; currentChannelId can be a DM or a group. const originLabel = formatTargetDisplay({ channel: params.channel, - target: params.toolContext.currentChannelId, + target: currentTarget, display: currentName, }); const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] "; diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index e982aa0753b7..81a24d90d0a0 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -728,6 +728,8 @@ describe("executeSendAction", () => { }, to: "channel:123", message: "hello", + replyToId: "reply-1", + threadId: "thread-1", }); expect(prepareSendPayload).toHaveBeenCalledWith( @@ -742,6 +744,8 @@ describe("executeSendAction", () => { const sendArgs = expectSingleCallFields(mocks.sendMessage, { channel: "discord", queuePolicy: "best_effort", + replyToId: "reply-1", + threadId: "thread-1", }); const [payload] = requireArray(sendArgs.payloads, "send payloads"); expectFields(requireRecord(payload, "prepared payload"), { diff --git a/src/infra/outbound/source-reply-mirror.ts b/src/infra/outbound/source-reply-mirror.ts index 4868f32d9d02..df1df4f569ea 100644 --- a/src/infra/outbound/source-reply-mirror.ts +++ b/src/infra/outbound/source-reply-mirror.ts @@ -31,11 +31,19 @@ type MirrorableSourceReplyTranscriptParams = SourceReplyTranscriptMirrorParams & sessionKey: string; }; +type SourceReplyThreadPlacement = "match" | "mismatch" | "unknown"; + // Mirror only enough delivered payload detail to preserve transcript context. function readStringArray(value: unknown): string[] | undefined { return normalizeOptionalTrimmedStringList(value); } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + function readFirstString( params: Record, keys: readonly string[], @@ -57,6 +65,50 @@ function resolveSourceReplyThreadId(params: SourceReplyTranscriptMirrorParams): return readFirstString(params.actionParams, ["threadId", "messageThreadId"]); } +function resolveDeliveredThreadPlacement( + params: SourceReplyTranscriptMirrorParams, + currentThreadId: string | undefined, +): SourceReplyThreadPlacement | undefined { + const payload = asRecord(params.deliveredPayload); + const result = asRecord(payload?.result); + const receipt = asRecord(result?.receipt) ?? asRecord(payload?.receipt); + if (!receipt) { + return undefined; + } + const deliveredThreadId = normalizeOptionalString(receipt.threadId); + return deliveredThreadId + ? deliveredThreadId === currentThreadId + ? "match" + : "mismatch" + : currentThreadId + ? "mismatch" + : "match"; +} + +function resolveSourceReplyThreadPlacement( + params: SourceReplyTranscriptMirrorParams, +): SourceReplyThreadPlacement { + const currentThreadId = normalizeOptionalString(params.toolContext?.currentThreadTs); + const deliveredPlacement = resolveDeliveredThreadPlacement(params, currentThreadId); + if (deliveredPlacement) { + return deliveredPlacement; + } + if (params.actionParams.topLevel === true) { + return currentThreadId ? "mismatch" : "match"; + } + for (const key of ["threadId", "messageThreadId"] as const) { + if (!Object.hasOwn(params.actionParams, key)) { + continue; + } + const explicitThreadId = normalizeOptionalString(params.actionParams[key]); + if (!explicitThreadId) { + return currentThreadId ? "mismatch" : "match"; + } + return explicitThreadId === currentThreadId ? "match" : "mismatch"; + } + return currentThreadId ? "unknown" : "match"; +} + function resolveThreadedSourceTarget( params: SourceReplyTranscriptMirrorParams, requestedTarget: string, @@ -100,23 +152,48 @@ function isCurrentSourceConversation( if (!params.sessionKey?.trim()) { return false; } - const currentChannel = normalizeOptionalLowercaseString( - params.toolContext?.currentChannelProvider, - ); + const toolContext = params.toolContext; + if (!toolContext) { + return false; + } + const currentChannel = normalizeOptionalLowercaseString(toolContext.currentChannelProvider); if (!currentChannel || currentChannel !== normalizeOptionalLowercaseString(params.channel)) { return false; } - const currentTarget = normalizeOptionalString(params.toolContext?.currentChannelId); - if (!currentTarget) { + const currentTargets = [ + normalizeOptionalString(toolContext.currentMessagingTarget), + normalizeOptionalString(toolContext.currentChannelId), + ].filter((target): target is string => Boolean(target)); + if (currentTargets.length === 0) { return false; } const requestedTarget = resolveSourceReplyTarget(params.actionParams); if (!requestedTarget) { return false; } - return ( - requestedTarget === currentTarget || - resolveThreadedSourceTarget(params, requestedTarget) === currentTarget + const threadPlacement = resolveSourceReplyThreadPlacement(params); + if (threadPlacement === "mismatch") { + return false; + } + const threadedTarget = resolveThreadedSourceTarget(params, requestedTarget); + const matchesToolContextTarget = getChannelPlugin(params.channel as ChannelId)?.threading + ?.matchesToolContextTarget; + if ( + threadPlacement === "match" && + (matchesToolContextTarget?.({ + target: requestedTarget, + toolContext, + }) || + (threadedTarget !== requestedTarget && + matchesToolContextTarget?.({ + target: threadedTarget, + toolContext, + }))) + ) { + return true; + } + return currentTargets.some( + (currentTarget) => requestedTarget === currentTarget || threadedTarget === currentTarget, ); } diff --git a/src/plugin-sdk/acp-runtime-backend.ts b/src/plugin-sdk/acp-runtime-backend.ts index f70ce5b976df..381caeee0fef 100644 --- a/src/plugin-sdk/acp-runtime-backend.ts +++ b/src/plugin-sdk/acp-runtime-backend.ts @@ -93,6 +93,7 @@ export async function tryDispatchAcpReplyHook( originatingTo: event.originatingTo, originatingAccountId: event.originatingAccountId, originatingThreadId: event.originatingThreadId, + originatingChatType: event.originatingChatType, shouldSendToolSummaries: event.shouldSendToolSummaries, shouldSendToolSummariesNow: () => event.shouldSendToolSummaries, bypassForCommand, diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 8b1ba68edda5..40079ba4fba2 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -129,6 +129,8 @@ export { } from "../auto-reply/heartbeat-tool-response.js"; export { isMessagingTool, isMessagingToolSendAction } from "../agents/embedded-agent-messaging.js"; export { + extractMessagingToolSend, + extractMessagingToolSendResult, extractToolResultMediaArtifact, filterToolResultMediaUrls, isToolResultError, diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts index 5c58819684e9..ce47bb41f9cf 100644 --- a/src/plugin-sdk/reply-runtime.ts +++ b/src/plugin-sdk/reply-runtime.ts @@ -48,6 +48,7 @@ export { export type { ReplyDispatchKind, ReplyDispatcher, + ReplyFollowupAdmissionBarrierTimeoutPolicy, } from "../auto-reply/reply/reply-dispatcher.types.js"; export type { ReplyDispatcherOptions, diff --git a/src/plugin-sdk/tool-send.ts b/src/plugin-sdk/tool-send.ts index f1d33d1f9550..efe2a046f055 100644 --- a/src/plugin-sdk/tool-send.ts +++ b/src/plugin-sdk/tool-send.ts @@ -16,6 +16,8 @@ export function extractToolSend( accountId?: string; /** Optional thread/topic id, normalized to string for channel send adapters. */ threadId?: string; + /** True when the send explicitly opts out of ambient thread inheritance. */ + threadSuppressed?: boolean; } | null { const action = readStringValue(args.action)?.trim() ?? ""; if (action !== expectedAction) { @@ -31,5 +33,6 @@ export function extractToolSend( ? String(args.threadId) : (readStringValue(args.threadId)?.trim() ?? ""); const threadId = threadIdRaw.length > 0 ? threadIdRaw : undefined; - return { to, accountId, threadId }; + const threadSuppressed = args.topLevel === true || args.threadId === null; + return { to, accountId, threadId, ...(threadSuppressed ? { threadSuppressed: true } : {}) }; } diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 290a61283aef..0b0c403b59c5 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -6,6 +6,7 @@ import type { ReplyDispatcher, } from "../auto-reply/reply/reply-dispatcher.types.js"; import type { FinalizedMsgContext } from "../auto-reply/templating.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 type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; @@ -476,6 +477,7 @@ export type PluginHookReplyDispatchEvent = { originatingTo?: string; originatingAccountId?: string; originatingThreadId?: string | number; + originatingChatType?: ChatType; shouldSendToolSummaries: boolean; sendPolicy: "allow" | "deny"; isTailDispatch?: boolean;