Skip to content

Commit 53d6e07

Browse files
fix(sessions): set transcriptPath to agent sessions directory (#24775) thanks @martinfrancois
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: martinfrancois <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 0f36ee5 commit 53d6e07

File tree

4 files changed

+242
-11
lines changed

4 files changed

+242
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
2020

2121
### Fixes
2222

23+
- 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.
2324
- 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.
2425
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709)
2526
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259)

src/agents/tools/sessions-list-tool.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import path from "node:path";
12
import { Type } from "@sinclair/typebox";
23
import { loadConfig } from "../../config/config.js";
3-
import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js";
4+
import {
5+
resolveSessionFilePath,
6+
resolveSessionFilePathOptions,
7+
resolveStorePath,
8+
} from "../../config/sessions.js";
49
import { callGateway } from "../../gateway/call.js";
510
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
611
import type { AnyAgentTool } from "./common.js";
@@ -153,16 +158,26 @@ export function createSessionsListTool(opts?: {
153158
const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
154159
const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined;
155160
let transcriptPath: string | undefined;
156-
if (sessionId && storePath) {
161+
if (sessionId) {
157162
try {
158-
const sessionPathOpts = resolveSessionFilePathOptions({
159-
agentId: resolveAgentIdFromSessionKey(key),
160-
storePath,
163+
const agentId = resolveAgentIdFromSessionKey(key);
164+
const trimmedStorePath = storePath?.trim();
165+
let effectiveStorePath: string | undefined;
166+
if (trimmedStorePath && trimmedStorePath !== "(multiple)") {
167+
if (trimmedStorePath.includes("{agentId}") || trimmedStorePath.startsWith("~")) {
168+
effectiveStorePath = resolveStorePath(trimmedStorePath, { agentId });
169+
} else if (path.isAbsolute(trimmedStorePath)) {
170+
effectiveStorePath = trimmedStorePath;
171+
}
172+
}
173+
const filePathOpts = resolveSessionFilePathOptions({
174+
agentId,
175+
storePath: effectiveStorePath,
161176
});
162177
transcriptPath = resolveSessionFilePath(
163178
sessionId,
164179
sessionFile ? { sessionFile } : undefined,
165-
sessionPathOpts,
180+
filePathOpts,
166181
);
167182
} catch {
168183
transcriptPath = undefined;

src/agents/tools/sessions.test.ts

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os from "node:os";
2+
import path from "node:path";
13
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
24
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
35
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
@@ -7,15 +9,24 @@ vi.mock("../../gateway/call.js", () => ({
79
callGateway: (opts: unknown) => callGatewayMock(opts),
810
}));
911

12+
type SessionsToolTestConfig = {
13+
session: { scope: "per-sender"; mainKey: string };
14+
tools: {
15+
agentToAgent: { enabled: boolean };
16+
sessions?: { visibility: "all" | "own" };
17+
};
18+
};
19+
20+
const loadConfigMock = vi.fn<() => SessionsToolTestConfig>(() => ({
21+
session: { scope: "per-sender", mainKey: "main" },
22+
tools: { agentToAgent: { enabled: false } },
23+
}));
24+
1025
vi.mock("../../config/config.js", async (importOriginal) => {
1126
const actual = await importOriginal<typeof import("../../config/config.js")>();
1227
return {
1328
...actual,
14-
loadConfig: () =>
15-
({
16-
session: { scope: "per-sender", mainKey: "main" },
17-
tools: { agentToAgent: { enabled: false } },
18-
}) as never,
29+
loadConfig: () => loadConfigMock() as never,
1930
};
2031
});
2132

@@ -94,6 +105,14 @@ beforeAll(async () => {
94105
({ setActivePluginRegistry } = await import("../../plugins/runtime.js"));
95106
});
96107

108+
beforeEach(() => {
109+
loadConfigMock.mockReset();
110+
loadConfigMock.mockReturnValue({
111+
session: { scope: "per-sender", mainKey: "main" },
112+
tools: { agentToAgent: { enabled: false } },
113+
});
114+
});
115+
97116
describe("extractAssistantText", () => {
98117
it("sanitizes blocks without injecting newlines", () => {
99118
const message = {
@@ -199,6 +218,176 @@ describe("sessions_list gating", () => {
199218
});
200219
});
201220

221+
describe("sessions_list transcriptPath resolution", () => {
222+
beforeEach(() => {
223+
callGatewayMock.mockClear();
224+
loadConfigMock.mockReturnValue({
225+
session: { scope: "per-sender", mainKey: "main" },
226+
tools: {
227+
agentToAgent: { enabled: true },
228+
sessions: { visibility: "all" },
229+
},
230+
});
231+
});
232+
233+
it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => {
234+
const stateDir = path.join(os.tmpdir(), "openclaw-state-relative");
235+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
236+
237+
try {
238+
callGatewayMock.mockResolvedValueOnce({
239+
path: "agents/main/sessions/sessions.json",
240+
sessions: [
241+
{
242+
key: "agent:worker:main",
243+
kind: "direct",
244+
sessionId: "sess-worker",
245+
},
246+
],
247+
});
248+
249+
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
250+
const result = await tool.execute("call1", {});
251+
252+
const details = result.details as
253+
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
254+
| undefined;
255+
const session = details?.sessions?.[0];
256+
expect(session).toMatchObject({ key: "agent:worker:main" });
257+
const transcriptPath = String(session?.transcriptPath ?? "");
258+
expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
259+
expect(transcriptPath).toMatch(/sess-worker\.jsonl$/);
260+
} finally {
261+
vi.unstubAllEnvs();
262+
}
263+
});
264+
265+
it("resolves transcriptPath even when sessions.list does not return a store path", async () => {
266+
const stateDir = path.join(os.tmpdir(), "openclaw-state-no-path");
267+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
268+
269+
try {
270+
callGatewayMock.mockResolvedValueOnce({
271+
sessions: [
272+
{
273+
key: "agent:worker:main",
274+
kind: "direct",
275+
sessionId: "sess-worker-no-path",
276+
},
277+
],
278+
});
279+
280+
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
281+
const result = await tool.execute("call1", {});
282+
283+
const details = result.details as
284+
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
285+
| undefined;
286+
const session = details?.sessions?.[0];
287+
expect(session).toMatchObject({ key: "agent:worker:main" });
288+
const transcriptPath = String(session?.transcriptPath ?? "");
289+
expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
290+
expect(transcriptPath).toMatch(/sess-worker-no-path\.jsonl$/);
291+
} finally {
292+
vi.unstubAllEnvs();
293+
}
294+
});
295+
296+
it("falls back to agent defaults when gateway path is non-string", async () => {
297+
const stateDir = path.join(os.tmpdir(), "openclaw-state-non-string-path");
298+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
299+
300+
try {
301+
callGatewayMock.mockResolvedValueOnce({
302+
path: { raw: "agents/main/sessions/sessions.json" },
303+
sessions: [
304+
{
305+
key: "agent:worker:main",
306+
kind: "direct",
307+
sessionId: "sess-worker-shape",
308+
},
309+
],
310+
});
311+
312+
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
313+
const result = await tool.execute("call1", {});
314+
315+
const details = result.details as
316+
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
317+
| undefined;
318+
const session = details?.sessions?.[0];
319+
expect(session).toMatchObject({ key: "agent:worker:main" });
320+
const transcriptPath = String(session?.transcriptPath ?? "");
321+
expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
322+
expect(transcriptPath).toMatch(/sess-worker-shape\.jsonl$/);
323+
} finally {
324+
vi.unstubAllEnvs();
325+
}
326+
});
327+
328+
it("falls back to agent defaults when gateway path is '(multiple)'", async () => {
329+
const stateDir = path.join(os.tmpdir(), "openclaw-state-multiple");
330+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
331+
332+
try {
333+
callGatewayMock.mockResolvedValueOnce({
334+
path: "(multiple)",
335+
sessions: [
336+
{
337+
key: "agent:worker:main",
338+
kind: "direct",
339+
sessionId: "sess-worker-multiple",
340+
},
341+
],
342+
});
343+
344+
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
345+
const result = await tool.execute("call1", {});
346+
347+
const details = result.details as
348+
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
349+
| undefined;
350+
const session = details?.sessions?.[0];
351+
expect(session).toMatchObject({ key: "agent:worker:main" });
352+
const transcriptPath = String(session?.transcriptPath ?? "");
353+
expect(path.normalize(transcriptPath)).toContain(
354+
path.join(stateDir, "agents", "worker", "sessions"),
355+
);
356+
expect(transcriptPath).toMatch(/sess-worker-multiple\.jsonl$/);
357+
} finally {
358+
vi.unstubAllEnvs();
359+
}
360+
});
361+
362+
it("resolves absolute {agentId} template paths per session agent", async () => {
363+
const templateStorePath = "/tmp/openclaw/agents/{agentId}/sessions/sessions.json";
364+
365+
callGatewayMock.mockResolvedValueOnce({
366+
path: templateStorePath,
367+
sessions: [
368+
{
369+
key: "agent:worker:main",
370+
kind: "direct",
371+
sessionId: "sess-worker-template",
372+
},
373+
],
374+
});
375+
376+
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
377+
const result = await tool.execute("call1", {});
378+
379+
const details = result.details as
380+
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
381+
| undefined;
382+
const session = details?.sessions?.[0];
383+
expect(session).toMatchObject({ key: "agent:worker:main" });
384+
const transcriptPath = String(session?.transcriptPath ?? "");
385+
const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker"));
386+
expect(path.normalize(transcriptPath)).toContain(path.normalize(expectedSessionsDir));
387+
expect(transcriptPath).toMatch(/sess-worker-template\.jsonl$/);
388+
});
389+
});
390+
202391
describe("sessions_send gating", () => {
203392
beforeEach(() => {
204393
callGatewayMock.mockClear();

src/config/sessions.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
deriveSessionKey,
99
loadSessionStore,
1010
resolveSessionFilePath,
11+
resolveSessionFilePathOptions,
1112
resolveSessionKey,
1213
resolveSessionTranscriptPath,
1314
resolveSessionTranscriptsDir,
@@ -598,6 +599,31 @@ describe("sessions", () => {
598599
});
599600
});
600601

602+
it("resolveSessionFilePathOptions keeps explicit agentId alongside absolute store path", () => {
603+
const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
604+
const resolved = resolveSessionFilePathOptions({
605+
agentId: "bot2",
606+
storePath,
607+
});
608+
expect(resolved?.agentId).toBe("bot2");
609+
expect(resolved?.sessionsDir).toBe(path.dirname(path.resolve(storePath)));
610+
});
611+
612+
it("resolves sibling agent absolute sessionFile using alternate agentId from options", () => {
613+
const stateDir = path.resolve("/home/user/.openclaw");
614+
withStateDir(stateDir, () => {
615+
const mainStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
616+
const bot2Session = path.join(stateDir, "agents", "bot2", "sessions", "sess-1.jsonl");
617+
const opts = resolveSessionFilePathOptions({
618+
agentId: "bot2",
619+
storePath: mainStorePath,
620+
});
621+
622+
const sessionFile = resolveSessionFilePath("sess-1", { sessionFile: bot2Session }, opts);
623+
expect(sessionFile).toBe(bot2Session);
624+
});
625+
});
626+
601627
it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => {
602628
withStateDir(path.resolve("/home/user/.openclaw"), () => {
603629
const sessionFile = resolveSessionFilePath(

0 commit comments

Comments
 (0)