From ecffdf7ff88ca6e7f2f401f5b40cc4b26a096305 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Tue, 18 Aug 2026 22:03:02 -0300 Subject: [PATCH] fix(sessions): bound batch organization updates --- src/agents/tool-description-presets.test.ts | 2 +- src/agents/tools/sessions-tool.test.ts | 49 +++++++++++++- src/agents/tools/sessions-tool.ts | 66 ++++++++++++++++--- .../codex-dynamic-tools.telegram-direct.json | 2 +- 4 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/agents/tool-description-presets.test.ts b/src/agents/tool-description-presets.test.ts index e26874e66043..9984e0866477 100644 --- a/src/agents/tool-description-presets.test.ts +++ b/src/agents/tool-description-presets.test.ts @@ -15,7 +15,7 @@ const SESSION_DESCRIPTIONS = [ tool: "sessions_list", describe: describeSessionsListTool, original: - "List visible sessions and sidebar categories; filter kind/label/agentId/search/activity/archive. Preview recent messages inline via includeLastMessage/messageLimit; includeDerivedTitles adds derived titles. Use before history/send target selection.", + "List visible sessions with category, unread, icon, owner, project/worktree, and active attention/status; filter kind/label/agentId/search/activity/archive. Preview recent messages inline via includeLastMessage/messageLimit; includeDerivedTitles adds derived titles. Use before history/send target selection.", }, { tool: "sessions_history", diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index c54445b0aaf8..4cdd2dc87975 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -1086,9 +1086,52 @@ describe("sessions tool", () => { status: "updated", requested: 2, updated: 1, - failed: [ - { sessionKey: "agent:main:dashboard:changed", error: "session changed; retry" }, - ], + failed: [{ sessionKey: "agent:main:dashboard:changed", error: "session changed; retry" }], }); + + await expect( + tool.execute("batch-unsupported", { + action: "patch_many", + targets: [{ sessionKey: "agent:main:main" }], + category: "Research", + archived: true, + }), + ).rejects.toThrow("patch_many does not support archived"); + }); + + it("bounds failed batch details", async () => { + const targetKey = "agent:main:dashboard:scoped"; + const callGateway = vi.fn(async (request: AgentToolGatewayRequest) => { + if (request.method === "sessions.patchMany") { + return { + outcomes: Array.from({ length: 100 }, (_, index) => ({ + ok: false, + key: `${targetKey}:${index}:${"k".repeat(200)}`, + error: { code: "INVALID_REQUEST", message: "e".repeat(1_000) }, + })), + }; + } + return { key: targetKey }; + }); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: { tools: { sessions: { visibility: "all" } } }, + callGateway: callGateway as never, + }); + + const result = await tool.execute("batch-bounded", { + action: "patch_many", + targets: [{ sessionKey: targetKey, expectedSessionId: "scoped-session" }], + unread: false, + }); + + expect(result.details).toEqual({ + status: "updated", + requested: 1, + updated: 0, + failedOmitted: { count: 100, reason: "response_budget_exceeded" }, + }); + const text = (result.content[0] as { text?: string } | undefined)?.text ?? ""; + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(512); }); }); diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index f75206c3b94b..58d856555bf4 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; /** Session self-service tool. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; @@ -62,6 +63,7 @@ const GROUP_NAMES_MAX_ITEMS = 200; const SELF_ARCHIVE_MAX_RETRY_DELAY_MS = 5_000; const SESSIONS_TOOL_RESULT_MAX_BYTES = 3_840; const RESOLVED_OMITTED_REASON = "response_budget_exceeded"; +const PATCH_MANY_ERROR_MAX_CHARS = 240; const SESSION_ICON_GLYPH_DESCRIPTION = SESSION_ICON_GLYPH_IDS.join(", "); const log = createSubsystemLogger("agents/sessions"); @@ -257,6 +259,7 @@ async function resolvePatchTarget( ): Promise<{ agentId: string; cfg: OpenClawConfig; + expectedSessionId?: string; isRequesterSession: boolean; key: string; requesterAgentId: string; @@ -310,6 +313,9 @@ async function resolvePatchTarget( }); const isRequesterSession = resolved.key === context.effectiveRequesterKey && agentId === requesterAgentId; + let expectedSessionId = isRequesterSession + ? normalizeOptionalString(opts.agentSessionId) + : undefined; if (!isRequesterSession) { // Session visibility is the configured read/write scope for session tools; // the action only selects error copy. Owner gating remains separate. @@ -336,10 +342,12 @@ async function resolvePatchTarget( if (!access.allowed) { throw new ToolAuthorizationError(access.error); } + expectedSessionId = access.expectedSessionId; } return { agentId, cfg: context.cfg, + ...(expectedSessionId ? { expectedSessionId } : {}), isRequesterSession, key: resolved.key, requesterAgentId, @@ -475,6 +483,21 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool if (params.targets.length > 100) { throw new ToolInputError("patch_many supports at most 100 targets"); } + for (const field of [ + "label", + "icon", + "statusNote", + "attention", + "ttlMinutes", + "pinned", + "archived", + "model", + "thinkingLevel", + ]) { + if (params[field] !== undefined) { + throw new ToolInputError(`patch_many does not support ${field}`); + } + } const patch = { ...(params.category !== undefined ? { category: readClearableString(params, "category") } @@ -486,19 +509,28 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool } const targets = await Promise.all( params.targets.map(async (rawTarget, index) => { - if (!rawTarget || typeof rawTarget !== "object") { + if (!isRecord(rawTarget)) { throw new ToolInputError(`targets[${index}] must be an object`); } - const target = rawTarget as Record; - const sessionKey = readToolStringParam(target, "sessionKey", { required: true }); + const sessionKey = readToolStringParam(rawTarget, "sessionKey", { required: true }); const resolved = await resolvePatchTarget( { ...opts, config: opts.config ?? getRuntimeConfig() }, sessionKey, gatewayRequest, ); - const expectedSessionId = normalizeOptionalString( - readToolStringParam(target, "expectedSessionId"), + const requestedSessionId = normalizeOptionalString( + readToolStringParam(rawTarget, "expectedSessionId"), ); + if ( + requestedSessionId && + resolved.expectedSessionId && + requestedSessionId !== resolved.expectedSessionId + ) { + throw new ToolAuthorizationError( + `Session changed after access was granted: ${sessionKey}`, + ); + } + const expectedSessionId = requestedSessionId ?? resolved.expectedSessionId; return { key: resolved.key, ...(!parseAgentSessionKey(resolved.key) ? { agentId: resolved.agentId } : {}), @@ -510,15 +542,29 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool targets, patch, }); - const failed = result.outcomes.flatMap((outcome) => - outcome.ok ? [] : [{ sessionKey: outcome.key, error: outcome.error.message }], - ); - return jsonResult({ + const failed = result.outcomes.flatMap((outcome) => { + if (outcome.ok) { + return []; + } + const error = outcome.error.message.slice(0, PATCH_MANY_ERROR_MAX_CHARS); + return [{ sessionKey: outcome.key, error }]; + }); + const acknowledgement = { status: "updated", requested: targets.length, updated: result.outcomes.length - failed.length, failed, - }); + }; + return jsonResult( + sessionsToolResultFitsBudget(acknowledgement) + ? acknowledgement + : { + status: "updated", + requested: targets.length, + updated: result.outcomes.length - failed.length, + failedOmitted: { count: failed.length, reason: RESOLVED_OMITTED_REASON }, + }, + ); } if (action !== "patch") { throw new ToolInputError(`Unknown action: ${action}`); 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 21da7c6a81f2..a27e25b4f42a 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 @@ -1096,7 +1096,7 @@ }, { "deferLoading": true, - "description": "List visible sessions and sidebar categories; filter kind/label/agentId/search/activity/archive. Preview recent messages inline via includeLastMessage/messageLimit; includeDerivedTitles adds derived titles. Use before history/send target selection.", + "description": "List visible sessions with category, unread, icon, owner, project/worktree, and active attention/status; filter kind/label/agentId/search/activity/archive. Preview recent messages inline via includeLastMessage/messageLimit; includeDerivedTitles adds derived titles. Use before history/send target selection.", "inputSchema": { "properties": { "activeMinutes": {