mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 13:18:09 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user