fix(dashboard): plumb planning draft text + model selection end-to-end

The planning modal's draft sync only updated SQLite, never the in-memory
session, so Start Planning ran the agent against whatever fragment was
typed before the first 500ms debounce and silently dropped everything
after. Drafts that survived a backend restart couldn't be started at
all (lazy rebuild was missing for the start path), reopened drafts left
the textarea empty, and the model override the user picked at create
time was forgotten on reopen — Start Planning would silently use the
project default instead.

This change rehydrates initialPlan from SQLite in startExistingSession,
flushes the latest text from the request body before reading it back,
lazy-rebuilds the in-memory session from the persisted row when needed,
and round-trips the model override through inputPayload so reopen
restores it into modal state. Sidebar drafts now render a derived
preview (from inputPayload, never persisted as title) so multiple
drafts are distinguishable while editing, and summarizeDraftTitle fires
on textarea blur and modal close — gated on status rather than title
content so blur-then-edit still refreshes rather than locking to the
first snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 00:05:28 -07:00
parent d73070c135
commit 2affc143dc
10 changed files with 785 additions and 60 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix planning draft sessions losing the user's typed text and model selection between draft create, sidebar reopen, and Start Planning. The agent now receives the freshest persisted `initialPlan` (not the truncated cache from when the draft was first auto-created), drafts that survive a backend restart can still be started, and the model override the user picked at draft time is restored when reopening from the sidebar and threaded through summarize. The sidebar shows a per-draft preview derived from `inputPayload` so multiple drafts are distinguishable while still hiding raw keystrokes from the persisted title, and titles get re-summarized on textarea blur and modal close so they reflect the final text rather than locking to the first blur snapshot.

View File

@@ -7161,6 +7161,8 @@ export interface AiSessionSummary {
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
status: "draft" | "generating" | "awaiting_input" | "complete" | "error";
title: string;
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
preview?: string;
projectId: string | null;
lockedByTab: string | null;
updatedAt: string;
@@ -7279,7 +7281,7 @@ export function pingSession(sessionId: string, projectId?: string): Promise<{ ok
export function updatePlanningSessionDraft(
sessionId: string,
draft: { title: string; initialPlan: string },
draft: { initialPlan: string; modelProvider?: string; modelId?: string },
projectId?: string,
): Promise<{ ok: boolean }> {
return api<{ ok: boolean }>(withProjectId(`/ai-sessions/${encodeURIComponent(sessionId)}/draft`, projectId), {
@@ -7288,6 +7290,22 @@ export function updatePlanningSessionDraft(
});
}
/**
* Ask the server to (re)generate the sidebar title for a draft planning
* session from its persisted initialPlan. Server-side is idempotent and
* a no-op once the session has been started, so callers can fire-and-
* forget on textarea blur and modal close.
*/
export function summarizePlanningDraftTitle(
sessionId: string,
projectId?: string,
): Promise<{ title: string | null }> {
return api<{ title: string | null }>(
withProjectId(`/planning/${encodeURIComponent(sessionId)}/summarize-draft-title`, projectId),
{ method: "POST" },
);
}
// ── Messages API ──────────────────────────────────────────────────────────
/** Response shape for GET /messages/inbox */

View File

@@ -21,6 +21,7 @@ import {
cancelPlanning,
stopPlanningGeneration,
updatePlanningSessionDraft,
summarizePlanningDraftTitle,
updateGlobalSettings,
type PlanningSession,
type SubtaskItem,
@@ -167,7 +168,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const overlayMouseDownOnSelfRef = useRef(false);
const thinkingOutputRef = useRef<HTMLDivElement>(null);
const draftSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSyncedDraftRef = useRef<{ sessionId: string; initialPlan: string } | null>(null);
const lastSyncedDraftRef = useRef<{
sessionId: string;
initialPlan: string;
modelProvider?: string;
modelId?: string;
} | null>(null);
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
@@ -602,7 +608,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
),
);
if (session.status === "awaiting_input" && session.currentQuestion) {
if (session.status === "draft") {
// Draft hasn't been started yet — restore the user's saved text +
// model selection into the editor, reattach the draft id so a
// future Start Planning call reuses this row, and route them back
// to the initial editor. Restoring the model ensures the start
// request uses the selection the user made when creating the
// draft, not whatever the modal's local state currently holds.
let savedPlan = "";
let savedProvider: string | undefined;
let savedModelId: string | undefined;
try {
const payload = session.inputPayload ? JSON.parse(session.inputPayload) : null;
if (payload && typeof payload.initialPlan === "string") {
savedPlan = payload.initialPlan;
}
if (payload && typeof payload.modelProvider === "string" && typeof payload.modelId === "string") {
savedProvider = payload.modelProvider;
savedModelId = payload.modelId;
}
} catch {
// Fall through with empty text; the row will remain editable.
}
setInitialPlan(savedPlan);
setPlanningModelProvider(savedProvider);
setPlanningModelId(savedModelId);
draftSessionIdRef.current = sessionId;
lastSyncedDraftRef.current = savedPlan
? {
sessionId,
initialPlan: savedPlan.trim(),
modelProvider: savedProvider,
modelId: savedModelId,
}
: null;
setView({ type: "initial" });
} else if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId);
const question = JSON.parse(session.currentQuestion);
setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null } });
@@ -771,9 +812,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return;
}
// Re-sync whenever the model selection changes, even if the plan text
// is unchanged — otherwise the persisted draft would silently keep the
// model the user picked at create time after they've switched.
const alreadySynced =
lastSyncedDraftRef.current?.sessionId === sessionId &&
lastSyncedDraftRef.current.initialPlan === trimmedPlan;
lastSyncedDraftRef.current.initialPlan === trimmedPlan &&
lastSyncedDraftRef.current.modelProvider === planningModelProvider &&
lastSyncedDraftRef.current.modelId === planningModelId;
if (alreadySynced) {
return;
}
@@ -782,17 +828,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
await updatePlanningSessionDraft(
sessionId,
{
title: trimmedPlan,
initialPlan: trimmedPlan,
modelProvider: planningModelProvider && planningModelId ? planningModelProvider : undefined,
modelId: planningModelProvider && planningModelId ? planningModelId : undefined,
},
projectId,
);
lastSyncedDraftRef.current = { sessionId, initialPlan: trimmedPlan };
lastSyncedDraftRef.current = {
sessionId,
initialPlan: trimmedPlan,
modelProvider: planningModelProvider,
modelId: planningModelId,
};
} catch {
// best-effort draft sync; avoid blocking typing UX on transient failures
}
},
[projectId],
[planningModelId, planningModelProvider, projectId],
);
useEffect(() => {
@@ -998,6 +1050,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen]);
// Flush any pending debounced draft sync, then ask the server to (re)derive
// the sidebar title from the persisted initialPlan. Fire-and-forget — the
// server is idempotent and a no-op once the session has started, so it's
// safe to invoke on textarea blur and on modal close. Awaiting the sync
// first guarantees the summary reflects the user's freshest text rather
// than whatever the last 500ms-debounced PATCH happened to persist.
const flushDraftAndSummarize = useCallback(
(sessionId: string, planText: string) => {
if (draftSyncTimerRef.current) {
clearTimeout(draftSyncTimerRef.current);
draftSyncTimerRef.current = null;
}
void (async () => {
try {
await syncPlanningDraft(sessionId, planText);
await summarizePlanningDraftTitle(sessionId, projectId);
} catch {
// best-effort title polish; don't surface to the user
}
})();
},
[projectId, syncPlanningDraft],
);
// Close the modal without abandoning the active server session. Sessions
// remain in the list and can be resumed later. Only an explicit Delete
// (from the sidebar) cancels and removes a session.
@@ -1007,6 +1083,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
savePlanningDescription(initialPlan, projectId);
}
// Capture before clearing — we want to fire summarize for this draft even
// though we're tearing down local state.
const draftSessionId = draftSessionIdRef.current;
if (draftSessionId && initialPlan.trim()) {
flushDraftAndSummarize(draftSessionId, initialPlan);
}
draftSessionIdRef.current = null;
if (draftDebounceRef.current) {
clearTimeout(draftDebounceRef.current);
@@ -1017,7 +1100,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setIsReconnecting(false);
setIsRetrying(false);
onClose();
}, [initialPlan, onClose, projectId, view.type]);
}, [flushDraftAndSummarize, initialPlan, onClose, projectId, view.type]);
// Handle escape key to close
useEffect(() => {
@@ -1422,6 +1505,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
type: "planning",
status: "draft",
title: response.title,
preview: content.length > 80 ? `${content.slice(0, 79).trimEnd()}` : content,
projectId: projectId ?? null,
lockedByTab: null,
updatedAt: new Date().toISOString(),
@@ -1444,6 +1528,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
handleStartPlanning();
}
}}
onBlur={() => {
// User paused or moved focus away — good moment to
// upgrade the sidebar from "New planning session" to a
// model-summarized title. No-op if the draft hasn't
// been persisted yet or if it's already been started.
const draftSessionId = draftSessionIdRef.current;
if (draftSessionId && initialPlan.trim()) {
flushDraftAndSummarize(draftSessionId, initialPlan);
}
}}
/>
</div>
@@ -2515,7 +2609,17 @@ function PlanningSessionList({
<PlanningSessionStatusIcon status={session.status} />
<span className="planning-sidebar-item-body">
<span className="planning-sidebar-item-title">
{session.title || "Untitled session"}
{/*
For draft rows the persisted title is intentionally a
generic placeholder so the sidebar doesn't leak raw
keystrokes. Surface the inputPayload-derived preview
instead so multiple drafts are distinguishable; once
the user starts the session, summarizeTitle replaces
`title` and `preview` is no longer present.
*/}
{session.status === "draft" && session.preview
? session.preview
: session.title || "Untitled session"}
</span>
<span className="planning-sidebar-item-meta">
<PlanningSessionStatusLabel status={session.status} />

View File

@@ -483,7 +483,12 @@ describe("PlanningModeModal", () => {
undefined,
);
expect(screen.getByText("Test Plan")).toBeDefined();
// Sidebar shows the inputPayload-derived preview for draft rows so
// multiple drafts are distinguishable, not the placeholder title that
// createDraftSession returns. The text also appears in the textarea
// value, so scope the query to the sidebar item title element.
const sidebarItem = document.querySelector(".planning-sidebar-item-title");
expect(sidebarItem?.textContent).toBe("Build a detailed auth system plan");
fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan with extras" } });
await new Promise((resolve) => setTimeout(resolve, 350));
@@ -977,6 +982,102 @@ describe("PlanningModeModal", () => {
expect(screen.getByText("Deliverable B")).toBeDefined();
});
it("restores the textarea and reattaches the draft id when reopening a persisted draft", async () => {
const draftPlan = "Persisted draft text the user typed before closing the modal";
mockFetchAiSession.mockResolvedValueOnce({
id: "session-draft-1",
type: "planning",
status: "draft",
title: "New planning session",
inputPayload: JSON.stringify({ initialPlan: draftPlan }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-draft-1"
/>,
);
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("session-draft-1");
});
// The draft is restored to the editor (initial view), not surfaced as a
// question or summary, and the textarea contains exactly the persisted
// initialPlan so the user can keep editing or click Start Planning.
const textarea = await screen.findByDisplayValue(draftPlan);
expect((textarea as HTMLTextAreaElement).tagName).toBe("TEXTAREA");
expect(screen.getByText("Start Planning")).toBeDefined();
});
it("restores the persisted model override when reopening a draft so Start Planning uses it", async () => {
// The draft was created under an explicit anthropic/claude-opus model.
// Reopening must restore that selection into the modal's local state
// so a subsequent Start Planning click uses it instead of silently
// falling back to whatever the dropdown currently defaults to. The
// server-side round-trip is covered separately in planning.test.ts;
// this test pins the React-state restoration.
mockFetchAiSession.mockResolvedValueOnce({
id: "session-draft-with-model",
type: "planning",
status: "draft",
title: "New planning session",
inputPayload: JSON.stringify({
initialPlan: "Plan that needs a specific model",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-draft-with-model"
/>,
);
// Wait for the textarea to be populated from the draft — proves the
// reopen path ran and the modal is in the editable initial view.
await screen.findByDisplayValue("Plan that needs a specific model");
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
"Plan that needs a specific model",
undefined,
{ planningModelProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
{ planningDepth: "medium", customQuestionCount: undefined },
"session-draft-with-model",
);
});
});
it("shows retry panel when resuming an errored session", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-error-1",

View File

@@ -469,23 +469,23 @@ describe("AiSessionStore", () => {
expect(store.updateStatus("S-missing", "error", "Nope")).toBe(false);
});
it("updateDraft updates planning title + input payload and emits updated", () => {
it("updateDraft persists initialPlan, leaves title untouched, and emits updated", () => {
seedSession({ id: "S-draft", status: "awaiting_input" });
const originalTitle = store.get("S-draft")?.title;
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
const updated = store.updateDraft("S-draft", {
title: " Refined plan title ",
initialPlan: " Refined draft body ",
});
expect(updated).toBe(true);
const session = store.get("S-draft");
expect(session?.title).toBe("Refined plan title");
expect(session?.title).toBe(originalTitle);
expect(session?.inputPayload).toBe(JSON.stringify({ initialPlan: "Refined draft body" }));
expect(onUpdated).toHaveBeenCalledWith(
expect.objectContaining({ id: "S-draft", title: "Refined plan title" }),
expect.objectContaining({ id: "S-draft", title: originalTitle }),
);
});
@@ -493,12 +493,34 @@ describe("AiSessionStore", () => {
seedSession({ id: "S-subtask", status: "awaiting_input" });
db.prepare("UPDATE ai_sessions SET type = 'subtask' WHERE id = ?").run("S-subtask");
expect(
store.updateDraft("S-subtask", { title: "Nope", initialPlan: "Nope" }),
).toBe(false);
expect(
store.updateDraft("S-missing", { title: "Missing", initialPlan: "Missing" }),
).toBe(false);
expect(store.updateDraft("S-subtask", { initialPlan: "Nope" })).toBe(false);
expect(store.updateDraft("S-missing", { initialPlan: "Missing" })).toBe(false);
});
it("listAll surfaces a derived preview for draft planning sessions only", () => {
// Three planning rows in different states; only the draft should have a
// sidebar preview derived from inputPayload.initialPlan. The others (and
// any non-planning rows) must keep `preview` undefined so the sidebar
// falls back to the persisted title.
seedSession({ id: "S-draft-short", status: "draft" });
db.prepare("UPDATE ai_sessions SET inputPayload = ? WHERE id = ?")
.run(JSON.stringify({ initialPlan: "Short plan body" }), "S-draft-short");
seedSession({ id: "S-draft-long", status: "draft" });
const longPlan = "A".repeat(150);
db.prepare("UPDATE ai_sessions SET inputPayload = ? WHERE id = ?")
.run(JSON.stringify({ initialPlan: longPlan }), "S-draft-long");
seedSession({ id: "S-active", status: "awaiting_input" });
const all = store.listAll();
const byId = new Map(all.map((row) => [row.id, row]));
expect(byId.get("S-draft-short")?.preview).toBe("Short plan body");
const longPreview = byId.get("S-draft-long")?.preview ?? "";
expect(longPreview.length).toBeLessThanOrEqual(80);
expect(longPreview.endsWith("…")).toBe(true);
expect(byId.get("S-active")?.preview).toBeUndefined();
});
it("listRecoverable returns awaiting_input and generating sessions", () => {

View File

@@ -771,7 +771,7 @@ describe("planning module", () => {
);
expect(session.sessionId).toBeDefined();
expect(session.title).toBe("Draft plan text for the planning modal");
expect(session.title).toBe("New planning session");
expect(getSession(session.sessionId)?.id).toBe(session.sessionId);
});
@@ -2437,7 +2437,7 @@ describe("planning routes lock enforcement", () => {
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
sessionId: expect.any(String),
title: "Build a dashboard settings wizard with guided onboarding steps",
title: "New planning session",
});
expect(response.body.sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
@@ -2532,6 +2532,180 @@ describe("planning routes lock enforcement", () => {
expect(startNew.body.sessionId).not.toBe(draftSessionId);
});
it("uses the freshest initialPlan when start-streaming races a pending draft sync", async () => {
// Simulate the race: draft was created with stale text, the latest debounced
// PATCH /draft hasn't arrived yet, and the user clicks Start Planning whose
// request body carries the up-to-date textarea contents. The agent must
// receive the body's text, not whatever was last persisted to SQLite.
const draft = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "Stale draft prefix from first keystroke" }),
{ "content-type": "application/json" },
);
expect(draft.status).toBe(201);
const draftSessionId = draft.body.sessionId as string;
const freshPlan =
"Stale draft prefix from first keystroke followed by everything the user typed after the debounce window closed";
const start = await request(
app,
"POST",
"/api/planning/start-streaming",
JSON.stringify({ initialPlan: freshPlan, existingSessionId: draftSessionId }),
{ "content-type": "application/json" },
);
expect(start.status).toBe(201);
const persisted = aiSessionStore.get(draftSessionId);
expect(persisted?.inputPayload).toBe(JSON.stringify({ initialPlan: freshPlan }));
});
it("re-summarizes the draft title on each call so blur-then-edit doesn't strand stale text", async () => {
// For short input (≤200 chars) summarizeTitle returns null, so
// summarizeDraftTitle uses its trimmed-text fallback. That's enough to
// exercise the regression: the helper used to bail once `title !==
// DRAFT_PLACEHOLDER_TITLE`, which would lock in the first fallback and
// ignore the user's subsequent edits even though they were persisted.
const draft = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "Initial partial draft text" }),
{ "content-type": "application/json" },
);
const draftSessionId = draft.body.sessionId as string;
const firstBlur = await request(
app,
"POST",
`/api/planning/${draftSessionId}/summarize-draft-title`,
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(firstBlur.status).toBe(200);
expect(firstBlur.body).toEqual({ title: "Initial partial draft text" });
expect(aiSessionStore.get(draftSessionId)?.title).toBe("Initial partial draft text");
await request(
app,
"PATCH",
`/api/ai-sessions/${draftSessionId}/draft`,
JSON.stringify({ initialPlan: "Final draft text after the user kept typing" }),
{ "content-type": "application/json" },
);
const secondBlur = await request(
app,
"POST",
`/api/planning/${draftSessionId}/summarize-draft-title`,
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(secondBlur.status).toBe(200);
expect(secondBlur.body).toEqual({ title: "Final draft text after the user kept typing" });
expect(aiSessionStore.get(draftSessionId)?.title).toBe(
"Final draft text after the user kept typing",
);
});
it("persists the model override on draft create and round-trips it through inputPayload", async () => {
const draft = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({
initialPlan: "Plan that needs a specific model",
planningModelProvider: "anthropic",
planningModelId: "claude-opus-4-7",
}),
{ "content-type": "application/json" },
);
expect(draft.status).toBe(201);
const draftSessionId = draft.body.sessionId as string;
// The draft row's inputPayload must carry the model override so the
// frontend reopen path can restore it into modal state and so a later
// summarize call uses it instead of falling back to project defaults.
const persisted = aiSessionStore.get(draftSessionId);
const payload = JSON.parse(persisted?.inputPayload ?? "{}");
expect(payload.modelProvider).toBe("anthropic");
expect(payload.modelId).toBe("claude-opus-4-7");
// PATCH /draft can also update the override (user switched models mid-edit).
await request(
app,
"PATCH",
`/api/ai-sessions/${draftSessionId}/draft`,
JSON.stringify({
initialPlan: "Plan that needs a specific model",
modelProvider: "openai",
modelId: "gpt-5",
}),
{ "content-type": "application/json" },
);
const updatedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}");
expect(updatedPayload.modelProvider).toBe("openai");
expect(updatedPayload.modelId).toBe("gpt-5");
// A half-set override on PATCH clears the persisted override entirely
// rather than landing in a half-configured state the start path rejects.
await request(
app,
"PATCH",
`/api/ai-sessions/${draftSessionId}/draft`,
JSON.stringify({
initialPlan: "Plan that needs a specific model",
modelProvider: "openai",
}),
{ "content-type": "application/json" },
);
const clearedPayload = JSON.parse(aiSessionStore.get(draftSessionId)?.inputPayload ?? "{}");
expect(clearedPayload.modelProvider).toBeUndefined();
expect(clearedPayload.modelId).toBeUndefined();
});
it("starts a draft that survived a backend restart by lazily rebuilding from SQLite", async () => {
// Recreate the post-restart state: draft persisted in SQLite but the
// in-memory sessions map is empty (rehydrateFromStore skips drafts since
// listRecoverable only returns generating/awaiting_input rows).
const draft = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "Plan that should outlive a server restart" }),
{ "content-type": "application/json" },
);
expect(draft.status).toBe(201);
const draftSessionId = draft.body.sessionId as string;
// Wipe in-memory state to simulate a backend restart, then re-wire the
// SQLite-backed store. The SQLite draft row survives; the in-memory
// sessions map is empty because rehydrateFromStore intentionally skips
// drafts (it only recovers in-flight generating/awaiting_input rows).
__resetPlanningState();
setAiSessionStore(aiSessionStore as any);
expect(aiSessionStore.get(draftSessionId)?.status).toBe("draft");
const start = await request(
app,
"POST",
"/api/planning/start-streaming",
JSON.stringify({
initialPlan: "Plan that should outlive a server restart",
existingSessionId: draftSessionId,
}),
{ "content-type": "application/json" },
);
expect(start.status).toBe(201);
expect(start.body).toEqual({ sessionId: draftSessionId });
expect(getSession(draftSessionId)?.id).toBe(draftSessionId);
expect(aiSessionStore.get(draftSessionId)?.status).toBe("awaiting_input");
});
it("keeps planning SSE stream read-only and unaffected by locks", async () => {
const { sessionId } = await createSession(getUniqueIp(), "SSE lock check", taskStore, tmpRoot);
await submitResponse(sessionId, { "q-scope": "small" }, tmpRoot);

View File

@@ -45,12 +45,23 @@ export interface AiSessionSummary {
type: AiSessionType;
status: AiSessionStatus;
title: string;
/**
* For draft planning sessions only: a short, derived preview of the
* persisted initialPlan so the sidebar can distinguish multiple drafts
* before the user has started any of them. Computed at read time from
* inputPayload — never persisted as the title — so unfinished keystrokes
* don't end up baked into the row's permanent title.
*/
preview?: string;
projectId: string | null;
lockedByTab: string | null;
updatedAt: string;
archived?: boolean;
}
/** Max characters of initialPlan surfaced as a sidebar preview for drafts. */
const DRAFT_PREVIEW_MAX_CHARS = 80;
export interface AiSessionStoreEvents {
"ai_session:updated": [AiSessionSummary];
"ai_session:deleted": [string]; // session id
@@ -221,20 +232,34 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
/**
* Update persisted draft metadata for a planning session.
* Keeps sidebar title/input payload current while the user edits.
* Persists the in-progress initialPlan so it survives reload; the sidebar
* title is intentionally left alone (set once at creation, replaced when
* the user actually starts the session) to avoid leaking raw keystrokes
* into the sidebar and to keep the entry stable while editing.
*
* Also persists an optional model override paired together (provider+id);
* passing one without the other clears the persisted override so we never
* end up with a half-configured selection that the start path would
* silently reject.
*/
updateDraft(id: string, draft: { title: string; initialPlan: string }): boolean {
updateDraft(
id: string,
draft: { initialPlan: string; modelProvider?: string; modelId?: string },
): boolean {
const now = new Date().toISOString();
const trimmedTitle = draft.title.trim();
const trimmedPlan = draft.initialPlan.trim();
const inputPayload = JSON.stringify({ initialPlan: trimmedPlan });
const hasModelOverride = Boolean(draft.modelProvider && draft.modelId);
const inputPayload = JSON.stringify({
initialPlan: trimmedPlan,
...(hasModelOverride ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}),
});
const result = this.db
.prepare(
`UPDATE ai_sessions
SET title = ?, inputPayload = ?, updatedAt = ?
SET inputPayload = ?, updatedAt = ?
WHERE id = ? AND type = 'planning'`,
)
.run(trimmedTitle, inputPayload, now, id) as { changes?: number };
.run(inputPayload, now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (!changed) {
@@ -299,26 +324,31 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
* the configured TTL, so this list does not grow unbounded.
*/
listAll(projectId?: string, options?: { includeArchived?: boolean }): AiSessionSummary[] {
// Pull `inputPayload` alongside the summary columns so we can derive the
// sidebar preview for draft rows. Non-draft rows ignore the payload —
// toSidebarSummary only inspects it when status === "draft".
const archivedClause = options?.includeArchived ? "" : " WHERE COALESCE(archived, 0) = 0";
if (projectId) {
const where = options?.includeArchived
? "WHERE projectId = ?"
: "WHERE projectId = ? AND COALESCE(archived, 0) = 0";
return this.db
const rows = this.db
.prepare(
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
`SELECT id, type, status, title, inputPayload, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
${where}
ORDER BY updatedAt DESC`,
)
.all(projectId) as unknown as AiSessionSummary[];
.all(projectId) as Array<Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "type" | "status" | "title" | "inputPayload" | "updatedAt">>;
return rows.map(toSidebarSummary);
}
return this.db
const rows = this.db
.prepare(
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
`SELECT id, type, status, title, inputPayload, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
${archivedClause}
ORDER BY updatedAt DESC`,
)
.all() as unknown as AiSessionSummary[];
.all() as Array<Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "type" | "status" | "title" | "inputPayload" | "updatedAt">>;
return rows.map(toSidebarSummary);
}
/**
@@ -696,9 +726,66 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary {
type: session.type,
status: session.status,
title: session.title,
preview: extractDraftPreview(session),
projectId: session.projectId,
lockedByTab: session.lockedByTab ?? null,
updatedAt,
archived: Number(session.archived ?? 0) === 1,
};
}
/**
* Lighter-weight summary builder for `listAll` rows that don't carry every
* column of `AiSessionRow`. Keeps the same preview-derivation behavior as
* `toSummary` (drafts only) without forcing the bulk-list query to SELECT
* conversationHistory / thinkingOutput / etc.
*/
function toSidebarSummary(
row: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "type" | "status" | "title" | "inputPayload" | "updatedAt">,
): AiSessionSummary {
const previewSource: AiSessionRow = {
id: row.id,
type: row.type,
status: row.status,
title: row.title,
inputPayload: row.inputPayload,
conversationHistory: "",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: row.projectId ?? null,
createdAt: "",
updatedAt: row.updatedAt,
lockedByTab: row.lockedByTab ?? null,
lockedAt: row.lockedAt ?? null,
archived: row.archived,
};
return {
id: row.id,
type: row.type,
status: row.status,
title: row.title,
preview: extractDraftPreview(previewSource),
projectId: row.projectId ?? null,
lockedByTab: row.lockedByTab ?? null,
updatedAt: row.updatedAt,
archived: Number(row.archived ?? 0) === 1,
};
}
function extractDraftPreview(session: AiSessionRow): string | undefined {
if (session.type !== "planning" || session.status !== "draft") return undefined;
if (!session.inputPayload) return undefined;
try {
const payload = JSON.parse(session.inputPayload) as { initialPlan?: unknown };
const plan = typeof payload.initialPlan === "string" ? payload.initialPlan.trim() : "";
if (!plan) return undefined;
const collapsed = plan.replace(/\s+/g, " ");
return collapsed.length > DRAFT_PREVIEW_MAX_CHARS
? `${collapsed.slice(0, DRAFT_PREVIEW_MAX_CHARS - 1).trimEnd()}`
: collapsed;
} catch {
return undefined;
}
}

View File

@@ -206,6 +206,22 @@ For questions:
For completion:
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`;
/** Placeholder title for draft sessions before the user starts planning. */
export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
/**
* Shape of the JSON blob persisted in `ai_sessions.inputPayload` for draft
* planning sessions. Carries the in-progress plan text plus an optional
* model override so reopening a draft restores the model selection the user
* picked at create time, and so summarizeDraftTitle calls hit that same
* model rather than silently falling back to project defaults.
*/
export interface DraftInputPayload {
initialPlan?: string;
modelProvider?: string;
modelId?: string;
}
/** Session TTL in milliseconds (7 days) */
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -272,6 +288,9 @@ interface Session {
initialPlan: string;
title: string;
projectId?: string;
/** Model override the user picked at draft-create time. Persisted in inputPayload so reopen restores it. */
draftModelProvider?: string;
draftModelId?: string;
ntfyConfig?: PlanningNtfyConfig;
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
lastNotifiedQuestionKey?: string;
@@ -381,7 +400,12 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
type: "planning",
status,
title: session.title || session.initialPlan.slice(0, 120),
inputPayload: JSON.stringify({ ip: session.ip, initialPlan: session.initialPlan }),
inputPayload: JSON.stringify({
ip: session.ip,
initialPlan: session.initialPlan,
...(session.draftModelProvider ? { modelProvider: session.draftModelProvider } : {}),
...(session.draftModelId ? { modelId: session.draftModelId } : {}),
}),
conversationHistory: JSON.stringify(session.history),
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
result: session.summary ? JSON.stringify(session.summary) : null,
@@ -409,7 +433,7 @@ function unpersistSession(sessionId: string): void {
}
function buildSessionFromRow(row: AiSessionRow): Session {
const payload = safeParseJson<{ ip?: string; initialPlan?: string }>(
const payload = safeParseJson<DraftInputPayload & { ip?: string }>(
row.inputPayload,
{},
{ throwOnError: true, fieldName: "inputPayload" },
@@ -435,6 +459,8 @@ function buildSessionFromRow(row: AiSessionRow): Session {
initialPlan: payload.initialPlan ?? row.title,
title: row.title,
projectId: row.projectId ?? undefined,
draftModelProvider: payload.modelProvider,
draftModelId: payload.modelId,
history: safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory,
[],
@@ -876,7 +902,7 @@ async function getFirstQuestionFromAgent(
export async function createDraftSession(
ip: string,
initialPlan: string,
rootDir: string,
_rootDir: string,
modelProvider?: string,
modelId?: string,
_promptOverrides?: PromptOverrideMap,
@@ -891,7 +917,12 @@ export async function createDraftSession(
}
const sessionId = randomUUID();
const title = initialPlan.slice(0, 120);
const title = DRAFT_PLACEHOLDER_TITLE;
// Pair modelProvider+modelId — the runtime treats half-set overrides as
// invalid (resolveTaskPlanningModel etc.), so persist nothing rather than
// a half-configured override that would mislead reopen.
const hasModelOverride = Boolean(modelProvider && modelId);
const session: Session = {
id: sessionId,
@@ -899,6 +930,8 @@ export async function createDraftSession(
initialPlan,
title,
projectId: options?.projectId,
draftModelProvider: hasModelOverride ? modelProvider : undefined,
draftModelId: hasModelOverride ? modelId : undefined,
history: [],
thinkingOutput: "",
lastGeneratedThinking: "",
@@ -909,23 +942,77 @@ export async function createDraftSession(
sessions.set(sessionId, session);
persistSession(session, "draft");
void (async () => {
try {
const generated = await summarizeTitle(initialPlan.trim(), rootDir, modelProvider, modelId);
const finalTitle = generated ?? initialPlan.trim().slice(0, 60).trim();
if (!finalTitle) {
return;
}
session.title = finalTitle;
_aiSessionStore?.updateTitle(sessionId, finalTitle);
} catch {
// Keep fallback title
}
})();
return { sessionId, title };
}
/**
* Replace a draft session's placeholder sidebar title with an AI-summarized
* one derived from the latest persisted initialPlan. Bounded by status, not
* by title content, so a user who blurs once then keeps editing still gets
* the title refreshed on subsequent blurs/close (otherwise the title would
* lock to the first summary and silently diverge from what they typed).
* - Only runs against rows still in `draft` status — once a session has been
* started, its title is owned by the start path / final summary and must
* not be overwritten by a stale draft summarize call that arrives late.
* - Reads the current initialPlan and any persisted model override from
* SQLite so the summary reflects whatever the latest debounced PATCH
* /draft persisted, and uses the model the draft was created under.
* - Re-checks status (not title) after the model call to detect a
* concurrent start and avoid clobbering the generating/awaiting_input
* title with a stale draft summary.
*
* Returns the resolved title (existing or freshly generated) or null if the
* session was not eligible for summarization.
*/
export async function summarizeDraftTitle(
sessionId: string,
rootDir: string,
modelProvider?: string,
modelId?: string,
): Promise<string | null> {
if (!_aiSessionStore) return null;
const row = _aiSessionStore.get(sessionId);
if (!row || row.type !== "planning" || row.status !== "draft") {
return null;
}
const payload = safeParseJson<DraftInputPayload>(row.inputPayload, {});
const trimmed = (payload.initialPlan ?? "").trim();
if (!trimmed) return null;
// Prefer the model the draft was created under; fall back to the caller-
// supplied override (e.g. project/global planning settings).
const effectiveProvider = payload.modelProvider ?? modelProvider;
const effectiveModelId = payload.modelId ?? modelId;
let finalTitle = trimmed.slice(0, 60).trim();
try {
const generated = await summarizeTitle(trimmed, rootDir, effectiveProvider, effectiveModelId);
finalTitle = generated?.trim() || finalTitle;
} catch (error) {
diagnostics.errorFromException(
"summarizeDraftTitle: model call failed, falling back to truncated text",
error,
{ sessionId, operation: "summarize-draft-title" },
);
}
if (!finalTitle) return null;
// Re-check status (not title) so a concurrent Start Planning or a later
// edit-then-blur cycle doesn't overwrite a real generating/complete title.
const latest = _aiSessionStore.get(sessionId);
if (!latest || latest.status !== "draft") {
return latest?.title ?? null;
}
_aiSessionStore.updateTitle(sessionId, finalTitle);
const session = sessions.get(sessionId);
if (session) session.title = finalTitle;
return finalTitle;
}
export async function startExistingSession(
sessionId: string,
rootDir: string,
@@ -933,11 +1020,77 @@ export async function startExistingSession(
modelId?: string,
promptOverrides?: PromptOverrideMap,
): Promise<void> {
const session = sessions.get(sessionId);
let session = sessions.get(sessionId);
// Draft sessions aren't included in rehydrateFromStore (which only loads
// recoverable in-flight sessions), and a backend restart drops the in-memory
// map entirely. Rebuild lazily from SQLite so persisted drafts can still be
// started after a restart, and so updateDraft-only state survives.
if (!session && _aiSessionStore) {
const row = _aiSessionStore.get(sessionId);
if (row && row.type === "planning") {
try {
session = buildSessionFromRow(row);
sessions.set(sessionId, session);
} catch (error) {
diagnostics.errorFromException(
"Failed to rebuild planning session from store",
error,
{ sessionId, operation: "start-existing-rebuild" },
);
}
}
}
if (!session) {
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
}
// Drafts are sync'd via aiSessionStore.updateDraft, which only writes
// SQLite. Pull the latest initialPlan + persisted model override from the
// row so the agent receives everything the user typed, and so the title
// summary uses the model the draft was originally created under (rather
// than silently switching to whatever the project default happens to be).
let persistedProvider: string | undefined;
let persistedModelId: string | undefined;
let cameFromDraft = false;
if (_aiSessionStore) {
const row = _aiSessionStore.get(sessionId);
if (row) {
cameFromDraft = row.status === "draft";
const payload = safeParseJson<DraftInputPayload>(row.inputPayload, {});
if (payload.initialPlan) {
session.initialPlan = payload.initialPlan;
}
persistedProvider = payload.modelProvider;
persistedModelId = payload.modelId;
}
}
// Always re-summarize when transitioning out of draft so the title reflects
// the FINAL text the user typed, even if a previous blur-fired summarize
// already replaced the placeholder against an older snapshot.
if (cameFromDraft) {
const trimmed = session.initialPlan.trim();
const fallback = trimmed.slice(0, 60).trim();
if (session.title === DRAFT_PLACEHOLDER_TITLE) {
session.title = fallback || DRAFT_PLACEHOLDER_TITLE;
}
const summarizeProvider = modelProvider ?? persistedProvider;
const summarizeModelId = modelId ?? persistedModelId;
void (async () => {
try {
const generated = await summarizeTitle(trimmed, rootDir, summarizeProvider, summarizeModelId);
const finalTitle = generated?.trim() || fallback;
if (!finalTitle) return;
session.title = finalTitle;
_aiSessionStore?.updateTitle(sessionId, finalTitle);
} catch {
// Keep fallback title
}
})();
}
persistSession(session, "generating");
await initializeAgent(session, rootDir, modelProvider, modelId, promptOverrides);
}

View File

@@ -3638,20 +3638,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("Only planning sessions support draft updates");
}
const rawTitle = typeof req.body?.title === "string" ? req.body.title : "";
const rawInitialPlan = typeof req.body?.initialPlan === "string" ? req.body.initialPlan : "";
const title = rawTitle.trim();
const initialPlan = rawInitialPlan.trim();
if (!title) {
throw badRequest("title is required");
}
if (!initialPlan) {
throw badRequest("initialPlan is required");
}
const updated = aiSessionStore.updateDraft(id, { title, initialPlan });
// Optional model override — pair-validated. Either both fields are
// strings (kept) or any other shape is dropped silently so a partial
// payload doesn't end up half-set in the persisted draft.
const rawProvider = typeof req.body?.modelProvider === "string" ? req.body.modelProvider.trim() : "";
const rawModelId = typeof req.body?.modelId === "string" ? req.body.modelId.trim() : "";
const modelProvider = rawProvider && rawModelId ? rawProvider : undefined;
const modelId = rawProvider && rawModelId ? rawModelId : undefined;
const updated = aiSessionStore.updateDraft(id, { initialPlan, modelProvider, modelId });
if (!updated) {
throw notFound("Session not found");
}

View File

@@ -569,6 +569,28 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
resolvedPlanningSettings.modelId;
if (existingSessionId) {
// Defeat the start-before-debounced-sync race: the textarea contents
// submitted with this request are authoritative — write them through
// to the draft row before startExistingSession reads back from SQLite.
// Otherwise a Start Planning click within the 500 ms debounce window
// would launch the session against stale text.
if (aiSessionStore) {
try {
aiSessionStore.updateDraft(existingSessionId, {
initialPlan,
// Persist the explicit body override (if both fields set) so a
// later summarizeDraftTitle picks the same model the user just
// chose; pass undefined to clear any half-set state otherwise.
modelProvider: planningModelProvider && planningModelId ? planningModelProvider : undefined,
modelId: planningModelProvider && planningModelId ? planningModelId : undefined,
});
} catch (error) {
planningLogger.warn(
"Failed to flush draft initialPlan before start",
{ sessionId: existingSessionId, error: String(error) },
);
}
}
const { startExistingSession } = await import("../planning.js");
await startExistingSession(
existingSessionId,
@@ -614,6 +636,43 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
});
/**
* POST /api/planning/:sessionId/summarize-draft-title
* Generate (or regenerate) the sidebar title for a draft session from its
* latest persisted initialPlan. Fired by the modal on textarea blur and on
* close so that drafts the user walks away from end up with a real title
* instead of "New planning session". Idempotent server-side: only acts on
* draft rows still holding the placeholder title.
*
* UTILITY PATH: This route is independent of task-lane saturation.
*/
router.post("/planning/:sessionId/summarize-draft-title", async (req, res) => {
try {
const { sessionId } = req.params;
if (!sessionId) {
throw badRequest("sessionId is required");
}
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const rootDir = scopedStore.getRootDir();
const resolvedPlanningSettings = resolvePlanningSettingsModel(settings);
const { summarizeDraftTitle } = await import("../planning.js");
const title = await summarizeDraftTitle(
sessionId,
rootDir,
resolvedPlanningSettings.provider,
resolvedPlanningSettings.modelId,
);
res.json({ title });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to summarize draft title");
}
});
/**
* POST /api/planning/respond
* Submit a response to the current planning question.