Skip to content

Commit 7d34cf4

Browse files
committed
Merge branch 'feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents a42ad77 + aaa9c9c commit 7d34cf4

8 files changed

Lines changed: 76 additions & 20 deletions

File tree

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { stripModelImages } from "./model-markdown";
2323
import { reportBlockFromToolPart } from "./report-block-adapter";
2424
import { shouldShowLiveTurnError } from "./turn-error";
2525
import type { ResolvedUri } from "./ReportView";
26-
import { answerContinuesAfter, turnAlreadyOffersWatch } from "./view-actions";
26+
import { answerContinuesAfter, turnAlreadyOffersWatch, turnProposesWatch } from "./view-actions";
2727
import { latestRevisionBlocks } from "./view-blocks";
2828
import { ViewBlocks } from "./view-catalog";
2929
import { findWakeWatch, WakeBanner, wakeRefFromMessageId, type WakeWatch } from "./WakeBanner";
@@ -258,11 +258,13 @@ const DashboardAgentTurn = memo(function DashboardAgentTurn({
258258
});
259259
// One answer for the whole turn: two `render_view` parts each deciding for themselves
260260
// would show the watch button twice. `ViewBlocks` collapses revisions the same way.
261-
const watchOfferedInTurn = turnAlreadyOffersWatch(
262-
blocksByPart
263-
.filter((blocks): blocks is unknown[] => blocks !== null)
264-
.map((blocks) => latestRevisionBlocks(blocks as never))
265-
);
261+
const watchOfferedInTurn =
262+
turnProposesWatch(parts as never) ||
263+
turnAlreadyOffersWatch(
264+
blocksByPart
265+
.filter((blocks): blocks is unknown[] => blocks !== null)
266+
.map((blocks) => latestRevisionBlocks(blocks as never))
267+
);
266268

267269
const body: React.ReactNode[] = [];
268270
for (let i = 0; i < parts.length; i++) {

apps/webapp/app/components/dashboard-agent/report-block-adapter.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,14 @@ describe("reportBlockFromToolPart", () => {
9191
expect(reportBlockFromToolPart(part({ output: { ...vm, generatedAt: undefined } }))).toBeNull();
9292
});
9393

94-
it("accepts the curated tool output, which drops links and series", () => {
95-
const { links, ...curated } = vm;
96-
const block = reportBlockFromToolPart(part({ output: { ...curated, seriesOmitted: true } }))!;
97-
expect(block.vm.links).toEqual([]);
98-
expect(block.vm.metrics[0]!.series).toBeUndefined();
94+
it("passes the tool output's series and links through", () => {
95+
const series = { points: [80, 40, 120], kind: "measured" };
96+
const links = [{ key: "queues", label: "Queues", url: "/queues" }];
97+
const block = reportBlockFromToolPart(
98+
part({ output: { ...vm, metrics: [{ ...vm.metrics[0], series }], links } })
99+
)!;
100+
expect(block.vm.metrics[0]!.series).toEqual(series);
101+
expect(block.vm.links).toEqual(links);
99102
expect(block.vm.summary.severity).toBe("crit");
100103
});
101104

apps/webapp/app/components/dashboard-agent/report-sparkline.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -570,15 +570,15 @@ export function ReportSparkline({
570570
* chart start on the same vertical whatever the value's width.
571571
*/
572572
/**
573-
* Below a 19rem container the fixed tracks no longer fit beside the value, so the
573+
* Below a 22rem container the fixed tracks no longer fit beside the value, so the
574574
* sparkline drops to its own line. The columns never change, so the value, delta
575575
* and note stay on the same verticals at every panel width.
576576
*/
577577
const METRIC_ROW_CLASS =
578-
"grid grid-cols-[7rem_minmax(0,1fr)_2.75rem_6.5rem] items-center gap-x-2 @max-[19rem]:grid-cols-[7rem_minmax(0,1fr)_2.75rem] @max-[19rem]:gap-y-1.5";
578+
"grid grid-cols-[7rem_minmax(0,1fr)_2.75rem_6.5rem] items-center gap-x-2 @max-[22rem]:grid-cols-[7rem_minmax(0,1fr)_2.75rem] @max-[22rem]:gap-y-1.5";
579579

580580
/** The sparkline cell: its own full-width line once the row goes narrow. */
581-
const SPARK_CELL_CLASS = "@max-[19rem]:col-span-3 @max-[19rem]:justify-self-end";
581+
const SPARK_CELL_CLASS = "@max-[22rem]:col-span-3 @max-[22rem]:justify-self-end";
582582

583583
// Labels are never truncated: the column is sized for the longest one and
584584
// anything longer wraps.
@@ -674,7 +674,7 @@ export function ReportMetricRow({
674674
) : (
675675
// Keeps the column occupied so a series-less metric doesn't pull the
676676
// rows out of alignment.
677-
<span aria-hidden className="@max-[19rem]:hidden" />
677+
<span aria-hidden className="@max-[22rem]:hidden" />
678678
)}
679679
</li>
680680

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
cardAlreadyOffersWatch,
77
renderableActions,
88
turnAlreadyOffersWatch,
9+
turnProposesWatch,
910
withoutWatchActions,
1011
} from "./view-actions";
1112

@@ -117,6 +118,32 @@ describe("one watch button per answer", () => {
117118
});
118119
});
119120

121+
describe("a turn that proposed a watch through the tool", () => {
122+
const text = { type: "text", text: "here is what I found" };
123+
const scheduled = (output: unknown, state = "output-available") => ({
124+
type: "tool-schedule_watch",
125+
state,
126+
output,
127+
});
128+
const intent = { intent: watchAction.intent };
129+
130+
it("sees the proposal that opened the card", () => {
131+
expect(turnProposesWatch([text, scheduled(intent)])).toBe(true);
132+
});
133+
134+
it("leaves the button alone when the spec was rejected — no card opened", () => {
135+
expect(turnProposesWatch([text, scheduled({ error: "Couldn't build that watch: bad" })])).toBe(
136+
false
137+
);
138+
});
139+
140+
it("waits for the output: a call still running proposes nothing", () => {
141+
expect(turnProposesWatch([scheduled(intent, "input-available")])).toBe(false);
142+
expect(turnProposesWatch([{ type: "tool-schedule_watch", output: intent }])).toBe(false);
143+
expect(turnProposesWatch([text])).toBe(false);
144+
});
145+
});
146+
120147
describe("ActionsBlock", () => {
121148
const source = readFileSync(new URL("./ActionsBlock.tsx", import.meta.url), "utf8");
122149

@@ -154,6 +181,10 @@ describe("the one-watch-button flag is decided per turn, not per render_view cal
154181
expect(turn).toContain("watchOfferedInTurn={watchOfferedInTurn}");
155182
});
156183

184+
it("counts the turn's own schedule_watch proposal as an offer", () => {
185+
expect(turn).toMatch(/turnProposesWatch\(parts as never\) \|\|/);
186+
});
187+
157188
it("lets a card add its own offer but never drop the turn's", () => {
158189
expect(catalog).toMatch(/watchOfferedInTurn \|\|\s*cardAlreadyOffersWatch\(/);
159190
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// A navigate target is a plain string at the contract boundary, so only targets
22
// that parse become buttons: a hallucinated URI costs a button, never a dead click.
33
import {
4+
agentIntentSchema,
45
isTriggerUri,
56
type ActionsBlockAction,
67
type ChartAction,
@@ -37,6 +38,21 @@ export function turnAlreadyOffersWatch(blockGroups: ViewBlock[][]): boolean {
3738
return blockGroups.some(cardAlreadyOffersWatch);
3839
}
3940

41+
/**
42+
* `schedule_watch` opens the pre-filled card itself, so a button repeating it is dead.
43+
* A rejected spec returns an error instead of an intent: no card opens, so the button stays.
44+
*/
45+
export function turnProposesWatch(
46+
parts: ReadonlyArray<{ type?: string; state?: string; output?: unknown }>
47+
): boolean {
48+
return parts.some(
49+
(part) =>
50+
part.type === "tool-schedule_watch" &&
51+
part.state === "output-available" &&
52+
agentIntentSchema.safeParse((part.output as { intent?: unknown } | undefined)?.intent).success
53+
);
54+
}
55+
4056
export function withoutWatchActions<T extends CardAction>(actions: T[]): T[] {
4157
return actions.filter((action) => action.intent.kind !== "watch");
4258
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,6 @@ const FIXTURES: Record<string, unknown> = {
200200
},
201201
},
202202
footer: [{ code: "raise_env_limit" }],
203-
seriesOmitted: true,
204203
},
205204
get_queue: {
206205
queue: "task/send-email",

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2478,15 +2478,16 @@ describe("the view-model tools don't echo the view back to the model", () => {
24782478
aggregation: "max",
24792479
normal: 40,
24802480
severity: "crit",
2481+
series: { points: Array.from({ length: 60 }, (_, i) => i * 3), kind: "measured" },
24812482
breakdown: Object.fromEntries(
24822483
Array.from({ length: 60 }, (_, i) => [`task/queue-${i}`, i * 7])
24832484
),
24842485
annotation: { code: "pending.estimated", value: 4200 },
24852486
},
24862487
],
24872488
facts: { trustworthy: true, throughput: 12 },
2489+
links: [{ key: "queues", label: "Queues", url: "/queues" }],
24882490
footer: { code: "report.footer" },
2489-
seriesOmitted: true,
24902491
uri: "trigger://proj_abc/env_abc/report/health",
24912492
};
24922493

@@ -2505,6 +2506,8 @@ describe("the view-model tools don't echo the view back to the model", () => {
25052506
expect(serialized).not.toContain("breakdown");
25062507
expect(serialized).not.toContain("observations");
25072508
expect(serialized).not.toContain("footer");
2509+
expect(serialized).not.toContain("series");
2510+
expect(serialized).not.toContain("links");
25082511
expect(serialized.length).toBeLessThan(JSON.stringify(output).length / 2);
25092512
});
25102513

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,9 @@ export function curateError(group: any) {
146146
};
147147
}
148148

149-
// Drops the per-metric `series` arrays and `links`; `seriesOmitted` records that the
150-
// shape is lossy. Grades and `facts.trustworthy` survive verbatim.
149+
// The card's copy: everything it draws, including the per-metric `series` and the
150+
// `links` recommendations key into. The model's copy is trimmed separately by
151+
// `getReportModelOutput`, so this costs no context.
151152
export function curateReport(data: unknown) {
152153
const vm = (data ?? {}) as any;
153154
const facts = (vm.facts ?? {}) as any;
@@ -180,6 +181,7 @@ export function curateReport(data: unknown) {
180181
aggregation: m.aggregation,
181182
normal: m.normal,
182183
delta: m.delta,
184+
series: m.series,
183185
breakdown: m.breakdown,
184186
annotation: m.annotation,
185187
// "unknown" means `value` is a placeholder, not a measurement. "measured" is the
@@ -200,8 +202,8 @@ export function curateReport(data: unknown) {
200202
dlqDelta: flowEvidence.dlqDelta,
201203
},
202204
},
205+
links: vm.links,
203206
footer: vm.footer,
204-
seriesOmitted: true,
205207
};
206208
}
207209

0 commit comments

Comments
 (0)