383 lines
12 KiB
TypeScript
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");
|
|
});
|