Skip to content

Commit 29ae454

Browse files
committed
merge: propagate second-pass fixes from feat/dashboard-agent-ui
2 parents 1bc64e0 + 917c75b commit 29ae454

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

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. */

0 commit comments

Comments
 (0)