From 0e6c3ce307b3138764a9df70e5e0442762364182 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 23:19:02 -0700 Subject: [PATCH 1/4] Assert transcript rows by content, not frame-wide substring absence Checking a rendered frame for the absence of "agent" anywhere breaks in any checkout whose directory name contains that substring (the footer prints the workspace path), including any path containing "subagent". Scope the assertion to the rows that actually carry a voice label instead. --- src/tui-opentui/shell.test.ts | 5 +++-- src/tui-opentui/transcript-layout.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index 34374c120..fdaf226c0 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -355,8 +355,9 @@ describe("product skin: stream + queue + overlay", () => { expect(frame).toContain("hi there") expect(frame).toContain("bash") // One agent is answering, so no row spends columns naming it. - expect(frame).not.toContain("agent") - expect(frame).not.toContain(" tool ") + const inkRows = frame.split("\n").filter((row) => row.trim().length > 0) + expect(inkRows.filter((row) => row.includes("● agent"))).toHaveLength(0) + expect(inkRows.filter((row) => row.includes(" tool "))).toHaveLength(0) // User row content is in the scroll buffer (pure paint covered in stream.test). expect( paintStreamRow( diff --git a/src/tui-opentui/transcript-layout.test.ts b/src/tui-opentui/transcript-layout.test.ts index a8edd24b1..b9575953e 100644 --- a/src/tui-opentui/transcript-layout.test.ts +++ b/src/tui-opentui/transcript-layout.test.ts @@ -73,8 +73,8 @@ describe("transcript turn layout", () => { expect(row).toBeDefined() const painted = row as string expect(painted.indexOf("listing")).toBe(resolveSideMargin(80)) - expect(frame).not.toContain("agent") - expect(frame).not.toContain("you") + expect(rowsContaining(frame, "● agent")).toHaveLength(0) + expect(rowsContaining(frame, "● you")).toHaveLength(0) }, ) }) From 54260341ae64e55879bb72ef4f95ac4b9c5b43eb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 23:19:10 -0700 Subject: [PATCH 2/4] Settle markdown highlight tests on content, not a fixed sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlighting runs on a worker outside the render scheduler, so a quiet renderer or a fixed 250ms sleep does not reliably mean the highlighted frame has landed. Poll for the settled shape instead — every block present and the raw markdown marker gone — so each test returns as soon as it is true and tolerates slower runs instead of racing a guessed duration. --- src/tui-opentui/markdown-rows.test.ts | 54 ++++++++++++++++++--------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/tui-opentui/markdown-rows.test.ts b/src/tui-opentui/markdown-rows.test.ts index 65e5d9da8..091b67209 100644 --- a/src/tui-opentui/markdown-rows.test.ts +++ b/src/tui-opentui/markdown-rows.test.ts @@ -23,17 +23,22 @@ const shellOpts = { } as const /** - * Markdown blocks highlight asynchronously; settle before capturing a frame. - * A row with several top-level blocks (heading, list, fence, link) resolves - * its highlight promises one render at a time, so a fixed couple of ticks - * that was enough for one block is not enough for several. + * Highlighting runs on a worker outside the render scheduler, so the + * scheduler goes idle before the highlighted frame lands. Pass a predicate + * for the settled shape and get the frame back the moment it's true, rather + * than gambling on a fixed sleep long enough to outrun load. */ -async function settle(h: Harness): Promise { - for (let i = 0; i < 8; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 50)) +async function settle(h: Harness, isSettled: (frame: string) => boolean): Promise { + const deadline = Date.now() + 2000 + for (;;) { await h.renderOnce() + const frame = h.captureCharFrame() + if (isSettled(frame)) return frame + if (Date.now() >= deadline) { + throw new Error(`markdown row never settled; last frame:\n${frame}`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) } - return h.captureCharFrame() } describe("markdown transcript rows", () => { @@ -68,7 +73,10 @@ describe("markdown transcript rows", () => { ].join("\n"), }) - const frame = await settle(h) + const frame = await settle( + h, + (f) => f.includes("docs") && !f.includes("## Title") && !f.includes("**bolded**"), + ) expect(frame).toContain("Title") expect(frame).not.toContain("## Title") expect(frame).toContain("bolded") @@ -94,7 +102,10 @@ describe("markdown transcript rows", () => { ].join("\n"), }) - const frame = await settle(h) + const frame = await settle( + h, + (f) => f.includes("What the site is") && !f.includes("###") && !f.includes("**Hardware:**"), + ) expect(frame).toContain("What the site is") expect(frame).not.toContain("###") expect(frame).toContain("Hardware:") @@ -108,7 +119,7 @@ describe("markdown transcript rows", () => { appendStreamRow(shell, { role: "tool", text: "## not a heading" }) appendStreamRow(shell, { role: "system", text: "**raw**" }) - const frame = await settle(h) + const frame = await settle(h, (f) => f.includes("**raw**")) expect(frame).toContain("## not a heading") expect(frame).toContain("**raw**") }, WIDE) @@ -123,7 +134,10 @@ describe("markdown transcript rows", () => { text: ["## Done", "", "```ts", "const partial = "].join("\n"), }) - const frame = await settle(h) + const frame = await settle( + h, + (f) => f.includes("Done") && !f.includes("## Done") && f.includes("const partial ="), + ) expect(frame).toContain("Done") expect(frame).not.toContain("## Done") expect(frame).toContain("const partial =") @@ -143,7 +157,7 @@ describe("markdown transcript rows", () => { // literal text and the row paints the bare markers until the title's // first character lands. Held back instead, so the line's classification // cannot flip under text already on screen. - const frame = await settle(h) + const frame = await settle(h, (f) => f.includes("Some body text.") && !f.includes("####")) expect(frame).toContain("Some body text.") expect(frame).not.toContain("####") @@ -152,7 +166,7 @@ describe("markdown transcript rows", () => { streaming: true, text: ["Some body text.", "", "#### Title"].join("\n"), }) - const next = await settle(h) + const next = await settle(h, (f) => f.includes("Title") && !f.includes("#### Title")) expect(next).toContain("Title") expect(next).not.toContain("#### Title") }, WIDE) @@ -240,7 +254,7 @@ describe("markdown transcript rows", () => { role: "assistant", text: ["### Title", "", "Here is the list:", "- alpha", "- beta"].join("\n"), }) - const frame = await settle(h) + const frame = await settle(h, (f) => f.includes("alpha")) const lines = frame.split("\n").map((line) => line.trimEnd()) const listLine = lines.findIndex((line) => line.includes("Here is the list:")) expect(listLine).toBeGreaterThan(-1) @@ -257,7 +271,7 @@ describe("markdown transcript rows", () => { role: "assistant", text: ["### Steps", "", ...items].join("\n"), }) - const frame = await settle(h) + const frame = await settle(h, (f) => f.includes("10. item 10")) expect(frame).toContain("1. item 1") expect(frame).toContain("10. item 10") }, WIDE) @@ -290,7 +304,8 @@ describe("markdown transcript rows", () => { }) // Warm up once: the first highlight pass in a process loads the // tree-sitter grammar and is not itself part of what this test samples. - const baseline = await settle(h).then(() => headingSpan(h)) + await settle(h, () => headingSpan(h) !== null) + const baseline = headingSpan(h) expect(baseline).not.toBeNull() for (let i = 2; i <= full.length; i += 1) { @@ -375,7 +390,10 @@ describe("markdown transcript rows", () => { "more prose streaming in", ].join("\n"), }) - const frame = await settle(h) + const frame = await settle( + h, + (f) => f.includes("# this is a comment, not a heading") && f.includes("more prose streaming in"), + ) expect(frame).toContain("# this is a comment, not a heading") expect(frame).toContain("echo hi") expect(frame).toContain("more prose streaming in") From 0ed64703ea06daf50ab034ad4a74c6376804ed54 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 23:19:18 -0700 Subject: [PATCH 3/4] Trigger the ripgrep timeout test deterministically The test raced a real spawned ripgrep process against a 1ms timeout, so it depended on the process being slower than the clock rather than on the timeout path itself. Thread the same stalled-child fake already used at the runRg level through ripgrepPlugin so the timeout is the only path to settlement. --- src/plugins/ripgrep-plugin.ts | 6 +++--- tests/unit/ripgrep-plugin.test.ts | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/plugins/ripgrep-plugin.ts b/src/plugins/ripgrep-plugin.ts index f05e46318..786b97c84 100644 --- a/src/plugins/ripgrep-plugin.ts +++ b/src/plugins/ripgrep-plugin.ts @@ -8,7 +8,7 @@ import { type BoundedGrepArgs, } from "./bounded-grep-fallback.js"; import { createRgCollector } from "./rg-output.js"; -import { MAX_OUTPUT_BYTES, runRg, type RgLimits } from "./rg-run.js"; +import { MAX_OUTPUT_BYTES, runRg, type RgLimits, type SpawnRg } from "./rg-run.js"; // A grep over a large tree with the pure-TypeScript walker enumerates the whole // directory (node_modules, build output, the lot) before searching, which stalls @@ -66,7 +66,7 @@ function searchLocation(path: string, fallbackCwd: string): { cwd: string; targe } } -export function ripgrepPlugin(cwd: string, limits: RgLimits = {}): ToolPlugin { +export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: SpawnRg): ToolPlugin { const maxBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES; return { middleware: (next) => async (call, signal) => { @@ -86,7 +86,7 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}): ToolPlugin { if (glob !== undefined) rgArgs.push("-g", glob); rgArgs.push("--regexp", pattern, target); - const result = await runRg(rgArgs, rgCwd, signal, limits); + const result = await runRg(rgArgs, rgCwd, signal, limits, spawnChild); if (result.kind === "unavailable") { try { const boundedArgs: BoundedGrepArgs = { diff --git a/tests/unit/ripgrep-plugin.test.ts b/tests/unit/ripgrep-plugin.test.ts index 1fc9d7f5d..4df6b29c6 100644 --- a/tests/unit/ripgrep-plugin.test.ts +++ b/tests/unit/ripgrep-plugin.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { ripgrepPlugin } from "../../src/plugins/ripgrep-plugin.js"; +import type { RgChild, SpawnRg } from "../../src/plugins/rg-run.js"; // Repo root derived from this file, not process.cwd(): these cases search real // repo paths, so they must not depend on where the runner was invoked from. @@ -14,11 +15,23 @@ const fallback = async (): Promise => ({ callId: "c", content: "FALL function run( call: ToolCall, limits: { timeoutMs?: number; maxOutputBytes?: number } = {}, + spawnChild?: SpawnRg, ): Promise { - const handler = ripgrepPlugin(cwd, limits).middleware!(fallback); + const handler = ripgrepPlugin(cwd, limits, spawnChild).middleware!(fallback); return handler(call, new AbortController().signal); } +// A child that never emits data or closes, so the timeout is the only path +// to settlement — the trigger the timeout test needs, not a race against how +// fast a real ripgrep process happens to run. +const stalledSpawn: SpawnRg = (): RgChild => ({ + pid: undefined, + stdout: { on: () => undefined }, + stderr: { on: () => undefined }, + on: (() => undefined) as RgChild["on"], + kill: () => undefined, +}); + async function withTempDir(run: (dir: string) => Promise): Promise { const dir = await mkdtemp(join(tmpdir(), "ripgrep-plugin-")); try { @@ -114,6 +127,7 @@ test("grep returns partial matches when the timeout fires", async () => { const result = await run( { id: "c", name: "grep", arguments: { pattern: "e", path: "src" } }, { timeoutMs: 1 }, + stalledSpawn, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("timed out"); From 12c027eef1523cde34781a2e4c849c9d1244970d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 23:20:10 -0700 Subject: [PATCH 4/4] Point contributors at the scoped test command A bare `bun test` also scans vendor/, inflating pass/fail counts by hundreds and making the count meaningless to compare across branches. --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index af891fd64..0df98dcdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,11 +39,15 @@ When refactoring replaces an old path, delete the old one. No back-compat shims, ```bash bun run typecheck bun run build -bun test +bun run test ``` Run the full suite before declaring any task complete. Do not substitute individual targets. If a failure is pre-existing and unrelated to your change, say so explicitly. +`bun run test` runs `bun test ./src ./tests ./evals`. A bare `bun test` also +scans `vendor/`, adding hundreds of unrelated results and making pass/fail +counts meaningless to compare across branches — always use `bun run test`. + ## Commits Follow the `style` skill's message format: plain-English summary, no `feat:`/`fix:` prefixes, no filename in the summary. Separate refactors from feature additions. Commit with the user's local git identity.