From fd036665aea0388b58031f6336d60098775b0f7f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 16:51:19 -0700 Subject: [PATCH] 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) --- .changeset/fn-8123-agent-clarification.md | 7 + docs/dashboard-guide.md | 6 + docs/settings-reference.md | 1 + .../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 +- .../app/components/PlanningModeModal.tsx | 29 ++- .../app/components/SettingsModal.tsx | 1 + .../PlanningModeModal.initial.test.tsx | 17 +- .../app/components/settings/save-split.ts | 1 + .../sections/NotificationsSection.tsx | 11 + .../src/__tests__/routes-planning.test.ts | 7 +- packages/dashboard/src/planning.ts | 228 ++++++++++++++---- .../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(-) create mode 100644 .changeset/fn-8123-agent-clarification.md diff --git a/.changeset/fn-8123-agent-clarification.md b/.changeset/fn-8123-agent-clarification.md new file mode 100644 index 0000000000..88eab8710d --- /dev/null +++ b/.changeset/fn-8123-agent-clarification.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b9aa1943a1..d3eb4ece7f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 95741741f1..cf4776163e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index c8d3e27370..c02d7363f7 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -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(); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 48ddc561d0..28c2855c17 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 88a24399e6..5184c00382 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */ diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index 2c09670219..e3dc4e5fdc 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -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; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b4dba638e1..bf089223d8 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 } : {}), }), }); diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index db8ae624a9..311de988a1 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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(""); 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([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(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
+

{t("planning.depthBlurb", "Plan size sets default interview depth. Questions lets you override with an exact count.")}

@@ -2427,7 +2450,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
+
+
+ {t("settings.notifications.agentClarification", "Agent clarification")} + + {t("settings.notifications.agentClarificationHint", "Default: disabled. When enabled, planner questions pause planning and notify your mailbox.")} +
+
+
{t("settings.notifications.ntfy", "ntfy")} diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 719d62efd3..91b231cd99 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -210,8 +210,9 @@ function createMockStore(overrides: Partial = {}): 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); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index 6ff012619a..0770a7c471 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -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[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>["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( row.conversationHistory, [], @@ -1193,6 +1220,7 @@ export async function createSession( planningDepth?: PlanningDepth, customQuestionCount?: number, pluginRunner?: SkillPluginRunner, + options?: Pick, ): 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 { +): Promise { 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 { + if (!session.agent) throw new InvalidSessionStateError("Planning session has no AI agent"); + await (session.agent.session.prompt as (input: string, options?: { signal?: AbortSignal }) => Promise)( + '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, ): Promise { 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 { - const config = session.ntfyConfig; - if (!config?.enabled || !config.topic) { - return; +async function maybeNotifyPlanningAwaitingInput( + session: Session, + question: PlanningQuestion, + proactiveClarification = false, +): Promise { + 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 { /** * Get session details. */ +/** Attach live-only route dependencies after session rehydration. */ +export async function attachPlanningRuntime( + sessionId: string, + options: Pick, +): Promise { + 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 { const inMemory = sessions.get(sessionId); if (inMemory) { diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index b0688e6599..048cd82696 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -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[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(); + const getPlanningMessageStore = async (req: import("express").Request): Promise => { + 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>) => ({ + 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) { diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 346eb969b1..0d16cff6a9 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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. ", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 74960c31b1..35a9cca332 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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. ",