feat: add heuristic session title derivation for session picker

Enable meaningful session titles via priority-based derivation:
1. displayName (user-set)
2. subject (group name)
3. First user message (truncated to 60 chars)
4. sessionId prefix + date fallback

Opt-in via includeDerivedTitles param to avoid perf impact on
regular listing. Reads only first 10 lines of transcript files.

Closes #1161
This commit is contained in:
CJ Winslow
2026-01-18 02:18:50 -08:00
committed by Peter Steinberger
parent 4fda10c508
commit 83d5e30027
6 changed files with 358 additions and 3 deletions

View File

@@ -0,0 +1,136 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js";
describe("readFirstUserMessageFromTranscript", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-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("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");
});
});