Put a tool call on the row that names it

e7cc33068cce · AtlantisPleb · · parent 4669feeeb455

Put a tool call on the row that names it

A collapsed call took three rows: the tool's name, its arguments as raw JSON,
and its result. The middle row is the one nobody reads as JSON —

    ✓ openagents
    {"args":["issue","view","212","-R","OpenAgentsInc/openagents.com"]}
    → #212 Rename the API to /api/v1 …

— because it is a command line wearing a costume, and eight of them in a row is
a screen of punctuation with the answers pushed off the bottom.

The call joins the row that names it, in the shape a person would have typed:

    ✓ openagents issue view 212 -R OpenAgentsInc/openagents.com
    → #212 Rename the API to /api/v1 …

An argument vector is joined back into its command line, which is the case this
exists for; a `command`, `path`, `pattern`, or `name` is shown as itself; and
anything else keeps every field without the JSON punctuation, so `delegate`
still reads `count=3 description="audit the providers"`. An argument with a
space in it is quoted, so it still reads as one argument rather than two.

Nothing invents a summary it cannot make. Arguments that are not JSON yet —
a call is streamed a fragment at a time — show as the raw text they are, which
is what the row did before, rather than going blank and back.

`ctrl+o` still shows the arguments whole. That is what expanding a call is for,
and it is where the JSON belongs.

706 tests pass, where 697 did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • added packages/openagents-cli/src/coder-tool-summary.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-tool-summary.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

6 files changed, +164 -11

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2457,
7
    "filesScanned": 2458,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:3efa9aed58a255fc524c7fb0c01478b9eb7afaf93cc50ffa7baddacd4590c65d",
4
  "sourceDigest": "sha256:d492fe3229c64f1d4b8d114ab4ed45bef787f8e7fc2277927d6b9d60491c4ee2",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (57 tracked test files)"
1879
          "ref": "packages/openagents-cli (58 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-tool-summary.ts added +71

@@ -0,0 +1,71 @@

1
/**
2
 * A tool call in one line, for the row that names it.
3
 *
4
 * A collapsed call took three rows: the tool's name, its arguments as raw
5
 * JSON, and its result. The middle row is the one nobody reads as JSON —
6
 * `{"args":["issue","view","212","-R","OpenAgentsInc/openagents.com"]}` is a
7
 * command line wearing a costume, and eight of them in a row is a screen of
8
 * punctuation with the answers pushed off the bottom.
9
 *
10
 * So the call joins the row that names it, in the shape a person would have
11
 * typed: `openagents issue view 212 -R OpenAgentsInc/openagents.com`. The raw
12
 * arguments are still there under `ctrl+o`, which is what expanding a call is
13
 * for.
14
 *
15
 * Nothing here invents a summary it cannot make. A tool whose arguments have no
16
 * obvious subject falls back to the JSON, clipped by the caller, which is what
17
 * the row showed before.
18
 */
19
20
/** Fields worth showing whole, in the order a tool would mean them. */
21
const SUBJECTS = ["command", "path", "file", "pattern", "query", "name"] as const;
22
23
export const summarizeToolCall = (args: string): string => {
24
  const trimmed = args.trim();
25
  if (trimmed.length === 0) return "";
26
27
  let parsed: unknown;
28
  try {
29
    parsed = JSON.parse(trimmed);
30
  } catch {
31
    // Arguments still streaming in, or a tool that never sent JSON. The raw
32
    // text is better than nothing and the caller clips it.
33
    return trimmed;
34
  }
35
36
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return trimmed;
37
  const record = parsed as Record<string, unknown>;
38
39
  // An argument vector, which is the case this exists for: the `openagents`
40
  // tool takes the command line as a list, and joining it back is the whole
41
  // translation.
42
  const vector = record["args"];
43
  if (Array.isArray(vector) && vector.every((part) => typeof part === "string")) {
44
    return (vector as ReadonlyArray<string>).map(quote).join(" ");
45
  }
46
47
  for (const key of SUBJECTS) {
48
    const value = record[key];
49
    if (typeof value === "string" && value.length > 0) return value;
50
  }
51
52
  // Nothing named. Keep every field, without the JSON punctuation that made
53
  // the row unreadable.
54
  const pairs = Object.entries(record).filter(([, value]) => value !== undefined);
55
  if (pairs.length === 0) return "";
56
57
  return pairs.map(([key, value]) => `${key}=${scalar(value)}`).join(" ");
58
};
59
60
/**
61
 * A shell-style quote, so a line with a space in it still reads as one
62
 * argument rather than as two.
63
 */
64
const quote = (part: string): string =>
65
  part.length === 0 || /[\s"']/.test(part) ? JSON.stringify(part) : part;
66
67
const scalar = (value: unknown): string => {
68
  if (typeof value === "string") return quote(value);
69
  if (typeof value === "number" || typeof value === "boolean") return String(value);
70
  return JSON.stringify(value);
71
};
packages/openagents-cli/src/coder-ui.ts modified +11 -3

@@ -36,6 +36,7 @@

36 36
 */
37 37
38 38
import { readChildTranscript } from "./coder-child-transcript.js";
39
import { summarizeToolCall } from "./coder-tool-summary.js";
39 40
import { activityPhrase, fleetRows, latestActivities, taskActivity } from "./coder-fleet.js";
40 41
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
41 42
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";

@@ -504,7 +505,16 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

504 505
          : tool.status === "failed"
505 506
            ? `${RED}✗${RESET}`
506 507
            : `${GREEN}✓${RESET}`;
507
      const rows = [`${mark} ${BOLD}${tool.name}${RESET}`];
508
      // The call on the row that names it, in the shape a person would have
509
      // typed. It used to sit below as raw JSON, which cost a row per call and
510
      // read as punctuation; `ctrl+o` still shows the arguments whole.
511
      const summary = clip(
512
        summarizeToolCall(tool.arguments),
513
        Math.max(8, width - tool.name.length - 4),
514
      );
515
      const rows = [
516
        `${mark} ${BOLD}${tool.name}${RESET}` + (summary.length === 0 ? "" : ` ${DIM}${summary}${RESET}`),
517
      ];
508 518
509 519
      if (tool.name === "delegate" && tool.status === "running") {
510 520
        // Working children first when there are more than fit: a finished child

@@ -557,8 +567,6 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

557 567
      }
558 568
559 569
      if (tool.name !== "delegate" || tool.status !== "running") {
560
        const args = clip(tool.arguments, Math.max(8, width - 4));
561
        if (args.length > 0) rows.push(`${DIM}${args}${RESET}`);
562 570
        const outcome =
563 571
          tool.error !== undefined
564 572
            ? `${RED}${clip(tool.error, Math.max(8, width - 4))}${RESET}`
packages/openagents-cli/test/coder-tool-summary.test.ts added +54

@@ -0,0 +1,54 @@

1
import { describe, expect, it } from "vitest";
2
3
import { summarizeToolCall } from "../src/coder-tool-summary.js";
4
5
describe("a tool call in one line", () => {
6
  it("turns an argument vector back into the command line it was", () => {
7
    // The case this exists for. Eight of these in a row was a screen of
8
    // punctuation with the answers pushed off the bottom.
9
    expect(
10
      summarizeToolCall(`{"args":["issue","view","212","-R","OpenAgentsInc/openagents.com"]}`),
11
    ).toBe("issue view 212 -R OpenAgentsInc/openagents.com");
12
  });
13
14
  it("quotes an argument with a space, so it still reads as one", () => {
15
    expect(summarizeToolCall(`{"args":["issue","create","--title","two words"]}`)).toBe(
16
      `issue create --title "two words"`,
17
    );
18
  });
19
20
  it("shows a command as the command", () => {
21
    expect(summarizeToolCall(`{"command":"ls -la docs/"}`)).toBe("ls -la docs/");
22
  });
23
24
  it("prefers the subject over the tool's other settings", () => {
25
    // `timeout_seconds` is a knob; the command is what the reader is reading
26
    // for.
27
    expect(summarizeToolCall(`{"timeout_seconds":600,"command":"pnpm test"}`)).toBe("pnpm test");
28
  });
29
30
  it("names a skill, a path, and a pattern", () => {
31
    expect(summarizeToolCall(`{"name":"superdelegate"}`)).toBe("superdelegate");
32
    expect(summarizeToolCall(`{"path":"lib/a.ex"}`)).toBe("lib/a.ex");
33
    expect(summarizeToolCall(`{"pattern":"needle"}`)).toBe("needle");
34
  });
35
36
  it("drops the punctuation when nothing is named", () => {
37
    // `delegate` has no single subject, and its fields are still what a reader
38
    // wants: how many children, and what they were asked for.
39
    expect(summarizeToolCall(`{"count":3,"description":"audit the providers"}`)).toBe(
40
      `count=3 description="audit the providers"`,
41
    );
42
  });
43
44
  it("says nothing for a call with no arguments", () => {
45
    expect(summarizeToolCall("{}")).toBe("");
46
    expect(summarizeToolCall("")).toBe("");
47
  });
48
49
  it("keeps the raw text while the arguments are still arriving", () => {
50
    // A call streams in a fragment at a time, so half of it is not JSON yet.
51
    // The row shows what there is rather than going blank and back.
52
    expect(summarizeToolCall(`{"command":"ls -`)).toBe(`{"command":"ls -`);
53
  });
54
});
packages/openagents-cli/test/coder-ui.test.ts modified +25 -5

@@ -138,15 +138,35 @@ describe("runCoderUi", () => {

138 138
    expect(rows.join("\n")).not.toContain("connected:Here");
139 139
  });
140 140
141
  it("shows the tool name, its arguments, and its outcome", async () => {
141
  it("shows the tool, its call, and its outcome, on two rows", async () => {
142 142
    const { rows } = await drive([
143 143
      { type: "tool_call", callId: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
144 144
      { type: "tool_result", callId: "c1", output: '{"matches":[]}', error: undefined },
145 145
    ]);
146
    const text = rows.join("\n");
147
    expect(text).toContain("repo_grep");
148
    expect(text).toContain('{"pattern":"x"}');
149
    expect(text).toContain('{"matches":[]}');
146
147
    // The call joins the row that names it. It used to sit below as raw JSON,
148
    // which cost a row per call and read as punctuation rather than as the
149
    // command it is.
150
    const named = rows.find((row) => row.includes("repo_grep")) ?? "";
151
    expect(named).toContain("repo_grep x");
152
    expect(named).not.toContain('{"pattern"');
153
154
    expect(rows.join("\n")).toContain('{"matches":[]}');
155
  });
156
157
  it("shows an argument vector as the command line it is", async () => {
158
    const { rows } = await drive([
159
      {
160
        type: "tool_call",
161
        callId: "c1",
162
        name: "openagents",
163
        arguments: '{"args":["issue","view","212","-R","OpenAgentsInc/openagents.com"]}',
164
      },
165
      { type: "tool_result", callId: "c1", output: "#212 Rename the API", error: undefined },
166
    ]);
167
168
    const named = rows.find((row) => row.includes("openagents")) ?? "";
169
    expect(named).toContain("openagents issue view 212 -R OpenAgentsInc/openagents.com");
150 170
  });
151 171
152 172
  it("renders assistant Markdown rather than its source", async () => {

This page updates live while a promote is in flight · changelog