diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0ac8ae7e6..49dc13d729b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s. - Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf. - Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680. - Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii. diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index 66197ca7031..778327062e8 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -30,7 +30,7 @@ export function describeSessionsHistoryTool(): string { /** Describes the sessions_send tool for model-facing instructions. */ export function describeSessionsSendTool(): string { return [ - "Send message to visible session by sessionKey/label, or configured agent by agentId.", + "Send message to visible session by sessionKey/label, or configured agent by agentId; sessionKey wins when redundant label metadata is present.", "Thread-scoped chats rejected; target parent channel session.", "Creates missing configured-agent main session; waits for reply when available.", ].join(" "); diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 183b5ff0460..8bf89536d2b 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -340,13 +340,6 @@ export function createSessionsSendTool(opts?: { const sessionKeyParam = readStringParam(params, "sessionKey"); const labelParam = normalizeOptionalString(readStringParam(params, "label")); const labelAgentIdParam = normalizeOptionalString(readStringParam(params, "agentId")); - if (sessionKeyParam && labelParam) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "error", - error: "Provide either sessionKey or label (not both).", - }); - } let sessionKey = sessionKeyParam; if (!sessionKey && !labelParam && labelAgentIdParam) { @@ -469,12 +462,13 @@ export function createSessionsSendTool(opts?: { restrictToSpawned, visibilitySessionKey: sessionKey, }); + const unresolvedDisplayKey = sessionKey; if (!visibleSession.ok) { return jsonResult({ runId: crypto.randomUUID(), status: visibleSession.status, error: visibleSession.error, - sessionKey: visibleSession.displayKey, + sessionKey: unresolvedDisplayKey, }); } // Normalize sessionKey/sessionId input into a canonical session key. @@ -493,7 +487,7 @@ export function createSessionsSendTool(opts?: { status: "error", error: "sessions_send cannot target a thread session for inter-agent coordination. Use the parent channel session key instead.", - sessionKey: displayKey, + sessionKey: unresolvedDisplayKey, }); } const visibilityGuard = await createSessionVisibilityGuard({ @@ -508,7 +502,7 @@ export function createSessionsSendTool(opts?: { runId: crypto.randomUUID(), status: access.status, error: access.error, - sessionKey: displayKey, + sessionKey: unresolvedDisplayKey, }); } diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 152fe28152f..d070473caff 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -768,7 +768,7 @@ describe("sessions_list channel derivation", () => { describe("sessions_send gating", () => { beforeEach(() => { - callGatewayMock.mockClear(); + callGatewayMock.mockReset(); }); it("returns an error when neither sessionKey nor label is provided", async () => { @@ -817,6 +817,82 @@ describe("sessions_send gating", () => { expect(requireGatewayRequest().method).toBe("sessions.resolve"); }); + it("prefers sessionKey over a redundant label", async () => { + const tool = createMainSessionsSendTool(); + + const result = await tool.execute("call-session-key-label", { + sessionKey: MAIN_AGENT_SESSION_KEY, + label: "stale-label", + message: "hi", + timeoutSeconds: 0, + }); + + const details = requireDetails(result); + expect(details).toMatchObject({ + status: "accepted", + sessionKey: MAIN_AGENT_SESSION_KEY, + }); + expect(callGatewayMock.mock.calls[0]?.[0]).toMatchObject({ method: "sessions.list" }); + expect(callGatewayMock.mock.calls).toContainEqual([ + expect.objectContaining({ + method: "agent", + params: expect.objectContaining({ sessionKey: MAIN_AGENT_SESSION_KEY }), + }), + ]); + expect(callGatewayMock.mock.calls).not.toContainEqual([ + expect.objectContaining({ + method: "sessions.resolve", + params: expect.objectContaining({ label: "stale-label" }), + }), + ]); + }); + + it("does not disclose a resolved session key when sessionId access is denied", async () => { + const tool = createSessionsSendTool({ + agentSessionKey: MAIN_AGENT_SESSION_KEY, + callGateway: callGatewayMock, + config: { + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "tree" }, + }, + } as never, + }); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: Record }; + if (request.method === "sessions.resolve") { + if (request.params?.key === "session-id-only") { + throw new Error("not a session key"); + } + return { key: "agent:other:main" }; + } + if (request.method === "sessions.list") { + if (request.params?.spawnedBy === MAIN_AGENT_SESSION_KEY) { + return { + path: "/tmp/sessions.json", + sessions: [], + }; + } + return { + path: "/tmp/sessions.json", + sessions: [{ key: "agent:other:main", kind: "direct" }], + }; + } + return {}; + }); + + const result = await tool.execute("call-denied-session-id", { + sessionKey: "session-id-only", + message: "hi", + timeoutSeconds: 0, + }); + + const details = requireDetails(result); + expect(details.status).toBe("forbidden"); + expect(details.sessionKey).toBe("session-id-only"); + }); + it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { const tool = createMainSessionsSendTool(); @@ -885,6 +961,34 @@ describe("sessions_send gating", () => { expect(requireGatewayRequest().method).toBe("sessions.resolve"); }); + it("does not disclose a resolved thread session key from a sessionId target", async () => { + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "all" }, + }, + }); + const threadSessionKey = "agent:other:discord:channel:123456:thread:987654"; + callGatewayMock.mockResolvedValueOnce({ key: threadSessionKey }); + const tool = createMainSessionsSendTool(); + + const result = await tool.execute("call-thread-session-id", { + sessionKey: "thread-session-id", + message: "hi", + timeoutSeconds: 0, + }); + + const details = requireDetails(result); + expect(details.status).toBe("error"); + expect(details.sessionKey).toBe("thread-session-id"); + expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( + "cannot target a thread session", + ); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(requireGatewayRequest().method).toBe("sessions.resolve"); + }); + it("does not reuse a stale assistant reply when no new reply appears", async () => { const tool = createMainSessionsSendTool(); let historyCalls = 0; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 805947b6810..813ce784f66 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -1063,7 +1063,7 @@ }, { "deferLoading": true, - "description": "Send message to visible session by sessionKey/label, or configured agent by agentId. Thread-scoped chats rejected; target parent channel session. Creates missing configured-agent main session; waits for reply when available.", + "description": "Send message to visible session by sessionKey/label, or configured agent by agentId; sessionKey wins when redundant label metadata is present. Thread-scoped chats rejected; target parent channel session. Creates missing configured-agent main session; waits for reply when available.", "inputSchema": { "properties": { "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index 3848fc38023..6b8c4972773 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -1099,7 +1099,7 @@ }, { "deferLoading": true, - "description": "Send message to visible session by sessionKey/label, or configured agent by agentId. Thread-scoped chats rejected; target parent channel session. Creates missing configured-agent main session; waits for reply when available.", + "description": "Send message to visible session by sessionKey/label, or configured agent by agentId; sessionKey wins when redundant label metadata is present. Thread-scoped chats rejected; target parent channel session. Creates missing configured-agent main session; waits for reply when available.", "inputSchema": { "properties": { "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index c60a9dcd0f9..b0e92e0f128 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -1063,7 +1063,7 @@ }, { "deferLoading": true, - "description": "Send message to visible session by sessionKey/label, or configured agent by agentId. Thread-scoped chats rejected; target parent channel session. Creates missing configured-agent main session; waits for reply when available.", + "description": "Send message to visible session by sessionKey/label, or configured agent by agentId; sessionKey wins when redundant label metadata is present. Thread-scoped chats rejected; target parent channel session. Creates missing configured-agent main session; waits for reply when available.", "inputSchema": { "properties": { "agentId": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 4eed05c6cfb..e33268a5153 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 44908, - "roughTokens": 11227 + "chars": 44966, + "roughTokens": 11242 }, "openClawDeveloperInstructions": { "chars": 2988, @@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6925 }, "totalWithDynamicToolsJson": { - "chars": 72610, - "roughTokens": 18153 + "chars": 72668, + "roughTokens": 18167 }, "userInputText": { "chars": 1629, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index ce3b2802ad7..0ceb8ad18da 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 44629, - "roughTokens": 11158 + "chars": 44687, + "roughTokens": 11172 }, "openClawDeveloperInstructions": { "chars": 1964, @@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6544 }, "totalWithDynamicToolsJson": { - "chars": 70807, - "roughTokens": 17702 + "chars": 70865, + "roughTokens": 17717 }, "userInputText": { "chars": 1129, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 3e60cd5a756..b2240e55101 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -224,8 +224,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 45724, - "roughTokens": 11431 + "chars": 45782, + "roughTokens": 11446 }, "openClawDeveloperInstructions": { "chars": 1983, @@ -236,8 +236,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6780 }, "totalWithDynamicToolsJson": { - "chars": 72845, - "roughTokens": 18212 + "chars": 72903, + "roughTokens": 18226 }, "userInputText": { "chars": 1367,