1068 lines
32 KiB
TypeScript
1068 lines
32 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { buildPrompt, buildResumePrompt } from "../prompt-builder";
|
|
|
|
describe("buildPrompt", () => {
|
|
it("returns empty string for empty messages array", () => {
|
|
const context = { messages: [] } as unknown as any;
|
|
expect(buildPrompt(context)).toBe("");
|
|
});
|
|
|
|
it("produces 'USER:\\n{text}' for a single user text message", () => {
|
|
const context = {
|
|
messages: [{ role: "user", content: "Hello world" }],
|
|
} as unknown as any;
|
|
expect(buildPrompt(context)).toBe("USER:\nHello world");
|
|
});
|
|
|
|
it("produces 'ASSISTANT:\\n{text}' for a single assistant text message", () => {
|
|
const context = {
|
|
messages: [{ role: "assistant", content: "Hi there" }],
|
|
} as unknown as any;
|
|
expect(buildPrompt(context)).toBe("ASSISTANT:\nHi there");
|
|
});
|
|
|
|
it("produces 'TOOL RESULT (historical {claudeName}):\\n{content}' for a tool result message", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "toolResult",
|
|
content: "file contents here",
|
|
toolName: "read",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
// Pi tool name "read" should be mapped to Claude name "Read" in the label
|
|
expect(buildPrompt(context)).toBe(
|
|
"TOOL RESULT (historical Read):\nfile contents here",
|
|
);
|
|
});
|
|
|
|
it("produces correctly ordered labeled blocks for mixed conversation", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "What is in file.ts?" },
|
|
{ role: "assistant", content: "Let me read that file." },
|
|
{
|
|
role: "toolResult",
|
|
content: "export const x = 1;",
|
|
toolName: "read",
|
|
},
|
|
{ role: "user", content: "Now explain it." },
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
const expected = [
|
|
"USER:",
|
|
"What is in file.ts?",
|
|
"ASSISTANT:",
|
|
"Let me read that file.",
|
|
"TOOL RESULT (historical Read):",
|
|
"export const x = 1;",
|
|
"USER:",
|
|
"Now explain it.",
|
|
].join("\n");
|
|
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it("extracts text from array content blocks in user messages", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "First block" },
|
|
{ type: "text", text: "Second block" },
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toBe("USER:\nFirst block\nSecond block");
|
|
});
|
|
|
|
it("serializes assistant mixed content (text + thinking + toolCall) with Claude name mapping", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: "I will help you." },
|
|
{ type: "thinking", thinking: "Let me think about this..." },
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/file.ts" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("ASSISTANT:");
|
|
expect(result).toContain("I will help you.");
|
|
// Thinking content is skipped in prompt replay (internal reasoning)
|
|
expect(result).not.toContain("Let me think about this...");
|
|
// Tool name should be mapped from pi "read" to Claude "Read"
|
|
// Arg "path" should be mapped from pi format to Claude "file_path"
|
|
expect(result).toContain(
|
|
'Historical tool call (non-executable): Read args={"file_path":"/file.ts"}',
|
|
);
|
|
});
|
|
|
|
it("inserts placeholder text for image blocks in non-final user messages", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Look at this image" },
|
|
{ type: "image", data: "abc", mimeType: "image/png" },
|
|
],
|
|
},
|
|
{ role: "assistant", content: "I see." },
|
|
{ role: "user", content: "Now explain it." },
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Non-final user message should have placeholder
|
|
expect(result).toContain(
|
|
"[An image was shared here but could not be included]",
|
|
);
|
|
// Console.warn should be called once with image count
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy.mock.calls[0][0]).toContain("1 image(s)");
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("handles toolCall with no arguments (maps pi name to Claude name)", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "bash",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Pi "bash" maps to Claude "Bash"
|
|
expect(result).toContain(
|
|
"Historical tool call (non-executable): Bash args={}",
|
|
);
|
|
});
|
|
|
|
it("handles tool result with array content blocks", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "toolResult",
|
|
content: [
|
|
{ type: "text", text: "line 1" },
|
|
{ type: "text", text: "line 2" },
|
|
],
|
|
toolName: "bash",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toBe("TOOL RESULT (historical Bash):\nline 1\nline 2");
|
|
});
|
|
|
|
describe("tool name and argument reverse mapping", () => {
|
|
it("maps pi tool name to Claude name in toolCall serialization", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/foo" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("Read");
|
|
expect(result).toContain('"file_path":"/foo"');
|
|
});
|
|
|
|
it("translates pi arguments to Claude format for edit tool", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "edit",
|
|
arguments: { path: "/foo.ts", oldText: "old", newText: "new" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("Edit");
|
|
expect(result).toContain('"file_path":"/foo.ts"');
|
|
expect(result).toContain('"old_string":"old"');
|
|
expect(result).toContain('"new_string":"new"');
|
|
});
|
|
|
|
it("maps pi tool name to Claude name in tool result label", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "toolResult",
|
|
content: "result text",
|
|
toolName: "read",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("TOOL RESULT (historical Read):");
|
|
});
|
|
|
|
it("prefixes custom (non-built-in) tool names with MCP prefix", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "custom_tool",
|
|
arguments: { key: "value" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Custom tool uses plain name format (not MCP-prefixed to avoid Claude re-calling)
|
|
expect(result).toContain("[Used custom_tool tool with args:");
|
|
expect(result).not.toContain("mcp__custom-tools__");
|
|
expect(result).toContain('"key":"value"');
|
|
});
|
|
|
|
it("handles toolCall with string arguments (raw unparsed)", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: "raw string args",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// String arguments should be serialized as JSON string
|
|
expect(result).toContain('Read args="raw string args"');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("image passthrough (HIST-02)", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("single user message with text and image returns ContentBlock[]", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Look at this" },
|
|
{ type: "image", data: "base64data", mimeType: "image/png" },
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Should return an array (not string) with text + image blocks
|
|
expect(Array.isArray(result)).toBe(true);
|
|
const arr = result as any[];
|
|
expect(arr).toContainEqual({ type: "text", text: "Look at this" });
|
|
expect(arr).toContainEqual({
|
|
type: "image",
|
|
source: { type: "base64", media_type: "image/png", data: "base64data" },
|
|
});
|
|
});
|
|
|
|
it("multi-turn with images only in final user message returns ContentBlock[]", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "Hello" },
|
|
{ role: "assistant", content: "Hi there" },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Check this" },
|
|
{ type: "image", data: "imgdata", mimeType: "image/jpeg" },
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(Array.isArray(result)).toBe(true);
|
|
const arr = result as any[];
|
|
// First elements should be text blocks for history
|
|
const textBlocks = arr.filter((b: any) => b.type === "text");
|
|
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
|
|
// History text should contain the prior messages
|
|
const historyText = textBlocks[0].text;
|
|
expect(historyText).toContain("USER:");
|
|
expect(historyText).toContain("Hello");
|
|
expect(historyText).toContain("ASSISTANT:");
|
|
expect(historyText).toContain("Hi there");
|
|
// Final user message text block
|
|
expect(arr).toContainEqual({ type: "text", text: "Check this" });
|
|
// Image block in Anthropic format
|
|
expect(arr).toContainEqual({
|
|
type: "image",
|
|
source: { type: "base64", media_type: "image/jpeg", data: "imgdata" },
|
|
});
|
|
});
|
|
|
|
it("multi-turn with images in non-final user message uses placeholder and returns string", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "See this" },
|
|
{ type: "image", data: "imgdata", mimeType: "image/png" },
|
|
],
|
|
},
|
|
{ role: "assistant", content: "Noted." },
|
|
{ role: "user", content: "What do you think?" },
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// No images in final message -> returns string
|
|
expect(typeof result).toBe("string");
|
|
// Non-final user message has placeholder
|
|
expect(result).toContain(
|
|
"[An image was shared here but could not be included]",
|
|
);
|
|
// Console.warn called once with image count
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy.mock.calls[0][0]).toContain("1 image(s)");
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("multi-turn with images in both non-final and final user messages", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "First image" },
|
|
{ type: "image", data: "img1", mimeType: "image/png" },
|
|
],
|
|
},
|
|
{ role: "assistant", content: "Got it." },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Second image" },
|
|
{ type: "image", data: "img2", mimeType: "image/jpeg" },
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Final user message has images -> returns ContentBlock[]
|
|
expect(Array.isArray(result)).toBe(true);
|
|
const arr = result as any[];
|
|
// Non-final user message should have placeholder in the history text
|
|
const textBlocks = arr.filter((b: any) => b.type === "text");
|
|
const historyText = textBlocks[0].text;
|
|
expect(historyText).toContain(
|
|
"[An image was shared here but could not be included]",
|
|
);
|
|
// Final user message image translated to Anthropic format
|
|
expect(arr).toContainEqual({
|
|
type: "image",
|
|
source: { type: "base64", media_type: "image/jpeg", data: "img2" },
|
|
});
|
|
// Console.warn called with count of placeholder images (1 placeholder)
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
expect(warnSpy.mock.calls[0][0]).toContain("1 image(s)");
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("no images returns string (backward compatible)", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "Hello" },
|
|
{ role: "assistant", content: "Hi" },
|
|
{ role: "user", content: "How are you?" },
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(typeof result).toBe("string");
|
|
expect(result).toContain("USER:");
|
|
expect(result).toContain("Hello");
|
|
});
|
|
|
|
it("image block without data/mimeType uses placeholder", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Broken image" },
|
|
{ type: "image" }, // Missing data and mimeType
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Invalid image in final message should fall back to placeholder -> string
|
|
// The single user message IS the final message, but image is invalid
|
|
// so it falls back to placeholder text block
|
|
if (Array.isArray(result)) {
|
|
// If still returns array, image should be a text placeholder
|
|
const hasImageBlock = result.some((b: any) => b.type === "image");
|
|
expect(hasImageBlock).toBe(false);
|
|
} else {
|
|
expect(result).toContain(
|
|
"[An image was shared here but could not be included]",
|
|
);
|
|
}
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("buildPrompt with custom tool result prompt (images in earlier messages) returns string", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Run this" },
|
|
{ type: "image", data: "imgdata", mimeType: "image/png" },
|
|
],
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "deploy",
|
|
arguments: { target: "prod" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
content: "Deploy succeeded",
|
|
toolName: "deploy",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Custom tool result prompt always returns string
|
|
expect(typeof result).toBe("string");
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("tool result image handling", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("tool result with image block triggers ContentBlock[] path with image passthrough", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "explain this image C:\\temp\\screenshot.png",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "C:\\temp\\screenshot.png" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
content: [
|
|
{ type: "text", text: "Read image file [image/png]" },
|
|
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
|
],
|
|
toolName: "read",
|
|
},
|
|
{ role: "user", content: "what does that code do?" },
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Tool result has image -> should return ContentBlock[] so Claude sees the image
|
|
expect(Array.isArray(result)).toBe(true);
|
|
const arr = result as any[];
|
|
// Should contain the translated image block from tool result
|
|
expect(arr).toContainEqual({
|
|
type: "image",
|
|
source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" },
|
|
});
|
|
});
|
|
|
|
it("tool result image in string path gets placeholder text", () => {
|
|
const _warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "read that file" },
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "photo.jpg" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
content: [
|
|
{ type: "text", text: "Read image file" },
|
|
{ type: "image", data: "base64jpg", mimeType: "image/jpeg" },
|
|
],
|
|
toolName: "read",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// No images in final user message, no images in user content at all
|
|
// But tool result has image -> should still handle it
|
|
// At minimum: placeholder text so Claude knows image existed
|
|
if (typeof result === "string") {
|
|
expect(result).toContain(
|
|
"[An image was shared here but could not be included]",
|
|
);
|
|
} else {
|
|
// Or ContentBlock[] with actual image
|
|
expect(result).toContainEqual(expect.objectContaining({ type: "image" }));
|
|
}
|
|
});
|
|
|
|
it("tool result with only text blocks works as before", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "read the file" },
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", name: "read", arguments: { path: "test.txt" } },
|
|
],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
content: [{ type: "text", text: "file contents here" }],
|
|
toolName: "read",
|
|
},
|
|
{ role: "user", content: "summarize it" },
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// No images anywhere -> should return string
|
|
expect(typeof result).toBe("string");
|
|
expect(result).toContain("file contents here");
|
|
});
|
|
});
|
|
|
|
describe("custom tool history replay", () => {
|
|
it("toolCall with custom tool name 'deploy' uses plain format (no MCP prefix)", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "deploy",
|
|
arguments: { target: "prod" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("[Used deploy tool with args:");
|
|
expect(result).toContain('"target":"prod"');
|
|
expect(result).not.toContain("mcp__custom-tools__");
|
|
});
|
|
|
|
it("toolCall with built-in name 'read' still produces 'Read' (not MCP-prefixed)", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/foo" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("Read");
|
|
expect(result).not.toContain("mcp__custom-tools__read");
|
|
});
|
|
|
|
it("toolResult for custom tool 'deploy' uses plain name (no MCP prefix)", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "toolResult",
|
|
content: "deployment succeeded",
|
|
toolName: "deploy",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("TOOL RESULT (deploy):");
|
|
expect(result).not.toContain("mcp__custom-tools__");
|
|
});
|
|
|
|
it("toolResult for built-in 'read' still produces TOOL RESULT with Claude name", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "toolResult",
|
|
content: "file contents",
|
|
toolName: "read",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
expect(result).toContain("TOOL RESULT (historical Read):");
|
|
expect(result).not.toContain("mcp__custom-tools__");
|
|
});
|
|
|
|
it("custom tool arguments pass through without translation", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "deploy",
|
|
arguments: { target: "prod", force: true },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
|
|
const result = buildPrompt(context);
|
|
// Custom tool args should pass through unchanged (no renames)
|
|
expect(result).toContain('"target":"prod"');
|
|
expect(result).toContain('"force":true');
|
|
});
|
|
});
|
|
|
|
describe("buildSystemPrompt", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns context systemPrompt when no AGENTS.md found", async () => {
|
|
// Mock fs to not find any AGENTS.md
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: () => false,
|
|
readFileSync: () => "",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "You are a helpful assistant.",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project/path");
|
|
expect(result).toContain("You are a helpful assistant.");
|
|
});
|
|
|
|
it("appends AGENTS.md content when found", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => path.endsWith("AGENTS.md"),
|
|
readFileSync: () => "# Agent Instructions\nDo things carefully.",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "Base prompt.",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
expect(result).toContain("Base prompt.");
|
|
expect(result).toContain("Agent Instructions");
|
|
expect(result).toContain("Do things carefully.");
|
|
});
|
|
|
|
it("sanitizes .pi references to .claude in AGENTS.md content", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => path.endsWith("AGENTS.md"),
|
|
readFileSync: () => "Check ~/.pi/config and .pi/settings for details.",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = { systemPrompt: "Base.", messages: [] } as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
expect(result).toContain("~/.claude");
|
|
expect(result).toContain(".claude/settings");
|
|
expect(result).not.toContain(".pi/");
|
|
});
|
|
|
|
it("returns empty string when no systemPrompt and no AGENTS.md", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: () => false,
|
|
readFileSync: () => "",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = { messages: [] } as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
expect(result).toBe("");
|
|
});
|
|
|
|
it("walks up directories to find AGENTS.md in parent", async () => {
|
|
// Only the parent directory's AGENTS.md exists, not the cwd
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => {
|
|
// Only parent path has AGENTS.md
|
|
if (path.includes("parent") && path.endsWith("AGENTS.md")) return true;
|
|
return false;
|
|
},
|
|
readFileSync: () => "# Parent AGENTS.md\nInstructions from parent.",
|
|
}));
|
|
vi.doMock("node:path", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("node:path")>("node:path");
|
|
return {
|
|
...actual,
|
|
resolve: (p: string) => p,
|
|
join: (...args: string[]) => args.join("/"),
|
|
dirname: (p: string) => {
|
|
// Simulate walking up: /a/b/parent/child -> /a/b/parent -> /a/b -> etc.
|
|
const parts = p.split("/").filter(Boolean);
|
|
if (parts.length <= 1) return p; // root
|
|
return "/" + parts.slice(0, -1).join("/");
|
|
},
|
|
};
|
|
});
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "Base.",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/a/b/parent/child");
|
|
expect(result).toContain("Parent AGENTS.md");
|
|
expect(result).toContain("Instructions from parent.");
|
|
});
|
|
|
|
it("sanitizes empty AGENTS.md content gracefully", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => path.endsWith("AGENTS.md"),
|
|
readFileSync: () => "",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "Base prompt.",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
// Empty AGENTS.md content should just produce the base prompt
|
|
expect(result).toContain("Base prompt.");
|
|
});
|
|
|
|
it("sanitizes AGENTS.md with only whitespace content", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => path.endsWith("AGENTS.md"),
|
|
readFileSync: () => " \n\n \t \n",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "Base.",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
expect(result).toContain("Base.");
|
|
});
|
|
|
|
it("sanitizes AGENTS.md with special regex characters", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => path.endsWith("AGENTS.md"),
|
|
readFileSync: () =>
|
|
"Config at ~/.pi/settings.json and .pi/rules/*.md files.",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
expect(result).toContain("~/.claude/settings.json");
|
|
expect(result).toContain(".claude/rules/*.md");
|
|
expect(result).not.toContain(".pi/");
|
|
});
|
|
|
|
it("handles readFileSync error gracefully (skip silently)", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: (path: string) => path.endsWith("AGENTS.md"),
|
|
readFileSync: () => {
|
|
throw new Error("EACCES: permission denied");
|
|
},
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "Base prompt.",
|
|
messages: [],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
// Should still return the base prompt despite read error
|
|
expect(result).toContain("Base prompt.");
|
|
// Should not include any AGENTS.md content
|
|
expect(result).not.toContain("EACCES");
|
|
});
|
|
|
|
it("appends tool result instruction when messages contain toolResult", async () => {
|
|
vi.doMock("node:fs", () => ({
|
|
existsSync: () => false,
|
|
readFileSync: () => "",
|
|
}));
|
|
|
|
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
|
|
const context = {
|
|
systemPrompt: "Base prompt.",
|
|
messages: [
|
|
{ role: "user", content: "read the file" },
|
|
{
|
|
role: "toolResult",
|
|
content: "file contents here",
|
|
toolName: "read",
|
|
},
|
|
],
|
|
} as unknown as any;
|
|
const result = bsp(context, "/some/project");
|
|
expect(result).toContain("IMPORTANT:");
|
|
expect(result).toContain("tool results");
|
|
});
|
|
});
|
|
|
|
describe("buildResumePrompt", () => {
|
|
it("returns empty string for empty messages array", () => {
|
|
expect(buildResumePrompt({ messages: [] })).toBe("");
|
|
});
|
|
|
|
it("returns just the user message text for a single user message", () => {
|
|
const context = {
|
|
messages: [{ role: "user", content: "Hello world" }],
|
|
};
|
|
expect(buildResumePrompt(context)).toBe("Hello world");
|
|
});
|
|
|
|
it("extracts only the last user message from a multi-turn conversation", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "First question" },
|
|
{ role: "assistant", content: "First answer" },
|
|
{ role: "user", content: "Follow-up question" },
|
|
],
|
|
};
|
|
expect(buildResumePrompt(context)).toBe("Follow-up question");
|
|
});
|
|
|
|
it("includes tool results preceding the final user message", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "Read a file" },
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/foo.ts" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
toolName: "read",
|
|
content: "file contents here",
|
|
},
|
|
{ role: "user", content: "Now explain it" },
|
|
],
|
|
};
|
|
const result = buildResumePrompt(context) as string;
|
|
expect(result).toContain("TOOL RESULT (historical Read):");
|
|
expect(result).toContain("file contents here");
|
|
expect(result).toContain("Now explain it");
|
|
});
|
|
|
|
it("includes multiple tool results preceding the final user message", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "Read two files" },
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/a.ts" },
|
|
},
|
|
{
|
|
type: "toolCall",
|
|
name: "read",
|
|
arguments: { path: "/b.ts" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
toolName: "read",
|
|
content: "contents of a",
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
toolName: "read",
|
|
content: "contents of b",
|
|
},
|
|
{ role: "user", content: "Compare them" },
|
|
],
|
|
};
|
|
const result = buildResumePrompt(context) as string;
|
|
expect(result).toContain("contents of a");
|
|
expect(result).toContain("contents of b");
|
|
expect(result).toContain("Compare them");
|
|
});
|
|
|
|
it("handles custom tool results with plain name format", () => {
|
|
const context = {
|
|
messages: [
|
|
{ role: "user", content: "Deploy" },
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", name: "deploy", arguments: {} }],
|
|
},
|
|
{
|
|
role: "toolResult",
|
|
toolName: "deploy",
|
|
content: "Deployed successfully",
|
|
},
|
|
{ role: "user", content: "Check status" },
|
|
],
|
|
};
|
|
const result = buildResumePrompt(context) as string;
|
|
expect(result).toContain("TOOL RESULT (deploy):");
|
|
expect(result).toContain("Deployed successfully");
|
|
expect(result).toContain("Check status");
|
|
});
|
|
|
|
it("returns empty string when no user message found", () => {
|
|
const context = {
|
|
messages: [{ role: "assistant", content: "Hello" }],
|
|
};
|
|
expect(buildResumePrompt(context)).toBe("");
|
|
});
|
|
|
|
it("handles content blocks array for user message", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "Hello from blocks" }],
|
|
},
|
|
],
|
|
};
|
|
expect(buildResumePrompt(context)).toBe("Hello from blocks");
|
|
});
|
|
|
|
it("handles images in the final user message by returning ContentBlock[]", () => {
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Look at this" },
|
|
{
|
|
type: "image",
|
|
data: "abc123",
|
|
mimeType: "image/png",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = buildResumePrompt(context);
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect((result as any[]).length).toBe(2);
|
|
expect((result as any[])[0].type).toBe("text");
|
|
expect((result as any[])[1].type).toBe("image");
|
|
});
|
|
});
|