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:
7
.changeset/fn-8818-planning-response-choices.md
Normal file
7
.changeset/fn-8818-planning-response-choices.md
Normal 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.
|
||||||
@@ -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. -->
|
<!-- 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.
|
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-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. -->
|
<!-- 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. -->
|
||||||
|
|||||||
@@ -364,27 +364,24 @@ function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion
|
|||||||
return question;
|
return question;
|
||||||
}
|
}
|
||||||
const options = Array.isArray(question.options)
|
const options = Array.isArray(question.options)
|
||||||
? question.options
|
? (() => {
|
||||||
.filter((option): option is { id: string; label: string; description?: string } =>
|
const ids = new Set<string>();
|
||||||
Boolean(
|
const labels = new Set<string>();
|
||||||
option &&
|
const descriptions = new Set<string>();
|
||||||
typeof option === "object" &&
|
return question.options.flatMap((option) => {
|
||||||
typeof option.id === "string" &&
|
if (!option || typeof option !== "object" || typeof option.id !== "string" || !option.id.trim()
|
||||||
option.id.trim().length > 0 &&
|
|| typeof option.label !== "string" || !option.label.trim() || (option.description !== undefined && typeof option.description !== "string")
|
||||||
typeof option.label === "string" &&
|
|| option.isOther === true || option.id === "other" || option.id === PLANNING_OTHER_OPTION_ID) return [];
|
||||||
option.label.trim().length > 0 &&
|
const id = option.id.trim();
|
||||||
(option.description === undefined || typeof option.description === "string") &&
|
const label = option.label.trim();
|
||||||
option.isOther !== true &&
|
const description = option.description?.trim();
|
||||||
option.id !== "other" &&
|
if (ids.has(id) || labels.has(label) || (description && descriptions.has(description))) return [];
|
||||||
option.id !== PLANNING_OTHER_OPTION_ID,
|
ids.add(id);
|
||||||
),
|
labels.add(label);
|
||||||
)
|
if (description) descriptions.add(description);
|
||||||
.map((option) => ({
|
return [{ ...option, id, label, ...(description ? { description } : {}) }];
|
||||||
...option,
|
});
|
||||||
id: option.id.trim(),
|
})()
|
||||||
label: option.label.trim(),
|
|
||||||
...(option.description ? { description: option.description.trim() } : {}),
|
|
||||||
}))
|
|
||||||
: [];
|
: [];
|
||||||
return { ...question, options };
|
return { ...question, options };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { readAppFile } from "../../test/cssFixture";
|
import { readAppFile } from "../../test/cssFixture";
|
||||||
|
import { QuestionForm } from "../PlanningModeModal";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe("PlanningModeModal sequential layout", () => {
|
describe("PlanningModeModal sequential layout", () => {
|
||||||
it("uses one persistent responsive plan-and-question workspace", () => {
|
it("uses one persistent responsive plan-and-question workspace", () => {
|
||||||
@@ -44,6 +50,35 @@ describe("PlanningModeModal sequential layout", () => {
|
|||||||
expect(component).toContain("pendingOpenCommentQuoteRef");
|
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", () => {
|
it("keeps plan actions in a non-scrolling sibling footer with equal mobile columns", () => {
|
||||||
const css = readAppFile("components/PlanningModeModal.css");
|
const css = readAppFile("components/PlanningModeModal.css");
|
||||||
expect(css).toMatch(/\.planning-actions\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
|
expect(css).toMatch(/\.planning-actions\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
|
||||||
|
|||||||
@@ -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
|
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
|
genuinely awaiting user input — cleared on answer accept, on retry, and never restored from
|
||||||
non-awaiting_input persisted rows.
|
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";
|
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-error"))?.currentQuestion).toBeUndefined();
|
||||||
expect((await getSession("restored-generating"))?.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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
import {
|
import {
|
||||||
__resetPlanningState,
|
__resetPlanningState,
|
||||||
__setCreateFnAgent,
|
__setCreateFnAgent,
|
||||||
|
__validatePlanningFallbackOptionsForTests,
|
||||||
__setPlanningNtfyHelpers,
|
__setPlanningNtfyHelpers,
|
||||||
createSession,
|
createSession,
|
||||||
createSessionWithAgent,
|
createSessionWithAgent,
|
||||||
@@ -229,15 +230,67 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
options: [{ id: "fast", label: "Rápido", pros: [], cons: [] }],
|
options: [{ id: "fast", label: "Rápido", pros: [], cons: [] }],
|
||||||
}, "Quiero añadir autenticación para usuarios españoles");
|
}, "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);
|
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(alternatives.every((option) => option.pros!.length > 0 && option.cons!.length > 0)).toBe(true);
|
||||||
expect(question.options!.filter((option) => option.isOther)).toEqual([
|
expect(question.options!.filter((option) => option.isOther)).toEqual([
|
||||||
expect.objectContaining({ label: "Otro (escribe tu respuesta)", isOther: true }),
|
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", () => {
|
it("deduplicates a model-authored Other option before appending the canonical one", () => {
|
||||||
const question = normalizePlanningQuestion({
|
const question = normalizePlanningQuestion({
|
||||||
id: "security",
|
id: "security",
|
||||||
@@ -256,7 +309,7 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
it("upgrades legacy text questions so every question has alternatives and Other", () => {
|
it("upgrades legacy text questions so every question has alternatives and Other", () => {
|
||||||
const question = normalizePlanningQuestion({ type: "text", question: "What matters next?", options: [{ id: "bad" }] });
|
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).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 }));
|
expect(question.options?.at(-1)).toEqual(expect.objectContaining({ isOther: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
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
|
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
|
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.
|
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[]>(
|
const history = safeParseJson<PlanningHistoryEntry[]>(
|
||||||
row.conversationHistory,
|
row.conversationHistory,
|
||||||
@@ -923,10 +928,13 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
|||||||
const currentQuestion = skippedMandatoryInterview
|
const currentQuestion = skippedMandatoryInterview
|
||||||
? buildMandatoryFirstPlanningQuestion()
|
? buildMandatoryFirstPlanningQuestion()
|
||||||
: row.status === "awaiting_input" && row.currentQuestion
|
: row.status === "awaiting_input" && row.currentQuestion
|
||||||
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
|
? (() => {
|
||||||
throwOnError: true,
|
const persistedQuestion = safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
|
||||||
fieldName: "currentQuestion",
|
throwOnError: true,
|
||||||
}) ?? undefined)
|
fieldName: "currentQuestion",
|
||||||
|
});
|
||||||
|
return persistedQuestion ? normalizePlanningQuestion(persistedQuestion, payload.initialPlan ?? row.title) : undefined;
|
||||||
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2661,45 +2669,172 @@ function mergeRunningSummary(session: Session, response?: PlanningResponse): Pla
|
|||||||
return buildRunningSummary(session.initialPlan, session.history, session.summary);
|
return buildRunningSummary(session.initialPlan, session.history, session.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
function planningFallbackCopy(input: string): { question: string; option: (n: number) => string; pro: string; con: string; other: string } {
|
type PlanningFallbackOption = {
|
||||||
if (/[一-龯]/.test(input)) {
|
id: string;
|
||||||
const traditional = /[繁體臺灣與為這個]/.test(input);
|
label: string;
|
||||||
return traditional
|
description: string;
|
||||||
? { question: "下一步最需要釐清的細節或限制是什麼?", option: (n) => `替代方案 ${n}`, pro: "提供明確的前進方向", con: "可能限制後續選擇", other: "其他(自行填寫)" }
|
pros: string[];
|
||||||
: { question: "接下来最需要细化的细节或限制是什么?", option: (n) => `替代方案 ${n}`, pro: "提供清晰的推进方向", con: "可能限制后续选择", other: "其他(自行填写)" };
|
cons: string[];
|
||||||
}
|
};
|
||||||
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)" };
|
type PlanningFallbackCopy = {
|
||||||
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)" };
|
question: string;
|
||||||
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)" };
|
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. */
|
/** Normalizes untrusted model output so select questions always meet the public option contract. */
|
||||||
export function normalizePlanningQuestion(input: unknown, userInput = ""): PlanningQuestion {
|
export function normalizePlanningQuestion(input: unknown, userInput = ""): PlanningQuestion {
|
||||||
const source = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
const source = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
||||||
const fallback = planningFallbackCopy(userInput);
|
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 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 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() } : {}) };
|
...(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 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>;
|
const option = item as Record<string, unknown>;
|
||||||
return option.isOther !== true && option.id !== "other" && option.id !== "__other__";
|
if (option.isOther === true || option.id === "other" || option.id === "__other__") continue;
|
||||||
})
|
const id = typeof option.id === "string" ? option.id.trim() : "";
|
||||||
.slice(0, 2).map((item, index) => ({
|
const label = typeof option.label === "string" ? option.label.trim() : "";
|
||||||
id: typeof item.id === "string" && item.id.trim() ? item.id : `option-${index + 1}`,
|
const description = typeof option.description === "string" ? option.description.trim() : "";
|
||||||
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : fallback.option(index + 1),
|
const canonicalLabel = canonicalPlanningOptionText(label);
|
||||||
...(typeof item.description === "string" && item.description.trim() ? { description: item.description.trim() } : {}),
|
const canonicalDescription = canonicalPlanningOptionText(description);
|
||||||
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],
|
if (!id || !label || fallbackIds.has(id) || ids.has(id) || fallbackLabels.has(canonicalLabel)
|
||||||
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],
|
|| labels.has(canonicalLabel) || (description && (fallbackDescriptions.has(canonicalDescription) || descriptions.has(canonicalDescription)))) continue;
|
||||||
}));
|
alternatives.push({
|
||||||
while (alternatives.length < 2) {
|
id,
|
||||||
const n = alternatives.length + 1;
|
label,
|
||||||
alternatives.push({ id: `option-${n}`, label: fallback.option(n), pros: [fallback.pro], cons: [fallback.con] });
|
...(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 }];
|
normalized.options = [...alternatives, { id: "other", label: fallback.other, isOther: true }];
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
Reference in New Issue
Block a user