feat(FN-2340): merge fusion/fn-2340

This commit is contained in:
gsxdsm
2026-04-23 22:57:21 -07:00
parent 10ea91a137
commit b7233e833d
13 changed files with 592 additions and 203 deletions

View File

@@ -79,6 +79,14 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
"in-review",
"merged",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
];
/** Well-known experimental feature flags with display labels.
* These always appear in the Experimental Features settings tab,
@@ -2824,7 +2832,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("in-review") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("in-review") ? current : [...current, "in-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "in-review");
@@ -2840,7 +2848,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("merged") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("merged") ? current : [...current, "merged" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "merged");
@@ -2856,7 +2864,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("failed") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("failed") ? current : [...current, "failed" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "failed");
@@ -2872,7 +2880,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("awaiting-approval") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("awaiting-approval") ? current : [...current, "awaiting-approval" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-approval");
@@ -2888,7 +2896,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("awaiting-user-review") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("awaiting-user-review") ? current : [...current, "awaiting-user-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-user-review");
@@ -2898,6 +2906,22 @@ export function SettingsModal({
User review needed
</label>
<small>When an agent hands off a task for human review (high priority)</small>
<label className="checkbox-label">
<input
type="checkbox"
checked={form.ntfyEvents?.includes("planning-awaiting-input") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS];
const newEvents = e.target.checked
? (current.includes("planning-awaiting-input") ? current : [...current, "planning-awaiting-input" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "planning-awaiting-input");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
Planning needs input
</label>
<small>When planning mode is waiting for your response to continue</small>
</div>
</div>
<div className="form-group">

View File

@@ -46,7 +46,7 @@ const defaultSettings: SettingsWithAutoArchive = {
defaultPresetBySize: {},
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
specStalenessEnabled: false,
@@ -2495,7 +2495,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2507,6 +2507,7 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Task failed")).toBeTruthy();
expect(screen.getByLabelText("Plan needs approval")).toBeTruthy();
expect(screen.getByLabelText("User review needed")).toBeTruthy();
expect(screen.getByLabelText("Planning needs input")).toBeTruthy();
});
it("shows awaiting-approval checkbox when ntfy is enabled", async () => {
@@ -2514,7 +2515,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2534,6 +2535,7 @@ describe("SettingsModal", () => {
expect(screen.queryByLabelText("Task failed")).toBeNull();
expect(screen.queryByLabelText("Plan needs approval")).toBeNull();
expect(screen.queryByLabelText("User review needed")).toBeNull();
expect(screen.queryByLabelText("Planning needs input")).toBeNull();
});
it("ntfyEvents checkboxes are all checked by default", async () => {
@@ -2552,6 +2554,7 @@ describe("SettingsModal", () => {
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("User review needed") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Planning needs input") as HTMLInputElement).checked).toBe(true);
});
it("saves ntfyEvents correctly when checkboxes are toggled", async () => {
@@ -2559,7 +2562,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2575,7 +2578,13 @@ describe("SettingsModal", () => {
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyEvents).toEqual(["in-review", "failed", "awaiting-approval", "awaiting-user-review"]);
expect(payload.ntfyEvents).toEqual([
"in-review",
"failed",
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
]);
});
it("sets ntfyEvents to null when all checkboxes are unchecked (null-as-delete)", async () => {
@@ -2583,7 +2592,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -2591,12 +2600,13 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getByText("Notifications"));
// Uncheck all five
// Uncheck all six
fireEvent.click(screen.getByLabelText("Task completed (in-review)"));
fireEvent.click(screen.getByLabelText("Task merged"));
fireEvent.click(screen.getByLabelText("Task failed"));
fireEvent.click(screen.getByLabelText("Plan needs approval"));
fireEvent.click(screen.getByLabelText("User review needed"));
fireEvent.click(screen.getByLabelText("Planning needs input"));
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
@@ -2621,6 +2631,7 @@ describe("SettingsModal", () => {
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Planning needs input") as HTMLInputElement).checked).toBe(false);
});
// Node Sync section tests

View File

@@ -22,6 +22,7 @@ import {
__resetPlanningState,
__setCreateFnAgent,
__setPlanningDiagnostics,
__setPlanningNtfyHelpers,
rehydrateFromStore,
setAiSessionStore,
RateLimitError,
@@ -115,6 +116,12 @@ function getUniqueIp(): string {
return `127.0.0.${++ipCounter}`;
}
async function flushAsyncWork(): Promise<void> {
await vi.waitFor(() => {
expect(true).toBe(true);
});
}
/**
* Helper: set up a fresh mock agent for the next createSession call.
* Returns the agent so tests can inspect `.session.prompt` calls.
@@ -158,6 +165,20 @@ function setupMockStreamingAgent(options?: {
return { createFnAgentSpy };
}
function setupMockPlanningNtfyHelpers(options?: { enabledEvent?: boolean; clickUrl?: string }) {
const sendNtfyNotification = vi.fn(async () => undefined);
const isNtfyEventEnabled = vi.fn(() => options?.enabledEvent ?? true);
const buildNtfyClickUrl = vi.fn(() => options?.clickUrl ?? "http://localhost:4040/?project=proj-123");
__setPlanningNtfyHelpers({
sendNtfyNotification,
isNtfyEventEnabled,
buildNtfyClickUrl,
});
return { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl };
}
class MockAiSessionStore extends EventEmitter {
rows = new Map<string, AiSessionRow>();
@@ -266,6 +287,7 @@ describe("planning module", () => {
afterEach(() => {
__setCreateFnAgent(undefined as any);
__setPlanningNtfyHelpers(undefined);
});
describe("createSession", () => {
@@ -553,6 +575,136 @@ describe("planning module", () => {
resetDiagnosticsSink();
}
});
it("persists projectId across planning session state transitions", async () => {
const store = new MockAiSessionStore();
setAiSessionStore(store as any);
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
{
projectId: "proj-123",
ntfyConfig: { enabled: false, topic: "planning-topic" },
},
);
await vi.waitFor(() => {
expect(store.get(sessionId)?.status).toBe("awaiting_input");
});
expect(store.get(sessionId)?.projectId).toBe("proj-123");
await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(store.get(sessionId)?.status).toBe("awaiting_input");
});
expect(store.get(sessionId)?.projectId).toBe("proj-123");
await submitResponse(sessionId, { "q-requirements": "Must support SSO" }, TEST_ROOT_DIR);
await submitResponse(sessionId, { "q-confirm": true }, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(store.get(sessionId)?.status).toBe("complete");
});
expect(store.get(sessionId)?.projectId).toBe("proj-123");
});
it("sends planning awaiting-input notifications once per question and allows later distinct questions", async () => {
const firstQuestion = JSON.stringify({
type: "question",
data: { id: "q-1", type: "text", question: "First question?", description: "one" },
});
const repeatedQuestion = JSON.stringify({
type: "question",
data: { id: "q-1", type: "text", question: "First question?", description: "one" },
});
const secondQuestion = JSON.stringify({
type: "question",
data: { id: "q-2", type: "text", question: "Second question?", description: "two" },
});
setupMockStreamingAgent({ responses: [firstQuestion, repeatedQuestion, secondQuestion] });
const { sendNtfyNotification, isNtfyEventEnabled, buildNtfyClickUrl } = setupMockPlanningNtfyHelpers({
enabledEvent: true,
clickUrl: "http://localhost:4040/?project=proj-123",
});
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
{
projectId: "proj-123",
ntfyConfig: {
enabled: true,
topic: "planning-topic",
dashboardHost: "http://localhost:4040/",
events: ["planning-awaiting-input"],
},
},
);
await vi.waitFor(() => {
expect(sendNtfyNotification).toHaveBeenCalledTimes(1);
});
await submitResponse(sessionId, { "q-1": "answer one" }, TEST_ROOT_DIR);
await flushAsyncWork();
expect(sendNtfyNotification).toHaveBeenCalledTimes(1);
await submitResponse(sessionId, { "q-1": "answer two" }, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(sendNtfyNotification).toHaveBeenCalledTimes(2);
});
expect(isNtfyEventEnabled).toHaveBeenCalledWith(["planning-awaiting-input"], "planning-awaiting-input");
expect(buildNtfyClickUrl).toHaveBeenCalledWith({
dashboardHost: "http://localhost:4040/",
projectId: "proj-123",
});
expect(sendNtfyNotification).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
topic: "planning-topic",
priority: "high",
clickUrl: "http://localhost:4040/?project=proj-123",
}),
);
});
it("suppresses planning awaiting-input notifications when event is disabled", async () => {
setupMockStreamingAgent({ responses: STANDARD_QUESTION_RESPONSES });
const { sendNtfyNotification } = setupMockPlanningNtfyHelpers({ enabledEvent: false });
await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
{
projectId: "proj-123",
ntfyConfig: {
enabled: true,
topic: "planning-topic",
dashboardHost: "http://localhost:4040/",
events: ["failed"],
},
},
);
await flushAsyncWork();
expect(sendNtfyNotification).not.toHaveBeenCalled();
});
});
describe("submitResponse", () => {

View File

@@ -17,6 +17,7 @@ import type {
PlanningSummary,
PlanningResponse,
TaskStore,
NtfyNotificationEvent,
} from "@fusion/core";
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
import type { SubtaskItem } from "./subtask-breakdown.js";
@@ -36,6 +37,30 @@ type AgentResult = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createFnAgent: any;
interface PlanningNtfyConfig {
enabled: boolean;
topic?: string;
dashboardHost?: string;
events?: NtfyNotificationEvent[];
ntfyBaseUrl?: string;
}
interface PlanningNtfyHelpers {
isNtfyEventEnabled: (events: NtfyNotificationEvent[] | undefined, event: NtfyNotificationEvent) => boolean;
buildNtfyClickUrl: (options: { dashboardHost?: string; projectId?: string; taskId?: string }) => string | undefined;
sendNtfyNotification: (input: {
ntfyBaseUrl?: string;
topic: string;
title: string;
message: string;
priority?: "low" | "default" | "high" | "urgent";
clickUrl?: string;
}) => Promise<void>;
}
let planningNtfyHelpers: PlanningNtfyHelpers | undefined;
let ntfyHelpersReady: Promise<void> | undefined;
/**
* Shared diagnostics helper for the planning module.
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
@@ -69,19 +94,24 @@ export function __setPlanningDiagnostics(_logger: unknown): void {
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine() {
if (!createFnAgent) {
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
if (!createFnAgent) {
createFnAgent = engine.createFnAgent;
}
} catch {
// Allow failure in test environments - agent functionality will be stubbed
if (!createFnAgent) {
createFnAgent = undefined;
}
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
if (!createFnAgent) {
createFnAgent = engine.createFnAgent;
}
if (!planningNtfyHelpers) {
planningNtfyHelpers = {
isNtfyEventEnabled: engine.isNtfyEventEnabled,
buildNtfyClickUrl: engine.buildNtfyClickUrl,
sendNtfyNotification: engine.sendNtfyNotification,
};
}
} catch {
// Allow failure in test environments - agent functionality will be stubbed
if (!createFnAgent) {
createFnAgent = undefined;
}
}
}
@@ -92,6 +122,11 @@ function ensureEngineReady() {
return engineReady;
}
async function ensureNtfyHelpersReady(): Promise<void> {
ntfyHelpersReady ??= initEngine();
await ntfyHelpersReady;
}
// ── Constants ───────────────────────────────────────────────────────────────
/** Planning system prompt for the AI agent */
@@ -170,6 +205,10 @@ interface Session {
id: string;
ip: string;
initialPlan: string;
projectId?: string;
ntfyConfig?: PlanningNtfyConfig;
/** Last planning question notified via ntfy, keyed as `${sessionId}:${questionId}` for dedupe across reconnect/replay. */
lastNotifiedQuestionKey?: string;
history: PlanningHistoryEntry[];
currentQuestion?: PlanningQuestion;
summary?: PlanningSummary;
@@ -260,7 +299,7 @@ function cleanupInMemorySession(sessionId: string): boolean {
}
/** Persist the current session state to SQLite (no-op if store not wired). */
function persistSession(session: Session, status: "generating" | "awaiting_input" | "complete" | "error", projectId?: string, error?: string): void {
function persistSession(session: Session, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void {
if (!_aiSessionStore) return;
const row: AiSessionRow = {
id: session.id,
@@ -273,7 +312,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
result: session.summary ? JSON.stringify(session.summary) : null,
thinkingOutput: session.thinkingOutput,
error: error ?? null,
projectId: projectId ?? null,
projectId: session.projectId ?? null,
createdAt: session.createdAt.toISOString(),
updatedAt: new Date().toISOString(),
lockedByTab: null,
@@ -308,21 +347,25 @@ function buildSessionFromRow(row: AiSessionRow): Session {
throw new Error("Invalid session timestamps");
}
const currentQuestion = row.currentQuestion
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
throwOnError: true,
fieldName: "currentQuestion",
}) ?? undefined)
: undefined;
return {
id: row.id,
ip: payload.ip ?? "",
initialPlan: payload.initialPlan ?? row.title,
projectId: row.projectId ?? undefined,
history: safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory,
[],
{ throwOnError: true, fieldName: "conversationHistory" },
),
currentQuestion: row.currentQuestion
? (safeParseJson<PlanningQuestion | null>(row.currentQuestion, null, {
throwOnError: true,
fieldName: "currentQuestion",
}) ?? undefined)
: undefined,
currentQuestion,
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
summary: row.result
? (safeParseJson<PlanningSummary | null>(row.result, null, {
throwOnError: true,
@@ -768,6 +811,7 @@ export async function createSessionWithAgent(
modelProvider?: string,
modelId?: string,
promptOverrides?: PromptOverrideMap,
options?: { projectId?: string; ntfyConfig?: PlanningNtfyConfig },
): Promise<string> {
// Check rate limit
if (!checkRateLimit(ip)) {
@@ -784,6 +828,16 @@ export async function createSessionWithAgent(
id: sessionId,
ip,
initialPlan,
projectId: options?.projectId,
ntfyConfig: options?.ntfyConfig
? {
enabled: options.ntfyConfig.enabled,
topic: options.ntfyConfig.topic,
dashboardHost: options.ntfyConfig.dashboardHost,
events: options.ntfyConfig.events ? [...options.ntfyConfig.events] : undefined,
ntfyBaseUrl: options.ntfyConfig.ntfyBaseUrl,
}
: undefined,
history: [],
thinkingOutput: "",
lastGeneratedThinking: "",
@@ -797,7 +851,7 @@ export async function createSessionWithAgent(
// Initialize AI agent in background - it will stream via planningStreamManager
initializeAgent(session, rootDir, modelProvider, modelId, promptOverrides).catch((err) => {
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistSession(session, "error", undefined, err.message || "Failed to initialize AI agent");
persistSession(session, "error", err.message || "Failed to initialize AI agent");
planningStreamManager.broadcast(sessionId, {
type: "error",
data: err.message || "Failed to initialize AI agent",
@@ -828,7 +882,7 @@ async function initializeAgent(
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", undefined, errorMessage);
persistSession(session, "error", errorMessage);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMessage,
@@ -915,6 +969,53 @@ async function ensureSessionAgent(
await session.agent.session.prompt(contextMessage);
}
async function maybeNotifyPlanningAwaitingInput(session: Session, question: PlanningQuestion): Promise<void> {
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;
}
session.lastNotifiedQuestionKey = questionKey;
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,
});
} 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",
});
}
}
/** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1;
@@ -1024,7 +1125,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
);
session.error = errorMsg;
session.updatedAt = new Date();
persistSession(session, "error", undefined, errorMsg);
persistSession(session, "error", errorMsg);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMsg,
@@ -1038,6 +1139,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
persistSession(session, "awaiting_input");
void maybeNotifyPlanningAwaitingInput(session, parsed.data);
planningStreamManager.broadcast(session.id, {
type: "question",
data: parsed.data,
@@ -1059,7 +1161,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", undefined, errorMessage);
persistSession(session, "error", errorMessage);
planningStreamManager.broadcast(session.id, {
type: "error",
data: errorMessage,
@@ -1603,6 +1705,9 @@ export function __resetPlanningState(): void {
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
planningNtfyHelpers = undefined;
ntfyHelpersReady = undefined;
// Reset diagnostics sink to default
resetDiagnosticsSink();
}
@@ -1614,6 +1719,12 @@ export function __setCreateFnAgent(mock: typeof createFnAgent): void {
createFnAgent = mock;
}
/** Inject ntfy helper implementations (test-only). */
export function __setPlanningNtfyHelpers(mock: PlanningNtfyHelpers | undefined): void {
planningNtfyHelpers = mock;
ntfyHelpersReady = undefined;
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class RateLimitError extends Error {

View File

@@ -8752,7 +8752,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("planningModelId must be a string when provided");
}
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, projectId } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = scopedStore.getRootDir();
@@ -8782,6 +8782,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
resolvedPlanningProvider,
resolvedPlanningModelId,
settings.promptOverrides,
{
projectId,
ntfyConfig: {
enabled: settings.ntfyEnabled ?? false,
topic: settings.ntfyTopic,
dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents,
},
},
);
res.status(201).json({ sessionId });
} catch (err: unknown) {