Skip to content

Commit 1ebf9de

Browse files
committed
fix(webapp): schedule_watch proposes a watch instead of creating one
The free-text path used to POST the watch straight from the tool, so a "yeah, set one up" created it with no card and no consent. The tool now validates the spec and returns a `watch` intent; the panel scans `tool-schedule_watch` results the same way it scans navigate_to and opens the pre-filled configuration card, replay-safe. The card's submit is the only creator, so it owns the opt-ins, the cap, dedup, and the one-shot result — prompt wording updated to match.
1 parent 39eb60a commit 1ebf9de

10 files changed

Lines changed: 268 additions & 356 deletions

File tree

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
1212
import { DashboardAgentHero } from "./DashboardAgentHero";
1313
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
1414
import { createTranscriptOrder, orderTranscript } from "./message-order";
15-
import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
15+
import { appendRunFilters } from "./navigate-target";
16+
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
1617
import type { AgentPageContext } from "./page-context-types";
1718
import { useAgentMessageQuota } from "./useAgentMessageQuota";
1819
import { useTriggerUriResolver } from "./useTriggerUriResolver";
@@ -345,9 +346,26 @@ export function DashboardAgentChat({
345346
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
346347
// Only the last one matters — the earlier destinations are already history.
347348
const target = pending.at(-1);
348-
if (target?.kind === "navigate") void goTo(target);
349+
if (target) void goTo(target);
349350
}, [messages, goTo]);
350351

352+
// `schedule_watch` proposes rather than creates: it answers with a watch intent
353+
// and the panel opens the card pre-filled, so a free-text ask ("set up a watch
354+
// then") is reviewed and confirmed like any other watch (§2.1 Path B). Seeded
355+
// and deduped exactly like navigate, so reopening a chat whose history holds
356+
// the call never reopens the card.
357+
const watchProposedRef = useRef<Set<string> | null>(null);
358+
if (watchProposedRef.current === null) {
359+
watchProposedRef.current = new Set();
360+
pendingWatchIntents(initialMessages, watchProposedRef.current);
361+
}
362+
useEffect(() => {
363+
const pending = pendingWatchIntents(messages, watchProposedRef.current!);
364+
// One card at a time, so the newest proposal is the one to review.
365+
const proposed = pending.at(-1);
366+
if (proposed) onWatchIntent?.(proposed.spec);
367+
}, [messages, onWatchIntent]);
368+
351369
const stop = useCallback(() => {
352370
transport.stopGeneration(chatId);
353371
aiStop();
Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { appendRunFilters, pendingNavigateIntents, sameOriginPath } from "./navigate-target";
2+
import { appendRunFilters, sameOriginPath } from "./navigate-target";
33

44
const RUNS_PATH = "/orgs/acme/projects/api/env/prod/runs";
55

@@ -49,49 +49,3 @@ describe("sameOriginPath", () => {
4949
expect(sameOriginPath("not a url", "")).toBeNull();
5050
});
5151
});
52-
53-
describe("pendingNavigateIntents", () => {
54-
const uri = "trigger://proj_abc/env_123/run/run_abc";
55-
const toolPart = (toolCallId: string, state = "output-available") => ({
56-
type: "tool-navigate_to",
57-
state,
58-
toolCallId,
59-
output: { intent: { kind: "navigate", target: uri } },
60-
});
61-
62-
it("returns the intent from a completed navigate_to call, once", () => {
63-
const seen = new Set<string>();
64-
const messages = [{ id: "m1", parts: [toolPart("call-1")] }];
65-
66-
expect(pendingNavigateIntents(messages, seen)).toEqual([{ kind: "navigate", target: uri }]);
67-
expect(pendingNavigateIntents(messages, seen)).toEqual([]);
68-
});
69-
70-
it("ignores a call that hasn't produced output yet", () => {
71-
expect(
72-
pendingNavigateIntents(
73-
[{ id: "m1", parts: [toolPart("call-1", "input-available")] }],
74-
new Set()
75-
)
76-
).toEqual([]);
77-
});
78-
79-
it("ignores output that isn't a navigate intent", () => {
80-
const messages = [
81-
{ id: "m1", parts: [{ ...toolPart("call-1"), output: { error: "nowhere to go" } }] },
82-
{ id: "m2", parts: [{ type: "text", text: "hello" }] },
83-
];
84-
85-
expect(pendingNavigateIntents(messages, new Set())).toEqual([]);
86-
});
87-
88-
it("skips calls seeded as already seen (loaded history)", () => {
89-
const history = [{ id: "m1", parts: [toolPart("call-1")] }];
90-
const seen = new Set<string>();
91-
pendingNavigateIntents(history, seen);
92-
93-
expect(
94-
pendingNavigateIntents([...history, { id: "m2", parts: [toolPart("call-2")] }], seen)
95-
).toEqual([{ kind: "navigate", target: uri }]);
96-
});
97-
});

apps/webapp/app/components/dashboard-agent/navigate-target.ts

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@
66
* client-side halves: the runs-list filters a navigate intent carries, and links
77
* that arrived as absolute URLs into our own origin.
88
*/
9-
import {
10-
agentIntentSchema,
11-
type AgentIntent,
12-
type RunFilters,
13-
} from "@internal/dashboard-agent-contracts";
9+
import { type RunFilters } from "@internal/dashboard-agent-contracts";
1410

1511
// The filter keys are already the runs page's own URL params (see
1612
// `TaskRunListSearchFilters`), with one exception: the page reads absolute
@@ -61,41 +57,3 @@ export function sameOriginPath(href: string, origin: string): string | null {
6157
if (url.origin !== origin) return null;
6258
return `${url.pathname}${url.search}${url.hash}`;
6359
}
64-
65-
type ToolPart = { type?: string; state?: string; toolCallId?: string; output?: unknown };
66-
type ToolMessage = { id: string; parts?: ReadonlyArray<unknown> };
67-
68-
/**
69-
* The navigate intents from completed `navigate_to` tool calls the host hasn't
70-
* honoured yet.
71-
*
72-
* The tool returns an intent rather than performing the navigation, so the panel
73-
* is what actually moves the user — otherwise the agent says "you're now on the
74-
* page" and nothing happened. `seen` is mutated with the calls handled, and is
75-
* seeded with the transcript loaded at mount so opening an old chat never
76-
* navigates on history.
77-
*/
78-
export function pendingNavigateIntents(
79-
messages: ReadonlyArray<ToolMessage>,
80-
seen: Set<string>
81-
): AgentIntent[] {
82-
const intents: AgentIntent[] = [];
83-
84-
for (const message of messages) {
85-
const parts = message.parts ?? [];
86-
for (let i = 0; i < parts.length; i++) {
87-
const part = parts[i] as ToolPart;
88-
if (part?.type !== "tool-navigate_to" || part.state !== "output-available") continue;
89-
90-
const key = part.toolCallId ?? `${message.id}:${i}`;
91-
if (seen.has(key)) continue;
92-
seen.add(key);
93-
94-
const output = part.output as { intent?: unknown } | undefined;
95-
const parsed = agentIntentSchema.safeParse(output?.intent);
96-
if (parsed.success && parsed.data.kind === "navigate") intents.push(parsed.data);
97-
}
98-
}
99-
100-
return intents;
101-
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from "vitest";
2+
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
3+
4+
describe("pendingNavigateIntents", () => {
5+
const uri = "trigger://proj_abc/env_123/run/run_abc";
6+
const toolPart = (toolCallId: string, state = "output-available") => ({
7+
type: "tool-navigate_to",
8+
state,
9+
toolCallId,
10+
output: { intent: { kind: "navigate", target: uri } },
11+
});
12+
13+
it("returns the intent from a completed navigate_to call, once", () => {
14+
const seen = new Set<string>();
15+
const messages = [{ id: "m1", parts: [toolPart("call-1")] }];
16+
17+
expect(pendingNavigateIntents(messages, seen)).toEqual([{ kind: "navigate", target: uri }]);
18+
expect(pendingNavigateIntents(messages, seen)).toEqual([]);
19+
});
20+
21+
it("ignores a call that hasn't produced output yet", () => {
22+
expect(
23+
pendingNavigateIntents(
24+
[{ id: "m1", parts: [toolPart("call-1", "input-available")] }],
25+
new Set()
26+
)
27+
).toEqual([]);
28+
});
29+
30+
it("ignores output that isn't a navigate intent", () => {
31+
const messages = [
32+
{ id: "m1", parts: [{ ...toolPart("call-1"), output: { error: "nowhere to go" } }] },
33+
{ id: "m2", parts: [{ type: "text", text: "hello" }] },
34+
];
35+
36+
expect(pendingNavigateIntents(messages, new Set())).toEqual([]);
37+
});
38+
39+
it("skips calls seeded as already seen (loaded history)", () => {
40+
const history = [{ id: "m1", parts: [toolPart("call-1")] }];
41+
const seen = new Set<string>();
42+
pendingNavigateIntents(history, seen);
43+
44+
expect(
45+
pendingNavigateIntents([...history, { id: "m2", parts: [toolPart("call-2")] }], seen)
46+
).toEqual([{ kind: "navigate", target: uri }]);
47+
});
48+
});
49+
50+
describe("pendingWatchIntents", () => {
51+
const spec = {
52+
kind: "run_finished",
53+
runId: "run_abc",
54+
checkEveryMinutes: 1,
55+
maxHours: 2,
56+
note: "tell me when the receipt run finishes",
57+
};
58+
const toolPart = (toolCallId: string, state = "output-available") => ({
59+
type: "tool-schedule_watch",
60+
state,
61+
toolCallId,
62+
output: { intent: { kind: "watch", spec } },
63+
});
64+
65+
it("returns the proposed spec from a completed schedule_watch call, once", () => {
66+
const seen = new Set<string>();
67+
const messages = [{ id: "m1", parts: [toolPart("call-1")] }];
68+
69+
expect(pendingWatchIntents(messages, seen)).toEqual([{ kind: "watch", spec }]);
70+
expect(pendingWatchIntents(messages, seen)).toEqual([]);
71+
});
72+
73+
it("ignores a call still running, and a spec the contract rejects", () => {
74+
expect(
75+
pendingWatchIntents([{ id: "m1", parts: [toolPart("call-1", "input-available")] }], new Set())
76+
).toEqual([]);
77+
78+
const invalid = [
79+
{
80+
id: "m1",
81+
parts: [
82+
{
83+
...toolPart("call-2"),
84+
output: { intent: { kind: "watch", spec: { kind: "run_finished" } } },
85+
},
86+
],
87+
},
88+
];
89+
expect(pendingWatchIntents(invalid, new Set())).toEqual([]);
90+
});
91+
92+
// The card must not reopen when an old chat is loaded: replaying a transcript
93+
// that already holds the proposal is not a new request.
94+
it("never reopens a proposal seeded from loaded history", () => {
95+
const history = [{ id: "m1", parts: [toolPart("call-1")] }];
96+
const seen = new Set<string>();
97+
pendingWatchIntents(history, seen);
98+
99+
expect(pendingWatchIntents(history, seen)).toEqual([]);
100+
expect(
101+
pendingWatchIntents([...history, { id: "m2", parts: [toolPart("call-2")] }], seen)
102+
).toEqual([{ kind: "watch", spec }]);
103+
});
104+
105+
it("doesn't confuse a navigate result for a watch", () => {
106+
const messages = [
107+
{
108+
id: "m1",
109+
parts: [
110+
{
111+
type: "tool-navigate_to",
112+
state: "output-available",
113+
toolCallId: "call-1",
114+
output: { intent: { kind: "navigate", target: "trigger://p/e/run/run_abc" } },
115+
},
116+
],
117+
},
118+
];
119+
120+
expect(pendingWatchIntents(messages, new Set())).toEqual([]);
121+
});
122+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* The intents the agent emitted as tool results that the host hasn't honoured
3+
* yet.
4+
*
5+
* A tool that emits an intent performs nothing — the panel is what acts, so what
6+
* the agent then narrates ("you're now on…", "I've filled in a watch") is what
7+
* actually happened. `seen` is mutated with the calls handled, and is seeded with
8+
* the transcript loaded at mount, so opening an old chat never re-fires on
9+
* history: only calls that land while this chat is open are honoured, once each.
10+
*/
11+
import { agentIntentSchema, type AgentIntent } from "@internal/dashboard-agent-contracts";
12+
13+
type ToolPart = { type?: string; state?: string; toolCallId?: string; output?: unknown };
14+
type ToolMessage = { id: string; parts?: ReadonlyArray<unknown> };
15+
16+
function pendingToolIntents<Kind extends AgentIntent["kind"]>(
17+
messages: ReadonlyArray<ToolMessage>,
18+
seen: Set<string>,
19+
toolType: string,
20+
kind: Kind
21+
): Array<Extract<AgentIntent, { kind: Kind }>> {
22+
const intents: Array<Extract<AgentIntent, { kind: Kind }>> = [];
23+
24+
for (const message of messages) {
25+
const parts = message.parts ?? [];
26+
for (let i = 0; i < parts.length; i++) {
27+
const part = parts[i] as ToolPart;
28+
if (part?.type !== toolType || part.state !== "output-available") continue;
29+
30+
const key = part.toolCallId ?? `${message.id}:${i}`;
31+
if (seen.has(key)) continue;
32+
seen.add(key);
33+
34+
const output = part.output as { intent?: unknown } | undefined;
35+
const parsed = agentIntentSchema.safeParse(output?.intent);
36+
if (parsed.success && parsed.data.kind === kind) {
37+
intents.push(parsed.data as Extract<AgentIntent, { kind: Kind }>);
38+
}
39+
}
40+
}
41+
42+
return intents;
43+
}
44+
45+
/** Where `navigate_to` asked the panel to take the user. */
46+
export function pendingNavigateIntents(
47+
messages: ReadonlyArray<ToolMessage>,
48+
seen: Set<string>
49+
): Array<Extract<AgentIntent, { kind: "navigate" }>> {
50+
return pendingToolIntents(messages, seen, "tool-navigate_to", "navigate");
51+
}
52+
53+
/**
54+
* The watches `schedule_watch` proposed. The tool never creates one: the spec
55+
* comes back for the panel to open the configuration card pre-filled, so a
56+
* free-text ask lands on the same review card as the contextual action.
57+
*/
58+
export function pendingWatchIntents(
59+
messages: ReadonlyArray<ToolMessage>,
60+
seen: Set<string>
61+
): Array<Extract<AgentIntent, { kind: "watch" }>> {
62+
return pendingToolIntents(messages, seen, "tool-schedule_watch", "watch");
63+
}

apps/webapp/app/components/dashboard-agent/tool-labels.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const TOOL_LABELS: Record<string, string> = {
3131
search_docs: "Searching the docs",
3232
get_current_page: "Reading the current page",
3333
navigate_to: "Opening the page",
34-
schedule_watch: "Setting up a watch",
34+
schedule_watch: "Filling in a watch",
3535
list_alerts: "Listing alerts",
3636
create_alert: "Creating an alert",
3737
delete_alert: "Deleting an alert",

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

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -268,16 +268,6 @@ const FIXTURES: Record<string, unknown> = {
268268
pullRequestTitle: "Batch the receipt sends",
269269
},
270270
},
271-
// A scheduled watch, as the host returns it: the id, the thing it watches, and
272-
// when it gives up. No immediate outcome, so the answer must promise a message.
273-
schedule_watch: {
274-
watchId: "watch_eval1",
275-
identity: "run_finished:run_a1",
276-
status: "active",
277-
expiresAt: "2026-01-02T01:00:00.000Z",
278-
checkEveryMinutes: 1,
279-
watching: true,
280-
},
281271
search_docs: {
282272
results:
283273
"batchTrigger() triggers many runs of the same task in one call. It takes an array of payloads and returns a batch handle; use batchTriggerAndWait() inside a task to wait for all of them.",
@@ -313,6 +303,11 @@ function makeFixtureTools(
313303
// navigate_to doesn't fetch anything: the real one echoes the intent it
314304
// built, so the fixture does too.
315305
if (name === "navigate_to") return input;
306+
// schedule_watch creates nothing either: it hands the spec back as the
307+
// intent that opens the pre-filled card.
308+
if (name === "schedule_watch") {
309+
return { intent: { kind: "watch", spec: (input as { watch?: unknown }).watch } };
310+
}
316311
// render_view echoes the spec and — for an investigation — reports the
317312
// identity the store assigned, which is what the model carries forward.
318313
if (name === "render_view") {

0 commit comments

Comments
 (0)