diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 9a49d558390..a87fa136225 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessagingAdapter } from "../channels/plugins/types.js"; @@ -297,9 +299,7 @@ describe("sessions tools", () => { params: { activeMinutes: undefined, agentId: "main", - includeDerivedTitles: true, includeGlobal: true, - includeLastMessage: true, includeUnknown: true, label: "mailbox", limit: undefined, @@ -356,6 +356,93 @@ describe("sessions tools", () => { expect(cronDetails.sessions?.[0]?.kind).toBe("cron"); }); + it("derives mailbox previews only after agent visibility filtering", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sessions-list-preview-")); + const storePath = path.join(tmpDir, "sessions.json"); + try { + fs.writeFileSync( + path.join(tmpDir, "visible.jsonl"), + [ + JSON.stringify({ type: "session", id: "visible" }), + JSON.stringify({ message: { role: "user", content: "Visible project kickoff" } }), + JSON.stringify({ message: { role: "assistant", content: "Visible latest reply" } }), + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(tmpDir, "hidden.jsonl"), + [ + JSON.stringify({ type: "session", id: "hidden" }), + JSON.stringify({ message: { role: "user", content: "Hidden cross-agent topic" } }), + JSON.stringify({ message: { role: "assistant", content: "Hidden latest reply" } }), + ].join("\n"), + "utf-8", + ); + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: Record }; + if (request.method === "sessions.list") { + expect(request.params?.includeDerivedTitles).toBeUndefined(); + expect(request.params?.includeLastMessage).toBeUndefined(); + return { + path: storePath, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + sessionId: "visible", + updatedAt: 20, + }, + { + key: "agent:other:main", + kind: "direct", + sessionId: "hidden", + updatedAt: 21, + }, + ], + }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + config: { + ...TEST_CONFIG, + tools: { + sessions: { visibility: "agent" }, + agentToAgent: { enabled: false }, + }, + } as OpenClawConfig, + }).find((candidate) => candidate.name === "sessions_list"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_list tool"); + } + + const result = await tool.execute("call-preview", { + includeDerivedTitles: true, + includeLastMessage: true, + }); + const details = result.details as { + sessions?: Array<{ + key?: string; + derivedTitle?: string; + lastMessagePreview?: string; + }>; + }; + expect(details.sessions).toHaveLength(1); + expect(details.sessions?.[0]).toMatchObject({ + key: "agent:main:main", + derivedTitle: "Visible project kickoff", + lastMessagePreview: "Visible latest reply", + }); + expect(JSON.stringify(details.sessions)).not.toContain("Hidden"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("sessions_list resolves transcriptPath from agent state dir for multi-store listings", async () => { callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string }; diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 91b1b841388..4d6cd130e52 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -8,6 +8,8 @@ import { } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; +import { readSessionTitleFieldsFromTranscript } from "../../gateway/session-utils.fs.js"; +import { deriveSessionTitle } from "../../gateway/session-utils.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../../shared/string-coerce.js"; import { @@ -105,8 +107,8 @@ export function createSessionsListTool(opts?: { const label = readStringParam(params, "label"); const agentId = readStringParam(params, "agentId"); const search = readStringParam(params, "search"); - const includeDerivedTitles = params.includeDerivedTitles === true ? true : undefined; - const includeLastMessage = params.includeLastMessage === true ? true : undefined; + const includeDerivedTitles = params.includeDerivedTitles === true; + const includeLastMessage = params.includeLastMessage === true; const gatewayCall = opts?.callGateway ?? callGateway; const list = await gatewayCall<{ sessions: Array; path: string }>({ @@ -117,8 +119,6 @@ export function createSessionsListTool(opts?: { label, agentId, search, - includeDerivedTitles, - includeLastMessage, includeGlobal: !restrictToSpawned, includeUnknown: !restrictToSpawned, spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined, @@ -309,6 +309,32 @@ export function createSessionsListTool(opts?: { lastAccountId, transcriptPath, }; + if (sessionId && (includeDerivedTitles || includeLastMessage)) { + const fields = readSessionTitleFieldsFromTranscript( + sessionId, + storePath, + sessionFile, + resolvedAgentId, + ); + if (includeDerivedTitles && !row.derivedTitle) { + const derivedTitle = deriveSessionTitle( + { + sessionId, + displayName: row.displayName, + label: row.label, + subject: readStringValue((entry as { subject?: unknown }).subject), + updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0, + }, + fields.firstUserMessage, + ); + if (derivedTitle) { + row.derivedTitle = derivedTitle; + } + } + if (includeLastMessage && !row.lastMessagePreview && fields.lastMessagePreview) { + row.lastMessagePreview = fields.lastMessagePreview; + } + } if (messageLimit > 0) { const resolvedKey = resolveInternalSessionKey({ key,