Files
OpenHands/.pr/issue-132/capture.mjs
T
Graham Neubigandopenhands e8ffdd2829 Render ACP sub-agent tool-call events in chat (port of OpenHands#13994 + #14246 + #14247) (#142)
* Render ACP sub-agent tool-call events in chat (#132)

Port of upstream OpenHands frontend PRs:
  - OpenHands#13994 — initial ACPToolCallEvent rendering
  - OpenHands#14246 — drop the 'ACP · ' prefix from titles
  - OpenHands#14247 — suppress in_progress events to avoid empty-args flash

Adds end-to-end support for the new V1 ACPToolCallEvent surfaced by ACP
sub-agents (Claude Code, Codex, Gemini CLI, …). Each tool call is rendered
through the same GenericEventMessage wrapper used for observation events,
so the resulting card shape, success indicator, and markdown formatting
match the rest of the OpenHands chat.

Highlights:
  - New ACPToolCallEvent type + isACPToolCallEvent type guard, wired into
    the OpenHandsEvent union.
  - getACPToolCallContent + getACPToolCallTitleKey helpers — execute calls
    render as Command:/Output: blocks just like getTerminalObservationContent;
    non-execute calls render their raw_input as a JSON Input: block. Errors
    use **Error:**, missing output falls back to OBSERVATION$COMMAND_NO_OUTPUT,
    and very long output is truncated to MAX_CONTENT_LENGTH.
  - getACPToolCallResult — maps status + is_error to success | error |
    undefined, mirroring getObservationResult so in_progress calls render
    without a check mark.
  - shouldRenderEvent — hides in_progress ACP events to avoid an empty-args
    card flashing before the first populated event arrives (matches #14247).
  - handleEventForUI — dedupes ACP events by tool_call_id so streaming
    in_progress → completed/failed transitions update the card in place
    instead of stacking duplicate cards.
  - EventMessage dispatch — routes ACP events to GenericEventMessageWrapper
    after hook execution events, before generic action handling.
  - Five new ACTION_MESSAGE$ACP_* i18n keys (RUN / EDIT / READ / FETCH /
    TOOL) localised across all bundled languages, with the title rendered
    inline via the existing <cmd> Trans component (no 'ACP · ' prefix per
    #14246).

Tests: 39 new/modified vitest cases across get-acp-tool-call-content,
should-render-event, handle-event-for-ui, and event-message-acp-tool-call.

Visual verification + demo GIF: .pr/issue-132/

This commit was created by an AI agent (OpenHands) on behalf of the user.

Co-authored-by: openhands <openhands@all-hands.dev>

* Capture real ACP card rendering for #142 demo

Addresses @neubig's review feedback on PR #142 ('I did not see any
examples of ACP events being rendered in the interface'):

  - Updates demo.gif to actually show three ACPToolCallEvent cards
    rendering in the live conversation UI (collapsed view + expanded
    view with Command/Output/Input blocks).
  - Adds 04-acp-cards-collapsed.png + 05-acp-cards-expanded.png as the
    individual frames, plus 04-acp-rendering-notes.md explaining what
    each frame shows and why a synthetic event injection was needed
    (no Claude Code / Codex / Gemini CLI binary in the sandbox).
  - Adds capture.mjs (the Playwright script used to record the demo) so
    future re-records are reproducible.
  - In src/stores/use-event-store.ts, exposes the existing Zustand
    store on window.__OH_EVENT_STORE__ when import.meta.env.DEV is
    true. Tree-shaken from production builds; no behaviour change for
    end users. Useful as a general dev affordance for fixture/preview
    tooling beyond this PR.

Functional verification (unchanged from the original PR):

    npx vitest run         __tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts         __tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
    Test Files  2 passed (2)
         Tests  20 passed (20)

This commit was created by an AI agent (OpenHands) on behalf of the user.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-07 20:35:11 -04:00

181 lines
6.5 KiB
JavaScript

import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
mkdirSync(".pr/issue-132", { recursive: true });
const URL = "https://work-1-yrsrggnfhzzshrxo.prod-runtime.all-hands.dev";
const OUT = ".pr/issue-132";
const CONV = "2c9236f8-7405-469a-afbe-f91788ba7e93";
const acpEvents = [
// Lead-in user message so v1UserEventsExist passes and the chat renders.
{
id: "user-evt-0",
timestamp: "2026-05-07T14:29:55.000Z",
source: "user",
kind: "MessageEvent",
llm_message: {
role: "user",
content: [
{ type: "text", text: "Use Claude Code to inspect the diff for upstream PR 14246, peek at event-message.tsx, then try to patch handle-event-for-ui.ts." },
],
},
activated_skills: [],
},
// Execute (shell command, completed) — should render as
// "ACPToolCallEvent · gh pr diff 14246" with Command/Output blocks
{
id: "acp-evt-1",
timestamp: "2026-05-07T14:30:00.000Z",
source: "agent",
kind: "ACPToolCallEvent",
tool_call_id: "tc_exec_1",
title: "gh pr diff 14246",
status: "completed",
tool_kind: "execute",
raw_input: { command: "gh pr diff 14246 --repo OpenHands/OpenHands" },
raw_output:
"diff --git a/frontend/src/components/.../acp-tool-call-card.tsx\n@@ ...\n+import { ACPToolCallEvent } from ...\n+\n+const result = getACPToolCallResult(event);\n+return <GenericEventMessage status={result} ... />;",
content: null,
is_error: false,
},
// Read (file read, completed) — rendered with JSON Input: block
{
id: "acp-evt-2",
timestamp: "2026-05-07T14:30:05.000Z",
source: "agent",
kind: "ACPToolCallEvent",
tool_call_id: "tc_read_1",
title: "src/components/v1/chat/event-message.tsx",
status: "completed",
tool_kind: "read",
raw_input: { path: "src/components/v1/chat/event-message.tsx", limit: 80 },
raw_output:
"import React from 'react';\nimport { isACPToolCallEvent } from '#/types/v1/type-guards';\n…\nif (isACPToolCallEvent(event)) {\n return <GenericEventMessageWrapper event={event} … />;\n}",
content: null,
is_error: false,
},
// Edit (failed) — rendered with **Error:** block instead of Output:
{
id: "acp-evt-3",
timestamp: "2026-05-07T14:30:10.000Z",
source: "agent",
kind: "ACPToolCallEvent",
tool_call_id: "tc_edit_1",
title: "src/utils/handle-event-for-ui.ts",
status: "failed",
tool_kind: "edit",
raw_input: { path: "src/utils/handle-event-for-ui.ts", anchor: "isACPToolCallEvent" },
raw_output: "anchor not found: isACPToolCallEvent",
content: null,
is_error: true,
},
];
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
// Intercept the events stream so the live conversation can't overwrite our
// injected uiEvents. The frontend uses an SSE-style endpoint
// /api/conversations/<id>/events for backfill. Cancel WebSocket / EventSource
// traffic too.
await page.route("**/api/conversations/*/events*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [], next_page_id: null, has_more: false }),
});
});
await page.goto(`${URL}/conversations/${CONV}`, { waitUntil: "networkidle" });
await page.waitForTimeout(2500);
// Inject the synthetic events into the dev-only window.__OH_EVENT_STORE__.
// We replace the uiEvents directly so the chat renders only our cards
// (clean demo) without interference from existing conversation traffic.
page.on("console", (msg) => console.log("[browser]", msg.type(), msg.text()));
const injected = await page.evaluate((events) => {
const store = (window).__OH_EVENT_STORE__;
if (!store?.setState) return { ok: false, reason: "store not exposed on window" };
store.setState({
events,
eventIds: new Set(events.map((e) => e.id)),
uiEvents: events,
});
console.log("OH-DEBUG store after setState:", JSON.stringify({
eventCount: store.getState().events.length,
uiEventCount: store.getState().uiEvents.length,
sample: store.getState().uiEvents[0],
}));
return { ok: true, count: events.length };
}, acpEvents);
console.log("inject:", injected);
await page.waitForTimeout(1500);
// Debug: re-read store
const after = await page.evaluate(() => {
const s = (window).__OH_EVENT_STORE__?.getState();
return {
events: s?.events?.length,
uiEvents: s?.uiEvents?.length,
sampleKind: s?.uiEvents?.[0]?.kind,
chatChildren:
document.querySelector("main, [role='main']")?.innerHTML?.slice(0, 500),
};
});
console.log("post-wait:", after);
// Hide the right-side Changes panel if open so the chat takes more width
const changesBtn = page.getByRole("button", { name: /changes/i });
if (await changesBtn.count()) {
try {
await changesBtn.first().click();
await page.waitForTimeout(400);
} catch {}
}
await page.screenshot({ path: `${OUT}/04-acp-cards-collapsed.png`, fullPage: false });
// Expand the cards by clicking each cursor-pointer header button
const headers = page.locator("button.cursor-pointer.text-left");
const count = await headers.count();
console.log("headers found:", count);
for (let i = 0; i < count; i += 1) {
try {
await headers.nth(i).click();
await page.waitForTimeout(200);
} catch (e) {
console.log("click err:", String(e).slice(0, 200));
}
}
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/05-acp-cards-expanded.png`, fullPage: false });
// Frame 3: re-collapse the first two cards so the failed Edit card moves
// into the viewport, capturing the Error block.
await page.evaluate(() => {
const headers = Array.from(document.querySelectorAll("button.cursor-pointer.text-left"));
const collapse = headers.filter(
(b) =>
b.textContent?.includes("gh pr diff 14246") ||
b.textContent?.includes("event-message.tsx"),
);
collapse.forEach((h) => h.click());
});
await page.waitForTimeout(400);
// Then expand the failed card if it isn't already
await page.evaluate(() => {
const headers = Array.from(document.querySelectorAll("button.cursor-pointer.text-left"));
const edit = headers.find((b) => b.textContent?.includes("handle-event-for-ui"));
// ensure expanded — if collapsed (chevron rotated), click to open
const chev = edit?.querySelector("[aria-expanded='false'], .rotate-0, svg");
if (edit && chev) edit.click();
});
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/06-acp-card-failed.png`, fullPage: false });
await browser.close();
console.log("done");