FN-8818: enforce planning response choices

Ensure Planning Mode always presents four distinct suggested responses and one Other choice.

- Normalize model and restored awaiting-input questions with localized fallback options
- Deduplicate rendered options and cover restored-session and UI selection behavior
- Document the response-choice contract and add a patch changeset

Files changed:
 .changeset/fn-8818-planning-response-choices.md    |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/PlanningModeModal.tsx |  39 ++--
 .../PlanningModeModal.ui-interactions.test.tsx     |  37 +++-
 .../planning-answered-question-reemit.test.ts      |  11 +-
 .../__tests__/planning-infinite-interview.test.ts  |  59 +++++-
 packages/dashboard/src/planning.ts                 | 205 +++++++++++++++++----
 7 files changed, 298 insertions(+), 62 deletions(-)

Fusion-Task-Id: FN-8818

Fusion-Task-Lineage: d515c293-c5ab-437b-86c9-734207321ded

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-06 16:19:33 -07:00
parent 6bacfd74f5
commit c3f4af67d6
7 changed files with 298 additions and 62 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show four distinct Planning Mode responses plus one write-your-own choice.
category: fix
dev: Planning question normalization reserves localized fallback alternatives for malformed or restored responses.

View File

@@ -608,7 +608,7 @@ Planning Mode is a single-surface sequence: enter an idea, wait while Fusion gen
<!-- FNXC:PlanningMode 2026-08-03-09:21: After five completed question-and-response pairs, mobile interview footers expose Review plan as a non-submitting shortcut to the existing Plan preview tab. The Questions tab remains available, preserving any unsent response so operators can review and continue without losing their draft. -->
After five completed answers, mobile interviews show **Next question** and **Review plan**. **Review plan** selects the existing **Plan preview** tab only: it does not submit the current response or start generation. Select **Questions** at any time to return to the same question and unsent answer. Desktop and earlier mobile turns retain the single **Next** action.
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; every selection question presents four materially distinct suggested responses with pros and cons plus exactly one **Other** free-text choice.
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
<!-- FNXC:PlanningMode 2026-08-03-10:15: FN-8759 requires Planning Mode task handoff to retain the full ordered interview, including selected answers, custom Other text, and comments, without replacing the verbatim original request. -->

View File

@@ -364,27 +364,24 @@ function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion
return question;
}
const options = Array.isArray(question.options)
? question.options
.filter((option): option is { id: string; label: string; description?: string } =>
Boolean(
option &&
typeof option === "object" &&
typeof option.id === "string" &&
option.id.trim().length > 0 &&
typeof option.label === "string" &&
option.label.trim().length > 0 &&
(option.description === undefined || typeof option.description === "string") &&
option.isOther !== true &&
option.id !== "other" &&
option.id !== PLANNING_OTHER_OPTION_ID,
),
)
.map((option) => ({
...option,
id: option.id.trim(),
label: option.label.trim(),
...(option.description ? { description: option.description.trim() } : {}),
}))
? (() => {
const ids = new Set<string>();
const labels = new Set<string>();
const descriptions = new Set<string>();
return question.options.flatMap((option) => {
if (!option || typeof option !== "object" || typeof option.id !== "string" || !option.id.trim()
|| typeof option.label !== "string" || !option.label.trim() || (option.description !== undefined && typeof option.description !== "string")
|| option.isOther === true || option.id === "other" || option.id === PLANNING_OTHER_OPTION_ID) return [];
const id = option.id.trim();
const label = option.label.trim();
const description = option.description?.trim();
if (ids.has(id) || labels.has(label) || (description && descriptions.has(description))) return [];
ids.add(id);
labels.add(label);
if (description) descriptions.add(description);
return [{ ...option, id, label, ...(description ? { description } : {}) }];
});
})()
: [];
return { ...question, options };
}

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { readAppFile } from "../../test/cssFixture";
import { QuestionForm } from "../PlanningModeModal";
afterEach(cleanup);
describe("PlanningModeModal sequential layout", () => {
it("uses one persistent responsive plan-and-question workspace", () => {
@@ -44,6 +50,35 @@ describe("PlanningModeModal sequential layout", () => {
expect(component).toContain("pendingOpenCommentQuoteRef");
});
it("renders four normalized choices plus one Other without duplicate rows", () => {
const onSubmit = vi.fn();
render(<QuestionForm
question={{
id: "direction",
type: "single_select",
question: "Which direction should we take?",
options: [
{ id: "speed", label: "Ship quickly", description: "Deliver a focused version.", pros: ["Fast"], cons: ["Narrow"] },
{ id: "reliable", label: "Prioritize reliability", description: "Build safeguards first.", pros: ["Safe"], cons: ["Slow"] },
{ id: "scope", label: "Reduce scope", description: "Deliver the essentials only.", pros: ["Small"], cons: ["Later"] },
{ id: "learn", label: "Investigate first", description: "Research before deciding.", pros: ["Informed"], cons: ["Delayed"] },
{ id: "speed", label: "Duplicate ID", description: "Must not render.", pros: [""], cons: [""] },
{ id: "other", label: "Duplicate Other", isOther: true },
],
}}
onSubmit={onSubmit}
/>);
for (const label of ["Ship quickly", "Prioritize reliability", "Reduce scope", "Investigate first", "Other (write your own)"]) {
expect(screen.getByText(label)).toBeTruthy();
}
expect(screen.queryByText("Duplicate ID")).toBeNull();
expect(screen.getAllByRole("radio")).toHaveLength(5);
fireEvent.click(screen.getByRole("radio", { name: /reduce scope/i }));
fireEvent.click(screen.getByRole("button", { name: /^next$/i }));
expect(onSubmit).toHaveBeenCalledWith({ direction: "scope" });
});
it("keeps plan actions in a non-scrolling sibling footer with equal mobile columns", () => {
const css = readAppFile("components/PlanningModeModal.css");
expect(css).toMatch(/\.planning-actions\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);

View File

@@ -9,6 +9,11 @@ auto-retry opens one), and the client's question handler reset the bounded auto-
an unbounded loop. Invariant under test: currentQuestion is only set while the session is
genuinely awaiting user input — cleared on answer accept, on retry, and never restored from
non-awaiting_input persisted rows.
FNXC:PlanningMode 2026-08-06-23:18:
An awaiting-input row is the one valid restored-question surface. Its legacy model payload
must cross the same normalization boundary as a live response, preserving four alternatives
and one Other choice instead of trusting persisted options.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -322,6 +327,10 @@ describe("answered planning questions are never re-emittable", () => {
expect((await getSession("restored-error"))?.currentQuestion).toBeUndefined();
expect((await getSession("restored-generating"))?.currentQuestion).toBeUndefined();
expect((await getSession("restored-awaiting"))?.currentQuestion).toMatchObject({ id: Q1.id, question: Q1.question });
const restoredQuestion = (await getSession("restored-awaiting"))?.currentQuestion;
expect(restoredQuestion).toMatchObject({ id: Q1.id, question: Q1.question, type: "single_select" });
expect(restoredQuestion?.options).toHaveLength(5);
expect(restoredQuestion?.options?.filter((option) => !option.isOther)).toHaveLength(4);
expect(restoredQuestion?.options?.filter((option) => option.isOther)).toHaveLength(1);
});
});

View File

@@ -22,6 +22,7 @@ vi.mock("@fusion/engine", () => ({
import {
__resetPlanningState,
__setCreateFnAgent,
__validatePlanningFallbackOptionsForTests,
__setPlanningNtfyHelpers,
createSession,
createSessionWithAgent,
@@ -229,15 +230,67 @@ describe("reactive Planning Mode question contract", () => {
options: [{ id: "fast", label: "Rápido", pros: [], cons: [] }],
}, "Quiero añadir autenticación para usuarios españoles");
expect(question.options).toHaveLength(3);
expect(question.options).toHaveLength(5);
const alternatives = question.options!.filter((option) => !option.isOther);
expect(alternatives).toHaveLength(2);
expect(alternatives).toHaveLength(4);
expect(alternatives.every((option) => option.pros!.length > 0 && option.cons!.length > 0)).toBe(true);
expect(question.options!.filter((option) => option.isOther)).toEqual([
expect.objectContaining({ label: "Otro (escribe tu respuesta)", isOther: true }),
]);
});
it("keeps the first four materially distinct choices and rejects canonical collisions", () => {
const question = normalizePlanningQuestion({
type: "multi_select",
question: "Which direction?",
options: [
{ id: "one", label: "Fast path", description: "Ship first.", pros: ["Fast"], cons: ["Risk"] },
{ id: "two", label: "FAST—PATH", description: "Different description.", pros: ["Fast"], cons: ["Risk"] },
{ id: "three", label: "Quality path", description: "Ship first。", pros: ["Safe"], cons: ["Slow"] },
{ id: "four", label: "Investigate", description: "Learn before committing.", pros: ["Learn"], cons: ["Delay"] },
{ id: "five", label: "Reduced scope", description: "Deliver less first.", pros: ["Small"], cons: ["Later"] },
{ id: "six", label: "Extra", description: "A sixth distinct choice.", pros: ["More"], cons: ["Noise"] },
],
});
const alternatives = question.options!.filter((option) => !option.isOther);
expect(question.type).toBe("multi_select");
expect(alternatives).toHaveLength(4);
expect(alternatives.map((option) => option.id)).toEqual(["one", "four", "five", "six"]);
expect(question.options!.filter((option) => option.isOther)).toHaveLength(1);
expect(normalizePlanningQuestion(question)).toEqual(question);
});
it("reserves all fallback archetypes and fails closed for invalid fallback definitions", () => {
const collisions = ["fallback-speed", "fallback-reliability", "fallback-scope", "fallback-investigate"].map((id) => ({
id,
label: id,
description: `${id} description`,
pros: ["Pro"],
cons: ["Con"],
}));
const question = normalizePlanningQuestion({ type: "single_select", question: "What next?", options: collisions });
const alternatives = question.options!.filter((option) => !option.isOther);
expect(alternatives).toHaveLength(4);
expect(new Set(alternatives.map((option) => option.id)).size).toBe(4);
expect(alternatives.every((option) => option.id.startsWith("fallback-"))).toBe(true);
expect(__validatePlanningFallbackOptionsForTests({
question: "Question", other: "Other",
options: [
{ id: "same", label: "Same", description: "Same", pros: ["Pro"], cons: ["Con"] },
{ id: "same", label: "Different", description: "Different", pros: ["Pro"], cons: ["Con"] },
{ id: "third", label: "Third", description: "Third", pros: ["Pro"], cons: ["Con"] },
{ id: "fourth", label: "Fourth", description: "Fourth", pros: ["Pro"], cons: ["Con"] },
],
})).toBe(false);
});
it("requires four distinct alternatives in the planning prompt", () => {
expect(PLANNING_SYSTEM_PROMPT).toMatch(/exactly four materially distinct actionable alternatives/i);
expect(PLANNING_SYSTEM_PROMPT).toMatch(/option-c/);
expect(PLANNING_SYSTEM_PROMPT).toMatch(/option-d/);
});
it("deduplicates a model-authored Other option before appending the canonical one", () => {
const question = normalizePlanningQuestion({
id: "security",
@@ -256,7 +309,7 @@ describe("reactive Planning Mode question contract", () => {
it("upgrades legacy text questions so every question has alternatives and Other", () => {
const question = normalizePlanningQuestion({ type: "text", question: "What matters next?", options: [{ id: "bad" }] });
expect(question).toEqual(expect.objectContaining({ type: "single_select", question: "What matters next?" }));
expect(question.options).toHaveLength(3);
expect(question.options).toHaveLength(5);
expect(question.options?.at(-1)).toEqual(expect.objectContaining({ isOther: true }));
});

View File

@@ -265,13 +265,13 @@ Help the operator iteratively turn an idea into a clear, useful plan. First inve
Build an evolving operator-facing plan, not an executor-ready task specification. Focus on intended outcomes, concrete deliverables, alternatives considered, and observable acceptance criteria. Do not produce task-specification bookkeeping or execution-process instructions such as task-size policy, commit guidance, no-code-change caveats, or task-creation directives. Author the operator-facing plan in Markdown: write the description as concise GitHub-flavored Markdown, while the structured change, acceptance, dependency, and deliverable fields become its Markdown sections and lists.
Use a deliberate iterative narrowing loop: analyze → concrete options → operator selection → plan rebuild → one deeper question. When the opener is vague, subjective, preference-based, or symptom-only, inspect the relevant implementation surface before proposing at least two materially distinct actionable directions grounded in those findings, plus exactly one Other option. Do not ask a generic clarification question or silently select a direction. Keep the provisional plan honest about unselected alternatives.
Use a deliberate iterative narrowing loop: analyze → concrete options → operator selection → plan rebuild → one deeper question. When the opener is vague, subjective, preference-based, or symptom-only, inspect the relevant implementation surface before proposing exactly four materially distinct actionable directions grounded in those findings, plus exactly one Other option. Do not ask a generic clarification question or silently select a direction. Keep the provisional plan honest about unselected alternatives.
After every selected option, multi-selection, or free-text Other answer, treat the choice as a durable decision and rebuild every affected running-plan field around all accumulated decisions. The title, description, proposedChanges, acceptanceCriteria, keyDeliverables, and suggestedRefinements must make the selected direction—not the original vague complaint or an unselected alternative—the central intended outcome. Preserve Other text verbatim as steering. Then inspect the selected direction and relevant repository context and ask exactly one consequential next question that narrows it one level further with concrete, materially distinct options. A refine turn uses the selected or free-text focus to choose that next question. Continue this loop until the operator chooses Proceed with plan. The model never validates or terminates the session. Only the user can validate it through the visible Proceed with plan action.
For every initial, answer, or refine turn respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}}.
For every initial, answer, or refine turn respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-c","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-d","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","proposedChanges":["specific change"],"acceptanceCriteria":["observable outcome"],"suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"],"suggestedRefinements":["next focus 1","next focus 2"]}}}.
Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and concise suggestedRefinements informed by the idea and answers so far. Include every distinct, high-value unresolved refinement area; do not cap the list at three. Never use interview question text as a deliverable. Proceed with plan serializes the plan as plan.md without priority or suggestedRefinements; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
Every turn must include the running-plan fields: only title, description, concrete proposedChanges, observable acceptanceCriteria, suggestedSize, optional priority, suggestedDependencies, concrete keyDeliverables, and concise suggestedRefinements informed by the idea and answers so far. Include every distinct, high-value unresolved refinement area; do not cap the list at three. Never use interview question text as a deliverable. Proceed with plan serializes the plan as plan.md without priority or suggestedRefinements; priority remains a task field. Every question must provide exactly four materially distinct actionable alternatives, each with a non-empty description, pros, and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
/*
FNXC:PlanningMode 2026-07-23-11:35:
@@ -904,6 +904,11 @@ function buildSessionFromRow(row: AiSessionRow): Session {
Only an awaiting_input row has a live question. Rows persisted while generating/error by
pre-fix builds still carry the already-answered question; restoring it would let the SSE
catch-up path re-emit it and re-trigger the answered-question retry loop after a restart.
FNXC:PlanningMode 2026-08-06-23:18:
Restored awaiting-input questions are untrusted persisted model output, just like live
responses. Normalize them at the restore boundary so legacy or malformed rows retain the
four suggested alternatives and one Other choice guaranteed by the Planning Mode UI.
*/
const history = safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory,
@@ -923,10 +928,13 @@ function buildSessionFromRow(row: AiSessionRow): Session {
const currentQuestion = skippedMandatoryInterview
? buildMandatoryFirstPlanningQuestion()
: row.status === "awaiting_input" && row.currentQuestion
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
throwOnError: true,
fieldName: "currentQuestion",
}) ?? undefined)
? (() => {
const persistedQuestion = safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
throwOnError: true,
fieldName: "currentQuestion",
});
return persistedQuestion ? normalizePlanningQuestion(persistedQuestion, payload.initialPlan ?? row.title) : undefined;
})()
: undefined;
return {
@@ -2661,45 +2669,172 @@ function mergeRunningSummary(session: Session, response?: PlanningResponse): Pla
return buildRunningSummary(session.initialPlan, session.history, session.summary);
}
function planningFallbackCopy(input: string): { question: string; option: (n: number) => string; pro: string; con: string; other: string } {
if (/[一-龯]/.test(input)) {
const traditional = /[繁體臺灣與為這個]/.test(input);
return traditional
? { question: "下一步最需要釐清的細節或限制是什麼?", option: (n) => `替代方案 ${n}`, pro: "提供明確的前進方向", con: "可能限制後續選擇", other: "其他(自行填寫)" }
: { question: "接下来最需要细化的细节或限制是什么?", option: (n) => `替代方案 ${n}`, pro: "提供清晰的推进方向", con: "可能限制后续选择", other: "其他(自行填写)" };
}
if (/[가-힣]/.test(input)) return { question: "다음으로 구체화할 가장 중요한 세부 사항이나 제약은 무엇인가요?", option: (n) => `대안 ${n}`, pro: "명확한 진행 방향을 제공합니다", con: "후속 선택을 제한할 수 있습니다", other: "기타(직접 입력)" };
if (/\b(le|la|les|une|fonctionnalité)\b/i.test(input)) return { question: "Quel est le prochain détail ou contrainte le plus important à préciser ?", option: (n) => `Alternative ${n}`, pro: "Donne une direction claire", con: "Peut limiter les choix ultérieurs", other: "Autre (écrivez votre réponse)" };
if (/\b(el|la|los|una|función|característica|español(?:es)?)\b/i.test(input)) return { question: "¿Cuál es el siguiente detalle o restricción más importante que debemos precisar?", option: (n) => `Alternativa ${n}`, pro: "Ofrece una dirección clara", con: "Puede limitar decisiones posteriores", other: "Otro (escribe tu respuesta)" };
return { question: "What is the next most important detail or constraint to refine?", option: (n) => `Option ${n}`, pro: "Provides a clear path forward", con: "May constrain later choices", other: "Other (write your own)" };
type PlanningFallbackOption = {
id: string;
label: string;
description: string;
pros: string[];
cons: string[];
};
type PlanningFallbackCopy = {
question: string;
other: string;
options: readonly PlanningFallbackOption[];
};
const ENGLISH_PLANNING_FALLBACK: PlanningFallbackCopy = {
question: "What is the next most important detail or constraint to refine?",
other: "Other (write your own)",
options: [
{ id: "fallback-speed", label: "Ship a focused first version", description: "Prioritize the fastest useful delivery.", pros: ["Gets value to users sooner"], cons: ["May defer broader improvements"] },
{ id: "fallback-reliability", label: "Invest in reliability first", description: "Prioritize robust behavior and durable safeguards.", pros: ["Reduces operational risk"], cons: ["Takes more time up front"] },
{ id: "fallback-scope", label: "Reduce the initial scope", description: "Deliver only the smallest essential outcome.", pros: ["Keeps the change easier to validate"], cons: ["Leaves some needs for later"] },
{ id: "fallback-investigate", label: "Investigate before committing", description: "Learn about the problem before choosing an implementation.", pros: ["Improves the next decision"], cons: ["Delays implementation"] },
],
};
const PLANNING_FALLBACKS: Record<string, PlanningFallbackCopy> = {
english: ENGLISH_PLANNING_FALLBACK,
spanish: {
question: "¿Cuál es el siguiente detalle o restricción más importante que debemos precisar?",
other: "Otro (escribe tu respuesta)",
options: [
{ id: "fallback-speed", label: "Lanzar una primera versión enfocada", description: "Prioriza la entrega útil más rápida.", pros: ["Aporta valor antes"], cons: ["Puede aplazar mejoras más amplias"] },
{ id: "fallback-reliability", label: "Invertir primero en fiabilidad", description: "Prioriza un comportamiento sólido y salvaguardas duraderas.", pros: ["Reduce el riesgo operativo"], cons: ["Requiere más tiempo inicial"] },
{ id: "fallback-scope", label: "Reducir el alcance inicial", description: "Entrega solo el resultado esencial más pequeño.", pros: ["Facilita validar el cambio"], cons: ["Deja algunas necesidades para después"] },
{ id: "fallback-investigate", label: "Investigar antes de decidir", description: "Aprende sobre el problema antes de elegir la implementación.", pros: ["Mejora la siguiente decisión"], cons: ["Retrasa la implementación"] },
],
},
french: {
question: "Quel est le prochain détail ou contrainte le plus important à préciser ?", other: "Autre (écrivez votre réponse)",
options: [
{ id: "fallback-speed", label: "Livrer une première version ciblée", description: "Privilégie la livraison utile la plus rapide.", pros: ["Apporte de la valeur plus tôt"], cons: ["Peut reporter des améliorations"] },
{ id: "fallback-reliability", label: "Privilégier la fiabilité", description: "Privilégie un comportement robuste et durable.", pros: ["Réduit le risque opérationnel"], cons: ["Demande plus de temps initial"] },
{ id: "fallback-scope", label: "Réduire le périmètre initial", description: "Livre seulement le résultat essentiel.", pros: ["Facilite la validation"], cons: ["Laisse des besoins pour plus tard"] },
{ id: "fallback-investigate", label: "Étudier avant de décider", description: "Apprend sur le problème avant de choisir.", pros: ["Améliore la prochaine décision"], cons: ["Retarde l'implémentation"] },
],
},
korean: {
question: "다음으로 구체화할 가장 중요한 세부 사항이나 제약은 무엇인가요?", other: "기타(직접 입력)",
options: [
{ id: "fallback-speed", label: "집중된 첫 버전 빠르게 출시", description: "가장 빠른 유용한 제공을 우선합니다.", pros: ["더 빨리 가치를 제공합니다"], cons: ["넓은 개선을 미룰 수 있습니다"] },
{ id: "fallback-reliability", label: "신뢰성에 먼저 투자", description: "견고한 동작과 지속적인 보호책을 우선합니다.", pros: ["운영 위험을 줄입니다"], cons: ["초기 시간이 더 듭니다"] },
{ id: "fallback-scope", label: "초기 범위 축소", description: "가장 작은 필수 결과만 제공합니다.", pros: ["변경 검증이 쉬워집니다"], cons: ["일부 요구를 나중으로 남깁니다"] },
{ id: "fallback-investigate", label: "결정 전에 조사", description: "구현을 고르기 전에 문제를 학습합니다.", pros: ["다음 결정을 개선합니다"], cons: ["구현이 늦어집니다"] },
],
},
simplifiedChinese: {
question: "接下来最需要细化的细节或限制是什么?", other: "其他(自行填写)",
options: [
{ id: "fallback-speed", label: "快速交付聚焦的首版", description: "优先最快的有用交付。", pros: ["更早提供价值"], cons: ["可能推迟更广泛的改进"] },
{ id: "fallback-reliability", label: "先投资可靠性", description: "优先稳健行为和持久保障。", pros: ["降低运营风险"], cons: ["前期需要更多时间"] },
{ id: "fallback-scope", label: "缩小初始范围", description: "只交付最小的必要结果。", pros: ["更易验证变更"], cons: ["一些需求留待以后"] },
{ id: "fallback-investigate", label: "先调查再决定", description: "在选择实现前了解问题。", pros: ["改善下一次决策"], cons: ["延迟实现"] },
],
},
traditionalChinese: {
question: "下一步最需要釐清的細節或限制是什麼?", other: "其他(自行填寫)",
options: [
{ id: "fallback-speed", label: "快速交付聚焦的首版", description: "優先最快的有用交付。", pros: ["更早提供價值"], cons: ["可能延後更廣泛的改善"] },
{ id: "fallback-reliability", label: "先投資可靠性", description: "優先穩健行為和持久保障。", pros: ["降低營運風險"], cons: ["前期需要更多時間"] },
{ id: "fallback-scope", label: "縮小初始範圍", description: "只交付最小的必要結果。", pros: ["更易驗證變更"], cons: ["一些需求留待以後"] },
{ id: "fallback-investigate", label: "先調查再決定", description: "在選擇實作前了解問題。", pros: ["改善下一次決策"], cons: ["延後實作"] },
],
},
};
function canonicalPlanningOptionText(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US").replace(/[\p{P}\p{S}\s]/gu, "");
}
export function __validatePlanningFallbackOptionsForTests(fallback: PlanningFallbackCopy): boolean {
if (fallback.options.length !== 4 || !fallback.question.trim() || !fallback.other.trim()) return false;
const ids = new Set<string>();
const labels = new Set<string>();
const descriptions = new Set<string>();
return fallback.options.every((option) => {
const id = option.id.trim();
const label = canonicalPlanningOptionText(option.label);
const description = canonicalPlanningOptionText(option.description);
const complete = id.length > 0 && label.length > 0 && description.length > 0
&& option.pros.some((item) => item.trim()) && option.cons.some((item) => item.trim());
if (!complete || ids.has(id) || labels.has(label) || descriptions.has(description)) return false;
ids.add(id);
labels.add(label);
descriptions.add(description);
return true;
});
}
const HAS_VALID_PLANNING_FALLBACKS = Object.values(PLANNING_FALLBACKS).every((fallback) => __validatePlanningFallbackOptionsForTests(fallback));
function planningFallbackCopy(input: string): PlanningFallbackCopy {
const locale = /[一-龯]/.test(input)
? (/[繁體臺灣與為這個]/.test(input) ? "traditionalChinese" : "simplifiedChinese")
: /[가-힣]/.test(input) ? "korean"
: /\b(le|la|les|une|fonctionnalité)\b/i.test(input) ? "french"
: /\b(el|la|los|una|función|característica|español(?:es)?)\b/i.test(input) ? "spanish" : "english";
const selected = PLANNING_FALLBACKS[locale] ?? ENGLISH_PLANNING_FALLBACK;
return HAS_VALID_PLANNING_FALLBACKS && __validatePlanningFallbackOptionsForTests(selected) ? selected : ENGLISH_PLANNING_FALLBACK;
}
function normalizedStringArray(value: unknown, fallback: string[]): string[] {
return Array.isArray(value) && value.some((item) => typeof item === "string" && item.trim())
? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).map((item) => item.trim())
: fallback;
}
/*
FNXC:PlanningMode 2026-08-06-22:54:
Every Planning turn must expose four materially distinct routes plus one free-text Other choice.
The model is instructed to provide that shape, while this canonical boundary normalizes Unicode,
whitespace, case, and punctuation collisions and reserves every locale fallback archetype so bad
model output, retries, and restored sessions cannot exhaust deterministic replacement choices.
*/
/** Normalizes untrusted model output so select questions always meet the public option contract. */
export function normalizePlanningQuestion(input: unknown, userInput = ""): PlanningQuestion {
const source = input && typeof input === "object" ? input as Record<string, unknown> : {};
const fallback = planningFallbackCopy(userInput);
// Every Planning question needs alternatives and an Other steer, so a model's legacy
// text question is upgraded to a select question instead of losing that escape hatch.
const type = source.type === "multi_select" || source.type === "single_select" ? source.type : "single_select";
const question = typeof source.question === "string" && source.question.trim() ? source.question.trim() : fallback.question;
const normalized: PlanningQuestion = { id: typeof source.id === "string" && source.id.trim() ? source.id : randomUUID(), type, question,
const normalized: PlanningQuestion = { id: typeof source.id === "string" && source.id.trim() ? source.id.trim() : randomUUID(), type, question,
...(typeof source.description === "string" && source.description.trim() ? { description: source.description.trim() } : {}) };
const fallbackIds = new Set(fallback.options.map((option) => option.id));
const fallbackLabels = new Set(fallback.options.map((option) => canonicalPlanningOptionText(option.label)));
const fallbackDescriptions = new Set(fallback.options.map((option) => canonicalPlanningOptionText(option.description)));
const ids = new Set<string>();
const labels = new Set<string>();
const descriptions = new Set<string>();
const alternatives: Array<Omit<PlanningFallbackOption, "description"> & { description?: string }> = [];
const raw = Array.isArray(source.options) ? source.options : [];
const alternatives = raw.filter((item): item is Record<string, unknown> => {
if (!item || typeof item !== "object") return false;
for (const item of raw) {
if (!item || typeof item !== "object" || alternatives.length === 4) continue;
const option = item as Record<string, unknown>;
return option.isOther !== true && option.id !== "other" && option.id !== "__other__";
})
.slice(0, 2).map((item, index) => ({
id: typeof item.id === "string" && item.id.trim() ? item.id : `option-${index + 1}`,
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : fallback.option(index + 1),
...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
pros: Array.isArray(item.pros) && item.pros.some((v) => typeof v === "string" && v.trim()) ? item.pros.filter((v): v is string => typeof v === "string" && Boolean(v.trim())).map((v) => v.trim()) : [fallback.pro],
cons: Array.isArray(item.cons) && item.cons.some((v) => typeof v === "string" && v.trim()) ? item.cons.filter((v): v is string => typeof v === "string" && Boolean(v.trim())).map((v) => v.trim()) : [fallback.con],
}));
while (alternatives.length < 2) {
const n = alternatives.length + 1;
alternatives.push({ id: `option-${n}`, label: fallback.option(n), pros: [fallback.pro], cons: [fallback.con] });
if (option.isOther === true || option.id === "other" || option.id === "__other__") continue;
const id = typeof option.id === "string" ? option.id.trim() : "";
const label = typeof option.label === "string" ? option.label.trim() : "";
const description = typeof option.description === "string" ? option.description.trim() : "";
const canonicalLabel = canonicalPlanningOptionText(label);
const canonicalDescription = canonicalPlanningOptionText(description);
if (!id || !label || fallbackIds.has(id) || ids.has(id) || fallbackLabels.has(canonicalLabel)
|| labels.has(canonicalLabel) || (description && (fallbackDescriptions.has(canonicalDescription) || descriptions.has(canonicalDescription)))) continue;
alternatives.push({
id,
label,
...(description ? { description } : {}),
pros: normalizedStringArray(option.pros, [fallback.options[0].pros[0]]),
cons: normalizedStringArray(option.cons, [fallback.options[0].cons[0]]),
});
ids.add(id);
labels.add(canonicalLabel);
if (description) descriptions.add(canonicalDescription);
}
for (const option of fallback.options) {
if (alternatives.length === 4) break;
if (!ids.has(option.id)) alternatives.push({ ...option, pros: [...option.pros], cons: [...option.cons] });
}
normalized.options = [...alternatives, { id: "other", label: fallback.other, isOther: true }];
return normalized;