Skip to content

Commit ebc7e88

Browse files
committed
merge: propagate second-pass fixes from test/chat-agent-durability-tri-11166
2 parents 08f9d78 + 074da82 commit ebc7e88

6 files changed

Lines changed: 176 additions & 13 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
The grouped "watch updates" notification now shows the total number of results waiting, instead of only the most recent batch's count.

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from "./panel-layout";
2424
import { nextPendingTurnChatId } from "./pending-turn";
2525
import { nextVisibleChat } from "./unread-counts";
26-
import { startWakePolling, wakesToToast } from "./wake-poll";
26+
import { planWakeToasts, startWakePolling, wakesToToast } from "./wake-poll";
2727
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2828
import {
2929
showWatchWakesSummaryToast,
@@ -99,6 +99,11 @@ export function DashboardAgent({
9999
// which outlives the render that started it, so it has to be a ref.
100100
const visibleChat = useRef<string | null>(null);
101101

102+
// The count the still-visible grouped toast claims. Consecutive polls add to it so a
103+
// later batch grows the summary instead of overwriting it with only its own count;
104+
// reset when the user opens the panel from that toast.
105+
const summaryPending = useRef(0);
106+
102107
// Switching environment re-runs the layout loader but does not remount it, so the seeds
103108
// above would keep the old environment's counts.
104109
const seededEnvironment = useRef(environment.id);
@@ -224,11 +229,22 @@ export function DashboardAgent({
224229
const fresh = wakesToToast(data.wakes, toastedWakes.current);
225230
for (const wake of fresh) rememberToasted(wake.watchId);
226231

227-
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
228-
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
229-
} else {
230-
for (const wake of [...fresh].reverse()) {
231-
showWatchWakeToast(wake, openChat);
232+
if (fresh.length > 0) {
233+
const { plan, pending } = planWakeToasts(
234+
fresh,
235+
summaryPending.current,
236+
WAKE_TOAST_MAX_INDIVIDUAL
237+
);
238+
summaryPending.current = pending;
239+
if (plan.mode === "summary") {
240+
showWatchWakesSummaryToast(plan.count, () => {
241+
summaryPending.current = 0;
242+
setPanelOpen(true);
243+
});
244+
} else {
245+
for (const wake of [...plan.wakes].reverse()) {
246+
showWatchWakeToast(wake, openChat);
247+
}
232248
}
233249
}
234250
} catch {

apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ describe("merging a re-read transcript", () => {
7575
});
7676
});
7777

78+
describe("replacing a stale running step from the re-read", () => {
79+
// Same message id, but the stream EOF'd before `get_report` produced an output.
80+
const RUNNING_STEP = {
81+
id: "msg_step",
82+
role: "assistant",
83+
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
84+
};
85+
86+
const FINISHED_STEP = {
87+
id: "msg_step",
88+
role: "assistant",
89+
parts: [
90+
{ type: "tool-get_report", toolCallId: "call_1", state: "output-available", output: {} },
91+
],
92+
};
93+
94+
it("swaps the still-running copy for its finished version from the authoritative read", () => {
95+
const merged = mergeSettledMessages([RUNNING_STEP], [FINISHED_STEP]);
96+
expect(merged).toEqual([FINISHED_STEP]);
97+
// The step no longer reads as running, so nothing keeps the panel on Working…
98+
expect(transcriptLooksUnfinished(merged)).toBe(false);
99+
});
100+
101+
it("still appends genuinely-new messages while replacing a stale one", () => {
102+
const merged = mergeSettledMessages([RUNNING_STEP], [FINISHED_STEP, SETTLED]);
103+
expect(merged.map((message) => message.id)).toEqual([FINISHED_STEP.id, SETTLED.id]);
104+
expect(merged[0]).toBe(FINISHED_STEP);
105+
});
106+
107+
it("leaves an in-flight message alone when the re-read is itself still running", () => {
108+
const merged = mergeSettledMessages([RUNNING_STEP], [RUNNING_STEP]);
109+
// Same reference back, no needless render, and the live turn is untouched.
110+
expect(merged).toEqual([RUNNING_STEP]);
111+
expect(merged[0]).toBe(RUNNING_STEP);
112+
});
113+
114+
it("does not touch a running message the re-read does not mention", () => {
115+
const merged = mergeSettledMessages([RUNNING_STEP], [SETTLED]);
116+
expect(merged.map((message) => message.id)).toEqual([RUNNING_STEP.id, SETTLED.id]);
117+
expect(merged[0]).toBe(RUNNING_STEP);
118+
});
119+
});
120+
78121
describe("reading the transcript endpoint", () => {
79122
afterEach(() => {
80123
vi.unstubAllGlobals();

apps/webapp/app/components/dashboard-agent/settled-transcript.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { inFlightToolName, liveInvestigation } from "./progress-line";
1+
import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./progress-line";
22

33
/**
44
* Re-reading the stored transcript once a turn settles.
@@ -11,17 +11,47 @@ import { inFlightToolName, liveInvestigation } from "./progress-line";
1111

1212
type Identified = { id: string };
1313

14+
/** A message whose stream died mid-tool: a `tool-*` part still reads as running. */
15+
function stillRunning(message: unknown): boolean {
16+
const parts = (message as { parts?: ReadonlyArray<{ type?: string; state?: string }> })?.parts;
17+
if (!Array.isArray(parts)) return false;
18+
return parts.some(
19+
(part) =>
20+
typeof part?.type === "string" &&
21+
part.type.startsWith("tool-") &&
22+
IN_FLIGHT_TOOL_STATES.has(part.state ?? "")
23+
);
24+
}
25+
1426
/**
15-
* Append-only, keyed on the message id. Ids are stable (a settlement card is
16-
* `investigation-settlement:{id}:{revision}`), so re-reading the same transcript any
17-
* number of times can never produce a second copy of a card, and nothing already
18-
* rendered is reordered or replaced.
27+
* Merge the authoritative re-read into what the panel holds, keyed on the message id.
28+
*
29+
* Genuinely-new messages (a settlement card is `investigation-settlement:{id}:{revision}`)
30+
* are appended, so re-reading the same transcript any number of times never produces a
31+
* second copy. A message whose in-memory copy died mid-tool — a stream that EOF'd before
32+
* the part settled — is replaced by its finished version from the re-read; otherwise it
33+
* would show that step running forever. We only replace a still-running copy with a copy
34+
* that has itself settled, so a live turn streaming under the same id is left alone and
35+
* ordering is preserved.
1936
*/
2037
export function mergeSettledMessages<T extends Identified>(current: T[], fetched: T[]): T[] {
38+
const byId = new Map(fetched.map((message) => [message.id, message]));
39+
40+
let replaced = false;
41+
const next = current.map((existing) => {
42+
const settled = byId.get(existing.id);
43+
if (settled && settled !== existing && stillRunning(existing) && !stillRunning(settled)) {
44+
replaced = true;
45+
return settled;
46+
}
47+
return existing;
48+
});
49+
2150
const missing = fetched.filter(
2251
(message) => !current.some((existing) => existing.id === message.id)
2352
);
24-
return missing.length === 0 ? current : [...current, ...missing];
53+
if (missing.length === 0) return replaced ? next : current;
54+
return [...next, ...missing];
2555
}
2656

2757
/** Whether the transcript still resolves to a card mid-investigation. */

apps/webapp/app/components/dashboard-agent/wake-poll.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { startWakePolling, UNREAD_POLL_INTERVAL_MS, wakesToToast } from "./wake-poll";
2+
import {
3+
planWakeToasts,
4+
startWakePolling,
5+
UNREAD_POLL_INTERVAL_MS,
6+
wakesToToast,
7+
} from "./wake-poll";
38

49
function harness() {
510
let hidden = false;
@@ -124,3 +129,42 @@ describe("wakesToToast", () => {
124129
expect(wakesToToast(undefined, new Set())).toEqual([]);
125130
});
126131
});
132+
133+
describe("planWakeToasts", () => {
134+
const MAX = 3;
135+
const batch = (n: number) => Array.from({ length: n }, (_, i) => i);
136+
137+
it("toasts a small batch individually but still counts it toward the running total", () => {
138+
const { plan, pending } = planWakeToasts(batch(2), 0, MAX);
139+
140+
expect(plan).toEqual({ mode: "individual", wakes: [0, 1] });
141+
expect(pending).toBe(2);
142+
});
143+
144+
it("summarizes when a single batch is over the max", () => {
145+
const { plan, pending } = planWakeToasts(batch(4), 0, MAX);
146+
147+
expect(plan).toEqual({ mode: "summary", count: 4 });
148+
expect(pending).toBe(4);
149+
});
150+
151+
it("accumulates across consecutive polls instead of showing only the latest", () => {
152+
// First batch of 2 is below the max: individual toasts, nothing pending yet.
153+
const first = planWakeToasts(batch(2), 0, MAX);
154+
expect(first.plan.mode).toBe("individual");
155+
156+
// A second batch of 3 pushes the running total to 5, so the grouped toast claims
157+
// the cumulative count, not just this batch's 3.
158+
const second = planWakeToasts(batch(3), first.pending, MAX);
159+
expect(second.plan).toEqual({ mode: "summary", count: 5 });
160+
expect(second.pending).toBe(5);
161+
});
162+
163+
it("grows the visible summary as later batches arrive", () => {
164+
const first = planWakeToasts(batch(4), 0, MAX);
165+
const second = planWakeToasts(batch(3), first.pending, MAX);
166+
167+
expect(second.plan).toEqual({ mode: "summary", count: 7 });
168+
expect(second.pending).toBe(7);
169+
});
170+
});

apps/webapp/app/components/dashboard-agent/wake-poll.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@ export function wakesToToast<T extends { watchId: string; unread?: boolean }>(
2121
return (wakes ?? []).filter((wake) => wake.unread === true && !toasted.has(wake.watchId));
2222
}
2323

24+
export type WakeToastPlan<T> =
25+
| { mode: "summary"; count: number }
26+
| { mode: "individual"; wakes: T[] };
27+
28+
/**
29+
* Whether this poll's fresh wakes join a grouped summary or each get their own toast.
30+
* `pending` is the running count of unacknowledged wakes carried from earlier polls; a
31+
* batch that pushes the total past `max` shows the summary with that cumulative count, so
32+
* a later batch adds to it rather than replacing it with only its own, smaller number.
33+
* The returned `pending` is what the caller carries into the next poll; it resets to zero
34+
* once the user acknowledges (opens the panel).
35+
*/
36+
export function planWakeToasts<T>(
37+
fresh: T[],
38+
pending: number,
39+
max: number
40+
): { plan: WakeToastPlan<T>; pending: number } {
41+
const total = pending + fresh.length;
42+
if (total > max) {
43+
return { plan: { mode: "summary", count: total }, pending: total };
44+
}
45+
return { plan: { mode: "individual", wakes: fresh }, pending: total };
46+
}
47+
2448
export type WakePollOptions = {
2549
load: () => Promise<void>;
2650
isHidden: () => boolean;

0 commit comments

Comments
 (0)