FN-7369: add custom answers to Planning Mode confirms

Planning Mode confirm prompts can now preserve a user-authored Other answer instead of forcing Yes or No.

- Add an Other option with a textarea to confirm-style Planning Mode questions.
- Submit confirm Other answers with the shared `_other` payload and render them in agent/history formatting.
- Cover confirm Other behavior in modal flow and formatter tests.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7369-confirm-other.md                |   7 ++
 .../dashboard/app/components/PlanningModeModal.css |  10 ++
 .../dashboard/app/components/PlanningModeModal.tsx |  74 +++++++++---
 .../PlanningModeModal.planning-flow.test.tsx       | 125 +++++++++++++++++++++
 .../planning-interview-formatters.test.ts          |  27 +++++
 packages/dashboard/src/planning.ts                 |  10 +-
 6 files changed, 234 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-7369

Fusion-Task-Lineage: 67b4d943-8ff3-4502-bca1-7e74eb95ac06

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 09:24:50 -07:00
parent 7e5d908efa
commit 4805182d0c
6 changed files with 234 additions and 19 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Let Planning Mode Yes/No questions accept a custom Other answer.
category: fix
dev: Extends confirm-question handling to preserve user-authored alternatives via `_other`.

View File

@@ -1033,6 +1033,12 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
gap: var(--space-sm);
}
.planning-confirm-answer {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.planning-confirm-group {
display: flex;
gap: var(--space-md);
@@ -1732,6 +1738,10 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
}
/* Confirm buttons: mobile-friendly sizing */
.planning-confirm-group {
flex-direction: column;
}
.planning-confirm-btn {
min-height: 36px;
padding: var(--space-sm) var(--space-md);

View File

@@ -2465,7 +2465,14 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
if (question.type === "text") {
nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") {
nextResponse = { [question.id]: response[question.id] === true };
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-07-01-00:00:
GitHub #1832 requires Yes/No Planning Mode questions to offer an Other path so users can replace a forced boolean with their own answer. Reuse the reserved `_other` payload shape used by structured selection questions instead of inventing a confirm-only custom-answer contract.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { [PLANNING_OTHER_RESPONSE_KEY]: trimmedOther }
: { [question.id]: response[question.id] === true };
} else if (question.type === "single_select") {
const trimmedOther = otherValue.trim();
/*
@@ -2522,7 +2529,7 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
*/
return (Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0) || (isOtherSelected && otherValue.trim().length > 0);
case "confirm":
return response[question.id] !== undefined;
return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0);
default:
return true;
}
@@ -2687,21 +2694,54 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
)}
{question.type === "confirm" && (
<div className="planning-confirm-group">
<button
className={`planning-confirm-btn ${response[question.id] === true ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: true })}
>
<CheckCircle size={18} />
{t("common.yes", "Yes")}
</button>
<button
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: false })}
>
<X size={18} />
{t("common.no", "No")}
</button>
<div className="planning-confirm-answer">
<div className="planning-confirm-group">
<button
className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`}
onClick={() => {
setIsOtherSelected(false);
setOtherValue("");
setResponse({ [question.id]: true });
}}
>
<CheckCircle size={18} />
{t("common.yes", "Yes")}
</button>
<button
className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`}
onClick={() => {
setIsOtherSelected(false);
setOtherValue("");
setResponse({ [question.id]: false });
}}
>
<X size={18} />
{t("common.no", "No")}
</button>
<button
className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`}
data-testid="planning-option-other"
onClick={() => {
setIsOtherSelected(true);
setResponse({});
}}
>
<HelpCircle size={18} />
{t("planning.otherOptionLabel", "Other (write your own)")}
</button>
</div>
{isOtherSelected && (
<div className="planning-other-answer">
<textarea
ref={otherAutosizeRef}
className="planning-textarea"
data-testid="planning-other-input"
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</div>
)}
</div>

View File

@@ -624,6 +624,131 @@ describe("PlanningModeModal", () => {
});
});
it.each(["desktop", "mobile"] as const)("lets confirm questions submit an Other answer on %s", async (viewportMode) => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockViewport(viewportMode);
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.({
id: "q-confirm-scope",
type: "confirm",
question: "Proceed with this scope?",
description: "Choose Yes, No, or write a different answer.",
});
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Proceed with this scope?")).toBeDefined();
});
expect(screen.getByRole("button", { name: /Yes/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /No/ })).toBeInTheDocument();
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
expect(screen.queryByTestId("planning-other-input")).toBeNull();
const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(screen.getByTestId("planning-other-input")).toBeInTheDocument();
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Ask a different scoping question " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ _other: "Ask a different scoping question" },
undefined,
"tab-self",
);
});
});
it("clears confirm Other text when switching back to Yes or No", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.({
id: "q-confirm-scope",
type: "confirm",
question: "Proceed with this scope?",
});
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Proceed with this scope?")).toBeDefined();
});
const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: "Ask a different scoping question" },
});
fireEvent.change(screen.getByLabelText("Additional comments (optional)"), {
target: { value: "Keep the planner moving" },
});
expect(continueButton).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: /No/ }));
expect(screen.queryByTestId("planning-other-input")).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ "q-confirm-scope": false, _comment: "Keep the planner moving" },
undefined,
"tab-self",
);
});
});
it("shows stop action in loading and stops generation", async () => {
let streamHandlers: any;
const closeSpy = vi.fn();

View File

@@ -22,6 +22,12 @@ const multiSelectQuestion: PlanningQuestion = {
],
};
const confirmQuestion: PlanningQuestion = {
id: "proceed",
type: "confirm",
question: "Proceed with this plan?",
};
describe("planning interview formatter Other answers", () => {
it("formats Other-only single-select answers for the planning agent and Q&A history", () => {
const response = { _other: "Run discovery first" };
@@ -44,4 +50,25 @@ describe("planning interview formatter Other answers", () => {
"A: Speed, Keep humans in review (user's own answer)",
);
});
it("formats confirm Yes and No answers without changing boolean semantics", () => {
expect(formatResponseForAgent(confirmQuestion, { proceed: true })).toContain("Answer: Yes");
expect(formatInterviewQA([{ question: confirmQuestion, response: { proceed: true } }])).toContain("A: Yes");
expect(formatResponseForAgent(confirmQuestion, { proceed: false })).toContain("Answer: No");
expect(formatInterviewQA([{ question: confirmQuestion, response: { proceed: false } }])).toContain("A: No");
});
it("formats confirm Other answers and comments as first-class custom answers", () => {
const response = { _other: "Ask a different scoping question", _comment: "Need product input" };
expect(formatResponseForAgent(confirmQuestion, response)).toContain(
"Answer: Ask a different scoping question (user's own answer)",
);
expect(formatResponseForAgent(confirmQuestion, response)).toContain("Additional context: Need product input");
expect(formatInterviewQA([{ question: confirmQuestion, response }])).toContain(
"A: Ask a different scoping question (user's own answer)",
);
expect(formatInterviewQA([{ question: confirmQuestion, response }])).toContain("Comment: Need product input");
});
});

View File

@@ -2478,7 +2478,13 @@ export function formatResponseForAgent(
break;
case "confirm":
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
/*
FNXC:PlanningInterview 2026-07-01-00:00:
GitHub #1832 lets Planning Mode confirm questions submit `_other` instead of a boolean. Preserve that user-authored answer for the agent; otherwise history replay would turn missing confirm ids into an unintended "No".
*/
formatted = other.length > 0
? `Question: ${question.question}\n\nAnswer: ${other} (user's own answer)`
: `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
break;
default:
@@ -2544,7 +2550,7 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow
}
case "confirm":
return responseValue === true ? "Yes" : "No";
return other.length > 0 ? `${other} (user's own answer)` : responseValue === true ? "Yes" : "No";
default:
return JSON.stringify(responseValue);