Skip to content

Commit 5779d5d

Browse files
committed
Merge branch 'feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents 6f54549 + a58b22b commit 5779d5d

8 files changed

Lines changed: 109 additions & 31 deletions

File tree

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
ReportSeverityIcon,
5252
type ReportFooterItem,
5353
} from "./report-sparkline";
54+
import { reportOffersRecoveryWatch } from "./view-actions";
5455

5556
export type ResolvedUri = { label: string; url: string };
5657

@@ -319,19 +320,18 @@ export function ReportView({
319320

320321
// Only offered when there is something to recover from, and only for the health
321322
// report, which is the one with a recovery watch kind.
322-
const recoveryWatch: AgentIntent | null =
323-
vm.title === "health" && (severity === "warn" || severity === "crit")
324-
? {
325-
kind: "watch",
326-
spec: {
327-
kind: "health_recovery",
328-
report: "health",
329-
fromSeverity: severity,
330-
note: `${vm.scope} health back to normal`,
331-
...RECOVERY_WATCH,
332-
},
333-
}
334-
: null;
323+
const recoveryWatch: AgentIntent | null = reportOffersRecoveryWatch(vm)
324+
? {
325+
kind: "watch",
326+
spec: {
327+
kind: "health_recovery",
328+
report: "health",
329+
fromSeverity: severity,
330+
note: `${vm.scope} health back to normal`,
331+
...RECOVERY_WATCH,
332+
},
333+
}
334+
: null;
335335

336336
// Links a footer action already speaks for aren't repeated as reading matter.
337337
const footerLinkKeys = new Set(layout.footer.map((entry) => entry.link).filter(Boolean));

apps/webapp/app/components/dashboard-agent/view-actions.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,24 @@ describe("one watch button per answer", () => {
102102
expect(turnAlreadyOffersWatch([actionsCall, investigationCall])).toBe(true);
103103
});
104104

105+
// The report card grows its own "Watch…" button from the view model, so the block
106+
// carries no watch action to match on.
107+
const reportCard = (title: string, severity: string) =>
108+
({ type: "report", vm: { title, summary: { severity, statements: [] } } }) as never;
109+
110+
it("sees the health report card's recovery watch", () => {
111+
expect(cardAlreadyOffersWatch([reportCard("health", "crit")])).toBe(true);
112+
expect(cardAlreadyOffersWatch([reportCard("health", "warn")])).toBe(true);
113+
const actionsCall = [{ type: "actions", actions: [watchAction] }] as never[];
114+
expect(turnAlreadyOffersWatch([[reportCard("health", "crit")], actionsCall])).toBe(true);
115+
});
116+
117+
it("leaves a report card with no watch button alone", () => {
118+
// Green: nothing to recover from. And only the health report has a recovery watch.
119+
expect(cardAlreadyOffersWatch([reportCard("health", "ok")])).toBe(false);
120+
expect(cardAlreadyOffersWatch([reportCard("cost", "crit")])).toBe(false);
121+
});
122+
105123
it("says no when no call in the turn has a card offering one", () => {
106124
const plain = [card([{ label: "Keep digging", intent: { kind: "ask", prompt: "" } }])];
107125
expect(turnAlreadyOffersWatch([plain, []])).toBe(false);

apps/webapp/app/components/dashboard-agent/view-actions.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isTriggerUri,
66
type ActionsBlockAction,
77
type ChartAction,
8+
type ReportViewModelPayload,
89
type ViewBlock,
910
} from "@internal/dashboard-agent-contracts";
1011

@@ -23,10 +24,22 @@ export function renderableActions<T extends CardAction>(actions: T[]): T[] {
2324
* shows the same button twice. The card wins: it is the one with the pre-filled spec.
2425
*/
2526
export function cardAlreadyOffersWatch(blocks: ViewBlock[]): boolean {
26-
return blocks.some(
27-
(block) =>
28-
block.type === "investigation" &&
29-
(block.capabilities?.actions ?? []).some((action) => action.intent.kind === "watch")
27+
return blocks.some((block) => {
28+
if (block.type === "investigation") {
29+
return (block.capabilities?.actions ?? []).some((action) => action.intent.kind === "watch");
30+
}
31+
return block.type === "report" && reportOffersRecoveryWatch(block.vm);
32+
});
33+
}
34+
35+
/**
36+
* The report card's watch button isn't in the block — `ReportView` grows it from the
37+
* view model, for a health report with something to recover from. Same condition here,
38+
* so the button the user will see is the one the guard counts.
39+
*/
40+
export function reportOffersRecoveryWatch(vm: ReportViewModelPayload): boolean {
41+
return (
42+
vm.title === "health" && (vm.summary.severity === "warn" || vm.summary.severity === "crit")
3043
);
3144
}
3245

apps/webapp/app/presenters/v3/reports/report-layout.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,52 @@ const livenessFinding = {
2929
metricIds: ["liveness"],
3030
};
3131

32+
function vmWithMetric(metric: LayoutViewModel["metrics"][number]): LayoutViewModel {
33+
return {
34+
title: "health",
35+
scope: "prod",
36+
period: "last 60 min",
37+
windowMinutes: 60,
38+
summary: { severity: "warn", statements: [] },
39+
findings: [{ type: "queue", severity: "warn", reason: "backlog", metricIds: [metric.id] }],
40+
metrics: [metric],
41+
footer: [],
42+
};
43+
}
44+
45+
function heroDelta(metric: LayoutViewModel["metrics"][number]) {
46+
const layout = buildReportLayout(vmWithMetric(metric), reportMessages("health"));
47+
return layout.hero?.metrics.find((m) => m.id === metric.id)?.delta;
48+
}
49+
50+
describe("buildReportLayout metric deltas", () => {
51+
it("renders no delta when a metric collapsed to nothing", () => {
52+
expect(
53+
heroDelta({
54+
id: "pending",
55+
value: 0,
56+
unit: "count",
57+
severity: "warn",
58+
normal: 40,
59+
delta: { dir: "down", mult: 0 },
60+
})
61+
).toBeUndefined();
62+
});
63+
64+
it("still renders a multiplier for a genuine fall", () => {
65+
expect(
66+
heroDelta({
67+
id: "pending",
68+
value: 10,
69+
unit: "count",
70+
severity: "warn",
71+
normal: 40,
72+
delta: { dir: "down", mult: 0 },
73+
})
74+
).toEqual({ text: "↓ 4×", dir: "down" });
75+
});
76+
});
77+
3278
describe("buildReportLayout self-evident findings", () => {
3379
it("drops the metric row of a non-hero finding whose only metric is its own line", () => {
3480
const layout = buildReportLayout(

apps/webapp/app/presenters/v3/reports/report-layout.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,8 @@ function metricDelta(metric: LayoutMetricInput): LayoutDelta | undefined {
574574
// A fall's own multiplier rounds to 0 or 1, so measure how far it fell instead.
575575
if (delta?.dir === "down") {
576576
const fall = fallMultiplier(metric);
577-
if (fall === null) return { text: REPORT_GLYPH.down, dir: "down" };
577+
// An arrow with no multiplier behind it says nothing the sparkline hasn't.
578+
if (fall === null) return undefined;
578579
if (fall !== undefined) return { text: `${REPORT_GLYPH.down} ${fall}×`, dir: "down" };
579580
}
580581
if (delta && delta.mult !== undefined && delta.mult > 1 && delta.dir !== "flat") {

internal-packages/dashboard-agent/src/__snapshots__/prompt-prefix.test.ts.snap

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal-packages/dashboard-agent/src/prompt-prefix.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe("the head-start and agent prefixes are the same prefix", () => {
8888
* drift. The snapshot below is the itemised diff a reviewer reads.
8989
*/
9090
const PREFIX_BUDGET = {
91-
assistant: { chars: 77_000, estimatedTokens: 19_500, tools: 24, promptChars: 26_000 },
91+
assistant: { chars: 77_000, estimatedTokens: 19_500, tools: 24, promptChars: 26_500 },
9292
code: { chars: 83_000, estimatedTokens: 21_000, tools: 28, promptChars: 29_000 },
9393
} as const;
9494

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ Guidelines:
508508
- "How do I check X?" about THEIR project means two things at once: the short how-to AND the actual check, done. Answer "how do I check queue health?" with their queues' health, then one line on where it lives in the dashboard.
509509
- The user does only what your tools genuinely cannot reach: their own infra, their code, external pages. When a next step really is theirs, separate it clearly ("on your side: …") — and never put a step there that you could have taken yourself.
510510
- For "what's broken" or "why is X failing" questions, start with list_errors to find the error groups, get_error for the detail, then list_runs with that error id to drill into the actual failing runs (and get_run_trace for one of them).
511-
- An answer whose headline is an UNRESOLVED, recurring error ENDS with the watch offer — one line, "Want me to set up a watch so you're told if it hits again?", plus the render_view "actions" block that makes it a button — not with generic advice alone. This is the rule from the Watches section applied to its most common case; it is not optional there, and neither is the button.
511+
- An answer whose headline is an UNRESOLVED, recurring error ENDS with the watch offer — one line, "Want me to set up a watch so you're told if it hits again?", then the render_view "actions" block that makes it a button — not with generic advice alone. This is the rule from the Watches section applied to its most common case; it is not optional there, and neither is the button.
512512
- Your tools are read-only and scoped to the current environment for run and task lookups. You can't change anything; for actions, point the user to where in the dashboard they can do it.
513513
- Never invent run IDs, task identifiers, metrics, or features. If a tool returns an error or nothing, say so plainly.
514514
- A truncated or paged result supports what you saw, never what you didn't. When a result is truncated or returns a nextCursor, you may not claim an absence — "only send-receipt failed", "nothing else is failing", "there are no others" are all out, even hedged with "in what I saw". Say what the page showed and that the list is incomplete, or read a source that can answer completeness (list_errors groups every error in the window) before you answer.
@@ -534,7 +534,7 @@ Is anything wrong?:
534534
535535
Watches — telling the user later:
536536
- When the user wants to be told when something happens ("tell me when this run finishes", "let me know when the backlog drains", "tell me when it's back under 100", "tell me if that queue stops moving", "ping me if runs start waiting more than 5 minutes", "ping me if that error comes back", "tell me when prod is healthy again"), call schedule_watch. Never poll: repeating a read tool until the thing happens is not a watch, and you cannot wait inside a turn.
537-
- Offer a watch whenever your answer points at something worth monitoring that you can't resolve now: a recurring or unresolved error, a queue trending toward trouble, a condition the user would want to hear about the moment it changes. The offer is two things together: one short line ("Want me to set up a watch so you're told if it hits again?") AND a render_view "actions" block with one button — label it like "Set up a watch", intent {"kind":"watch","spec":{…}} carrying the same spec schedule_watch would compose. Clicking it opens the configuration card pre-filled, so the user answers with a click instead of typing "yeah". One offer per answer at most; skip it when the news is good, when the user is clearly just browsing, or when this answer's investigation card already carries a watch button — the card is the offer, and repeating it puts two watch buttons on one answer. schedule_watch is still how you answer a user who asks for a watch in their own words.
537+
- Offer a watch whenever your answer points at something worth monitoring that you can't resolve now: a recurring or unresolved error, a queue trending toward trouble, a condition the user would want to hear about the moment it changes. The offer is two things in this order: one short line ("Want me to set up a watch so you're told if it hits again?") as the LAST sentence of your answer, and THEN the render_view "actions" block with one button, emitted after that line as the final part of the turn with nothing after it — label it like "Set up a watch", intent {"kind":"watch","spec":{…}} carrying the same spec schedule_watch would compose. Clicking it opens the configuration card pre-filled, so the user answers with a click instead of typing "yeah". One offer per answer at most; skip it when the news is good, when the user is clearly just browsing, or when a card you just rendered already carries a watch button — an investigation card, or a health report card whose next steps offer "Watch recovery". That card is the offer, and repeating it puts two watch buttons on one answer. schedule_watch is still how you answer a user who asks for a watch in their own words.
538538
- schedule_watch does not start anything. It opens a configuration card pre-filled with what you composed, and the user confirming that card is what starts the watch. So say what you filled in — what is being watched, how often it checks, and when it gives up (the maxHours you set) — and that confirming starts it. Never say it's running, scheduled, or that you'll tell them later: "I've filled in a watch for you to review — confirm to start it", never "I'll let you know when it finishes". Pick the longest cadence that still answers in time — 1 minute only for a run's state, 5 minutes or more for backlog, error recurrence, and health.
539539
- The card settles everything after the user confirms: whether this chat can hold another watch, whether the same thing is already watched, and whether the condition is already true (in which case they get the answer instead of a watch). Never promise, predict, or pre-explain any of those.
540540
- A watch wake is a message you send unprompted, and it is narrated ONCE, briefly: what the outcome was, the numbers from the facts you were given, and one suggested next step. Nothing else — no new investigation, no fresh reads, no recap of the conversation.

0 commit comments

Comments
 (0)