feat(sessions): expose organization tools

This commit is contained in:
vyctorbrzezowski
2026-08-18 21:22:22 -03:00
parent d9e67a198c
commit 53ef72c753
10 changed files with 309 additions and 10 deletions
+3 -2
View File
@@ -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)] : []),
+11 -1
View File
@@ -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"] },
+14
View File
@@ -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;
+26 -3
View File
@@ -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<string>; contextTokens?: number; derivedTitle?: string; displayName?: string; label?: string; lastMessagePreview?: string; messages?: Array<unknown>; 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<string>; contextTokens?: number; derivedTitle?: string; displayName?: string; hasWorktree?: boolean; icon?: string; label?: string; lastMessagePreview?: string; messages?: Array<unknown>; 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.",
+46
View File
@@ -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 } : {}),
+62 -3
View File
@@ -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" },
],
});
});
});
+81 -1
View File
@@ -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<string, unknown>;
@@ -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<string, unknown>;
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<SessionsPatchManyResult>("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") }
@@ -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) => ({
+1
View File
@@ -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,
+1
View File
@@ -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. */