fix: slack keep resumed sends in thread (#77620)

carry agent thread context into the message tool so resumed Slack parent sends inherit the ambient thread when no explicit threadId is provided
This commit is contained in:
Bek
2026-05-04 22:39:46 -04:00
committed by GitHub
parent 978bc53e80
commit 58c4f9e190
6 changed files with 163 additions and 12 deletions

View File

@@ -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-<uid>` 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)

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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"),
);
}

View File

@@ -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", () => {

View File

@@ -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.