Files
triggerdotdev--trigger.dev/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts
T
Katia Bulatova 9a3bee0288 feat(webapp): dashboard agent — UI (#4529)
Stacked on #4418. Merge that first.

The UI slice of the dashboard agent: the side panel, the chat transport
wiring, message and card rendering, suggested prompts, and chat history.
#4418 works without this — the system is simply invisible. The diff is
mostly components, so the notes below cover only the three decisions you
can't read off the markup. Behavior and a hands-on walkthrough live in
GUIDEBOOK.md, which lands with #4525.

## Decisions worth knowing

- **Action rows always render at the end of a turn.** The model's
emission order isn't trusted for layout, so action blocks are split out
of the stream and appended last. Display only — `answered` stays keyed
on the emission index.
- **The last-chat memory is org-true.** It's keyed by the chat's own
organization, and a foreign or deleted chat comes back as a 404 the
client treats as gone, rather than an empty chat it keeps around.
- **A dead stream self-heals from the settled transcript.** Terminal
records are written to the chat row after the client's stream closes, so
the panel re-reads it. The poll gate is any unfinished turn — a dangling
tool part, not just an open investigation.

## Notes

- Gated by `canAccessDashboardAgent`; no behavior change with the flag
off.
- Page marks: `handle.agentPageContext` on 47 routes, ~20 lines each.
- Entry points: Ask Trigger button, ⌘J, Help & Feedback. The old ⌘I and
`?aiHelp=` links keep working.

## Screenshots

<img width="1440" height="788" alt="Screenshot 2026-08-07 at 15 14 29"
src="https://github.com/user-attachments/assets/f4e89e8d-13ed-4be3-a88d-d5cca3ece0fa"
/>
2026-08-12 08:38:59 +02:00

286 lines
9.8 KiB
TypeScript

import { VIEW_BLOCK_VERSION } from "@internal/dashboard-agent-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { liveProgress } from "./progress-line";
import {
fetchChatTranscript,
hasOpenInvestigation,
mergeSettledMessages,
pollSettledTranscript,
transcriptLooksUnfinished,
} from "./settled-transcript";
/**
* The open panel. A settled turn writes its terminal card to the chat row rather than
* pushing a stream chunk, so a panel that stays mounted has to re-read the transcript
* or it renders the last `in_progress` revision forever.
*/
const INVESTIGATION_ID = "inv_open_panel";
function cardMessage(args: { id: string; revision: number; outcome: string; progress?: string }) {
return {
id: args.id,
role: "assistant",
parts: [
{
type: "tool-render_view",
toolCallId: args.id,
state: "output-available",
output: {
blocks: [
{
type: "investigation",
id: INVESTIGATION_ID,
revision: args.revision,
version: VIEW_BLOCK_VERSION,
investigation: { outcome: args.outcome, progress: args.progress },
},
],
},
},
],
};
}
const OPEN = cardMessage({
id: "msg_open",
revision: 0,
outcome: "in_progress",
progress: "Reading the run's spans",
});
const SETTLED = cardMessage({
id: `investigation-settlement:${INVESTIGATION_ID}:1`,
revision: 1,
outcome: "inconclusive",
});
describe("merging a re-read transcript", () => {
it("adds only what the panel doesn't have, keeping what is already rendered in place", () => {
const merged = mergeSettledMessages([OPEN], [OPEN, SETTLED]);
expect(merged.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
expect(merged[0]).toBe(OPEN);
});
it("cannot produce a second copy of a card, however many times it re-reads", () => {
let merged = mergeSettledMessages([OPEN], [OPEN, SETTLED]);
merged = mergeSettledMessages(merged, [OPEN, SETTLED]);
merged = mergeSettledMessages(merged, [OPEN, SETTLED]);
expect(merged.filter((message) => message.id === SETTLED.id)).toHaveLength(1);
});
it("returns the same array when the re-read adds nothing, so no render is forced", () => {
const current = [OPEN, SETTLED];
expect(mergeSettledMessages(current, [OPEN, SETTLED])).toBe(current);
});
});
describe("replacing a stale running step from the re-read", () => {
// Same message id, but the stream EOF'd before `get_report` produced an output.
const RUNNING_STEP = {
id: "msg_step",
role: "assistant",
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
};
const FINISHED_STEP = {
id: "msg_step",
role: "assistant",
parts: [
{ type: "tool-get_report", toolCallId: "call_1", state: "output-available", output: {} },
],
};
it("swaps the still-running copy for its finished version from the authoritative read", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [FINISHED_STEP]);
expect(merged).toEqual([FINISHED_STEP]);
// The step no longer reads as running, so nothing keeps the panel on Working…
expect(transcriptLooksUnfinished(merged)).toBe(false);
});
it("still appends genuinely-new messages while replacing a stale one", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [FINISHED_STEP, SETTLED]);
expect(merged.map((message) => message.id)).toEqual([FINISHED_STEP.id, SETTLED.id]);
expect(merged[0]).toBe(FINISHED_STEP);
});
it("leaves an in-flight message alone when the re-read is itself still running", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [RUNNING_STEP]);
// Same reference back, no needless render, and the live turn is untouched.
expect(merged).toEqual([RUNNING_STEP]);
expect(merged[0]).toBe(RUNNING_STEP);
});
it("does not touch a running message the re-read does not mention", () => {
const merged = mergeSettledMessages([RUNNING_STEP], [SETTLED]);
expect(merged.map((message) => message.id)).toEqual([RUNNING_STEP.id, SETTLED.id]);
expect(merged[0]).toBe(RUNNING_STEP);
});
});
describe("reading the transcript endpoint", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function respondWith(body: unknown, ok = true) {
vi.stubGlobal("fetch", async () => ({ ok, json: async () => body }) as unknown as Response);
}
it("returns the transcript when the response carries one", async () => {
respondWith({ messages: [OPEN, SETTLED] });
const fetched = await fetchChatTranscript<typeof OPEN>("/agent/transcript", "chat_1");
expect(fetched?.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
});
it("reads a response with no messages at all as a failed re-read", async () => {
respondWith({});
expect(await fetchChatTranscript("/agent/transcript", "chat_1")).toBeNull();
});
it("reads a non-array under messages as a failed re-read, not as a transcript", async () => {
respondWith({ messages: { msg_open: OPEN } });
expect(await fetchChatTranscript("/agent/transcript", "chat_1")).toBeNull();
});
it("keeps only entries the merge can key on", async () => {
respondWith({ messages: [OPEN, null, "msg_open", { revision: 1 }, SETTLED] });
const fetched = await fetchChatTranscript<typeof OPEN>("/agent/transcript", "chat_1");
expect(fetched?.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
});
it("leaves the panel's transcript alone when the endpoint answers with a shape it cannot merge", async () => {
respondWith({ messages: { msg_open: OPEN } });
let rendered: (typeof OPEN)[] = [OPEN];
await pollSettledTranscript<typeof OPEN>({
fetchTranscript: () => fetchChatTranscript("/agent/transcript", "chat_1"),
apply: (merge) => void (rendered = merge(rendered)),
wait: async () => {},
});
expect(rendered).toEqual([OPEN]);
});
});
describe("deciding whether a settled turn is worth re-reading", () => {
// The stream EOF'd while `get_report` was running: the part never gets an output.
const DANGLING_TOOL = {
id: "msg_dangling",
role: "assistant",
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
};
it("re-reads when the stream died mid-tool, not only when a card is open", () => {
expect(transcriptLooksUnfinished([DANGLING_TOOL])).toBe(true);
});
it("re-reads while a card is still open", () => {
expect(transcriptLooksUnfinished([OPEN])).toBe(true);
});
it("leaves a fully settled transcript alone", () => {
expect(transcriptLooksUnfinished([OPEN, SETTLED])).toBe(false);
});
});
describe("an already-open panel when a turn is exhausted", () => {
it("stops showing Working… without a reload or a reopen", async () => {
// What the mounted panel holds when the stream closes: the card the model opened
// and never concluded, and no turn in flight.
let rendered: (typeof OPEN)[] = [OPEN];
expect(liveProgress(rendered, null)).toEqual({
source: "investigation",
label: "Reading the run's spans",
});
// The stored transcript, which `onTurnComplete` has closed out by now.
const waits: number[] = [];
await pollSettledTranscript({
fetchTranscript: async () => [OPEN, SETTLED],
apply: (merge) => void (rendered = merge(rendered)),
wait: async (ms) => void waits.push(ms),
});
expect(rendered.map((message) => message.id)).toEqual([OPEN.id, SETTLED.id]);
// The panel's own progress line is gone: the winning revision is terminal.
expect(liveProgress(rendered, null)).toBeNull();
// One re-read was enough, because the transcript came back closed.
expect(waits).toHaveLength(1);
});
it("retries while the stored transcript is still open, because the write lands after the stream closes", async () => {
const responses = [[OPEN], [OPEN], [OPEN, SETTLED]];
let rendered: (typeof OPEN)[] = [OPEN];
let reads = 0;
await pollSettledTranscript({
fetchTranscript: async () => responses[reads++] ?? null,
apply: (merge) => void (rendered = merge(rendered)),
wait: async () => {},
});
expect(reads).toBe(3);
expect(hasOpenInvestigation(rendered)).toBe(false);
});
it("gives up rather than polling forever, leaving the sweep as the backstop", async () => {
let reads = 0;
await pollSettledTranscript({
fetchTranscript: async () => {
reads++;
return [OPEN];
},
apply: () => {},
wait: async () => {},
delays: [0, 0],
});
expect(reads).toBe(2);
});
it("keeps re-reading a stream that died mid-tool with no card open", async () => {
// No investigation anywhere: only the dangling `get_report` says the turn is unfinished.
const DANGLING = {
id: "msg_step",
role: "assistant",
parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }],
};
const FINISHED = {
id: "msg_step",
role: "assistant",
parts: [
{ type: "tool-get_report", toolCallId: "call_1", state: "output-available", output: {} },
],
};
const responses = [[DANGLING], [DANGLING], [FINISHED]];
let rendered: (typeof DANGLING)[] = [DANGLING];
let reads = 0;
await pollSettledTranscript({
fetchTranscript: async () => responses[reads++] ?? null,
apply: (merge) => void (rendered = merge(rendered)),
wait: async () => {},
});
expect(reads).toBe(3);
expect(rendered).toEqual([FINISHED]);
expect(transcriptLooksUnfinished(rendered)).toBe(false);
});
it("stops on a failed re-read instead of hammering the endpoint", async () => {
let reads = 0;
await pollSettledTranscript<typeof OPEN>({
fetchTranscript: async () => {
reads++;
return null;
},
apply: () => {},
wait: async () => {},
});
expect(reads).toBe(1);
});
});