Skip to content

Commit 3659ff8

Browse files
fix(agents): prefer explicit sessions_send keys (#92047)
Honor caller-provided sessionKey values when stale label metadata is also present, and keep denied session-id sends from echoing the resolved canonical session key. Supersedes #74009 and fixes #64699. Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
1 parent f995f9f commit 3659ff8

10 files changed

Lines changed: 126 additions & 27 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
6464

6565
### Fixes
6666

67+
- 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.
6768
- 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.
6869
- 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.
6970
- 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.

src/agents/tool-description-presets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function describeSessionsHistoryTool(): string {
3030
/** Describes the sessions_send tool for model-facing instructions. */
3131
export function describeSessionsSendTool(): string {
3232
return [
33-
"Send message to visible session by sessionKey/label, or configured agent by agentId.",
33+
"Send message to visible session by sessionKey/label, or configured agent by agentId; sessionKey wins when redundant label metadata is present.",
3434
"Thread-scoped chats rejected; target parent channel session.",
3535
"Creates missing configured-agent main session; waits for reply when available.",
3636
].join(" ");

src/agents/tools/sessions-send-tool.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -340,13 +340,6 @@ export function createSessionsSendTool(opts?: {
340340
const sessionKeyParam = readStringParam(params, "sessionKey");
341341
const labelParam = normalizeOptionalString(readStringParam(params, "label"));
342342
const labelAgentIdParam = normalizeOptionalString(readStringParam(params, "agentId"));
343-
if (sessionKeyParam && labelParam) {
344-
return jsonResult({
345-
runId: crypto.randomUUID(),
346-
status: "error",
347-
error: "Provide either sessionKey or label (not both).",
348-
});
349-
}
350343

351344
let sessionKey = sessionKeyParam;
352345
if (!sessionKey && !labelParam && labelAgentIdParam) {
@@ -469,12 +462,13 @@ export function createSessionsSendTool(opts?: {
469462
restrictToSpawned,
470463
visibilitySessionKey: sessionKey,
471464
});
465+
const unresolvedDisplayKey = sessionKey;
472466
if (!visibleSession.ok) {
473467
return jsonResult({
474468
runId: crypto.randomUUID(),
475469
status: visibleSession.status,
476470
error: visibleSession.error,
477-
sessionKey: visibleSession.displayKey,
471+
sessionKey: unresolvedDisplayKey,
478472
});
479473
}
480474
// Normalize sessionKey/sessionId input into a canonical session key.
@@ -493,7 +487,7 @@ export function createSessionsSendTool(opts?: {
493487
status: "error",
494488
error:
495489
"sessions_send cannot target a thread session for inter-agent coordination. Use the parent channel session key instead.",
496-
sessionKey: displayKey,
490+
sessionKey: unresolvedDisplayKey,
497491
});
498492
}
499493
const visibilityGuard = await createSessionVisibilityGuard({
@@ -508,7 +502,7 @@ export function createSessionsSendTool(opts?: {
508502
runId: crypto.randomUUID(),
509503
status: access.status,
510504
error: access.error,
511-
sessionKey: displayKey,
505+
sessionKey: unresolvedDisplayKey,
512506
});
513507
}
514508

src/agents/tools/sessions.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ describe("sessions_list channel derivation", () => {
768768

769769
describe("sessions_send gating", () => {
770770
beforeEach(() => {
771-
callGatewayMock.mockClear();
771+
callGatewayMock.mockReset();
772772
});
773773

774774
it("returns an error when neither sessionKey nor label is provided", async () => {
@@ -817,6 +817,82 @@ describe("sessions_send gating", () => {
817817
expect(requireGatewayRequest().method).toBe("sessions.resolve");
818818
});
819819

820+
it("prefers sessionKey over a redundant label", async () => {
821+
const tool = createMainSessionsSendTool();
822+
823+
const result = await tool.execute("call-session-key-label", {
824+
sessionKey: MAIN_AGENT_SESSION_KEY,
825+
label: "stale-label",
826+
message: "hi",
827+
timeoutSeconds: 0,
828+
});
829+
830+
const details = requireDetails(result);
831+
expect(details).toMatchObject({
832+
status: "accepted",
833+
sessionKey: MAIN_AGENT_SESSION_KEY,
834+
});
835+
expect(callGatewayMock.mock.calls[0]?.[0]).toMatchObject({ method: "sessions.list" });
836+
expect(callGatewayMock.mock.calls).toContainEqual([
837+
expect.objectContaining({
838+
method: "agent",
839+
params: expect.objectContaining({ sessionKey: MAIN_AGENT_SESSION_KEY }),
840+
}),
841+
]);
842+
expect(callGatewayMock.mock.calls).not.toContainEqual([
843+
expect.objectContaining({
844+
method: "sessions.resolve",
845+
params: expect.objectContaining({ label: "stale-label" }),
846+
}),
847+
]);
848+
});
849+
850+
it("does not disclose a resolved session key when sessionId access is denied", async () => {
851+
const tool = createSessionsSendTool({
852+
agentSessionKey: MAIN_AGENT_SESSION_KEY,
853+
callGateway: callGatewayMock,
854+
config: {
855+
session: { scope: "per-sender", mainKey: "main" },
856+
tools: {
857+
agentToAgent: { enabled: false },
858+
sessions: { visibility: "tree" },
859+
},
860+
} as never,
861+
});
862+
callGatewayMock.mockImplementation(async (opts: unknown) => {
863+
const request = opts as { method?: string; params?: Record<string, unknown> };
864+
if (request.method === "sessions.resolve") {
865+
if (request.params?.key === "session-id-only") {
866+
throw new Error("not a session key");
867+
}
868+
return { key: "agent:other:main" };
869+
}
870+
if (request.method === "sessions.list") {
871+
if (request.params?.spawnedBy === MAIN_AGENT_SESSION_KEY) {
872+
return {
873+
path: "/tmp/sessions.json",
874+
sessions: [],
875+
};
876+
}
877+
return {
878+
path: "/tmp/sessions.json",
879+
sessions: [{ key: "agent:other:main", kind: "direct" }],
880+
};
881+
}
882+
return {};
883+
});
884+
885+
const result = await tool.execute("call-denied-session-id", {
886+
sessionKey: "session-id-only",
887+
message: "hi",
888+
timeoutSeconds: 0,
889+
});
890+
891+
const details = requireDetails(result);
892+
expect(details.status).toBe("forbidden");
893+
expect(details.sessionKey).toBe("session-id-only");
894+
});
895+
820896
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
821897
const tool = createMainSessionsSendTool();
822898

@@ -885,6 +961,34 @@ describe("sessions_send gating", () => {
885961
expect(requireGatewayRequest().method).toBe("sessions.resolve");
886962
});
887963

964+
it("does not disclose a resolved thread session key from a sessionId target", async () => {
965+
loadConfigMock.mockReturnValue({
966+
session: { scope: "per-sender", mainKey: "main" },
967+
tools: {
968+
agentToAgent: { enabled: false },
969+
sessions: { visibility: "all" },
970+
},
971+
});
972+
const threadSessionKey = "agent:other:discord:channel:123456:thread:987654";
973+
callGatewayMock.mockResolvedValueOnce({ key: threadSessionKey });
974+
const tool = createMainSessionsSendTool();
975+
976+
const result = await tool.execute("call-thread-session-id", {
977+
sessionKey: "thread-session-id",
978+
message: "hi",
979+
timeoutSeconds: 0,
980+
});
981+
982+
const details = requireDetails(result);
983+
expect(details.status).toBe("error");
984+
expect(details.sessionKey).toBe("thread-session-id");
985+
expect((result.details as { error?: string } | undefined)?.error ?? "").toContain(
986+
"cannot target a thread session",
987+
);
988+
expect(callGatewayMock).toHaveBeenCalledTimes(1);
989+
expect(requireGatewayRequest().method).toBe("sessions.resolve");
990+
});
991+
888992
it("does not reuse a stale assistant reply when no new reply appears", async () => {
889993
const tool = createMainSessionsSendTool();
890994
let historyCalls = 0;

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@
10631063
},
10641064
{
10651065
"deferLoading": true,
1066-
"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.",
1066+
"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.",
10671067
"inputSchema": {
10681068
"properties": {
10691069
"agentId": {

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1099,7 +1099,7 @@
10991099
},
11001100
{
11011101
"deferLoading": true,
1102-
"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.",
1102+
"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.",
11031103
"inputSchema": {
11041104
"properties": {
11051105
"agentId": {

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@
10631063
},
10641064
{
10651065
"deferLoading": true,
1066-
"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.",
1066+
"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.",
10671067
"inputSchema": {
10681068
"properties": {
10691069
"agentId": {

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
223223
"roughTokens": 0
224224
},
225225
"dynamicToolsJson": {
226-
"chars": 44908,
227-
"roughTokens": 11227
226+
"chars": 44966,
227+
"roughTokens": 11242
228228
},
229229
"openClawDeveloperInstructions": {
230230
"chars": 2988,
@@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
235235
"roughTokens": 6925
236236
},
237237
"totalWithDynamicToolsJson": {
238-
"chars": 72610,
239-
"roughTokens": 18153
238+
"chars": 72668,
239+
"roughTokens": 18167
240240
},
241241
"userInputText": {
242242
"chars": 1629,

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
223223
"roughTokens": 0
224224
},
225225
"dynamicToolsJson": {
226-
"chars": 44629,
227-
"roughTokens": 11158
226+
"chars": 44687,
227+
"roughTokens": 11172
228228
},
229229
"openClawDeveloperInstructions": {
230230
"chars": 1964,
@@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
235235
"roughTokens": 6544
236236
},
237237
"totalWithDynamicToolsJson": {
238-
"chars": 70807,
239-
"roughTokens": 17702
238+
"chars": 70865,
239+
"roughTokens": 17717
240240
},
241241
"userInputText": {
242242
"chars": 1129,

test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
224224
"roughTokens": 0
225225
},
226226
"dynamicToolsJson": {
227-
"chars": 45724,
228-
"roughTokens": 11431
227+
"chars": 45782,
228+
"roughTokens": 11446
229229
},
230230
"openClawDeveloperInstructions": {
231231
"chars": 1983,
@@ -236,8 +236,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
236236
"roughTokens": 6780
237237
},
238238
"totalWithDynamicToolsJson": {
239-
"chars": 72845,
240-
"roughTokens": 18212
239+
"chars": 72903,
240+
"roughTokens": 18226
241241
},
242242
"userInputText": {
243243
"chars": 1367,

0 commit comments

Comments
 (0)