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.
|
- 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: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:
|
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 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.
|
- 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.
|
- 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
|
## Roadmaps View
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import { useViewportMode } from "../hooks/useViewportMode";
|
|||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
const WARNING_ICON = "⚠️";
|
const WARNING_ICON = "⚠️";
|
||||||
|
const MILESTONE_SLICE_OTHER_RESPONSE_KEY = "_other";
|
||||||
|
const MILESTONE_SLICE_OTHER_OPTION_ID = "__other__";
|
||||||
|
|
||||||
interface MilestoneSliceInterviewModalProps {
|
interface MilestoneSliceInterviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -645,9 +647,12 @@ interface InterviewQuestionFormProps {
|
|||||||
|
|
||||||
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const questionOptions = question.options ?? [];
|
||||||
const [response, setResponse] = useState<QuestionResponse>({});
|
const [response, setResponse] = useState<QuestionResponse>({});
|
||||||
const [textValue, setTextValue] = useState("");
|
const [textValue, setTextValue] = useState("");
|
||||||
const [commentValue, setCommentValue] = useState("");
|
const [commentValue, setCommentValue] = useState("");
|
||||||
|
const [otherValue, setOtherValue] = useState("");
|
||||||
|
const [isOtherSelected, setIsOtherSelected] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
let nextResponse: QuestionResponse;
|
let nextResponse: QuestionResponse;
|
||||||
@@ -656,6 +661,24 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
nextResponse = { [question.id]: textValue };
|
nextResponse = { [question.id]: textValue };
|
||||||
} else if (question.type === "confirm") {
|
} else if (question.type === "confirm") {
|
||||||
nextResponse = { [question.id]: response[question.id] === true };
|
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 {
|
} else {
|
||||||
nextResponse = response;
|
nextResponse = response;
|
||||||
}
|
}
|
||||||
@@ -666,12 +689,14 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(nextResponse);
|
onSubmit(nextResponse);
|
||||||
}, [commentValue, question, response, textValue, onSubmit]);
|
}, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResponse({});
|
setResponse({});
|
||||||
setTextValue("");
|
setTextValue("");
|
||||||
setCommentValue("");
|
setCommentValue("");
|
||||||
|
setOtherValue("");
|
||||||
|
setIsOtherSelected(false);
|
||||||
}, [question.id]);
|
}, [question.id]);
|
||||||
|
|
||||||
const isValid = () => {
|
const isValid = () => {
|
||||||
@@ -679,9 +704,17 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
case "text":
|
case "text":
|
||||||
return textValue.trim().length > 0;
|
return textValue.trim().length > 0;
|
||||||
case "single_select":
|
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":
|
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":
|
case "confirm":
|
||||||
return response[question.id] !== undefined;
|
return response[question.id] !== undefined;
|
||||||
default:
|
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">
|
<div className="planning-radio-group" role="radiogroup">
|
||||||
{question.options.map((option) => (
|
{questionOptions.map((option) => (
|
||||||
<label key={option.id} className="planning-option planning-option--radio">
|
<label key={option.id} className="planning-option planning-option--radio">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name={question.id}
|
name={question.id}
|
||||||
value={option.id}
|
value={option.id}
|
||||||
checked={response[question.id] === option.id}
|
checked={response[question.id] === option.id && !isOtherSelected}
|
||||||
onChange={() => setResponse({ [question.id]: option.id })}
|
onChange={() => {
|
||||||
|
setIsOtherSelected(false);
|
||||||
|
setOtherValue("");
|
||||||
|
setResponse({ [question.id]: option.id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="planning-option-content">
|
<div className="planning-option-content">
|
||||||
<span className="planning-option-label">{option.label}</span>
|
<span className="planning-option-label">{option.label}</span>
|
||||||
@@ -754,12 +791,40 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{question.type === "multi_select" && question.options && (
|
{question.type === "multi_select" && (
|
||||||
<div className="planning-checkbox-group">
|
<div className="planning-checkbox-group">
|
||||||
{question.options.map((option) => {
|
{questionOptions.map((option) => {
|
||||||
const selected = (response[question.id] as string[]) || [];
|
const selected = (response[question.id] as string[]) || [];
|
||||||
return (
|
return (
|
||||||
<label key={option.id} className="planning-option planning-option--checkbox">
|
<label key={option.id} className="planning-option planning-option--checkbox">
|
||||||
@@ -783,6 +848,35 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WARNING_ICON = "⚠️";
|
const WARNING_ICON = "⚠️";
|
||||||
|
const MISSION_INTERVIEW_OTHER_RESPONSE_KEY = "_other";
|
||||||
|
const MISSION_INTERVIEW_OTHER_OPTION_ID = "__other__";
|
||||||
|
|
||||||
interface MissionInterviewModalProps {
|
interface MissionInterviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -1126,9 +1128,12 @@ interface InterviewQuestionFormProps {
|
|||||||
|
|
||||||
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const questionOptions = question.options ?? [];
|
||||||
const [response, setResponse] = useState<QuestionResponse>({});
|
const [response, setResponse] = useState<QuestionResponse>({});
|
||||||
const [textValue, setTextValue] = useState("");
|
const [textValue, setTextValue] = useState("");
|
||||||
const [commentValue, setCommentValue] = useState("");
|
const [commentValue, setCommentValue] = useState("");
|
||||||
|
const [otherValue, setOtherValue] = useState("");
|
||||||
|
const [isOtherSelected, setIsOtherSelected] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
let nextResponse: QuestionResponse;
|
let nextResponse: QuestionResponse;
|
||||||
@@ -1137,6 +1142,24 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
nextResponse = { [question.id]: textValue };
|
nextResponse = { [question.id]: textValue };
|
||||||
} else if (question.type === "confirm") {
|
} else if (question.type === "confirm") {
|
||||||
nextResponse = { [question.id]: response[question.id] === true };
|
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 {
|
} else {
|
||||||
nextResponse = response;
|
nextResponse = response;
|
||||||
}
|
}
|
||||||
@@ -1147,12 +1170,14 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(nextResponse);
|
onSubmit(nextResponse);
|
||||||
}, [commentValue, question, response, textValue, onSubmit]);
|
}, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResponse({});
|
setResponse({});
|
||||||
setTextValue("");
|
setTextValue("");
|
||||||
setCommentValue("");
|
setCommentValue("");
|
||||||
|
setOtherValue("");
|
||||||
|
setIsOtherSelected(false);
|
||||||
}, [question.id]);
|
}, [question.id]);
|
||||||
|
|
||||||
const isValid = () => {
|
const isValid = () => {
|
||||||
@@ -1160,9 +1185,17 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
case "text":
|
case "text":
|
||||||
return textValue.trim().length > 0;
|
return textValue.trim().length > 0;
|
||||||
case "single_select":
|
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":
|
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":
|
case "confirm":
|
||||||
return response[question.id] !== undefined;
|
return response[question.id] !== undefined;
|
||||||
default:
|
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">
|
<div className="planning-radio-group" role="radiogroup">
|
||||||
{question.options.map((option) => (
|
{questionOptions.map((option) => (
|
||||||
<label key={option.id} className="planning-option planning-option--radio">
|
<label key={option.id} className="planning-option planning-option--radio">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name={question.id}
|
name={question.id}
|
||||||
value={option.id}
|
value={option.id}
|
||||||
checked={response[question.id] === option.id}
|
checked={response[question.id] === option.id && !isOtherSelected}
|
||||||
onChange={() => setResponse({ [question.id]: option.id })}
|
onChange={() => {
|
||||||
|
setIsOtherSelected(false);
|
||||||
|
setOtherValue("");
|
||||||
|
setResponse({ [question.id]: option.id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="planning-option-content">
|
<div className="planning-option-content">
|
||||||
<span className="planning-option-label">{option.label}</span>
|
<span className="planning-option-label">{option.label}</span>
|
||||||
@@ -1235,12 +1272,40 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{question.type === "multi_select" && question.options && (
|
{question.type === "multi_select" && (
|
||||||
<div className="planning-checkbox-group">
|
<div className="planning-checkbox-group">
|
||||||
{question.options.map((option) => {
|
{questionOptions.map((option) => {
|
||||||
const selected = (response[question.id] as string[]) || [];
|
const selected = (response[question.id] as string[]) || [];
|
||||||
return (
|
return (
|
||||||
<label key={option.id} className="planning-option planning-option--checkbox">
|
<label key={option.id} className="planning-option planning-option--checkbox">
|
||||||
@@ -1264,6 +1329,35 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
|||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -766,6 +766,14 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
|
|||||||
margin-top: var(--space-lg);
|
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 {
|
.planning-comment-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--space-xs);
|
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;
|
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);
|
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 {
|
function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion {
|
||||||
if (question.type !== "single_select" && question.type !== "multi_select") {
|
if (question.type !== "single_select" && question.type !== "multi_select") {
|
||||||
return question;
|
return question;
|
||||||
@@ -2430,6 +2433,8 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
const [response, setResponse] = useState<QuestionResponse>({});
|
const [response, setResponse] = useState<QuestionResponse>({});
|
||||||
const [textValue, setTextValue] = useState("");
|
const [textValue, setTextValue] = useState("");
|
||||||
const [commentValue, setCommentValue] = useState("");
|
const [commentValue, setCommentValue] = useState("");
|
||||||
|
const [otherValue, setOtherValue] = useState("");
|
||||||
|
const [isOtherSelected, setIsOtherSelected] = useState(false);
|
||||||
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
|
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
|
||||||
value: textValue,
|
value: textValue,
|
||||||
minHeight: 120,
|
minHeight: 120,
|
||||||
@@ -2442,6 +2447,12 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
maxHeight: 640,
|
maxHeight: 640,
|
||||||
deps: [question.id],
|
deps: [question.id],
|
||||||
});
|
});
|
||||||
|
const { ref: otherAutosizeRef } = useAutosizeTextarea({
|
||||||
|
value: otherValue,
|
||||||
|
minHeight: 80,
|
||||||
|
maxHeight: 640,
|
||||||
|
deps: [question.id],
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
let nextResponse: QuestionResponse;
|
let nextResponse: QuestionResponse;
|
||||||
@@ -2450,6 +2461,24 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
nextResponse = { [question.id]: textValue };
|
nextResponse = { [question.id]: textValue };
|
||||||
} else if (question.type === "confirm") {
|
} else if (question.type === "confirm") {
|
||||||
nextResponse = { [question.id]: response[question.id] === true };
|
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 {
|
} else {
|
||||||
nextResponse = response;
|
nextResponse = response;
|
||||||
}
|
}
|
||||||
@@ -2460,13 +2489,15 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(nextResponse);
|
onSubmit(nextResponse);
|
||||||
}, [commentValue, question, response, textValue, onSubmit]);
|
}, [commentValue, isOtherSelected, otherValue, question, response, textValue, onSubmit]);
|
||||||
|
|
||||||
// Reset state when question changes
|
// Reset state when question changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResponse({});
|
setResponse({});
|
||||||
setTextValue("");
|
setTextValue("");
|
||||||
setCommentValue("");
|
setCommentValue("");
|
||||||
|
setOtherValue("");
|
||||||
|
setIsOtherSelected(false);
|
||||||
}, [question.id]);
|
}, [question.id]);
|
||||||
|
|
||||||
const isValid = () => {
|
const isValid = () => {
|
||||||
@@ -2474,9 +2505,17 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
case "text":
|
case "text":
|
||||||
return textValue.trim().length > 0;
|
return textValue.trim().length > 0;
|
||||||
case "single_select":
|
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":
|
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":
|
case "confirm":
|
||||||
return response[question.id] !== undefined;
|
return response[question.id] !== undefined;
|
||||||
default:
|
default:
|
||||||
@@ -2538,8 +2577,12 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
type="radio"
|
type="radio"
|
||||||
name={question.id}
|
name={question.id}
|
||||||
value={option.id}
|
value={option.id}
|
||||||
checked={response[question.id] === option.id}
|
checked={response[question.id] === option.id && !isOtherSelected}
|
||||||
onChange={() => setResponse({ [question.id]: option.id })}
|
onChange={() => {
|
||||||
|
setIsOtherSelected(false);
|
||||||
|
setOtherValue("");
|
||||||
|
setResponse({ [question.id]: option.id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="planning-option-content">
|
<div className="planning-option-content">
|
||||||
<span className="planning-option-label">{option.label}</span>
|
<span className="planning-option-label">{option.label}</span>
|
||||||
@@ -2549,6 +2592,34 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2578,6 +2649,35 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
|||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -454,6 +454,319 @@ describe("MilestoneSliceInterviewModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("comment input", () => {
|
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 () => {
|
it("shows comment textarea and submits _comment in milestone interview", async () => {
|
||||||
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
|
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", () => {
|
it("restores persisted goal from localStorage on open", () => {
|
||||||
mockGetMissionGoal.mockReturnValue("Previous mission goal");
|
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 () => {
|
it("shows stop action in loading and stops generation", async () => {
|
||||||
let streamHandlers: any;
|
let streamHandlers: any;
|
||||||
const closeSpy = vi.fn();
|
const closeSpy = vi.fn();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
listCliAdapterDescriptors: () => [],
|
||||||
|
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||||
buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => {
|
buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => {
|
||||||
const requestedSkillNames = ["fusion"];
|
const requestedSkillNames = ["fusion"];
|
||||||
for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) {
|
for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) {
|
||||||
@@ -51,6 +52,8 @@ import {
|
|||||||
SLICE_INTERVIEW_SYSTEM_PROMPT,
|
SLICE_INTERVIEW_SYSTEM_PROMPT,
|
||||||
stopMilestoneSliceInterviewGeneration,
|
stopMilestoneSliceInterviewGeneration,
|
||||||
GENERATION_TIMEOUT_MS,
|
GENERATION_TIMEOUT_MS,
|
||||||
|
formatInterviewHistory,
|
||||||
|
formatResponseForAgent,
|
||||||
type MilestoneInterviewSummary,
|
type MilestoneInterviewSummary,
|
||||||
type SliceInterviewSummary,
|
type SliceInterviewSummary,
|
||||||
} from "../milestone-slice-interview.js";
|
} 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 {
|
function createMilestoneCompleteJson(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: "complete",
|
type: "complete",
|
||||||
@@ -315,7 +362,7 @@ describe("milestone-slice-interview module", () => {
|
|||||||
const session = getTargetInterviewSession(sessionId);
|
const session = getTargetInterviewSession(sessionId);
|
||||||
expect(session).toBeDefined();
|
expect(session).toBeDefined();
|
||||||
expect(session?.targetType).toBe("milestone");
|
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) ?? [];
|
const customToolNames = createFnAgentCallArg.customTools?.map((tool) => tool.name) ?? [];
|
||||||
expect(customToolNames).toContain("fn_task_list");
|
expect(customToolNames).toContain("fn_task_list");
|
||||||
expect(customToolNames).toContain("fn_task_get");
|
expect(customToolNames).toContain("fn_task_get");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
listCliAdapterDescriptors: () => [],
|
||||||
|
resolveMcpServersForStore: async () => ({ servers: [] }),
|
||||||
buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => {
|
buildSessionSkillContextSync: (_agent: unknown, sessionPurpose: string, projectRootDir: string, pluginRunner?: { getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }> }) => {
|
||||||
const requestedSkillNames = ["fusion"];
|
const requestedSkillNames = ["fusion"];
|
||||||
for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) {
|
for (const contribution of pluginRunner?.getPluginSkills?.() ?? []) {
|
||||||
@@ -46,6 +47,8 @@ import {
|
|||||||
stopMissionInterviewGeneration,
|
stopMissionInterviewGeneration,
|
||||||
submitMissionInterviewResponse,
|
submitMissionInterviewResponse,
|
||||||
GENERATION_TIMEOUT_MS,
|
GENERATION_TIMEOUT_MS,
|
||||||
|
formatMissionInterviewHistory,
|
||||||
|
formatResponseForAgent,
|
||||||
} from "../mission-interview.js";
|
} from "../mission-interview.js";
|
||||||
import {
|
import {
|
||||||
setDiagnosticsSink,
|
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 {
|
function createCompleteJson(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: "complete",
|
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.
|
* Format user response as a message for the AI agent.
|
||||||
*/
|
*/
|
||||||
function formatResponseForAgent(
|
export function formatResponseForAgent(
|
||||||
question: PlanningQuestion,
|
question: PlanningQuestion,
|
||||||
responses: Record<string, unknown>
|
responses: Record<string, unknown>
|
||||||
): string {
|
): string {
|
||||||
const responseValue = responses[question.id];
|
const responseValue = responses[question.id];
|
||||||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||||||
|
const other = typeof responses._other === "string" ? responses._other.trim() : "";
|
||||||
|
|
||||||
let formatted: string;
|
let formatted: string;
|
||||||
|
|
||||||
@@ -667,6 +668,14 @@ function formatResponseForAgent(
|
|||||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
break;
|
break;
|
||||||
case "single_select":
|
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") {
|
if (typeof responseValue === "string") {
|
||||||
const option = question.options?.find((o) => o.id === responseValue);
|
const option = question.options?.find((o) => o.id === responseValue);
|
||||||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||||
@@ -675,11 +684,18 @@ function formatResponseForAgent(
|
|||||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
break;
|
break;
|
||||||
case "multi_select":
|
case "multi_select":
|
||||||
if (Array.isArray(responseValue)) {
|
if (Array.isArray(responseValue) || other.length > 0) {
|
||||||
const selected = responseValue.map((id) => {
|
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
|
||||||
const option = question.options?.find((o) => o.id === id);
|
const option = question.options?.find((o) => o.id === id);
|
||||||
return option?.label || 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(", ")}`;
|
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||||
break;
|
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 }>,
|
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||||
): string {
|
): string {
|
||||||
if (history.length === 0) {
|
if (history.length === 0) {
|
||||||
@@ -782,10 +832,11 @@ function formatInterviewHistory(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||||||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||||
|
const other = typeof responseRecord?._other === "string" ? responseRecord._other.trim() : "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`Q: ${question.question}`,
|
`Q: ${question.question}`,
|
||||||
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
|
`A: ${formatTargetInterviewHistoryAnswer(question, responseValue, other)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (comment.length > 0) {
|
if (comment.length > 0) {
|
||||||
|
|||||||
@@ -738,12 +738,13 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
|
|||||||
/**
|
/**
|
||||||
* Format user response as a message for the AI agent.
|
* Format user response as a message for the AI agent.
|
||||||
*/
|
*/
|
||||||
function formatResponseForAgent(
|
export function formatResponseForAgent(
|
||||||
question: PlanningQuestion,
|
question: PlanningQuestion,
|
||||||
responses: Record<string, unknown>
|
responses: Record<string, unknown>
|
||||||
): string {
|
): string {
|
||||||
const responseValue = responses[question.id];
|
const responseValue = responses[question.id];
|
||||||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||||||
|
const other = typeof responses._other === "string" ? responses._other.trim() : "";
|
||||||
|
|
||||||
let formatted: string;
|
let formatted: string;
|
||||||
|
|
||||||
@@ -752,6 +753,14 @@ function formatResponseForAgent(
|
|||||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
break;
|
break;
|
||||||
case "single_select":
|
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") {
|
if (typeof responseValue === "string") {
|
||||||
const option = question.options?.find((o) => o.id === responseValue);
|
const option = question.options?.find((o) => o.id === responseValue);
|
||||||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||||
@@ -760,11 +769,18 @@ function formatResponseForAgent(
|
|||||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue}`;
|
||||||
break;
|
break;
|
||||||
case "multi_select":
|
case "multi_select":
|
||||||
if (Array.isArray(responseValue)) {
|
if (Array.isArray(responseValue) || other.length > 0) {
|
||||||
const selected = responseValue.map((id) => {
|
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
|
||||||
const option = question.options?.find((o) => o.id === id);
|
const option = question.options?.find((o) => o.id === id);
|
||||||
return option?.label || 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(", ")}`;
|
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||||
break;
|
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 }>,
|
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||||
): string {
|
): string {
|
||||||
if (history.length === 0) {
|
if (history.length === 0) {
|
||||||
@@ -907,10 +957,11 @@ function formatMissionInterviewHistory(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||||||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||||
|
const other = typeof responseRecord?._other === "string" ? responseRecord._other.trim() : "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`Q: ${question.question}`,
|
`Q: ${question.question}`,
|
||||||
`A: ${typeof responseValue === "string" ? responseValue : JSON.stringify(responseValue ?? null)}`,
|
`A: ${formatMissionHistoryAnswer(question, responseValue, other)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (comment.length > 0) {
|
if (comment.length > 0) {
|
||||||
|
|||||||
@@ -2421,12 +2421,13 @@ export function stopGeneration(sessionId: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Format user response as a message for the AI agent.
|
* Format user response as a message for the AI agent.
|
||||||
*/
|
*/
|
||||||
function formatResponseForAgent(
|
export function formatResponseForAgent(
|
||||||
question: PlanningQuestion,
|
question: PlanningQuestion,
|
||||||
responses: Record<string, unknown>
|
responses: Record<string, unknown>
|
||||||
): string {
|
): string {
|
||||||
const responseValue = responses[question.id];
|
const responseValue = responses[question.id];
|
||||||
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
const comment = typeof responses._comment === "string" ? responses._comment.trim() : "";
|
||||||
|
const other = typeof responses._other === "string" ? responses._other.trim() : "";
|
||||||
|
|
||||||
let formatted: string;
|
let formatted: string;
|
||||||
|
|
||||||
@@ -2436,6 +2437,14 @@ function formatResponseForAgent(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "single_select":
|
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") {
|
if (typeof responseValue === "string") {
|
||||||
const option = question.options?.find((o) => o.id === responseValue);
|
const option = question.options?.find((o) => o.id === responseValue);
|
||||||
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
formatted = `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
|
||||||
@@ -2445,11 +2454,18 @@ function formatResponseForAgent(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "multi_select":
|
case "multi_select":
|
||||||
if (Array.isArray(responseValue)) {
|
if (Array.isArray(responseValue) || other.length > 0) {
|
||||||
const selected = responseValue.map((id) => {
|
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
|
||||||
const option = question.options?.find((o) => o.id === id);
|
const option = question.options?.find((o) => o.id === id);
|
||||||
return option?.label || 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(", ")}`;
|
formatted = `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2493,30 +2509,34 @@ function disposeSessionAgentForRetry(session: Session): void {
|
|||||||
session.agent = undefined;
|
session.agent = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknown): string {
|
function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknown, other = ""): string {
|
||||||
switch (question.type) {
|
switch (question.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
|
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
|
||||||
|
|
||||||
case "single_select":
|
case "single_select":
|
||||||
|
if (other.length > 0) {
|
||||||
|
return `${other} (user's own answer)`;
|
||||||
|
}
|
||||||
if (typeof responseValue === "string") {
|
if (typeof responseValue === "string") {
|
||||||
const option = question.options?.find((candidate) => candidate.id === responseValue);
|
const option = question.options?.find((candidate) => candidate.id === responseValue);
|
||||||
return option?.label || responseValue;
|
return option?.label || responseValue;
|
||||||
}
|
}
|
||||||
return String(responseValue ?? "");
|
return String(responseValue ?? "");
|
||||||
|
|
||||||
case "multi_select":
|
case "multi_select": {
|
||||||
if (Array.isArray(responseValue)) {
|
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
|
||||||
const selected = responseValue.map((id) => {
|
if (typeof id !== "string") {
|
||||||
if (typeof id !== "string") {
|
return String(id);
|
||||||
return String(id);
|
}
|
||||||
}
|
const option = question.options?.find((candidate) => candidate.id === id);
|
||||||
const option = question.options?.find((candidate) => candidate.id === id);
|
return option?.label || id;
|
||||||
return option?.label || id;
|
}) : [];
|
||||||
});
|
if (other.length > 0) {
|
||||||
return selected.join(", ");
|
selected.push(`${other} (user's own answer)`);
|
||||||
}
|
}
|
||||||
return String(responseValue ?? "");
|
return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
case "confirm":
|
case "confirm":
|
||||||
return responseValue === true ? "Yes" : "No";
|
return responseValue === true ? "Yes" : "No";
|
||||||
@@ -2543,8 +2563,9 @@ export function formatInterviewQA(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
const responseValue = responseRecord ? responseRecord[question.id] : response;
|
||||||
const comment = typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
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;
|
return comment.length > 0 ? `${answerLine}\nComment: ${comment}` : answerLine;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user