mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:30:42 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user