diff --git a/src/agents/tools/sessions-tool-patch-many.ts b/src/agents/tools/sessions-tool-patch-many.ts new file mode 100644 index 000000000000..0014452f80ac --- /dev/null +++ b/src/agents/tools/sessions-tool-patch-many.ts @@ -0,0 +1,127 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { SessionsPatchManyResult } from "../../../packages/gateway-protocol/src/index.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { readToolStringParam, ToolAuthorizationError, ToolInputError } from "./common.js"; +import type { AgentToolGatewayRequestCaller } from "./in-process-gateway.js"; + +const PATCH_MANY_ERROR_MAX_CHARS = 240; +const RESULT_OMITTED_REASON = "response_budget_exceeded"; +const UNSUPPORTED_PATCH_MANY_FIELDS = [ + "label", + "icon", + "statusNote", + "attention", + "ttlMinutes", + "pinned", + "archived", + "model", + "thinkingLevel", +] as const; + +type ResolvedPatchTarget = { + agentId: string; + expectedSessionId?: string; + key: string; +}; + +function readPatchCategory(params: Record): string | null | undefined { + const value = params.category; + if (value === undefined || value === null) { + return value; + } + if (typeof value !== "string") { + throw new ToolInputError("category must be a string"); + } + return value.trim() || null; +} + +function readPatchUnread(params: Record): boolean | undefined { + const value = params.unread; + if (value === undefined) { + return undefined; + } + if (typeof value !== "boolean") { + throw new ToolInputError("unread must be boolean"); + } + return value; +} + +export async function executeSessionsPatchMany(params: { + raw: Record; + callGateway: AgentToolGatewayRequestCaller; + resolveTarget: (sessionKey: string) => Promise; + resultFitsBudget: (payload: Record) => boolean; +}): Promise> { + const targetsInput = params.raw.targets; + if (!Array.isArray(targetsInput) || targetsInput.length === 0) { + throw new ToolInputError("patch_many requires targets"); + } + if (targetsInput.length > 100) { + throw new ToolInputError("patch_many supports at most 100 targets"); + } + for (const field of UNSUPPORTED_PATCH_MANY_FIELDS) { + if (params.raw[field] !== undefined) { + throw new ToolInputError(`patch_many does not support ${field}`); + } + } + const category = readPatchCategory(params.raw); + const unread = readPatchUnread(params.raw); + const patch = { + ...(category !== undefined ? { category } : {}), + ...(unread !== undefined ? { unread } : {}), + }; + if (Object.keys(patch).length === 0) { + throw new ToolInputError("patch_many requires category or unread"); + } + const targets = await Promise.all( + targetsInput.map(async (rawTarget, index) => { + if (!isRecord(rawTarget)) { + throw new ToolInputError(`targets[${index}] must be an object`); + } + const sessionKey = readToolStringParam(rawTarget, "sessionKey", { required: true }); + const resolved = await params.resolveTarget(sessionKey); + 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 } : {}), + ...(expectedSessionId ? { expectedSessionId } : {}), + }; + }), + ); + const result = await params.callGateway({ + method: "sessions.patchMany", + params: { targets, patch }, + }); + const failed = result.outcomes.flatMap((outcome) => + outcome.ok + ? [] + : [ + { + sessionKey: outcome.key, + error: outcome.error.message.slice(0, PATCH_MANY_ERROR_MAX_CHARS), + }, + ], + ); + const updated = result.outcomes.length - failed.length; + const status = updated === 0 ? "failed" : failed.length > 0 ? "partial" : "updated"; + const acknowledgement = { status, requested: targets.length, updated, failed }; + return params.resultFitsBudget(acknowledgement) + ? acknowledgement + : { + status, + requested: targets.length, + updated, + failedOmitted: { count: failed.length, reason: RESULT_OMITTED_REASON }, + }; +} diff --git a/src/agents/tools/sessions-tool.organization.test.ts b/src/agents/tools/sessions-tool.organization.test.ts new file mode 100644 index 000000000000..ed64cabeb059 --- /dev/null +++ b/src/agents/tools/sessions-tool.organization.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentToolGatewayRequestCaller } from "./in-process-gateway.js"; +import { createSessionsTool } from "./sessions-tool.js"; + +type AgentToolGatewayRequest = Parameters[0]; + +describe("sessions organization", () => { + it("patches and clears category with the other sidebar state", async () => { + const callGateway = vi.fn(async () => ({ ok: true })); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + agentSessionId: "session-main", + config: {}, + callGateway: callGateway as never, + }); + + await tool.execute("declare", { + action: "patch", + label: "Waiting on staging", + category: "Blocked", + icon: "🦞", + statusNote: "Blocked: need the staging password", + attention: "key", + ttlMinutes: 45, + archived: true, + }); + await tool.execute("clear-empty", { + action: "patch", + label: "", + category: "", + icon: "", + attention: "clear", + }); + await tool.execute("clear-null", { action: "patch", category: null }); + + expect(callGateway.mock.calls).toEqual([ + [ + { + method: "sessions.patch", + params: { + key: "agent:main:main", + label: "Waiting on staging", + category: "Blocked", + icon: "🦞", + statusNote: "Blocked: need the staging password", + attention: "key", + ttlMinutes: 45, + archived: true, + expectedSessionId: "session-main", + }, + }, + ], + [ + { + method: "sessions.patch", + params: { + key: "agent:main:main", + label: null, + category: null, + icon: null, + attention: null, + }, + }, + ], + [ + { + method: "sessions.patch", + params: { key: "agent:main:main", category: null }, + }, + ], + ]); + }); + + it("rejects empty patches and category targets outside the caller tree", async () => { + const callGateway = vi.fn(async () => ({ sessions: [] })); + const currentTool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: {}, + callGateway, + }); + await expect(currentTool.execute("patch-empty", { action: "patch" })).rejects.toThrow( + "Patch setting required", + ); + + const restrictedTool = createSessionsTool({ + agentSessionKey: "agent:main:dashboard:caller", + callGateway: callGateway as never, + }); + await expect( + restrictedTool.execute("patch-other", { + action: "patch", + sessionKey: "agent:main:other", + category: "Projects", + }), + ).rejects.toThrow("Session status visibility is restricted"); + expect(callGateway).not.toHaveBeenCalledWith({ + method: "sessions.patch", + params: expect.objectContaining({ key: "agent:main:other" }), + }); + }); + + it("patches visible sessions in one compact batch result", async () => { + const callGateway = vi.fn(async (request: AgentToolGatewayRequest) => { + if (request.method === "sessions.patchMany") { + return { + outcomes: [ + { ok: true, key: "agent:main:main" }, + { + ok: false, + key: "agent:main:dashboard:changed", + error: { code: "INVALID_REQUEST", message: "session changed; retry" }, + }, + ], + }; + } + return { key: "agent:main:dashboard:changed" }; + }); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + agentSessionId: "main-session", + config: { tools: { sessions: { visibility: "all" } } }, + callGateway: callGateway as never, + }); + + const result = await tool.execute("batch-category", { + action: "patch_many", + targets: [{ sessionKey: "agent:main:main" }, { sessionKey: "agent:main:dashboard:changed" }], + category: "Research", + unread: false, + }); + + expect(callGateway).toHaveBeenCalledWith({ + method: "sessions.patchMany", + params: { + targets: [ + { key: "agent:main:main", expectedSessionId: "main-session" }, + { key: "agent:main:dashboard:changed" }, + ], + patch: { category: "Research", unread: false }, + }, + }); + expect(result.details).toEqual({ + status: "partial", + requested: 2, + updated: 1, + 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"); + await expect( + tool.execute("batch-stale-current", { + action: "patch_many", + targets: [{ sessionKey: "agent:main:main", expectedSessionId: "stale-session" }], + unread: false, + }), + ).rejects.toThrow("Session changed after access was granted"); + }); + + 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: "failed", + 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.test.ts b/src/agents/tools/sessions-tool.test.ts index 01e998ecab10..3171a7763b3e 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -20,9 +20,7 @@ import { expectOmittedResolvedAcknowledgement, expectedResolvedOmission, } from "./sessions-tool.test-helpers.js"; - type AgentToolGatewayRequest = Parameters[0]; - describe("sessions tool", () => { it("carries the persisted fixed-store owner for a bare patch key", async () => { const callGateway = vi.fn().mockResolvedValue({}); @@ -1003,7 +1001,6 @@ describe("sessions tool", () => { }); }); }); - it("rejects an empty patch", async () => { const callGateway = vi.fn(); const tool = createSessionsTool({ @@ -1039,104 +1036,4 @@ describe("sessions tool", () => { params: expect.objectContaining({ key: "agent:main:other" }), }); }); - - it("patches visible sessions in one compact batch result", async () => { - const callGateway = vi.fn(async (request: AgentToolGatewayRequest) => { - if (request.method === "sessions.patchMany") { - return { - outcomes: [ - { ok: true, key: "agent:main:main" }, - { - ok: false, - key: "agent:main:dashboard:changed", - error: { code: "INVALID_REQUEST", message: "session changed; retry" }, - }, - ], - }; - } - return { key: "agent:main:dashboard:changed" }; - }); - const tool = createSessionsTool({ - agentSessionKey: "agent:main:main", - agentSessionId: "main-session", - config: { tools: { sessions: { visibility: "all" } } }, - callGateway: callGateway as never, - }); - - const result = await tool.execute("batch-category", { - action: "patch_many", - targets: [{ sessionKey: "agent:main:main" }, { sessionKey: "agent:main:dashboard:changed" }], - category: "Research", - unread: false, - }); - - expect(callGateway).toHaveBeenCalledWith({ - method: "sessions.patchMany", - params: { - targets: [ - { key: "agent:main:main", expectedSessionId: "main-session" }, - { key: "agent:main:dashboard:changed" }, - ], - patch: { category: "Research", unread: false }, - }, - }); - expect(result.details).toEqual({ - status: "partial", - requested: 2, - updated: 1, - 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"); - await expect( - tool.execute("batch-stale-current", { - action: "patch_many", - targets: [{ sessionKey: "agent:main:main", expectedSessionId: "stale-session" }], - unread: false, - }), - ).rejects.toThrow("Session changed after access was granted"); - }); - - 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: "failed", - 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 62778ff7b332..6cac8846a45d 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -1,10 +1,7 @@ -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -/** Session self-service tool. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; import type { SessionsAssignOwnerResult, - SessionsPatchManyResult, SessionsPatchResult, } from "../../../packages/gateway-protocol/src/index.js"; import { @@ -46,6 +43,7 @@ import { } from "./sessions-access.js"; import { resolveSessionToolContext } from "./sessions-helpers.js"; import { resolveSessionReference, shouldResolveSessionIdInput } from "./sessions-resolution.js"; +import { executeSessionsPatchMany } from "./sessions-tool-patch-many.js"; const ACTIONS = [ "patch", @@ -63,7 +61,6 @@ 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"); @@ -477,95 +474,18 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool ); } if (action === "patch_many") { - if (!Array.isArray(params.targets) || params.targets.length === 0) { - throw new ToolInputError("patch_many requires targets"); - } - 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") } - : {}), - ...(params.unread !== undefined ? { unread: readBooleanParam(params, "unread") } : {}), - }; - if (Object.keys(patch).length === 0) { - throw new ToolInputError("patch_many requires category or unread"); - } - const targets = await Promise.all( - params.targets.map(async (rawTarget, index) => { - if (!isRecord(rawTarget)) { - throw new ToolInputError(`targets[${index}] must be an object`); - } - const sessionKey = readToolStringParam(rawTarget, "sessionKey", { required: true }); - const resolved = await resolvePatchTarget( - { ...opts, config: opts.config ?? getRuntimeConfig() }, - sessionKey, - gatewayRequest, - ); - 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 } : {}), - ...(expectedSessionId ? { expectedSessionId } : {}), - }; - }), - ); - const result = await callGateway("sessions.patchMany", { - targets, - patch, - }); - 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 updated = result.outcomes.length - failed.length; - const status = updated === 0 ? "failed" : failed.length > 0 ? "partial" : "updated"; - const acknowledgement = { - status, - requested: targets.length, - updated, - failed, - }; return jsonResult( - sessionsToolResultFitsBudget(acknowledgement) - ? acknowledgement - : { - status, - requested: targets.length, - updated, - failedOmitted: { count: failed.length, reason: RESOLVED_OMITTED_REASON }, - }, + await executeSessionsPatchMany({ + raw: params, + callGateway: gatewayRequest, + resolveTarget: async (sessionKey) => + await resolvePatchTarget( + { ...opts, config: opts.config ?? getRuntimeConfig() }, + sessionKey, + gatewayRequest, + ), + resultFitsBudget: sessionsToolResultFitsBudget, + }), ); } if (action !== "patch") {