From 49be9a15fe10fbcf1c3d4be05bbe50a42bb83353 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:55:48 +0100 Subject: [PATCH] fix(sessions): reject thread send targets --- CHANGELOG.md | 1 + docs/concepts/session-tool.md | 5 +++ src/agents/tool-description-presets.ts | 1 + src/agents/tools/sessions-send-tool.ts | 10 +++++ src/agents/tools/sessions.test.ts | 56 ++++++++++++++++++++++++++ 5 files changed, 73 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a05e25b92..807f0465127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana. - Web search/MiniMax: allow `MINIMAX_OAUTH_TOKEN` to satisfy MiniMax Search credentials, so OAuth-authorized MiniMax Token Plan setups do not need a separate web-search key. Fixes #65768. Thanks @kikibrian and @zhouhe-xydt. - Providers/MiniMax: derive Coding Plan usage polling from the configured MiniMax base URL, so global setups no longer query the CN usage host. Fixes #65054. Thanks @sixone74 and @Yanhu007. +- Sessions: reject `sessions_send` targets that resolve to thread-scoped chat sessions, so inter-agent coordination cannot be injected into active human-facing Slack or Discord threads. Fixes #52496. Thanks @barry-p5cc. - Subagents: honor `sessions_spawn` with `expectsCompletionMessage: false` by skipping parent completion handoff delivery while still running child cleanup. Fixes #75848. Thanks @alfredjbclaw. - Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc. - Replies/typing: keep typing alive for queued follow-up messages that are genuinely waiting behind an active run, instead of making chat surfaces look idle while work is queued. Fixes #65685. Thanks @papag00se. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 5f0cb3f20b2..e0500ee001f 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -93,6 +93,11 @@ the response: immediately. - **Wait for reply:** set a timeout and get the response inline. +Thread-scoped chat sessions, such as Slack or Discord keys ending in +`:thread:`, are not valid `sessions_send` targets. Use the parent channel +session key for inter-agent coordination so tool-routed messages do not appear +inside an active human-facing thread. + Messages and A2A follow-up replies are marked as inter-session data in the receiving prompt (`[Inter-session message ... isUser=false]`) and in transcript provenance. The receiving agent should treat them as tool-routed data, not as a diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index 1da6ac64faa..a285dd17826 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -28,6 +28,7 @@ export function describeSessionsHistoryTool(): string { export function describeSessionsSendTool(): string { return [ "Send a message into another visible session by sessionKey or label.", + "Thread-scoped chat sessions are rejected; target the parent channel session for inter-agent coordination.", "Use this to delegate follow-up work to an existing session; waits for the target run and returns the updated assistant reply when available.", ].join(" "); } diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index db40ebeac8d..c03c34ec2e4 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { Type } from "typebox"; import { isRequesterParentOfBackgroundAcpSession } from "../../acp/session-interaction-mode.js"; +import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -254,6 +255,15 @@ export function createSessionsSendTool(opts?: { sessionKey: displayKey, }); } + if (parseSessionThreadInfoFast(resolvedKey).threadId) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: + "sessions_send cannot target a thread session for inter-agent coordination. Use the parent channel session key instead.", + sessionKey: displayKey, + }); + } // Capture the pre-run assistant snapshot before starting the nested run. // Fast in-process test doubles and short-circuit agent paths can finish diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index fa7fb9db2a8..bd0a0f142e0 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -658,6 +658,62 @@ describe("sessions_send gating", () => { expect(result.details).toMatchObject({ status: "forbidden" }); }); + it("rejects direct thread session targets before dispatching an agent run", async () => { + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "all" }, + }, + }); + const threadSessionKey = "agent:main:slack:channel:C123:thread:1710000000.000100"; + const tool = createMainSessionsSendTool(); + + const result = await tool.execute("call-thread-target", { + sessionKey: threadSessionKey, + message: "hi", + timeoutSeconds: 0, + }); + + expect(result.details).toMatchObject({ + status: "error", + sessionKey: threadSessionKey, + }); + expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( + "cannot target a thread session", + ); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects label targets that resolve to canonical thread sessions", async () => { + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "all" }, + }, + }); + const threadSessionKey = "agent:main:discord:channel:123456:thread:987654"; + callGatewayMock.mockResolvedValueOnce({ key: threadSessionKey }); + const tool = createMainSessionsSendTool(); + + const result = await tool.execute("call-thread-label", { + label: "active thread", + message: "hi", + timeoutSeconds: 0, + }); + + expect(result.details).toMatchObject({ + status: "error", + sessionKey: threadSessionKey, + }); + expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( + "cannot target a thread session", + ); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(callGatewayMock.mock.calls[0]?.[0]).toMatchObject({ method: "sessions.resolve" }); + }); + it("does not reuse a stale assistant reply when no new reply appears", async () => { const tool = createMainSessionsSendTool(); let historyCalls = 0;