feat(dashboard): add draft planning sessions with debounced auto-creation

Introduce a draft lifecycle for planning sessions: typing into the
PlanningModeModal textarea now creates a server-side draft after a
300ms debounce, persisted with status='draft' so the user's in-flight
plan survives modal close/reopen and shows up immediately in the
session list.

- planning.ts: new createDraftSession path; persistSession status
  union widened to include 'draft'; Session gains an explicit title
  field so subsequent updates don't clobber it.
- register-planning-subtask-routes.ts: wires the createPlanningDraft
  POST endpoint that the modal calls on debounce.
- ai-session-store.ts: tracks the draft status across queries so the
  session list and locks behave the same as any active session.
- legacy.ts: client wrapper for createPlanningDraft.
- PlanningModeModal styling, tests, and ModalReentry coverage updated
  for the new flow.
- docs/architecture.md notes the expanded ai_sessions.status lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-02 15:56:29 -07:00
parent 7a2da530b9
commit 014791de4f
10 changed files with 469 additions and 39 deletions

View File

@@ -91,6 +91,25 @@ describe("AiSessionStore", () => {
return entries;
}
it("updateTitle updates title and emits ai_session:updated", () => {
const row = makeRow("S-title", "draft");
store.upsert(row);
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
const updated = store.updateTitle("S-title", "New Draft Title");
expect(updated).toBe(true);
expect(store.get("S-title")?.title).toBe("New Draft Title");
expect(onUpdated).toHaveBeenCalled();
expect(onUpdated.mock.calls.at(-1)?.[0]).toMatchObject({
id: "S-title",
title: "New Draft Title",
status: "draft",
});
});
it("cleanupOld removes only stale terminal sessions and emits deleted events", () => {
const deletedIds: string[] = [];
store.on("ai_session:deleted", (id) => deletedIds.push(id));

View File

@@ -9,6 +9,8 @@ import { Database, TaskStore } from "@fusion/core";
import {
createSession,
createSessionWithAgent,
createDraftSession,
startExistingSession,
submitResponse,
retrySession,
cancelSession,
@@ -760,6 +762,41 @@ describe("planning module", () => {
});
});
describe("draft session helpers", () => {
it("creates a draft session with draft status", async () => {
const session = await createDraftSession(
getUniqueIp(),
"Draft plan text for the planning modal",
TEST_ROOT_DIR,
);
expect(session.sessionId).toBeDefined();
expect(session.title).toBe("Draft plan text for the planning modal");
expect(getSession(session.sessionId)?.id).toBe(session.sessionId);
});
it("starts an existing draft session and moves it into active flow", async () => {
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
const draft = await createDraftSession(
getUniqueIp(),
"Draft plan reused by start",
TEST_ROOT_DIR,
);
await startExistingSession(draft.sessionId, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(getSession(draft.sessionId)?.currentQuestion?.id).toBe("q-scope");
});
});
it("throws when starting a missing draft session", async () => {
await expect(startExistingSession("missing-session", TEST_ROOT_DIR)).rejects.toThrow(
SessionNotFoundError,
);
});
});
describe("submitResponse", () => {
it("processes response and returns next question", async () => {
const mockIp = getUniqueIp();
@@ -2388,6 +2425,113 @@ describe("planning routes lock enforcement", () => {
});
});
it("creates a draft planning session via route and persists draft status", async () => {
const response = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "Build a dashboard settings wizard with guided onboarding steps" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
sessionId: expect.any(String),
title: "Build a dashboard settings wizard with guided onboarding steps",
});
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,
);
const persisted = aiSessionStore.get(response.body.sessionId as string);
expect(persisted?.status).toBe("draft");
});
it("returns 400 for draft creation without non-empty initialPlan", async () => {
const missing = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(missing.status).toBe(400);
const empty = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "" }),
{ "content-type": "application/json" },
);
expect(empty.status).toBe(400);
});
it("returns 429 when draft creation rate limit is exceeded", async () => {
for (let i = 0; i < 1000; i++) {
const created = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: `Rate-limited draft ${i}` }),
{ "content-type": "application/json" },
);
expect(created.status).toBe(201);
}
const rateLimited = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "This draft should hit the rate limit" }),
{ "content-type": "application/json" },
);
expect(rateLimited.status).toBe(429);
expect(String(rateLimited.body?.error ?? "")).toContain("Rate limit exceeded");
});
it("reuses existing draft session when starting streaming", async () => {
const draft = await request(
app,
"POST",
"/api/planning/create-draft",
JSON.stringify({ initialPlan: "Plan draft to be reused by start-streaming" }),
{ "content-type": "application/json" },
);
expect(draft.status).toBe(201);
const draftSessionId = draft.body.sessionId as string;
const startExisting = await request(
app,
"POST",
"/api/planning/start-streaming",
JSON.stringify({
initialPlan: "Plan draft to be reused by start-streaming",
existingSessionId: draftSessionId,
}),
{ "content-type": "application/json" },
);
expect(startExisting.status).toBe(201);
expect(startExisting.body).toEqual({ sessionId: draftSessionId });
expect(aiSessionStore.get(draftSessionId)?.status).toBe("awaiting_input");
const startNew = await request(
app,
"POST",
"/api/planning/start-streaming",
JSON.stringify({ initialPlan: "Plan without existing draft" }),
{ "content-type": "application/json" },
);
expect(startNew.status).toBe(201);
expect(startNew.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,
);
expect(startNew.body.sessionId).not.toBe(draftSessionId);
});
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);