/** * TASK-0122 — unit tests for the server-side context assembler (prompts.ts). * * Covers the spec's unit acceptance criteria: * - caps: at most 40 blocks of page content / 20 sample database rows; * - HTML is stripped (no raw block HTML reaches the prompt); * - permission drop: a resource the requester can't VIEW is excluded from * the prompt (content never included) and a note is set (IDOR guard: * cross-workspace IDs drop silently too, because lookups are scoped); * - record value rendering (title/select/checkbox/number/date/person/url). */ import { describe, expect, it, vi } from "vitest"; import { actorLevel } from "../../../lib/authorize.js"; import { assembleContext, BASE_SYSTEM_PROMPT, MAX_CONTEXT_BLOCKS, MAX_CONTEXT_ROWS, recordValueText, stripHtml, } from "../prompts.js"; // TASK-0127: prompts.ts resolves visibility through the ACTOR-aware // `actorLevel` (no HTTP context in unit tests → the user path, same result // as the raw `effectiveLevel`). Stub the function the assembler actually // calls. vi.mock("../../../lib/authorize.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, actorLevel: vi.fn() }; }); const lvl = actorLevel as unknown as ReturnType; /** A mock Prisma carrying just the delegates assembleContext touches. */ function mockPrisma(state: { page?: { id: string; workspaceId: string; title: string } | null; blocks?: Array<{ id: string; type: string; content: unknown }>; database?: { id: string; workspaceId: string; title: string } | null; dbProps?: Array<{ name: string; type: string; config: unknown }>; records?: Array<{ position: string; properties: Array<{ property: { name: string; type: string; config: unknown }; value: unknown }> }>; }) { return { page: { findFirst: vi.fn(async () => (state.page ? { id: state.page.id, title: state.page.title } : null)), }, block: { // Honors `take` like the real delegate (the DB-side cap the // assembler relies on — the spec's "up to 40 blocks"). findMany: vi.fn(async (args: { take?: number }) => { const rows = state.blocks ?? []; return (args.take ? rows.slice(0, args.take) : rows).map((b) => ({ id: b.id, type: b.type, content: b.content, position: b.id })); }), }, database: { findFirst: vi.fn(async () => (state.database ? { id: state.database.id, title: state.database.title } : null)), }, databaseProperty: { findMany: vi.fn(async () => state.dbProps ?? []), }, databaseRecord: { findMany: vi.fn(async (args: { take?: number }) => { const rows = state.records ?? []; return args.take ? rows.slice(0, args.take) : rows; }), }, } as never; } const ws = "ws1"; const user = "u1"; describe("stripHtml", () => { it("removes tags, decodes entities, collapses whitespace", () => { expect(stripHtml("

Hello world

")).toBe("Hello world"); expect(stripHtml("a&b<c>d"e"")).toBe('a&bd"e"'); }); }); describe("recordValueText", () => { it("renders stored per-type JSON values to display text", () => { expect(recordValueText("TITLE", { text: "Hello x", marks: [] })).toBe("Hello x"); expect(recordValueText("NUMBER", { value: 42 })).toBe("42"); expect(recordValueText("SELECT", { optionId: 1 }, ["a", "b", "c"])).toBe("b"); expect(recordValueText("MULTI_SELECT", { optionIds: [0, 2] }, ["a", "b", "c"])).toBe("a, c"); expect(recordValueText("CHECKBOX", { value: true })).toBe("Yes"); expect(recordValueText("CHECKBOX", { value: false })).toBe("No"); expect(recordValueText("DATE", { start: "2026-01-01", end: null })).toBe("2026-01-01"); expect(recordValueText("PERSON", { userIds: ["user_abcdefghij"] })).toBe("@efghij"); expect(recordValueText("URL", { value: "https://x.dev", title: null })).toBe("https://x.dev"); expect(recordValueText("SELECT", null)).toBe(""); }); }); describe("assembleContext — page", () => { it("includes the page title + block text (plain, HTML-stripped)", async () => { lvl.mockResolvedValue("VIEW"); const prisma = mockPrisma({ page: { id: "p1", workspaceId: ws, title: "My Plan" }, blocks: [ { id: "b1", type: "PARAGRAPH", content: { text: "First block" } }, { id: "b2", type: "HEADING_1", content: { text: "Section" } }, ], }); const out = await assembleContext(prisma, ws, user, { pageId: "p1" }); expect(out.dropped).toEqual([]); expect(out.systemPrompt).toContain("# Page: My Plan"); expect(out.systemPrompt).toContain("First block"); // HTML stripped expect(out.systemPrompt).not.toContain(""); expect(out.note).toBeUndefined(); }); it("caps content at 40 blocks (truncation marker)", async () => { lvl.mockResolvedValue("VIEW"); const blocks = Array.from({ length: 60 }, (_, i) => ({ id: `b${i}`, type: "PARAGRAPH", content: { text: `block ${i}` }, })); const prisma = mockPrisma({ page: { id: "p1", workspaceId: ws, title: "Big" }, blocks, }); const out = await assembleContext(prisma, ws, user, { pageId: "p1" }); expect(out.systemPrompt).toContain("block 0"); expect(out.systemPrompt).toContain(`block ${MAX_CONTEXT_BLOCKS - 1}`); expect(out.systemPrompt).not.toContain(`block ${MAX_CONTEXT_BLOCKS}\n`); expect(out.systemPrompt).toContain(`truncated at ${MAX_CONTEXT_BLOCKS} blocks`); }); it("surfaces a selected block first (blockId)", async () => { lvl.mockResolvedValue("VIEW"); const prisma = mockPrisma({ page: { id: "p1", workspaceId: ws, title: "T" }, blocks: [ { id: "b1", type: "PARAGRAPH", content: { text: "alpha" } }, { id: "b2", type: "PARAGRAPH", content: { text: "the selected one" } }, ], }); const out = await assembleContext(prisma, ws, user, { pageId: "p1", blockId: "b2" }); const selectedIdx = out.systemPrompt!.indexOf("## Selected block"); expect(selectedIdx).toBeGreaterThan(-1); expect(out.systemPrompt!.slice(selectedIdx, selectedIdx + 60)).toContain("the selected one"); }); it("drops the page (content excluded) when the requester lacks VIEW", async () => { lvl.mockResolvedValue("NONE"); const prisma = mockPrisma({ page: { id: "p1", workspaceId: ws, title: "Secret" }, blocks: [{ id: "b1", type: "PARAGRAPH", content: { text: "top-secret-content" } }], }); const out = await assembleContext(prisma, ws, user, { pageId: "p1" }); expect(out.dropped).toEqual(["page"]); expect(out.note).toMatch(/unavailable/); // The content must NOT leak into the prompt (even as a note). expect(out.systemPrompt ?? "").not.toContain("top-secret-content"); expect(out.note).not.toContain("top-secret-content"); // No prompt body at all for a fully-dropped context. expect(out.systemPrompt).toBeUndefined(); }); it("drops silently (note set) when the page is missing / cross-workspace", async () => { lvl.mockResolvedValue("VIEW"); const prisma = mockPrisma({}); // findFirst scoped to workspace → null const out = await assembleContext(prisma, ws, user, { pageId: "other-ws-page" }); expect(out.dropped).toEqual(["page"]); expect(out.note).toMatch(/unavailable/); expect(out.systemPrompt).toBeUndefined(); }); }); describe("assembleContext — database", () => { const dbProps = [ { name: "Name", type: "TITLE", config: {} }, { name: "Status", type: "SELECT", config: { options: [{ name: "Todo" }, { name: "Done" }] } }, { name: "Amount", type: "NUMBER", config: {} }, ]; const mkRecords = (n: number) => Array.from({ length: n }, (_, i) => ({ position: `a${i}`, properties: [ { property: dbProps[0]!, value: { text: `Rec ${i}`, marks: [] } }, { property: dbProps[1]!, value: { optionId: i % 2 } }, { property: dbProps[2]!, value: { value: i * 10 } }, ], })); it("includes schema + up to 20 sample rows with option labels", async () => { lvl.mockResolvedValue("VIEW"); const prisma = mockPrisma({ database: { id: "db1", workspaceId: ws, title: "Tasks" }, dbProps, records: mkRecords(5), }); const out = await assembleContext(prisma, ws, user, { databaseId: "db1" }); expect(out.systemPrompt).toContain("# Database: Tasks"); expect(out.systemPrompt).toContain("## Schema"); expect(out.systemPrompt).toContain("Name (TITLE)"); expect(out.systemPrompt).toContain("Status (SELECT)"); expect(out.systemPrompt).toContain("Rec 0"); expect(out.systemPrompt).toContain("Status: Todo"); // optionId 0 → label expect(out.systemPrompt).toContain("Status: Done"); // optionId 1 → label expect(out.systemPrompt).toContain("Amount: 10"); }); it("caps rows at 20 (truncation marker)", async () => { lvl.mockResolvedValue("VIEW"); const prisma = mockPrisma({ database: { id: "db1", workspaceId: ws, title: "Tasks" }, dbProps, records: mkRecords(30), }); const out = await assembleContext(prisma, ws, user, { databaseId: "db1" }); expect(out.systemPrompt).toContain("Rec 0"); expect(out.systemPrompt).toContain(`Rec ${MAX_CONTEXT_ROWS - 1}`); expect(out.systemPrompt).not.toContain(`Rec ${MAX_CONTEXT_ROWS} `); expect(out.systemPrompt).toContain(`truncated at ${MAX_CONTEXT_ROWS} rows`); }); it("drops the database when the requester lacks VIEW (no row leak)", async () => { lvl.mockResolvedValue("NONE"); const prisma = mockPrisma({ database: { id: "db1", workspaceId: ws, title: "Secret DB" }, dbProps, records: mkRecords(3), }); const out = await assembleContext(prisma, ws, user, { databaseId: "db1" }); expect(out.dropped).toEqual(["database"]); expect(out.systemPrompt).toBeUndefined(); expect(out.note).toMatch(/unavailable/); }); }); describe("assembleContext — combined", () => { it("combines page + database sections and reports drops per resource", async () => { lvl.mockResolvedValue("VIEW"); const dbProps = [{ name: "Name", type: "TITLE", config: {} }]; const prisma = mockPrisma({ page: { id: "p1", workspaceId: ws, title: "Page" }, blocks: [{ id: "b1", type: "PARAGRAPH", content: { text: "page body" } }], database: null, // missing → dropped dbProps, }); const out = await assembleContext(prisma, ws, user, { pageId: "p1", databaseId: "dbX" }); expect(out.dropped).toEqual(["database"]); expect(out.systemPrompt).toContain("# Page: Page"); expect(out.systemPrompt).toContain("page body"); expect(out.systemPrompt).not.toContain("# Database:"); }); it("returns no prompt when nothing is bound", async () => { lvl.mockResolvedValue("VIEW"); const prisma = mockPrisma({}); const out = await assembleContext(prisma, ws, user, {}); expect(out.systemPrompt).toBeUndefined(); expect(out.dropped).toEqual([]); }); it("includes the base system prompt export", () => { expect(BASE_SYSTEM_PROMPT).toContain("workspace assistant"); }); }); // ── TASK-0126 — the generatePlan tool spec + model-quirk normalization ────── import { validateTools, parseGeneratePlan } from "@factory/ai"; import { GENERATE_PLAN_SYSTEM_PROMPT, GENERATE_PLAN_TOOL, normalizeGeneratePlanArgs, } from "../prompts.js"; describe("GENERATE_PLAN_TOOL", () => { it("passes the gateway's strict tool-schema validator", () => { // The same validator the AiGateway runs before any spend — the spec must // stay inside the allowed JSON-Schema subset (no oneOf/$ref/pattern). expect(validateTools([GENERATE_PLAN_TOOL]).ok).toBe(true); }); it("describes the four step names + uppercase block types", () => { const params = GENERATE_PLAN_TOOL.parameters as { properties: { tools: { items: { properties: { name: { enum: readonly string[] } } } } }; }; expect(params.properties.tools.items.properties.name.enum).toEqual([ "createPage", "insertBlocks", "createDatabase", "createRelation", ]); expect(GENERATE_PLAN_TOOL.description).toContain("HEADING_2"); expect(GENERATE_PLAN_TOOL.description).toContain("$page"); // the "never use this" warning }); }); describe("GENERATE_PLAN_SYSTEM_PROMPT", () => { it("forces the tool call and the {type, content:{text}} block shape", () => { expect(GENERATE_PLAN_SYSTEM_PROMPT).toContain("generatePlan tool exactly once"); expect(GENERATE_PLAN_SYSTEM_PROMPT).toContain("content is { text:"); expect(GENERATE_PLAN_SYSTEM_PROMPT).toContain("UPPERCASE"); }); }); describe("normalizeGeneratePlanArgs (OSS model-quirk repair)", () => { it("repairs tools double-serialized as a JSON string + $page placeholder → a VALID plan", () => { // The exact shapes observed from qwen3.8-27B (20,764-char dump, // /tmp/ai-tool-args.json): tools is a STRING, the first page is targeted // via pageRef:"$page", block types are lowercase + `text` instead of // `content:{text}` (the executor uppercases types; `text` is dropped by // the schema — content defaults to {}). const raw = { tools: JSON.stringify([ { name: "createPage", args: { title: "MySQL 8.0 → 8.4 Migration Plan" } }, { name: "insertBlocks", args: { pageRef: "$page", blocks: [{ type: "paragraph", text: "Step one" }, { type: "HEADING_2", content: { text: "Phases" } }], }, }, { name: "createDatabase", args: { ref: "tasks", pageRef: "$page", title: "Tasks", properties: [{ name: "Status", type: "SELECT", options: [{ name: "To Do" }] }, { name: "Phase", type: "SELECT", options: [{ name: "To Do" }] }] } }, ]), }; const out = normalizeGeneratePlanArgs(raw) as Record; expect(Array.isArray(out.tools)).toBe(true); const parsed = parseGeneratePlan(out); expect(parsed.ok).toBe(true); if (!parsed.ok) return; // The $page placeholders were dropped (the executor treats a missing // pageRef as "the first page"); the valid 'tasks' ref survived. const insert = parsed.plan.tools[1] as { name: string; args: { pageRef?: string } }; expect(insert.name).toBe("insertBlocks"); expect(insert.args.pageRef).toBeUndefined(); const db = parsed.plan.tools[2] as { name: string; args: { ref: string; pageRef?: string } }; expect(db.args.ref).toBe("tasks"); expect(db.args.pageRef).toBeUndefined(); // The input was not mutated. expect(typeof raw.tools).toBe("string"); }); it("hoists block text into content.{text} (top-level text, empty content, bare-string content)", () => { // The model's observed quirk: block text lives OUTSIDE content — as a // top-level `text`, with `content: {}` (schema default), or as a bare // string (the web editor's round-trip shape). Without the repair the // schema's `content` default `{}` silently created EMPTY blocks. const out = normalizeGeneratePlanArgs({ tools: [ { name: "insertBlocks", args: { blocks: [ { type: "paragraph", text: "top-level text" }, // → hoist { type: "HEADING_2", content: {}, text: "empty content + text" }, // → merge { type: "quote", content: "bare string" }, // → { text } { type: "PARAGRAPH", content: { text: "already canonical" } }, // → untouched { type: "CODE", content: { code: "x", language: "sql" }, text: "structured wins" }, // → untouched ], }, }, { name: "createPage", args: { title: "P", blocks: [{ type: "PARAGRAPH", text: "inline" }] } }, ], }) as { tools: Array<{ args: { blocks?: Array> } }> }; const b = out.tools[0]!.args.blocks as Array>; expect(b[0]).toEqual({ type: "paragraph", content: { text: "top-level text" } }); expect(b[1]).toEqual({ type: "HEADING_2", content: { text: "empty content + text" } }); expect(b[2]).toEqual({ type: "quote", content: { text: "bare string" } }); expect(b[3]).toEqual({ type: "PARAGRAPH", content: { text: "already canonical" } }); expect(b[4]).toEqual({ type: "CODE", content: { code: "x", language: "sql" }, text: "structured wins" }); // createPage's inline blocks are repaired too. expect(out.tools[1]!.args.blocks).toEqual([{ type: "PARAGRAPH", content: { text: "inline" } }]); }); it("drops known placeholder refs case-insensitively, keeps valid refs", () => { const out = normalizeGeneratePlanArgs({ tools: [ { name: "insertBlocks", args: { pageRef: "$PAGE", blocks: [{ type: "PARAGRAPH", content: { text: "a" } }] } }, { name: "insertBlocks", args: { pageRef: "phase1", blocks: [{ type: "PARAGRAPH", content: { text: "b" } }] } }, { name: "createRelation", args: { databaseRef: "root", targetPageRef: "phase1", name: "links" } }, ], }) as { tools: Array<{ args: Record }> }; expect(out.tools[0]!.args.pageRef).toBeUndefined(); expect(out.tools[1]!.args.pageRef).toBe("phase1"); expect(out.tools[2]!.args.databaseRef).toBeUndefined(); expect(out.tools[2]!.args.targetPageRef).toBe("phase1"); }); it("drops the SEEDED db properties (Title/Status) a createDatabase step re-declares, keeps the rest", () => { // The database service always ships Title + Status; validateCreateDatabase // rejects the whole plan on a name collision (observed 2025: the model // emitted a Status property → createDatabase step failed). The repair // drops the seeded names and keeps the user's custom columns. const out = normalizeGeneratePlanArgs({ tools: [ { name: "createPage", args: { ref: "plan", title: "P" } }, { name: "createDatabase", args: { ref: "tasks", pageRef: "plan", title: "Tasks", properties: [ { name: "Status", type: "STATUS", options: [{ name: "To Do" }, { name: "Done" }] }, // seeded → drop { name: "Title", type: "RICH_TEXT" }, // seeded → drop { name: "Phase", type: "STATUS", options: [{ name: "To Do" }, { name: "Done" }] }, // custom → keep { name: "Due", type: "DATE" }, // custom → keep ], }, }, ], }) as { tools: Array<{ args: { properties?: Array> } }> }; const props = out.tools[1]!.args.properties; expect(props?.map((p) => p.name)).toEqual(["Phase", "Due"]); // The result still validates. expect(parseGeneratePlan(out).ok).toBe(true); }); it("does not hide real schema errors (unknown tool name still fails)", () => { const out = normalizeGeneratePlanArgs({ tools: [{ name: "deletePage", args: { ref: "root" } }], }); expect(parseGeneratePlan(out).ok).toBe(false); }); it("leaves unparseable tools-strings untouched (the schema rejects them)", () => { const raw = { tools: "{not json" }; const out = normalizeGeneratePlanArgs(raw) as Record; expect(out.tools).toBe("{not json"); expect(parseGeneratePlan(out).ok).toBe(false); }); it("passes non-object inputs through unchanged", () => { expect(normalizeGeneratePlanArgs(undefined)).toBe(undefined); expect(normalizeGeneratePlanArgs(null)).toBe(null); expect(normalizeGeneratePlanArgs("str")).toBe("str"); expect(normalizeGeneratePlanArgs([1, 2])).toEqual([1, 2]); }); });