feat(FN-2340): merge fusion/fn-2340
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user