Merge pull request #1271 from Whoaa512/feat/session-picker-mvp
feat: session picker MVP - fuzzy search, derived titles, relative time
This commit is contained in:
@@ -3,81 +3,92 @@ import { Type } from "@sinclair/typebox";
|
||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||
|
||||
export const SessionsListParamsSchema = Type.Object(
|
||||
{
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
/**
|
||||
* Read first 8KB of each session transcript to derive title from first user message.
|
||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||
*/
|
||||
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
||||
/**
|
||||
* Read last 16KB of each session transcript to extract most recent message preview.
|
||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||
*/
|
||||
includeLastMessage: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
search: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsResolveParamsSchema = Type.Object(
|
||||
{
|
||||
key: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsPatchParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
responseUsage: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("off"),
|
||||
Type.Literal("tokens"),
|
||||
Type.Literal("full"),
|
||||
// Backward compat with older clients/stores.
|
||||
Type.Literal("on"),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
sendPolicy: Type.Optional(
|
||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||
),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: NonEmptyString,
|
||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
responseUsage: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("off"),
|
||||
Type.Literal("tokens"),
|
||||
Type.Literal("full"),
|
||||
// Backward compat with older clients/stores.
|
||||
Type.Literal("on"),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
sendPolicy: Type.Optional(
|
||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||
),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsResetParamsSchema = Type.Object(
|
||||
{ key: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
{ key: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsDeleteParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: NonEmptyString,
|
||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsCompactParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: NonEmptyString,
|
||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
343
src/gateway/session-utils.fs.test.ts
Normal file
343
src/gateway/session-utils.fs.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
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,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
} 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("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(), "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 = 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: 你好世界 🌍");
|
||||
});
|
||||
});
|
||||
@@ -79,3 +79,113 @@ 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 || typeof part.text !== "string") continue;
|
||||
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
|
||||
const trimmed = part.text.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const LAST_MSG_MAX_BYTES = 16384;
|
||||
const LAST_MSG_MAX_LINES = 20;
|
||||
|
||||
export function readLastMessagePreviewFromTranscript(
|
||||
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 stat = fs.fstatSync(fd);
|
||||
const size = stat.size;
|
||||
if (size === 0) return null;
|
||||
|
||||
const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES);
|
||||
const readLen = Math.min(size, LAST_MSG_MAX_BYTES);
|
||||
const buf = Buffer.alloc(readLen);
|
||||
fs.readSync(fd, buf, 0, readLen, readStart);
|
||||
|
||||
const chunk = buf.toString("utf-8");
|
||||
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
|
||||
const tailLines = lines.slice(-LAST_MSG_MAX_LINES);
|
||||
|
||||
for (let i = tailLines.length - 1; i >= 0; i--) {
|
||||
const line = tailLines[i];
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptMessage | undefined;
|
||||
if (msg?.role === "user" || msg?.role === "assistant") {
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (text) return text;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file error
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { SessionEntry } from "../config/sessions.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
classifySessionKey,
|
||||
deriveSessionTitle,
|
||||
listSessionsFromStore,
|
||||
parseGroupKey,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionStoreKey,
|
||||
@@ -91,3 +93,242 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessionsFromStore search", () => {
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const makeStore = (): Record<string, SessionEntry> => ({
|
||||
"agent:main:work-project": {
|
||||
sessionId: "sess-work-1",
|
||||
updatedAt: Date.now(),
|
||||
displayName: "Work Project Alpha",
|
||||
label: "work",
|
||||
} as SessionEntry,
|
||||
"agent:main:personal-chat": {
|
||||
sessionId: "sess-personal-1",
|
||||
updatedAt: Date.now() - 1000,
|
||||
displayName: "Personal Chat",
|
||||
subject: "Family Reunion Planning",
|
||||
} as SessionEntry,
|
||||
"agent:main:discord:group:dev-team": {
|
||||
sessionId: "sess-discord-1",
|
||||
updatedAt: Date.now() - 2000,
|
||||
label: "discord",
|
||||
subject: "Dev Team Discussion",
|
||||
} as SessionEntry,
|
||||
});
|
||||
|
||||
test("returns all sessions when search is empty", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(3);
|
||||
});
|
||||
|
||||
test("returns all sessions when search is undefined", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
expect(result.sessions.length).toBe(3);
|
||||
});
|
||||
|
||||
test("filters by displayName case-insensitively", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "WORK PROJECT" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
|
||||
});
|
||||
|
||||
test("filters by subject", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "reunion" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].subject).toBe("Family Reunion Planning");
|
||||
});
|
||||
|
||||
test("filters by label", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "discord" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].label).toBe("discord");
|
||||
});
|
||||
|
||||
test("filters by sessionId", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "sess-personal" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].sessionId).toBe("sess-personal-1");
|
||||
});
|
||||
|
||||
test("filters by key", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "dev-team" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team");
|
||||
});
|
||||
|
||||
test("returns empty array when no matches", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "nonexistent-term" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("matches partial strings", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "alpha" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
|
||||
});
|
||||
|
||||
test("trims whitespace from search query", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: " personal " },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
||||
import {
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
} from "./session-utils.fs.js";
|
||||
import type {
|
||||
GatewayAgentRow,
|
||||
GatewaySessionRow,
|
||||
@@ -32,6 +36,8 @@ import type {
|
||||
export {
|
||||
archiveFileOnDisk,
|
||||
capArrayByJsonBytes,
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
readSessionMessages,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
@@ -43,6 +49,52 @@ 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()) {
|
||||
const normalized = firstUserMessage.replace(/\s+/g, " ").trim();
|
||||
return truncateTitle(normalized, 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,9 +393,12 @@ export function listSessionsFromStore(params: {
|
||||
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const includeLastMessage = opts.includeLastMessage === 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) : "";
|
||||
const search = typeof opts.search === "string" ? opts.search.trim().toLowerCase() : "";
|
||||
const activeMinutes =
|
||||
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
|
||||
? Math.max(1, Math.floor(opts.activeMinutes))
|
||||
@@ -400,6 +455,7 @@ export function listSessionsFromStore(params: {
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
return {
|
||||
key,
|
||||
entry,
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
displayName,
|
||||
@@ -429,10 +485,17 @@ 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));
|
||||
|
||||
if (search) {
|
||||
sessions = sessions.filter((s) => {
|
||||
const fields = [s.displayName, s.label, s.subject, s.sessionId, s.key];
|
||||
return fields.some((f) => typeof f === "string" && f.toLowerCase().includes(search));
|
||||
});
|
||||
}
|
||||
|
||||
if (activeMinutes !== undefined) {
|
||||
const cutoff = now - activeMinutes * 60_000;
|
||||
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
|
||||
@@ -443,11 +506,36 @@ export function listSessionsFromStore(params: {
|
||||
sessions = sessions.slice(0, limit);
|
||||
}
|
||||
|
||||
const finalSessions: GatewaySessionRow[] = sessions.map((s) => {
|
||||
const { entry, ...rest } = s;
|
||||
let derivedTitle: string | undefined;
|
||||
let lastMessagePreview: string | undefined;
|
||||
if (entry?.sessionId) {
|
||||
if (includeDerivedTitles) {
|
||||
const firstUserMsg = readFirstUserMessageFromTranscript(
|
||||
entry.sessionId,
|
||||
storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
derivedTitle = deriveSessionTitle(entry, firstUserMsg);
|
||||
}
|
||||
if (includeLastMessage) {
|
||||
const lastMsg = readLastMessagePreviewFromTranscript(
|
||||
entry.sessionId,
|
||||
storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
if (lastMsg) lastMessagePreview = lastMsg;
|
||||
}
|
||||
}
|
||||
return { ...rest, derivedTitle, lastMessagePreview } satisfies GatewaySessionRow;
|
||||
});
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
count: finalSessions.length,
|
||||
defaults: getSessionDefaults(cfg),
|
||||
sessions,
|
||||
sessions: finalSessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export type GatewaySessionRow = {
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
derivedTitle?: string;
|
||||
lastMessagePreview?: string;
|
||||
channel?: string;
|
||||
subject?: string;
|
||||
groupChannel?: string;
|
||||
|
||||
Reference in New Issue
Block a user