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>
This commit is contained in:
François Martin
2026-03-01 16:41:06 +01:00
committed by GitHub
parent 0f36ee5a2e
commit 53d6e07a60
4 changed files with 242 additions and 11 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -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<typeof import("../../config/config.js")>();
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();

View File

@@ -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(