Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-berthd/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Read only. It checks the port answers with the app, that the PID in `/tmp/verify
node .claude/skills/verify-berthd/scripts/drive.mjs <scenario> --port $PORT [--out DIR]
```

`<scenario>` is a file in `scripts/scenarios/` (by name, without `.mjs`) or a path. With `--agent http://127.0.0.1:9378` and `VERIFY_BERTH_TOKEN` in the environment it opens the app on that laptop agent instead of the mock (a scenario that needs it exports `live = true`), and gives the scenario `call(path, init)`, the agent's API (a box's own under `/v1/boxes/<box>/api/`), to read a change's effect on the box. The driver:
`<scenario>` is a file in `scripts/scenarios/` (by name, without `.mjs`) or a path. With `--agent http://127.0.0.1:9378` and `VERIFY_BERTH_TOKEN` in the environment it opens the app on that laptop agent instead of the mock (a scenario that needs it exports `live = true`), and gives the scenario `call(path, init)`, the agent's API (a box's own under `/v1/boxes/<box>/api/`), to read a change's effect on the box (JSON; with `init.raw`, `{ type, bytes }` for a body that isn't). The driver:

- opens `http://localhost:$PORT/?mock=1` at 1440×900 in dark mode, and waits until the sidebar's `nav-home` is there;
- refuses every request to the laptop agent's ports (1377-1379), as the e2e fixtures do;
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) |
| Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it |
| Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock |
| Tool calls in chat: a turn's steps folded and grouped, a call opened to its detail, the pictures a call returned (from the record, at full size on a click) | [chat-tool-calls.md](chat-tool-calls.md) | `chat-tool-image-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock |
| Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it |
| Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box |
| The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it |
Expand Down
40 changes: 40 additions & 0 deletions .claude/skills/verify-berthd/features/chat-tool-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Chat: tool calls, opened

An agent's steps fold into the chat. A turn's steps sit under one line (**Working · read 2 files**, then **Worked …** once it ends); under it, calls of a kind group (**Read 2 files**, **Searched 3 times**, **Ran 2 commands**), and each call is a row: its verb, its target, and a chevron. Opening a row asks the box for that call's detail (GET `…/sessions/{name}/transcript/tool/{id}`; a helper's `…/subagents/{id}/tool/{tool}`) and draws it as the terminal does: a command and its output, a search and its matches, an edit's change, a written file (`app/src/components/conversation/conversation-view.tsx` `Tools`, `ToolRow`, `ToolDetailView`; `internal/transcript/detail.go`).

A call that returned pictures (a Read of a .png or .jpg, a screenshot tool) shows each picture where the terminal says `[image]`, from the conversation's own record, not the file: it shows for files outside the worktree, changed or deleted since. The detail only names them (`images: [{n, line, mediaType}]`, `line` being where each stands in the output: its own `[image]` line, or the `… more lines …` line when the middle of a long output is cut, so a text block that itself reads `[image]` stays text); the app fetches each one's bytes once the row is open (GET `…/tool/{id}/image/{n}`, through the agent with its token, as a blob URL), as a thumbnail up to 320 px tall that opens at full size (`tool-image.tsx`, `lib/tool-images.ts`). Only PNG, JPEG, GIF and WebP are served; anything else, or a picture the box can't read back (the record rotated past the box's 32 MB window), stays the text `[image]`.

## Sub-features

- `tool-groups`: a turn's steps fold under one line; calls group by kind with a count; a group still running opens, and folds to its summary a moment after it finishes in view.
- `tool-detail`: a row opens to its detail, read when opened; a call that is gone from the record says so.
- `tool-images`: a call's pictures in place of its `[image]` lines, fetched only once opened; a click opens one at full size, Escape closes it; the picture is the record's, so it outlives the file.

## How to get to it (user POV)

- Open an agent in chat, open **Worked …** and a group such as **Read 3 files**, then a row.
- On the mock: `gpu/shop`'s chat, **Read 3 files**: `webhook.ts` opens to its lines, `checkout-result.png` to a drawn checkout page.
- On a real box: ask the agent to read a picture, such as a screenshot in `/tmp`.

## Driving it with drive.mjs

- Mock: `app/e2e/chat.spec.ts` asserts it ("a tool call opens to what it did", "a call that returned a picture shows it once opened, …": the picture isn't asked for before the row opens, `window.__berthToolImages`).
- Real box: `scenarios/chat-tool-image-live.mjs` (`live = true`), on a dev box of this checkout, in either driver:

```sh
VERIFY_BOX_DIR=/tmp/vb-$$ .claude/skills/verify-berthd/scripts/dev-box.sh up # or VERIFY_DRIVER=claude … up
eval "$(VERIFY_BOX_DIR=/tmp/vb-$$ .claude/skills/verify-berthd/scripts/dev-box.sh env)"
VERIFY_WORKTREE=devq/acme-shop node .claude/skills/verify-berthd/scripts/drive.mjs chat-tool-image-live --port $PORT --agent http://127.0.0.1:9378
# The build before: [image] as text. Main's berthd and main's app, same box:
VERIFY_BOX_DIR=/tmp/vb-$$ .claude/skills/verify-berthd/scripts/dev-box.sh restart <main checkout>/bin/berthd
VERIFY_EXPECT=text VERIFY_WORKTREE=devq/acme-shop node .claude/skills/verify-berthd/scripts/drive.mjs chat-tool-image-live --port <main's preview> --agent http://127.0.0.1:9378
```

It draws `/tmp/acme-shot.png` (a synthetic acme checkout page, 480x300), has Claude Code read it from the chat, and waits until that Read's detail names one PNG. Second view: the box's image route, through the agent (`call(…, { raw: true })`), gives back the very bytes of the file. Then it opens the row (the thumbnail's `naturalWidth` is 480, no `[image]` text), opens it full size and closes it, deletes the file, and opens the row again: the picture still shows.

- Go: `TestDetailNamesTheImagesAResultHoldsButNotTheirBytes` and `TestAToolsImagesStandAtTheirOwnLines` (`internal/transcript`), `TestAToolsImagesAreServedFromTheTranscript` (`internal/box`: the session's and a helper's route, a bad index and an unknown call are 404).

## Gotchas

- A terminal agent's folder that Claude Code already trusts gets dev-box.sh's Up+Enter as a prompt, which re-sends the last one: on a second `up` in the same `VERIFY_BOX_DIR` the chat has an extra Read. The scenario waits for a Read after its own prompt, so it still holds; for clean screenshots use a fresh `VERIFY_BOX_DIR`, or the structured driver.
- The pictures are never in the detail itself, so a chat's 2 s polling and the 32 KB output cap never carry image bytes. The box marks them cacheable (`private, immutable`): a row opened again may not ask the box at all.
6 changes: 4 additions & 2 deletions .claude/skills/verify-berthd/scripts/drive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ const shot = async (label) => {

// call(path, init) is the laptop agent's API as the app calls it (a box's
// own under /v1/boxes/<box>/api/), for a live scenario to read a change's
// effect on the box.
const call = async (path, init = {}) => {
// effect on the box. init.raw answers with the body's bytes and type
// instead of its JSON.
const call = async (path, { raw, ...init } = {}) => {
const res = await fetch(`${agent}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init.headers } });
if (!res.ok) throw new Error(redact(`${init.method ?? "GET"} ${path}: ${res.status} ${await res.text()}`));
if (raw) return { type: res.headers.get("content-type"), bytes: Buffer.from(await res.arrayBuffer()) };
return res.json();
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// chat-tool-image-live: a real Claude Code on a dev box (../dev-box.sh up)
// reads a picture outside its worktree, /tmp/acme-shot.png, drawn here.
// Its Read opens to the picture itself, read back from the conversation's
// record by the box (GET …/transcript/tool/{id}/image/0), and the picture
// opens at full size. Then the file is deleted and the call opened again:
// the picture still shows, so it never came from the file.
// See ../../features/chat-tool-calls.md.
//
// VERIFY_WORKTREE the worktree row's box/name (devq/acme-shop).
// VERIFY_SESSION its agent's session (ask).
// VERIFY_EXPECT image (the default) or text: the build before, which
// shows the call's output as the text "[image]".
import { existsSync, rmSync, writeFileSync } from "node:fs";

export const live = true;
export const params = { view: "conversation", sidebar: "list" };

const SHOT = "/tmp/acme-shot.png";
const WIDTH = 480;
const HEIGHT = 300;

export async function run({ page, shot, step, expect, call }) {
const expectNow = process.env.VERIFY_EXPECT ?? "image";
const worktree = process.env.VERIFY_WORKTREE;
if (!worktree) throw new Error("set VERIFY_WORKTREE to the worktree row's box/name");
const [box] = worktree.split("/");

step(`draw ${SHOT}: an acme checkout page, ${WIDTH}x${HEIGHT}`);
const png = await page.evaluate(
([w, h]) => {
const c = document.createElement("canvas");
c.width = w;
c.height = h;
const g = c.getContext("2d");
g.fillStyle = "#f8fafc";
g.fillRect(0, 0, w, h);
g.fillStyle = "#0f172a";
g.fillRect(0, 0, w, 48);
g.fillStyle = "#fff";
g.font = "bold 20px sans-serif";
g.fillText("acme shop", 18, 31);
g.fillStyle = "#16a34a";
g.beginPath();
g.arc(w / 2, 130, 36, 0, Math.PI * 2);
g.fill();
g.fillStyle = "#0f172a";
g.font = "bold 24px sans-serif";
g.textAlign = "center";
g.fillText("Order confirmed", w / 2, 210);
g.font = "16px sans-serif";
g.fillStyle = "#475569";
g.fillText("Order #1042 · 3 items · $84.00", w / 2, 240);
return c.toDataURL("image/png").split(",")[1];
},
[WIDTH, HEIGHT],
);
const bytes = Buffer.from(png, "base64");
writeFileSync(SHOT, bytes);

step("open the agent's chat");
await page.locator(`[data-testid=worktree-row][data-worktree="${worktree}"]`).click({ position: { x: 32, y: 12 } });
const pane = page.locator("[data-testid=pane]:visible").first();
const chat = pane.getByTestId("chat");
await expect(chat).toBeVisible({ timeout: 30_000 });
let reply = pane.getByRole("textbox", { name: "What should Claude Code do?", exact: true });
try {
await expect(reply).toBeEditable({ timeout: 15_000 });
} catch {
reply = pane.getByTestId("composer").getByRole("textbox", { name: "Reply" });
}
const session = process.env.VERIFY_SESSION ?? "ask";
const api = `/v1/boxes/${box}/api/sessions/${encodeURIComponent(session)}`;
// Its Reads of the picture so far, so this run's is the one after them.
const reads = async () => {
const res = await call(`${api}/transcript?since=0`);
return (res.items ?? []).flatMap((it) => (it.kind === "tools" ? (it.items ?? []) : [])).filter((c) => c.target?.includes("acme-shot.png") && c.id);
};
const before = (await reads()).length;
step(`ask Claude to read ${SHOT}`);
await reply.fill(`Use the Read tool on ${SHOT}, then say in one short sentence what it shows. Do nothing else.`);
await reply.press("Enter");

step("its Read of the picture is in the chat, answered");
let id;
await expect
.poll(
async () => {
const all = await reads();
if (all.length <= before) return "no read yet";
id = all[all.length - 1].id;
const d = await call(`${api}/transcript/tool/${encodeURIComponent(id)}`);
return d.pending ? "pending" : JSON.stringify(d.images ?? []);
},
{ timeout: 240_000, intervals: [3_000] },
)
.toBe(expectNow === "image" ? JSON.stringify([{ n: 0, line: 0, mediaType: "image/png" }]) : "[]");

if (expectNow === "image") {
step("second view: the box serves the very bytes Claude read, from its record");
const got = await call(`${api}/transcript/tool/${encodeURIComponent(id)}/image/0`, { raw: true });
if (got.type !== "image/png" || !got.bytes.equals(bytes)) throw new Error(`image 0 is ${got.type}, ${got.bytes.length} bytes; the file is ${bytes.length}`);
}

// The steps fold away once the turn ends: open them, then the Read.
const row = chat.getByRole("button", { name: /^Read\b.*acme-shot\.png$/ }).last();
const openRead = async () => {
const fold = chat.getByRole("button", { name: /^Work(ed|ing)/ }).last();
if ((await fold.count()) && (await fold.getAttribute("aria-expanded")) === "false") await fold.click();
const group = chat.getByRole("button", { name: /^Read \d+ files?/ }).last();
await expect(group).toBeVisible({ timeout: 30_000 });
if ((await group.getAttribute("aria-expanded")) !== "true") await group.click();
await expect(row).toBeVisible();
if ((await row.getAttribute("aria-expanded")) !== "true") await row.click();
await expect(row).toHaveAttribute("aria-expanded", "true");
};
step("open the steps, then the Read");
await openRead();

if (expectNow === "text") {
await expect(chat.getByText("[image]", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(chat.getByTestId("tool-image")).toHaveCount(0);
await shot("1-read-shows-text");
rmSync(SHOT, { force: true });
return;
}
if (expectNow !== "image") throw new Error("set VERIFY_EXPECT to image or text");

const thumb = chat.getByTestId("tool-image").last();
await expect(thumb).toBeVisible({ timeout: 15_000 });
await expect.poll(() => thumb.locator("img").evaluate((el) => el.naturalWidth)).toBe(WIDTH);
await expect(row.locator("xpath=..").getByText("[image]", { exact: true })).toHaveCount(0);
await row.scrollIntoViewIfNeeded();
await shot("1-read-shows-picture");

step("the picture opens at full size, and closes with Escape");
await thumb.click();
const full = page.getByTestId("tool-image-full");
await expect(full).toBeVisible();
await expect.poll(() => full.locator("img").evaluate((el) => el.naturalWidth)).toBe(WIDTH);
await shot("2-full-size");
await page.keyboard.press("Escape");
await expect(full).toBeHidden();

step(`delete ${SHOT}, close and open the Read again: the picture is the record's`);
rmSync(SHOT, { force: true });
if (existsSync(SHOT)) throw new Error(`${SHOT} is still there`);
await expect(row).toBeVisible();
if ((await row.getAttribute("aria-expanded")) === "true") await row.click();
await expect(row).toHaveAttribute("aria-expanded", "false");
await openRead();
await expect.poll(() => chat.getByTestId("tool-image").last().locator("img").evaluate((el) => el.naturalWidth)).toBe(WIDTH);
await shot("3-after-the-file-is-gone");
}
27 changes: 26 additions & 1 deletion app/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,39 @@ test("a tool call opens to what it did", async ({ app }) => {
const fold = app.chat.getByRole("button", { name: /^Work(ed|ing)/ }).first();
await fold.click();
await expect(fold).toHaveAttribute("aria-expanded", "true");
const group = app.chat.getByRole("button", { name: /Read 2 files/ });
const group = app.chat.getByRole("button", { name: /Read 3 files/ });
await group.click();
const call = app.chat.getByRole("button", { name: /Read\s*ts\s*webhook\.ts/ });
await call.click();
await expect(call).toHaveAttribute("aria-expanded", "true");
await expect(app.chat.getByText("export async function handleWebhook(req: Request)")).toBeVisible();
});

test("a call that returned a picture shows it once opened, and opens it at full size", async ({ app }) => {
mockOnly();
await app.openWorktree("gpu/shop");
const fold = app.chat.getByRole("button", { name: /^Work(ed|ing)/ }).first();
await fold.click();
await app.chat.getByRole("button", { name: /Read 3 files/ }).click();
const call = app.chat.getByRole("button", { name: /^Read\b.*checkout-result\.png$/ });
await expect(call).toBeVisible();
// Nothing is fetched for a call that stays closed.
const asked = () => app.page.evaluate(() => (window as unknown as { __berthToolImages?: string[] }).__berthToolImages ?? []);
expect(await asked()).toEqual([]);
await call.click();
const thumb = app.chat.getByTestId("tool-image");
await expect(thumb).toBeVisible();
await expect.poll(() => thumb.locator("img").evaluate((el: HTMLImageElement) => el.naturalWidth)).toBe(640);
expect(await asked()).toEqual(["mock-read-shot/0"]);
await expect(app.chat.getByText("[image]", { exact: true })).toHaveCount(0);
await thumb.click();
const full = app.page.getByTestId("tool-image-full");
await expect(full).toBeVisible();
await expect.poll(() => full.locator("img").evaluate((el: HTMLImageElement) => el.naturalWidth)).toBe(640);
await app.page.keyboard.press("Escape");
await expect(full).toBeHidden();
});

test("a form of questions steps through to a review and submits", async ({ app }) => {
mockOnly("answers a question on a box");
await app.openWorktree("gpu/shop");
Expand Down
1 change: 1 addition & 0 deletions app/src/components/conversation/conversation-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS
return {
load: (file) => boxApi.diff(client, box, session, file),
tool: (id) => (mock ? mockConversation().then((m) => m.mockToolDetail(id)) : boxApi.toolDetail(client, box, session, id)),
toolImage: (id, n) => (mock ? mockConversation().then((m) => m.mockToolImage(id, n)) : boxApi.toolImage(client, box, session, id, n)),
comments: (file) =>
reviewKey
? {
Expand Down
Loading