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 openclaw/openclaw#74009 and fixes openclaw/openclaw#64699.

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-06-11 08:26:25 +09:00
committed by GitHub
parent f995f9f411
commit 3659ff8bbf
10 changed files with 126 additions and 27 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> };
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;

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

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

View File

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