Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/api/services/notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,41 @@ describe("notification service", () => {
});
});

it("suppresses completion delivery while the session already has attention", async () => {
await expect(
notifyTaskCompletion("Done", settings, {
context: {
sessionId: "active-session",
background: false,
},
})
).resolves.toEqual({
disposition: "suppressed",
systemNotificationSent: false,
soundPlayed: false,
reason: "foreground-session",
});

expect(mocks.sendNotification).not.toHaveBeenCalled();
expect(mocks.playNotificationSound).not.toHaveBeenCalled();
});

it("delivers completion sound once the session is in the background", async () => {
await expect(
notifyTaskCompletion("Done", settings, {
context: {
sessionId: "background-session",
background: true,
},
})
).resolves.toMatchObject({
disposition: "delivered",
soundPlayed: true,
});

expect(mocks.playNotificationSound).toHaveBeenCalledOnce();
});

it("uses the selected preset for the test notification", async () => {
await sendTestNotification(settings);

Expand Down
41 changes: 39 additions & 2 deletions src/api/services/notificationPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe("evaluateNotificationPolicy", () => {
).toBe("deliver");
});

it("plays foreground completion sound without a redundant system alert", () => {
it("suppresses completion alerts for an attended foreground session", () => {
expect(
evaluateNotificationPolicy(
{
Expand All @@ -154,12 +154,49 @@ describe("evaluateNotificationPolicy", () => {
makeSettings()
)
).toEqual({
disposition: "deliver",
disposition: "suppress",
sendSystemNotification: false,
playSound: false,
reason: "foreground-session",
});
});

it("delivers completion alerts once the session needs background attention", () => {
expect(
evaluateNotificationPolicy(
{
category: "taskCompletion",
context: { sessionId: "background-session", background: true },
playSound: true,
},
makeSettings()
)
).toEqual({
disposition: "deliver",
sendSystemNotification: true,
playSound: true,
});
});

it("keeps approval and error alerts eligible in an attended session", () => {
for (const category of ["agentApproval", "errors"] as const) {
expect(
evaluateNotificationPolicy(
{
category,
context: { sessionId: "active-session", background: false },
playSound: true,
},
makeSettings()
)
).toEqual({
disposition: "deliver",
sendSystemNotification: false,
playSound: true,
});
}
});

it("defers background completion during quiet hours", () => {
const decision = evaluateNotificationPolicy(
{
Expand Down
15 changes: 15 additions & 0 deletions src/api/services/notificationPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface NotificationPolicyDecision {
| "disabled"
| "category-disabled"
| "critical-only"
| "foreground-session"
| "quiet-hours"
| "session-muted";
}
Expand Down Expand Up @@ -249,6 +250,20 @@ export function evaluateNotificationPolicy(
};
}

// A completion cue is only useful when the user is no longer attending the
// session. Approval and error alerts remain eligible in the foreground.
if (
request.category === "taskCompletion" &&
request.context?.background === false
) {
return {
disposition: "suppress",
sendSystemNotification: false,
playSound: false,
reason: "foreground-session",
};
}

if (!isQuietHoursActive(settings, now)) {
return deliver(request.playSound);
}
Expand Down
13 changes: 8 additions & 5 deletions src/hooks/cliSession/useBackgroundSessionMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import { registerNotificationSoundUnlock } from "@src/api/services/notificationSound";
import Message from "@src/components/Message";
import { deliverSessionTerminalNotification } from "@src/hooks/session/sessionTerminalNotifications";
import { sessionByIdAtom } from "@src/store/session";
import { activeSessionIdAtom, sessionByIdAtom } from "@src/store/session";
import {
type NotificationSettings,
notificationSettingsAtom,
Expand Down Expand Up @@ -181,12 +181,15 @@ function deliverCliStatus(
t: TFunction,
completedTurn: boolean
): void {
const session = isStoreInitialized()
? getInstrumentedStore().get(sessionByIdAtom(msg.session_id))
: undefined;
const store = isStoreInitialized() ? getInstrumentedStore() : null;
const session = store?.get(sessionByIdAtom(msg.session_id));
const activeSessionId = store?.get(activeSessionIdAtom);
const sessionInBackground = msg.background ?? session?.background ?? false;
const outsideActiveSession =
sessionInBackground ||
(store !== null && activeSessionId !== msg.session_id);
const attentionRequired =
isNotificationAttentionRequired(sessionInBackground);
isNotificationAttentionRequired(outsideActiveSession);
const sessionName =
msg.session_name || session?.name || t("notifications.backgroundSession");

Expand Down
1 change: 1 addition & 0 deletions src/types/ui/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface NotificationDeliveryResult {
| "category-disabled"
| "critical-only"
| "duplicate"
| "foreground-session"
| "non-primary-window"
| "quiet-hours"
| "session-muted";
Expand Down
Loading