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));
|
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 {
|
.planning-refine-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--space-xl);
|
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 TABLET_SUMMARY_ACTIONS_QUERY = "@media (min-width: 769px) and (max-width: 1024px)";
|
||||||
const MOBILE_ACTIONS_QUERY = "@media (max-width: 768px)";
|
const MOBILE_ACTIONS_QUERY = "@media (max-width: 768px)";
|
||||||
const MOBILE_PLANNING_SHELL_QUERY = "@media (max-width: 768px), (max-height: 480px)";
|
const MOBILE_PLANNING_SHELL_QUERY = "@media (max-width: 768px), (max-height: 480px)";
|
||||||
|
const DESKTOP_PLANNING_WORKSPACE_QUERY = "@media (min-width: 1025px)";
|
||||||
|
|
||||||
function loadPlanningCss(): string {
|
function loadPlanningCss(): string {
|
||||||
return readFileSync(PLANNING_CSS_PATH, "utf-8");
|
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*;/);
|
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", () => {
|
it("makes the history sheet full width on mobile while keeping its own scroll owner", () => {
|
||||||
const css = loadPlanningCss();
|
const css = loadPlanningCss();
|
||||||
expect(findRule(css, ".planning-history-scroll")).toMatch(/overflow-y\s*:\s*auto\s*;/);
|
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 question = document.querySelector<HTMLElement>("[data-testid='planning-question-pane']")!;
|
||||||
const scroll = document.querySelector<HTMLElement>("[data-testid='planning-plan-scroll']")!;
|
const scroll = document.querySelector<HTMLElement>("[data-testid='planning-plan-scroll']")!;
|
||||||
const actions = document.querySelector<HTMLElement>("[data-testid='planning-plan-actions']")!;
|
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 planRect = plan.getBoundingClientRect();
|
||||||
const questionRect = question.getBoundingClientRect();
|
const questionRect = question.getBoundingClientRect();
|
||||||
const scrollRect = scroll.getBoundingClientRect();
|
const scrollRect = scroll.getBoundingClientRect();
|
||||||
const actionsRect = actions.getBoundingClientRect();
|
const actionsRect = actions.getBoundingClientRect();
|
||||||
|
const questionActionsRect = questionActions.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
planVisible: planRect.width > 0 && planRect.height > 0,
|
planVisible: planRect.width > 0 && planRect.height > 0,
|
||||||
questionVisible: questionRect.width > 0 && questionRect.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,
|
scrollable: scroll.scrollHeight > scroll.clientHeight,
|
||||||
scrollOwnerConfigured: getComputedStyle(scroll).overflowY === "auto",
|
scrollOwnerConfigured: getComputedStyle(scroll).overflowY === "auto",
|
||||||
markdownRendered: Boolean(plan.querySelector("h1") && plan.querySelector("strong")),
|
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,
|
scrollable: true,
|
||||||
scrollOwnerConfigured: true,
|
scrollOwnerConfigured: true,
|
||||||
markdownRendered: true,
|
markdownRendered: true,
|
||||||
|
flushPaneInsets: !mobile,
|
||||||
|
desktopActionRowsAligned: mobile ? false : true,
|
||||||
|
actionTopDelta: mobile ? expect.any(Number) : 0,
|
||||||
|
actionBottomDelta: mobile ? expect.any(Number) : 0,
|
||||||
});
|
});
|
||||||
await page.close();
|
await page.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("@fusion/engine", () => ({
|
||||||
listCliAdapterDescriptors: () => [],
|
listCliAdapterDescriptors: () => [],
|
||||||
@@ -16,6 +18,7 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
import {
|
import {
|
||||||
__resetPlanningState,
|
__resetPlanningState,
|
||||||
__setCreateFnAgent,
|
__setCreateFnAgent,
|
||||||
|
__setPlanningNtfyHelpers,
|
||||||
createSession,
|
createSession,
|
||||||
createSessionWithAgent,
|
createSessionWithAgent,
|
||||||
formatInitialRunningPlanRequestForAgent,
|
formatInitialRunningPlanRequestForAgent,
|
||||||
@@ -186,59 +189,117 @@ describe("reactive Planning Mode question contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-07-18-17:30:
|
FNXC:PlanningMode 2026-07-21-09:15:
|
||||||
A model completion is never a Planning Mode terminal state. This exercises the real
|
Planning questions belong to the planning surface. Exercise both the initial and follow-up
|
||||||
createSession/submitResponse agent seam so regression coverage proves the running plan,
|
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.
|
||||||
Other steering, and explicit-only validation invariant rather than only testing normalization.
|
|
||||||
*/
|
*/
|
||||||
it("delivers planning-clarification metadata that can reopen the exact session", async () => {
|
it("keeps initial and follow-up questions out of Mailbox while preserving ntfy", async () => {
|
||||||
installScriptedAgent([payload({
|
installScriptedAgent([
|
||||||
...FIRST_QUESTION,
|
payload({
|
||||||
runningPlan: {
|
...FIRST_QUESTION,
|
||||||
title: "Secure account recovery delivery",
|
runningPlan: {
|
||||||
description: "Build a reviewed recovery workflow with audit coverage.",
|
title: "Secure account recovery delivery",
|
||||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
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) => {
|
payload({
|
||||||
resolveDelivered = resolve;
|
...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 = {
|
const messageStore = {
|
||||||
getInbox: vi.fn(async () => []),
|
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",
|
"127.0.0.11",
|
||||||
"Plan mailbox navigation",
|
"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",
|
"/tmp/project",
|
||||||
MOCK_TASK_STORE,
|
MOCK_TASK_STORE,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
{ clarificationEnabled: true, messageStore: messageStore as never },
|
{ clarificationEnabled: true, messageStore },
|
||||||
);
|
);
|
||||||
|
const initialQuestionReady = new Promise<void>((resolveQuestion) => {
|
||||||
const initialPlanReady = new Promise<void>((resolve) => {
|
|
||||||
planningStreamManager.subscribe(sessionId, (event) => {
|
planningStreamManager.subscribe(sessionId, (event) => {
|
||||||
if (event.type === "summary") resolve();
|
if (event.type === "question") resolveQuestion();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
planningStreamManager.consumeInitialTurn(sessionId)?.();
|
||||||
await initialPlanReady;
|
await initialQuestionReady;
|
||||||
const message = await delivered;
|
await new Promise<void>((resolveTurn) => setImmediate(resolveTurn));
|
||||||
|
await submitResponse(sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||||
|
|
||||||
expect(message).toMatchObject({
|
expect((await getSession(sessionId))?.currentQuestion?.id).toBe(SECOND_QUESTION.id);
|
||||||
type: "system",
|
expect(messageStore.getInbox).not.toHaveBeenCalled();
|
||||||
content: expect.stringContaining(FIRST_QUESTION.question),
|
expect(messageStore.sendMessage).not.toHaveBeenCalled();
|
||||||
metadata: {
|
|
||||||
kind: "planning-clarification",
|
|
||||||
sessionId,
|
|
||||||
questionId: FIRST_QUESTION.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("generates a durable initial plan with one question and validates only on user action", async () => {
|
||||||
const prompts = installScriptedAgent([
|
const prompts = installScriptedAgent([
|
||||||
payload({
|
payload({
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
MessageStore,
|
MessageStore,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
DASHBOARD_USER_ID,
|
|
||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
@@ -73,13 +72,16 @@ type AgentMessage = {
|
|||||||
|
|
||||||
const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const;
|
const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const;
|
||||||
type PlanningMcpServers = Awaited<ReturnType<typeof resolveMcpServersForStore>>["servers"];
|
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 = {
|
type PlanningSessionOptions = {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
ntfyConfig?: PlanningNtfyConfig;
|
ntfyConfig?: PlanningNtfyConfig;
|
||||||
clarificationEnabled?: boolean;
|
clarificationEnabled?: boolean;
|
||||||
/** Workflow selected by the planning entry point; retained for agent rebuilds. */
|
/** Workflow selected by the planning entry point; retained for agent rebuilds. */
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
/** Runtime-only mailbox dependency; never serialize this store. */
|
|
||||||
messageStore?: MessageStore;
|
messageStore?: MessageStore;
|
||||||
pluginRunner?: SkillPluginRunner;
|
pluginRunner?: SkillPluginRunner;
|
||||||
};
|
};
|
||||||
@@ -307,6 +309,7 @@ export interface DraftInputPayload {
|
|||||||
generationStartedAt?: string;
|
generationStartedAt?: string;
|
||||||
generationReturnQuestion?: PlanningQuestion;
|
generationReturnQuestion?: PlanningQuestion;
|
||||||
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. */
|
||||||
lastMailboxNotifiedQuestionKey?: string;
|
lastMailboxNotifiedQuestionKey?: string;
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
@@ -381,13 +384,9 @@ interface Session {
|
|||||||
ntfyConfig?: PlanningNtfyConfig;
|
ntfyConfig?: PlanningNtfyConfig;
|
||||||
/** Persisted per-session override for proactive AI clarification checkpoints. */
|
/** Persisted per-session override for proactive AI clarification checkpoints. */
|
||||||
clarificationEnabled?: boolean;
|
clarificationEnabled?: boolean;
|
||||||
/** Runtime-only mailbox dependency, attached by the current route. */
|
|
||||||
messageStore?: MessageStore;
|
|
||||||
autoMerge?: boolean;
|
autoMerge?: boolean;
|
||||||
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
|
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
|
||||||
lastNotifiedQuestionKey?: string;
|
lastNotifiedQuestionKey?: string;
|
||||||
/** Durable fast-path marker; the inbox lookup remains authoritative after a crash. */
|
|
||||||
lastMailboxNotifiedQuestionKey?: string;
|
|
||||||
history: PlanningHistoryEntry[];
|
history: PlanningHistoryEntry[];
|
||||||
currentQuestion?: PlanningQuestion;
|
currentQuestion?: PlanningQuestion;
|
||||||
/** Question currently being edited; history is preserved rather than truncated. */
|
/** 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.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.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
|
|
||||||
}),
|
}),
|
||||||
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,
|
||||||
@@ -780,9 +778,6 @@ 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,
|
||||||
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
|
|
||||||
? payload.lastMailboxNotifiedQuestionKey
|
|
||||||
: undefined,
|
|
||||||
history,
|
history,
|
||||||
currentQuestion,
|
currentQuestion,
|
||||||
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
|
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
|
||||||
@@ -1107,7 +1102,6 @@ export async function createSession(
|
|||||||
clarificationEnabled: options?.clarificationEnabled === true,
|
clarificationEnabled: options?.clarificationEnabled === true,
|
||||||
workflowId: options?.workflowId,
|
workflowId: options?.workflowId,
|
||||||
ntfyConfig: options?.ntfyConfig,
|
ntfyConfig: options?.ntfyConfig,
|
||||||
messageStore: options?.messageStore,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
@@ -1615,7 +1609,6 @@ export async function startExistingSession(
|
|||||||
if (runtimeOptions) {
|
if (runtimeOptions) {
|
||||||
session.clarificationEnabled = runtimeOptions.clarificationEnabled === true;
|
session.clarificationEnabled = runtimeOptions.clarificationEnabled === true;
|
||||||
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
session.ntfyConfig = runtimeOptions.ntfyConfig;
|
||||||
session.messageStore = runtimeOptions.messageStore;
|
|
||||||
}
|
}
|
||||||
beginPlanningGeneration(session, "initial_plan");
|
beginPlanningGeneration(session, "initial_plan");
|
||||||
await persistSession(session, "generating");
|
await persistSession(session, "generating");
|
||||||
@@ -1688,7 +1681,6 @@ export async function createSessionWithAgent(
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
clarificationEnabled: options?.clarificationEnabled === true,
|
clarificationEnabled: options?.clarificationEnabled === true,
|
||||||
messageStore: options?.messageStore,
|
|
||||||
history: [],
|
history: [],
|
||||||
summary: buildRunningSummary(initialPlan, []),
|
summary: buildRunningSummary(initialPlan, []),
|
||||||
validated: false,
|
validated: false,
|
||||||
@@ -1943,43 +1935,6 @@ async function maybeNotifyPlanningAwaitingInput(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const questionKey = `${session.id}:${question.id}`;
|
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
|
// Summary deepening checkpoints retain their existing ntfy behavior regardless
|
||||||
// of the clarification preference; only proactive questions are setting-gated.
|
// of the clarification preference; only proactive questions are setting-gated.
|
||||||
if (proactiveClarification && !session.clarificationEnabled) return;
|
if (proactiveClarification && !session.clarificationEnabled) return;
|
||||||
@@ -3303,12 +3258,11 @@ export async function attachPlanningRuntime(
|
|||||||
/*
|
/*
|
||||||
FNXC:AgentClarification 2026-07-16-16:15:
|
FNXC:AgentClarification 2026-07-16-16:15:
|
||||||
The following mutator owns the authoritative missing-session error. Runtime
|
The following mutator owns the authoritative missing-session error. Runtime
|
||||||
attachment is best-effort so restored live sessions receive current ntfy and
|
attachment is best-effort so restored live sessions receive current ntfy
|
||||||
mailbox dependencies without changing existing route error semantics.
|
settings without changing existing route error semantics.
|
||||||
*/
|
*/
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
session.ntfyConfig = options.ntfyConfig;
|
session.ntfyConfig = options.ntfyConfig;
|
||||||
session.messageStore = options.messageStore;
|
|
||||||
// Persisted session choice wins on resumed sessions; route defaults only fill old rows.
|
// Persisted session choice wins on resumed sessions; route defaults only fill old rows.
|
||||||
if (session.clarificationEnabled === undefined) session.clarificationEnabled = options.clarificationEnabled === true;
|
if (session.clarificationEnabled === undefined) session.clarificationEnabled = options.clarificationEnabled === true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
formatPlanningPlanMd,
|
formatPlanningPlanMd,
|
||||||
MessageStore,
|
|
||||||
resolvePlanningSettingsModel,
|
resolvePlanningSettingsModel,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
@@ -17,7 +16,6 @@ import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
|
|||||||
import type { AiSessionStore } from "../ai-session-store.js";
|
import type { AiSessionStore } from "../ai-session-store.js";
|
||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
import { resolveBranchAssignmentContext, resolveBranchSelection, resolveEntryPointBranchAssignment } from "./branch-selection.js";
|
import { resolveBranchAssignmentContext, resolveBranchSelection, resolveEntryPointBranchAssignment } from "./branch-selection.js";
|
||||||
import { requireAsyncLayer } from "../require-async-layer.js";
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
type SkillPluginRunner = Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3];
|
type SkillPluginRunner = Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3];
|
||||||
@@ -76,27 +74,9 @@ function rethrowPlanningWorkflowCreateError(
|
|||||||
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
|
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
|
||||||
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
|
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
|
||||||
const { aiSessionStore, parseLastEventId, replayBufferedSSE } = deps;
|
const { aiSessionStore, parseLastEventId, replayBufferedSSE } = deps;
|
||||||
const messageStoreCache = new Map<string, MessageStore>();
|
const planningRuntime = (settings: Awaited<ReturnType<TaskStore["getSettings"]>>) => ({
|
||||||
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"]>>) => ({
|
|
||||||
clarificationEnabled: settings.agentClarificationEnabled === true,
|
clarificationEnabled: settings.agentClarificationEnabled === true,
|
||||||
ntfyConfig: { enabled: settings.ntfyEnabled ?? false, topic: settings.ntfyTopic, ntfyBaseUrl: settings.ntfyBaseUrl, dashboardHost: settings.ntfyDashboardHost, events: settings.ntfyEvents },
|
ntfyConfig: { enabled: settings.ntfyEnabled ?? false, topic: settings.ntfyTopic, ntfyBaseUrl: settings.ntfyBaseUrl, dashboardHost: settings.ntfyDashboardHost, events: settings.ntfyEvents },
|
||||||
messageStore: await getPlanningMessageStore(req),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
||||||
@@ -551,10 +531,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
/*
|
/*
|
||||||
FNXC:AgentClarification 2026-07-16-16:10:
|
FNXC:AgentClarification 2026-07-16-16:10:
|
||||||
The legacy synchronous planning-start endpoint can emit the initial proactive question too.
|
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
|
Attach live notification settings here so it follows the same setting-gated hold and
|
||||||
hold and delivery contract as streaming Planning Mode.
|
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 { createSession, RateLimitError: _RateLimitError } = await import("../planning.js");
|
||||||
const result = await createSession(
|
const result = await createSession(
|
||||||
@@ -709,7 +689,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
const rootDir = scopedStore.getRootDir();
|
const rootDir = scopedStore.getRootDir();
|
||||||
const resolvedClarificationEnabled = clarificationEnabled ?? settings.agentClarificationEnabled ?? false;
|
const resolvedClarificationEnabled = clarificationEnabled ?? settings.agentClarificationEnabled ?? false;
|
||||||
const runtime = await planningRuntime(req, settings);
|
const runtime = planningRuntime(settings);
|
||||||
runtime.clarificationEnabled = resolvedClarificationEnabled;
|
runtime.clarificationEnabled = resolvedClarificationEnabled;
|
||||||
|
|
||||||
// Resolve planning model using canonical lane hierarchy:
|
// 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 { 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");
|
||||||
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
|
await attachPlanningRuntime(sessionId, planningRuntime(settings));
|
||||||
const result = await submitResponse(
|
const result = await submitResponse(
|
||||||
sessionId,
|
sessionId,
|
||||||
responses,
|
responses,
|
||||||
@@ -951,7 +931,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const { rewindSession, attachPlanningRuntime } = await import("../planning.js");
|
const { rewindSession, attachPlanningRuntime } = await import("../planning.js");
|
||||||
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
|
await attachPlanningRuntime(sessionId, planningRuntime(settings));
|
||||||
const rewound = await rewindSession(
|
const rewound = await rewindSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
questionId,
|
questionId,
|
||||||
@@ -991,7 +971,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
const { retrySession, attachPlanningRuntime } = await import("../planning.js");
|
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);
|
await retrySession(sessionId, scopedStore.getRootDir(), settings.promptOverrides, scopedStore);
|
||||||
res.json({ success: true, sessionId });
|
res.json({ success: true, sessionId });
|
||||||
} catch (err: unknown) {
|
} 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.
|
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[] = [
|
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:
|
FNXC:DashboardTestQuarantine 2026-07-17-16:50:
|
||||||
FN-8245 re-admits all three UI files with their ledger rows removed in lockstep.
|
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.",
|
"$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