Skip to content

Commit 732b2e8

Browse files
committed
test(webapp): prove a dropped navigation stays dropped
The guard compared a slice against its own start, so it could not fail. Move the decision into takeNavigateIntent and drive it across two commits instead.
1 parent d9ebf95 commit 732b2e8

3 files changed

Lines changed: 88 additions & 17 deletions

File tree

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
hasOpenInvestigation,
2828
pollSettledTranscript,
2929
} from "./settled-transcript";
30-
import { navigateIntentApplies } from "./turn-navigation";
30+
import { takeNavigateIntent } from "./turn-navigation";
3131
import { teardownCancelsTurn, unmountTeardown } from "./turn-teardown";
3232
import { useAgentMessageQuota } from "./useAgentMessageQuota";
3333
import { useTriggerUriResolver } from "./useTriggerUriResolver";
@@ -306,16 +306,13 @@ export function DashboardAgentChat({
306306
}, [status]);
307307

308308
useEffect(() => {
309-
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
310-
const target = pending.at(-1);
311-
if (!target) return;
312-
// Marked handled above either way, so a dropped navigation stays dropped and the answer's
313-
// own button remains the way to take it.
314-
const applies = navigateIntentApplies({
309+
const target = takeNavigateIntent({
310+
messages,
311+
handled: navigatedRef.current!,
315312
startedPath: turnStartedPathRef.current,
316313
currentPath: renderedPathRef.current,
317314
});
318-
if (applies) void goTo(target);
315+
if (target) void goTo(target);
319316
}, [messages, goTo]);
320317

321318
const watchProposedRef = useRef<Set<string> | null>(null);

apps/webapp/app/components/dashboard-agent/turn-navigation.test.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync } from "node:fs";
22
import { describe, expect, it } from "vitest";
3-
import { navigateIntentApplies } from "./turn-navigation";
3+
import { navigateIntentApplies, takeNavigateIntent } from "./turn-navigation";
44

55
const runs = "/orgs/acme/projects/api/env/prod/runs";
66
const queues = "/orgs/acme/projects/api/env/prod/queues";
@@ -20,6 +20,62 @@ describe("navigateIntentApplies", () => {
2020
});
2121
});
2222

23+
describe("takeNavigateIntent", () => {
24+
const target = "trigger://proj_abc/env_123/run/run_abc";
25+
26+
function messages() {
27+
return [
28+
{
29+
id: "msg_1",
30+
parts: [
31+
{
32+
type: "tool-navigate_to",
33+
state: "output-available",
34+
toolCallId: "call_1",
35+
output: { intent: { kind: "navigate", target } },
36+
},
37+
],
38+
},
39+
];
40+
}
41+
42+
it("takes the navigation on the page the turn was asked for", () => {
43+
const taken = takeNavigateIntent({
44+
messages: messages(),
45+
handled: new Set(),
46+
startedPath: runs,
47+
currentPath: runs,
48+
});
49+
expect(taken).toMatchObject({ kind: "navigate", target });
50+
});
51+
52+
it("takes nothing once the user has walked to another screen", () => {
53+
expect(
54+
takeNavigateIntent({
55+
messages: messages(),
56+
handled: new Set(),
57+
startedPath: runs,
58+
currentPath: queues,
59+
})
60+
).toBeUndefined();
61+
});
62+
63+
// The property the panel depends on: a commit that drops a navigation still consumes it, so
64+
// walking back to the page it was asked on does not make it fire late.
65+
it("marks a dropped navigation handled, so a later commit cannot fire it", () => {
66+
const handled = new Set<string>();
67+
const parts = messages();
68+
69+
expect(
70+
takeNavigateIntent({ messages: parts, handled, startedPath: runs, currentPath: queues })
71+
).toBeUndefined();
72+
73+
expect(
74+
takeNavigateIntent({ messages: parts, handled, startedPath: runs, currentPath: runs })
75+
).toBeUndefined();
76+
});
77+
});
78+
2379
/**
2480
* Structural guards, not behavioural proof: whether the started-at path is still right when the
2581
* intent lands depends on effect order and on nothing clearing it, which these assertions pin
@@ -29,9 +85,9 @@ describe("the chat scopes a turn's navigation to the page it started on", () =>
2985
const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8");
3086

3187
it("gates the navigate intent on the shared rule", () => {
32-
expect(chat).toContain("navigateIntentApplies({");
88+
expect(chat).toContain("takeNavigateIntent({");
3389
expect(chat).toContain("startedPath: turnStartedPathRef.current");
34-
expect(chat).not.toContain("if (target) void goTo(target);");
90+
expect(chat).not.toContain("pendingNavigateIntents(messages");
3591
});
3692

3793
it("records the path only as a turn goes in flight", () => {
@@ -47,14 +103,11 @@ describe("the chat scopes a turn's navigation to the page it started on", () =>
47103

48104
it("records the path before the intent effect reads it", () => {
49105
expect(chat.indexOf("turnStartedPathRef.current = renderedPathRef.current")).toBeLessThan(
50-
chat.indexOf("navigateIntentApplies({")
106+
chat.indexOf("takeNavigateIntent({")
51107
);
52108
});
53109

54-
it("marks a dropped navigation handled, so it cannot fire on a later commit", () => {
55-
const effect = chat.slice(chat.indexOf("const pending = pendingNavigateIntents(messages"));
56-
expect(effect.indexOf("pendingNavigateIntents(messages")).toBeLessThan(
57-
effect.indexOf("navigateIntentApplies({")
58-
);
110+
it("hands the persistent handled-set in, so drops are recorded across commits", () => {
111+
expect(chat).toContain("handled: navigatedRef.current!");
59112
});
60113
});

apps/webapp/app/components/dashboard-agent/turn-navigation.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { pendingNavigateIntents } from "./pending-intents";
2+
13
/**
24
* The panel follows the user around the dashboard, so a turn can outlive the page it was asked
35
* on. Its navigation applies only there: someone who has since walked to another screen keeps
@@ -10,3 +12,22 @@ export function navigateIntentApplies(paths: {
1012
}): boolean {
1113
return paths.startedPath === paths.currentPath;
1214
}
15+
16+
type NavigateIntent = ReturnType<typeof pendingNavigateIntents>[number];
17+
18+
/**
19+
* The navigation to take on this commit, if any. Every intent is marked handled whether or not
20+
* it applies, so one dropped here cannot fire on a later commit.
21+
*/
22+
export function takeNavigateIntent(args: {
23+
messages: Parameters<typeof pendingNavigateIntents>[0];
24+
handled: Set<string>;
25+
startedPath: string | null;
26+
currentPath: string;
27+
}): NavigateIntent | undefined {
28+
const target = pendingNavigateIntents(args.messages, args.handled).at(-1);
29+
if (!target) return undefined;
30+
return navigateIntentApplies({ startedPath: args.startedPath, currentPath: args.currentPath })
31+
? target
32+
: undefined;
33+
}

0 commit comments

Comments
 (0)