From 04b1aa2ac92efeab5cd5d75d014520b2478e9e82 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 20 Aug 2026 11:50:59 -0500 Subject: [PATCH] feat(runner): invoke tools from devtools --- .../issue-thread/issue-thread.spec.ts | 5 +- .../devtools/issue-thread/src/App.tsx | 7 +++ .../issue-thread/src/EvidencePanel.tsx | 60 ++++++++++++++++++- .../issue-thread/src/issue-thread.css | 22 +++++++ .../devtools/issue-thread/src/live-client.ts | 13 ++++ .../capability-issue-thread-server.mjs | 14 ++++- .../src/issue-thread/fixtures.ts | 6 +- .../src/issue-thread/live-projection.ts | 12 +++- .../src/live/clean-room-server.test.ts | 42 ++++++++++++- .../paperclip-runner/src/live/live-session.ts | 56 +++++++++++++++++ 10 files changed, 226 insertions(+), 11 deletions(-) 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 7cdb4f8660c..88f08663eec 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -362,10 +362,11 @@ test.describe("Capability issue thread", () => { await expect(page.getByTestId("replay-next-turn")).toBeDisabled(); }); - test("a terminal disposition disables the composer with a reason", async ({ page }) => { + test("a terminal disposition keeps the composer available", async ({ page }) => { await open(page, "disposition-terminal"); await expect(page.locator('[data-thread-item="disposition"]')).toContainText("Done"); - await expect(page.getByTestId("composer-reason")).toHaveText("Issue is done"); + await expect(page.locator("#composer-input")).toBeEnabled(); + await expect(page.getByText("This task is done. You can still continue the conversation.")).toBeVisible(); }); test("composer drafts survive a refresh", async ({ page }) => { diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index f5a861a842c..71e62614b9a 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -974,6 +974,13 @@ export function App() { } onClose={closeEvidence} onJumpToThread={jumpToThread} + onInvokeTool={route.mode === "live" ? async (operationId, input) => { + setActionError(null); + const next = await capabilityLiveClient.invokeTool(snapshot.sessionId, operationId, input); + setSnapshot(next.view); + setSelectedTurnId(next.toolTurnId); + return next.toolResult; + } : undefined} /> ) : null} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx index c8e5a05ff98..16c1261b62b 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx @@ -6,6 +6,7 @@ import type { CapabilityEvidenceSectionId, CapabilityEvidenceToolRow, CapabilityIssueThreadSnapshot, + CapabilityJsonValue, CapabilityToolDisposition, } from "../../../src/issue-thread/types"; import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools"; @@ -122,6 +123,7 @@ export interface EvidencePanelProps { onToggleSection: (section: CapabilityEvidenceSectionId) => void; onClose: () => void; onJumpToThread: (anchorId: string) => void; + onInvokeTool?: (operationId: string, input: CapabilityJsonValue) => Promise; } function sectionCount( @@ -145,9 +147,29 @@ function sectionCount( return String(rows.length); } -function ToolsBrowser({ records }: { records: CapabilityEvidenceModel["tools"] }) { +function starterInput(schema: { properties?: Readonly>; required?: readonly string[] } | undefined): Record { + const result: Record = {}; + for (const name of schema?.required ?? []) { + const property = schema?.properties?.[name]; + if (property?.default !== undefined) result[name] = property.default; + else if (property?.enum?.[0] !== undefined) result[name] = property.enum[0]; + else if (name === "idempotencyKey") result[name] = `devtools-${Date.now()}`; + else if (property?.type === "integer" || property?.type === "number") result[name] = 0; + else if (property?.type === "boolean") result[name] = false; + else if (property?.type === "array") result[name] = []; + else if (property?.type === "object") result[name] = {}; + else result[name] = ""; + } + return result; +} + +function ToolsBrowser({ records, onInvoke }: { records: CapabilityEvidenceModel["tools"]; onInvoke?: (operationId: string, input: CapabilityJsonValue) => Promise }) { const [collapsed, setCollapsed] = useState([]); const [selected, setSelected] = useState(null); + const [inputText, setInputText] = useState("{}"); + const [invocationResult, setInvocationResult] = useState(null); + const [invocationError, setInvocationError] = useState(null); + const [invoking, setInvoking] = useState(false); const rows = useMemo(() => { const unique = new Map(); for (const record of records) { @@ -157,6 +179,13 @@ function ToolsBrowser({ records }: { records: CapabilityEvidenceModel["tools"] } }, [records]); const descriptor = selected === null ? undefined : capabilitySemanticToolDescriptor(selected.operationId); + useEffect(() => { + if (selected === null) return; + setInputText(JSON.stringify(starterInput(descriptor?.inputSchema), null, 2)); + setInvocationResult(null); + setInvocationError(null); + }, [descriptor, selected]); + useEffect(() => { if (selected === null) return; const close = (event: KeyboardEvent) => { if (event.key === "Escape") setSelected(null); }; @@ -219,6 +248,32 @@ function ToolsBrowser({ records }: { records: CapabilityEvidenceModel["tools"] }

Input schema

{JSON.stringify(descriptor?.inputSchema ?? {}, null, 2)}
+ {onInvoke !== undefined ? ( +
+

Invoke in conversation

+

Runs through the real semantic dispatcher and records a turn, evidence, and company-state changes.

+ +