diff --git a/CHANGELOG.md b/CHANGELOG.md index 6074b698071..0d1375eaa32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91. - Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077. - Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y. - Agents/tools: honor narrow runtime tool allowlists when constructing embedded-runner tool families and bundled MCP/LSP runtimes, so cron/subagent runs that request tools such as `update_plan`, `browser`, `x_search`, channel login tools, or `group:plugins` no longer start with missing tools or unrelated bootstrap work. (#77519, #77532) diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 79f23b22910..b799bf3ced4 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -479,6 +479,7 @@ export function createOpenClawTools( currentChannelId: options?.currentChannelId, currentChannelProvider: options?.agentChannel, currentThreadTs: options?.currentThreadTs, + agentThreadId: options?.agentThreadId, currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, diff --git a/src/agents/subagent-announce-output.test.ts b/src/agents/subagent-announce-output.test.ts index f2ad7a6b988..e75525fc512 100644 --- a/src/agents/subagent-announce-output.test.ts +++ b/src/agents/subagent-announce-output.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { __testing, readSubagentOutput } from "./subagent-announce-output.js"; +import { + __testing, + buildChildCompletionFindings, + readSubagentOutput, +} from "./subagent-announce-output.js"; type CallGateway = typeof import("../gateway/call.js").callGateway; type ReadLatestAssistantReply = typeof import("./tools/agent-step.js").readLatestAssistantReply; @@ -101,3 +105,56 @@ describe("readSubagentOutput", () => { ); }); }); + +describe("buildChildCompletionFindings", () => { + it("does not convert ANNOUNCE_SKIP child completions into no-output findings", () => { + const findings = buildChildCompletionFindings([ + { + childSessionKey: "agent:main:subagent:silent", + task: "silent task", + createdAt: 1, + frozenResultText: "ANNOUNCE_SKIP", + outcome: { status: "ok" }, + }, + ]); + + expect(findings).toBeUndefined(); + }); + + it("keeps failed ANNOUNCE_SKIP child completions visible", () => { + const findings = buildChildCompletionFindings([ + { + childSessionKey: "agent:main:subagent:silent", + task: "silent task", + createdAt: 1, + frozenResultText: "ANNOUNCE_SKIP", + outcome: { status: "error", error: "boom" }, + }, + ]); + + expect(findings).toContain("status: error: boom"); + expect(findings).toContain("ANNOUNCE_SKIP"); + }); + + it("numbers findings contiguously after skipped silent completions", () => { + const findings = buildChildCompletionFindings([ + { + childSessionKey: "agent:main:subagent:silent", + task: "silent task", + createdAt: 1, + frozenResultText: "ANNOUNCE_SKIP", + outcome: { status: "ok" }, + }, + { + childSessionKey: "agent:main:subagent:visible", + task: "visible task", + createdAt: 2, + frozenResultText: "actual output", + outcome: { status: "ok" }, + }, + ]); + + expect(findings).toContain("1. visible task"); + expect(findings).not.toContain("2. visible task"); + }); +}); diff --git a/src/agents/subagent-announce-output.ts b/src/agents/subagent-announce-output.ts index 03f32d86c8f..01639b284c6 100644 --- a/src/agents/subagent-announce-output.ts +++ b/src/agents/subagent-announce-output.ts @@ -447,17 +447,27 @@ export function buildChildCompletionFindings( const sections: string[] = []; for (const [index, child] of sorted.entries()) { + const resultText = child.frozenResultText?.trim(); + const outcome = describeSubagentOutcome(child.outcome); + if ( + child.outcome?.status === "ok" && + resultText && + (isAnnounceSkip(resultText) || isSilentReplyText(resultText, SILENT_REPLY_TOKEN)) + ) { + continue; + } const title = child.label?.trim() || child.task.trim() || child.childSessionKey.trim() || `child ${index + 1}`; - const resultText = child.frozenResultText?.trim(); - const outcome = describeSubagentOutcome(child.outcome); + const displayIndex = sections.length + 1; sections.push( - [`${index + 1}. ${title}`, `status: ${outcome}`, formatUntrustedChildResult(resultText)].join( - "\n", - ), + [ + `${displayIndex}. ${title}`, + `status: ${outcome}`, + formatUntrustedChildResult(resultText), + ].join("\n"), ); } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 0a933cd8e5d..2a1d6bd662e 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -4,12 +4,14 @@ import type { ChannelMessageCapability } from "../../channels/plugins/message-ca import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; +type CreateOpenClawTools = typeof import("../openclaw-tools.js").createOpenClawTools; type ResetPluginRuntimeStateForTest = typeof import("../../plugins/runtime.js").resetPluginRuntimeStateForTest; type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry; type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry; let createMessageTool: CreateMessageTool; +let createOpenClawTools: CreateOpenClawTools; let resetPluginRuntimeStateForTest: ResetPluginRuntimeStateForTest; let setActivePluginRegistry: SetActivePluginRegistry; let createTestRegistry: CreateTestRegistry; @@ -154,6 +156,7 @@ beforeAll(async () => { await import("../../plugins/runtime.js")); ({ createTestRegistry } = await import("../../test-utils/channel-plugins.js")); ({ createMessageTool } = await import("./message-tool.js")); + ({ createOpenClawTools } = await import("../openclaw-tools.js")); }); beforeEach(() => { @@ -358,6 +361,79 @@ describe("message tool agent routing", () => { expect(call?.agentId).toBe("alpha"); expect(call?.sessionKey).toBe("agent:alpha:main"); }); + + it("uses agentThreadId as ambient thread context when currentThreadTs is absent", async () => { + mockSendResult({ channel: "slack", to: "channel:C123" }); + + const tool = createMessageTool({ + agentSessionKey: "agent:main:slack:channel:c123:thread:111.222", + config: {} as never, + currentChannelProvider: "slack", + currentChannelId: "channel:C123", + agentThreadId: "111.222", + runMessageAction: mocks.runMessageAction as never, + }); + + await tool.execute("1", { + action: "send", + channel: "slack", + message: "stay in thread", + }); + + const call = mocks.runMessageAction.mock.calls[0]?.[0]; + expect(call?.toolContext?.currentThreadTs).toBe("111.222"); + expect(call?.toolContext?.replyToMode).toBe("all"); + }); + + it("keeps explicit reply mode opt-out when agentThreadId is present", async () => { + mockSendResult({ channel: "slack", to: "channel:C123" }); + + const tool = createMessageTool({ + agentSessionKey: "agent:main:slack:channel:c123:thread:111.222", + config: {} as never, + currentChannelProvider: "slack", + currentChannelId: "channel:C123", + agentThreadId: "111.222", + replyToMode: "off", + runMessageAction: mocks.runMessageAction as never, + }); + + await tool.execute("1", { + action: "send", + channel: "slack", + message: "send at channel level", + }); + + const call = mocks.runMessageAction.mock.calls[0]?.[0]; + expect(call?.toolContext?.currentThreadTs).toBe("111.222"); + expect(call?.toolContext?.replyToMode).toBe("off"); + }); + + it("forwards agentThreadId through createOpenClawTools to the message tool", async () => { + mockSendResult({ channel: "slack", to: "channel:C123" }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:slack:channel:c123:thread:111.222", + config: {} as never, + agentChannel: "slack", + currentChannelId: "channel:C123", + agentThreadId: "111.222", + }).find((candidate) => candidate.name === "message"); + + if (!tool) { + throw new Error("message tool not found"); + } + + await tool.execute("1", { + action: "send", + channel: "slack", + message: "stay in thread", + }); + + const call = mocks.runMessageAction.mock.calls[0]?.[0]; + expect(call?.toolContext?.currentThreadTs).toBe("111.222"); + expect(call?.toolContext?.replyToMode).toBe("all"); + }); }); describe("message tool explicit target guard", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 5d8e72a8671..db8da223899 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -17,6 +17,7 @@ import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -513,6 +514,7 @@ type MessageToolOptions = { currentChannelId?: string; currentChannelProvider?: string; currentThreadTs?: string; + agentThreadId?: string | number; currentMessageId?: string | number; replyToMode?: "off" | "first" | "all" | "batched"; hasRepliedRef?: { value: boolean }; @@ -706,6 +708,10 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway; const runMessageActionForTool = options?.runMessageAction ?? runMessageAction; const agentAccountId = resolveAgentAccountId(options?.agentAccountId); + const currentThreadTs = + options?.currentThreadTs ?? + (options?.agentThreadId != null ? stringifyRouteThreadId(options.agentThreadId) : undefined); + const replyToMode = options?.replyToMode ?? (currentThreadTs ? "all" : undefined); const resolvedAgentId = options?.agentSessionKey ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, @@ -717,7 +723,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { cfg: options.config, currentChannelProvider: options.currentChannelProvider, currentChannelId: options.currentChannelId, - currentThreadTs: options.currentThreadTs, + currentThreadTs, currentMessageId: options.currentMessageId, currentAccountId: agentAccountId, sessionKey: options.agentSessionKey, @@ -731,7 +737,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { config: options?.config, currentChannel: options?.currentChannelProvider, currentChannelId: options?.currentChannelId, - currentThreadTs: options?.currentThreadTs, + currentThreadTs, currentMessageId: options?.currentMessageId, currentAccountId: agentAccountId, sessionKey: options?.agentSessionKey, @@ -834,16 +840,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const toolContext = options?.currentChannelId || options?.currentChannelProvider || - options?.currentThreadTs || + currentThreadTs || hasCurrentMessageId || - options?.replyToMode || + replyToMode || options?.hasRepliedRef ? { currentChannelId: options?.currentChannelId, currentChannelProvider: options?.currentChannelProvider, - currentThreadTs: options?.currentThreadTs, + currentThreadTs, currentMessageId: options?.currentMessageId, - replyToMode: options?.replyToMode, + replyToMode, hasRepliedRef: options?.hasRepliedRef, // Direct tool invocations should not add cross-context decoration. // The agent is composing a message, not forwarding from another chat.