fix(planning): keep questions out of mailbox
Keep planning questions in their dedicated surface while preserving ntfy alerts, and tighten the desktop planning panes without changing compact or shared layouts.
This commit is contained in:
7
.changeset/tidy-planning-questions.md
Normal file
7
.changeset/tidy-planning-questions.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Stop Planning Mode questions from filling Mailbox and tighten desktop planning pane spacing.
|
||||
category: fix
|
||||
dev: Removes planning mailbox delivery and redundant desktop pane/footer insets.
|
||||
@@ -1662,6 +1662,39 @@ its refinement menu remain reachable at the bottom of bounded desktop and mobile
|
||||
min-height: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-21-09:15:
|
||||
Desktop Planning panes already own their framed surfaces, so their scroll content must sit flush and their action rows must share a compact baseline. Scope these overrides to Planning's pane wrappers and desktop breakpoint so shared Mission/Milestone interview classes and compact safe-area padding retain their existing presentation.
|
||||
*/
|
||||
@media (min-width: 1025px) {
|
||||
.planning-question-pane .planning-question-scroll,
|
||||
.planning-plan-pane .planning-plan-scroll {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.planning-question-pane .planning-question-panel {
|
||||
background: var(--surface);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.planning-plan-pane .planning-plan-document {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.planning-question-pane .planning-actions,
|
||||
.planning-plan-actions {
|
||||
padding: var(--space-sm) var(--space-xl);
|
||||
}
|
||||
|
||||
.planning-question-pane .planning-actions .btn,
|
||||
.planning-plan-actions .btn {
|
||||
min-height: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
}
|
||||
|
||||
.planning-refine-menu {
|
||||
position: absolute;
|
||||
right: var(--space-xl);
|
||||
|
||||
@@ -7,6 +7,7 @@ const PLANNING_CSS_PATH = resolve(__dirname, "..", "PlanningModeModal.css");
|
||||
const TABLET_SUMMARY_ACTIONS_QUERY = "@media (min-width: 769px) and (max-width: 1024px)";
|
||||
const MOBILE_ACTIONS_QUERY = "@media (max-width: 768px)";
|
||||
const MOBILE_PLANNING_SHELL_QUERY = "@media (max-width: 768px), (max-height: 480px)";
|
||||
const DESKTOP_PLANNING_WORKSPACE_QUERY = "@media (min-width: 1025px)";
|
||||
|
||||
function loadPlanningCss(): string {
|
||||
return readFileSync(PLANNING_CSS_PATH, "utf-8");
|
||||
@@ -82,6 +83,27 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expect(findRule(mobileCss, ".planning-workspace--mobile-tab-question .planning-plan-pane,\n .planning-workspace--mobile-tab-plan .planning-question-pane")).toMatch(/display\s*:\s*none\s*;/);
|
||||
});
|
||||
|
||||
it("keeps desktop planning content flush inside both panes with compact aligned action rows", () => {
|
||||
const css = loadPlanningCss();
|
||||
const desktopCss = getMediaBlocks(css, DESKTOP_PLANNING_WORKSPACE_QUERY).join("\n");
|
||||
const flushScrollRule = findRule(desktopCss, ".planning-question-pane .planning-question-scroll,\n .planning-plan-pane .planning-plan-scroll");
|
||||
const desktopQuestionPanelRule = findRule(desktopCss, ".planning-question-pane .planning-question-panel");
|
||||
const desktopPlanDocumentRule = findRule(desktopCss, ".planning-plan-pane .planning-plan-document");
|
||||
const sharedActionsRule = findRule(desktopCss, ".planning-question-pane .planning-actions,\n .planning-plan-actions");
|
||||
const sharedButtonsRule = findRule(desktopCss, ".planning-question-pane .planning-actions .btn,\n .planning-plan-actions .btn");
|
||||
|
||||
expect(flushScrollRule).toMatch(/padding\s*:\s*0\s*;/);
|
||||
expect(desktopQuestionPanelRule).toMatch(/border\s*:\s*none\s*;/);
|
||||
expect(desktopQuestionPanelRule).toMatch(/border-radius\s*:\s*0\s*;/);
|
||||
expect(desktopPlanDocumentRule).toMatch(/width\s*:\s*100%\s*;/);
|
||||
expect(desktopPlanDocumentRule).toMatch(/box-shadow\s*:\s*none\s*;/);
|
||||
expect(sharedActionsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-xl\)\s*;/);
|
||||
expect(sharedButtonsRule).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-sm\)\)\s*;/);
|
||||
|
||||
expect(findRule(css, ".planning-question-panel")).toMatch(/border\s*:\s*var\(--btn-border-width\) solid var\(--border\)\s*;/);
|
||||
expect(findRules(css, ".planning-plan-document").some((rule) => /border-radius\s*:\s*var\(--radius-xl\)\s*;/.test(rule))).toBe(true);
|
||||
});
|
||||
|
||||
it("makes the history sheet full width on mobile while keeping its own scroll owner", () => {
|
||||
const css = loadPlanningCss();
|
||||
expect(findRule(css, ".planning-history-scroll")).toMatch(/overflow-y\s*:\s*auto\s*;/);
|
||||
|
||||
@@ -111,10 +111,13 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
const question = document.querySelector<HTMLElement>("[data-testid='planning-question-pane']")!;
|
||||
const scroll = document.querySelector<HTMLElement>("[data-testid='planning-plan-scroll']")!;
|
||||
const actions = document.querySelector<HTMLElement>("[data-testid='planning-plan-actions']")!;
|
||||
const questionScroll = question.querySelector<HTMLElement>(".planning-question-scroll")!;
|
||||
const questionActions = question.querySelector<HTMLElement>(".planning-actions")!;
|
||||
const planRect = plan.getBoundingClientRect();
|
||||
const questionRect = question.getBoundingClientRect();
|
||||
const scrollRect = scroll.getBoundingClientRect();
|
||||
const actionsRect = actions.getBoundingClientRect();
|
||||
const questionActionsRect = questionActions.getBoundingClientRect();
|
||||
return {
|
||||
planVisible: planRect.width > 0 && planRect.height > 0,
|
||||
questionVisible: questionRect.width > 0 && questionRect.height > 0,
|
||||
@@ -127,6 +130,12 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
scrollable: scroll.scrollHeight > scroll.clientHeight,
|
||||
scrollOwnerConfigured: getComputedStyle(scroll).overflowY === "auto",
|
||||
markdownRendered: Boolean(plan.querySelector("h1") && plan.querySelector("strong")),
|
||||
flushPaneInsets: getComputedStyle(scroll).paddingLeft === "0px"
|
||||
&& getComputedStyle(questionScroll).paddingLeft === "0px",
|
||||
desktopActionRowsAligned: Math.abs(actionsRect.top - questionActionsRect.top) <= 1
|
||||
&& Math.abs(actionsRect.bottom - questionActionsRect.bottom) <= 1,
|
||||
actionTopDelta: Math.round(Math.abs(actionsRect.top - questionActionsRect.top)),
|
||||
actionBottomDelta: Math.round(Math.abs(actionsRect.bottom - questionActionsRect.bottom)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -142,6 +151,10 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
scrollable: true,
|
||||
scrollOwnerConfigured: true,
|
||||
markdownRendered: true,
|
||||
flushPaneInsets: !mobile,
|
||||
desktopActionRowsAligned: mobile ? false : true,
|
||||
actionTopDelta: mobile ? expect.any(Number) : 0,
|
||||
actionBottomDelta: mobile ? expect.any(Number) : 0,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
@@ -16,6 +18,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
import {
|
||||
__resetPlanningState,
|
||||
__setCreateFnAgent,
|
||||
__setPlanningNtfyHelpers,
|
||||
createSession,
|
||||
createSessionWithAgent,
|
||||
formatInitialRunningPlanRequestForAgent,
|
||||
@@ -186,59 +189,117 @@ describe("reactive Planning Mode question contract", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-18-17:30:
|
||||
A model completion is never a Planning Mode terminal state. This exercises the real
|
||||
createSession/submitResponse agent seam so regression coverage proves the running plan,
|
||||
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
||||
FNXC:PlanningMode 2026-07-21-09:15:
|
||||
Planning questions belong to the planning surface. Exercise both the initial and follow-up
|
||||
streaming/non-streaming seams so neither can duplicate its question in the dashboard user's mailbox, while configured ntfy notifications remain available outside the planning view.
|
||||
*/
|
||||
it("delivers planning-clarification metadata that can reopen the exact session", async () => {
|
||||
installScriptedAgent([payload({
|
||||
...FIRST_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
},
|
||||
})]);
|
||||
let resolveDelivered: ((message: Record<string, unknown>) => void) | undefined;
|
||||
const delivered = new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveDelivered = resolve;
|
||||
});
|
||||
it("keeps initial and follow-up questions out of Mailbox while preserving ntfy", async () => {
|
||||
installScriptedAgent([
|
||||
payload({
|
||||
...FIRST_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
...SECOND_QUESTION,
|
||||
runningPlan: {
|
||||
title: "Secure account recovery delivery",
|
||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const messageStore = {
|
||||
getInbox: vi.fn(async () => []),
|
||||
sendMessage: vi.fn(async (message: Record<string, unknown>) => resolveDelivered?.(message)),
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
};
|
||||
const sessionId = await createSessionWithAgent(
|
||||
let notificationCount = 0;
|
||||
let resolveNotifications!: () => void;
|
||||
const notificationsDelivered = new Promise<void>((resolveNotificationsPromise) => {
|
||||
resolveNotifications = resolveNotificationsPromise;
|
||||
});
|
||||
const sendNtfyNotification = vi.fn(async () => {
|
||||
notificationCount += 1;
|
||||
if (notificationCount === 2) resolveNotifications();
|
||||
});
|
||||
__setPlanningNtfyHelpers({
|
||||
isNtfyEventEnabled: () => true,
|
||||
buildNtfyClickUrl: () => "http://localhost/planning",
|
||||
sendNtfyNotification,
|
||||
});
|
||||
const created = await createSession(
|
||||
"127.0.0.11",
|
||||
"Plan mailbox navigation",
|
||||
MOCK_TASK_STORE,
|
||||
"/tmp/project",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
clarificationEnabled: true,
|
||||
messageStore: messageStore as never,
|
||||
ntfyConfig: { enabled: true, topic: "planning-tests", events: ["planning-awaiting-input"] },
|
||||
},
|
||||
);
|
||||
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
await notificationsDelivered;
|
||||
|
||||
expect((await getSession(created.sessionId))?.currentQuestion?.id).toBe(SECOND_QUESTION.id);
|
||||
expect(messageStore.getInbox).not.toHaveBeenCalled();
|
||||
expect(messageStore.sendMessage).not.toHaveBeenCalled();
|
||||
expect(sendNtfyNotification).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps streamed initial and follow-up questions out of Mailbox", async () => {
|
||||
installScriptedAgent([
|
||||
payload({ ...FIRST_QUESTION, runningPlan: normalizePlanningSummaryPayload({ title: "Streaming plan", description: "Initial plan" }) }),
|
||||
payload({ ...SECOND_QUESTION, runningPlan: normalizePlanningSummaryPayload({ title: "Streaming plan", description: "Updated plan" }) }),
|
||||
]);
|
||||
const messageStore = {
|
||||
getInbox: vi.fn(async () => []),
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
};
|
||||
const sessionId = await createSessionWithAgent(
|
||||
"127.0.0.12",
|
||||
"Plan streaming mailbox silence",
|
||||
"/tmp/project",
|
||||
MOCK_TASK_STORE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ clarificationEnabled: true, messageStore: messageStore as never },
|
||||
{ clarificationEnabled: true, messageStore },
|
||||
);
|
||||
|
||||
const initialPlanReady = new Promise<void>((resolve) => {
|
||||
const initialQuestionReady = new Promise<void>((resolveQuestion) => {
|
||||
planningStreamManager.subscribe(sessionId, (event) => {
|
||||
if (event.type === "summary") resolve();
|
||||
if (event.type === "question") resolveQuestion();
|
||||
});
|
||||
});
|
||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||
await initialPlanReady;
|
||||
const message = await delivered;
|
||||
await initialQuestionReady;
|
||||
await new Promise<void>((resolveTurn) => setImmediate(resolveTurn));
|
||||
await submitResponse(sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
expect(message).toMatchObject({
|
||||
type: "system",
|
||||
content: expect.stringContaining(FIRST_QUESTION.question),
|
||||
metadata: {
|
||||
kind: "planning-clarification",
|
||||
sessionId,
|
||||
questionId: FIRST_QUESTION.id,
|
||||
},
|
||||
});
|
||||
expect((await getSession(sessionId))?.currentQuestion?.id).toBe(SECOND_QUESTION.id);
|
||||
expect(messageStore.getInbox).not.toHaveBeenCalled();
|
||||
expect(messageStore.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps planning route and session sources free of mailbox delivery wiring", () => {
|
||||
const planningSource = readFileSync(resolve(__dirname, "..", "planning.ts"), "utf8");
|
||||
const planningRoutesSource = readFileSync(resolve(__dirname, "..", "routes", "register-planning-subtask-routes.ts"), "utf8");
|
||||
|
||||
expect(planningSource).not.toMatch(/\.(?:getInbox|sendMessage)\(/);
|
||||
expect(planningRoutesSource).not.toMatch(/\bMessageStore\b|getMessageStore\(/);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-18-17:30:
|
||||
A model completion is never a Planning Mode terminal state. This exercises the real
|
||||
createSession/submitResponse agent seam so regression coverage proves the running plan,
|
||||
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
||||
*/
|
||||
it("generates a durable initial plan with one question and validates only on user action", async () => {
|
||||
const prompts = installScriptedAgent([
|
||||
payload({
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
MessageStore,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
DASHBOARD_USER_ID,
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
TASK_PRIORITIES,
|
||||
THINKING_LEVELS,
|
||||
@@ -73,13 +72,16 @@ type AgentMessage = {
|
||||
|
||||
const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const;
|
||||
type PlanningMcpServers = Awaited<ReturnType<typeof resolveMcpServersForStore>>["servers"];
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-21-09:15:
|
||||
Planning questions must never create dashboard Mailbox messages. Retain the optional MessageStore input only as a source-compatible no-op for callers compiled against the prior planning API while route and session code omit every mailbox read/write path.
|
||||
*/
|
||||
type PlanningSessionOptions = {
|
||||
projectId?: string;
|
||||
ntfyConfig?: PlanningNtfyConfig;
|
||||
clarificationEnabled?: boolean;
|
||||
/** Workflow selected by the planning entry point; retained for agent rebuilds. */
|
||||
workflowId?: string;
|
||||
/** Runtime-only mailbox dependency; never serialize this store. */
|
||||
messageStore?: MessageStore;
|
||||
pluginRunner?: SkillPluginRunner;
|
||||
};
|
||||
@@ -307,6 +309,7 @@ export interface DraftInputPayload {
|
||||
generationStartedAt?: string;
|
||||
generationReturnQuestion?: PlanningQuestion;
|
||||
clarificationEnabled?: boolean;
|
||||
/* FNXC:PlanningMode 2026-07-21-09:15: Keep old payloads source-compatible without reading or writing this retired mailbox dedupe marker. */
|
||||
lastMailboxNotifiedQuestionKey?: string;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
@@ -381,13 +384,9 @@ interface Session {
|
||||
ntfyConfig?: PlanningNtfyConfig;
|
||||
/** Persisted per-session override for proactive AI clarification checkpoints. */
|
||||
clarificationEnabled?: boolean;
|
||||
/** Runtime-only mailbox dependency, attached by the current route. */
|
||||
messageStore?: MessageStore;
|
||||
autoMerge?: boolean;
|
||||
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
|
||||
lastNotifiedQuestionKey?: string;
|
||||
/** Durable fast-path marker; the inbox lookup remains authoritative after a crash. */
|
||||
lastMailboxNotifiedQuestionKey?: string;
|
||||
history: PlanningHistoryEntry[];
|
||||
currentQuestion?: PlanningQuestion;
|
||||
/** Question currently being edited; history is preserved rather than truncated. */
|
||||
@@ -619,7 +618,6 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
||||
...(session.generationPurpose ? { generationPurpose: session.generationPurpose } : {}),
|
||||
...(session.generationStartedAt ? { generationStartedAt: session.generationStartedAt } : {}),
|
||||
...(session.generationReturnQuestion ? { generationReturnQuestion: session.generationReturnQuestion } : {}),
|
||||
...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
|
||||
}),
|
||||
conversationHistory: JSON.stringify(session.history),
|
||||
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
|
||||
@@ -780,9 +778,6 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
generationReturnQuestion: payload.generationReturnQuestion && typeof payload.generationReturnQuestion === "object"
|
||||
? normalizePlanningQuestion(payload.generationReturnQuestion, payload.initialPlan ?? row.title)
|
||||
: undefined,
|
||||
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
|
||||
? payload.lastMailboxNotifiedQuestionKey
|
||||
: undefined,
|
||||
history,
|
||||
currentQuestion,
|
||||
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
|
||||
@@ -1107,7 +1102,6 @@ export async function createSession(
|
||||
clarificationEnabled: options?.clarificationEnabled === true,
|
||||
workflowId: options?.workflowId,
|
||||
ntfyConfig: options?.ntfyConfig,
|
||||
messageStore: options?.messageStore,
|
||||
};
|
||||
|
||||
sessions.set(sessionId, session);
|
||||
@@ -1615,7 +1609,6 @@ export async function startExistingSession(
|
||||
if (runtimeOptions) {
|
||||
session.clarificationEnabled = runtimeOptions.clarificationEnabled === true;
|
||||
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
||||
session.messageStore = runtimeOptions.messageStore;
|
||||
}
|
||||
beginPlanningGeneration(session, "initial_plan");
|
||||
await persistSession(session, "generating");
|
||||
@@ -1688,7 +1681,6 @@ export async function createSessionWithAgent(
|
||||
}
|
||||
: undefined,
|
||||
clarificationEnabled: options?.clarificationEnabled === true,
|
||||
messageStore: options?.messageStore,
|
||||
history: [],
|
||||
summary: buildRunningSummary(initialPlan, []),
|
||||
validated: false,
|
||||
@@ -1943,43 +1935,6 @@ async function maybeNotifyPlanningAwaitingInput(
|
||||
): Promise<void> {
|
||||
const questionKey = `${session.id}:${question.id}`;
|
||||
|
||||
/*
|
||||
FNXC:AgentClarification 2026-07-16-12:00:
|
||||
Proactive planner questions use an inbox message independently of ntfy. The
|
||||
inbox lookup is authoritative because a process can die after sendMessage but
|
||||
before the persisted marker write; ntfy remains best-effort and separately deduped.
|
||||
|
||||
FNXC:MailboxRelatedWork 2026-07-20-09:30:
|
||||
FN-8428 relies on this stable kind/sessionId/questionId tuple to deduplicate clarification
|
||||
notices and open the exact Planning session from mailbox detail. Keep the readable question in
|
||||
the body, but never replace these metadata fields with a markdown-only navigation link.
|
||||
*/
|
||||
if (proactiveClarification && session.clarificationEnabled && session.messageStore
|
||||
&& session.lastMailboxNotifiedQuestionKey !== questionKey) {
|
||||
try {
|
||||
const inbox = await session.messageStore.getInbox(DASHBOARD_USER_ID, "user", { type: "system" });
|
||||
const delivered = inbox.some((message) => message.metadata?.kind === "planning-clarification"
|
||||
&& message.metadata?.sessionId === session.id && message.metadata?.questionId === question.id);
|
||||
if (!delivered) {
|
||||
await session.messageStore.sendMessage({
|
||||
fromType: "system",
|
||||
toType: "user",
|
||||
toId: DASHBOARD_USER_ID,
|
||||
type: "system",
|
||||
content: `Planning needs your answer in the planner chat: ${question.question}`,
|
||||
metadata: { kind: "planning-clarification", sessionId: session.id, questionId: question.id },
|
||||
});
|
||||
}
|
||||
session.lastMailboxNotifiedQuestionKey = questionKey;
|
||||
await persistSession(session, "awaiting_input");
|
||||
} catch (error) {
|
||||
diagnostics.warn("Failed to deliver planning clarification mailbox message", {
|
||||
sessionId: session.id, questionId: question.id,
|
||||
error: error instanceof Error ? error.message : String(error), operation: "planning-clarification-mailbox",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Summary deepening checkpoints retain their existing ntfy behavior regardless
|
||||
// of the clarification preference; only proactive questions are setting-gated.
|
||||
if (proactiveClarification && !session.clarificationEnabled) return;
|
||||
@@ -3303,12 +3258,11 @@ export async function attachPlanningRuntime(
|
||||
/*
|
||||
FNXC:AgentClarification 2026-07-16-16:15:
|
||||
The following mutator owns the authoritative missing-session error. Runtime
|
||||
attachment is best-effort so restored live sessions receive current ntfy and
|
||||
mailbox dependencies without changing existing route error semantics.
|
||||
attachment is best-effort so restored live sessions receive current ntfy
|
||||
settings without changing existing route error semantics.
|
||||
*/
|
||||
if (!session) return;
|
||||
session.ntfyConfig = options.ntfyConfig;
|
||||
session.messageStore = options.messageStore;
|
||||
// Persisted session choice wins on resumed sessions; route defaults only fill old rows.
|
||||
if (session.clarificationEnabled === undefined) session.clarificationEnabled = options.clarificationEnabled === true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
DEFAULT_TASK_PRIORITY,
|
||||
formatPlanningPlanMd,
|
||||
MessageStore,
|
||||
resolvePlanningSettingsModel,
|
||||
TASK_PRIORITIES,
|
||||
THINKING_LEVELS,
|
||||
@@ -17,7 +16,6 @@ import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
|
||||
import type { AiSessionStore } from "../ai-session-store.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
import { resolveBranchAssignmentContext, resolveBranchSelection, resolveEntryPointBranchAssignment } from "./branch-selection.js";
|
||||
import { requireAsyncLayer } from "../require-async-layer.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
type SkillPluginRunner = Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3];
|
||||
@@ -76,27 +74,9 @@ function rethrowPlanningWorkflowCreateError(
|
||||
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
|
||||
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
|
||||
const { aiSessionStore, parseLastEventId, replayBufferedSSE } = deps;
|
||||
const messageStoreCache = new Map<string, MessageStore>();
|
||||
const getPlanningMessageStore = async (req: import("express").Request): Promise<MessageStore | undefined> => {
|
||||
try {
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const runtimeStore = engine?.getMessageStore();
|
||||
if (runtimeStore) return runtimeStore;
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const cached = messageStoreCache.get(rootDir);
|
||||
if (cached) return cached;
|
||||
const created = new MessageStore(null, { asyncLayer: requireAsyncLayer(scopedStore, "Planning MessageStore") });
|
||||
messageStoreCache.set(rootDir, created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
planningLogger.warn("Planning mailbox unavailable; continuing without inbox delivery", { error: String(error) });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const planningRuntime = async (req: import("express").Request, settings: Awaited<ReturnType<TaskStore["getSettings"]>>) => ({
|
||||
const planningRuntime = (settings: Awaited<ReturnType<TaskStore["getSettings"]>>) => ({
|
||||
clarificationEnabled: settings.agentClarificationEnabled === true,
|
||||
ntfyConfig: { enabled: settings.ntfyEnabled ?? false, topic: settings.ntfyTopic, ntfyBaseUrl: settings.ntfyBaseUrl, dashboardHost: settings.ntfyDashboardHost, events: settings.ntfyEvents },
|
||||
messageStore: await getPlanningMessageStore(req),
|
||||
});
|
||||
|
||||
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
||||
@@ -551,10 +531,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
/*
|
||||
FNXC:AgentClarification 2026-07-16-16:10:
|
||||
The legacy synchronous planning-start endpoint can emit the initial proactive question too.
|
||||
Attach live notification and mailbox dependencies here so it follows the same setting-gated
|
||||
hold and delivery contract as streaming Planning Mode.
|
||||
Attach live notification settings here so it follows the same setting-gated hold and
|
||||
delivery contract as streaming Planning Mode.
|
||||
*/
|
||||
const runtime = await planningRuntime(req, settings);
|
||||
const runtime = planningRuntime(settings);
|
||||
|
||||
const { createSession, RateLimitError: _RateLimitError } = await import("../planning.js");
|
||||
const result = await createSession(
|
||||
@@ -709,7 +689,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const resolvedClarificationEnabled = clarificationEnabled ?? settings.agentClarificationEnabled ?? false;
|
||||
const runtime = await planningRuntime(req, settings);
|
||||
const runtime = planningRuntime(settings);
|
||||
runtime.clarificationEnabled = resolvedClarificationEnabled;
|
||||
|
||||
// Resolve planning model using canonical lane hierarchy:
|
||||
@@ -914,7 +894,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const { submitResponse, attachPlanningRuntime, SessionNotFoundError: _SessionNotFoundError, InvalidSessionStateError: _InvalidSessionStateError } = await import("../planning.js");
|
||||
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
|
||||
await attachPlanningRuntime(sessionId, planningRuntime(settings));
|
||||
const result = await submitResponse(
|
||||
sessionId,
|
||||
responses,
|
||||
@@ -951,7 +931,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const { rewindSession, attachPlanningRuntime } = await import("../planning.js");
|
||||
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
|
||||
await attachPlanningRuntime(sessionId, planningRuntime(settings));
|
||||
const rewound = await rewindSession(
|
||||
sessionId,
|
||||
questionId,
|
||||
@@ -991,7 +971,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const { retrySession, attachPlanningRuntime } = await import("../planning.js");
|
||||
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
|
||||
await attachPlanningRuntime(sessionId, planningRuntime(settings));
|
||||
await retrySession(sessionId, scopedStore.getRootDir(), settings.promptOverrides, scopedStore);
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -338,6 +338,11 @@ FNXC:DashboardTestQuarantine 2026-07-16-09:00:
|
||||
FN-8077 removed routes-system.test.ts from this list and the ledger in lockstep. Its test now explicitly advances a fake Date-only clock between CPU samples, so unrelated route clock reads cannot stretch elapsed time under the loaded API lane; assertions and timeout policy are unchanged.
|
||||
*/
|
||||
const quarantinedDashboardTests: string[] = [
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-21-09:30:
|
||||
planning-browser-e2e completed its desktop assertions but timed out twice while tearing down Chromium/Vite. Quarantine the file without widening teardown timeouts or retries; delete it on 2026-08-04 unless a root-cause rescue restores deterministic teardown.
|
||||
*/
|
||||
"src/__tests__/planning-browser-e2e.test.ts",
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-17-16:50:
|
||||
FN-8245 re-admits all three UI files with their ledger rows removed in lockstep.
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet \u2014 see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date \u2014 the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config is the enforcement.",
|
||||
"entries": []
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/dashboard/src/__tests__/planning-browser-e2e.test.ts",
|
||||
"reason": "Chromium/Vite teardown timed out after the desktop assertions passed during [local Codex run 2026-07-21 09:29 PDT](local://codex/2026-07-21/planning-browser-e2e-teardown); repeated once in the same session.",
|
||||
"quarantinedAt": "2026-07-21"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user