Skip to content

Commit 56ac177

Browse files
committed
feat(agent,webapp): code-grounded investigation evidence (review F5-F7)
- source/span evidence refs are structured fields; nothing canonicalizes silently — a bad ref fails the tool call by name; full URIs are validated for kind and project/env scope - source URIs resolve to the GitHub blob at the pinned SHA - executor-generated investigation actions: Show code only when concluded, code-addressable, and the file was read this turn at the pinned snapshot; typed follow-ups emit intents like chart actions; the card takes onIntent - the live panel resolves card evidence links through the resolve action (sync facade with a per-URI cache) - wake narration names terminal_unsatisfied as an answer, not a timeout
1 parent 03f1fa0 commit 56ac177

14 files changed

Lines changed: 990 additions & 71 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createTranscriptOrder, orderTranscript } from "./message-order";
1515
import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
1616
import type { AgentPageContext } from "./page-context-types";
1717
import { useAgentMessageQuota } from "./useAgentMessageQuota";
18+
import { useTriggerUriResolver } from "./useTriggerUriResolver";
1819
import { WatchChips, type WatchChip } from "./WatchChips";
1920

2021
/**
@@ -267,6 +268,10 @@ export function DashboardAgentChat({
267268
// owns the environment scope the resolver needs); the intent's runs-list
268269
// filters are applied on top. Same-origin, so this is a client-side navigation
269270
// — the panel lives in the env layout and survives it.
271+
// Sync facade over the same `resolve` action — evidence and card links render
272+
// as raw URIs on first paint and become links once the server answers.
273+
const resolveUri = useTriggerUriResolver(actionPath);
274+
270275
const goTo = useCallback(
271276
async (intent: Extract<AgentIntent, { kind: "navigate" }>) => {
272277
const body = new FormData();
@@ -381,6 +386,7 @@ export function DashboardAgentChat({
381386
onIntent={handleIntent}
382387
pagePaths={pagePaths}
383388
watches={watches}
389+
resolveUri={resolveUri}
384390
/>
385391
)}
386392
{/* The Free plan's message cap occupies the composer slot: at the cap the

apps/webapp/app/components/dashboard-agent/InvestigationCard.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,19 @@ describe("InvestigationCard purity", () => {
3131
expect(source).toMatch(/resolveUri/);
3232
expect(source).not.toMatch(/\/orgs\//);
3333
});
34+
35+
it("hands its actions to the host as intents, and never composes its own", () => {
36+
// The actions row is exactly the chart's seam: the button emits the block's
37+
// intent and the host decides. So the card reads `capabilities.actions` and
38+
// calls `onIntent` — it never builds a prompt or a target itself.
39+
expect(source).toMatch(/capabilities\?\.actions/);
40+
expect(source).toMatch(/onIntent\(action\.intent\)/);
41+
expect(source).not.toMatch(/kind:\s*"(ask|navigate)"/);
42+
// The same row component the rest of the chat uses, so there's one button row.
43+
expect(source).toMatch(/ChatActionsRow/);
44+
});
45+
46+
it("renders nothing action-shaped without a host to hand intents to", () => {
47+
expect(source).toMatch(/if \(!onIntent \|\| actions\.length === 0\) return null;/);
48+
});
3449
});

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
*/
2121
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
2222
import type {
23+
AgentIntent,
2324
Evidence,
2425
HypothesisVerdict,
26+
InvestigationAction,
2527
InvestigationBlock,
2628
InvestigationHypothesis,
2729
InvestigationSeverity,
@@ -37,7 +39,7 @@ import {
3739
SeverityBadge,
3840
VerdictBadge,
3941
} from "./agent-badges";
40-
import { ChatPendingTool } from "./chat-layout";
42+
import { ChatActionsRow, ChatPendingTool } from "./chat-layout";
4143
import type { ResolvedUri } from "./ReportView";
4244

4345
const SEVERITY_LABELS: Record<InvestigationSeverity, string> = {
@@ -135,15 +137,57 @@ function HypothesisRow({
135137
);
136138
}
137139

140+
/**
141+
* The card's footer actions — "Show code" and the follow-ups.
142+
*
143+
* Every one of them is server-decided: the executor only attaches an action when
144+
* the thing it offers really is available (a source location it saw read at the
145+
* pinned commit, an error group that exists). So there is nothing to validate — the
146+
* card hands the intent to the host, exactly like the chart's action row, and
147+
* renders nothing when there is no host to hand it to.
148+
*/
149+
function InvestigationActions({
150+
actions,
151+
onIntent,
152+
}: {
153+
actions: InvestigationAction[];
154+
onIntent?: (intent: AgentIntent) => void;
155+
}) {
156+
if (!onIntent || actions.length === 0) return null;
157+
return (
158+
<div className="border-t border-grid-bright pt-4">
159+
<ChatActionsRow>
160+
{actions.map((action, i) => (
161+
<Button
162+
key={action.kind}
163+
// The first action is the one to take; the rest are alternatives.
164+
variant={i === 0 ? "primary/small" : "secondary/small"}
165+
onClick={() => onIntent(action.intent)}
166+
>
167+
{action.label}
168+
</Button>
169+
))}
170+
</ChatActionsRow>
171+
</div>
172+
);
173+
}
174+
138175
export function InvestigationCard({
139176
block,
140177
/** Start expanded — used by the gallery states that review the detail view. */
141178
defaultExpanded = false,
142179
resolveUri,
180+
/**
181+
* Where the footer actions go. The card never navigates or asks on its own —
182+
* it emits an intent and the host honours it (or doesn't), the same seam the
183+
* chart's actions use. Without it the row isn't rendered.
184+
*/
185+
onIntent,
143186
}: {
144187
block: InvestigationBlock;
145188
defaultExpanded?: boolean;
146189
resolveUri?: ResolveUri;
190+
onIntent?: (intent: AgentIntent) => void;
147191
}) {
148192
const [expanded, setExpanded] = useState(defaultExpanded);
149193
const investigation = block.investigation;
@@ -242,6 +286,8 @@ export function InvestigationCard({
242286
</div>
243287
) : null}
244288
</div>
289+
290+
<InvestigationActions actions={block.capabilities?.actions ?? []} onIntent={onIntent} />
245291
</div>
246292
</div>
247293
{/* Progress lives outside the card, on the left — the same line the chat
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { isTriggerUri } from "@internal/dashboard-agent-contracts";
2+
import { useCallback, useEffect, useRef, useState } from "react";
3+
import type { ResolvedUri } from "./ReportView";
4+
5+
/**
6+
* A synchronous `resolveUri` for the cards, backed by the panel's async
7+
* `resolve` action. Resolution needs the server (environment scope, connected
8+
* repository), but the cards read links during render — so the first render of
9+
* a URI returns null (the card shows the raw URI), the answer is fetched once,
10+
* and the re-render turns it into a link. Failures cache as null, so a URI the
11+
* server can't resolve is asked about exactly once.
12+
*/
13+
export function useTriggerUriResolver(actionPath: string): (uri: string) => ResolvedUri | null {
14+
const [resolved, setResolved] = useState<Record<string, ResolvedUri | null>>({});
15+
// URIs the cards asked about this render; a ref because it's written during render.
16+
const seen = useRef(new Set<string>());
17+
const requested = useRef(new Set<string>());
18+
19+
const resolveUri = useCallback(
20+
(uri: string): ResolvedUri | null => {
21+
if (!isTriggerUri(uri)) return null;
22+
if (!(uri in resolved)) seen.current.add(uri);
23+
return resolved[uri] ?? null;
24+
},
25+
[resolved]
26+
);
27+
28+
// No dependency array on purpose: after every render, fetch whatever the
29+
// cards asked about that hasn't been requested yet.
30+
useEffect(() => {
31+
const toFetch = [...seen.current].filter((uri) => !requested.current.has(uri));
32+
for (const uri of toFetch) {
33+
requested.current.add(uri);
34+
const body = new FormData();
35+
body.set("intent", "resolve");
36+
body.set("uri", uri);
37+
fetch(actionPath, { method: "POST", body })
38+
.then((res) => (res.ok ? (res.json() as Promise<{ path?: string; label?: string }>) : null))
39+
.then((data) => {
40+
setResolved((prev) => ({
41+
...prev,
42+
[uri]: data?.path ? { url: data.path, label: data.label ?? uri } : null,
43+
}));
44+
})
45+
.catch(() => {
46+
setResolved((prev) => ({ ...prev, [uri]: null }));
47+
});
48+
}
49+
});
50+
51+
return resolveUri;
52+
}

apps/webapp/app/components/dashboard-agent/view-catalog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ export function ViewBlocks({
5959
// The one progressive block: revisions share the investigationId, so
6060
// latest-wins above keeps a single live card.
6161
case "investigation":
62-
return <InvestigationCard key={key} block={block} resolveUri={resolveUri} />;
62+
return (
63+
<InvestigationCard
64+
key={key}
65+
block={block}
66+
resolveUri={resolveUri}
67+
onIntent={onIntent}
68+
/>
69+
);
6370
case "report":
6471
return (
6572
<ReportView

apps/webapp/app/services/resolveTriggerUri.server.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,29 @@ export type TriggerUriScope = {
4343
slug: string;
4444
project: { slug: string; externalRef: string };
4545
organization: { slug: string };
46+
/**
47+
* The project's connected repository, when the caller resolved one. Only a
48+
* `source` URI needs it: the URI carries the commit and the repo-relative path,
49+
* but "which repo" lives on the project's GitHub connection
50+
* (`ConnectedGithubRepository.repository.fullName`) or on the deployment's git
51+
* metadata (`WorkerDeployment.git.remoteUrl`) — either is enough here. Omit it
52+
* and a source URI resolves to nothing, which is the honest answer for a
53+
* project with no repo connected.
54+
*/
55+
repository?: { fullName?: string | null; remoteUrl?: string | null } | null;
4656
};
4757

4858
export type ResolvedTriggerUri = {
4959
/** Short human label for the resource, e.g. a run id or a queue name. */
5060
label: string;
51-
/** Dashboard path, relative to the app origin. */
61+
/** Dashboard path, relative to the app origin — unless `external` is set. */
5262
url: string;
63+
/**
64+
* True when `url` is an absolute link off the dashboard (today: a GitHub blob
65+
* for a `source` URI). A host must open it as a link, never hand it to the
66+
* router.
67+
*/
68+
external?: boolean;
5369
};
5470

5571
/**
@@ -71,6 +87,44 @@ export function resolveTriggerUri(
7187
return resolveInScope(scope, parsed.data);
7288
}
7389

90+
const GITHUB_ORIGIN = "https://github.com";
91+
/** `owner/repo` — GitHub's own character set, and nothing that could add a path. */
92+
const FULL_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
93+
94+
/**
95+
* The repository's canonical `https://github.com/{owner}/{repo}` base, or null.
96+
*
97+
* `fullName` is the reliable input (it comes off the GitHub connection);
98+
* `remoteUrl` is the deployment's own git metadata, so it can be an SSH remote or
99+
* carry credentials and is normalized the same way the deployments UI does it
100+
* (see `BranchesPresenter`). Anything that isn't github.com is rejected rather
101+
* than guessed at — a wrong link is worse than no link.
102+
*/
103+
function githubRepoBaseUrl(repository: TriggerUriScope["repository"]): string | null {
104+
const fullName = repository?.fullName?.trim();
105+
if (fullName && FULL_NAME.test(fullName)) return `${GITHUB_ORIGIN}/${fullName}`;
106+
107+
const remoteUrl = repository?.remoteUrl?.trim();
108+
if (!remoteUrl) return null;
109+
110+
const normalized = remoteUrl
111+
.replace(/^git@github\.com:/, `${GITHUB_ORIGIN}/`)
112+
.replace(/^ssh:\/\/git@github\.com\//, `${GITHUB_ORIGIN}/`)
113+
.replace(/\.git$/, "");
114+
115+
let url: URL;
116+
try {
117+
url = new URL(normalized);
118+
} catch {
119+
return null;
120+
}
121+
if (url.hostname !== "github.com") return null;
122+
123+
const path = url.pathname.replace(/^\/+|\/+$/g, "");
124+
if (!FULL_NAME.test(path)) return null;
125+
return `${GITHUB_ORIGIN}/${path}`;
126+
}
127+
74128
/** True when the URI names this exact project and environment. */
75129
function isInScope(scope: TriggerUriScope, parsed: ParsedTriggerUri): boolean {
76130
return parsed.projectRef === scope.project.externalRef && parsed.environmentId === scope.id;
@@ -128,11 +182,26 @@ function resolveInScope(
128182
label: parsed.version,
129183
url: v3DeploymentVersionPath(organization, project, environment, parsed.version),
130184
};
185+
case "source": {
186+
// A source citation is the investigation card's code grounding, so it has
187+
// to be clickable: the URI already pins the commit and the repo-relative
188+
// path, and the connected repo says where that lives. Without a repo
189+
// connection there is nothing to open — the label still renders.
190+
const base = githubRepoBaseUrl(scope.repository);
191+
const label = parsed.line === undefined ? parsed.path : `${parsed.path}:${parsed.line}`;
192+
if (!base) return null;
193+
const path = parsed.path.split("/").map(encodeURIComponent).join("/");
194+
const fragment = parsed.line === undefined ? "" : `#L${parsed.line}`;
195+
return {
196+
label,
197+
url: `${base}/blob/${encodeURIComponent(parsed.sha)}/${path}${fragment}`,
198+
external: true,
199+
};
200+
}
131201
// No dashboard page exists for these yet. Returning null keeps the caller
132202
// honest (it renders a label with no link) instead of inventing a URL that
133203
// 404s. Add a case here the day the page ships.
134204
case "report":
135-
case "source":
136205
case "investigation":
137206
return null;
138207
default: {

0 commit comments

Comments
 (0)