From 53ef72c753d355f51cbfc7dc5bbd8661bdb42cf4 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Tue, 18 Aug 2026 21:22:22 -0300 Subject: [PATCH] feat(sessions): expose organization tools --- src/agents/tool-description-presets.ts | 5 +- src/agents/tool-display-config.ts | 12 ++- src/agents/tools/sessions-helpers.ts | 14 ++++ src/agents/tools/sessions-list-tool.test.ts | 29 ++++++- src/agents/tools/sessions-list-tool.ts | 46 +++++++++++ src/agents/tools/sessions-tool.test.ts | 65 ++++++++++++++- src/agents/tools/sessions-tool.ts | 82 ++++++++++++++++++- .../sessions-mutations.perf.test.ts | 64 +++++++++++++++ src/gateway/session-utils-row.ts | 1 + src/gateway/session-utils.types.ts | 1 + 10 files changed, 309 insertions(+), 10 deletions(-) diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index 48f9237e920e..3fd15e438927 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -3,7 +3,8 @@ export const EXEC_TOOL_DISPLAY_SUMMARY = "Run shell now."; export const PROCESS_TOOL_DISPLAY_SUMMARY = "Inspect/control exec sessions."; export const CRON_TOOL_DISPLAY_SUMMARY = "Schedule reminders, automations, wake events."; -export const SESSIONS_LIST_TOOL_DISPLAY_SUMMARY = "List visible sessions; filters/previews."; +export const SESSIONS_LIST_TOOL_DISPLAY_SUMMARY = + "List visible sessions; organization, status, filters/previews."; export const SESSIONS_HISTORY_TOOL_DISPLAY_SUMMARY = "Read sanitized session history."; export const SESSIONS_SEARCH_TOOL_DISPLAY_SUMMARY = "Search past session transcripts."; export const SESSIONS_SEND_TOOL_DISPLAY_SUMMARY = "Run same-Gateway session/agent."; @@ -64,7 +65,7 @@ export function describeSessionLinkRule(base: string): string { /** Describes the sessions_list tool for model-facing instructions. */ export function describeSessionsListTool(options?: SessionLinkDescriptionOptions): string { return [ - "List visible sessions and sidebar categories; filter kind/label/agentId/search/activity/archive.", + "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.", ...(options?.sessionLinkBase ? [describeSessionLinkRule(options.sessionLinkBase)] : []), diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index af174e17f8e4..7de7cf7dc67f 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -376,8 +376,18 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { actions: { patch: { label: "update", - detailKeys: ["sessionKey", "label", "pinned", "archived", "model", "thinkingLevel"], + detailKeys: [ + "sessionKey", + "label", + "category", + "unread", + "pinned", + "archived", + "model", + "thinkingLevel", + ], }, + patch_many: { label: "update many", detailKeys: ["category", "unread", "targets"] }, group_list: { label: "groups" }, group_set: { label: "set groups", detailKeys: ["names"] }, group_rename: { label: "rename group", detailKeys: ["name", "to"] }, diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 3135dccd4532..acf847c32f1d 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -1,8 +1,10 @@ import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce"; import type { + SessionOwner, SessionRow, SessionRunStatus, } from "../../../packages/gateway-protocol/src/schema/sessions-row.js"; +import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-agent-status.js"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { parseRawSessionConversationRef } from "../../sessions/session-key-utils.js"; @@ -67,6 +69,12 @@ export type GatewaySessionListRow = { spawnedBy?: string; label?: string; category?: string; + icon?: string; + unread?: boolean; + owner?: SessionOwner; + projectId?: string; + worktree?: { id: string; branch: string; repoRoot: string }; + agentStatus?: SessionAgentStatus; displayName?: string; derivedTitle?: string; lastMessagePreview?: string; @@ -117,6 +125,12 @@ export type SessionListRow = { channel: string; label?: string; category?: string; + icon?: string; + unread?: boolean; + owner?: SessionOwner["actor"]; + projectId?: string; + hasWorktree?: boolean; + agentStatus?: SessionAgentStatus; displayName?: string; derivedTitle?: string; lastMessagePreview?: string; diff --git a/src/agents/tools/sessions-list-tool.test.ts b/src/agents/tools/sessions-list-tool.test.ts index ccfbefd2f532..644c2ee844a1 100644 --- a/src/agents/tools/sessions-list-tool.test.ts +++ b/src/agents/tools/sessions-list-tool.test.ts @@ -369,7 +369,20 @@ describe("sessions-list-tool", () => { classification: "subagent", channel: "discord", label: "worker", - category: "P1 issues", + category: "Projects", + icon: "🦞", + unread: true, + owner: { + actor: { + type: "human", + id: "profile-owner", + label: "Owner", + avatarUrl: "/avatars/private", + }, + }, + projectId: "openclaw", + worktree: { id: "worktree-private", branch: "work", repoRoot: "/private/repo" }, + agentStatus: { note: "Needs review", attention: "flag", expiresAt: Date.now() + 60_000 }, displayName: "Worker", derivedTitle: "Investigate queue", lastMessagePreview: "Use `[[reply_to_current]]` literally.", @@ -406,7 +419,7 @@ describe("sessions-list-tool", () => { linkedDetails.sessionLinkRule, ); expect(compactToolOutputHint(tool.outputSchema)).toBe( - '{ count: number; sessions: Array<{ agentId: string; archived: boolean; channel: string; key: string; kind: "main" | "group" | "cron" | "hook" | "node" | "other"; pinned: boolean; abortedLastRun?: boolean; category?: string; childSessions?: Array; contextTokens?: number; derivedTitle?: string; displayName?: string; label?: string; lastMessagePreview?: string; messages?: Array; model?: string; parentSessionKey?: string; sessionId?: string; stateVersion?: number; status?: "running" | "done" | "failed" | "killed" | "timeout"; totalTokens?: number; updatedAt?: number }>; sessionLinkRule?: string; visibility?: { mode: "self" | "tree" | "agent"; restricted: true; warning: string } }', + '{ count: number; sessions: Array<{ agentId: string; archived: boolean; channel: string; key: string; kind: "main" | "group" | "cron" | "hook" | "node" | "other"; pinned: boolean; abortedLastRun?: boolean; agentStatus?: { expiresAt: number; note: string; attention?: string }; category?: string; childSessions?: Array; contextTokens?: number; derivedTitle?: string; displayName?: string; hasWorktree?: boolean; icon?: string; label?: string; lastMessagePreview?: string; messages?: Array; model?: string; owner?: { type: "human" | "agent" | "system"; id?: string; label?: string }; parentSessionKey?: string; projectId?: string; sessionId?: string; stateVersion?: number; status?: "running" | "done" | "failed" | "killed" | "timeout"; totalTokens?: number; unread?: boolean; updatedAt?: number }>; sessionLinkRule?: string; visibility?: { mode: "self" | "tree" | "agent"; restricted: true; warning: string } }', ); expect(result.details).toEqual({ count: 1, @@ -420,7 +433,17 @@ describe("sessions-list-tool", () => { archived: false, pinned: true, label: "worker", - category: "P1 issues", + category: "Projects", + icon: "🦞", + unread: true, + owner: { type: "human", id: "profile-owner", label: "Owner" }, + projectId: "openclaw", + hasWorktree: true, + agentStatus: { + note: "Needs review", + attention: "flag", + expiresAt: expect.any(Number), + }, displayName: "Worker", derivedTitle: "Investigate queue", lastMessagePreview: "Use `[[reply_to_current]]` literally.", diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 3f67d8b4356f..79cff0b6bbc4 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -86,6 +86,34 @@ const SessionListRowOutputSchema = Type.Object( pinned: Type.Boolean(), label: Type.Optional(Type.String()), category: Type.Optional(Type.String()), + icon: Type.Optional(Type.String()), + unread: Type.Optional(Type.Boolean()), + owner: Type.Optional( + Type.Object( + { + type: Type.Union([ + Type.Literal("human"), + Type.Literal("agent"), + Type.Literal("system"), + ]), + id: Type.Optional(Type.String()), + label: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), + projectId: Type.Optional(Type.String()), + hasWorktree: Type.Optional(Type.Boolean()), + agentStatus: Type.Optional( + Type.Object( + { + note: Type.String(), + expiresAt: Type.Number(), + attention: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), displayName: Type.Optional(Type.String()), derivedTitle: Type.Optional(Type.String()), lastMessagePreview: Type.Optional(Type.String()), @@ -379,6 +407,18 @@ export function createSessionsListTool(opts?: { const stateVersion = stateVersions[stateVersionAgentId]?.[key]; const rowLabel = readStringValue(entry.label); const category = readStringValue(entry.category); + const icon = readStringValue(entry.icon); + const ownerActor = entry.owner?.actor; + const owner = ownerActor + ? { + type: ownerActor.type, + ...(ownerActor.id ? { id: ownerActor.id } : {}), + ...(ownerActor.label ? { label: ownerActor.label } : {}), + } + : undefined; + const projectId = readStringValue(entry.projectId); + const hasWorktree = entry.worktree ? true : undefined; + const agentStatus = entry.agentStatus; const displayName = readStringValue(entry.displayName); const derivedTitle = readStringValue(entry.derivedTitle); const lastMessagePreview = readStringValue(entry.lastMessagePreview); @@ -431,6 +471,12 @@ export function createSessionsListTool(opts?: { pinned: entry.pinned === true, ...(rowLabel ? { label: rowLabel } : {}), ...(category ? { category } : {}), + ...(icon ? { icon } : {}), + ...(entry.unread !== undefined ? { unread: entry.unread } : {}), + ...(owner ? { owner } : {}), + ...(projectId ? { projectId } : {}), + ...(hasWorktree ? { hasWorktree } : {}), + ...(agentStatus ? { agentStatus } : {}), ...(displayName ? { displayName } : {}), ...(derivedTitle ? { derivedTitle } : {}), ...(lastMessagePreview ? { lastMessagePreview } : {}), diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index db7a728366bf..c54445b0aaf8 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -147,6 +147,7 @@ describe("sessions tool", () => { type: "string", enum: [ "patch", + "patch_many", "reset", "delete", "assign_owner", @@ -158,6 +159,12 @@ describe("sessions tool", () => { }, deleteTranscript: { type: "boolean" }, label: { type: "string", description: expect.stringContaining("Empty string clears") }, + category: { + anyOf: [{ type: "string" }, { type: "null" }], + description: expect.stringContaining("Null or empty string clears"), + }, + unread: { type: "boolean" }, + targets: { type: "array", maxItems: 100 }, icon: { type: "string", description: expect.stringContaining( @@ -767,20 +774,20 @@ describe("sessions tool", () => { const result = await tool.execute("patch-sidebar", { action: "patch", - label: "Movies", + category: "Movies", }); expect(callGateway).toHaveBeenCalledWith({ method: "sessions.patch", params: { key: "agent:main:main", - label: "Movies", + category: "Movies", }, }); expect(result.details).toEqual({ status: "updated", sessionKey: "agent:main:main", - updated: ["label"], + updated: ["category"], }); const text = (result.content[0] as { text?: string } | undefined)?.text ?? ""; expect(text).not.toContain('"entry"'); @@ -1032,4 +1039,56 @@ 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: String(request.params.sessionKey) }; + }); + const tool = createSessionsTool({ + agentSessionKey: "agent:main:main", + config: { tools: { sessions: { visibility: "all" } } }, + callGateway: callGateway as never, + }); + + const result = await tool.execute("batch-category", { + action: "patch_many", + targets: [ + { sessionKey: "agent:main:main", expectedSessionId: "main-session" }, + { 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: "updated", + requested: 2, + updated: 1, + failed: [ + { sessionKey: "agent:main:dashboard:changed", error: "session changed; retry" }, + ], + }); + }); }); diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index fd3fd697185a..f75206c3b94b 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -3,6 +3,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { Type } from "typebox"; import type { SessionsAssignOwnerResult, + SessionsPatchManyResult, SessionsPatchResult, } from "../../../packages/gateway-protocol/src/index.js"; import { @@ -47,6 +48,7 @@ import { resolveSessionReference, shouldResolveSessionIdInput } from "./sessions const ACTIONS = [ "patch", + "patch_many", "reset", "delete", "assign_owner", @@ -108,6 +110,12 @@ const SessionsToolSchema = Type.Object( label: Type.Optional( Type.String({ description: "Sidebar title override. Empty string clears it." }), ), + category: Type.Optional( + Type.Union([Type.String(), Type.Null()], { + description: "Sidebar group/category. Null or empty string clears it to Other.", + }), + ), + unread: Type.Optional(Type.Boolean({ description: "Mark the session unread or read." })), icon: Type.Optional( Type.String({ description: `Persistent sidebar icon: a single emoji, or a named icon: ${SESSION_ICON_GLYPH_DESCRIPTION}. Empty string clears it. Distinct from attention, which is temporary.`, @@ -158,6 +166,22 @@ const SessionsToolSchema = Type.Object( ), name: Type.Optional(Type.String({ description: "Group name" })), to: Type.Optional(Type.String({ description: "New group name" })), + targets: Type.Optional( + Type.Array( + Type.Object( + { + sessionKey: Type.String(), + expectedSessionId: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + { + minItems: 1, + maxItems: 100, + description: "Visible sessions for patch_many; maximum 100.", + }, + ), + ), }, { additionalProperties: false }, ); @@ -333,7 +357,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool label: "Sessions", name: "sessions", description: - "Session settings, ownership, reset, delete, and sidebar categories: patch label/icon/category/status, pin, archive/restore, model/thinking override; category assigns one session while group_set replaces the ordered category catalog; assign_owner hands responsibility to a human or agent; reset/delete visible sessions; group_list/group_set/group_rename/group_delete.", + "Session settings, ownership, reset, delete, and groups: patch label/icon/category/status, unread, pin, archive/restore, model/thinking override; patch_many changes category/unread for up to 100 visible sessions; assign_owner hands responsibility to a human or agent; reset/delete visible sessions; group_list/group_set/group_rename/group_delete.", parameters: SessionsToolSchema, execute: async (_toolCallId, rawArgs) => { const params = rawArgs as Record; @@ -444,6 +468,58 @@ 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"); + } + 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 (!rawTarget || typeof rawTarget !== "object") { + throw new ToolInputError(`targets[${index}] must be an object`); + } + const target = rawTarget as Record; + const sessionKey = readToolStringParam(target, "sessionKey", { required: true }); + const resolved = await resolvePatchTarget( + { ...opts, config: opts.config ?? getRuntimeConfig() }, + sessionKey, + gatewayRequest, + ); + const expectedSessionId = normalizeOptionalString( + readToolStringParam(target, "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) => + outcome.ok ? [] : [{ sessionKey: outcome.key, error: outcome.error.message }], + ); + return jsonResult({ + status: "updated", + requested: targets.length, + updated: result.outcomes.length - failed.length, + failed, + }); + } if (action !== "patch") { throw new ToolInputError(`Unknown action: ${action}`); } @@ -471,6 +547,10 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool key, ...lifecycleIdentity, ...(params.label !== undefined ? { label: readClearableString(params, "label") } : {}), + ...(params.category !== undefined + ? { category: readClearableString(params, "category") } + : {}), + ...(params.unread !== undefined ? { unread: readBooleanParam(params, "unread") } : {}), ...(params.icon !== undefined ? { icon: readClearableString(params, "icon") } : {}), ...(params.category !== undefined ? { category: readClearableString(params, "category") } diff --git a/src/gateway/server-methods/sessions-mutations.perf.test.ts b/src/gateway/server-methods/sessions-mutations.perf.test.ts index e666bf65d4d4..27b8ee8220db 100644 --- a/src/gateway/server-methods/sessions-mutations.perf.test.ts +++ b/src/gateway/server-methods/sessions-mutations.perf.test.ts @@ -112,6 +112,70 @@ test("single non-label sessions.patch avoids a whole-store projection", async () }); }); +test("sessions.patchMany categorizes 100 sessions without transcript hydration", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async (state) => { + const targets = Array.from({ length: 100 }, (_, index) => ({ + key: `agent:main:category-perf-${index}`, + expectedSessionId: `session-category-perf-${index}`, + })); + for (const [index, target] of targets.entries()) { + await upsertSessionEntryCore( + { agentId: "main", sessionKey: target.key }, + { sessionId: target.expectedSessionId, updatedAt: index + 1 }, + ); + } + + const database = openOpenClawAgentDatabase({ agentId: "main", env: state.env }); + const statements = trackSqliteStatementExecutions( + database.db, + ["transcript-full-hydration"] as const, + (sql) => { + const normalized = sql.toLowerCase().replaceAll(/\s+/g, " ").trim(); + const fromIndex = normalized.indexOf(" from "); + const selectedColumns = fromIndex > 0 ? normalized.slice("select ".length, fromIndex) : ""; + return normalized.startsWith("select ") && + /\b(?:from|join) "transcript_events"(?: |$)/.test(normalized) && + (selectedColumns.includes("event_json") || /(?:^|, )(?:(?:"[^"]+"\.)?\*)/.test(selectedColumns)) + ? "transcript-full-hydration" + : null; + }, + ); + sqliteTransactionLabels.length = 0; + const respond = vi.fn(); + const startedAt = performance.now(); + try { + await sessionMutationHandlers["sessions.patchMany"]!({ + params: { targets, patch: { category: "Research" } }, + respond, + context: { + getRuntimeConfig: () => ({}), + loadGatewayModelCatalog: vi.fn(async () => []), + broadcastToConnIds: vi.fn(), + getSessionEventSubscriberConnIds: () => new Set(), + chatAbortControllers: new Map(), + chatQueuedTurns: new Map(), + dedupe: new Map(), + } as unknown as GatewayRequestContext, + client: humanClient(), + } as never); + } finally { + statements.restore(); + } + + expect(performance.now() - startedAt).toBeLessThan(1_000); + expect(respond.mock.calls[0]?.[1]?.outcomes).toHaveLength(100); + expect(statements.counts["transcript-full-hydration"]).toBe(0); + expect( + sqliteTransactionLabels.filter((label) => label === "session.entry-replacements"), + ).toHaveLength(1); + for (const target of targets) { + expect(loadSessionEntry({ agentId: "main", sessionKey: target.key })?.category).toBe( + "Research", + ); + } + }); +}); + test("sessions.patchMany archives 30 human sessions without transcript hydration", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const targets = Array.from({ length: 30 }, (_, index) => ({ diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index af807596cb07..faa1b80c182f 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -521,6 +521,7 @@ export function buildGatewaySessionRow(params: { ? { sessionRoot: entry.sessionRoot } : {}), worktree: entry?.worktree, + projectId: entry?.projectId, execNode: entry?.execNode, execCwd: entry?.execCwd, forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 6959d6b30641..f86f9cb6d364 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -78,6 +78,7 @@ export type GatewaySessionRow = { sessionRoot?: string; /** Managed worktree bound to this session (repo checkout + branch). */ worktree?: SessionEntry["worktree"]; + projectId?: SessionEntry["projectId"]; /** Session-scoped exec node binding (exec host=node routing). */ execNode?: string; /** Working directory interpreted only by the bound exec node. */