FN-8529: add contextual plan review comments

Enable operators to submit selected-plan suggestions through the existing Planning Mode revision flow.

- Capture text selections, batch suggestions, and retain retryable comment submissions in the Planning Mode UI.
- Validate bounded comment payloads and format them for the existing planning session update path.
- Document the workflow and add coverage for ordering, retry behavior, and selection boundaries.

Files changed:
 .changeset/fn-8529-planning-comments.md            |   7 +
 docs/dashboard-guide.md                            |   4 +
 packages/dashboard/app/api/legacy.ts               |   1 +
 packages/dashboard/app/api/planning.ts             |   9 +-
 .../dashboard/app/components/PlanningModeModal.css |  86 ++++++++++++
 .../dashboard/app/components/PlanningModeModal.tsx | 144 ++++++++++++++++++++-
 .../PlanningModeModal.planning-flow.test.tsx       |  39 ++++++
 .../PlanningModeModal.ui-interactions.test.tsx     |  10 ++
 .../__tests__/planning-infinite-interview.test.ts  |  62 +++++++++
 packages/dashboard/src/planning.ts                 |  76 ++++++++++-
 .../src/routes/register-planning-subtask-routes.ts |  27 +++-
 11 files changed, 455 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8529

Fusion-Task-Lineage: c7064d76-effd-4597-a2b4-03f344dd59b9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 00:56:47 -07:00
parent 5f3f7709da
commit ebbb5948ab
11 changed files with 455 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add contextual comments to Planning Mode plan reviews.
category: feature
dev: Batches selected plan quotes and suggestions into the existing plan-update generation.

View File

@@ -2254,3 +2254,7 @@ Productivity duration uses total agent-active time: planning (`cumulativePlannin
### Custom workflow column descriptions ### Custom workflow column descriptions
Custom workflow authors can add optional explanatory copy beneath each column name in the workflow editor. The description appears on selected, aggregate, and archived workflow board columns. Clearing it removes the custom metadata; columns then continue to use the standard lifecycle description when one exists. Custom workflow authors can add optional explanatory copy beneath each column name in the workflow editor. The description appears on selected, aggregate, and archived workflow board columns. Clearing it removes the custom metadata; columns then continue to use the standard lifecycle description when one exists.
## Planning Mode contextual comments
In plan review, select text inside the rendered plan and choose **Add comment to selection**. Enter a suggestion to capture the selected quote and suggestion as a pending contextual comment. You can remove individual comments before choosing **Submit comments**; Fusion sends the ordered batch through the existing Planning Mode revision generation, so the agent revises the quoted areas while preserving unaffected plan content. A successful revised-plan update clears the batch; a failed submission retains it for retry.

View File

@@ -613,6 +613,7 @@ export {
export type { export type {
PlanningSession, PlanningSession,
PlanningResponse, PlanningResponse,
PlanningContextualComment,
PlanningStreamEvent, PlanningStreamEvent,
AgentOnboardingSummary, AgentOnboardingSummary,
OnboardingMode, OnboardingMode,

View File

@@ -155,10 +155,15 @@ export function updatePlanningSessionTitle(sessionId: string, title: string, pro
}); });
} }
/** Submit a response to the current planning question */ export interface PlanningContextualComment {
quote: string;
suggestion: string;
}
/** Submit a response to the current planning question or a bounded contextual-comment batch. */
export function respondToPlanning( export function respondToPlanning(
sessionId: string, sessionId: string,
responses: Record<string, unknown>, responses: Record<string, unknown> | { contextualComments: PlanningContextualComment[] },
projectId?: string, projectId?: string,
): Promise<PlanningResponse> { ): Promise<PlanningResponse> {
return api<PlanningResponse>(withProjectId("/planning/respond", projectId), { return api<PlanningResponse>(withProjectId("/planning/respond", projectId), {

View File

@@ -1761,6 +1761,78 @@ Tablet (769–1024) keeps the same two-pane shell as desktop but previously fell
min-width: 0; min-width: 0;
} }
/*
FNXC:PlanningComments 2026-07-23-12:00:
Contextual comments remain transient review controls: render the capture/editor only after a
selection, and render the tray only after a valid comment exists so an empty plan review keeps
its established Refine and Proceed action hierarchy.
*/
.planning-add-comment {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-lg);
}
.planning-comment-editor,
.planning-comment-tray {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-width: 0;
}
.planning-comment-editor {
margin-top: var(--space-lg);
padding: var(--space-lg);
border: solid var(--btn-border-width) var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.planning-comment-quote,
.planning-comment-tray blockquote,
.planning-comment-tray p {
margin: 0;
overflow-wrap: anywhere;
}
.planning-comment-quote,
.planning-comment-tray blockquote {
padding-inline-start: var(--space-md);
border-inline-start: solid var(--btn-border-width) var(--todo);
color: var(--text-muted);
}
.planning-comment-tray {
flex: 1;
}
.planning-comment-tray ul {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
padding: 0;
list-style: none;
}
.planning-comment-tray li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-sm);
align-items: start;
}
.planning-comment-tray li > p {
grid-column: 1;
}
.planning-comment-tray .btn-icon {
grid-column: 2;
grid-row: 1 / span 2;
}
.planning-summary-actions-right { .planning-summary-actions-right {
display: flex; display: flex;
gap: var(--space-sm); gap: var(--space-sm);
@@ -1787,6 +1859,10 @@ Tablet (769–1024) keeps the same two-pane shell as desktop but previously fell
min-height: calc(var(--space-2xl) + var(--space-md)); min-height: calc(var(--space-2xl) + var(--space-md));
} }
.planning-comment-tray {
grid-column: 1 / -1;
}
.planning-refine-menu { .planning-refine-menu {
right: var(--space-lg); right: var(--space-lg);
left: var(--space-lg); left: var(--space-lg);
@@ -1803,6 +1879,16 @@ Tablet embedded Planning keeps the desktop two-pane shell. The question Next act
Refine/Proceed actions share one compact bottom baseline; equal footer/button heights, non-wrapping Refine/Proceed actions share one compact bottom baseline; equal footer/button heights, non-wrapping
plan actions, and a token-sized bottom inset keep all three controls inline without extra space. plan actions, and a token-sized bottom inset keep all three controls inline without extra space.
*/ */
@media (max-width: 768px) {
.planning-comment-tray li {
grid-template-columns: minmax(0, 1fr) auto;
}
.planning-comment-editor {
padding: var(--space-md);
}
}
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
/* FNXC:PlanningModeMobile 2026-07-20-10:30: tablet uses the same compact list destination as phone and short shells, so summary/compose detail cannot remain beside saved session rows. */ /* FNXC:PlanningModeMobile 2026-07-20-10:30: tablet uses the same compact list destination as phone and short shells, so summary/compose detail cannot remain beside saved session rows. */
.planning-modal-body--show-list { .planning-modal-body--show-list {

View File

@@ -42,6 +42,7 @@ import {
type ModelInfo, type ModelInfo,
type ConversationHistoryEntry, type ConversationHistoryEntry,
type AiSessionSummary, type AiSessionSummary,
type PlanningContextualComment,
} from "../api"; } from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useModalResizePersist } from "../hooks/useModalResizePersist";
@@ -646,6 +647,27 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
non-whitespace text before submission. non-whitespace text before submission.
*/ */
const [refinementPrompt, setRefinementPrompt] = useState(""); const [refinementPrompt, setRefinementPrompt] = useState("");
const [contextualComments, setContextualComments] = useState<PlanningContextualComment[]>([]);
const [selectedPlanQuote, setSelectedPlanQuote] = useState<string | null>(null);
const [commentDraft, setCommentDraft] = useState("");
const [isCommentEditorOpen, setIsCommentEditorOpen] = useState(false);
const contextualCommentInFlightRef = useRef(false);
const contextualCommentSubmissionRef = useRef(false);
const planDocumentRef = useRef<HTMLDivElement>(null);
const commentInputRef = useRef<HTMLTextAreaElement>(null);
const addCommentTriggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
// A batch belongs to one visible session; never carry comments into another plan.
setContextualComments([]);
setSelectedPlanQuote(null);
setCommentDraft("");
setIsCommentEditorOpen(false);
}, [selectedSessionId]);
useEffect(() => {
if (isCommentEditorOpen) commentInputRef.current?.focus();
}, [isCommentEditorOpen]);
useEffect(() => { useEffect(() => {
if (isMobile && workspaceQuestion) { if (isMobile && workspaceQuestion) {
@@ -1167,6 +1189,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
*/ */
runningSummaryRef.current = normalizedSummary; runningSummaryRef.current = normalizedSummary;
setRunningSummary(normalizedSummary); setRunningSummary(normalizedSummary);
setSelectedPlanQuote(null);
setIsCommentEditorOpen(false);
if (contextualCommentSubmissionRef.current) {
contextualCommentSubmissionRef.current = false;
setContextualComments([]);
}
setView((previous) => previous.type === "question" setView((previous) => previous.type === "question"
? { ...previous, session: { ...previous.session, summary: normalizedSummary } } ? { ...previous, session: { ...previous.session, summary: normalizedSummary } }
: previous); : previous);
@@ -2561,6 +2589,61 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} }
}, [connectToPlanningStream, projectId, refinementInstructions, t, view, workspaceQuestion]); }, [connectToPlanningStream, projectId, refinementInstructions, t, view, workspaceQuestion]);
const capturePlanSelection = useCallback(() => {
const selection = window.getSelection();
const root = planDocumentRef.current;
if (!selection || selection.rangeCount === 0 || !root || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) {
setSelectedPlanQuote(null);
return;
}
const quote = selection.toString().replace(/\s+/g, " ").trim();
setSelectedPlanQuote(quote || null);
}, []);
const handleAddContextualComment = useCallback(() => {
const quote = selectedPlanQuote;
const suggestion = commentDraft.trim();
if (!quote || !suggestion) return;
setContextualComments((comments) => [...comments, { quote, suggestion }]);
setCommentDraft("");
setSelectedPlanQuote(null);
setIsCommentEditorOpen(false);
window.getSelection()?.removeAllRanges();
addCommentTriggerRef.current?.focus();
}, [commentDraft, selectedPlanQuote]);
const handleSubmitContextualComments = useCallback(async () => {
const sessionId = currentSessionIdRef.current;
const summary = runningSummaryRef.current;
if (!sessionId || !summary || contextualComments.length === 0 || contextualCommentInFlightRef.current) return;
/* FNXC:PlanningComments 2026-07-23-12:00: A synchronous guard makes one batch one established plan-update turn despite rapid pointer or keyboard activation. */
contextualCommentInFlightRef.current = true;
contextualCommentSubmissionRef.current = true;
setError(null);
setGenerationActivity("plan_update");
setGenerationStartTime(Date.now());
setView({ type: "loading" });
if (!streamConnectionRef.current?.isConnected()) connectToPlanningStream(sessionId);
try {
const response = await respondToPlanning(sessionId, { contextualComments }, projectId);
const nextSummary = "type" in response ? null : response.summary;
if (nextSummary) {
const normalized = normalizePlanningSummary(nextSummary);
runningSummaryRef.current = normalized;
setRunningSummary(normalized);
contextualCommentSubmissionRef.current = false;
setContextualComments([]);
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary: normalized }, summary: normalized });
}
} catch (err) {
contextualCommentSubmissionRef.current = false;
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit comments"));
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
} finally {
contextualCommentInFlightRef.current = false;
}
}, [connectToPlanningStream, contextualComments, projectId, t]);
const handleProceedWithPlan = useCallback(async () => { const handleProceedWithPlan = useCallback(async () => {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
const summary = runningSummaryRef.current; const summary = runningSummaryRef.current;
@@ -2793,14 +2876,65 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<section id="planning-plan-panel" className="planning-plan-pane" data-testid="planning-plan-pane" aria-label={t("planning.currentPlan", "Current plan")}> <section id="planning-plan-panel" className="planning-plan-pane" data-testid="planning-plan-pane" aria-label={t("planning.currentPlan", "Current plan")}>
<div className="planning-view-scroll planning-summary-scroll planning-plan-scroll" data-testid="planning-plan-scroll"> <div className="planning-view-scroll planning-summary-scroll planning-plan-scroll" data-testid="planning-plan-scroll">
<article className="planning-plan-document"> <article className="planning-plan-document">
<MailboxMessageContent {/*
className="planning-plan-markdown markdown-body" FNXC:PlanningComments 2026-07-23-13:00:
content={formatPlanningPlanMd(summary)} Contextual quotes must originate exclusively in rendered plan Markdown. The editor and
testId="planning-plan-markdown" action controls remain outside this selection root so typing or selecting a suggestion
/> cannot replace the captured plan quote.
*/}
<div
ref={planDocumentRef}
onMouseUp={capturePlanSelection}
onTouchEnd={capturePlanSelection}
onKeyUp={capturePlanSelection}
>
<MailboxMessageContent
className="planning-plan-markdown markdown-body"
content={formatPlanningPlanMd(summary)}
testId="planning-plan-markdown"
/>
</div>
{selectedPlanQuote && !isCommentEditorOpen && (
<button
ref={addCommentTriggerRef}
type="button"
className="btn planning-add-comment"
onClick={() => setIsCommentEditorOpen(true)}
>
<MessageSquarePlus />
{t("planning.addComment", "Add comment to selection")}
</button>
)}
{isCommentEditorOpen && selectedPlanQuote && (
<div className="planning-comment-editor" role="dialog" aria-label={t("planning.addPlanComment", "Add plan comment")}>
<p className="planning-comment-quote">{selectedPlanQuote}</p>
<label className="planning-refine-menu-input">
<span>{t("planning.commentSuggestion", "Suggestion")}</span>
<textarea ref={commentInputRef} className="input" value={commentDraft} onChange={(event) => setCommentDraft(event.target.value)} />
</label>
<div className="planning-refine-menu-actions">
<button type="button" className="btn" onClick={() => { setCommentDraft(""); setIsCommentEditorOpen(false); addCommentTriggerRef.current?.focus(); }}>{t("common.cancel", "Cancel")}</button>
<button type="button" className="btn btn-primary" disabled={!commentDraft.trim()} onClick={handleAddContextualComment}>{t("planning.addComment", "Add comment")}</button>
</div>
</div>
)}
</article> </article>
</div> </div>
<div className="planning-actions planning-summary-actions planning-plan-actions" data-testid="planning-plan-actions"> <div className="planning-actions planning-summary-actions planning-plan-actions" data-testid="planning-plan-actions">
{contextualComments.length > 0 && (
<div className="planning-comment-tray" data-testid="planning-comment-tray">
<ul>
{contextualComments.map((comment, index) => (
<li key={`${comment.quote}-${index}`}>
<blockquote>{comment.quote}</blockquote>
<p>{comment.suggestion}</p>
<button type="button" className="btn btn-icon" aria-label={t("planning.removeComment", "Remove comment")} onClick={() => setContextualComments((comments) => comments.filter((_, commentIndex) => commentIndex !== index))}><Trash2 /></button>
</li>
))}
</ul>
<button type="button" className="btn btn-primary" disabled={contextualCommentInFlightRef.current} onClick={() => void handleSubmitContextualComments()}>{t("planning.submitComments", "Submit comments")}</button>
</div>
)}
{isRefineMenuOpen && ( {isRefineMenuOpen && (
<div <div
id="planning-refine-menu" id="planning-refine-menu"

View File

@@ -273,6 +273,45 @@ describe("PlanningModeModal sequential flow", () => {
expect(mockConnectPlanningStream).not.toHaveBeenCalled(); expect(mockConnectPlanningStream).not.toHaveBeenCalled();
}); });
it("batches contextual plan comments in selection order and keeps the normal plan actions", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }),
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
mockRespondToPlanning.mockResolvedValue({ summary: summaryWithRefinements, currentQuestion: null });
renderSession();
const documentNode = await screen.findByTestId("planning-plan-markdown");
const selectQuote = (quote: string) => {
const walker = document.createTreeWalker(documentNode, NodeFilter.SHOW_TEXT);
let textNode: Node | null = walker.nextNode();
while (textNode && !textNode.textContent?.includes(quote)) textNode = walker.nextNode();
expect(textNode).not.toBeNull();
const range = document.createRange();
range.selectNodeContents(textNode!);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.mouseUp(documentNode);
};
selectQuote("Build authentication system");
fireEvent.click(screen.getByRole("button", { name: "Add comment to selection" }));
const suggestionInput = screen.getByLabelText("Suggestion");
fireEvent.change(suggestionInput, { target: { value: "Explain the audit path." } });
// Editor selections are not plan selections: the captured quote must remain the Markdown text.
suggestionInput.setSelectionRange(0, suggestionInput.value.length);
fireEvent.mouseUp(suggestionInput);
fireEvent.click(screen.getByRole("button", { name: "Add comment" }));
expect(screen.getByTestId("planning-comment-tray")).toHaveTextContent("Explain the audit path.");
expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Submit comments" }));
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", {
contextualComments: [{ quote: expect.stringContaining("Build authentication system"), suggestion: "Explain the audit path." }],
}, "project-1"));
});
it("rehydrates a restored idle session when another tab advances its question", async () => { it("rehydrates a restored idle session when another tab advances its question", async () => {
mockFetchAiSession.mockResolvedValue({ mockFetchAiSession.mockResolvedValue({
...base, ...base,

View File

@@ -11,6 +11,16 @@ describe("PlanningModeModal sequential layout", () => {
expect(css).toContain("planning-summary-actions"); expect(css).toContain("planning-summary-actions");
}); });
it("captures selections only from the rendered plan and provides accessible comment controls", () => {
const component = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.tsx"), "utf8");
expect(component).toContain("planDocumentRef.current");
expect(component).toContain("root.contains(selection.anchorNode)");
expect(component).toContain("root.contains(selection.focusNode)");
expect(component).toContain("Add comment to selection");
expect(component).toContain("contextualComments");
expect(component).toContain("setContextualComments([])");
});
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 = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.css"), "utf8"); const css = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.css"), "utf8");
expect(css).toMatch(/\.planning-actions\s*\{[^}]*flex-shrink\s*:\s*0\s*;/); expect(css).toMatch(/\.planning-actions\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);

View File

@@ -22,12 +22,14 @@ import {
createSession, createSession,
createSessionWithAgent, createSessionWithAgent,
formatInitialRunningPlanRequestForAgent, formatInitialRunningPlanRequestForAgent,
formatContextualCommentsForAgent,
formatResponseForAgent, formatResponseForAgent,
getSession, getSession,
normalizePlanningSummaryPayload, normalizePlanningSummaryPayload,
normalizePlanningQuestion, normalizePlanningQuestion,
PLANNING_SYSTEM_PROMPT, PLANNING_SYSTEM_PROMPT,
planningStreamManager, planningStreamManager,
retrySession,
rewindSession, rewindSession,
submitResponse, submitResponse,
validateSession, validateSession,
@@ -122,6 +124,22 @@ describe("reactive Planning Mode question contract", () => {
expect(summary.suggestedRefinements).toEqual(refinementCategories); expect(summary.suggestedRefinements).toEqual(refinementCategories);
}); });
it("formats contextual comments in order with their selected quotes", () => {
const message = formatContextualCommentsForAgent({
title: "Recovery plan",
description: "Keep accounts recoverable.",
keyDeliverables: [],
suggestedRefinements: [],
}, [
{ quote: "Add audit events", suggestion: "Specify retention." },
{ quote: "Deploy safely", suggestion: "Use a staged rollout." },
]);
expect(message).toContain("1. Selected quote: Add audit events");
expect(message).toContain("Suggestion: Specify retention.");
expect(message.indexOf("Add audit events")).toBeLessThan(message.indexOf("Deploy safely"));
});
it("asks the model for all high-value categories without a three-category cap", () => { it("asks the model for all high-value categories without a three-category cap", () => {
const prompts = [ const prompts = [
PLANNING_SYSTEM_PROMPT, PLANNING_SYSTEM_PROMPT,
@@ -348,6 +366,50 @@ describe("reactive Planning Mode question contract", () => {
expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined }); expect(await getSession(sessionId)).toMatchObject({ validated: true, currentQuestion: undefined });
}); });
it("submits contextual comments through the existing plan-update session seam", async () => {
const prompts = installScriptedAgent([
payload(FIRST_QUESTION),
payload(SECOND_QUESTION),
]);
const created = await createSession("127.0.0.18", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
await submitResponse(created.sessionId, {
contextualComments: [
{ quote: "Use audit logs", suggestion: "Define retention." },
{ quote: "Ship safely", suggestion: "Add a staged rollout." },
],
}, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(prompts.at(-1)).toContain("Use audit logs");
expect(prompts.at(-1)).toContain("Add a staged rollout.");
expect(prompts.at(-1)!.indexOf("Use audit logs")).toBeLessThan(prompts.at(-1)!.indexOf("Ship safely"));
});
it("replays a failed contextual batch when the session is retried", async () => {
const prompts = installScriptedAgent([
payload(FIRST_QUESTION),
"not valid planning JSON",
"still not valid planning JSON",
payload(SECOND_QUESTION),
]);
const created = await createSession("127.0.0.19", "Build secure account recovery", MOCK_TASK_STORE, "/tmp/project");
const contextualComments = [
{ quote: "Use audit logs", suggestion: "Define retention." },
{ quote: "Ship safely", suggestion: "Add a staged rollout." },
];
await submitResponse(created.sessionId, { contextualComments }, "/tmp/project", undefined, MOCK_TASK_STORE);
expect((await getSession(created.sessionId))?.pendingContextualComments).toEqual(contextualComments);
await retrySession(created.sessionId, "/tmp/project", undefined, MOCK_TASK_STORE);
expect(prompts).toHaveLength(4);
expect(prompts[1]).toContain("Use audit logs");
expect(prompts[3]).toContain("Use audit logs");
expect(prompts[3]).toContain("Add a staged rollout.");
expect((await getSession(created.sessionId))?.pendingContextualComments).toBeUndefined();
});
it("uses Refine to replace the active question without recording a fake answer", async () => { it("uses Refine to replace the active question without recording a fake answer", async () => {
const prompts = installScriptedAgent([ const prompts = installScriptedAgent([
payload(FIRST_QUESTION), payload(FIRST_QUESTION),

View File

@@ -308,6 +308,8 @@ export interface DraftInputPayload {
generationPurpose?: "initial_plan" | "plan_update" | "question"; generationPurpose?: "initial_plan" | "plan_update" | "question";
generationStartedAt?: string; generationStartedAt?: string;
generationReturnQuestion?: PlanningQuestion; generationReturnQuestion?: PlanningQuestion;
/** Contextual batch retained until its plan-update generation succeeds, so retry can replay it. */
pendingContextualComments?: ContextualComment[];
clarificationEnabled?: boolean; clarificationEnabled?: boolean;
/* FNXC:PlanningMode 2026-07-21-09:15: Keep old payloads source-compatible without reading or writing this retired mailbox dedupe marker. */ /* FNXC:PlanningMode 2026-07-21-09:15: Keep old payloads source-compatible without reading or writing this retired mailbox dedupe marker. */
lastMailboxNotifiedQuestionKey?: string; lastMailboxNotifiedQuestionKey?: string;
@@ -414,6 +416,13 @@ interface Session {
generationStartedAt?: string; generationStartedAt?: string;
/** Question restored when the user stops the active turn. */ /** Question restored when the user stops the active turn. */
generationReturnQuestion?: PlanningQuestion; generationReturnQuestion?: PlanningQuestion;
/**
* FNXC:PlanningComments 2026-07-23-13:00:
* A contextual batch is a durable pending turn, not UI-only state. Retain its normalized
* quote/suggestion pairs until the revised summary is accepted so retry and rehydration replay
* precisely the requested plan update after the agent session is disposed.
*/
pendingContextualComments?: ContextualComment[];
/** Last terminal error for retry UX */ /** Last terminal error for retry UX */
error?: string; error?: string;
/** AI agent session for real-time interaction */ /** AI agent session for real-time interaction */
@@ -746,6 +755,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}), ...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}),
...(session.generationStartedAt ? { generationStartedAt: session.generationStartedAt } : {}), ...(session.generationStartedAt ? { generationStartedAt: session.generationStartedAt } : {}),
...(session.generationReturnQuestion ? { generationReturnQuestion: session.generationReturnQuestion } : {}), ...(session.generationReturnQuestion ? { generationReturnQuestion: session.generationReturnQuestion } : {}),
...(session.pendingContextualComments ? { pendingContextualComments: session.pendingContextualComments } : {}),
}), }),
conversationHistory: JSON.stringify(session.history), conversationHistory: JSON.stringify(session.history),
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null, currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
@@ -906,6 +916,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
generationReturnQuestion: payload.generationReturnQuestion && typeof payload.generationReturnQuestion === "object" generationReturnQuestion: payload.generationReturnQuestion && typeof payload.generationReturnQuestion === "object"
? normalizePlanningQuestion(payload.generationReturnQuestion, payload.initialPlan ?? row.title) ? normalizePlanningQuestion(payload.generationReturnQuestion, payload.initialPlan ?? row.title)
: undefined, : undefined,
pendingContextualComments: getContextualComments({ contextualComments: payload.pendingContextualComments }) ?? undefined,
history, history,
currentQuestion, currentQuestion,
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined, lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
@@ -2699,6 +2710,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
session.generationPurpose = undefined; session.generationPurpose = undefined;
session.generationStartedAt = undefined; session.generationStartedAt = undefined;
session.generationReturnQuestion = undefined; session.generationReturnQuestion = undefined;
// The revised summary is now durable, so a later retry must not reapply this batch.
session.pendingContextualComments = undefined;
session.currentQuestion = coerceQuestionResponse(parsed, session); session.currentQuestion = coerceQuestionResponse(parsed, session);
await persistSession(session, "awaiting_input"); await persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary }); planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
@@ -2969,6 +2982,36 @@ function isRefineRequest(responses: Record<string, unknown>): boolean {
return responses.refine === true; return responses.refine === true;
} }
type ContextualComment = { quote: string; suggestion: string };
function getContextualComments(responses: Record<string, unknown>): ContextualComment[] | null {
if (!Array.isArray(responses.contextualComments)
|| responses.contextualComments.length === 0
|| responses.contextualComments.length > 20) return null;
const comments = responses.contextualComments.map((comment) => {
if (!comment || typeof comment !== "object" || Array.isArray(comment)) return null;
const { quote, suggestion } = comment as { quote?: unknown; suggestion?: unknown };
const normalizedQuote = typeof quote === "string" ? quote.trim() : "";
const normalizedSuggestion = typeof suggestion === "string" ? suggestion.trim() : "";
return normalizedQuote && normalizedQuote.length <= 4_000 && normalizedSuggestion && normalizedSuggestion.length <= 2_000
? { quote: normalizedQuote, suggestion: normalizedSuggestion }
: null;
});
return comments.every((comment): comment is ContextualComment => comment !== null) ? comments : null;
}
export function formatContextualCommentsForAgent(summary: PlanningSummary, comments: ContextualComment[]): string {
return [
"The operator reviewed the running plan and submitted contextual comments.",
"Revise the running plan using every comment below, preserve unaffected content, and continue the established Planning Mode response contract.",
"Return the revised plan in Markdown and ask exactly one next question when more input is needed.",
"Current summary:",
JSON.stringify(summary),
"Contextual comments, in submitted order:",
...comments.flatMap((comment, index) => [`${index + 1}. Selected quote: ${comment.quote}`, `Suggestion: ${comment.suggestion}`]),
].join("\n\n");
}
function formatRefineRequestForAgent(summary: PlanningSummary, focus?: string): string { function formatRefineRequestForAgent(summary: PlanningSummary, focus?: string): string {
return [ return [
"The user clicked Refine Further on the planning summary.", "The user clicked Refine Further on the planning summary.",
@@ -3054,7 +3097,21 @@ export async function submitResponse(
let answeredQuestion: PlanningQuestion | undefined; let answeredQuestion: PlanningQuestion | undefined;
try { try {
if (isRefineRequest(responses) && session.summary) { const contextualComments = getContextualComments(responses);
if (contextualComments && session.summary) {
/*
FNXC:PlanningComments 2026-07-23-12:00:
Comment batches deliberately reuse the existing session, active-turn reservation, SSE, and
plan_update generation. They are not a second review authority or agent lifecycle.
*/
beginPlanningGeneration(session, "plan_update");
session.currentQuestion = undefined;
session.error = undefined;
session.pendingContextualComments = contextualComments;
await persistSession(session, "generating");
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
await continueAgentConversation(session, formatContextualCommentsForAgent(session.summary, contextualComments));
} else if (isRefineRequest(responses) && session.summary) {
// Refinement steers which question comes next; it is never an answer to the // Refinement steers which question comes next; it is never an answer to the
// currently displayed question and therefore must not create a history entry. // currently displayed question and therefore must not create a history entry.
beginPlanningGeneration(session, "question"); beginPlanningGeneration(session, "question");
@@ -3186,7 +3243,10 @@ export async function retrySession(
disposeSessionAgentForRetry(session); disposeSessionAgentForRetry(session);
session.error = undefined; session.error = undefined;
session.summary = undefined; const pendingContextualComments = session.pendingContextualComments;
// Keep the reviewed plan available while replaying a contextual batch; ordinary answer
// retries still rebuild their running summary from persisted interview history.
if (!pendingContextualComments) session.summary = undefined;
/* /*
FNXC:PlanningRetry 2026-07-14-00:00: FNXC:PlanningRetry 2026-07-14-00:00:
A retry regenerates the last turn, so no question is awaiting input. Clearing here also A retry regenerates the last turn, so no question is awaiting input. Clearing here also
@@ -3199,6 +3259,18 @@ export async function retrySession(
beginPlanningGeneration(session, session.history.length === 0 ? "initial_plan" : "plan_update"); beginPlanningGeneration(session, session.history.length === 0 ? "initial_plan" : "plan_update");
await persistSession(session, "generating"); await persistSession(session, "generating");
if (pendingContextualComments) {
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
await continueAgentConversation(
session,
formatContextualCommentsForAgent(
session.summary ?? buildRunningSummary(session.initialPlan, session.history),
pendingContextualComments,
),
);
return;
}
if (session.history.length === 0) { if (session.history.length === 0) {
await ensureSessionAgent(session, rootDir, [], promptOverrides, store); await ensureSessionAgent(session, rootDir, [], promptOverrides, store);
await continueAgentConversation(session, formatInitialRunningPlanRequestForAgent(session.initialPlan)); await continueAgentConversation(session, formatInitialRunningPlanRequestForAgent(session.initialPlan));

View File

@@ -887,10 +887,35 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
throw badRequest("sessionId is required"); throw badRequest("sessionId is required");
} }
if (!responses || typeof responses !== "object") { if (!responses || typeof responses !== "object" || Array.isArray(responses)) {
throw badRequest("responses is required and must be an object"); throw badRequest("responses is required and must be an object");
} }
/*
FNXC:PlanningComments 2026-07-23-12:00:
Contextual review batches carry only captured plain-text quotes and operator suggestions.
Bound and normalize the narrow shape at the HTTP boundary so arbitrary nested prompt data
cannot enter the existing Planning Mode generation session.
*/
if ("contextualComments" in responses) {
const comments = responses.contextualComments;
if (!Array.isArray(comments) || comments.length === 0 || comments.length > 20) {
throw badRequest("contextualComments must contain between 1 and 20 comments");
}
const normalized = comments.map((comment) => {
if (!comment || typeof comment !== "object" || Array.isArray(comment)) {
throw badRequest("Each contextual comment must be an object");
}
const quote = typeof comment.quote === "string" ? comment.quote.trim() : "";
const suggestion = typeof comment.suggestion === "string" ? comment.suggestion.trim() : "";
if (!quote || !suggestion || quote.length > 4_000 || suggestion.length > 2_000) {
throw badRequest("Each contextual comment needs a bounded quote and suggestion");
}
return { quote, suggestion };
});
req.body.responses = { contextualComments: normalized };
}
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings(); const settings = await scopedStore.getSettings();
const { submitResponse, attachPlanningRuntime, SessionNotFoundError: _SessionNotFoundError, InvalidSessionStateError: _InvalidSessionStateError } = await import("../planning.js"); const { submitResponse, attachPlanningRuntime, SessionNotFoundError: _SessionNotFoundError, InvalidSessionStateError: _InvalidSessionStateError } = await import("../planning.js");