Files
pi-agent-integrated/pi-web/lib/session-title.test.mjs

242 lines
7.9 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url);
const {
appendTitleRequestToTrailingUser,
buildSessionTitleAgentOptions,
generateSessionTitle,
parseGeneratedSessionTitle,
sanitizeTitleMessages,
} = await jiti.import("./session-title.ts");
function assistantMessage(text) {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "test",
provider: "test",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
test("cleans common session title response wrappers", () => {
assert.equal(parseGeneratedSessionTitle("标题:修复 SSE 重连。"), "修复 SSE 重连");
assert.equal(parseGeneratedSessionTitle('```json\n{"title":"整理 Session 文件夹"}\n```'), "整理 Session 文件夹");
assert.equal(parseGeneratedSessionTitle('"Improve worktree session grouping"'), "Improve worktree session grouping");
});
test("rejects responses without a usable title", () => {
assert.throws(() => parseGeneratedSessionTitle("```\n---\n```"), /usable session title/);
});
test("folds the title request into a trailing user message without mutating the source", () => {
const source = [
{ role: "assistant", content: [], timestamp: 1 },
{ role: "user", content: [{ type: "text", text: "Fix the running-session race" }], timestamp: 2 },
];
const prepared = appendTitleRequestToTrailingUser(source);
assert.deepEqual(prepared.map((message) => message.role), ["assistant", "user"]);
assert.match(prepared[1].content.at(-1).text, /Create a concise title/);
assert.equal(source[1].content.length, 1);
assert.notEqual(prepared[1], source[1]);
});
test("leaves a completed conversation unchanged before adding the title turn", () => {
const source = [
{ role: "user", content: "Fix it", timestamp: 1 },
{ role: "assistant", content: [], timestamp: 2 },
];
assert.equal(appendTitleRequestToTrailingUser(source), source);
});
test("waits for the source reply before sending the title prompt", async () => {
let sourceReplyFinished = false;
let providerRoles;
const sourceAgent = {
state: {
systemPrompt: "system",
model: { provider: "test", id: "test-model" },
thinkingLevel: "off",
tools: [],
messages: [{ role: "user", content: "Implement auto name", timestamp: 1 }],
},
waitForIdle: async () => {
sourceAgent.state.messages.push(assistantMessage("The implementation is complete"));
sourceReplyFinished = true;
},
convertToLlm: (messages) => messages,
streamFunction: (_model, context) => {
assert.equal(sourceReplyFinished, true);
providerRoles = context.messages.map((message) => message.role);
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: assistantMessage("Wait for Complete Agent Reply"),
});
});
return stream;
},
sessionId: "source-session-id",
};
const result = await generateSessionTitle({ agent: sourceAgent });
assert.equal(result.title, "Wait for Complete Agent Reply");
assert.deepEqual(providerRoles, ["user", "assistant", "user"]);
});
test("temporary title agent preserves the provider-facing prefix", async () => {
const model = { provider: "test", id: "cached-model" };
const messages = [{ role: "user", content: [{ type: "text", text: "Fix it" }] }];
const originalExecute = async () => ({ content: [], details: {} });
const tools = [{
name: "read",
label: "read",
description: "Read a file",
parameters: { type: "object", properties: {} },
execute: originalExecute,
}];
const convertToLlm = (value) => value;
const transformContext = async (value) => value;
const streamFunction = () => { throw new Error("not called"); };
const source = {
state: {
systemPrompt: "cached system prompt",
model,
thinkingLevel: "high",
tools,
messages,
},
convertToLlm,
transformContext,
streamFunction,
steeringMode: "one-at-a-time",
followUpMode: "one-at-a-time",
sessionId: "source-session-id",
transport: "sse",
toolExecution: "parallel",
};
const options = buildSessionTitleAgentOptions(source);
assert.equal(options.initialState.systemPrompt, source.state.systemPrompt);
assert.equal(options.initialState.model, model);
assert.equal(options.initialState.thinkingLevel, "high");
assert.equal(options.initialState.messages, messages);
assert.equal(options.convertToLlm, convertToLlm);
assert.equal(options.transformContext, transformContext);
assert.equal(options.streamFn, streamFunction);
assert.equal(options.sessionId, "source-session-id");
const withoutExecute = (tool) => Object.fromEntries(
Object.entries(tool).filter(([key]) => key !== "execute"),
);
assert.deepEqual(
options.initialState.tools.map(withoutExecute),
tools.map(withoutExecute),
);
assert.notEqual(options.initialState.tools[0].execute, originalExecute);
await assert.rejects(
options.initialState.tools[0].execute("call", {}, undefined, undefined),
/cannot be executed/,
);
});
test("keeps only tool calls with adjacent matching results", () => {
const messages = [
{ role: "user", content: "inspect both files", timestamp: 1 },
{
...assistantMessage("Inspecting files"),
content: [
{ type: "text", text: "Inspecting files" },
{ type: "toolCall", id: "call-complete", name: "read", arguments: { path: "a.txt" } },
{ type: "toolCall", id: "call-incomplete", name: "read", arguments: { path: "b.txt" } },
],
stopReason: "toolUse",
},
{
role: "toolResult",
toolCallId: "call-complete",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
isError: false,
timestamp: 2,
},
];
const sanitized = sanitizeTitleMessages(messages);
assert.deepEqual(
sanitized[1].content.filter((block) => block.type === "toolCall").map((block) => block.id),
["call-complete"],
);
assert.equal(sanitized[2], messages[2]);
assert.equal(messages[1].content.length, 3);
});
test("removes incomplete tool calls before invoking the title provider", async () => {
let providerMessages;
const sourceAgent = {
state: {
systemPrompt: "system",
model: { provider: "test", id: "test-model" },
thinkingLevel: "off",
tools: [],
messages: [
{ role: "user", content: "run a command", timestamp: 1 },
{
...assistantMessage(""),
content: [{
type: "toolCall",
id: "call-incomplete",
name: "bash",
arguments: { command: "sleep 10" },
}],
stopReason: "toolUse",
},
],
},
waitForIdle: async () => {},
convertToLlm: (messages) => messages,
streamFunction: (_model, context) => {
providerMessages = context.messages.map((message) => ({
role: message.role,
content: message.content,
}));
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: assistantMessage("Sanitized Tool Call History"),
});
});
return stream;
},
sessionId: "source-session-id",
};
const result = await generateSessionTitle({ agent: sourceAgent });
assert.equal(result.title, "Sanitized Tool Call History");
assert.deepEqual(providerMessages.map((message) => message.role), ["user"]);
assert.match(providerMessages[0].content, /Create a concise title/);
});