import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { DiffComment } from "@/lib/diff/types"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import { buildContextFilesMeta, buildPassthroughFinalMessage, buildPassthroughPlanContext, clearPassthroughComposerContext, formatPassthroughBaseMessage, } from "./passthrough-chat-composer"; const mockGetTaskPlan = vi.hoisted(() => vi.fn()); vi.mock("@/lib/api/domains/plan-api", () => ({ getTaskPlan: mockGetTaskPlan, })); const SESSION_ID = "session-1"; const TASK_ID = "task-1"; const PLAN_CONTEXT_PATH = "plan:context"; function file(path: string, name = path): ContextFile { return { path, name }; } function comment(id = "comment-1"): DiffComment { return { id, source: "diff", sessionId: SESSION_ID, filePath: "src/app.ts", startLine: 10, endLine: 11, side: "additions", codeContent: "const value = 1;", text: "Please fix this", status: "pending", createdAt: "2026-06-17T00:00:00.000Z", }; } function panelState(overrides: Record = {}) { return { resolvedSessionId: SESSION_ID, taskId: TASK_ID, contextFiles: [], prompts: [], pendingPRFeedback: [], planComments: [], planModeEnabled: false, handleClearPRFeedback: vi.fn(), clearSessionPlanComments: vi.fn(), clearEphemeral: vi.fn(), addContextFile: vi.fn(), ...overrides, } as never; } beforeEach(() => { mockGetTaskPlan.mockReset(); }); describe("passthrough chat composer metadata helpers", () => { it("filters virtual prompt and plan context files from context_files metadata", () => { expect( buildContextFilesMeta([ file("src/app.ts", "app.ts"), file("prompt:review", "Review prompt"), file(PLAN_CONTEXT_PATH, "Plan"), ]), ).toEqual([{ path: "src/app.ts", name: "app.ts" }]); expect(buildContextFilesMeta([file("prompt:review"), file(PLAN_CONTEXT_PATH)])).toBeUndefined(); }); it("prepends pending review comments when no structured comment payload is supplied", () => { const result = formatPassthroughBaseMessage("Ship it", undefined, [comment()], panelState()); expect(result.commentsToSend).toHaveLength(1); expect(result.formatted).toContain("### Review Comments"); expect(result.formatted).toContain("Please fix this"); expect(result.formatted).toContain("Ship it"); }); it("merges selected context files with inline file, prompt, and task mentions", async () => { const inlineTask: TaskMentionData = { taskId: "task-2", title: "Follow-up task", workflowId: "workflow-1", workflowStepId: "step-1", state: "in_progress", }; const result = await buildPassthroughFinalMessage({ taskId: TASK_ID, content: "Please check this", pendingComments: [], panelState: panelState({ contextFiles: [file("src/existing.ts", "existing.ts")], prompts: [{ id: "prompt-1", name: "Review", content: "Look carefully." }], }), inlineMentions: [file("src/inline.ts", "inline.ts"), file("prompt:prompt-1", "Review")], inlineTaskMentions: [inlineTask], getState: () => ({ kanban: { steps: [{ id: "step-1", title: "Review" }] }, kanbanMulti: { snapshots: {} }, taskPlans: { byTaskId: {} }, }) as never, }); expect(result.contextFilesMeta).toEqual([ { path: "src/existing.ts", name: "existing.ts" }, { path: "src/inline.ts", name: "inline.ts" }, ]); expect(result.content).toContain("CONTEXT FILES"); expect(result.content).toContain("- src/existing.ts"); expect(result.content).toContain("- src/inline.ts"); expect(result.content).toContain("CONTEXT PROMPTS"); expect(result.content).toContain("Look carefully."); expect(result.content).toContain("REFERENCED TASKS"); expect(result.content).toContain("Follow-up task"); }); }); describe("passthrough chat composer plan context", () => { it("expands selected plan context and strips the literal @Plan mention", async () => { const result = await buildPassthroughFinalMessage({ taskId: TASK_ID, content: "@Plan", pendingComments: [], panelState: panelState(), inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], getState: () => ({ kanban: { steps: [] }, kanbanMulti: { snapshots: {} }, taskPlans: { byTaskId: { [TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" }, }, }, }) as never, }); expect(result.content).toContain(""); expect(result.content).toContain("CONTEXT PLAN"); expect(result.content).toContain("## Plan"); expect(result.content).toContain("Ship passthrough fixes"); expect(result.content).not.toContain("@Plan"); }); it("strips a selected plan mention from the middle of a message without double spacing", async () => { const result = await buildPassthroughFinalMessage({ taskId: TASK_ID, content: "Please use @Plan now", pendingComments: [], panelState: panelState(), inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], getState: () => ({ kanban: { steps: [] }, kanbanMulti: { snapshots: {} }, taskPlans: { byTaskId: { [TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" }, }, }, }) as never, }); expect(result.content).toContain("Please use now"); expect(result.content).not.toContain("Please use now"); expect(result.content).not.toContain("@Plan"); }); it("propagates plan lookup failures when plan context was selected", async () => { mockGetTaskPlan.mockRejectedValueOnce(new Error("plan lookup failed")); await expect( buildPassthroughFinalMessage({ taskId: TASK_ID, content: "@Plan", pendingComments: [], panelState: panelState(), inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], getState: () => ({ kanban: { steps: [] }, kanbanMulti: { snapshots: {} }, taskPlans: { byTaskId: {} }, }) as never, }), ).rejects.toThrow("plan lookup failed"); }); it("escapes embedded kandev system closing tags in plan context", () => { const context = buildPassthroughPlanContext("before after"); expect(context).toContain("before after"); expect(context.match(/<\/kandev-system>/gi)).toHaveLength(1); }); }); describe("passthrough chat composer cleanup", () => { it("clears ephemeral context and re-adds plan context when plan mode stays enabled", () => { const state = panelState({ planModeEnabled: true, pendingPRFeedback: [{ id: "feedback-1" }], planComments: [{ id: "plan-comment-1" }], }) as unknown as { handleClearPRFeedback: ReturnType; clearSessionPlanComments: ReturnType; clearEphemeral: ReturnType; addContextFile: ReturnType; }; clearPassthroughComposerContext(state as never); expect(state.handleClearPRFeedback).toHaveBeenCalledTimes(1); expect(state.clearSessionPlanComments).toHaveBeenCalledTimes(1); expect(state.clearEphemeral).toHaveBeenCalledWith(SESSION_ID); expect(state.addContextFile).toHaveBeenCalledWith(SESSION_ID, { path: PLAN_CONTEXT_PATH, name: "Plan", }); }); });