feat(FN-4146): append verification subtask to planning flow

FN-4146 adds workspace verification to the planning system, including a new verification subtask in the planning module and reconciling the corresponding route tests. The bulk of the work is in `planning.ts` and its test files, with a minor adjustment to the merger overlap guard test.

Fusion-Task-Id: FN-4146
This commit is contained in:
Fusion
2026-05-12 10:23:28 -07:00
committed by gsxdsm
parent 7e089e195a
commit f3d275a962
5 changed files with 123 additions and 19 deletions

View File

@@ -2053,7 +2053,7 @@ describe("planning module", () => {
expect(result).toEqual([]);
});
it("generates subtasks from keyDeliverables", async () => {
it("generates subtasks from keyDeliverables and appends verification", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth system");
@@ -2061,7 +2061,7 @@ describe("planning module", () => {
// The AI-generated session produces 3 key deliverables:
// "Implementation", "Tests", "Documentation"
expect(result.length).toBe(3);
expect(result.length).toBe(4);
// First subtask has no dependencies
expect(result[0]).toEqual({
@@ -2083,7 +2083,7 @@ describe("planning module", () => {
dependsOn: ["subtask-1"],
});
// Third subtask depends on second
// Third deliverable subtask depends on second
expect(result[2]).toEqual({
id: "subtask-3",
title: "Documentation",
@@ -2092,6 +2092,16 @@ describe("planning module", () => {
priority: "normal",
dependsOn: ["subtask-2"],
});
expect(result[3]).toEqual({
id: "subtask-4",
title: "Verify end-to-end",
description: expect.any(String),
suggestedSize: "S",
priority: "normal",
dependsOn: ["subtask-3"],
});
expect(result[3]?.description).toContain("Verify the full plan end-to-end now that all deliverables are implemented.");
});
it("inherits summary priority for generated subtasks", async () => {
@@ -2115,10 +2125,11 @@ describe("planning module", () => {
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBe(3);
expect(result.length).toBe(4);
expect(result[0]?.description).toContain('Implement "Implementation" as this subtask\'s primary outcome.');
expect(result[1]?.description).toContain('Implement "Tests" as this subtask\'s primary outcome.');
expect(result[2]?.description).toContain('Implement "Documentation" as this subtask\'s primary outcome.');
expect(result[3]?.description).toContain("Verify the full plan end-to-end now that all deliverables are implemented.");
expect(result[0]?.description).toContain("## Larger Plan Context");
expect(result[0]?.description).toContain("## Planning Interview Context");
@@ -2200,7 +2211,7 @@ describe("planning module", () => {
expect(result[0]?.description).toContain("## Larger Plan Context");
});
it("assigns correct sizes based on deliverable position", async () => {
it("assigns correct sizes based on deliverable position and appended verification", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Multi-deliverable test", undefined, TEST_ROOT_DIR);
@@ -2222,14 +2233,16 @@ describe("planning module", () => {
}
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBe(5);
expect(result.length).toBe(6);
// First: S, Middle: M, Last: S
// First: S, Middle: M, Last deliverable: S, Verification: S
expect(result[0]?.suggestedSize).toBe("S");
expect(result[1]?.suggestedSize).toBe("M");
expect(result[2]?.suggestedSize).toBe("M");
expect(result[3]?.suggestedSize).toBe("M");
expect(result[4]?.suggestedSize).toBe("S");
expect(result[5]?.title).toBe("Verify end-to-end");
expect(result[5]?.suggestedSize).toBe("S");
});
it("uses sequential dependencies between subtasks", async () => {
@@ -2244,11 +2257,43 @@ describe("planning module", () => {
}
});
it("appends verification after a single deliverable", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Single deliverable test", undefined, TEST_ROOT_DIR);
await submitResponse(sessionId, { scope: "small" });
await submitResponse(sessionId, { requirements: "one thing" });
await submitResponse(sessionId, { confirm: true });
const session = getSession(sessionId);
if (session?.summary) {
session.summary.keyDeliverables = ["Only one"];
}
const result = generateSubtasksFromPlanning(sessionId);
expect(result).toHaveLength(2);
expect(result[0]?.id).toBe("subtask-1");
expect(result[0]?.title).toBe("Only one");
expect(result[1]).toEqual(expect.objectContaining({
id: "subtask-2",
title: "Verify end-to-end",
suggestedSize: "S",
dependsOn: ["subtask-1"],
}));
});
it("merges compact subtask drafts onto generated planning subtasks", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Compact draft merge test");
const generated = generateSubtasksFromPlanning(sessionId);
const verificationSubtask = generated.at(-1);
expect(verificationSubtask).toEqual(expect.objectContaining({
id: "subtask-4",
title: "Verify end-to-end",
dependsOn: ["subtask-3"],
}));
const merged = mergePlanningSubtaskDrafts(sessionId, [
{ id: generated[0]!.id },
{
@@ -2263,6 +2308,12 @@ describe("planning module", () => {
id: generated[2]!.id,
dependsOn: [generated[0]!.id, generated[1]!.id],
},
{
id: verificationSubtask!.id,
title: "Edited verification",
description: "Run end-to-end verification and capture follow-ups",
dependsOn: [generated[1]!.id, generated[2]!.id],
},
]);
expect(merged[0]).toEqual(generated[0]);
@@ -2277,6 +2328,12 @@ describe("planning module", () => {
...generated[2],
dependsOn: [generated[0]!.id, generated[1]!.id],
});
expect(merged[3]).toEqual({
...verificationSubtask,
title: "Edited verification",
description: "Run end-to-end verification and capture follow-ups",
dependsOn: [generated[1]!.id, generated[2]!.id],
});
});
it("preserves client-added subtasks when merging compact drafts", async () => {

View File

@@ -1264,13 +1264,21 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(200);
expect(res.body.sessionId).toBe(sessionId);
expect(res.body.subtasks).toHaveLength(1);
expect(res.body.subtasks).toHaveLength(2);
expect(res.body.subtasks[0]).toEqual(
expect.objectContaining({
title: "OAuth integration",
}),
);
expect(res.body.subtasks[0].description).toContain("Use OAuth providers and secure refresh tokens");
expect(res.body.subtasks[1]).toEqual(
expect.objectContaining({
id: "subtask-2",
title: "Verify end-to-end",
dependsOn: ["subtask-1"],
suggestedSize: "S",
}),
);
});
});
@@ -1738,7 +1746,7 @@ describe("Planning Mode Routes", () => {
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
for (let index = 0; index < 15; index += 1) {
for (let index = 0; index < 16; index += 1) {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...createdTaskBase,
id: `FN-${300 + index}`,
@@ -1786,7 +1794,15 @@ describe("Planning Mode Routes", () => {
priority?: string;
dependsOn: string[];
}>;
expect(generatedSubtasks).toHaveLength(15);
expect(generatedSubtasks).toHaveLength(16);
expect(generatedSubtasks[15]).toEqual(
expect.objectContaining({
id: "subtask-16",
title: "Verify end-to-end",
dependsOn: ["subtask-15"],
suggestedSize: "S",
}),
);
const oversizedLegacyPayload = JSON.stringify({ planningSessionId, subtasks: generatedSubtasks });
const compactPayload = JSON.stringify({
@@ -1805,7 +1821,7 @@ describe("Planning Mode Routes", () => {
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledTimes(15);
expect(store.createTask).toHaveBeenCalledTimes(16);
expect(store.createTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@@ -1814,13 +1830,13 @@ describe("Planning Mode Routes", () => {
}),
);
expect(store.createTask).toHaveBeenNthCalledWith(
15,
16,
expect.objectContaining({
title: generatedSubtasks[14]!.title,
description: generatedSubtasks[14]!.description,
title: generatedSubtasks[15]!.title,
description: generatedSubtasks[15]!.description,
}),
);
expect(store.logEntry).toHaveBeenCalledTimes(15);
expect(store.logEntry).toHaveBeenCalledTimes(16);
});
it("applies branchSelection when creating a planning task", async () => {

View File

@@ -42,6 +42,17 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
vi.mock("@fusion/engine", () => ({
createFnAgent: mockCreateFnAgent,
createResolvedAgentSession: vi.fn(async () => ({
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
runtimeModel: undefined,
})),
promptWithFallback: vi.fn(async (session: { prompt: (message: string) => Promise<void> }, prompt: string) => {
await session.prompt(prompt);
}),
extractRuntimeHint: vi.fn(() => undefined),
extractRuntimeModel: vi.fn(() => undefined),
createSendMessageTool: vi.fn(() => ({})),
createReadMessagesTool: vi.fn(() => ({})),
}));
function makePlanningAgent(responses: string[]) {

View File

@@ -2214,6 +2214,7 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
/**
* Generate subtasks from a completed planning summary.
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
* Always appends a final end-to-end verification subtask, regardless of deliverable count.
*
* @param sessionId - The planning session ID
* @returns Array of SubtaskItem with titles derived from keyDeliverables, or fallback
@@ -2244,6 +2245,10 @@ export interface PlanningSubtaskDraft {
dependsOn?: string[];
}
/**
* Generate planning subtasks from a completed planning summary.
* Always appends a final end-to-end verification subtask, regardless of deliverable count.
*/
export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
const session = sessions.get(sessionId);
if (!session) return [];
@@ -2252,9 +2257,9 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
const { summary } = session;
const qaSection = formatInterviewQA(session.history);
// If key deliverables exist, create one subtask per deliverable
// If key deliverables exist, create one subtask per deliverable plus a final verification subtask.
if (summary.keyDeliverables.length > 0) {
return summary.keyDeliverables.map((deliverable, index) => {
const deliverableSubtasks = summary.keyDeliverables.map((deliverable, index) => {
const id = `subtask-${index + 1}`;
const dependsOn = index > 0 ? [`subtask-${index}`] : [] as string[];
return {
@@ -2270,6 +2275,21 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
dependsOn,
};
});
deliverableSubtasks.push({
id: `subtask-${summary.keyDeliverables.length + 1}`,
title: "Verify end-to-end",
description: buildPlanningSubtaskDescription({
taskGuidance: "Verify the full plan end-to-end now that all deliverables are implemented. Exercise the integrated behavior described in the plan, confirm acceptance criteria hold, run the project test suite, and capture any follow-ups as new tasks rather than expanding scope.",
summaryDescription: summary.description,
qaSection,
}),
suggestedSize: "S",
priority: summary.priority ?? DEFAULT_TASK_PRIORITY,
dependsOn: [`subtask-${summary.keyDeliverables.length}`],
});
return deliverableSubtasks;
}
// Fallback: 3 subtasks
@@ -2302,7 +2322,7 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
id: "subtask-3",
title: "Verify and polish",
description: buildPlanningSubtaskDescription({
taskGuidance: "Verify the implementation end-to-end, then polish quality items like tests, docs, and edge-case handling.",
taskGuidance: "Verify the implementation end-to-end, then polish quality items like tests, docs, and edge-case handling before closing out the plan.",
summaryDescription: summary.description,
qaSection,
}),

View File

@@ -369,7 +369,7 @@ describe("aiMergeTask overlap-aware fallback integration", () => {
expect(result.resolutionMethod).toBe("ours");
expect(git(dir, "git show HEAD:store.ts")).toContain("main fallback");
expect(git(dir, "git show HEAD:store.ts")).not.toContain("branch hardening");
});
}, 15_000);
it("warn-only logs overlap but preserves main-wins behavior", async () => {
commitFile(dir, "store.ts", "export const mode = 'base';\n", "feat: add store");