fix(sessions): reject thread send targets

This commit is contained in:
Peter Steinberger
2026-05-02 05:55:48 +01:00
parent f9c0375f26
commit 49be9a15fe
5 changed files with 73 additions and 0 deletions

View File

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

View File

@@ -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:<id>`, 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

View File

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

View File

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

View File

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