fix(sessions): bound batch organization updates

This commit is contained in:
vyctorbrzezowski
2026-08-18 22:03:02 -03:00
parent d0822b517d
commit ecffdf7ff8
4 changed files with 104 additions and 15 deletions
+1 -1
View File
@@ -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",
+46 -3
View File
@@ -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);
});
});
+56 -10
View File
@@ -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<string, unknown>;
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}`);
@@ -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": {