* fix(paths): structurally resolve home dir to prevent Windows path bugs
Extract resolveRawHomeDir as a private function and gate the public
resolveEffectiveHomeDir through a single path.resolve() exit point.
This makes it structurally impossible for unresolved paths (missing
drive letter on Windows) to escape the function, regardless of how
many return paths exist in the raw lookup logic.
Simplify resolveRequiredHomeDir to only resolve the process.cwd()
fallback, since resolveEffectiveHomeDir now returns resolved values.
Fix shortenMeta in tool-meta.ts: the colon-based split for file:line
patterns (e.g. file.txt:12) conflicts with Windows drive letters
(C:\...) because indexOf(":") matches the drive colon first.
shortenHomeInString already handles file:line patterns correctly via
split/join, so the colon split was both unnecessary and harmful.
Update test assertions across all affected files to use path.resolve()
in expected values and input strings so they match the now-correct
resolved output on both Unix and Windows.
Fixes #12119
* fix(changelog): add paths Windows fix entry (#12125)
---------
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import {
|
|
readFirstUserMessageFromTranscript,
|
|
readLastMessagePreviewFromTranscript,
|
|
readSessionMessages,
|
|
readSessionPreviewItemsFromTranscript,
|
|
resolveSessionTranscriptCandidates,
|
|
} from "./session-utils.fs.js";
|
|
|
|
describe("readFirstUserMessageFromTranscript", () => {
|
|
let tmpDir: string;
|
|
let storePath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
|
|
storePath = path.join(tmpDir, "sessions.json");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("returns null when transcript file does not exist", () => {
|
|
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns first user message from transcript with string content", () => {
|
|
const sessionId = "test-session-1";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
|
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Hello world");
|
|
});
|
|
|
|
test("returns first user message from transcript with array content", () => {
|
|
const sessionId = "test-session-2";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "user",
|
|
content: [{ type: "text", text: "Array message content" }],
|
|
},
|
|
}),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Array message content");
|
|
});
|
|
|
|
test("returns first user message from transcript with input_text content", () => {
|
|
const sessionId = "test-session-2b";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "Input text content" }],
|
|
},
|
|
}),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Input text content");
|
|
});
|
|
test("skips non-user messages to find first user message", () => {
|
|
const sessionId = "test-session-3";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
|
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
|
JSON.stringify({ message: { role: "user", content: "First user question" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("First user question");
|
|
});
|
|
|
|
test("returns null when no user messages exist", () => {
|
|
const sessionId = "test-session-4";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
|
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("handles malformed JSON lines gracefully", () => {
|
|
const sessionId = "test-session-5";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
"not valid json",
|
|
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Valid message");
|
|
});
|
|
|
|
test("uses sessionFile parameter when provided", () => {
|
|
const sessionId = "test-session-6";
|
|
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
|
|
];
|
|
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
|
|
expect(result).toBe("Custom file message");
|
|
});
|
|
|
|
test("trims whitespace from message content", () => {
|
|
const sessionId = "test-session-7";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Padded message");
|
|
});
|
|
|
|
test("returns null for empty content", () => {
|
|
const sessionId = "test-session-8";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "user", content: "" } }),
|
|
JSON.stringify({ message: { role: "user", content: "Second message" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Second message");
|
|
});
|
|
});
|
|
|
|
describe("readLastMessagePreviewFromTranscript", () => {
|
|
let tmpDir: string;
|
|
let storePath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
|
|
storePath = path.join(tmpDir, "sessions.json");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("returns null when transcript file does not exist", () => {
|
|
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns null for empty file", () => {
|
|
const sessionId = "test-last-empty";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
fs.writeFileSync(transcriptPath, "", "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("returns last user message from transcript", () => {
|
|
const sessionId = "test-last-user";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "user", content: "First user" } }),
|
|
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
|
|
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Last user message");
|
|
});
|
|
|
|
test("returns last assistant message from transcript", () => {
|
|
const sessionId = "test-last-assistant";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "user", content: "User question" } }),
|
|
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Final assistant reply");
|
|
});
|
|
|
|
test("skips system messages to find last user/assistant", () => {
|
|
const sessionId = "test-last-skip-system";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "user", content: "Real last" } }),
|
|
JSON.stringify({ message: { role: "system", content: "System at end" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Real last");
|
|
});
|
|
|
|
test("returns null when no user/assistant messages exist", () => {
|
|
const sessionId = "test-last-no-match";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "system", content: "Only system" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test("handles malformed JSON lines gracefully", () => {
|
|
const sessionId = "test-last-malformed";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "user", content: "Valid first" } }),
|
|
"not valid json at end",
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Valid first");
|
|
});
|
|
|
|
test("handles array content format", () => {
|
|
const sessionId = "test-last-array";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Array content response" }],
|
|
},
|
|
}),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Array content response");
|
|
});
|
|
|
|
test("handles output_text content format", () => {
|
|
const sessionId = "test-last-output-text";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: "Output text response" }],
|
|
},
|
|
}),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Output text response");
|
|
});
|
|
test("uses sessionFile parameter when provided", () => {
|
|
const sessionId = "test-last-custom";
|
|
const customPath = path.join(tmpDir, "custom-last.jsonl");
|
|
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
|
|
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
|
|
expect(result).toBe("Custom file last");
|
|
});
|
|
|
|
test("trims whitespace from message content", () => {
|
|
const sessionId = "test-last-trim";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Padded response");
|
|
});
|
|
|
|
test("skips empty content to find previous message", () => {
|
|
const sessionId = "test-last-skip-empty";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
|
|
JSON.stringify({ message: { role: "user", content: "" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Has content");
|
|
});
|
|
|
|
test("reads from end of large file (16KB window)", () => {
|
|
const sessionId = "test-last-large";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
lines.push(padding);
|
|
}
|
|
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Last in large file");
|
|
});
|
|
|
|
test("handles valid UTF-8 content", () => {
|
|
const sessionId = "test-last-utf8";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const validLine = JSON.stringify({
|
|
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
|
|
});
|
|
fs.writeFileSync(transcriptPath, validLine, "utf-8");
|
|
|
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
|
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
|
});
|
|
});
|
|
|
|
describe("readSessionMessages", () => {
|
|
let tmpDir: string;
|
|
let storePath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-fs-test-"));
|
|
storePath = path.join(tmpDir, "sessions.json");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("includes synthetic compaction markers for compaction entries", () => {
|
|
const sessionId = "test-session-compaction";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
|
JSON.stringify({
|
|
type: "compaction",
|
|
id: "comp-1",
|
|
timestamp: "2026-02-07T00:00:00.000Z",
|
|
summary: "Compacted history",
|
|
firstKeptEntryId: "x",
|
|
tokensBefore: 123,
|
|
}),
|
|
JSON.stringify({ message: { role: "assistant", content: "World" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const out = readSessionMessages(sessionId, storePath);
|
|
expect(out).toHaveLength(3);
|
|
const marker = out[1] as {
|
|
role: string;
|
|
content?: Array<{ text?: string }>;
|
|
__openclaw?: { kind?: string; id?: string };
|
|
timestamp?: number;
|
|
};
|
|
expect(marker.role).toBe("system");
|
|
expect(marker.content?.[0]?.text).toBe("Compaction");
|
|
expect(marker.__openclaw?.kind).toBe("compaction");
|
|
expect(marker.__openclaw?.id).toBe("comp-1");
|
|
expect(typeof marker.timestamp).toBe("number");
|
|
});
|
|
});
|
|
|
|
describe("readSessionPreviewItemsFromTranscript", () => {
|
|
let tmpDir: string;
|
|
let storePath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-preview-test-"));
|
|
storePath = path.join(tmpDir, "sessions.json");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("returns recent preview items with tool summary", () => {
|
|
const sessionId = "preview-session";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
|
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
|
JSON.stringify({
|
|
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
|
|
}),
|
|
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readSessionPreviewItemsFromTranscript(
|
|
sessionId,
|
|
storePath,
|
|
undefined,
|
|
undefined,
|
|
3,
|
|
120,
|
|
);
|
|
|
|
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
|
expect(result[1]?.text).toContain("call weather");
|
|
});
|
|
|
|
test("detects tool calls from tool_use/tool_call blocks and toolName field", () => {
|
|
const sessionId = "preview-session-tools";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const lines = [
|
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
toolName: "camera",
|
|
content: [
|
|
{ type: "tool_use", name: "read" },
|
|
{ type: "tool_call", name: "write" },
|
|
],
|
|
},
|
|
}),
|
|
JSON.stringify({ message: { role: "assistant", content: "Done" } }),
|
|
];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readSessionPreviewItemsFromTranscript(
|
|
sessionId,
|
|
storePath,
|
|
undefined,
|
|
undefined,
|
|
3,
|
|
120,
|
|
);
|
|
|
|
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
|
expect(result[1]?.text).toContain("call");
|
|
expect(result[1]?.text).toContain("camera");
|
|
expect(result[1]?.text).toContain("read");
|
|
// Preview text may not list every tool name; it should at least hint there were multiple calls.
|
|
expect(result[1]?.text).toMatch(/\+\d+/);
|
|
});
|
|
|
|
test("truncates preview text to max chars", () => {
|
|
const sessionId = "preview-truncate";
|
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
|
const longText = "a".repeat(60);
|
|
const lines = [JSON.stringify({ message: { role: "assistant", content: longText } })];
|
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
|
|
const result = readSessionPreviewItemsFromTranscript(
|
|
sessionId,
|
|
storePath,
|
|
undefined,
|
|
undefined,
|
|
1,
|
|
24,
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.text.length).toBe(24);
|
|
expect(result[0]?.text.endsWith("...")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("resolveSessionTranscriptCandidates", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
test("fallback candidate uses OPENCLAW_HOME instead of os.homedir()", () => {
|
|
vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home");
|
|
vi.stubEnv("HOME", "/home/other");
|
|
|
|
const candidates = resolveSessionTranscriptCandidates("sess-1", undefined);
|
|
const fallback = candidates[candidates.length - 1];
|
|
expect(fallback).toBe(
|
|
path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "sessions", "sess-1.jsonl"),
|
|
);
|
|
});
|
|
});
|