Skip to content

Commit 7abce81

Browse files
committed
fix(webapp): one investigation card per transcript; the verdict close is one sentence
Latest-wins now applies across the whole transcript, not just within one tool call — the in_progress working copy disappears when the verdict revision renders. The prompt bans list-shaped prose after the verdict card: everything list-shaped belongs on the card.
1 parent fd5006e commit 7abce81

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

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

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,55 @@ function blocksFor(part: UIMessage["parts"][number]): unknown[] | null {
9696
return report ? [report] : null;
9797
}
9898

99+
type InvestigationRef = { id: string; revision: number };
100+
101+
function investigationRef(block: unknown): InvestigationRef | null {
102+
const b = block as { type?: string; id?: string; revision?: number };
103+
if (b?.type !== "investigation" || typeof b.id !== "string") return null;
104+
return { id: b.id, revision: typeof b.revision === "number" ? b.revision : 0 };
105+
}
106+
107+
/**
108+
* Latest-wins across the WHOLE transcript, not just within one tool call: an
109+
* investigation renders at least twice (in_progress, then the verdict), each
110+
* from its own render_view part, so without this the user sees the working
111+
* copy stacked above the finished card. Returns, per investigation id, the one
112+
* occurrence (`messageId:partIndex`) allowed to render — the highest revision,
113+
* last occurrence winning a tie.
114+
*/
115+
export function winningInvestigationOccurrences(messages: UIMessage[]): Map<string, string> {
116+
const best = new Map<string, { revision: number; occurrence: string }>();
117+
for (const message of messages) {
118+
(message.parts ?? []).forEach((part, partIndex) => {
119+
for (const block of blocksFor(part) ?? []) {
120+
const ref = investigationRef(block);
121+
if (!ref) continue;
122+
const current = best.get(ref.id);
123+
if (!current || ref.revision >= current.revision) {
124+
best.set(ref.id, {
125+
revision: ref.revision,
126+
occurrence: `${message.id}:${partIndex}`,
127+
});
128+
}
129+
}
130+
});
131+
}
132+
return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence]));
133+
}
134+
135+
/** Drop investigation blocks whose id renders elsewhere (a later revision). */
136+
function withoutSupersededInvestigations(
137+
blocks: unknown[],
138+
occurrence: string,
139+
winners: Map<string, string> | undefined
140+
): unknown[] {
141+
if (!winners) return blocks;
142+
return blocks.filter((block) => {
143+
const ref = investigationRef(block);
144+
return !ref || winners.get(ref.id) === occurrence;
145+
});
146+
}
147+
99148
// #region chat-layout transcript
100149
// Everything from here down composes via ./chat-layout only — see rule 1 there.
101150
// `chat-layout.test.ts` fails if a spacing utility class appears in this region.
@@ -198,11 +247,14 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
198247
onIntent,
199248
resolveUri,
200249
watches,
250+
investigationWinners,
201251
}: {
202252
message: UIMessage;
203253
onIntent?: (intent: AgentIntent) => void;
204254
resolveUri?: (uri: string) => ResolvedUri | null;
205255
watches?: WakeWatch[];
256+
/** See {@link winningInvestigationOccurrences}. */
257+
investigationWinners?: Map<string, string>;
206258
}) {
207259
if (message.role === "user") {
208260
return (
@@ -219,13 +271,20 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
219271
for (let i = 0; i < parts.length; i++) {
220272
const part = parts[i]!;
221273

222-
const blocks = blocksFor(part);
223-
if (blocks) {
224-
body.push(
225-
<ChatCardSlot key={i}>
226-
<ViewBlocks blocks={blocks as never} onIntent={onIntent} resolveUri={resolveUri} />
227-
</ChatCardSlot>
274+
const rawBlocks = blocksFor(part);
275+
if (rawBlocks) {
276+
const blocks = withoutSupersededInvestigations(
277+
rawBlocks,
278+
`${message.id}:${i}`,
279+
investigationWinners
228280
);
281+
if (blocks.length > 0) {
282+
body.push(
283+
<ChatCardSlot key={i}>
284+
<ViewBlocks blocks={blocks as never} onIntent={onIntent} resolveUri={resolveUri} />
285+
</ChatCardSlot>
286+
);
287+
}
229288
continue;
230289
}
231290

@@ -289,15 +348,24 @@ export function DashboardAgentTurns({
289348
// One status line at a time: a tool's own progress beats the generic activity.
290349
const showActivity = activity !== null && !hasToolProgressLine(messages);
291350

351+
// Strip once, up front: the winners map keys occurrences by part index, so
352+
// it must be computed on the exact parts the turns will render.
353+
const stripped = messages.map(stripStepParts);
354+
355+
// Across the whole transcript, one card per investigation: the latest
356+
// revision renders where it landed; earlier working copies disappear.
357+
const investigationWinners = winningInvestigationOccurrences(stripped);
358+
292359
return (
293360
<>
294-
{messages.map((message) => (
361+
{stripped.map((message) => (
295362
<DashboardAgentTurn
296363
key={message.id}
297-
message={stripStepParts(message)}
364+
message={message}
298365
onIntent={onIntent}
299366
resolveUri={resolveUri}
300367
watches={watches}
368+
investigationWinners={investigationWinners}
301369
/>
302370
))}
303371
{showActivity && activity && (

internal-packages/dashboard-agent/src/tool-schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ Investigations:
621621
2. Pose two hypotheses — three only if the evidence really demands it.
622622
3. Render. call render_view with an "investigation" block, outcome in_progress, BEFORE you test anything and no later than your third step — even when the answer already looks obvious. The result carries investigationId.
623623
4. Test — ONE round, one targeted check per hypothesis, issued together, read tools only. That round is all you get: a check that comes back empty, unavailable, or truncated is itself a finding. Never retry a search with different terms and never reach for a second tool to get the same answer.
624-
5. Render the verdict, immediately after that round — prose is never a substitute, and a card still reading in_progress when the turn ends leaves the user watching a spinner: render_view again, same investigationId, outcome concluded or inconclusive. This is your VERY NEXT call — before any other tool and before you write a word — and it is always the last tool call of the turn. If you find yourself about to call something that isn't a read of evidence, render the verdict instead. Then close with one short line of prose, and let the outcome decide what it says. concluded: name the cause concretely, in the user's own terms — the limit that's saturated, the file:line that broke. inconclusive: say what is NOT established and what to check first — no "the culprit is", no cause presented as found, and no fix, not even a fast one or a hedged one. "Here's what I found" is not an answer, and don't restate the card.
624+
5. Render the verdict, immediately after that round — prose is never a substitute, and a card still reading in_progress when the turn ends leaves the user watching a spinner: render_view again, same investigationId, outcome concluded or inconclusive. This is your VERY NEXT call — before any other tool and before you write a word — and it is always the last tool call of the turn. If you find yourself about to call something that isn't a read of evidence, render the verdict instead. Then close with one short line of prose, and let the outcome decide what it says. concluded: name the cause concretely, in the user's own terms — the limit that's saturated, the file:line that broke. inconclusive: say what is NOT established and what to check first — no "the culprit is", no cause presented as found, and no fix, not even a fast one or a hedged one. "Here's what I found" is not an answer, and don't restate the card. The close is ONE sentence, never a list: if you're writing bullets after the verdict card, you are retyping the card's remediation or checkNext — everything list-shaped belongs on the card and only there.
625625
- That is FOUR tool phases and there is no fifth: gather, open the card, one test round, verdict. You cannot count how many steps you have left and the ceiling is hard — a turn that hits it renders nothing and answers nothing — so anything outside those four phases is a step you cannot afford. Never call get_current_page, list_projects, or list_environments inside an investigation: your tools are already scoped and the card needs none of it.
626626
- You do not need every hypothesis settled to conclude. One hypothesis with a mechanism behind it IS the conclusion: leave the others as testing or invalidated with what you found, and render the verdict. Chasing the last unsettled hypothesis — for call sites, a type definition, a payload you can't see — is how a turn ends with no verdict at all.
627627
- Never state a cause, a fix, or a dead end in prose while the card says in_progress or doesn't exist yet. The verdict lands on the card first.

0 commit comments

Comments
 (0)