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:
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user