Skip to content

Commit 7ce8bc2

Browse files
committed
test(dashboard-agent): eval pins the watch offer shape and ordering
1 parent a58b22b commit 7ce8bc2

1 file changed

Lines changed: 195 additions & 1 deletion

File tree

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

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ async function runCase(
334334
): Promise<{
335335
calls: RecordedCall[];
336336
answer: string;
337+
chunks: UIMessageChunk[];
337338
}> {
338339
const calls: RecordedCall[] = [];
339340
const harness = mockChatAgent(dashboardAgent, {
@@ -347,7 +348,7 @@ async function runCase(
347348
});
348349
try {
349350
const turn = await harness.sendMessage(userMessage(question));
350-
return { calls, answer: collectText(turn.chunks) };
351+
return { calls, answer: collectText(turn.chunks), chunks: turn.chunks };
351352
} finally {
352353
await harness.close();
353354
}
@@ -463,6 +464,51 @@ function describeRenders(renders: InvestigationRender[]): string {
463464
.join("\n");
464465
}
465466

467+
// The turn as the user receives it: runs of prose and tool calls, in emission order.
468+
// Ordering assertions need this — `calls` alone can't say what came after the last word.
469+
type TurnPart = { kind: "text"; text: string } | { kind: "tool"; tool: string; input: unknown };
470+
471+
function turnParts(chunks: UIMessageChunk[]): TurnPart[] {
472+
const parts: TurnPart[] = [];
473+
let text = "";
474+
const flush = () => {
475+
if (text.trim().length > 0) parts.push({ kind: "text", text });
476+
text = "";
477+
};
478+
for (const chunk of chunks) {
479+
if (chunk.type === "text-delta") text += chunk.delta;
480+
else if (chunk.type === "tool-input-available") {
481+
flush();
482+
parts.push({ kind: "tool", tool: chunk.toolName, input: chunk.input });
483+
}
484+
}
485+
flush();
486+
return parts;
487+
}
488+
489+
// The offer's button: an "actions" block whose intent opens the watch card.
490+
function watchButtons(input: unknown): Array<{ label?: string }> {
491+
const blocks =
492+
(
493+
input as {
494+
blocks?: Array<{
495+
type?: string;
496+
actions?: Array<{ label?: string; intent?: { kind?: string } }>;
497+
}>;
498+
}
499+
).blocks ?? [];
500+
return blocks
501+
.filter((b) => b?.type === "actions")
502+
.flatMap((b) => b.actions ?? [])
503+
.filter((a) => a?.intent?.kind === "watch");
504+
}
505+
506+
function watchOfferParts(parts: TurnPart[]): TurnPart[] {
507+
return parts.filter(
508+
(p) => p.kind === "tool" && p.tool === "render_view" && watchButtons(p.input).length > 0
509+
);
510+
}
511+
466512
function describeCalls(calls: RecordedCall[]): string {
467513
if (calls.length === 0) return " (no tool calls)";
468514
return calls
@@ -876,6 +922,154 @@ const TRUNCATED_FIXTURES: Record<string, unknown> = {
876922
},
877923
};
878924

925+
// One unresolved, recurring error, and nothing else wrong: the headline the watch
926+
// offer is written for.
927+
const RECURRING_ERROR_FIXTURES: Record<string, unknown> = {
928+
list_errors: {
929+
errors: [
930+
{
931+
id: "error_stripe",
932+
taskIdentifier: "send-receipt",
933+
errorType: "TimeoutError",
934+
errorMessage: "Stripe API timed out after 30s",
935+
status: "unresolved",
936+
count: 37,
937+
},
938+
],
939+
nextCursor: undefined,
940+
},
941+
get_report: {
942+
title: "health",
943+
scope: "prod",
944+
period: "last 1h",
945+
generatedAt: "2026-01-01T00:00:00.000Z",
946+
windowMinutes: 60,
947+
summary: {
948+
severity: "ok",
949+
statements: [
950+
{ findingType: "flow", severity: "ok" },
951+
{ findingType: "execution", severity: "ok" },
952+
{ findingType: "liveness", severity: "ok" },
953+
],
954+
},
955+
findings: [
956+
{ type: "flow", severity: "ok", reason: "healthy", metricIds: [] },
957+
{ type: "execution", severity: "ok", reason: "healthy", metricIds: [] },
958+
{ type: "liveness", severity: "ok", reason: "fresh", metricIds: ["liveness"] },
959+
],
960+
metrics: [{ id: "pending", value: 3, unit: "count", severity: "ok" }],
961+
facts: { trustworthy: true },
962+
footer: [],
963+
},
964+
};
965+
966+
// A degraded report, so the report card already carries its own "Watch recovery".
967+
const WARN_REPORT_FIXTURES: Record<string, unknown> = {
968+
list_errors: { errors: [], nextCursor: undefined },
969+
get_report: {
970+
title: "health",
971+
scope: "prod",
972+
period: "last 1h",
973+
baselineLabel: "vs your 7d normal",
974+
generatedAt: "2026-01-01T00:00:00.000Z",
975+
windowMinutes: 60,
976+
summary: {
977+
severity: "warn",
978+
statements: [
979+
{ findingType: "flow", severity: "warn" },
980+
{ findingType: "execution", severity: "ok" },
981+
{ findingType: "liveness", severity: "ok" },
982+
],
983+
},
984+
findings: [
985+
{
986+
type: "flow",
987+
severity: "warn",
988+
reason: "queue_backlog_growing",
989+
read: "backlog_building",
990+
metricIds: ["pending", "start_latency_p95"],
991+
attribution: { dim: "queue", key: "task/send-receipt", share: 0.74, of: "pending" },
992+
},
993+
{ type: "execution", severity: "ok", reason: "healthy", metricIds: [] },
994+
{ type: "liveness", severity: "ok", reason: "fresh", metricIds: ["liveness"] },
995+
],
996+
metrics: [
997+
{
998+
id: "start_latency_p95",
999+
value: 9000,
1000+
unit: "ms",
1001+
aggregation: "p95",
1002+
normal: 900,
1003+
severity: "warn",
1004+
},
1005+
{ id: "pending", value: 640, unit: "count", severity: "warn" },
1006+
{ id: "concurrency", value: 32, unit: "count", breakdown: { limit: 50 }, severity: "ok" },
1007+
],
1008+
facts: {
1009+
trustworthy: true,
1010+
flowSource: "queue_metrics_v1",
1011+
pendingEstimated: false,
1012+
flowEvidence: { envLimit: 50, throttledShare: 0.12, dlqDelta: 0 },
1013+
},
1014+
footer: [],
1015+
},
1016+
get_queue: {
1017+
queue: "task/send-receipt",
1018+
period: "1h",
1019+
waitMs: { p50: 3000, p95: 9000 },
1020+
peakQueued: 640,
1021+
startedCount: 4100,
1022+
startedPerMin: 68,
1023+
throttledCount: 120,
1024+
bucketIntervalMs: 300000,
1025+
depthTrend: [40, 120, 300, 480, 640],
1026+
},
1027+
};
1028+
1029+
describe.skipIf(!HAS_KEY)("dashboardAgent watch offer evals (real model)", () => {
1030+
it(
1031+
"recurring error: one watch offer, its line last, its button after it",
1032+
() =>
1033+
goldenCase(async () => {
1034+
const question = "What's broken?";
1035+
const { calls, answer, chunks } = await runCase(question, {
1036+
fixtures: RECURRING_ERROR_FIXTURES,
1037+
});
1038+
const parts = turnParts(chunks);
1039+
report("watch offer", calls, answer);
1040+
1041+
const offers = watchOfferParts(parts);
1042+
expect(offers).toHaveLength(1);
1043+
const offer = offers[0]!;
1044+
expect(offer.kind === "tool" ? watchButtons(offer.input) : []).toHaveLength(1);
1045+
// Nothing after the button, and the line before it is the offer question.
1046+
expect(parts[parts.length - 1]).toBe(offer);
1047+
const line = parts[parts.length - 2];
1048+
expect(line?.kind).toBe("text");
1049+
const text = line?.kind === "text" ? line.text.trim() : "";
1050+
expect(text).toMatch(/watch/i);
1051+
expect(text).toMatch(/\?$/);
1052+
}),
1053+
420_000
1054+
);
1055+
1056+
it(
1057+
"degraded report: the report card is the offer, so no second watch button",
1058+
() =>
1059+
goldenCase(async () => {
1060+
const question = "How is prod doing?";
1061+
const { calls, answer, chunks } = await runCase(question, {
1062+
fixtures: WARN_REPORT_FIXTURES,
1063+
});
1064+
report("no duplicate watch offer", calls, answer);
1065+
1066+
expect(calls.some((c) => c.tool === "get_report")).toBe(true);
1067+
expect(watchOfferParts(turnParts(chunks))).toHaveLength(0);
1068+
}),
1069+
420_000
1070+
);
1071+
});
1072+
8791073
describe.skipIf(!HAS_KEY)("dashboardAgent investigation evals (real model)", () => {
8801074
it(
8811075
"env-limit saturation: concludes on flow/config, not code",

0 commit comments

Comments
 (0)