From 53d6e07a601b83fe2ed03ebcf667ca68f315cb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Martin?= Date: Sun, 1 Mar 2026 16:41:06 +0100 Subject: [PATCH] fix(sessions): set transcriptPath to agent sessions directory (openclaw#24775) thanks @martinfrancois Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: martinfrancois <14319020+martinfrancois@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/tools/sessions-list-tool.ts | 27 +++- src/agents/tools/sessions.test.ts | 199 ++++++++++++++++++++++++- src/config/sessions.test.ts | 26 ++++ 4 files changed, 242 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7171cd864..5afb5f4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois. - Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau. - Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) - CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 73a25a681..0cba87e56 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -1,6 +1,11 @@ +import path from "node:path"; import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; -import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, + resolveStorePath, +} from "../../config/sessions.js"; import { callGateway } from "../../gateway/call.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; @@ -153,16 +158,26 @@ export function createSessionsListTool(opts?: { const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile; const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined; let transcriptPath: string | undefined; - if (sessionId && storePath) { + if (sessionId) { try { - const sessionPathOpts = resolveSessionFilePathOptions({ - agentId: resolveAgentIdFromSessionKey(key), - storePath, + const agentId = resolveAgentIdFromSessionKey(key); + const trimmedStorePath = storePath?.trim(); + let effectiveStorePath: string | undefined; + if (trimmedStorePath && trimmedStorePath !== "(multiple)") { + if (trimmedStorePath.includes("{agentId}") || trimmedStorePath.startsWith("~")) { + effectiveStorePath = resolveStorePath(trimmedStorePath, { agentId }); + } else if (path.isAbsolute(trimmedStorePath)) { + effectiveStorePath = trimmedStorePath; + } + } + const filePathOpts = resolveSessionFilePathOptions({ + agentId, + storePath: effectiveStorePath, }); transcriptPath = resolveSessionFilePath( sessionId, sessionFile ? { sessionFile } : undefined, - sessionPathOpts, + filePathOpts, ); } catch { transcriptPath = undefined; diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 9796ac88a..0d381a3e4 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -1,3 +1,5 @@ +import os from "node:os"; +import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; @@ -7,15 +9,24 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); +type SessionsToolTestConfig = { + session: { scope: "per-sender"; mainKey: string }; + tools: { + agentToAgent: { enabled: boolean }; + sessions?: { visibility: "all" | "own" }; + }; +}; + +const loadConfigMock = vi.fn<() => SessionsToolTestConfig>(() => ({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { agentToAgent: { enabled: false } }, +})); + vi.mock("../../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, + loadConfig: () => loadConfigMock() as never, }; }); @@ -94,6 +105,14 @@ beforeAll(async () => { ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); }); +beforeEach(() => { + loadConfigMock.mockReset(); + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { agentToAgent: { enabled: false } }, + }); +}); + describe("extractAssistantText", () => { it("sanitizes blocks without injecting newlines", () => { const message = { @@ -199,6 +218,176 @@ describe("sessions_list gating", () => { }); }); +describe("sessions_list transcriptPath resolution", () => { + beforeEach(() => { + callGatewayMock.mockClear(); + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: true }, + sessions: { visibility: "all" }, + }, + }); + }); + + it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => { + const stateDir = path.join(os.tmpdir(), "openclaw-state-relative"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + try { + callGatewayMock.mockResolvedValueOnce({ + path: "agents/main/sessions/sessions.json", + sessions: [ + { + key: "agent:worker:main", + kind: "direct", + sessionId: "sess-worker", + }, + ], + }); + + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + + const details = result.details as + | { sessions?: Array<{ key?: string; transcriptPath?: string }> } + | undefined; + const session = details?.sessions?.[0]; + expect(session).toMatchObject({ key: "agent:worker:main" }); + const transcriptPath = String(session?.transcriptPath ?? ""); + expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); + expect(transcriptPath).toMatch(/sess-worker\.jsonl$/); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("resolves transcriptPath even when sessions.list does not return a store path", async () => { + const stateDir = path.join(os.tmpdir(), "openclaw-state-no-path"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + try { + callGatewayMock.mockResolvedValueOnce({ + sessions: [ + { + key: "agent:worker:main", + kind: "direct", + sessionId: "sess-worker-no-path", + }, + ], + }); + + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + + const details = result.details as + | { sessions?: Array<{ key?: string; transcriptPath?: string }> } + | undefined; + const session = details?.sessions?.[0]; + expect(session).toMatchObject({ key: "agent:worker:main" }); + const transcriptPath = String(session?.transcriptPath ?? ""); + expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); + expect(transcriptPath).toMatch(/sess-worker-no-path\.jsonl$/); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("falls back to agent defaults when gateway path is non-string", async () => { + const stateDir = path.join(os.tmpdir(), "openclaw-state-non-string-path"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + try { + callGatewayMock.mockResolvedValueOnce({ + path: { raw: "agents/main/sessions/sessions.json" }, + sessions: [ + { + key: "agent:worker:main", + kind: "direct", + sessionId: "sess-worker-shape", + }, + ], + }); + + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + + const details = result.details as + | { sessions?: Array<{ key?: string; transcriptPath?: string }> } + | undefined; + const session = details?.sessions?.[0]; + expect(session).toMatchObject({ key: "agent:worker:main" }); + const transcriptPath = String(session?.transcriptPath ?? ""); + expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); + expect(transcriptPath).toMatch(/sess-worker-shape\.jsonl$/); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("falls back to agent defaults when gateway path is '(multiple)'", async () => { + const stateDir = path.join(os.tmpdir(), "openclaw-state-multiple"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + try { + callGatewayMock.mockResolvedValueOnce({ + path: "(multiple)", + sessions: [ + { + key: "agent:worker:main", + kind: "direct", + sessionId: "sess-worker-multiple", + }, + ], + }); + + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + + const details = result.details as + | { sessions?: Array<{ key?: string; transcriptPath?: string }> } + | undefined; + const session = details?.sessions?.[0]; + expect(session).toMatchObject({ key: "agent:worker:main" }); + const transcriptPath = String(session?.transcriptPath ?? ""); + expect(path.normalize(transcriptPath)).toContain( + path.join(stateDir, "agents", "worker", "sessions"), + ); + expect(transcriptPath).toMatch(/sess-worker-multiple\.jsonl$/); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("resolves absolute {agentId} template paths per session agent", async () => { + const templateStorePath = "/tmp/openclaw/agents/{agentId}/sessions/sessions.json"; + + callGatewayMock.mockResolvedValueOnce({ + path: templateStorePath, + sessions: [ + { + key: "agent:worker:main", + kind: "direct", + sessionId: "sess-worker-template", + }, + ], + }); + + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + + const details = result.details as + | { sessions?: Array<{ key?: string; transcriptPath?: string }> } + | undefined; + const session = details?.sessions?.[0]; + expect(session).toMatchObject({ key: "agent:worker:main" }); + const transcriptPath = String(session?.transcriptPath ?? ""); + const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker")); + expect(path.normalize(transcriptPath)).toContain(path.normalize(expectedSessionsDir)); + expect(transcriptPath).toMatch(/sess-worker-template\.jsonl$/); + }); +}); + describe("sessions_send gating", () => { beforeEach(() => { callGatewayMock.mockClear(); diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 26696d60a..ea4eaa8b4 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -8,6 +8,7 @@ import { deriveSessionKey, loadSessionStore, resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionKey, resolveSessionTranscriptPath, resolveSessionTranscriptsDir, @@ -598,6 +599,31 @@ describe("sessions", () => { }); }); + it("resolveSessionFilePathOptions keeps explicit agentId alongside absolute store path", () => { + const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; + const resolved = resolveSessionFilePathOptions({ + agentId: "bot2", + storePath, + }); + expect(resolved?.agentId).toBe("bot2"); + expect(resolved?.sessionsDir).toBe(path.dirname(path.resolve(storePath))); + }); + + it("resolves sibling agent absolute sessionFile using alternate agentId from options", () => { + const stateDir = path.resolve("/home/user/.openclaw"); + withStateDir(stateDir, () => { + const mainStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); + const bot2Session = path.join(stateDir, "agents", "bot2", "sessions", "sess-1.jsonl"); + const opts = resolveSessionFilePathOptions({ + agentId: "bot2", + storePath: mainStorePath, + }); + + const sessionFile = resolveSessionFilePath("sess-1", { sessionFile: bot2Session }, opts); + expect(sessionFile).toBe(bot2Session); + }); + }); + it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => { withStateDir(path.resolve("/home/user/.openclaw"), () => { const sessionFile = resolveSessionFilePath(