Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const thread = {
archivedAt: null as string | null,
hasPendingApprovals: false,
hasPendingUserInput: false,
pendingRequestId: "request-1",
session: null as { status: string } | null,
latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null },
};
Expand Down Expand Up @@ -144,9 +145,9 @@ function toV2ThreadShell(input: typeof thread) {
? "failed"
: "running",
pendingRuntimeRequest: input.hasPendingUserInput
? { id: "request-1", kind: "user_input", createdAt: SHELL_NOW }
? { id: input.pendingRequestId, kind: "user_input", createdAt: SHELL_NOW }
: input.hasPendingApprovals
? { id: "request-1", kind: "command", createdAt: SHELL_NOW }
? { id: input.pendingRequestId, kind: "command", createdAt: SHELL_NOW }
: null,
latestVisibleMessage: null,
latestUserMessageAt: null,
Expand Down Expand Up @@ -240,6 +241,18 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
},
);

it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
"notifies again when a new %s request replaces the pending one in the same run",
async (flag) => {
await using app = setup();
app.start();
state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-1" }));
state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-2" }));
const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed";
expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]);
},
);

it("presents completion notifications and sound as soon as the shell completes", async () => {
await using app = setup();
state.mode = "notifications-and-sound";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {

const working: ThreadNotificationObservation = {
attention: null,
requestId: null,
settled: false,
completedAt: null,
};
const completed = (at: number): ThreadNotificationObservation => ({
attention: null,
requestId: null,
settled: true,
completedAt: at,
});
Expand Down Expand Up @@ -54,17 +56,37 @@ describe("createThreadNotificationGate", () => {
expect(notify).toHaveBeenCalledTimes(2);
});

it("notifies attention once and still notifies the subsequent completion", () => {
it("notifies each attention once per stretch and still notifies the subsequent completion", () => {
const { notify, observe } = harness();
observe(working);
const attention = { attention: "run-1:approval", settled: false, completedAt: null };
const attention = {
attention: "run-1:approval:a",
requestId: "a",
settled: false,
completedAt: null,
};
observe(attention);
expect(notify.mock.calls).toEqual([["thread", "input"]]);
observe({ ...attention });
expect(notify).toHaveBeenCalledTimes(1);
observe({ ...attention, attention: "run-1:approval:b", requestId: "b" });
observe(attention);
expect(notify).toHaveBeenCalledTimes(2);
observe(working);
observe(attention);
expect(notify).toHaveBeenCalledTimes(3);
const subagent = { ...attention, attention: "run-1:approval:", requestId: null };
observe(subagent);
observe(attention);
observe(subagent);
expect(notify).toHaveBeenCalledTimes(5);
observe(working);
observe(completed(1));
expect(notify.mock.calls).toEqual([
["thread", "input"],
["thread", "input"],
["thread", "input"],
["thread", "input"],
["thread", "input"],
["thread", "completion"],
]);
Expand All @@ -74,7 +96,7 @@ describe("createThreadNotificationGate", () => {
const { notify, observe } = harness();
observe(working);
observe(completed(1));
observe({ attention: "run-1:limited", settled: false, completedAt: null });
observe({ attention: "run-1:limited", requestId: null, settled: false, completedAt: null });
observe(completed(1));
expect(notify.mock.calls).toEqual([
["thread", "completion"],
Expand Down
27 changes: 25 additions & 2 deletions apps/web/src/components/ThreadNotificationCoordinator.logic.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export type ThreadNotificationKind = "input" | "completion";

export interface ThreadNotificationObservation {
/** Run and status key while the thread needs attention, otherwise null. */
/** Run, status and request key while the thread needs attention, otherwise null. */
readonly attention: string | null;
/** The pending request's id, when it has one; null whenever attention is. */
readonly requestId: string | null;
/** The thread is idle and its latest run completed. */
readonly settled: boolean;
/** When the latest run completed, if it did. */
Expand All @@ -17,6 +19,24 @@ export function createThreadNotificationGate<Key>(
const skip = (key: Key, at: number | null) => {
if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at);
};
// Attention keys with a request id seen since the thread last stopped needing attention.
const attended = new Map<Key, Set<string>>();
const attend = (
key: Key,
prior: ThreadNotificationObservation | undefined,
next: ThreadNotificationObservation,
): boolean => {
if (next.attention === null) {
attended.delete(key);
return false;
}
if (next.requestId === null) return next.attention !== prior?.attention;
const seen = attended.get(key);
if (seen?.has(next.attention)) return false;
if (seen) seen.add(next.attention);
else attended.set(key, new Set([next.attention]));
return true;
};
return {
observe(
key: Key,
Expand All @@ -26,8 +46,9 @@ export function createThreadNotificationGate<Key>(
): void {
// The first sighting, archived threads, and stopped or silent runs never notify completion.
if (!prior || options.archived || options.quiet) skip(key, next.completedAt);
const fresh = attend(key, prior, next);
if (!prior || options.archived) return;
if (next.attention && next.attention !== prior.attention) {
if (fresh) {
notify(key, "input");
return;
}
Expand All @@ -39,9 +60,11 @@ export function createThreadNotificationGate<Key>(
/** Forgets threads that left the snapshot. */
retain(keys: ReadonlySet<Key>): void {
for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key);
for (const key of attended.keys()) if (!keys.has(key)) attended.delete(key);
},
clear(): void {
handled.clear();
attended.clear();
},
};
}
12 changes: 8 additions & 4 deletions apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
threadNotificationTag,
unlockNotificationAudio,
} from "../threadNotifications";
import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement";
import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic";
import {
createThreadNotificationGate,
Expand Down Expand Up @@ -252,14 +253,16 @@ export function startThreadNotifications(
const thread = presentThreadShell(environmentId, rawThread, subagentRequest);
let status = resolveSidebarThreadStatus(thread);
if (status === "ready" && thread.latestRun?.status === "failed") status = "failed";
const attention =
const needsAttention =
status === "input" ||
status === "request" ||
status === "approval" ||
status === "failed" ||
status === "limited"
? `${thread.latestRun?.runId ?? ""}:${status}`
: null;
status === "limited";
const requestId = needsAttention ? resolveBackgroundThreadRequest(thread).requestId : null;
const attention = needsAttention
? `${thread.latestRun?.runId ?? ""}:${status}:${requestId ?? ""}`
: null;
const completedAt = Date.parse(thread.latestRun?.completedAt ?? "");
const settled = status === "ready" && thread.latestRun?.status === "completed";
const state = {
Expand All @@ -268,6 +271,7 @@ export function startThreadNotifications(
thread,
status,
attention,
requestId,
settled,
completedAt: settled && Number.isFinite(completedAt) ? completedAt : null,
};
Expand Down
Loading