Skip to content

Commit d9085d7

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 f472fb3 commit d9085d7

4 files changed

Lines changed: 212 additions & 55 deletions

File tree

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
@@ -204,12 +204,24 @@ export function settlementCardMessages(
204204

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

207+
/** Both shapes the panel reads: a tool's output blocks, and a host-written view. */
208+
function blocksInPart(part: unknown): unknown[] {
209+
const typed = part as {
210+
type?: string;
211+
output?: { blocks?: unknown[] };
212+
data?: { blocks?: unknown[] };
213+
};
214+
if (typed.type === "tool-render_view" && Array.isArray(typed.output?.blocks)) {
215+
return typed.output.blocks;
216+
}
217+
if (typed.type === "data-view" && Array.isArray(typed.data?.blocks)) return typed.data.blocks;
218+
return [];
219+
}
220+
207221
function cardsInMessage(message: UIMessage): TranscriptCard[] {
208222
const found: TranscriptCard[] = [];
209223
for (const part of message.parts ?? []) {
210-
const typed = part as { type?: string; output?: { blocks?: unknown[] } };
211-
if (typed.type !== "tool-render_view" || !Array.isArray(typed.output?.blocks)) continue;
212-
for (const block of typed.output.blocks) {
224+
for (const block of blocksInPart(part)) {
213225
const candidate = block as { type?: string; id?: string; revision?: number };
214226
if (candidate?.type !== "investigation" || typeof candidate.id !== "string") continue;
215227
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
function watchConfirmationMessage(args: {
6597
watchId: string;
6698
headline: string;
@@ -158,6 +190,56 @@ describe("the state a summary may not swallow", () => {
158190
expect(first).toContain("never open a second card");
159191
});
160192

193+
it("pins a card written the way render_view writes one", () => {
194+
const state = collectDurableState([
195+
investigationMessage({
196+
id: "inv_tool",
197+
title: "orders queue is backing up",
198+
outcome: "in_progress",
199+
revision: 1,
200+
}),
201+
]);
202+
expect(state.investigations.map((i) => i.id)).toEqual(["inv_tool"]);
203+
expect(
204+
describeDurableState([
205+
investigationMessage({
206+
id: "inv_tool",
207+
title: "orders queue is backing up",
208+
outcome: "in_progress",
209+
revision: 1,
210+
}),
211+
])
212+
).toContain("inv_tool");
213+
});
214+
215+
it("pins a card a host view wrote, too", () => {
216+
const state = collectDurableState([
217+
hostInvestigationMessage({ id: "inv_host", title: "host card", outcome: "in_progress" }),
218+
]);
219+
expect(state.investigations.map((i) => i.id)).toEqual(["inv_host"]);
220+
});
221+
222+
it("keeps a settled card closed when a stale render arrives after it", () => {
223+
const settledThenStale = [
224+
investigationMessage({
225+
id: "inv_1",
226+
title: "first pass",
227+
outcome: "in_progress",
228+
revision: 0,
229+
}),
230+
investigationMessage({ id: "inv_1", title: "first pass", outcome: "concluded", revision: 3 }),
231+
// A late replay of an earlier revision: lower, so it loses whatever order it lands in.
232+
investigationMessage({
233+
id: "inv_1",
234+
title: "first pass",
235+
outcome: "in_progress",
236+
revision: 1,
237+
}),
238+
];
239+
expect(collectDurableState(settledThenStale).investigations).toEqual([]);
240+
expect(describeDurableState(settledThenStale)).toBeUndefined();
241+
});
242+
161243
it("pins the freshest revision of one card, not one entry per render", () => {
162244
const state = collectDurableState([
163245
investigationMessage({

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

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { locals, logger } from "@trigger.dev/sdk";
22
import type { ChatAgentCompactionOptions, SummarizeEvent } from "@trigger.dev/sdk/ai";
33
import { generateText, type ModelMessage, type UIMessage } from "ai";
4-
import { dashboardAgentModelKey, registry, sanitizeReplayedToolInputs } from "./agent-runtime";
4+
import {
5+
dashboardAgentModelKey,
6+
latestCards,
7+
registry,
8+
sanitizeReplayedToolInputs,
9+
} from "./agent-runtime";
510

611
/**
712
* Bounded context: how a long conversation is summarised, and what may never be
@@ -110,15 +115,6 @@ export function shouldCompactConversation(event: {
110115
* The state a summary may not swallow
111116
* ------------------------------------------------------------------ */
112117

113-
type DataViewPart = { type: string; data?: { blocks?: unknown[] } };
114-
115-
function viewBlocks(message: UIMessage): unknown[] {
116-
const parts = (message.parts ?? []) as DataViewPart[];
117-
return parts.flatMap((part) =>
118-
part.type === "data-view" && Array.isArray(part.data?.blocks) ? part.data!.blocks! : []
119-
);
120-
}
121-
122118
export type PinnedInvestigation = {
123119
id: string;
124120
title: string;
@@ -131,8 +127,10 @@ export type DurableState = {
131127
};
132128

133129
/**
134-
* The state read off the UI transcript, which compaction never touches. Keyed by id,
135-
* so the freshest revision of a card wins and one card stays one card.
130+
* The state read off the UI transcript, which compaction never touches. `latestCards`
131+
* resolves it the way the panel does — highest revision per id wins, whatever order the
132+
* renders arrived in — so one card stays one card and a stale render can't reopen a
133+
* settled one.
136134
*
137135
* Only an `in_progress` card is state: a concluded or inconclusive one is finished
138136
* work the summary already covers, and pinning it would grow the note forever and
@@ -145,34 +143,19 @@ export type DurableState = {
145143
* remembering it, so the summary is where a watch belongs.
146144
*/
147145
export function collectDurableState(uiMessages: UIMessage[]): DurableState {
148-
const investigations = new Map<string, PinnedInvestigation>();
149-
150-
for (const message of uiMessages) {
151-
for (const block of viewBlocks(message)) {
152-
const typed = block as {
153-
type?: string;
154-
id?: string;
155-
revision?: number;
156-
investigation?: { title?: string; outcome?: string };
157-
};
158-
if (typed.type !== "investigation" || typeof typed.id !== "string") continue;
159-
160-
const outcome = typed.investigation?.outcome ?? "in_progress";
161-
// A later revision that settles the card removes the pin the earlier one added.
162-
if (outcome !== "in_progress") {
163-
investigations.delete(typed.id);
164-
continue;
165-
}
166-
investigations.set(typed.id, {
167-
id: typed.id,
168-
title: typed.investigation?.title ?? "",
169-
outcome,
170-
revision: typed.revision,
171-
});
172-
}
146+
const investigations: PinnedInvestigation[] = [];
147+
148+
for (const card of latestCards(uiMessages).values()) {
149+
if (card.state?.outcome !== "in_progress") continue;
150+
investigations.push({
151+
id: card.id,
152+
title: card.state.title,
153+
outcome: card.state.outcome,
154+
revision: card.revision,
155+
});
173156
}
174157

175-
return { investigations: [...investigations.values()] };
158+
return { investigations };
176159
}
177160

178161
/**

0 commit comments

Comments
 (0)