refactor(sessions): isolate batch organization adapter

This commit is contained in:
vyctorbrzezowski
2026-08-18 22:52:41 -03:00
parent 9ac8c72d04
commit f283ad3af4
4 changed files with 340 additions and 195 deletions
@@ -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, unknown>): 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<string, unknown>): 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<string, unknown>;
callGateway: AgentToolGatewayRequestCaller;
resolveTarget: (sessionKey: string) => Promise<ResolvedPatchTarget>;
resultFitsBudget: (payload: Record<string, unknown>) => boolean;
}): Promise<Record<string, unknown>> {
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<SessionsPatchManyResult>({
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 },
};
}
@@ -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<AgentToolGatewayRequestCaller>[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);
});
});
-103
View File
@@ -20,9 +20,7 @@ import {
expectOmittedResolvedAcknowledgement,
expectedResolvedOmission,
} from "./sessions-tool.test-helpers.js";
type AgentToolGatewayRequest = Parameters<AgentToolGatewayRequestCaller>[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);
});
});
+12 -92
View File
@@ -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<SessionsPatchManyResult>("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") {