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

This commit is contained in:
gsxdsm
2026-04-10 21:22:59 -07:00
parent 7be8c17279
commit 39ffb4b456
8 changed files with 173 additions and 19 deletions

View File

@@ -1813,7 +1813,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"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const newEvents = e.target.checked
? (current.includes("in-review") ? current : [...current, "in-review" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "in-review");
@@ -1829,7 +1829,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("merged") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const newEvents = e.target.checked
? (current.includes("merged") ? current : [...current, "merged" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "merged");
@@ -1845,7 +1845,7 @@ export function SettingsModal({
type="checkbox"
checked={form.ntfyEvents?.includes("failed") ?? true}
onChange={(e) => {
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const newEvents = e.target.checked
? (current.includes("failed") ? current : [...current, "failed" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "failed");
@@ -1861,7 +1861,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"] as NtfyNotificationEvent[]);
const current = form.ntfyEvents ?? (["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] as NtfyNotificationEvent[]);
const newEvents = e.target.checked
? (current.includes("awaiting-approval") ? current : [...current, "awaiting-approval" as NtfyNotificationEvent])
: current.filter((ev): ev is NtfyNotificationEvent => ev !== "awaiting-approval");
@@ -1871,6 +1871,22 @@ export function SettingsModal({
Plan needs approval
</label>
<small>When a task specification needs manual approval before execution</small>
<label className="checkbox-label">
<input
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 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");
setForm((f) => ({ ...f, ntfyEvents: newEvents.length > 0 ? newEvents : undefined }));
}}
/>
User review needed
</label>
<small>When an agent hands off a task for human review (high priority)</small>
</div>
</div>
<div className="form-group">

View File

@@ -23,7 +23,7 @@ const defaultSettings: Settings = {
defaultPresetBySize: {},
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
runStepsInNewSessions: false,
@@ -1763,7 +1763,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -1774,6 +1774,7 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Task merged")).toBeTruthy();
expect(screen.getByLabelText("Task failed")).toBeTruthy();
expect(screen.getByLabelText("Plan needs approval")).toBeTruthy();
expect(screen.getByLabelText("User review needed")).toBeTruthy();
});
it("shows awaiting-approval checkbox when ntfy is enabled", async () => {
@@ -1781,7 +1782,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -1800,6 +1801,7 @@ describe("SettingsModal", () => {
expect(screen.queryByLabelText("Task merged")).toBeNull();
expect(screen.queryByLabelText("Task failed")).toBeNull();
expect(screen.queryByLabelText("Plan needs approval")).toBeNull();
expect(screen.queryByLabelText("User review needed")).toBeNull();
});
it("ntfyEvents checkboxes are all checked by default", async () => {
@@ -1817,6 +1819,7 @@ describe("SettingsModal", () => {
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(true);
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);
});
it("saves ntfyEvents correctly when checkboxes are toggled", async () => {
@@ -1824,7 +1827,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -1848,7 +1851,7 @@ describe("SettingsModal", () => {
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
@@ -1856,11 +1859,12 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getByText("Notifications"));
// Uncheck all four
// Uncheck all five
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.getByText("Save"));
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));

View File

@@ -26,7 +26,7 @@ const defaultSettings = {
defaultPresetBySize: {},
ntfyEnabled: false,
ntfyTopic: undefined,
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
taskStuckTimeoutMs: undefined,
maxStuckKills: 6,
runStepsInNewSessions: false,

View File

@@ -201,6 +201,29 @@ describe("NtfyNotifier", () => {
);
});
it("sends high priority notification when task needs user review", async () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const awaitingUserReviewTask = createTask("FN-003", "Review Task", "awaiting-user-review");
store.triggerTaskUpdated(awaitingUserReviewTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Title": "User review needed for FN-003",
"Priority": "high",
}),
body: 'Task "Review Task" needs human review before it can proceed',
})
);
});
it("sends notification when task is merged", async () => {
notifier = new NtfyNotifier(store);
await notifier.start();
@@ -437,6 +460,29 @@ describe("NtfyNotifier", () => {
);
});
it("includes Click header for awaiting-user-review notifications when dashboard host is set", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store);
await notifier.start();
const awaitingUserReviewTask = createTask("FN-003", "Review Task", "awaiting-user-review");
store.triggerTaskUpdated(awaitingUserReviewTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "https://fusion.example.com/?task=FN-003",
}),
})
);
});
it("includes Click header for merged task notifications when dashboard host is set", async () => {
store.setSettings({
ntfyEnabled: true,
@@ -568,6 +614,29 @@ describe("NtfyNotifier", () => {
);
});
it("includes projectId in Click URL when configured for awaiting-user-review notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store, { projectId: "user-review-project" });
await notifier.start();
const awaitingUserReviewTask = createTask("FN-001", "Test Task", "awaiting-user-review");
store.triggerTaskUpdated(awaitingUserReviewTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "https://fusion.example.com/?project=user-review-project&task=FN-001",
}),
})
);
});
it("falls back to task-only URL when projectId not configured", async () => {
store.setSettings({
ntfyEnabled: true,
@@ -769,6 +838,29 @@ describe("NtfyNotifier", () => {
);
});
it("prevents duplicate awaiting-user-review notifications for the same task", async () => {
notifier = new NtfyNotifier(store);
await notifier.start();
const task = createTask("FN-005", "User Review Task", "awaiting-user-review");
store.triggerTaskUpdated(task);
store.triggerTaskUpdated(task);
store.triggerTaskUpdated(task);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Title": "User review needed for FN-005",
}),
}),
);
});
it("allows different event types for the same task", async () => {
notifier = new NtfyNotifier(store);
await notifier.start();
@@ -1152,6 +1244,18 @@ describe("NtfyNotifier", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
notifier = new NtfyNotifier(store);
await notifier.start();
const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review");
store.triggerTaskUpdated(awaitingUserReviewTask);
await flushAsyncWork();
expect(fetchMock).not.toHaveBeenCalled();
});
it("sends notification for enabled events while others are disabled", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] });
notifier = new NtfyNotifier(store);
@@ -1185,6 +1289,12 @@ describe("NtfyNotifier", () => {
store.triggerTaskUpdated(awaitingApprovalTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
// awaiting-user-review - should NOT send
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
store.triggerTaskUpdated(awaitingUserReviewTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
});
it("defaults to all events when ntfyEvents is undefined", async () => {
@@ -1216,10 +1326,15 @@ describe("NtfyNotifier", () => {
store.triggerTaskUpdated(awaitingApprovalTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(4);
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
store.triggerTaskUpdated(awaitingUserReviewTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(5);
});
it("updates notifications when ntfyEvents changes at runtime", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] });
notifier = new NtfyNotifier(store);
await notifier.start();
@@ -1229,14 +1344,14 @@ describe("NtfyNotifier", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
// Disable in-review
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval"] });
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] });
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
// Enable in-review again
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] });
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
await flushAsyncWork();

View File

@@ -40,7 +40,7 @@ interface NtfyConfig {
}
/** Event types for notification deduplication */
type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-approval";
type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
/**
* NtfyNotifier sends push notifications via ntfy.sh when tasks complete
@@ -54,7 +54,7 @@ type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-appro
* - Configurable notification events (hardcoded defaults)
*/
export class NtfyNotifier {
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined, events: ["in-review", "merged", "failed", "awaiting-approval"] };
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined, events: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] };
private ntfyBaseUrl: string;
/** Project identifier for deep links in notifications */
private projectId?: string;
@@ -171,6 +171,20 @@ export class NtfyNotifier {
),
);
}
// Notify when task needs human review (agent handoff to user)
if (task.status === "awaiting-user-review" && this.isEventEnabled("awaiting-user-review")) {
const clickUrl = this.buildTaskUrl(task.id);
this.maybeNotify(task.id, "awaiting-user-review", () =>
this.sendNotification(
this.config.topic!,
`User review needed for ${task.id}`,
`Task "${formatTaskIdentifier(task)}" needs human review before it can proceed`,
"high",
clickUrl,
),
);
}
};
private handleTaskMerged = (result: MergeResult): void => {
@@ -221,7 +235,7 @@ export class NtfyNotifier {
enabled: settings.ntfyEnabled ?? false,
topic: settings.ntfyTopic,
dashboardHost: settings.ntfyDashboardHost,
events: settings.ntfyEvents ?? ["in-review", "merged", "failed", "awaiting-approval"],
events: settings.ntfyEvents ?? ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
};
}