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:
7
.changeset/fn-8123-agent-clarification.md
Normal file
7
.changeset/fn-8123-agent-clarification.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -1168,6 +1168,7 @@ export function SettingsModal({
|
||||
includeTaskIdInCommit: true,
|
||||
worktreeInitCommand: "",
|
||||
ntfyEnabled: false,
|
||||
agentClarificationEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyAccessToken: undefined,
|
||||
failureNotificationMode: "sticky-only",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
|
||||
]),
|
||||
notifications: new Set([
|
||||
"ntfyEnabled",
|
||||
"agentClarificationEnabled",
|
||||
"ntfyTopic",
|
||||
"ntfyBaseUrl",
|
||||
"ntfyAccessToken",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. ",
|
||||
|
||||
4
packages/i18n/src/resources.d.ts
vendored
4
packages/i18n/src/resources.d.ts
vendored
@@ -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. ",
|
||||
|
||||
Reference in New Issue
Block a user