FN-8123: add agent clarification notifications

Add agent clarification notices with ntfy delivery and mailbox support.

- Add configurable clarification ntfy topics and notification settings
- Route agent clarification requests to ntfy and the planning mailbox
- Update planning dialogs, translations, documentation, and regression tests

Files changed:
 .changeset/fn-8123-agent-clarification.md          |   7 +
 docs/dashboard-guide.md                            |   6 +
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/global-settings.test.ts     |  11 +
 packages/core/src/settings-schema.ts               |   2 +
 packages/core/src/types.ts                         |   6 +
 .../app/__tests__/settings-save-split.test.ts      |  13 ++
 packages/dashboard/app/api/legacy.ts               |   3 +-
 .../dashboard/app/components/PlanningModeModal.tsx |  29 ++-
 .../dashboard/app/components/SettingsModal.tsx     |   1 +
 .../__tests__/PlanningModeModal.initial.test.tsx   |  17 +-
 .../app/components/settings/save-split.ts          |   1 +
 .../settings/sections/NotificationsSection.tsx     |  11 +
 .../src/__tests__/routes-planning.test.ts          |   7 +-
 packages/dashboard/src/planning.ts                 | 228 ++++++++++++++++-----
 .../src/routes/register-planning-subtask-routes.ts |  59 +++++-
 packages/i18n/locales/en/app.json                  |   4 +
 packages/i18n/src/resources.d.ts                   |   4 +
 18 files changed, 346 insertions(+), 64 deletions(-)

Fusion-Task-Id: FN-8123

Fusion-Task-Lineage: cf397749-01c6-44d6-8d12-8ce7f5627c29

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 16:51:19 -07:00
parent bd8a6ae7bb
commit fd036665ae
18 changed files with 346 additions and 64 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add planner clarification controls with ntfy and mailbox alerts.
category: feature
dev: Adds the disabled-by-default agentClarificationEnabled setting and session override.

View File

@@ -2017,3 +2017,9 @@ done
```
If the endpoint is unavailable on the running dashboard build, the response will be `{"error":"Not found"}` until a build containing the branch-group router is deployed.
### Planner clarification notifications
In **Settings → Notifications**, enable **Agent clarification** to let Planning Mode pause when the planner needs an answer. The Planning Mode advanced settings include a per-session override, initialized from that global preference. With clarification disabled, proactive questions are redirected to a final plan summary instead of holding the session; the final summary deepening checkpoint is unchanged.
When enabled, a proactive question holds the planner at `awaiting_input`, sends the configured `planning-awaiting-input` ntfy event, and delivers a dashboard mailbox message that links the operator back to planner chat. Mailbox delivery does not depend on ntfy configuration and is deduplicated by session/question across restarts.

View File

@@ -71,6 +71,7 @@ Fallback thinking-level values are applied at runtime when Fusion swaps from the
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | `undefined` | Default reasoning effort for AI sessions. `xhigh` requests maximum reasoning effort; Claude CLI adapters map it to `high` for non-Opus models and `max` for Opus models. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. |
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
| `agentClarificationEnabled` | `boolean` | `false` | Allow Planning Mode to pause for proactive AI clarification questions. When disabled, the planner requests a final summary instead; enabled proactive questions notify configured ntfy recipients and the dashboard mailbox. |
| `failureNotificationMode` | `"sticky-only" \| "terminal-only" \| "all"` | `"sticky-only"` | Failure notification behavior. `sticky-only` defers failed-task notifications by `failureNotificationDelayMs` and suppresses transient self-recoveries. `terminal-only` suppresses while auto-retry is still active and only dispatches when `paused === true` or `column === "in-review"` with `status === "failed"`. `all` restores legacy immediate failure notifications. |
| `failureNotificationDelayMs` | `number` | `30000` | Delay window (ms) before evaluating/sending a `failed` notification in `sticky-only` and `terminal-only` modes. Set `0` for immediate dispatch in legacy `all` mode. |
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |

View File

@@ -229,6 +229,17 @@ describe("GlobalSettingsStore", () => {
expect(parsed.themeMode).toBe("system");
});
it("persists and clears the planner clarification preference", async () => {
await store.init();
expect((await store.getSettings()).agentClarificationEnabled).toBe(false);
await store.updateSettings({ agentClarificationEnabled: true });
expect((await store.getSettings()).agentClarificationEnabled).toBe(true);
await store.updateSettings({ agentClarificationEnabled: undefined });
expect((await store.getSettings()).agentClarificationEnabled).toBeUndefined();
});
it("merges multiple updates without losing fields", async () => {
await store.init();

View File

@@ -135,6 +135,8 @@ export const DEFAULT_GLOBAL_SETTINGS = {
fallbackThinkingLevel: undefined,
defaultThinkingLevel: undefined,
ntfyEnabled: false,
// FNXC:AgentClarification 2026-07-16-12:00: Planner clarification pauses are opt-in; disabled planners must complete a summary instead of waiting for proactive answers.
agentClarificationEnabled: false,
ntfyTopic: undefined,
ntfyBaseUrl: undefined,
ntfyAccessToken: undefined,

View File

@@ -2479,6 +2479,12 @@ export interface GlobalSettings {
/** When true, enables ntfy.sh push notifications for task completion and failures.
* Requires ntfyTopic to be set. Default: false. */
ntfyEnabled?: boolean;
/**
* FNXC:AgentClarification 2026-07-16-12:00:
* Controls proactive planner clarification checkpoints. Disabled sessions re-prompt
* for a final summary; enabled sessions hold for input and notify via ntfy/mailbox.
*/
agentClarificationEnabled?: boolean;
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
* notifications are sent to {ntfyBaseUrl}/{topic} (default: https://ntfy.sh/{topic})
* when tasks complete or fail. */

View File

@@ -77,6 +77,19 @@ describe("resolveScopedMcpSettings", () => {
});
});
describe("agent clarification notification ownership", () => {
it("persists the Notifications setting through the section save split", () => {
const result = splitSettingsSave({
payload: { agentClarificationEnabled: true },
initialValues: { agentClarificationEnabled: false } as never,
initialScopedValues: { global: { agentClarificationEnabled: false }, project: {} } as never,
activeSection: "notifications",
});
expect(result.globalPatch).toEqual({ agentClarificationEnabled: true });
});
});
describe("splitSettingsSave", () => {
it("routes one global + one project edit into the right patches", () => {
const initialValues = { language: "en", maxConcurrent: 2 } as never;

View File

@@ -3384,7 +3384,7 @@ export function startPlanningStreaming(
initialPlan: string,
projectId?: string,
modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel },
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number },
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number; clarificationEnabled?: boolean },
existingSessionId?: string,
): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
@@ -3396,6 +3396,7 @@ export function startPlanningStreaming(
thinkingLevel: modelOverride?.thinkingLevel,
planningDepth: planningOptions?.planningDepth,
customQuestionCount: planningOptions?.customQuestionCount,
clarificationEnabled: planningOptions?.clarificationEnabled,
...(existingSessionId ? { existingSessionId } : {}),
}),
});

View File

@@ -35,6 +35,7 @@ import {
updatePlanningSessionDraft,
summarizePlanningDraftTitle,
updateGlobalSettings,
fetchGlobalSettings,
type PlanningSession,
type SubtaskItem,
type PlanningSubtaskDraft,
@@ -437,6 +438,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [planningThinkingLevel, setPlanningThinkingLevel] = useState<ThinkingLevel | "">("");
const [planningDepth, setPlanningDepth] = useState<"small" | "medium" | "large">("medium");
const [customQuestionCount, setCustomQuestionCount] = useState("");
const [clarificationEnabled, setClarificationEnabled] = useState(false);
const [clarificationSettingsLoading, setClarificationSettingsLoading] = useState(true);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -1096,6 +1099,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}, [activePlanPrompt, addToast, t]);
const handleStartPlanning = useCallback(async (planOverride?: string) => {
if (clarificationSettingsLoading) return;
const plan = planOverride ?? initialPlan;
const startedPlan = plan.trim();
if (!startedPlan) return;
@@ -1132,6 +1136,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
customQuestionCount: Number.isInteger(parsedCustomQuestionCount)
? parsedCustomQuestionCount
: undefined,
clarificationEnabled,
},
draftSessionId ?? undefined,
);
@@ -1148,6 +1153,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
currentSessionIdRef.current = null;
}
}, [
clarificationEnabled,
clarificationSettingsLoading,
connectToPlanningStream,
customQuestionCount,
initialPlan,
@@ -1159,6 +1166,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
resetPlanningAutoRetryBudget,
]);
useEffect(() => {
if (!isOpen) return;
let active = true;
setClarificationSettingsLoading(true);
void fetchGlobalSettings()
.then((settings) => { if (active) setClarificationEnabled(settings.agentClarificationEnabled === true); })
// Safe fallback keeps automatic/manual starts from unexpectedly pausing.
.catch(() => { if (active) setClarificationEnabled(false); })
.finally(() => { if (active) setClarificationSettingsLoading(false); });
return () => { active = false; };
}, [isOpen]);
/*
FNXC:PlanningFocus 2026-06-23-00:00:
Viewing Planning Mode must not auto-focus the initial composer because mobile browsers open the keyboard before the user chooses to type. Keep the textarea ref for autosize and explicit user focus only; populated initialPlan handoffs still auto-start through the separate effect below.
@@ -1173,7 +1192,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// Auto-start planning when initialPlan prop is provided
useEffect(() => {
if (isOpen && initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") {
if (isOpen && initialPlanProp && !clarificationSettingsLoading && !hasAutoStartedRef.current && view.type === "initial") {
setInitialPlan(initialPlanProp);
// Use a small timeout to allow state update to propagate before starting
const timer = setTimeout(() => {
@@ -1199,7 +1218,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setInitialPlan(persisted);
}
}
}, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]);
}, [isOpen, initialPlanProp, clarificationSettingsLoading, view.type, handleStartPlanning, projectId]);
// Load a specific persisted session into the right pane.
const loadSession = useCallback(
@@ -2379,6 +2398,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
<div className="planning-advanced-section planning-depth-selector">
<label className="checkbox-label" htmlFor="planning-clarification-enabled">
<input id="planning-clarification-enabled" type="checkbox" checked={clarificationEnabled} disabled={clarificationSettingsLoading} onChange={(event) => setClarificationEnabled(event.target.checked)} />
{t("planning.agentClarification", " Allow agent clarification questions")}
</label>
<p className="planning-advanced-blurb">
{t("planning.depthBlurb", "Plan size sets default interview depth. Questions lets you override with an exact count.")}
</p>
@@ -2427,7 +2450,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<button
className="btn btn-primary planning-start-btn"
onClick={() => handleStartPlanning()}
disabled={!initialPlan.trim()}
disabled={!initialPlan.trim() || clarificationSettingsLoading}
>
<Lightbulb size={16} className="icon-mr-8" />
{t("planning.startPlanning", "Start Planning")}

View File

@@ -1168,6 +1168,7 @@ export function SettingsModal({
includeTaskIdInCommit: true,
worktreeInitCommand: "",
ntfyEnabled: false,
agentClarificationEnabled: false,
ntfyTopic: undefined,
ntfyAccessToken: undefined,
failureNotificationMode: "sticky-only",

View File

@@ -95,6 +95,7 @@ vi.mock("../../api", () => ({
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
refineTask: (...args: any[]) => mockRefineTask(...args),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({ agentClarificationEnabled: false }),
fetchModels: (...args: any[]) => mockFetchModels(...args),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
refineText: vi.fn(),
@@ -286,6 +287,7 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from handoff", undefined, undefined, {
planningDepth: "medium",
clarificationEnabled: false,
customQuestionCount: undefined,
}, undefined);
});
@@ -520,7 +522,7 @@ describe("PlanningModeModal", () => {
expect(screen.queryByLabelText("Send to background")).toBeNull();
});
it("enables start button when text is entered", () => {
it("enables start button when text is entered", async () => {
render(
<PlanningModeModal
isOpen={true}
@@ -534,7 +536,9 @@ describe("PlanningModeModal", () => {
const startButton = screen.getByText("Start Planning");
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.change(textarea, { target: { value: "Test plan" } });
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false);
@@ -631,6 +635,7 @@ describe("PlanningModeModal", () => {
planningModelId: "claude-sonnet-4-5",
}, {
planningDepth: "medium",
clarificationEnabled: false,
customQuestionCount: undefined,
}, undefined);
});
@@ -685,6 +690,7 @@ describe("PlanningModeModal", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "Large" }));
fireEvent.change(screen.getByLabelText("Questions"), { target: { value: "7" } });
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
@@ -695,6 +701,7 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "large",
clarificationEnabled: false,
customQuestionCount: 7,
}, undefined);
});
@@ -711,13 +718,16 @@ describe("PlanningModeModal", () => {
/>
);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
planningDepth: "medium",
clarificationEnabled: false,
customQuestionCount: undefined,
}, undefined);
});
@@ -774,6 +784,7 @@ describe("PlanningModeModal", () => {
undefined,
{
planningDepth: "medium",
clarificationEnabled: false,
customQuestionCount: undefined,
},
"draft-123",
@@ -860,6 +871,7 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
planningDepth: "medium",
clarificationEnabled: false,
customQuestionCount: undefined,
}, undefined);
}, { timeout: 2000 });
@@ -886,6 +898,7 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
planningDepth: "medium",
clarificationEnabled: false,
customQuestionCount: undefined,
}, undefined);
}, { timeout: 2000 });
@@ -913,6 +926,8 @@ describe("PlanningModeModal", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
await waitFor(() => expect(document.querySelector("#planning-clarification-enabled")).not.toBeDisabled());
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Draft a migration plan" },
});

View File

@@ -110,6 +110,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
]),
notifications: new Set([
"ntfyEnabled",
"agentClarificationEnabled",
"ntfyTopic",
"ntfyBaseUrl",
"ntfyAccessToken",

View File

@@ -117,6 +117,17 @@ export function NotificationsSection({ form, setForm, testNotificationLoading, t
</div>
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>{t("settings.notifications.agentClarification", "Agent clarification")}</strong>
<label htmlFor="agentClarificationEnabled" className="checkbox-label">
<input id="agentClarificationEnabled" type="checkbox" checked={form.agentClarificationEnabled ?? false} onChange={(event) => setForm((current) => ({ ...current, agentClarificationEnabled: event.target.checked }))} />
{t("settings.notifications.agentClarificationEnable", " Allow the planner to ask questions")}
</label>
<SettingsHelpTip settingKey="agentClarificationEnabled">{t("settings.notifications.agentClarificationHint", "Default: disabled. When enabled, planner questions pause planning and notify your mailbox.")}</SettingsHelpTip>
</div>
</div>
<div className="notification-provider-card">
<div className="notification-provider-header">
<strong>{t("settings.notifications.ntfy", "ntfy")}</strong>

View File

@@ -210,8 +210,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ autoMerge: false, defaultBranch: "main" }),
getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: false, defaultBranch: "main" }),
// Existing planning-route scenarios exercise the enabled checkpoint flow; explicit disabled cases override this default.
getSettings: vi.fn().mockResolvedValue({ autoMerge: false, defaultBranch: "main", agentClarificationEnabled: true }),
getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: false, defaultBranch: "main", agentClarificationEnabled: true }),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
@@ -3547,9 +3548,11 @@ describe("Saturated-slot regression: utility AI routes", () => {
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 0, // SATURATED: zero task slots available
promptOverrides: {},
agentClarificationEnabled: true,
}),
getSettingsFast: vi.fn().mockResolvedValue({
maxConcurrent: 0,
agentClarificationEnabled: true,
}),
...overrides,
} as Partial<TaskStore>);

View File

@@ -20,8 +20,10 @@ import type {
TaskStore,
NtfyNotificationEvent,
ThinkingLevel,
MessageStore,
} from "@fusion/core";
import {
DASHBOARD_USER_ID,
DEFAULT_TASK_PRIORITY,
PLANNING_DEEPEN_CHECKPOINT_ID,
PLANNING_DEEPEN_CHECKPOINT_QUESTION,
@@ -62,12 +64,19 @@ const PLANNING_NO_AMBIENT_TASK_ID = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any;
type SkillPluginRunner = Parameters<typeof buildSessionSkillContextSync>[3];
type AgentMessage = {
role: string;
content?: string | Array<{ type: string; text?: string; thinking?: string }>;
};
const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const;
type PlanningMcpServers = Awaited<ReturnType<typeof resolveMcpServersForStore>>["servers"];
type PlanningSessionOptions = {
projectId?: string;
ntfyConfig?: PlanningNtfyConfig;
clarificationEnabled?: boolean;
/** Runtime-only mailbox dependency; never serialize this store. */
messageStore?: MessageStore;
planningDepth?: PlanningDepth;
customQuestionCount?: number;
pluginRunner?: SkillPluginRunner;
@@ -291,6 +300,8 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session";
*/
export interface DraftInputPayload {
initialPlan?: string;
clarificationEnabled?: boolean;
lastMailboxNotifiedQuestionKey?: string;
modelProvider?: string;
modelId?: string;
thinkingLevel?: ThinkingLevel;
@@ -382,9 +393,15 @@ interface Session {
/** Plan text the current title was summarized from; lets startExistingSession skip a redundant re-summarize when blur/close already covered the final text. */
draftSummarizedFor?: string;
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;
summary?: PlanningSummary;
@@ -792,6 +809,10 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
...(session.draftThinkingLevel ? { thinkingLevel: session.draftThinkingLevel } : {}),
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
...(session.pendingSummary ? { pendingSummary: session.pendingSummary } : {}),
...(typeof session.clarificationEnabled === "boolean"
? { clarificationEnabled: session.clarificationEnabled }
: {}),
...(session.lastMailboxNotifiedQuestionKey ? { lastMailboxNotifiedQuestionKey: session.lastMailboxNotifiedQuestionKey } : {}),
}),
conversationHistory: JSON.stringify(session.history),
currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null,
@@ -899,6 +920,12 @@ function buildSessionFromRow(row: AiSessionRow): Session {
draftModelId: payload.modelId,
draftThinkingLevel: thinkingLevel,
draftSummarizedFor: payload.summarizedFor,
clarificationEnabled: typeof payload.clarificationEnabled === "boolean"
? payload.clarificationEnabled
: undefined,
lastMailboxNotifiedQuestionKey: typeof payload.lastMailboxNotifiedQuestionKey === "string"
? payload.lastMailboxNotifiedQuestionKey
: undefined,
history: safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory,
[],
@@ -1193,6 +1220,7 @@ export async function createSession(
planningDepth?: PlanningDepth,
customQuestionCount?: number,
pluginRunner?: SkillPluginRunner,
options?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled">,
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
// Check rate limit
if (!checkRateLimit(ip)) {
@@ -1225,6 +1253,9 @@ export async function createSession(
store,
rootDir,
pluginRunner,
clarificationEnabled: options?.clarificationEnabled === true,
ntfyConfig: options?.ntfyConfig,
messageStore: options?.messageStore,
};
sessions.set(sessionId, session);
@@ -1282,11 +1313,32 @@ export async function createSession(
session.updatedAt = new Date();
// Send initial plan to get first question from AI
const firstQuestion = await getFirstQuestionFromAgent(session, initialPlan);
const firstResponse = await getFirstQuestionFromAgent(session, initialPlan);
if (firstResponse.type === "complete") {
const firstQuestion = setPendingSummaryCheckpoint(session, normalizePlanningSummaryPayload(firstResponse.data, {
title: session.title || session.initialPlan,
description: session.initialPlan,
}));
return { sessionId, firstQuestion };
}
if (!session.clarificationEnabled) {
try {
const firstQuestion = await continueToSummaryAfterSuppressedQuestion(session);
return { sessionId, firstQuestion };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setSessionError(session, message);
throw error;
}
}
const firstQuestion = firstResponse.data;
session.currentQuestion = firstQuestion;
session.updatedAt = new Date();
await persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, firstQuestion, true);
return { sessionId, firstQuestion };
}
@@ -1299,7 +1351,7 @@ export async function createSession(
async function getFirstQuestionFromAgent(
session: Session,
message: string
): Promise<PlanningQuestion> {
): Promise<PlanningResponse> {
if (!session.agent) {
throw new InvalidSessionStateError("AI agent not initialized");
}
@@ -1422,15 +1474,44 @@ async function getFirstQuestionFromAgent(
throw new Error(`Failed to get first question from AI: ${errorMessage}`);
}
if (parsed.type === "complete") {
const summary = normalizePlanningSummaryPayload(parsed.data, {
title: session.title || session.initialPlan,
description: session.initialPlan,
});
return setPendingSummaryCheckpoint(session, summary);
}
return parsed;
}
return parsed.data;
/**
* FNXC:AgentClarification 2026-07-16-13:00:
* Disabled clarification must never leave a proactive question parked. One
* bounded follow-up requests the protocol's complete payload, then hands it
* to the existing deepening checkpoint; malformed/question follow-ups fail
* visibly instead of creating a prompt loop.
*/
async function continueToSummaryAfterSuppressedQuestion(
session: Session,
abortSignal?: AbortSignal,
): Promise<PlanningQuestion> {
if (!session.agent) throw new InvalidSessionStateError("Planning session has no AI agent");
await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise<void>)(
'Clarification is disabled. Return ONLY the final {"type":"complete","data":...} JSON summary; do not ask another question.',
{ signal: abortSignal },
);
const followUp = (session.agent.session.state.messages as AgentMessage[])
.filter((message) => message.role === "assistant")
.pop();
const followUpText = typeof followUp?.content === "string"
? followUp.content
: Array.isArray(followUp?.content)
? followUp.content
.filter((block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string")
.map((block) => block.text)
.join("")
: "";
const complete = parseAgentResponse(followUpText);
if (complete.type !== "complete") {
throw new Error("Clarification-disabled follow-up did not produce a summary");
}
return setPendingSummaryCheckpoint(session, normalizePlanningSummaryPayload(complete.data, {
title: session.title || session.initialPlan,
description: session.initialPlan,
}));
}
export async function createDraftSession(
@@ -1470,6 +1551,9 @@ export async function createDraftSession(
draftModelProvider: hasModelOverride ? modelProvider : undefined,
draftModelId: hasModelOverride ? modelId : undefined,
draftThinkingLevel: thinkingLevel,
// Draft creation has no resolved settings context; startExistingSession
// applies the request override/global default before generation begins.
clarificationEnabled: undefined,
history: [],
thinkingOutput: "",
lastGeneratedThinking: "",
@@ -1563,6 +1647,7 @@ export async function startExistingSession(
thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap,
promptOverridesOrPluginRunner?: PromptOverrideMap | SkillPluginRunner,
pluginRunnerMaybe?: SkillPluginRunner,
runtimeOptions?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled">,
): Promise<void> {
const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined;
const promptOverrides = isThinkingLevel(thinkingLevelOrPromptOverrides)
@@ -1651,6 +1736,11 @@ export async function startExistingSession(
}
session.draftThinkingLevel = thinkingLevel ?? persistedThinkingLevel;
if (runtimeOptions) {
session.clarificationEnabled = runtimeOptions.clarificationEnabled === true;
session.ntfyConfig = runtimeOptions.ntfyConfig;
session.messageStore = runtimeOptions.messageStore;
}
persistSession(session, "generating");
planningStreamManager.registerInitialTurn(sessionId, () => {
session.pluginRunner = pluginRunner;
@@ -1719,6 +1809,8 @@ export async function createSessionWithAgent(
ntfyBaseUrl: options.ntfyConfig.ntfyBaseUrl,
}
: undefined,
clarificationEnabled: options?.clarificationEnabled === true,
messageStore: options?.messageStore,
history: [],
thinkingOutput: "",
lastGeneratedThinking: "",
@@ -1975,49 +2067,65 @@ async function ensureSessionAgent(
});
}
async function maybeNotifyPlanningAwaitingInput(session: Session, question: PlanningQuestion): Promise<void> {
const config = session.ntfyConfig;
if (!config?.enabled || !config.topic) {
return;
async function maybeNotifyPlanningAwaitingInput(
session: Session,
question: PlanningQuestion,
proactiveClarification = false,
): 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.
*/
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;
const config = session.ntfyConfig;
if (!config?.enabled || !config.topic) return;
await ensureNtfyHelpersReady();
const eventEnabled = planningNtfyHelpers?.isNtfyEventEnabled
? planningNtfyHelpers.isNtfyEventEnabled(config.events, "planning-awaiting-input")
: (config.events ? config.events.includes("planning-awaiting-input") : true);
if (!eventEnabled) {
return;
}
const questionKey = `${session.id}:${question.id}`;
if (session.lastNotifiedQuestionKey === questionKey) {
return;
}
if (!eventEnabled || session.lastNotifiedQuestionKey === questionKey) return;
session.lastNotifiedQuestionKey = questionKey;
if (!planningNtfyHelpers) {
return;
}
if (!planningNtfyHelpers) return;
try {
const clickUrl = planningNtfyHelpers.buildNtfyClickUrl({
dashboardHost: config.dashboardHost,
projectId: session.projectId,
});
await planningNtfyHelpers.sendNtfyNotification({
ntfyBaseUrl: config.ntfyBaseUrl,
topic: config.topic,
title: "Planning needs your input",
message: `Planning mode is waiting for input: ${question.question}`,
priority: "high",
clickUrl,
});
const clickUrl = planningNtfyHelpers.buildNtfyClickUrl({ dashboardHost: config.dashboardHost, projectId: session.projectId });
await planningNtfyHelpers.sendNtfyNotification({ ntfyBaseUrl: config.ntfyBaseUrl, topic: config.topic,
title: "Planning needs your input", message: `Planning mode is waiting for input: ${question.question}`, priority: "high", clickUrl });
} catch (error) {
diagnostics.warn("Failed to deliver planning awaiting-input ntfy notification", {
sessionId: session.id,
questionId: question.id,
error: error instanceof Error ? error.message : String(error),
operation: "planning-notify-awaiting-input",
sessionId: session.id, questionId: question.id, error: error instanceof Error ? error.message : String(error), operation: "planning-notify-awaiting-input",
});
}
}
@@ -2374,12 +2482,17 @@ async function continueAgentConversation(session: Session, message: string): Pro
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
if (!session.clarificationEnabled) {
try {
await continueToSummaryAfterSuppressedQuestion(session, abortSignal);
} catch (error) {
setSessionError(session, error instanceof Error ? error.message : String(error));
}
return;
}
persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
planningStreamManager.broadcast(session.id, {
type: "question",
data: parsed.data,
});
void maybeNotifyPlanningAwaitingInput(session, parsed.data, true);
planningStreamManager.broadcast(session.id, { type: "question", data: parsed.data });
} else if (parsed.type === "complete") {
const summary = normalizePlanningSummaryPayload(parsed.data, {
title: session.title || session.initialPlan,
@@ -3071,6 +3184,25 @@ export async function cancelSession(sessionId: string): Promise<void> {
/**
* Get session details.
*/
/** Attach live-only route dependencies after session rehydration. */
export async function attachPlanningRuntime(
sessionId: string,
options: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled">,
): Promise<void> {
const session = await getSession(sessionId);
/*
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.
*/
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;
}
export async function getSession(sessionId: string): Promise<Session | undefined> {
const inMemory = sessions.get(sessionId);
if (inMemory) {

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_TASK_PRIORITY,
MessageStore,
resolvePlanningSettingsModel,
TASK_PRIORITIES,
THINKING_LEVELS,
@@ -14,6 +15,7 @@ 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";
type SkillPluginRunner = Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3];
@@ -48,6 +50,28 @@ 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"]>>) => ({
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 ──────────────────────────────────────────────────
// UTILITY PATH: Planning and subtask session routes are on a separate control-plane lane.
@@ -497,6 +521,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = scopedStore.getRootDir();
/*
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.
*/
const runtime = await planningRuntime(req, settings);
const { createSession, RateLimitError: _RateLimitError } = await import("../planning.js");
const result = await createSession(
@@ -508,6 +539,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
planningDepth,
customQuestionCount,
ctx.options?.pluginRunner as SkillPluginRunner,
runtime,
);
res.status(201).json(result);
} catch (err: unknown) {
@@ -615,6 +647,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
customQuestionCount,
existingSessionId,
thinkingLevel,
clarificationEnabled,
} = req.body;
if (!initialPlan || typeof initialPlan !== "string") {
@@ -645,6 +678,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
throw badRequest("customQuestionCount must be an integer between 1 and 20 when provided");
}
if (clarificationEnabled !== undefined && typeof clarificationEnabled !== "boolean") {
throw badRequest("clarificationEnabled must be a boolean when provided");
}
if (existingSessionId !== undefined && typeof existingSessionId !== "string") {
throw badRequest("existingSessionId must be a string when provided");
}
@@ -658,6 +695,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = scopedStore.getRootDir();
const resolvedClarificationEnabled = clarificationEnabled ?? settings.agentClarificationEnabled ?? false;
const runtime = await planningRuntime(req, settings);
runtime.clarificationEnabled = resolvedClarificationEnabled;
// Resolve planning model using canonical lane hierarchy:
// 1. Request body planning override
@@ -708,6 +748,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
validatedThinkingLevel,
settings.promptOverrides,
ctx.options?.pluginRunner as SkillPluginRunner,
runtime,
);
} else {
await startExistingSession(
@@ -718,6 +759,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
resolvedPlanningModelId,
settings.promptOverrides,
ctx.options?.pluginRunner as SkillPluginRunner,
undefined,
runtime,
);
}
res.status(201).json({ sessionId: existingSessionId });
@@ -727,12 +770,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { createSessionWithAgent, RateLimitError: _RateLimitError2 } = await import("../planning.js");
const planningOptions = {
projectId,
ntfyConfig: {
enabled: settings.ntfyEnabled ?? false,
topic: settings.ntfyTopic,
dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents,
},
...runtime,
planningDepth,
customQuestionCount,
pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner,
@@ -837,7 +875,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const { submitResponse, 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));
const result = await submitResponse(
sessionId,
responses,
@@ -871,7 +910,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const { rewindSession } = await import("../planning.js");
const { rewindSession, attachPlanningRuntime } = await import("../planning.js");
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
const rewound = await rewindSession(
sessionId,
scopedStore.getRootDir(),
@@ -909,7 +949,8 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const { retrySession } = await import("../planning.js");
const { retrySession, attachPlanningRuntime } = await import("../planning.js");
await attachPlanningRuntime(sessionId, await planningRuntime(req, settings));
await retrySession(sessionId, scopedStore.getRootDir(), settings.promptOverrides, scopedStore);
res.json({ success: true, sessionId });
} catch (err: unknown) {

View File

@@ -4582,6 +4582,7 @@
"deleteSession": "Delete session",
"dependencies": "Dependencies",
"dependencyCycle": "Dependencies contain a cycle. Remove circular references before creating tasks.",
"agentClarification": "Allow agent clarification questions",
"depthBlurb": "Plan size sets default interview depth. Questions lets you override with an exact count.",
"depthLarge": "Large",
"depthMedium": "Medium",
@@ -6338,6 +6339,9 @@
},
"notifications": {
"accessTokenOptional": "Access token (optional)",
"agentClarification": "Agent clarification",
"agentClarificationEnable": "Allow the planner to ask questions",
"agentClarificationHint": "Default: disabled. When enabled, planner questions pause planning and notify your mailbox.",
"advanced": "Advanced",
"allFailuresLegacy": "All failures (legacy)",
"baseURLForDeepLinksInNotificationsWhen": " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. No default — unset. ",

View File

@@ -4584,6 +4584,7 @@ export default interface Resources {
"deleteSession": "Delete session",
"dependencies": "Dependencies",
"dependencyCycle": "Dependencies contain a cycle. Remove circular references before creating tasks.",
"agentClarification": "Allow agent clarification questions",
"depthBlurb": "Plan size sets default interview depth. Questions lets you override with an exact count.",
"depthLarge": "Large",
"depthMedium": "Medium",
@@ -6336,6 +6337,9 @@ export default interface Resources {
},
"notifications": {
"accessTokenOptional": "Access token (optional)",
"agentClarification": "Agent clarification",
"agentClarificationEnable": "Allow the planner to ask questions",
"agentClarificationHint": "Default: disabled. When enabled, planner questions pause planning and notify your mailbox.",
"advanced": "Advanced",
"allFailuresLegacy": "All failures (legacy)",
"baseURLForDeepLinksInNotificationsWhen": " Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. No default — unset. ",