FN-7089: add other answers to interview prompts

Adds free-text Other answers across planning and mission interview prompts.

- Add synthetic Other options for single-select and multi-select planning, mission, milestone, and slice interviews.
- Thread reserved `_other` responses through agent-facing and history formatters.
- Cover Other-only and Other-plus-selection behavior with dashboard component and formatter tests.
- Document the behavior and add a minor changeset for the published CLI package.

Files changed:
 .changeset/fn-7089-interview-other-option.md       |   7 +
 docs/dashboard-guide.md                            |   2 +
 .../components/MilestoneSliceInterviewModal.tsx    | 112 +++++++-
 .../app/components/MissionInterviewModal.tsx       | 112 +++++++-
 .../dashboard/app/components/PlanningModeModal.css |  11 +-
 .../dashboard/app/components/PlanningModeModal.tsx | 110 +++++++-
 .../MilestoneSliceInterviewModal.test.tsx          | 313 +++++++++++++++++++++
 .../__tests__/MissionInterviewModal.test.tsx       | 244 ++++++++++++++++
 .../PlanningModeModal.planning-flow.test.tsx       | 200 +++++++++++++
 .../__tests__/milestone-slice-interview.test.ts    |  49 +++-
 .../src/__tests__/mission-interview.test.ts        |  47 ++++
 .../planning-interview-formatters.test.ts          |  47 ++++
 .../dashboard/src/milestone-slice-interview.ts     |  63 ++++-
 packages/dashboard/src/mission-interview.ts        |  63 ++++-
 packages/dashboard/src/planning.ts                 |  55 ++--
 15 files changed, 1381 insertions(+), 54 deletions(-)

Fusion-Task-Id: FN-7089

Fusion-Task-Lineage: 1c629492-13e4-4ee7-aa37-1d7d067548b7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-26 21:31:42 -07:00
parent 513882ecac
commit ae0679b004
15 changed files with 1381 additions and 54 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add an "Other" free-text answer to planning and mission interview questions.
category: feature
dev: single_select/multi_select questions now render a synthetic Other option backed by a reserved `_other` response key, threaded through formatResponseForAgent/history formatters in planning.ts, mission-interview.ts, and milestone-slice-interview.ts.

View File

@@ -736,11 +736,13 @@ Workflow behavior:
- If no workflow is selected, or workflow columns are unavailable, mission-created tasks continue to use the project default workflow.
<!-- FNXC:MissionInterviewDocs 2026-06-25-15:55: FN-6975 made the Plan Mission with AI workspace movable/resizable on desktop while preserving mobile's fixed full-screen flow, and stream failures now surface one recoverable retry state instead of leaving the modal spinning. -->
<!-- FNXC:PlanningInterview 2026-06-26-00:00: GitHub #1794 requires structured planning, mission, milestone, and slice interview questions to let users reject all provided single-select/multi-select options by choosing Other and writing their own answer. -->
Plan Mission with AI modal behavior:
- On desktop, the modal opens as a floating workspace that can be dragged by its title bar and resized from the window edges/corners.
- On mobile, the mission interview keeps the fixed full-screen/sheet-style layout so touch users retain the original focused flow.
- If the mission interview stream reports a terminal failure, the modal closes the failed stream, shows one normalized error, and offers retry without duplicating late error/complete events.
- Structured single-select and multi-select interview questions include **Other (write your own)** so users can decline all suggested options, submit a free-text answer, or combine that text with selected multi-select options.
## Roadmaps View

View File

@@ -36,6 +36,8 @@ import { useViewportMode } from "../hooks/useViewportMode";
import { getSessionTabId } from "../utils/getSessionTabId";
const WARNING_ICON = "⚠️";
const MILESTONE_SLICE_OTHER_RESPONSE_KEY = "_other";
const MILESTONE_SLICE_OTHER_OPTION_ID = "__other__";
interface MilestoneSliceInterviewModalProps {
isOpen: boolean;
@@ -645,9 +647,12 @@ interface InterviewQuestionFormProps {
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
const { t } = useTranslation("app");
const questionOptions = question.options ?? [];
const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const [otherValue, setOtherValue] = useState("");
const [isOtherSelected, setIsOtherSelected] = useState(false);
const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
@@ -656,6 +661,24 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") {
nextResponse = { [question.id]: response[question.id] === true };
} else if (question.type === "single_select") {
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-06-26-00:00:
GitHub #1794 requires milestone and slice interviews to let users decline every AI-provided single-select option and submit their own answer through `_other`.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { [MILESTONE_SLICE_OTHER_RESPONSE_KEY]: trimmedOther }
: response;
} else if (question.type === "multi_select") {
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Milestone and slice multi-select questions must support Other-only answers and Other-plus-option answers without forcing users into suggested choices they reject.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { ...response, [MILESTONE_SLICE_OTHER_RESPONSE_KEY]: trimmedOther }
: response;
} else {
nextResponse = response;
}
@@ -666,12 +689,14 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
}
onSubmit(nextResponse);
}, [commentValue, question, response, textValue, onSubmit]);
}, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]);
useEffect(() => {
setResponse({});
setTextValue("");
setCommentValue("");
setOtherValue("");
setIsOtherSelected(false);
}, [question.id]);
const isValid = () => {
@@ -679,9 +704,17 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
case "text":
return textValue.trim().length > 0;
case "single_select":
return response[question.id] !== undefined;
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Continue is valid for milestone/slice single-select questions when the user writes a non-empty Other answer, even with no provided option selected.
*/
return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0);
case "multi_select":
return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0;
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Continue is valid for milestone/slice multi-select questions when Other has non-whitespace text, including the Other-only case.
*/
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;
default:
@@ -735,16 +768,20 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
/>
)}
{question.type === "single_select" && question.options && (
{question.type === "single_select" && (
<div className="planning-radio-group" role="radiogroup">
{question.options.map((option) => (
{questionOptions.map((option) => (
<label key={option.id} className="planning-option planning-option--radio">
<input
type="radio"
name={question.id}
value={option.id}
checked={response[question.id] === option.id}
onChange={() => setResponse({ [question.id]: option.id })}
checked={response[question.id] === option.id && !isOtherSelected}
onChange={() => {
setIsOtherSelected(false);
setOtherValue("");
setResponse({ [question.id]: option.id });
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
@@ -754,12 +791,40 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
</div>
</label>
))}
{/* FNXC:PlanningInterview 2026-06-26-00:00: The synthetic Other radio keeps milestone/slice interviews redirectable when every provided answer is wrong for the user's intent. */}
<label className="planning-option planning-option--radio" data-testid="planning-option-other">
<input
type="radio"
name={question.id}
value={MILESTONE_SLICE_OTHER_OPTION_ID}
checked={isOtherSelected}
onChange={() => {
setIsOtherSelected(true);
setResponse({});
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{t("interview.otherOptionLabel", "Other (write your own)")}</span>
</div>
</label>
{isOtherSelected && (
<div className="planning-other-answer">
<textarea
className="planning-textarea"
data-testid="planning-other-input"
rows={2}
placeholder={t("interview.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</div>
)}
{question.type === "multi_select" && question.options && (
{question.type === "multi_select" && (
<div className="planning-checkbox-group">
{question.options.map((option) => {
{questionOptions.map((option) => {
const selected = (response[question.id] as string[]) || [];
return (
<label key={option.id} className="planning-option planning-option--checkbox">
@@ -783,6 +848,35 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
</label>
);
})}
{/* FNXC:PlanningInterview 2026-06-26-00:00: The synthetic Other checkbox lets milestone/slice users submit their own answer by itself or alongside provided options. */}
<label className="planning-option planning-option--checkbox" data-testid="planning-option-other">
<input
type="checkbox"
value={MILESTONE_SLICE_OTHER_OPTION_ID}
checked={isOtherSelected}
onChange={(e) => {
setIsOtherSelected(e.target.checked);
if (!e.target.checked) {
setOtherValue("");
}
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{t("interview.otherOptionLabel", "Other (write your own)")}</span>
</div>
</label>
{isOtherSelected && (
<div className="planning-other-answer">
<textarea
className="planning-textarea"
data-testid="planning-other-input"
rows={2}
placeholder={t("interview.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</div>
)}

View File

@@ -76,6 +76,8 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
}
const WARNING_ICON = "⚠️";
const MISSION_INTERVIEW_OTHER_RESPONSE_KEY = "_other";
const MISSION_INTERVIEW_OTHER_OPTION_ID = "__other__";
interface MissionInterviewModalProps {
isOpen: boolean;
@@ -1126,9 +1128,12 @@ interface InterviewQuestionFormProps {
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
const { t } = useTranslation("app");
const questionOptions = question.options ?? [];
const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const [otherValue, setOtherValue] = useState("");
const [isOtherSelected, setIsOtherSelected] = useState(false);
const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
@@ -1137,6 +1142,24 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") {
nextResponse = { [question.id]: response[question.id] === true };
} else if (question.type === "single_select") {
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-06-26-00:00:
GitHub #1794 requires mission interviews to let users decline every AI-provided single-select option and submit their own answer through the reserved `_other` key.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { [MISSION_INTERVIEW_OTHER_RESPONSE_KEY]: trimmedOther }
: response;
} else if (question.type === "multi_select") {
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Mission multi-select questions must preserve chosen provided options while adding a user-authored Other answer when the user wants framing beyond the offered choices.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { ...response, [MISSION_INTERVIEW_OTHER_RESPONSE_KEY]: trimmedOther }
: response;
} else {
nextResponse = response;
}
@@ -1147,12 +1170,14 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
}
onSubmit(nextResponse);
}, [commentValue, question, response, textValue, onSubmit]);
}, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]);
useEffect(() => {
setResponse({});
setTextValue("");
setCommentValue("");
setOtherValue("");
setIsOtherSelected(false);
}, [question.id]);
const isValid = () => {
@@ -1160,9 +1185,17 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
case "text":
return textValue.trim().length > 0;
case "single_select":
return response[question.id] !== undefined;
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Continue is valid for mission single-select questions when the user writes a non-empty Other answer, even with no provided option selected.
*/
return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0);
case "multi_select":
return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0;
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Continue is valid for mission multi-select questions when Other has non-whitespace text, including the Other-only case.
*/
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;
default:
@@ -1216,16 +1249,20 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
/>
)}
{question.type === "single_select" && question.options && (
{question.type === "single_select" && (
<div className="planning-radio-group" role="radiogroup">
{question.options.map((option) => (
{questionOptions.map((option) => (
<label key={option.id} className="planning-option planning-option--radio">
<input
type="radio"
name={question.id}
value={option.id}
checked={response[question.id] === option.id}
onChange={() => setResponse({ [question.id]: option.id })}
checked={response[question.id] === option.id && !isOtherSelected}
onChange={() => {
setIsOtherSelected(false);
setOtherValue("");
setResponse({ [question.id]: option.id });
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
@@ -1235,12 +1272,40 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
</div>
</label>
))}
{/* FNXC:PlanningInterview 2026-06-26-00:00: The synthetic Other radio gives mission users an explicit way to reject all provided choices while staying in the structured interview. */}
<label className="planning-option planning-option--radio" data-testid="planning-option-other">
<input
type="radio"
name={question.id}
value={MISSION_INTERVIEW_OTHER_OPTION_ID}
checked={isOtherSelected}
onChange={() => {
setIsOtherSelected(true);
setResponse({});
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{t("missions.otherOptionLabel", "Other (write your own)")}</span>
</div>
</label>
{isOtherSelected && (
<div className="planning-other-answer">
<textarea
className="planning-textarea"
data-testid="planning-other-input"
rows={2}
placeholder={t("missions.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</div>
)}
{question.type === "multi_select" && question.options && (
{question.type === "multi_select" && (
<div className="planning-checkbox-group">
{question.options.map((option) => {
{questionOptions.map((option) => {
const selected = (response[question.id] as string[]) || [];
return (
<label key={option.id} className="planning-option planning-option--checkbox">
@@ -1264,6 +1329,35 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
</label>
);
})}
{/* FNXC:PlanningInterview 2026-06-26-00:00: The synthetic Other checkbox is additive for mission multi-select questions so user-authored answers can stand alone or augment provided options. */}
<label className="planning-option planning-option--checkbox" data-testid="planning-option-other">
<input
type="checkbox"
value={MISSION_INTERVIEW_OTHER_OPTION_ID}
checked={isOtherSelected}
onChange={(e) => {
setIsOtherSelected(e.target.checked);
if (!e.target.checked) {
setOtherValue("");
}
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{t("missions.otherOptionLabel", "Other (write your own)")}</span>
</div>
</label>
{isOtherSelected && (
<div className="planning-other-answer">
<textarea
className="planning-textarea"
data-testid="planning-other-input"
rows={2}
placeholder={t("missions.otherOptionPlaceholder", "Write your own answer...")}
value={otherValue}
onChange={(e) => setOtherValue(e.target.value)}
/>
</div>
)}
</div>
)}

View File

@@ -766,6 +766,14 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
margin-top: var(--space-lg);
}
.planning-other-answer {
margin-top: var(--space-sm);
}
.planning-other-answer .planning-textarea {
min-height: calc(var(--space-md) * 5);
}
.planning-comment-label {
display: block;
margin-bottom: var(--space-xs);
@@ -1627,7 +1635,8 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
font-size: 16px;
}
.planning-comment-section .planning-textarea {
.planning-comment-section .planning-textarea,
.planning-other-answer .planning-textarea {
min-height: calc(var(--space-md) * 5);
}

View File

@@ -156,6 +156,9 @@ function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary {
};
}
const PLANNING_OTHER_RESPONSE_KEY = "_other";
const PLANNING_OTHER_OPTION_ID = "__other__";
function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion {
if (question.type !== "single_select" && question.type !== "multi_select") {
return question;
@@ -2430,6 +2433,8 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const [otherValue, setOtherValue] = useState("");
const [isOtherSelected, setIsOtherSelected] = useState(false);
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
value: textValue,
minHeight: 120,
@@ -2442,6 +2447,12 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
maxHeight: 640,
deps: [question.id],
});
const { ref: otherAutosizeRef } = useAutosizeTextarea({
value: otherValue,
minHeight: 80,
maxHeight: 640,
deps: [question.id],
});
const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
@@ -2450,6 +2461,24 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") {
nextResponse = { [question.id]: response[question.id] === true };
} else if (question.type === "single_select") {
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-06-26-00:00:
GitHub #1794 requires dashboard planning interviews to let users reject every AI-provided single-select option and submit their own framing instead. Store that answer under the reserved `_other` key so agent prompts and history can distinguish it from an option id.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { [PLANNING_OTHER_RESPONSE_KEY]: trimmedOther }
: response;
} else if (question.type === "multi_select") {
const trimmedOther = otherValue.trim();
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Multi-select planning questions can combine provided choices with a user-authored Other answer. Preserve selected option ids and append `_other` only while the Other checkbox is active and non-empty.
*/
nextResponse = isOtherSelected && trimmedOther.length > 0
? { ...response, [PLANNING_OTHER_RESPONSE_KEY]: trimmedOther }
: response;
} else {
nextResponse = response;
}
@@ -2460,13 +2489,15 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
}
onSubmit(nextResponse);
}, [commentValue, question, response, textValue, onSubmit]);
}, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]);
// Reset state when question changes
useEffect(() => {
setResponse({});
setTextValue("");
setCommentValue("");
setOtherValue("");
setIsOtherSelected(false);
}, [question.id]);
const isValid = () => {
@@ -2474,9 +2505,17 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
case "text":
return textValue.trim().length > 0;
case "single_select":
return response[question.id] !== undefined;
/*
FNXC:PlanningInterview 2026-06-26-00:00:
The Other radio is a first-class valid answer only when it has non-whitespace text; the Continue button must not force an unwanted provided option.
*/
return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0);
case "multi_select":
return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0;
/*
FNXC:PlanningInterview 2026-06-26-00:00:
The Other checkbox may be the only multi-select answer, but empty Other text is incomplete so users do not submit a blank custom answer.
*/
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;
default:
@@ -2538,8 +2577,12 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
type="radio"
name={question.id}
value={option.id}
checked={response[question.id] === option.id}
onChange={() => setResponse({ [question.id]: option.id })}
checked={response[question.id] === option.id && !isOtherSelected}
onChange={() => {
setIsOtherSelected(false);
setOtherValue("");
setResponse({ [question.id]: option.id });
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
@@ -2549,6 +2592,34 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
</div>
</label>
))}
{/* FNXC:PlanningInterview 2026-06-26-00:00: The synthetic Other radio must appear beside provided options so planning users can decline all suggested answers without leaving the structured question flow. */}
<label className="planning-option planning-option--radio" data-testid="planning-option-other">
<input
type="radio"
name={question.id}
value={PLANNING_OTHER_OPTION_ID}
checked={isOtherSelected}
onChange={() => {
setIsOtherSelected(true);
setResponse({});
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{t("planning.otherOptionLabel", "Other (write your own)")}</span>
</div>
</label>
{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>
)}
@@ -2578,6 +2649,35 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
</label>
);
})}
{/* FNXC:PlanningInterview 2026-06-26-00:00: The synthetic Other checkbox is additive for multi-select so users can mix provided answers with their own answer or use only their own answer. */}
<label className="planning-option planning-option--checkbox" data-testid="planning-option-other">
<input
type="checkbox"
value={PLANNING_OTHER_OPTION_ID}
checked={isOtherSelected}
onChange={(e) => {
setIsOtherSelected(e.target.checked);
if (!e.target.checked) {
setOtherValue("");
}
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{t("planning.otherOptionLabel", "Other (write your own)")}</span>
</div>
</label>
{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>
)}

View File

@@ -454,6 +454,319 @@ describe("MilestoneSliceInterviewModal", () => {
});
describe("comment input", () => {
it("submits trimmed Other-only answers for single-select milestone questions", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion(SAMPLE_QUESTION);
});
await screen.findByText("What is the target scope?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Split this differently " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
{ _other: "Split this differently" },
"test-project",
"test-tab-id",
);
});
});
it("renders Other for single-select milestone questions with no provided options", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="MS-001"
targetTitle="Test Milestone"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "open_scope",
type: "single_select",
question: "What is the target scope?",
});
});
await screen.findByText("What is the target scope?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Define a custom scope " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMilestoneInterview).toHaveBeenCalledWith(
"session-123",
{ _other: "Define a custom scope" },
"test-project",
"test-tab-id",
);
});
});
it("clears stale Other text when unchecking Other in multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: "Keep this manual" },
});
expect(continueButton).toBeEnabled();
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(screen.queryByTestId("planning-other-input")).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ priorities: ["speed"] },
"test-project",
"test-tab-id",
);
});
});
it("submits Other-only answers for multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Reframe around dependencies " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ _other: "Reframe around dependencies" },
"test-project",
"test-tab-id",
);
});
});
it("renders Other for multi-select slice questions with no provided options", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "open_priorities",
type: "multi_select",
question: "Which priorities matter?",
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Ask customers first " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ _other: "Ask customers first" },
"test-project",
"test-tab-id",
);
});
});
it("combines provided options with Other text for multi-select slice questions", async () => {
mockStartSliceInterview.mockResolvedValue({ sessionId: "slice-session-123" });
render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="slice"
targetId="SL-001"
targetTitle="Test Slice"
projectId="test-project"
/>,
);
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(screen.getByText(/Preparing next question/)).toBeDefined();
});
act(() => {
streamHandlers.onQuestion({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
await screen.findByText("Which priorities matter?");
const continueButton = screen.getByRole("button", { name: /Continue/ });
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Preserve manual review " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToSliceInterview).toHaveBeenCalledWith(
"slice-session-123",
{ priorities: ["speed"], _other: "Preserve manual review" },
"test-project",
"test-tab-id",
);
});
});
it("shows comment textarea and submits _comment in milestone interview", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });

View File

@@ -617,6 +617,250 @@ describe("MissionInterviewModal", () => {
});
});
it("submits trimmed Other-only answers for single-select mission questions", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
const continueButton = await screen.findByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Start with discovery instead " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ _other: "Start with discovery instead" },
undefined,
expect.any(String),
);
});
});
it("renders Other for single-select mission questions with no provided options", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.({
id: "open_scope",
type: "single_select",
question: "What scope should we use?",
});
});
const continueButton = await screen.findByRole("button", { name: "Continue" });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Define a custom scope " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ _other: "Define a custom scope" },
undefined,
expect.any(String),
);
});
});
it("clears stale Other text when switching back to a provided mission option", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
const continueButton = await screen.findByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: "Plan a discovery mission" },
});
expect(continueButton).toBeEnabled();
fireEvent.click(screen.getByText("MVP"));
expect(screen.queryByTestId("planning-other-input")).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ scope: "mvp" },
undefined,
expect.any(String),
);
});
});
it("submits Other-only answers for multi-select mission questions", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
const continueButton = await screen.findByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Add field research first " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ _other: "Add field research first" },
undefined,
expect.any(String),
);
});
});
it("renders Other for multi-select mission questions with no provided options", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.({
id: "open_priorities",
type: "multi_select",
question: "Which priorities matter?",
});
});
const continueButton = await screen.findByRole("button", { name: "Continue" });
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Ask customers first " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ _other: "Ask customers first" },
undefined,
expect.any(String),
);
});
});
it("combines provided options with Other text for multi-select mission questions", async () => {
renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.({
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
});
const continueButton = await screen.findByRole("button", { name: "Continue" });
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
fireEvent.change(screen.getByTestId("planning-other-input"), {
target: { value: " Preserve operator review " },
});
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToMissionInterview).toHaveBeenCalledWith(
"mission-session-1",
{ priorities: ["speed"], _other: "Preserve operator review" },
undefined,
expect.any(String),
);
});
});
it("restores persisted goal from localStorage on open", () => {
mockGetMissionGoal.mockReturnValue("Previous mission goal");

View File

@@ -424,6 +424,206 @@ describe("PlanningModeModal", () => {
);
});
it("allows Other-only answers for single-select planning questions", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
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("What is the scope?")).toBeDefined();
});
const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
const otherInput = screen.getByTestId("planning-other-input");
fireEvent.change(otherInput, { target: { value: " Make this a design spike " } });
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ _other: "Make this a design spike" },
undefined,
"tab-self",
);
});
});
it("clears stale Other text when switching back to a provided planning option", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
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("What is the 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: " " } });
expect(continueButton).toBeDisabled();
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: "Ignore suggested scope" } });
expect(continueButton).toBeEnabled();
fireEvent.click(screen.getByText("Small"));
expect(screen.queryByTestId("planning-other-input")).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ "q-scope": "small" },
undefined,
"tab-self",
);
});
});
it("allows Other-only answers for multi-select planning questions", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.({
id: "q-priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
}, 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("Which priorities matter?")).toBeDefined();
});
const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByTestId("planning-option-other"));
expect(continueButton).toBeDisabled();
const otherInput = screen.getByTestId("planning-other-input");
fireEvent.change(otherInput, { target: { value: " Challenge the premise " } });
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ _other: "Challenge the premise" },
undefined,
"tab-self",
);
});
});
it("combines provided options with Other text for multi-select planning questions on mobile", async () => {
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
mockViewport("mobile");
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
setTimeout(() => {
handlers.onQuestion?.({
id: "q-priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
});
}, 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("Which priorities matter?")).toBeDefined();
});
const continueButton = screen.getByRole("button", { name: "Continue" });
fireEvent.click(screen.getByText("Speed"));
fireEvent.click(screen.getByTestId("planning-option-other"));
const otherInput = screen.getByTestId("planning-other-input");
fireEvent.change(otherInput, { target: { value: " Preserve operator control " } });
expect(continueButton).toBeEnabled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-123",
{ "q-priorities": ["speed"], _other: "Preserve operator control" },
undefined,
"tab-self",
);
});
});
it("shows stop action in loading and stops generation", async () => {
let streamHandlers: any;
const closeSpy = vi.fn();

View File

@@ -8,6 +8,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
resolveMcpServersForStore: async () => ({ servers: [] }),
buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => {
const requestedSkillNames = ["fusion"];
for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) {
@@ -51,6 +52,8 @@ import {
SLICE_INTERVIEW_SYSTEM_PROMPT,
stopMilestoneSliceInterviewGeneration,
GENERATION_TIMEOUT_MS,
formatInterviewHistory,
formatResponseForAgent,
type MilestoneInterviewSummary,
type SliceInterviewSummary,
} from "../milestone-slice-interview.js";
@@ -77,6 +80,50 @@ function createQuestionJson(id = "q-1"): string {
});
}
describe("milestone/slice interview formatter Other answers", () => {
const singleSelectQuestion = {
id: "scope",
type: "single_select" as const,
question: "What scope should this slice use?",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full launch" },
],
};
const multiSelectQuestion = {
id: "priorities",
type: "multi_select" as const,
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
};
it("formats Other-only single-select answers for the target interview agent and history replay", () => {
const response = { _other: "Split this by rollout risk" };
expect(formatResponseForAgent(singleSelectQuestion, response)).toContain(
"Selected: Split this by rollout risk (user's own answer)",
);
expect(formatInterviewHistory([{ question: singleSelectQuestion, response }])).toContain(
"A: Split this by rollout risk (user's own answer)",
);
});
it("appends Other text to multi-select answers for the target interview agent and history replay", () => {
const response = { priorities: ["speed"], _other: "Keep QA manual" };
expect(formatResponseForAgent(multiSelectQuestion, response)).toContain(
"Selected: Speed, Keep QA manual (user's own answer)",
);
expect(formatInterviewHistory([{ question: multiSelectQuestion, response }])).toContain(
"A: Speed, Keep QA manual (user's own answer)",
);
});
});
function createMilestoneCompleteJson(): string {
return JSON.stringify({
type: "complete",
@@ -315,7 +362,7 @@ describe("milestone-slice-interview module", () => {
const session = getTargetInterviewSession(sessionId);
expect(session).toBeDefined();
expect(session?.targetType).toBe("milestone");
const createFnAgentCallArg = mockCreateFnAgent.mock.calls.at(-1)?.[0] as { customTools?: Array<{ name: string }> };
const createFnAgentCallArg = await waitForCreateFnAgentOptions() as { customTools?: Array<{ name: string }> };
const customToolNames = createFnAgentCallArg.customTools?.map((tool) => tool.name) ?? [];
expect(customToolNames).toContain("fn_task_list");
expect(customToolNames).toContain("fn_task_get");

View File

@@ -8,6 +8,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
resolveMcpServersForStore: async () => ({ servers: [] }),
buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => {
const requestedSkillNames = ["fusion"];
for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) {
@@ -46,6 +47,8 @@ import {
stopMissionInterviewGeneration,
submitMissionInterviewResponse,
GENERATION_TIMEOUT_MS,
formatMissionInterviewHistory,
formatResponseForAgent,
} from "../mission-interview.js";
import {
setDiagnosticsSink,
@@ -75,6 +78,50 @@ function createQuestionJson(id = "q-1"): string {
});
}
describe("mission interview formatter Other answers", () => {
const singleSelectQuestion = {
id: "scope",
type: "single_select" as const,
question: "What scope should this mission use?",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full launch" },
],
};
const multiSelectQuestion = {
id: "priorities",
type: "multi_select" as const,
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
};
it("formats Other-only single-select answers for the mission agent and history replay", () => {
const response = { _other: "Interview stakeholders first" };
expect(formatResponseForAgent(singleSelectQuestion, response)).toContain(
"Selected: Interview stakeholders first (user's own answer)",
);
expect(formatMissionInterviewHistory([{ question: singleSelectQuestion, response }])).toContain(
"A: Interview stakeholders first (user's own answer)",
);
});
it("appends Other text to multi-select answers for the mission agent and history replay", () => {
const response = { priorities: ["quality"], _other: "Keep launch reversible" };
expect(formatResponseForAgent(multiSelectQuestion, response)).toContain(
"Selected: Quality, Keep launch reversible (user's own answer)",
);
expect(formatMissionInterviewHistory([{ question: multiSelectQuestion, response }])).toContain(
"A: Quality, Keep launch reversible (user's own answer)",
);
});
});
function createCompleteJson(): string {
return JSON.stringify({
type: "complete",

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { PlanningQuestion } from "@fusion/core";
import { formatInterviewQA, formatResponseForAgent } from "../planning";
const singleSelectQuestion: PlanningQuestion = {
id: "scope",
type: "single_select",
question: "What scope should we plan?",
options: [
{ id: "mvp", label: "MVP" },
{ id: "full", label: "Full launch" },
],
};
const multiSelectQuestion: PlanningQuestion = {
id: "priorities",
type: "multi_select",
question: "Which priorities matter?",
options: [
{ id: "speed", label: "Speed" },
{ id: "quality", label: "Quality" },
],
};
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" };
expect(formatResponseForAgent(singleSelectQuestion, response)).toContain(
"Selected: Run discovery first (user's own answer)",
);
expect(formatInterviewQA([{ question: singleSelectQuestion, response }])).toContain(
"A: Run discovery first (user's own answer)",
);
});
it("appends Other text to multi-select option labels for the planning agent and Q&A history", () => {
const response = { priorities: ["speed"], _other: "Keep humans in review" };
expect(formatResponseForAgent(multiSelectQuestion, response)).toContain(
"Selected: Speed, Keep humans in review (user's own answer)",
);
expect(formatInterviewQA([{ question: multiSelectQuestion, response }])).toContain(
"A: Speed, Keep humans in review (user's own answer)",
);
});
});

View File

@@ -653,12 +653,13 @@ export function getRateLimitResetTime(ip: string): Date | null {
/**
* Format user response as a message for the AI agent.
*/
function formatResponseForAgent(
export function formatResponseForAgent(
question: PlanningQuestion,
responses: Record<string, unknown>
): string {
const responseValue = responses[question.id];
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
const other = typeof responses._other === "string" ? responses._other.trim() : "";
let formatted: string;
@@ -667,6 +668,14 @@ function formatResponseForAgent(
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "single_select":
/*
FNXC:PlanningInterview 2026-06-26-00:00:
GitHub #1794 requires milestone/slice Other-only single-select answers to reach the agent as the user's own answer rather than an undefined fallback or unwanted provided option.
*/
if (other.length > 0) {
formatted = `Question: ${question.question}\n\nSelected: ${other} (user's own answer)`;
break;
}
if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue);
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
@@ -675,11 +684,18 @@ function formatResponseForAgent(
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "multi_select":
if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => {
if (Array.isArray(responseValue) || other.length > 0) {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
const option = question.options?.find((o) => o.id === id);
return option?.label || id;
});
}) : [];
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Milestone/slice multi-select Other answers are additive context; append the free-text answer to selected labels and keep Other-only payloads explicit for the agent.
*/
if (other.length > 0) {
selected.push(`${other} (user's own answer)`);
}
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
break;
}
@@ -767,7 +783,41 @@ export async function createTargetInterviewAgent(
});
}
function formatInterviewHistory(
function formatTargetInterviewHistoryAnswer(question: PlanningQuestion, responseValue: unknown, other: string): string {
switch (question.type) {
case "single_select": {
if (other.length > 0) {
return `${other} (user's own answer)`;
}
if (typeof responseValue === "string") {
const option = question.options?.find((candidate) => candidate.id === responseValue);
return option?.label || responseValue;
}
return String(responseValue ?? "");
}
case "multi_select": {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
if (typeof id !== "string") {
return String(id);
}
const option = question.options?.find((candidate) => candidate.id === id);
return option?.label || id;
}) : [];
if (other.length > 0) {
selected.push(`${other} (user's own answer)`);
}
return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
}
case "confirm":
return responseValue === true ? "Yes" : "No";
case "text":
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
default:
return JSON.stringify(responseValue ?? null);
}
}
export function formatInterviewHistory(
history: Array<{ question: PlanningQuestion; response: unknown }>,
): string {
if (history.length === 0) {
@@ -782,10 +832,11 @@ function formatInterviewHistory(
: undefined;
const responseValue = responseRecord ? responseRecord[question.id] : response;
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
const other = typeof responseRecord?._other === "string" ? responseRecord._other.trim() : "";
const lines = [
`Q: ${question.question}`,
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
`A: ${formatTargetInterviewHistoryAnswer(question, responseValue, other)}`,
];
if (comment.length > 0) {

View File

@@ -738,12 +738,13 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
/**
* Format user response as a message for the AI agent.
*/
function formatResponseForAgent(
export function formatResponseForAgent(
question: PlanningQuestion,
responses: Record<string, unknown>
): string {
const responseValue = responses[question.id];
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
const other = typeof responses._other === "string" ? responses._other.trim() : "";
let formatted: string;
@@ -752,6 +753,14 @@ function formatResponseForAgent(
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "single_select":
/*
FNXC:PlanningInterview 2026-06-26-00:00:
GitHub #1794 requires mission Other-only single-select answers to reach the agent as the user's own answer rather than an undefined fallback or unwanted provided option.
*/
if (other.length > 0) {
formatted = `Question: ${question.question}\n\nSelected: ${other} (user's own answer)`;
break;
}
if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue);
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
@@ -760,11 +769,18 @@ function formatResponseForAgent(
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
break;
case "multi_select":
if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => {
if (Array.isArray(responseValue) || other.length > 0) {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
const option = question.options?.find((o) => o.id === id);
return option?.label || id;
});
}) : [];
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Mission multi-select Other answers are additive context; append the free-text answer to selected labels and keep Other-only payloads explicit for the agent.
*/
if (other.length > 0) {
selected.push(`${other} (user's own answer)`);
}
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
break;
}
@@ -892,7 +908,41 @@ export async function createMissionInterviewAgent(
});
}
function formatMissionInterviewHistory(
function formatMissionHistoryAnswer(question: PlanningQuestion, responseValue: unknown, other: string): string {
switch (question.type) {
case "single_select": {
if (other.length > 0) {
return `${other} (user's own answer)`;
}
if (typeof responseValue === "string") {
const option = question.options?.find((candidate) => candidate.id === responseValue);
return option?.label || responseValue;
}
return String(responseValue ?? "");
}
case "multi_select": {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
if (typeof id !== "string") {
return String(id);
}
const option = question.options?.find((candidate) => candidate.id === id);
return option?.label || id;
}) : [];
if (other.length > 0) {
selected.push(`${other} (user's own answer)`);
}
return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
}
case "confirm":
return responseValue === true ? "Yes" : "No";
case "text":
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
default:
return JSON.stringify(responseValue ?? null);
}
}
export function formatMissionInterviewHistory(
history: Array<{ question: PlanningQuestion; response: unknown }>,
): string {
if (history.length === 0) {
@@ -907,10 +957,11 @@ function formatMissionInterviewHistory(
: undefined;
const responseValue = responseRecord ? responseRecord[question.id] : response;
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
const other = typeof responseRecord?._other === "string" ? responseRecord._other.trim() : "";
const lines = [
`Q: ${question.question}`,
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
`A: ${formatMissionHistoryAnswer(question, responseValue, other)}`,
];
if (comment.length > 0) {

View File

@@ -2421,12 +2421,13 @@ export function stopGeneration(sessionId: string): boolean {
/**
* Format user response as a message for the AI agent.
*/
function formatResponseForAgent(
export function formatResponseForAgent(
question: PlanningQuestion,
responses: Record<string, unknown>
): string {
const responseValue = responses[question.id];
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
const other = typeof responses._other === "string" ? responses._other.trim() : "";
let formatted: string;
@@ -2436,6 +2437,14 @@ function formatResponseForAgent(
break;
case "single_select":
/*
FNXC:PlanningInterview 2026-06-26-00:00:
GitHub #1794 requires Other-only single-select answers to reach the planning agent as the user's own answer instead of forcing a provided option id or rendering an undefined fallback.
*/
if (other.length > 0) {
formatted = `Question: ${question.question}\n\nSelected: ${other} (user's own answer)`;
break;
}
if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue);
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
@@ -2445,11 +2454,18 @@ function formatResponseForAgent(
break;
case "multi_select":
if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => {
if (Array.isArray(responseValue) || other.length > 0) {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
const option = question.options?.find((o) => o.id === id);
return option?.label || id;
});
}) : [];
/*
FNXC:PlanningInterview 2026-06-26-00:00:
Multi-select Other answers are additive agent context; append the free-text answer to the selected list and keep Other-only payloads from collapsing to a blank/undefined answer.
*/
if (other.length > 0) {
selected.push(`${other} (user's own answer)`);
}
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
break;
}
@@ -2493,30 +2509,34 @@ function disposeSessionAgentForRetry(session: Session): void {
session.agent = undefined;
}
function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknown): string {
function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknown, other = ""): string {
switch (question.type) {
case "text":
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
case "single_select":
if (other.length > 0) {
return `${other} (user's own answer)`;
}
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(", ");
case "multi_select": {
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
if (typeof id !== "string") {
return String(id);
}
const option = question.options?.find((candidate) => candidate.id === id);
return option?.label || id;
}) : [];
if (other.length > 0) {
selected.push(`${other} (user's own answer)`);
}
return String(responseValue ?? "");
return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
}
case "confirm":
return responseValue === true ? "Yes" : "No";
@@ -2543,8 +2563,9 @@ export function formatInterviewQA(
: undefined;
const responseValue = responseRecord ? responseRecord[question.id] : response;
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
const other = typeof responseRecord?._other === "string" ? responseRecord._other.trim() : "";
const answerLine = `**Q: ${question.question}**\nA: ${formatInterviewAnswer(question, responseValue)}`;
const answerLine = `**Q: ${question.question}**\nA: ${formatInterviewAnswer(question, responseValue, other)}`;
return comment.length > 0 ? `${answerLine}\nComment: ${comment}` : answerLine;
});