4681135423
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
/**
|
|
* Session title generation unit tests: prompt shape, sanitization rules, single-shot request
|
|
* driving (fake LLM), and Session.generateTitle's composition-layer wiring (no real requests sent).
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
assistantText,
|
|
emptyTokenCounts,
|
|
sanitizeTitle,
|
|
Session,
|
|
thinkingMessage,
|
|
tokenUsage,
|
|
userText,
|
|
} from "../src/index.js";
|
|
// Prompt/request internals are no longer exported via the barrel: imported directly from the internal module.
|
|
import { buildTitlePrompt, generateTitleWithLLM } from "../src/internal/session-title.js";
|
|
import type {
|
|
EnvironmentInterface,
|
|
LLMInterface,
|
|
LLMOutcome,
|
|
OmniMessage,
|
|
SessionMetaPayload,
|
|
} from "../src/index.js";
|
|
|
|
/** A fake LLM: yields the given messages and finishes with the outcome; records the prompt received. */
|
|
function fakeLLM(
|
|
outputs: OmniMessage[],
|
|
outcome: LLMOutcome = { status: "completed" },
|
|
seenPrompts: string[] = [],
|
|
): LLMInterface {
|
|
return {
|
|
async *streamGenerate({ newMessages }) {
|
|
const first = newMessages[0];
|
|
if (first) seenPrompts.push((first.payload as { text: string }).text);
|
|
for (const msg of outputs) yield msg;
|
|
return outcome;
|
|
},
|
|
};
|
|
}
|
|
|
|
const fakeEnvironment: EnvironmentInterface = {
|
|
listTools: async () => [],
|
|
// eslint-disable-next-line require-yield
|
|
executeTool: async function* () {
|
|
throw new Error("not used");
|
|
},
|
|
toolPermission: () => undefined,
|
|
};
|
|
|
|
const META: SessionMetaPayload = {
|
|
session_id: "session-title-1",
|
|
provider: "custom",
|
|
model_id: "m1",
|
|
model_context_window: 1000,
|
|
system_prompt: "sp",
|
|
tools: [],
|
|
agent_state: "/tmp/state",
|
|
workspace: "/tmp/w",
|
|
};
|
|
|
|
describe("session-title", () => {
|
|
it("generateTitleWithLLM: collects model text and usage, returns the sanitized result", async () => {
|
|
const seen: string[] = [];
|
|
const result = await generateTitleWithLLM(
|
|
fakeLLM(
|
|
[
|
|
thinkingMessage("thinking"), // thinking does not count
|
|
assistantText("「Tailwind theme setup」。"),
|
|
tokenUsage(emptyTokenCounts(), { cache_read: 1, cache_write: 2, output: 3, total: 6 }),
|
|
],
|
|
{ status: "completed" },
|
|
seen,
|
|
),
|
|
{ userText: "explain @theme", assistantText: "sure thing." },
|
|
);
|
|
expect(result.title).toBe("Tailwind theme setup");
|
|
expect(result.usage).toEqual({ cache_read: 1, cache_write: 2, output: 3, total: 6 });
|
|
expect(seen[0]).toBe(buildTitlePrompt("explain @theme", "sure thing."));
|
|
expect(seen[0]).toContain("SAME language");
|
|
});
|
|
|
|
it("sends no request when material is empty; title is null when the outcome is not completed (usage kept)", async () => {
|
|
const seen: string[] = [];
|
|
const empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), {
|
|
userText: " ",
|
|
assistantText: "a",
|
|
});
|
|
expect(empty).toEqual({ title: null, usage: null });
|
|
expect(seen).toHaveLength(0);
|
|
|
|
const failed = await generateTitleWithLLM(
|
|
fakeLLM(
|
|
[
|
|
assistantText("partial"),
|
|
tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }),
|
|
],
|
|
{ status: "failed", message: "401" },
|
|
),
|
|
{ userText: "u", assistantText: "a" },
|
|
);
|
|
expect(failed.title).toBeNull();
|
|
expect(failed.usage?.total).toBe(1);
|
|
});
|
|
|
|
it("still generates with empty assistant material (tool-only turn): uses only the user request, prompt omits the assistant section", async () => {
|
|
const seen: string[] = [];
|
|
const result = await generateTitleWithLLM(
|
|
fakeLLM([assistantText("Configure the Tailwind theme")], { status: "completed" }, seen),
|
|
{ userText: "help me configure @theme", assistantText: "" },
|
|
);
|
|
expect(result.title).toBe("Configure the Tailwind theme");
|
|
expect(seen[0]).toBe(buildTitlePrompt("help me configure @theme", ""));
|
|
expect(seen[0]).not.toContain("[Assistant]");
|
|
});
|
|
|
|
it("sanitizeTitle: strips quotes/punctuation to a fixed point, collapses whitespace, truncates overlong input, returns null for empty", () => {
|
|
expect(sanitizeTitle("“ Build config notes 。”")).toBe("Build config notes");
|
|
expect(sanitizeTitle("『Title』!")).toBe("Title");
|
|
expect(sanitizeTitle(" \n ")).toBeNull();
|
|
expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
|
|
// A leaked [use_skills] block is stripped from the model output.
|
|
expect(
|
|
sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page"),
|
|
).toBe("Build a landing page");
|
|
});
|
|
|
|
it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => {
|
|
const withFactory = new Session({
|
|
meta: META,
|
|
llm: fakeLLM([]),
|
|
environment: fakeEnvironment,
|
|
createBareLLM: () => fakeLLM([assistantText("Title A")]),
|
|
});
|
|
expect(
|
|
await withFactory.generateTitle({ material: { userText: "u", assistantText: "a" } }),
|
|
).toEqual({
|
|
title: "Title A",
|
|
usage: null,
|
|
});
|
|
|
|
const withoutFactory = new Session({
|
|
meta: META,
|
|
llm: fakeLLM([]),
|
|
environment: fakeEnvironment,
|
|
});
|
|
expect(await withoutFactory.generateTitle()).toEqual({
|
|
title: null,
|
|
usage: null,
|
|
});
|
|
});
|
|
|
|
it("Session.generateTitle: self-collects material (run gathers the user input and model text), none needed from the caller", async () => {
|
|
const seen: string[] = [];
|
|
const session = new Session({
|
|
meta: META,
|
|
llm: fakeLLM([thinkingMessage("thinking"), assistantText("answer body")]),
|
|
environment: fakeEnvironment,
|
|
createBareLLM: () => fakeLLM([assistantText("Title B")], { status: "completed" }, seen),
|
|
});
|
|
for await (const _ of session.run([userText("user question")])) {
|
|
void _; // Drains the output stream; once run finishes, the material is settled
|
|
}
|
|
const res = await session.generateTitle();
|
|
expect(res.title).toBe("Title B");
|
|
// Material = the first Task's user text + model text (thinking does not count), matching
|
|
// buildTitlePrompt's shape.
|
|
expect(seen[0]).toBe(buildTitlePrompt("user question", "answer body"));
|
|
// Anti-CoT shape: an explicit no-thinking rule, and the prompt ends with an empty think
|
|
// block so reasoning models treat their thinking phase as already closed.
|
|
expect(seen[0]).toContain("do not think aloud");
|
|
expect(seen[0]!.endsWith("<think></think>")).toBe(true);
|
|
|
|
// No request is sent when no material has been collected (run was never called).
|
|
const idle = new Session({
|
|
meta: META,
|
|
llm: fakeLLM([]),
|
|
environment: fakeEnvironment,
|
|
createBareLLM: () => fakeLLM([assistantText("must not be produced")]),
|
|
});
|
|
expect(await idle.generateTitle()).toEqual({ title: null, usage: null });
|
|
});
|
|
});
|