Skip to content
Merged
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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/plugins/ripgrep-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 = {
Expand Down
54 changes: 36 additions & 18 deletions src/tui-opentui/markdown-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
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", () => {
Expand Down Expand Up @@ -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")
Expand All @@ -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:")
Expand All @@ -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)
Expand All @@ -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 =")
Expand All @@ -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("####")

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions src/tui-opentui/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/tui-opentui/transcript-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
)
})
Expand Down
16 changes: 15 additions & 1 deletion tests/unit/ripgrep-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -14,11 +15,23 @@ const fallback = async (): Promise<ToolResult> => ({ callId: "c", content: "FALL
function run(
call: ToolCall,
limits: { timeoutMs?: number; maxOutputBytes?: number } = {},
spawnChild?: SpawnRg,
): Promise<ToolResult> {
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<void>): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "ripgrep-plugin-"));
try {
Expand Down Expand Up @@ -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");
Expand Down
Loading