Skip to content

Commit 0cae301

Browse files
committed
fix(dashboard-agent): pin the investigation cards the transcript actually holds
`collectDurableState` read `data-view` parts only, but `render_view` writes every investigation card as a `tool-render_view` part, so compaction pinned nothing and an open card could be summarised away. It now resolves cards through `latestCards`, the resolver the panel and the watch actions already use: highest revision per id wins whatever order the renders arrive in, so a stale `in_progress` render landing after the settling one no longer reopens a closed card. `latestCards` reads host-written `data-view` blocks too, matching the panel.
1 parent ed0a7b3 commit 0cae301

5 files changed

Lines changed: 277 additions & 58 deletions

File tree

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

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,65 @@ function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } |
2323
return Array.isArray(p.output?.blocks) ? { blocks: p.output!.blocks! } : null;
2424
}
2525

26+
function hostViewBlocks(part: UIMessage["parts"][number]): unknown[] | null {
27+
const p = part as { type: string; data?: { blocks?: unknown[] } };
28+
if (p.type !== "data-view") return null;
29+
return Array.isArray(p.data?.blocks) ? p.data!.blocks! : null;
30+
}
31+
32+
function investigationBlocksFor(part: UIMessage["parts"][number]): unknown[] | null {
33+
return viewSpecFor(part)?.blocks ?? hostViewBlocks(part);
34+
}
35+
36+
type InvestigationRef = { id: string; revision: number };
37+
38+
function investigationRef(block: unknown): InvestigationRef | null {
39+
const b = block as { type?: string; id?: string; revision?: number };
40+
if (b?.type !== "investigation" || typeof b.id !== "string") return null;
41+
return { id: b.id, revision: typeof b.revision === "number" ? b.revision : 0 };
42+
}
43+
44+
/** Per investigation id, the one `messageId:partIndex` allowed to render: highest revision. */
45+
export function winningInvestigationOccurrences(messages: UIMessage[]): Map<string, string> {
46+
const best = new Map<string, { revision: number; occurrence: string }>();
47+
for (const message of messages) {
48+
(message.parts ?? []).forEach((part, partIndex) => {
49+
for (const block of investigationBlocksFor(part) ?? []) {
50+
const ref = investigationRef(block);
51+
if (!ref) continue;
52+
const current = best.get(ref.id);
53+
if (!current || ref.revision >= current.revision) {
54+
best.set(ref.id, { revision: ref.revision, occurrence: `${message.id}:${partIndex}` });
55+
}
56+
}
57+
});
58+
}
59+
return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence]));
60+
}
61+
62+
function withoutSupersededInvestigations(
63+
blocks: unknown[],
64+
occurrence: string,
65+
winners: Map<string, string> | undefined
66+
): unknown[] {
67+
if (!winners) return blocks;
68+
return blocks.filter((block) => {
69+
const ref = investigationRef(block);
70+
return !ref || winners.get(ref.id) === occurrence;
71+
});
72+
}
73+
2674
// Renders one message. Assistant messages that include a completed render_view
2775
// part get the catalog cards (plus the gather tool rows / lead-in text for
2876
// transparency); everything else uses the shared MessageBubble unchanged, so
2977
// its streaming memoization is preserved for the common case.
3078
const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
3179
message,
80+
investigationWinners,
3281
}: {
3382
message: UIMessage;
83+
/** See {@link winningInvestigationOccurrences}. */
84+
investigationWinners?: Map<string, string>;
3485
}) {
3586
if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
3687
return <MessageBubble message={message} />;
@@ -39,8 +90,14 @@ const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
3990
<div className="space-y-2">
4091
{message.parts.map((part, i) => {
4192
const spec = viewSpecFor(part);
42-
if (spec) return <ViewBlocks key={i} blocks={spec.blocks as never} />;
43-
return renderPart(part, i);
93+
if (!spec) return renderPart(part, i);
94+
const blocks = withoutSupersededInvestigations(
95+
spec.blocks,
96+
`${message.id}:${i}`,
97+
investigationWinners
98+
);
99+
if (blocks.length === 0) return null;
100+
return <ViewBlocks key={i} blocks={blocks as never} />;
44101
})}
45102
</div>
46103
);
@@ -60,12 +117,17 @@ export function DashboardAgentMessages({
60117
error?: Error;
61118
}) {
62119
const rootRef = useAutoScrollToBottom([messages, isThinking]);
120+
const investigationWinners = winningInvestigationOccurrences(messages);
63121

64122
return (
65123
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
66124
<div ref={rootRef} className="space-y-4 p-4">
67125
{messages.map((message) => (
68-
<DashboardAgentMessageBubble key={message.id} message={stripStepParts(message)} />
126+
<DashboardAgentMessageBubble
127+
key={message.id}
128+
message={stripStepParts(message)}
129+
investigationWinners={investigationWinners}
130+
/>
69131
))}
70132
{isThinking && (
71133
<div className="flex items-center gap-2 text-sm text-text-dimmed">
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { UIMessage } from "ai";
3+
import { winningInvestigationOccurrences } from "~/components/dashboard-agent/DashboardAgentMessages";
4+
5+
/**
6+
* The panel half of the pin invariant: the compactor pins the revision the panel
7+
* renders, so the transcripts in `compaction.test.ts` are resolved here too. The
8+
* message id carries the revision, so the winner names it.
9+
*/
10+
function investigationMessage(args: {
11+
id: string;
12+
title: string;
13+
outcome: string;
14+
revision?: number;
15+
}): UIMessage {
16+
return {
17+
id: `msg-${args.id}-${args.revision ?? 0}`,
18+
role: "assistant",
19+
parts: [
20+
{
21+
type: "tool-render_view",
22+
toolCallId: `call-${args.id}-${args.revision ?? 0}`,
23+
state: "output-available",
24+
output: {
25+
blocks: [
26+
{
27+
type: "investigation",
28+
id: args.id,
29+
revision: args.revision ?? 0,
30+
version: 1,
31+
investigation: {
32+
outcome: args.outcome,
33+
severity: "warn",
34+
confidence: "medium",
35+
title: args.title,
36+
headline: `${args.title} — what we have so far.`,
37+
hypotheses: [],
38+
evidence: [],
39+
},
40+
},
41+
],
42+
},
43+
} as never,
44+
],
45+
};
46+
}
47+
48+
describe("the winning revision of an investigation card", () => {
49+
it("is the highest revision, not the last render", () => {
50+
const winners = winningInvestigationOccurrences([
51+
investigationMessage({ id: "inv_1", title: "first pass", outcome: "in_progress" }),
52+
investigationMessage({ id: "inv_1", title: "first pass", outcome: "concluded", revision: 3 }),
53+
investigationMessage({
54+
id: "inv_1",
55+
title: "first pass",
56+
outcome: "in_progress",
57+
revision: 1,
58+
}),
59+
]);
60+
61+
expect(winners.get("inv_1")).toBe("msg-inv_1-3:0");
62+
});
63+
64+
it("resolves a host-written card the same way", () => {
65+
const hostCard: UIMessage = {
66+
id: "host-inv_2-2",
67+
role: "assistant",
68+
parts: [
69+
{
70+
type: "data-view",
71+
data: {
72+
blocks: [{ type: "investigation", id: "inv_2", revision: 2, version: 1 }],
73+
},
74+
} as never,
75+
],
76+
};
77+
78+
expect(winningInvestigationOccurrences([hostCard]).get("inv_2")).toBe("host-inv_2-2:0");
79+
});
80+
});

internal-packages/dashboard-agent/src/agent-runtime.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,12 +202,24 @@ export function settlementCardMessages(
202202

203203
export type TranscriptCard = { id: string; revision: number; state: InvestigationState | null };
204204

205+
/** Both shapes the panel reads: a tool's output blocks, and a host-written view. */
206+
function blocksInPart(part: unknown): unknown[] {
207+
const typed = part as {
208+
type?: string;
209+
output?: { blocks?: unknown[] };
210+
data?: { blocks?: unknown[] };
211+
};
212+
if (typed.type === "tool-render_view" && Array.isArray(typed.output?.blocks)) {
213+
return typed.output.blocks;
214+
}
215+
if (typed.type === "data-view" && Array.isArray(typed.data?.blocks)) return typed.data.blocks;
216+
return [];
217+
}
218+
205219
function cardsInMessage(message: UIMessage): TranscriptCard[] {
206220
const found: TranscriptCard[] = [];
207221
for (const part of message.parts ?? []) {
208-
const typed = part as { type?: string; output?: { blocks?: unknown[] } };
209-
if (typed.type !== "tool-render_view" || !Array.isArray(typed.output?.blocks)) continue;
210-
for (const block of typed.output.blocks) {
222+
for (const block of blocksInPart(part)) {
211223
const candidate = block as { type?: string; id?: string; revision?: number };
212224
if (candidate?.type !== "investigation" || typeof candidate.id !== "string") continue;
213225
const parsed = investigationStateSchema.safeParse(

internal-packages/dashboard-agent/src/compaction.test.ts

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,67 @@ function bulk(count: number, chars: number): ModelMessage[] {
3232
);
3333
}
3434

35-
/** The card, as `render_view` persisted it into the transcript. */
35+
function investigationBlock(args: {
36+
id: string;
37+
title: string;
38+
outcome: string;
39+
revision?: number;
40+
}) {
41+
return {
42+
type: "investigation",
43+
id: args.id,
44+
revision: args.revision ?? 0,
45+
version: 1,
46+
investigation: {
47+
outcome: args.outcome,
48+
severity: "warn",
49+
confidence: "medium",
50+
title: args.title,
51+
headline: `${args.title} — what we have so far.`,
52+
hypotheses: [],
53+
evidence: [],
54+
},
55+
};
56+
}
57+
58+
/**
59+
* The card as `render_view` persists it: a tool part, which is the only shape
60+
* production writes an investigation in.
61+
*/
3662
function investigationMessage(args: {
3763
id: string;
3864
title: string;
3965
outcome: string;
4066
revision?: number;
4167
}): UIMessage {
4268
return {
43-
id: `msg-${args.id}`,
69+
id: `msg-${args.id}-${args.revision ?? 0}`,
4470
role: "assistant",
4571
parts: [
4672
{
47-
type: "data-view",
48-
data: {
49-
blocks: [
50-
{
51-
type: "investigation",
52-
id: args.id,
53-
revision: args.revision ?? 0,
54-
version: 1,
55-
investigation: { title: args.title, outcome: args.outcome },
56-
},
57-
],
58-
},
73+
type: "tool-render_view",
74+
toolCallId: `call-${args.id}-${args.revision ?? 0}`,
75+
state: "output-available",
76+
output: { blocks: [investigationBlock(args)] },
5977
} as never,
6078
],
6179
};
6280
}
6381

82+
/** The other shape the panel accepts: a host-written view. */
83+
function hostInvestigationMessage(args: {
84+
id: string;
85+
title: string;
86+
outcome: string;
87+
revision?: number;
88+
}): UIMessage {
89+
return {
90+
id: `host-${args.id}-${args.revision ?? 0}`,
91+
role: "assistant",
92+
parts: [{ type: "data-view", data: { blocks: [investigationBlock(args)] } } as never],
93+
};
94+
}
95+
6496
describe("when the conversation is compacted", () => {
6597
it("stays under the budget for an ordinary conversation", () => {
6698
expect(shouldCompactConversation({ messages: bulk(20, 500), inputTokens: 22_000 })).toBe(false);
@@ -120,6 +152,56 @@ describe("the state a summary may not swallow", () => {
120152
expect(first).toContain("never open a second card");
121153
});
122154

155+
it("pins a card written the way render_view writes one", () => {
156+
const state = collectDurableState([
157+
investigationMessage({
158+
id: "inv_tool",
159+
title: "orders queue is backing up",
160+
outcome: "in_progress",
161+
revision: 1,
162+
}),
163+
]);
164+
expect(state.investigations.map((i) => i.id)).toEqual(["inv_tool"]);
165+
expect(
166+
describeDurableState([
167+
investigationMessage({
168+
id: "inv_tool",
169+
title: "orders queue is backing up",
170+
outcome: "in_progress",
171+
revision: 1,
172+
}),
173+
])
174+
).toContain("inv_tool");
175+
});
176+
177+
it("pins a card a host view wrote, too", () => {
178+
const state = collectDurableState([
179+
hostInvestigationMessage({ id: "inv_host", title: "host card", outcome: "in_progress" }),
180+
]);
181+
expect(state.investigations.map((i) => i.id)).toEqual(["inv_host"]);
182+
});
183+
184+
it("keeps a settled card closed when a stale render arrives after it", () => {
185+
const settledThenStale = [
186+
investigationMessage({
187+
id: "inv_1",
188+
title: "first pass",
189+
outcome: "in_progress",
190+
revision: 0,
191+
}),
192+
investigationMessage({ id: "inv_1", title: "first pass", outcome: "concluded", revision: 3 }),
193+
// A late replay of an earlier revision: lower, so it loses whatever order it lands in.
194+
investigationMessage({
195+
id: "inv_1",
196+
title: "first pass",
197+
outcome: "in_progress",
198+
revision: 1,
199+
}),
200+
];
201+
expect(collectDurableState(settledThenStale).investigations).toEqual([]);
202+
expect(describeDurableState(settledThenStale)).toBeUndefined();
203+
});
204+
123205
it("pins the freshest revision of one card, not one entry per render", () => {
124206
const state = collectDurableState([
125207
investigationMessage({

0 commit comments

Comments
 (0)