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. 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/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") 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) }, ) }) 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");