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:
committed by
Peter Steinberger
parent
4fda10c508
commit
83d5e30027
@@ -8,6 +8,7 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
|
||||
136
src/gateway/session-utils.fs.test.ts
Normal file
136
src/gateway/session-utils.fs.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -79,3 +79,61 @@ export function capArrayByJsonBytes<T>(
|
||||
const next = start > 0 ? items.slice(start) : items;
|
||||
return { items: next, bytes };
|
||||
}
|
||||
|
||||
const MAX_LINES_TO_SCAN = 10;
|
||||
|
||||
type TranscriptMessage = {
|
||||
role?: string;
|
||||
content?: string | Array<{ type: string; text?: string }>;
|
||||
};
|
||||
|
||||
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
|
||||
if (typeof content === "string") return content.trim() || null;
|
||||
if (!Array.isArray(content)) return null;
|
||||
for (const part of content) {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
||||
return part.text.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readFirstUserMessageFromTranscript(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile?: string,
|
||||
agentId?: string,
|
||||
): string | null {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return null;
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
if (bytesRead === 0) return null;
|
||||
const chunk = buf.toString("utf-8", 0, bytesRead);
|
||||
const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptMessage | undefined;
|
||||
if (msg?.role === "user") {
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (text) return text;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file read error
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SessionEntry } from "../config/sessions.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
classifySessionKey,
|
||||
deriveSessionTitle,
|
||||
parseGroupKey,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionStoreKey,
|
||||
@@ -91,3 +92,98 @@ describe("gateway session utils", () => {
|
||||
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSessionTitle", () => {
|
||||
test("returns undefined for undefined entry", () => {
|
||||
expect(deriveSessionTitle(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers displayName when set", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
displayName: "My Custom Session",
|
||||
subject: "Group Chat",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("My Custom Session");
|
||||
});
|
||||
|
||||
test("falls back to subject when displayName is missing", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
subject: "Dev Team Chat",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("Dev Team Chat");
|
||||
});
|
||||
|
||||
test("uses first user message when displayName and subject missing", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry, "Hello, how are you?")).toBe("Hello, how are you?");
|
||||
});
|
||||
|
||||
test("truncates long first user message to 60 chars with ellipsis", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
const longMsg =
|
||||
"This is a very long message that exceeds sixty characters and should be truncated appropriately";
|
||||
const result = deriveSessionTitle(entry, longMsg);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.length).toBeLessThanOrEqual(60);
|
||||
expect(result!.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
test("truncates at word boundary when possible", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
const longMsg = "This message has many words and should be truncated at a word boundary nicely";
|
||||
const result = deriveSessionTitle(entry, longMsg);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.endsWith("…")).toBe(true);
|
||||
expect(result!.includes(" ")).toBe(false);
|
||||
});
|
||||
|
||||
test("falls back to sessionId prefix with date", () => {
|
||||
const entry = {
|
||||
sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv",
|
||||
updatedAt: new Date("2024-03-15T10:30:00Z").getTime(),
|
||||
} as SessionEntry;
|
||||
const result = deriveSessionTitle(entry);
|
||||
expect(result).toBe("abcd1234 (2024-03-15)");
|
||||
});
|
||||
|
||||
test("falls back to sessionId prefix without date when updatedAt missing", () => {
|
||||
const entry = {
|
||||
sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv",
|
||||
updatedAt: 0,
|
||||
} as SessionEntry;
|
||||
const result = deriveSessionTitle(entry);
|
||||
expect(result).toBe("abcd1234");
|
||||
});
|
||||
|
||||
test("trims whitespace from displayName", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
displayName: " Padded Name ",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("Padded Name");
|
||||
});
|
||||
|
||||
test("ignores empty displayName and falls through", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
displayName: " ",
|
||||
subject: "Actual Subject",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("Actual Subject");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
||||
import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js";
|
||||
import type {
|
||||
GatewayAgentRow,
|
||||
GatewaySessionRow,
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
export {
|
||||
archiveFileOnDisk,
|
||||
capArrayByJsonBytes,
|
||||
readFirstUserMessageFromTranscript,
|
||||
readSessionMessages,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
@@ -43,6 +45,51 @@ export type {
|
||||
SessionsPatchResult,
|
||||
} from "./session-utils.types.js";
|
||||
|
||||
const DERIVED_TITLE_MAX_LEN = 60;
|
||||
|
||||
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
||||
const prefix = sessionId.slice(0, 8);
|
||||
if (updatedAt && updatedAt > 0) {
|
||||
const d = new Date(updatedAt);
|
||||
const date = d.toISOString().slice(0, 10);
|
||||
return `${prefix} (${date})`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function truncateTitle(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
const cut = text.slice(0, maxLen - 1);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
if (lastSpace > maxLen * 0.6) return cut.slice(0, lastSpace) + "…";
|
||||
return cut + "…";
|
||||
}
|
||||
|
||||
export function deriveSessionTitle(
|
||||
entry: SessionEntry | undefined,
|
||||
firstUserMessage?: string | null,
|
||||
): string | undefined {
|
||||
if (!entry) return undefined;
|
||||
|
||||
if (entry.displayName?.trim()) {
|
||||
return entry.displayName.trim();
|
||||
}
|
||||
|
||||
if (entry.subject?.trim()) {
|
||||
return entry.subject.trim();
|
||||
}
|
||||
|
||||
if (firstUserMessage?.trim()) {
|
||||
return truncateTitle(firstUserMessage.trim(), DERIVED_TITLE_MAX_LEN);
|
||||
}
|
||||
|
||||
if (entry.sessionId) {
|
||||
return formatSessionIdPrefix(entry.sessionId, entry.updatedAt);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function loadSessionEntry(sessionKey: string) {
|
||||
const cfg = loadConfig();
|
||||
const sessionCfg = cfg.session;
|
||||
@@ -341,6 +388,7 @@ export function listSessionsFromStore(params: {
|
||||
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
||||
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||
@@ -400,6 +448,7 @@ export function listSessionsFromStore(params: {
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
return {
|
||||
key,
|
||||
entry,
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
displayName,
|
||||
@@ -429,7 +478,7 @@ export function listSessionsFromStore(params: {
|
||||
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
||||
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
|
||||
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
|
||||
} satisfies GatewaySessionRow;
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
@@ -443,11 +492,25 @@ export function listSessionsFromStore(params: {
|
||||
sessions = sessions.slice(0, limit);
|
||||
}
|
||||
|
||||
const finalSessions: GatewaySessionRow[] = sessions.map((s) => {
|
||||
const { entry, ...rest } = s;
|
||||
let derivedTitle: string | undefined;
|
||||
if (includeDerivedTitles && entry?.sessionId) {
|
||||
const firstUserMsg = readFirstUserMessageFromTranscript(
|
||||
entry.sessionId,
|
||||
storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
derivedTitle = deriveSessionTitle(entry, firstUserMsg);
|
||||
}
|
||||
return { ...rest, derivedTitle } satisfies GatewaySessionRow;
|
||||
});
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
count: finalSessions.length,
|
||||
defaults: getSessionDefaults(cfg),
|
||||
sessions,
|
||||
sessions: finalSessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type GatewaySessionRow = {
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
derivedTitle?: string;
|
||||
channel?: string;
|
||||
subject?: string;
|
||||
groupChannel?: string;
|
||||
|
||||
Reference in New Issue
Block a user