test: add coverage for readLastMessagePreviewFromTranscript
Also add CHANGELOG entry and perf docs for session list flags.
This commit is contained in:
committed by
Peter Steinberger
parent
f2666d2092
commit
36719690a2
@@ -63,6 +63,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
|
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
|
|||||||
@@ -3,84 +3,92 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||||
|
|
||||||
export const SessionsListParamsSchema = Type.Object(
|
export const SessionsListParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
/**
|
||||||
includeLastMessage: Type.Optional(Type.Boolean()),
|
* Read first 8KB of each session transcript to derive title from first user message.
|
||||||
label: Type.Optional(SessionLabelString),
|
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
*/
|
||||||
agentId: Type.Optional(NonEmptyString),
|
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
||||||
search: Type.Optional(Type.String()),
|
/**
|
||||||
},
|
* Read last 16KB of each session transcript to extract most recent message preview.
|
||||||
{ additionalProperties: false },
|
* 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(
|
export const SessionsResolveParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: Type.Optional(NonEmptyString),
|
key: Type.Optional(NonEmptyString),
|
||||||
label: Type.Optional(SessionLabelString),
|
label: Type.Optional(SessionLabelString),
|
||||||
agentId: Type.Optional(NonEmptyString),
|
agentId: Type.Optional(NonEmptyString),
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsPatchParamsSchema = Type.Object(
|
export const SessionsPatchParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
responseUsage: Type.Optional(
|
responseUsage: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("off"),
|
Type.Literal("off"),
|
||||||
Type.Literal("tokens"),
|
Type.Literal("tokens"),
|
||||||
Type.Literal("full"),
|
Type.Literal("full"),
|
||||||
// Backward compat with older clients/stores.
|
// Backward compat with older clients/stores.
|
||||||
Type.Literal("on"),
|
Type.Literal("on"),
|
||||||
Type.Null(),
|
Type.Null(),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
sendPolicy: Type.Optional(
|
sendPolicy: Type.Optional(
|
||||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||||
),
|
),
|
||||||
groupActivation: Type.Optional(
|
groupActivation: Type.Optional(
|
||||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsResetParamsSchema = Type.Object(
|
export const SessionsResetParamsSchema = Type.Object(
|
||||||
{ key: NonEmptyString },
|
{ key: NonEmptyString },
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsDeleteParamsSchema = Type.Object(
|
export const SessionsDeleteParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsCompactParamsSchema = Type.Object(
|
export const SessionsCompactParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,135 +2,309 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js";
|
import {
|
||||||
|
readFirstUserMessageFromTranscript,
|
||||||
|
readLastMessagePreviewFromTranscript,
|
||||||
|
} from "./session-utils.fs.js";
|
||||||
|
|
||||||
describe("readFirstUserMessageFromTranscript", () => {
|
describe("readFirstUserMessageFromTranscript", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storePath: string;
|
let storePath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
||||||
storePath = path.join(tmpDir, "sessions.json");
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when transcript file does not exist", () => {
|
test("returns null when transcript file does not exist", () => {
|
||||||
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns first user message from transcript with string content", () => {
|
test("returns first user message from transcript with string content", () => {
|
||||||
const sessionId = "test-session-1";
|
const sessionId = "test-session-1";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Hello world");
|
expect(result).toBe("Hello world");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns first user message from transcript with array content", () => {
|
test("returns first user message from transcript with array content", () => {
|
||||||
const sessionId = "test-session-2";
|
const sessionId = "test-session-2";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: {
|
message: {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "Array message content" }],
|
content: [{ type: "text", text: "Array message content" }],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Array message content");
|
expect(result).toBe("Array message content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips non-user messages to find first user message", () => {
|
test("skips non-user messages to find first user message", () => {
|
||||||
const sessionId = "test-session-3";
|
const sessionId = "test-session-3";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
||||||
JSON.stringify({ message: { role: "user", content: "First user question" } }),
|
JSON.stringify({ message: { role: "user", content: "First user question" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("First user question");
|
expect(result).toBe("First user question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when no user messages exist", () => {
|
test("returns null when no user messages exist", () => {
|
||||||
const sessionId = "test-session-4";
|
const sessionId = "test-session-4";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles malformed JSON lines gracefully", () => {
|
test("handles malformed JSON lines gracefully", () => {
|
||||||
const sessionId = "test-session-5";
|
const sessionId = "test-session-5";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
"not valid json",
|
"not valid json",
|
||||||
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
|
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Valid message");
|
expect(result).toBe("Valid message");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses sessionFile parameter when provided", () => {
|
test("uses sessionFile parameter when provided", () => {
|
||||||
const sessionId = "test-session-6";
|
const sessionId = "test-session-6";
|
||||||
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
|
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
|
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
|
||||||
expect(result).toBe("Custom file message");
|
expect(result).toBe("Custom file message");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("trims whitespace from message content", () => {
|
test("trims whitespace from message content", () => {
|
||||||
const sessionId = "test-session-7";
|
const sessionId = "test-session-7";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
|
||||||
JSON.stringify({ message: { role: "user", content: " Padded message " } }),
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Padded message");
|
expect(result).toBe("Padded message");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for empty content", () => {
|
test("returns null for empty content", () => {
|
||||||
const sessionId = "test-session-8";
|
const sessionId = "test-session-8";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ message: { role: "user", content: "" } }),
|
JSON.stringify({ message: { role: "user", content: "" } }),
|
||||||
JSON.stringify({ message: { role: "user", content: "Second message" } }),
|
JSON.stringify({ message: { role: "user", content: "Second message" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Second message");
|
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("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: 你好世界 🌍");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
|
|||||||
/**
|
/**
|
||||||
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
|
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
|
||||||
*/
|
*/
|
||||||
export function prepareSearchItems<
|
export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>(
|
||||||
T extends { label?: string; description?: string; searchText?: string },
|
items: T[],
|
||||||
>(items: T[]): (T & { searchTextLower: string })[] {
|
): (T & { searchTextLower: string })[] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (item.label) parts.push(item.label);
|
if (item.label) parts.push(item.label);
|
||||||
|
|||||||
Reference in New Issue
Block a user