diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index 9ce216c7f87..51373476c98 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -240,6 +240,22 @@ test.describe("Capability issue thread", () => { await expect(panel.getByText("Agent tool — granted")).toBeVisible(); }); + test("tool exposure groups are deduplicated, foldable, and open catalog details", async ({ page }) => { + await open(page, "debug-panel-open", { panel: "tools" }); + const panel = page.getByTestId("evidence-panel"); + const always = panel.getByRole("button", { name: /Agent tool — always/ }); + await expect(always).toHaveCount(1); + await always.click(); + await expect(panel.getByRole("button", { name: /get_task_context/ })).toHaveCount(0); + await always.click(); + await panel.getByRole("button", { name: /get_task_context/ }).click(); + const dialog = page.getByRole("dialog", { name: "Get active task context" }); + await expect(dialog).toContainText("Read the active mock task, actor, wake, ancestors, budget, and interaction results."); + await expect(dialog).toContainText("Input schema"); + await dialog.getByRole("button", { name: "Close tool details" }).click(); + await expect(dialog).toHaveCount(0); + }); + test("Evidence records link back to their thread anchor", async ({ page }) => { await open(page, "debug-panel-open", { panel: "calls" }); const panel = page.getByTestId("evidence-panel"); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx index 7ce748f36de..74d0d0b416d 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx @@ -1,15 +1,17 @@ -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { CapabilityEvidenceModel, CapabilityEvidenceRunnerRecord, CapabilityEvidenceSectionId, + CapabilityEvidenceToolRow, CapabilityIssueThreadSnapshot, CapabilityToolDisposition, } from "../../../src/issue-thread/types"; import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools"; import { DevtoolsInspector } from "./DevtoolsInspector"; import { Icon } from "./Icons"; +import { capabilitySemanticToolDescriptor } from "../../../src/semantic-tools/catalog"; import { CAPABILITY_EVIDENCE_SECTIONS, capabilityDispositionLabel, @@ -127,6 +129,10 @@ function sectionCount( section: CapabilityEvidenceSectionId, turnId: string | "all", ): string { + if (section === "tools") { + const records = filterByTurn(evidence.tools, turnId); + return String(new Set(records.flatMap((record) => record.rows.map((row) => row.operationId))).size); + } if (section === "parity") { const rows = filterByTurn(evidence.parity, turnId); const passing = rows.filter((row) => row.verdict === "pass").length; @@ -139,6 +145,87 @@ function sectionCount( return String(rows.length); } +function ToolsBrowser({ records }: { records: CapabilityEvidenceModel["tools"] }) { + const [collapsed, setCollapsed] = useState([]); + const [selected, setSelected] = useState(null); + const rows = useMemo(() => { + const unique = new Map(); + for (const record of records) { + for (const row of record.rows) unique.set(row.operationId, row); + } + return [...unique.values()]; + }, [records]); + const descriptor = selected === null ? undefined : capabilitySemanticToolDescriptor(selected.operationId); + + useEffect(() => { + if (selected === null) return; + const close = (event: KeyboardEvent) => { if (event.key === "Escape") setSelected(null); }; + document.addEventListener("keydown", close); + return () => document.removeEventListener("keydown", close); + }, [selected]); + + return ( + <> +
+ {DISPOSITION_ORDER.map((disposition) => { + const group = rows.filter((row) => row.disposition === disposition); + if (group.length === 0) return null; + const folded = collapsed.includes(disposition); + const title = disposition === "control_plane_owned" + ? "Control plane (not exposed to the agent)" + : capabilityDispositionLabel(disposition); + return ( +
+

+ +

+ {!folded ? ( +
+ {group.map((row) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ + {selected !== null ? ( +
{ if (event.target === event.currentTarget) setSelected(null); }}> +
+
+
Semantic tool · v{descriptor?.version ?? 1}

{descriptor?.title ?? selected.operationId}

+ +
+ {selected.operationId} +

{descriptor?.description ?? selected.description}

+
+
Placement
{capabilityDispositionLabel(selected.disposition)}
+
Required claims
{descriptor?.requiredClaims.join(", ") || selected.grant || "None"}
+
Task modes
{descriptor?.allowedModes.join(", ") || "—"}
+
Actor roles
{descriptor?.allowedRoles?.join(", ") || "Any allowed role"}
+
+
+

Input schema

+
{JSON.stringify(descriptor?.inputSchema ?? {}, null, 2)}
+
+
+
+ ) : null} + + ); +} + function filterByTurn( rows: ReadonlyArray, turnId: string | "all", @@ -264,35 +351,7 @@ export function EvidencePanel(props: EvidencePanelProps) { open={isOpen("tools")} onToggle={() => onToggleSection("tools")} > - {filterByTurn(evidence.tools, selectedTurnId).map((record) => ( -
- {DISPOSITION_ORDER.map((disposition) => { - const rows = record.rows.filter((row) => row.disposition === disposition); - if (rows.length === 0) return null; - return ( -
-

- {disposition === "control_plane_owned" - ? "Control plane (not exposed to the agent)" - : capabilityDispositionLabel(disposition)} -

- {rows.map((row) => ( -

- {row.operationId} - {row.grant !== null && disposition === "optional_agent_tool" ? ( - {row.grant} - ) : null} -

- ))} -
- ); - })} -
- ))} +