feat(FN-1168): include planning interview Q&A in breakdown outputs

- Add a shared interview Q&A formatter for planning history across question types
- Append formatted interview context to generated subtask descriptions when history exists
- Include the same interview context in multi-task planning log entries for created tasks
- Expand planning tests to cover formatter behavior and subtask description context handling
This commit is contained in:
gsxdsm
2026-04-08 02:30:50 -07:00
parent 92b6393386
commit e4af27910e
3 changed files with 211 additions and 18 deletions

View File

@@ -16,6 +16,7 @@ import {
InvalidSessionStateError,
parseAgentResponse,
generateSubtasksFromPlanning,
formatInterviewQA,
} from "./planning.js";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
@@ -558,6 +559,103 @@ describe("planning module", () => {
});
});
describe("formatInterviewQA", () => {
it("returns empty string for empty history", () => {
expect(formatInterviewQA([])).toBe("");
});
it("formats text, single_select, multi_select, and confirm responses", () => {
const history: Array<{ question: PlanningQuestion; response: unknown }> = [
{
question: {
id: "q-text",
type: "text",
question: "What constraints should we consider?",
},
response: { "q-text": "Must support offline mode" },
},
{
question: {
id: "q-single",
type: "single_select",
question: "What is the target scope?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { "q-single": "medium" },
},
{
question: {
id: "q-multi",
type: "multi_select",
question: "Which platforms are required?",
options: [
{ id: "web", label: "Web" },
{ id: "ios", label: "iOS" },
{ id: "android", label: "Android" },
],
},
response: { "q-multi": ["web", "android"] },
},
{
question: {
id: "q-confirm",
type: "confirm",
question: "Should we include backward compatibility?",
},
response: { "q-confirm": true },
},
];
expect(formatInterviewQA(history)).toBe(
[
"## Planning Interview Context",
"",
"**Q: What constraints should we consider?**",
"A: Must support offline mode",
"",
"**Q: What is the target scope?**",
"A: Medium",
"",
"**Q: Which platforms are required?**",
"A: Web, Android",
"",
"**Q: Should we include backward compatibility?**",
"A: Yes",
].join("\n")
);
});
it("handles missing options gracefully", () => {
const history: Array<{ question: PlanningQuestion; response: unknown }> = [
{
question: {
id: "q-single",
type: "single_select",
question: "Which tier?",
options: [{ id: "starter", label: "Starter" }],
},
response: { "q-single": "enterprise" },
},
{
question: {
id: "q-multi",
type: "multi_select",
question: "Which integrations?",
options: [{ id: "slack", label: "Slack" }],
},
response: { "q-multi": ["slack", "jira"] },
},
];
const formatted = formatInterviewQA(history);
expect(formatted).toContain("A: enterprise");
expect(formatted).toContain("A: Slack, jira");
});
});
describe("generateSubtasksFromPlanning", () => {
/** Helper: create a session and complete it to get a summary */
async function createCompletedSession(
@@ -566,9 +664,9 @@ describe("planning module", () => {
): Promise<string> {
const { sessionId } = await createSession(ip, plan, undefined, TEST_ROOT_DIR);
// Complete the session by submitting 3 responses
await submitResponse(sessionId, { scope: "medium" });
await submitResponse(sessionId, { requirements: "Test requirements" });
await submitResponse(sessionId, { confirm: true });
await submitResponse(sessionId, { "q-scope": "medium" });
await submitResponse(sessionId, { "q-requirements": "Test requirements" });
await submitResponse(sessionId, { "q-confirm": true });
return sessionId;
}
@@ -623,6 +721,41 @@ describe("planning module", () => {
});
});
it("appends planning interview context to subtask descriptions when history exists", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth system with context");
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBeGreaterThan(0);
expect(result[0]?.description).toContain("## Planning Interview Context");
expect(result[0]?.description).toContain("**Q: What is the scope of this plan?**");
expect(result[0]?.description).toContain("A: Medium");
expect(result[0]?.description).toContain("**Q: What are the key requirements?**");
expect(result[0]?.description).toContain("A: Test requirements");
expect(result[0]?.description).toContain("**Q: Are there specific technologies to use?**");
expect(result[0]?.description).toContain("A: Yes");
});
it("keeps subtask descriptions unchanged when history is empty", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Build auth without context");
const session = getSession(sessionId);
expect(session?.summary).toBeDefined();
if (!session?.summary) {
throw new Error("Expected summary to exist for completed session");
}
session.history = [];
const result = generateSubtasksFromPlanning(sessionId);
expect(result.length).toBeGreaterThan(0);
for (const subtask of result) {
expect(subtask.description).toBe(session.summary.description);
}
});
it("generates fallback subtasks when keyDeliverables is empty", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, "Fallback test", undefined, TEST_ROOT_DIR);

View File

@@ -998,18 +998,18 @@ function formatResponseForAgent(
responses: Record<string, unknown>
): string {
const responseValue = responses[question.id];
switch (question.type) {
case "text":
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
case "single_select":
if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue);
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
}
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
case "multi_select":
if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => {
@@ -1019,15 +1019,70 @@ function formatResponseForAgent(
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
}
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
case "confirm":
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
default:
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
}
}
function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknown): string {
switch (question.type) {
case "text":
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
case "single_select":
if (typeof responseValue === "string") {
const option = question.options?.find((candidate) => candidate.id === responseValue);
return option?.label || responseValue;
}
return String(responseValue ?? "");
case "multi_select":
if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => {
if (typeof id !== "string") {
return String(id);
}
const option = question.options?.find((candidate) => candidate.id === id);
return option?.label || id;
});
return selected.join(", ");
}
return String(responseValue ?? "");
case "confirm":
return responseValue === true ? "Yes" : "No";
default:
return JSON.stringify(responseValue);
}
}
/**
* Format planning interview Q&A history for task descriptions and logs.
*/
export function formatInterviewQA(
history: Array<{ question: PlanningQuestion; response: unknown }>
): string {
if (history.length === 0) {
return "";
}
const entries = history.map(({ question, response }) => {
const responseValue =
response && typeof response === "object" && !Array.isArray(response)
? (response as Record<string, unknown>)[question.id]
: response;
return `**Q: ${question.question}**\nA: ${formatInterviewAnswer(question, responseValue)}`;
});
return `## Planning Interview Context\n\n${entries.join("\n\n")}`;
}
/**
* Cancel and cleanup a planning session.
*/
@@ -1088,6 +1143,10 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
if (!session.summary) return [];
const { summary } = session;
const qaSection = formatInterviewQA(session.history);
const descriptionWithContext = qaSection
? `${summary.description}\n\n${qaSection}`
: summary.description;
// If key deliverables exist, create one subtask per deliverable
if (summary.keyDeliverables.length > 0) {
@@ -1097,7 +1156,7 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
return {
id,
title: deliverable,
description: summary.description,
description: descriptionWithContext,
suggestedSize: index === 0 ? "S" as const : index === summary.keyDeliverables.length - 1 ? "S" as const : "M" as const,
dependsOn,
};
@@ -1109,21 +1168,21 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
{
id: "subtask-1",
title: "Define implementation approach",
description: summary.description,
description: descriptionWithContext,
suggestedSize: "S" as const,
dependsOn: [],
},
{
id: "subtask-2",
title: "Implement core changes",
description: summary.description,
description: descriptionWithContext,
suggestedSize: "M" as const,
dependsOn: ["subtask-1"],
},
{
id: "subtask-3",
title: "Verify and polish",
description: summary.description,
description: descriptionWithContext,
suggestedSize: "S" as const,
dependsOn: ["subtask-2"],
},

View File

@@ -5847,7 +5847,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const { getSession, cleanupSession } = await import("./planning.js");
const { getSession, cleanupSession, formatInterviewQA } = await import("./planning.js");
const session = getSession(planningSessionId);
if (!session) {
@@ -5860,6 +5860,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const qaSection = formatInterviewQA(session.history);
const logDetails = qaSection
? `Source: ${session.initialPlan.slice(0, 200)}\n\n${qaSection}`
: `Source: ${session.initialPlan.slice(0, 200)}`;
// Validate each subtask
for (const item of subtasks) {
if (!item || typeof item.id !== "string" || typeof item.title !== "string" || !item.title.trim()) {
@@ -5901,11 +5906,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
createdTasks[index] = updated;
}
await store.logEntry(
created.id,
"Created via Planning Mode (multi-task)",
`Source: ${session.initialPlan.slice(0, 200)}`
);
await store.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails);
}
// Cleanup the planning session