Files
kandev/apps/web/components/task/passthrough-chat-composer.test.ts
T
2026-09-18 16:03:18 +01:00

383 lines
12 KiB
TypeScript

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 { planCommentRecovery } from "@/lib/plan-comment-recovery";
import {
buildContextFilesMeta,
buildPassthroughFinalMessage,
buildPassthroughPlanContext,
assertPlanCommentMigrationReady,
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 messageComment() {
return {
id: "message-comment-1",
sessionId: SESSION_ID,
source: "agent-message",
messageId: "reply-1",
selectedText: "settled answer",
anchor: {
messageId: "reply-1",
start: 0,
end: 14,
selectedText: "settled answer",
prefix: "",
suffix: "",
},
text: "Please expand this.",
createdAt: "2026-07-20T00:00:00.000Z",
status: "pending",
} as never;
}
function planComment() {
return {
id: "plan-comment-1",
sessionId: "",
taskId: TASK_ID,
planId: "plan-1",
version: 2,
source: "plan",
text: "Split this step.",
selectedText: "Large step",
createdAt: "2026-09-02T00:00:00Z",
status: "pending",
} as const;
}
function panelState(overrides: Record<string, unknown> = {}) {
return {
resolvedSessionId: SESSION_ID,
taskId: TASK_ID,
contextFiles: [],
prompts: [],
pendingPRFeedback: [],
planComments: [],
walkthroughComments: [],
messageComments: [],
planModeEnabled: false,
planCommentMigration: {
status: "complete",
pendingCount: 0,
failure: null,
needsAttention: false,
isReady: true,
isBlocking: false,
retry: vi.fn(),
},
handleClearPRFeedback: vi.fn(),
clearSessionPlanComments: vi.fn(),
handleClearWalkthroughComments: vi.fn(),
handleClearMessageComments: 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("includes pending agent message comments in passthrough context once", () => {
const result = formatPassthroughBaseMessage(
"Ship it",
undefined,
[],
panelState({ messageComments: [messageComment()] }),
);
expect(result.formatted.match(/### Agent Message Comments/g)).toHaveLength(1);
expect(result.commentsToSend.map((item) => item.id)).toEqual(["message-comment-1"]);
});
it("keeps task plan comment text out of the client-formatted passthrough message", () => {
const result = formatPassthroughBaseMessage(
"Ship it",
undefined,
[],
panelState({ planComments: [planComment()] }),
);
expect(result.formatted).toBe("Ship it");
expect(result.formatted).not.toContain("Split this step.");
});
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 PATHS");
expect(result.content).toContain("- file: src/existing.ts");
expect(result.content).toContain("- file: 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("returns displayed task plan comment refs for backend expansion", async () => {
const result = await buildPassthroughFinalMessage({
taskId: TASK_ID,
content: "Please continue",
pendingComments: [],
panelState: panelState({ planComments: [planComment()] }),
getState: () =>
({
kanban: { steps: [] },
kanbanMulti: { snapshots: {} },
taskPlans: { byTaskId: {} },
}) as never,
});
expect(result.planCommentRefs).toEqual([{ id: "plan-comment-1", version: 2 }]);
});
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("<kandev-system>");
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 </kandev-system> after");
expect(context).toContain("before </ kandev-system> after");
expect(context.match(/<\/kandev-system>/gi)).toHaveLength(1);
});
});
describe("passthrough chat composer cleanup", () => {
it("accepts plain delivery after background read failures without identified drafts", () => {
expect(() =>
assertPlanCommentMigrationReady(
panelState({
planCommentMigration: {
...planCommentRecovery({ status: "failed", pendingCount: 0, failure: "transient" }),
retry: vi.fn(),
},
}),
),
).not.toThrow();
});
it.each(["transient", "conflict", "rejected"] as const)(
"preserves blocked delivery without promising retries for %s recovery",
(failure) => {
expect(() =>
assertPlanCommentMigrationReady(
panelState({
planCommentMigration: {
status: "failed",
pendingCount: 1,
failure,
needsAttention: true,
isReady: false,
isBlocking: true,
retry: vi.fn(),
},
}),
),
).toThrow("Saved plan comments are still being restored. Your message is kept.");
},
);
it("clears session context but leaves task plan comments to the backend snapshot", () => {
const state = panelState({
planModeEnabled: true,
pendingPRFeedback: [{ id: "feedback-1" }],
planComments: [{ id: "plan-comment-1" }],
walkthroughComments: [{ id: "walkthrough-comment-1" }],
messageComments: [messageComment()],
}) as unknown as {
handleClearPRFeedback: ReturnType<typeof vi.fn>;
clearSessionPlanComments: ReturnType<typeof vi.fn>;
handleClearWalkthroughComments: ReturnType<typeof vi.fn>;
handleClearMessageComments: ReturnType<typeof vi.fn>;
clearEphemeral: ReturnType<typeof vi.fn>;
addContextFile: ReturnType<typeof vi.fn>;
};
clearPassthroughComposerContext(state as never);
expect(state.handleClearPRFeedback).toHaveBeenCalledTimes(1);
expect(state.clearSessionPlanComments).not.toHaveBeenCalled();
expect(state.handleClearWalkthroughComments).toHaveBeenCalledTimes(1);
expect(state.handleClearMessageComments).toHaveBeenCalledTimes(1);
expect(state.clearEphemeral).toHaveBeenCalledWith(SESSION_ID);
expect(state.addContextFile).toHaveBeenCalledWith(SESSION_ID, {
path: PLAN_CONTEXT_PATH,
name: "Plan",
});
});
});
it("sends whole-file feedback through passthrough review context", () => {
const result = formatPassthroughBaseMessage(
"Continue.",
undefined,
[
{
id: "file",
source: "review-file",
sessionId: SESSION_ID,
repositoryName: "api",
filePath: "README.md",
text: "Split this file",
createdAt: "now",
status: "pending",
},
],
panelState(),
);
expect(result.commentsToSend.map((c) => c.id)).toEqual(["file"]);
expect(result.formatted).toContain("**api/README.md**");
expect(result.formatted).toContain("> Split this file");
});