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:
7
.changeset/fn-7089-interview-other-option.md
Normal file
7
.changeset/fn-7089-interview-other-option.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user