Render a tool call as what it did, not as one phrase

09f64a78d77c · AtlantisPleb · · parent 24bc97f0b8ad

Render a tool call as what it did, not as one phrase

The fleet showed every tool the same way, so a shell command, a file
read, and a search all read alike and none of them said what they
touched. Tool activity now goes through a renderer registry: a
renderer takes an activity and a width and returns bounded display
rows, keyed by tool name.

Three renderers to start, chosen where the argument carries the
meaning: shell and bash show the command, file reads and writes show
the path with the range or size, and search shows the pattern with a
hit count. Names cover both the tools this session declares and the
ones child harnesses emit.

The fallback is the point of the shape. Anything unregistered renders
through the exact activityPhrase it renders through today, so a tool
nobody has written a renderer for still reads correctly — pinned by a
test, so the fallback cannot drift away from the old output.

Renderers are pure functions of an activity: no I/O, deterministic,
and width-bounded so a long command or path cannot break the block.
This is the third slice of the fleet port plan; the registry and fleet
rendering landed earlier.

Built by a Devin child through the openagents coder's delegate tool;
760 CLI tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <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
  • modified packages/openagents-cli/src/coder-delegate.ts
  • modified packages/openagents-cli/src/coder-fleet.ts
  • modified packages/openagents-cli/src/coder-tasks.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-delegate.test.ts
  • added packages/openagents-cli/test/coder-fleet.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

9 files changed, +336 -28

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": 2466,
7
    "filesScanned": 2467,
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:2c21c17d2d8df32e49231085ac25061a9ed63bac9c4b0aae149a6904dd2972c5",
4
  "sourceDigest": "sha256:f11a893e45986831504d003dfa23967abb728700567e6ec3469c41b7dae7aa0f",
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 (62 tracked test files)"
1879
          "ref": "packages/openagents-cli (63 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-delegate.ts modified +76 -14

@@ -36,7 +36,12 @@ import { appendFileSync, createWriteStream, mkdirSync } from "node:fs";

36 36
import { runDevinAcp } from "./coder-devin-acp.js";
37 37
import { tmpdir } from "node:os";
38 38
import { dirname, join } from "node:path";
39
import type { CoderTaskId, CoderTaskRegistry, CoderToolActivity } from "./coder-tasks.js";
39
import type {
40
  CoderTaskId,
41
  CoderTaskRegistry,
42
  CoderToolActivity,
43
  CoderToolActivityMeta,
44
} from "./coder-tasks.js";
40 45
41 46
/** What the console asks for. One shape whether it wants one child or fifteen. */
42 47
export interface DelegationRequest {

@@ -123,6 +128,8 @@ export type DelegateEvent =

123 128
      readonly callId: string;
124 129
      readonly name: string;
125 130
      readonly target: string | undefined;
131
      /** Optional display metadata derived from the tool's input and output. */
132
      readonly meta?: CoderToolActivityMeta;
126 133
    }
127 134
  | { readonly type: "text"; readonly value: string }
128 135
  | { readonly type: "tokens"; readonly input: number; readonly output: number }

@@ -185,7 +192,8 @@ export function parseOpencodeEvent(line: string): DelegateEvent | undefined {

185 192
    const name = stringField(part, "tool") ?? "tool";
186 193
    const callId = stringField(part, "callID") ?? `${name}-${String(event["timestamp"] ?? "")}`;
187 194
    const state = isRecord(part["state"]) ? part["state"] : undefined;
188
    return { type: "tool", callId, name, target: toolTarget(state) };
195
    const { target, ...rest } = toolTarget(state);
196
    return { type: "tool", callId, name, target, ...rest };
189 197
  }
190 198
191 199
  if (type === "text" && part !== undefined) {

@@ -412,23 +420,73 @@ function describeHarnessError(event: Record<string, unknown>): string {

412 420
}
413 421
414 422
/**
415
 * What the child was working on.
423
 * What the child was working on, plus any display metadata the harness gave.
416 424
 *
417 425
 * The harness's own title is preferred because it is what the harness chose to
418
 * show; the input fields are a fallback for tools that set no title. A row that
419
 * says only `bash` is much less use than one that says the command.
426
 * show; the input fields are a fallback for tools that set no title. File
427
 * ranges come from `offset`/`limit` or `start`/`end`, and search hit counts come
428
 * from the output fields a real opencode harness uses.
420 429
 */
421
function toolTarget(state: Record<string, unknown> | undefined): string | undefined {
422
  if (state === undefined) return undefined;
430
type MutableToolMeta = { -readonly [K in keyof CoderToolActivityMeta]: CoderToolActivityMeta[K] };
431
432
function toolTarget(
433
  state: Record<string, unknown> | undefined,
434
): { readonly target: string | undefined; readonly meta?: CoderToolActivityMeta } {
435
  if (state === undefined) return { target: undefined };
436
423 437
  const title = stringField(state, "title");
424
  if (title !== undefined && title.length > 0) return title;
425 438
  const input = isRecord(state["input"]) ? state["input"] : undefined;
426
  if (input === undefined) return undefined;
427
  for (const key of ["filePath", "path", "command", "pattern", "query", "description", "url"]) {
428
    const value = stringField(input, key);
429
    if (value !== undefined && value.length > 0) return value;
439
  const output = isRecord(state["output"]) ? state["output"] : undefined;
440
441
  let target: string | undefined;
442
  if (title !== undefined && title.length > 0) {
443
    target = title;
444
  } else if (input !== undefined) {
445
    for (const key of ["filePath", "path", "file_path", "file", "command", "pattern", "query", "description", "url"]) {
446
      const value = stringField(input, key);
447
      if (value !== undefined && value.length > 0) {
448
        target = value;
449
        break;
450
      }
451
    }
430 452
  }
431
  return undefined;
453
454
  if (target === undefined) return { target: undefined };
455
456
  const meta: MutableToolMeta = {};
457
458
  if (input !== undefined) {
459
    const offset = numberField(input, "offset");
460
    const limit = numberField(input, "limit");
461
    const start = numberField(input, "start");
462
    const end = numberField(input, "end");
463
    if (offset !== undefined && limit !== undefined) {
464
      meta.range = { start: offset, end: offset + limit };
465
    } else if (start !== undefined && end !== undefined) {
466
      meta.range = { start, end };
467
    }
468
  }
469
470
  if (output !== undefined) {
471
    const size = numberField(output, "size") ?? numberField(output, "length");
472
    if (size !== undefined) meta.size = size;
473
474
    const hits =
475
      numberField(output, "hits") ??
476
      numberField(output, "hitCount") ??
477
      numberField(output, "total") ??
478
      numberField(output, "totalHits");
479
    if (hits !== undefined) {
480
      meta.hitCount = hits;
481
    } else {
482
      const matches = Array.isArray(output["matches"]) ? (output["matches"] as ReadonlyArray<unknown>).length : undefined;
483
      const results = Array.isArray(output["results"]) ? (output["results"] as ReadonlyArray<unknown>).length : undefined;
484
      if (matches !== undefined) meta.hitCount = matches;
485
      else if (results !== undefined) meta.hitCount = results;
486
    }
487
  }
488
489
  return Object.keys(meta).length === 0 ? { target } : { target, meta };
432 490
}
433 491
434 492
export interface OpencodeHarnessOptions {

@@ -1410,7 +1468,11 @@ export class DelegateFleet {

1410 1468
          } else if (event.type === "tool") {
1411 1469
            if (counted.has(event.callId)) continue;
1412 1470
            counted.add(event.callId);
1413
            const activity: CoderToolActivity = { toolName: event.name, target: event.target };
1471
            const activity: CoderToolActivity = {
1472
              toolName: event.name,
1473
              target: event.target,
1474
              ...(event.meta === undefined ? {} : { meta: event.meta }),
1475
            };
1414 1476
            this.registry.recordToolUse(id, activity);
1415 1477
          } else if (event.type === "tokens") {
1416 1478
            this.registry.recordTokens(id, { input: event.input, output: event.output });
packages/openagents-cli/src/coder-fleet.ts modified +74

@@ -82,6 +82,80 @@ export function activityPhrase(activity: CoderToolActivity): string {

82 82
  return `${activity.toolName}${target}`;
83 83
}
84 84
85
/**
86
 * A renderer turns one tool activity into one or more bounded display rows.
87
 */
88
export type CoderActivityRenderer = (
89
  activity: CoderToolActivity,
90
  width: number,
91
) => ReadonlyArray<string>;
92
93
/** Show a shell or command as `name: <command>`, cut to the available width. */
94
const shellActivityRenderer: CoderActivityRenderer = (activity, width) => {
95
  if (activity.target === undefined) return [activity.toolName];
96
  return [cut(`${activity.toolName}: ${activity.target}`, width)];
97
};
98
99
/** Show a file read or write as `name: <path>:<start>-<end>` or `name: <path> (<size>)`. */
100
const fileActivityRenderer: CoderActivityRenderer = (activity, width) => {
101
  if (activity.target === undefined) return [activity.toolName];
102
  let body = `${activity.toolName}: ${activity.target}`;
103
  if (activity.meta?.range !== undefined) {
104
    body += `:${activity.meta.range.start}-${activity.meta.range.end}`;
105
  } else if (activity.meta?.size !== undefined) {
106
    body += ` (${activity.meta.size})`;
107
  }
108
  return [cut(body, width)];
109
};
110
111
/** Show a search as `name: <pattern>` plus a hit count. */
112
const searchActivityRenderer: CoderActivityRenderer = (activity, width) => {
113
  if (activity.target === undefined) return [activity.toolName];
114
  const hits = activity.meta?.hitCount;
115
  const body =
116
    hits === undefined
117
      ? `${activity.toolName}: ${activity.target}`
118
      : `${activity.toolName}: ${activity.target} (${hits} ${hits === 1 ? "hit" : "hits"})`;
119
  return [cut(body, width)];
120
};
121
122
/**
123
 * Tool-name lookup for the row renderer.
124
 *
125
 * Activity names come from the tools this session declares (shell, openagents,
126
 * skill, delegate) and from the child harnesses that emit them (bash, read,
127
 * write, edit, grep, search, repo_grep). Each name is mapped to the renderer
128
 * that best shows its argument.
129
 */
130
export const activityRenderers: Readonly<Record<string, CoderActivityRenderer>> = Object.freeze({
131
  shell: shellActivityRenderer,
132
  bash: shellActivityRenderer,
133
  read: fileActivityRenderer,
134
  write: fileActivityRenderer,
135
  edit: fileActivityRenderer,
136
  search: searchActivityRenderer,
137
  grep: searchActivityRenderer,
138
  repo_grep: searchActivityRenderer,
139
});
140
141
/**
142
 * The bounded, multi-row display for a single activity.
143
 *
144
 * Unregistered tools fall back to the exact one-line `activityPhrase` so the
145
 * fleet and the detail view stay readable for anything that shows up later.
146
 *
147
 * `taskActivity` keeps the existing one-line status phrase; surfaces that want
148
 * more than one row can call `activityRows` directly.
149
 */
150
export function activityRows(
151
  activity: CoderToolActivity,
152
  width: number,
153
): ReadonlyArray<string> {
154
  const renderer = activityRenderers[activity.toolName];
155
  if (renderer === undefined) return [activityPhrase(activity)];
156
  return renderer(activity, Math.max(4, width));
157
}
158
85 159
/**
86 160
 * The newest activities across a fleet, oldest first, at most `count` of them.
87 161
 *
packages/openagents-cli/src/coder-tasks.ts modified +15 -1

@@ -35,6 +35,16 @@ export type CoderTaskId = string;

35 35
 */
36 36
export type CoderTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped";
37 37
38
/** Optional metadata for richer display of a tool activity. */
39
export interface CoderToolActivityMeta {
40
  /** Inclusive byte/character range for a file read or write. */
41
  readonly range?: { readonly start: number; readonly end: number };
42
  /** Size of the data read or written, in bytes or characters. */
43
  readonly size?: number;
44
  /** Number of matches for a search tool. */
45
  readonly hitCount?: number;
46
}
47
38 48
/** One thing a child did, in the shape a one-line status needs. */
39 49
export interface CoderToolActivity {
40 50
  readonly toolName: string;

@@ -44,6 +54,8 @@ export interface CoderToolActivity {

44 54
   * then only the tool name is shown.
45 55
   */
46 56
  readonly target: string | undefined;
57
  /** Optional structured display metadata derived from the tool's input and output. */
58
  readonly meta?: CoderToolActivityMeta;
47 59
}
48 60
49 61
/** What a fleet row and a detail view read. Aggregated, never recomputed. */

@@ -355,6 +367,8 @@ function isSameTask(previous: CoderTask, next: CoderTask): boolean {

355 367
    previous.progress.toolUseCount === next.progress.toolUseCount &&
356 368
    previous.progress.tokenCount === next.progress.tokenCount &&
357 369
    previous.progress.lastActivity?.toolName === next.progress.lastActivity?.toolName &&
358
    previous.progress.lastActivity?.target === next.progress.lastActivity?.target
370
    previous.progress.lastActivity?.target === next.progress.lastActivity?.target &&
371
    JSON.stringify(previous.progress.lastActivity?.meta) ===
372
      JSON.stringify(next.progress.lastActivity?.meta)
359 373
  );
360 374
}
packages/openagents-cli/src/coder-ui.ts modified +6 -3

@@ -37,7 +37,7 @@

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

@@ -559,7 +559,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

559 559
          // Three lines of the child's own latest activity, nested under it.
560 560
          const activities = latestActivities([task], PREVIEW_ROWS);
561 561
          for (const activity of activities) {
562
            const text = truncate(activityPhrase(activity), Math.max(4, width - 12));
562
            const rendered = activityRows(activity, Math.max(4, width - 12));
563
            const text = rendered[0] ?? "";
563 564
            rows.push(`${DIM}  → ${text}${RESET}`);
564 565
          }
565 566
        }

@@ -739,7 +740,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

739 740
740 741
        for (const activity of activities) {
741 742
          if (rows.length + 1 > height) break;
742
          rows.push(`${DIM}  → ${truncate(activityPhrase(activity), inner - 4)}${RESET}`);
743
          const rendered = activityRows(activity, Math.max(4, inner - 4));
744
          const text = rendered[0] ?? "";
745
          rows.push(`${DIM}  → ${text}${RESET}`);
743 746
        }
744 747
745 748
        rows.push("");
packages/openagents-cli/test/coder-delegate.test.ts modified +48

@@ -141,6 +141,54 @@ describe("parseOpencodeEvent", () => {

141 141
    expect(parseOpencodeEvent(line)).toMatchObject({ name: "bash", target: "pnpm test" });
142 142
  });
143 143
144
  it("extracts a file range from offset and limit", () => {
145
    const line = JSON.stringify({
146
      type: "tool_use",
147
      part: {
148
        tool: "read",
149
        callID: "call-range",
150
        state: { input: { filePath: "src/a.ts", offset: 10, limit: 40 } },
151
      },
152
    });
153
    expect(parseOpencodeEvent(line)).toMatchObject({
154
      name: "read",
155
      target: "src/a.ts",
156
      meta: { range: { start: 10, end: 50 } },
157
    });
158
  });
159
160
  it("extracts a file size from output", () => {
161
    const line = JSON.stringify({
162
      type: "tool_use",
163
      part: {
164
        tool: "read",
165
        callID: "call-size",
166
        state: { input: { filePath: "src/b.ts" }, output: { size: 240 } },
167
      },
168
    });
169
    expect(parseOpencodeEvent(line)).toMatchObject({
170
      name: "read",
171
      target: "src/b.ts",
172
      meta: { size: 240 },
173
    });
174
  });
175
176
  it("extracts a search hit count from output", () => {
177
    const line = JSON.stringify({
178
      type: "tool_use",
179
      part: {
180
        tool: "grep",
181
        callID: "call-hits",
182
        state: { input: { pattern: "needle" }, output: { matches: [1, 2, 3] } },
183
      },
184
    });
185
    expect(parseOpencodeEvent(line)).toMatchObject({
186
      name: "grep",
187
      target: "needle",
188
      meta: { hitCount: 3 },
189
    });
190
  });
191
144 192
  it("reads text and token usage", () => {
145 193
    expect(parseOpencodeEvent('{"type":"text","part":{"text":"banana"}}')).toEqual({
146 194
      type: "text",
packages/openagents-cli/test/coder-fleet.test.ts added +107

@@ -0,0 +1,107 @@

1
import { describe, expect, it } from "vitest";
2
3
import { activityPhrase, activityRenderers, activityRows } from "../src/coder-fleet.js";
4
5
describe("activity rows", () => {
6
  it("shows a shell command, truncated to the width", () => {
7
    expect(activityRows({ toolName: "bash", target: "pnpm test" }, 40)).toEqual([
8
      "bash: pnpm test",
9
    ]);
10
    expect(activityRows({ toolName: "shell", target: "pnpm build" }, 40)).toEqual([
11
      "shell: pnpm build",
12
    ]);
13
14
    const long = "a".repeat(200);
15
    const rows = activityRows({ toolName: "bash", target: long }, 20);
16
    expect(rows).toHaveLength(1);
17
    expect([...rows[0]!].length).toBeLessThanOrEqual(20);
18
    expect(rows[0]).toMatch(/^bash: /);
19
    expect(rows[0]).toMatch(/…$/);
20
  });
21
22
  it("shows a file path for read, write, and edit", () => {
23
    expect(activityRows({ toolName: "read", target: "src/a.ts" }, 40)).toEqual([
24
      "read: src/a.ts",
25
    ]);
26
    expect(activityRows({ toolName: "write", target: "src/b.ts" }, 40)).toEqual([
27
      "write: src/b.ts",
28
    ]);
29
    expect(activityRows({ toolName: "edit", target: "src/c.ts" }, 40)).toEqual([
30
      "edit: src/c.ts",
31
    ]);
32
33
    const long = "packages/openagents-cli/src/some/very/deep/file.ts";
34
    const rows = activityRows({ toolName: "read", target: long }, 20);
35
    expect(rows).toHaveLength(1);
36
    expect([...rows[0]!].length).toBeLessThanOrEqual(20);
37
  });
38
39
  it("shows a search pattern", () => {
40
    expect(activityRows({ toolName: "grep", target: "needle" }, 40)).toEqual(["grep: needle"]);
41
    expect(activityRows({ toolName: "search", target: "term" }, 40)).toEqual(["search: term"]);
42
    expect(activityRows({ toolName: "repo_grep", target: "pattern" }, 40)).toEqual([
43
      "repo_grep: pattern",
44
    ]);
45
46
    const long = "a very long search pattern that needs cutting";
47
    const rows = activityRows({ toolName: "grep", target: long }, 20);
48
    expect(rows).toHaveLength(1);
49
    expect([...rows[0]!].length).toBeLessThanOrEqual(20);
50
  });
51
52
  it("leaves the tool name alone when there is no target", () => {
53
    expect(activityRows({ toolName: "bash", target: undefined }, 40)).toEqual(["bash"]);
54
    expect(activityRows({ toolName: "read", target: undefined }, 40)).toEqual(["read"]);
55
    expect(activityRows({ toolName: "grep", target: undefined }, 40)).toEqual(["grep"]);
56
  });
57
58
  it("falls back to the exact activityPhrase for an unregistered tool", () => {
59
    const withTarget = { toolName: "think", target: "deeply" };
60
    expect(activityRows(withTarget, 80)).toEqual([activityPhrase(withTarget)]);
61
62
    const noTarget = { toolName: "unknown", target: undefined };
63
    expect(activityRows(noTarget, 80)).toEqual([activityPhrase(noTarget)]);
64
  });
65
66
  it("exposes the same renderer under harness aliases", () => {
67
    expect(activityRenderers["bash"]).toBe(activityRenderers["shell"]);
68
    expect(activityRenderers["write"]).toBe(activityRenderers["edit"]);
69
    expect(activityRenderers["grep"]).toBe(activityRenderers["search"]);
70
    expect(activityRenderers["repo_grep"]).toBe(activityRenderers["search"]);
71
  });
72
73
  it("shows a file read with a range", () => {
74
    expect(
75
      activityRows({ toolName: "read", target: "src/a.ts", meta: { range: { start: 10, end: 50 } } }, 40),
76
    ).toEqual(["read: src/a.ts:10-50"]);
77
  });
78
79
  it("shows a file write with a size", () => {
80
    expect(
81
      activityRows({ toolName: "write", target: "src/b.ts", meta: { size: 240 } }, 40),
82
    ).toEqual(["write: src/b.ts (240)"]);
83
  });
84
85
  it("shows a search with a hit count", () => {
86
    expect(
87
      activityRows({ toolName: "grep", target: "needle", meta: { hitCount: 3 } }, 40),
88
    ).toEqual(["grep: needle (3 hits)"]);
89
    expect(
90
      activityRows({ toolName: "search", target: "term", meta: { hitCount: 1 } }, 40),
91
    ).toEqual(["search: term (1 hit)"]);
92
    expect(
93
      activityRows({ toolName: "repo_grep", target: "pattern", meta: { hitCount: 0 } }, 40),
94
    ).toEqual(["repo_grep: pattern (0 hits)"]);
95
  });
96
97
  it("cuts a file or search with metadata to the given width", () => {
98
    const long = "packages/openagents-cli/src/some/very/deep/file.ts";
99
    const withRange = activityRows(
100
      { toolName: "read", target: long, meta: { range: { start: 0, end: 100 } } },
101
      20,
102
    );
103
    expect(withRange).toHaveLength(1);
104
    expect([...withRange[0]!].length).toBeLessThanOrEqual(20);
105
    expect(withRange[0]).toMatch(/^read: /);
106
  });
107
});
packages/openagents-cli/test/coder-ui.test.ts modified +7 -7

@@ -1065,17 +1065,17 @@ describe("where a running child is shown", () => {

1065 1065
    const previews = rows.slice(childRow + 1, childRow + 4);
1066 1066
    expect(previews).toHaveLength(3);
1067 1067
    const text = previews.join("\n");
1068
    expect(text).toContain("shell(mix test)");
1069
    expect(text).toContain("edit(src/b.ts)");
1070
    expect(text).toContain("bash(pnpm test)");
1068
    expect(text).toContain("shell: mix test");
1069
    expect(text).toContain("edit: src/b.ts");
1070
    expect(text).toContain("bash: pnpm test");
1071 1071
    // Pushed out by the newer work.
1072
    expect(text).not.toContain("read(src/a.ts)");
1073
    expect(text).not.toContain("grep(needle)");
1072
    expect(text).not.toContain("read: src/a.ts");
1073
    expect(text).not.toContain("grep: needle");
1074 1074
1075 1075
    // Newest last, so reading down is reading forward in time.
1076 1076
    const at = (phrase: string) => previews.findIndex((row) => row.includes(phrase));
1077
    expect(at("bash(pnpm test)")).toBeLessThan(at("edit(src/b.ts)"));
1078
    expect(at("edit(src/b.ts)")).toBeLessThan(at("shell(mix test)"));
1077
    expect(at("bash: pnpm test")).toBeLessThan(at("edit: src/b.ts"));
1078
    expect(at("edit: src/b.ts")).toBeLessThan(at("shell: mix test"));
1079 1079
  });
1080 1080
1081 1081
  it("draws no preview until the child has done something", async () => {

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