Skip to content

Commit 37c56aa

Browse files
committed
test(webapp): pin the settlement failure window and the open panel
Covers the two halves the earlier pass left open. Against a real database, a stale card that cannot be rendered now proves the sweep rolls the settle back with it: the row stays `in_progress` at revision 0, the chat stays empty, and the row is still in the next run's selection. The panel half is covered over `liveProgress`, the code that decides whether "Working…" is shown: a mounted panel holding the unconcluded card re-reads the stored transcript, merges by stable id, and the progress line goes away without a reload. Re-reading repeatedly cannot add a second copy of the card.
1 parent 5bf3612 commit 37c56aa

4 files changed

Lines changed: 215 additions & 24 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts";
2+
import { describe, expect, it } from "vitest";
3+
import { liveProgress } from "./progress-line";
4+
import {
5+
hasOpenInvestigation,
6+
mergeSettledMessages,
7+
pollSettledTranscript,
8+
} from "./settled-transcript";
9+
10+
/**
11+
* The open panel. A settled turn writes its terminal card to the chat row rather than
12+
* pushing a stream chunk, so a panel that stays mounted has to re-read the transcript
13+
* or it renders the last `in_progress` revision forever.
14+
*/
15+
16+
const INVESTIGATION_ID = "inv_open_panel";
17+
18+
function cardMessage(args: { id: string; revision: number; outcome: string; progress?: string }) {
19+
return {
20+
id: args.id,
21+
role: "assistant",
22+
parts: [
23+
{
24+
type: "tool-render_view",
25+
toolCallId: args.id,
26+
state: "output-available",
27+
output: {
28+
blocks: [
29+
{
30+
type: "investigation",
31+
id: INVESTIGATION_ID,
32+
revision: args.revision,
33+
version: VIEW_BLOCK_VERSION,
34+
investigation: { outcome: args.outcome, progress: args.progress },
35+
},
36+
],
37+
},
38+
},
39+
],
40+
};
41+
}
42+
43+
const OPEN = cardMessage({
44+
id: "msg_open",
45+
revision: 0,
46+
outcome: "in_progress",
47+
progress: "Reading the run's spans",
48+
});
49+
50+
const SETTLED = cardMessage({
51+
id: `investigation-settlement:${INVESTIGATION_ID}:1`,
52+
revision: 1,
53+
outcome: "inconclusive",
54+
});
55+
56+
describe("merging a re-read transcript", () => {
57+
it("adds only what the panel doesn't have, keeping what is already rendered in place", () => {
58+
const merged = mergeSettledMessages([OPEN], [OPEN, SETTLED]);
59+
expect(merged.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
60+
expect(merged[0]).toBe(OPEN);
61+
});
62+
63+
it("cannot produce a second copy of a card, however many times it re-reads", () => {
64+
let merged = mergeSettledMessages([OPEN], [OPEN, SETTLED]);
65+
merged = mergeSettledMessages(merged, [OPEN, SETTLED]);
66+
merged = mergeSettledMessages(merged, [OPEN, SETTLED]);
67+
expect(merged.filter((message) => message.id === SETTLED.id)).toHaveLength(1);
68+
});
69+
70+
it("returns the same array when the re-read adds nothing, so no render is forced", () => {
71+
const current = [OPEN, SETTLED];
72+
expect(mergeSettledMessages(current, [OPEN, SETTLED])).toBe(current);
73+
});
74+
});
75+
76+
describe("an already-open panel when a turn is exhausted", () => {
77+
it("stops showing Working… without a reload or a reopen", async () => {
78+
// What the mounted panel holds when the stream closes: the card the model opened
79+
// and never concluded, and no turn in flight.
80+
let rendered: (typeof OPEN)[] = [OPEN];
81+
expect(liveProgress(rendered, null)).toEqual({
82+
source: "investigation",
83+
label: "Reading the run's spans",
84+
});
85+
86+
// The stored transcript, which `onTurnComplete` has closed out by now.
87+
const waits: number[] = [];
88+
await pollSettledTranscript({
89+
fetchTranscript: async () => [OPEN, SETTLED],
90+
apply: (merge) => void (rendered = merge(rendered)),
91+
wait: async (ms) => void waits.push(ms),
92+
});
93+
94+
expect(rendered.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
95+
// The panel's own progress line is gone: the winning revision is terminal.
96+
expect(liveProgress(rendered, null)).toBeNull();
97+
// One re-read was enough, because the transcript came back closed.
98+
expect(waits).toHaveLength(1);
99+
});
100+
101+
it("retries while the stored transcript is still open, because the write lands after the stream closes", async () => {
102+
const responses = [[OPEN], [OPEN], [OPEN, SETTLED]];
103+
let rendered: (typeof OPEN)[] = [OPEN];
104+
let reads = 0;
105+
106+
await pollSettledTranscript({
107+
fetchTranscript: async () => responses[reads++] ?? null,
108+
apply: (merge) => void (rendered = merge(rendered)),
109+
wait: async () => {},
110+
});
111+
112+
expect(reads).toBe(3);
113+
expect(hasOpenInvestigation(rendered)).toBe(false);
114+
});
115+
116+
it("gives up rather than polling forever, leaving the sweep as the backstop", async () => {
117+
let reads = 0;
118+
await pollSettledTranscript({
119+
fetchTranscript: async () => {
120+
reads++;
121+
return [OPEN];
122+
},
123+
apply: () => {},
124+
wait: async () => {},
125+
delays: [0, 0],
126+
});
127+
128+
expect(reads).toBe(2);
129+
});
130+
131+
it("stops on a failed re-read instead of hammering the endpoint", async () => {
132+
let reads = 0;
133+
await pollSettledTranscript<typeof OPEN>({
134+
fetchTranscript: async () => {
135+
reads++;
136+
return null;
137+
},
138+
apply: () => {},
139+
wait: async () => {},
140+
});
141+
142+
expect(reads).toBe(1);
143+
});
144+
});

apps/webapp/test/dashboardAgentInvestigationSweep.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
getInvestigation,
66
investigationSettlementMessageId,
77
listStaleOpenInvestigations,
8-
settleInvestigationAsInconclusive,
8+
settleInvestigationAndCloseCard,
99
upsertInvestigationRevision,
1010
type DashboardAgentDb,
1111
type DashboardAgentDbClient,
@@ -309,6 +309,52 @@ describe("the dashboard agent investigation sweep", () => {
309309
}
310310
);
311311

312+
/**
313+
* The failure window. Settling the row and delivering its card used to be two
314+
* operations: once the row was terminal, a failed append left a card reading
315+
* `in_progress` that nothing would ever repair, because this sweep only selects
316+
* `in_progress` rows. They must land together or not at all.
317+
*/
318+
postgresTest(
319+
"a card that can't be delivered leaves the row in_progress, so the next run retries it",
320+
async ({ prisma, postgresContainer }) => {
321+
await boot(prisma, postgresContainer.getConnectionUri());
322+
await seedChat("chat_undeliverable");
323+
// A state the settle can merge but no card can be rendered from, so the delivery
324+
// half genuinely fails against a real database.
325+
const id = await seedInvestigation({
326+
chatId: "chat_undeliverable",
327+
state: { outcome: "in_progress" } as unknown as InvestigationState,
328+
ageMs: STALE_AGE_MS,
329+
});
330+
331+
await expect(sweepDashboardAgentInvestigations()).rejects.toThrow(
332+
/failed on 1 investigations/
333+
);
334+
335+
// The settle rolled back with the card: no half-applied terminal row.
336+
const row = await getInvestigation(ctx.agentDb, { id });
337+
expect(row?.revision).toBe(0);
338+
expect((row?.state as { outcome?: string }).outcome).toBe("in_progress");
339+
expect(
340+
await getChatMessages(ctx.agentDb, {
341+
chatId: "chat_undeliverable",
342+
userId: "user_sweep",
343+
organizationId: "org_sweep",
344+
})
345+
).toEqual([]);
346+
347+
// And it is still in the selection, so the sweep keeps trying rather than
348+
// leaving a permanent spinner behind.
349+
const stale = await listStaleOpenInvestigations(ctx.agentDb, {
350+
olderThan: new Date(),
351+
limit: 10,
352+
});
353+
expect(stale.map((candidate) => candidate.id)).toEqual([id]);
354+
},
355+
30_000
356+
);
357+
312358
postgresTest(
313359
"one failing row doesn't cost the batch, and the run throws so the job retries",
314360
async ({ prisma, postgresContainer }) => {
@@ -328,10 +374,10 @@ describe("the dashboard agent investigation sweep", () => {
328374
const attempted: string[] = [];
329375
await expect(
330376
sweepDashboardAgentInvestigations({
331-
settle: async (params) => {
377+
settleAndClose: async (params) => {
332378
attempted.push(params.id);
333379
if (params.id === first) throw new Error("the settle failed");
334-
return settleInvestigationAsInconclusive(ctx.agentDb, params);
380+
return settleInvestigationAndCloseCard(ctx.agentDb, params);
335381
},
336382
})
337383
).rejects.toThrow(/failed on 1 investigations/);

apps/webapp/test/dashboardAgentInvestigationSweepCard.test.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
investigationSettlementMessage,
23
investigationSettlementMessageId,
34
type Investigation,
45
type InvestigationCardMessage,
@@ -93,24 +94,32 @@ function staleRow(state: InvestigationState): Investigation {
9394

9495
/**
9596
* Stands in for the datastore: the conditional settle (`in_progress` only, mirroring
96-
* `forceSettledInvestigationState`) and the id-deduped append.
97+
* `forceSettledInvestigationState`) and the id-deduped append, as one operation —
98+
* which is what the real query is, so a half-applied settle can't exist.
9799
*/
98100
function fakeStore(initial: InvestigationState) {
99101
const row = { revision: 0, state: initial };
100102
const appended: InvestigationCardMessage[] = [];
101103
return {
102104
row,
103105
appended,
104-
settle: async (params: { id: string; note: string }) => {
106+
settleAndClose: async (params: { id: string; chatId: string; note: string }) => {
105107
if (investigationStateSchema.parse(row.state).outcome !== "in_progress") return null;
106-
row.state = forceSettledInvestigationState(investigationStateSchema.parse(row.state));
107-
row.revision += 1;
108-
return { id: params.id, revision: row.revision, state: row.state };
109-
},
110-
closeCard: async (params: { chatId: string; message: InvestigationCardMessage }) => {
111-
if (appended.some((message) => message.id === params.message.id)) return false;
112-
appended.push(params.message);
113-
return true;
108+
const state = forceSettledInvestigationState(investigationStateSchema.parse(row.state));
109+
const revision = row.revision + 1;
110+
111+
const message = investigationSettlementMessage({
112+
investigationId: params.id,
113+
revision,
114+
state,
115+
});
116+
if (!message) throw new Error("the closing card didn't validate");
117+
118+
row.state = state;
119+
row.revision = revision;
120+
const closed = !appended.some((existing) => existing.id === message.id);
121+
if (closed) appended.push(message);
122+
return { settled: { id: params.id, revision, state }, closed };
114123
},
115124
};
116125
}
@@ -122,8 +131,7 @@ describe("the dashboard agent investigation sweep's closing card", () => {
122131

123132
const result = await sweepDashboardAgentInvestigations({
124133
listStale: async () => [staleRow(open)],
125-
settle: store.settle,
126-
closeCard: store.closeCard,
134+
settleAndClose: store.settleAndClose,
127135
});
128136

129137
expect(store.appended).toHaveLength(1);
@@ -166,8 +174,7 @@ describe("the dashboard agent investigation sweep's closing card", () => {
166174
const store = fakeStore(open);
167175
const deps = {
168176
listStale: async () => [staleRow(open)],
169-
settle: store.settle,
170-
closeCard: store.closeCard,
177+
settleAndClose: store.settleAndClose,
171178
};
172179

173180
await sweepDashboardAgentInvestigations(deps);

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { chat } from "@trigger.dev/sdk/ai";
22
import { locals, logger, tasks } from "@trigger.dev/sdk";
3-
import {
4-
generateText,
5-
stepCountIs,
6-
streamText,
7-
type ModelMessage,
8-
type UIMessage,
9-
} from "ai";
3+
import { generateText, stepCountIs, streamText, type ModelMessage, type UIMessage } from "ai";
104
import {
115
orgAllowsTurnEvals,
126
redactEvalToolValue,

0 commit comments

Comments
 (0)