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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user