Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
43346bd
Trim content-pin and cosmetic tests from the suite
TheGreatAxios Sep 8, 2026
72634b2
Restructure CI to parallelize static analysis and shard tests
TheGreatAxios Sep 8, 2026
9428878
Pin git-global-config ask against shell wrapper bypasses
TheGreatAxios Sep 8, 2026
68871ba
Cover torn JSONL recovery when the base segment is the active one
TheGreatAxios Sep 8, 2026
1e99aa9
Extend exec close tests to multi-worker cancel and close failure
TheGreatAxios Sep 8, 2026
65590ad
Pin session queue ordering for rotation racing an in-flight delivery
TheGreatAxios Sep 8, 2026
cc7b8a7
Cover MCP reconnect after the server's tool schemas drift
TheGreatAxios Sep 8, 2026
9f10287
Fix flaky subagent mount-gate tests pinned on retry backoff sleeps
TheGreatAxios Sep 8, 2026
dc5f9ff
Format the new and reformatted test files with prettier
TheGreatAxios Sep 8, 2026
f78008f
Guard against content-pin tests regrowing
TheGreatAxios Sep 8, 2026
55394bd
Remove aesthetic palette invariants from ramp paint tests
TheGreatAxios Sep 8, 2026
2277285
Pin the CI shard test:paths script in the check gate
TheGreatAxios Sep 8, 2026
0324c71
Rewrite forbidden-content policy guards as violation collectors
TheGreatAxios Sep 8, 2026
9352323
Pin the CI shard path union in the check gate
TheGreatAxios Sep 8, 2026
59543f7
Restore eval CLI coverage and drop the content-pin lint rule
TheGreatAxios Sep 9, 2026
4e53901
style: prettier after rebase conflict in skills catalog tests
TheGreatAxios Sep 9, 2026
eece356
Align exec dispose tests with posix-first teardown
TheGreatAxios Sep 10, 2026
ef8fe45
Publish legacy CI check names after the restructured jobs
TheGreatAxios Sep 10, 2026
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
126 changes: 82 additions & 44 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ concurrency:
cancel-in-progress: ${{ github.event_name != 'push' }}

jobs:
# Prettier and eslint run un-cached in CI: restored result caches can mark
# files clean against a stale tool version or config, masking real failures.
# The --cache flags in the package.json lint script remain for local speed.
prettier:
# Prettier, eslint, and typecheck share one runner: one checkout and one
# install instead of three of each. Prettier and eslint still run un-cached
# in CI: restored result caches can mark files clean against a stale tool
# version or config, masking real failures. The --cache flags in the
# package.json lint script remain for local speed.
static-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -29,6 +31,9 @@ jobs:
uses: actions/cache@v4
with:
path: node_modules
# Exact-key-only: a restore-keys prefix of bun- would hydrate
# node_modules from a different lockfile. bun install then has to
# reconcile a stale tree; missing that step leaves wrong deps.
key: bun-${{ hashFiles('bun.lock') }}

- name: Install dependencies
Expand All @@ -37,35 +42,26 @@ jobs:
- name: Prettier
run: bunx prettier --check .

eslint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"

- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}

- name: Install dependencies
run: bun install --frozen-lockfile

- name: ESLint
run: bunx eslint .

typecheck:
- name: Typecheck
run: bun run typecheck

# Build runs beside the suite instead of before it: tests import ./src
# directly and never read ./dist, so serializing build ahead of test put
# build time on the critical path for no dependency reason.
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
Expand All @@ -80,11 +76,26 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Typecheck
run: bun run typecheck
- name: Build
run: bun run build

build-and-test:
# The suite is sharded so the slowest slice, not the whole suite, sets the
# wall clock. Every shard still goes through check:projects-dir-guard: the
# guard forwards these path filters to the suite it wraps, and the union of
# the shards' filters is exactly ./src ./tests ./evals ./scripts, so the gate covers
# the same tests as before, all of them sandboxed.
test:
runs-on: ubuntu-latest
strategy:
# A red shard must not cancel the other; both results are the signal.
fail-fast: false
matrix:
shard:
- name: src
paths: ./src
- name: tests-evals-and-scripts
paths: ./tests ./evals ./scripts
name: test (${{ matrix.shard.name }})
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -99,29 +110,56 @@ jobs:
with:
bun-version: "1.3.14"

# The runner image has no ripgrep, so the grep plugin silently exercised
# its fallback walker and left the ripgrep path untested.
- name: Install ripgrep
run: sudo apt-get install -y ripgrep

- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}

# The runner image has no ripgrep, so the grep plugin silently exercised
# its fallback walker and left the ripgrep path untested.
- name: Install ripgrep
run: sudo apt-get install -y ripgrep

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build
run: bun run build

# Same script the local `bun run check` gate runs: the projects-dir
# guard wraps `bun run test`, which is the seeded, randomized suite
# (bun test ./src ./tests ./evals --randomize --seed 424242) defined
# once in package.json. Randomized order catches tests that only pass
# in the default file order (shared module-level state, an unrestored
# global mock, a leaked env var); the seed is fixed so a failure here
# reproduces locally with `bun run test`.
# The same script the local `bun run check` gate runs, with the shard's
# path filters forwarded through the guard to the suite. The guard
# routes a filtered run through test:paths, which carries the same
# seeded flags as the `test` script; bun test filters are additive, so
# appending filters to `bun run test` could not narrow it. Randomized
# order catches tests that only pass in the default file order (shared
# module-level state, an unrestored global mock, a leaked env var).
# The seed stays 424242 in every shard rather than varying per shard:
# the shards already run disjoint file sets, and a fixed seed keeps
# any failure reproducible locally with the same
# `bun run test:paths <paths>`.
- name: Test
run: bun run check:projects-dir-guard
run: bun run check:projects-dir-guard ${{ matrix.shard.paths }}

# protect-main still requires the pre-restructure check names. These jobs
# exist only to publish those contexts after the real work succeeds.
prettier:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- run: "true"

eslint:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- run: "true"

typecheck:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- run: "true"

build-and-test:
needs: [build, test]
runs-on: ubuntu-latest
steps:
- run: "true"
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ When refactoring replaces an old path, delete the old one. No back-compat shims,
- Add or update tests with every behavior change.
- Bug fixes start with a failing test that reproduces the bug. Do not start by patching.
- `tests/unit/` shared unit tests and helpers · co-located `src/**/*.test.ts` for module logic · `tests/fixtures/` fixture repos · `tests/integration/` reactor/permission harness. Planned: `tests/e2e/` (fixture-repo runs).
- A test must not depend on another file having run, or on the default file order. It must pass under `bun test ./src ./tests ./evals --randomize`. If a test mutates module-level state or calls `mock.module`, it must restore that state itself (`afterEach`/`afterAll`), not rely on the process happening to reset it. When capturing a module's real exports to restore later, shallow-copy them (`{ ...moduleNamespace }`) at capture time, whether the namespace came from `await import(path)` or a static `import * as ns from "path"` — Bun mutates the live namespace object in place when the module is mocked, so holding a bare reference to it (either form) silently turns into the mocked exports.
- A test must not depend on another file having run, or on the default file order. It must pass under `bun test ./src ./tests ./evals ./scripts --randomize`. If a test mutates module-level state or calls `mock.module`, it must restore that state itself (`afterEach`/`afterAll`), not rely on the process happening to reset it. When capturing a module's real exports to restore later, shallow-copy them (`{ ...moduleNamespace }`) at capture time, whether the namespace came from `await import(path)` or a static `import * as ns from "path"` — Bun mutates the live namespace object in place when the module is mocked, so holding a bare reference to it (either form) silently turns into the mocked exports.
- Never call `mock.module` directly. Bun runs every test file in one process, so a `mock.module` call without its own teardown stays installed for the rest of the run and silently replaces the real module for other files — producing failures in files the change never touched, with no obvious link to the cause and no signal from `tsc` or a per-file run (CL-6967). Use `withMockedModule`/`withMockedModuleDuring` from `tests/helpers/mock-module.ts`, which capture the real module and register their own restore. An eslint rule (`no-restricted-syntax` in `eslint.config.js`) rejects bare `mock.module` calls in `*.test.ts` files.
- A test earns its place only if a real behavior change can fail it. Document copy, brand colors, marketing assets, and splash text are not behavior: assertions that pin an asset's literal wording, an exact palette hex/ANSI value, or rendered copy fail on copy/design edits and catch no regressions — assert the contract instead (parsing, formatting, ranges, aliases, invariants). Tests are code too: pinning a source file's own text is the same trap. This bar is a review and authorship rule, not an eslint shape match.

## Build & Validation

Expand All @@ -48,10 +49,15 @@ the projects-dir sandbox guard — in that order, matching CI.

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 --randomize --seed 424242` —
the same suite CI runs. 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`.
`bun run test` runs `bun test ./src ./tests ./evals ./scripts --randomize --seed 424242`
as a single process. CI shards the same path union via `test:paths`
(`.github/workflows/ci.yml`) for wall clock. Path-union is not the same
isolation domain: a `mock.module` leak across `./src` vs `./tests` fails
locally in the one-process suite but not in a CI shard (CL-6967). 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`. `test:paths` with no path filters refuses to run for the
same reason.

## Commits, pull requests, and issue tracking

Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ bun run build
bun run test
```

These match the CI workflow in `.github/workflows/ci.yml`. Run `bun run check`
These match the local development loop. CI shards the same path union via
`test:paths` rather than running the one-process `bun run test` suite.
Run `bun run check`
(lint, typecheck, build, and the guarded test suite) before opening a PR —
`bun run test` alone skips the projects-dir sandbox guard, which only runs
under `bun run check` and CI. Do
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*' && bun scripts/copy-repo-plugins.ts",
"build:bin": "bun build ./src/index.ts --compile --minify --define process.env.NODE_ENV='\"production\"' --outfile ./dist/corbits && bun scripts/copy-repo-plugins.ts",
"typecheck": "tsc --noEmit",
"test": "bun test ./src ./tests ./evals --randomize --seed 424242",
"test": "bun test ./src ./tests ./evals ./scripts --randomize --seed 424242",
"test:paths": "bun scripts/test-paths.ts",
"lint": "prettier --check --cache . && eslint --cache .",
"check:projects-dir-guard": "bun scripts/guard-real-projects-dir.ts",
"check": "bun run lint && bun run typecheck && bun run build && bun run check:projects-dir-guard",
Expand Down
25 changes: 18 additions & 7 deletions scripts/guard-real-projects-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { join } from "node:path";
import { mkdir, readdir, rm } from "node:fs/promises";
import { spawn } from "node:child_process";

// Runs the test suite (`bun run test` — the same seeded, randomized command
// CI runs) and fails the run if any test wrote into the real
// ~/.corbits/projects directory. Tests must sandbox state under a temp
// `home` (see src/session/index.ts's `home` overrides); nothing running
// under this wrapper is allowed to fall back to the developer's own
// session history.
// Runs the test suite (`bun run test` — the seeded, randomized one-process
// command whose path union CI shards via `test:paths`) and fails the run if
// any test wrote into the real ~/.corbits/projects directory. Tests must
// sandbox state under a temp `home` (see src/session/index.ts's `home`
// overrides); nothing running under this wrapper is allowed to fall back to
// the developer's own session history.
//
// This is a backstop, not a substitute for threading `home` correctly: a
// leak is only caught after it already wrote into a real directory once,
Expand Down Expand Up @@ -45,7 +45,18 @@ async function main(): Promise<void> {
const runTmpDir = join(tmpdir(), `corbits-test-guard-${runId}`);
await mkdir(runTmpDir, { recursive: true });

const child = spawn("bun", ["run", "test"], {
// CI test shards pass bun-test path filters here (e.g. ./src) so each
// shard runs only its slice of the suite and still runs sandboxed. bun
// test filters are additive, so filters cannot be appended to
// `bun run test` (its own filters would widen the run back to the full
// suite), so a sharded run goes through test:paths, which carries the
// same seeded flags as `test` and takes the shard's filters. With no
// arguments the full default suite runs via `bun run test`, so `bun run
// check` behavior is unchanged.
const shardArgs = process.argv.slice(2);
const testCommand = shardArgs.length > 0 ? ["run", "test:paths", ...shardArgs] : ["run", "test"];

const child = spawn("bun", testCommand, {
stdio: "inherit",
env: { ...process.env, TMPDIR: runTmpDir, TMP: runTmpDir, TEMP: runTmpDir },
});
Expand Down
23 changes: 23 additions & 0 deletions scripts/test-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { spawn } from "node:child_process";

// CI shards and `check:projects-dir-guard` pass bun-test path filters here.
// A zero-arg `bun test` walks the whole tree, including vendor/, so this
// script refuses to run without at least one path (not a flag).

const args = process.argv.slice(2);
const paths = args.filter((arg) => !arg.startsWith("-"));

if (paths.length === 0) {
process.stderr.write(
"test:paths requires at least one path filter (refusing a whole-tree scan of vendor/)\n",
);
process.exit(1);
}

const child = spawn("bun", ["test", "--randomize", "--seed", "424242", ...args], {
stdio: "inherit",
});

child.on("exit", (code) => {
process.exit(code ?? 1);
});
47 changes: 45 additions & 2 deletions src/agent/tools-mcp-disconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { withMockedModule } from "../../tests/helpers/mock-module.js";
import { createExaMCPServerConfig, type ResolvedMCPServerConfig } from "../mcp/exa.js";
import type { MCPConnectOptions } from "../mcp/client.js";
import type { MCPConnectOptions, MCPTool } from "../mcp/client.js";
import { createPermissionGate } from "../permission/gate.js";
import type { MCPServerState } from "./tools.js";

Expand All @@ -15,6 +15,9 @@ let connectGeneration = 0;
let connectOptions: MCPConnectOptions[] = [];
let releaseDeferredConnect: (() => void) | undefined;
let connectMode: "success" | "deferred" = "success";
// Reconnect tests repoint this to simulate a server whose tool set drifted
// between generations; the default matches the original static payload.
let connectedTools: MCPTool[] = [{ name: "list", description: "List", inputSchema: {} }];

function tempCwd(): string {
const dir = mkdtempSync(join(tmpdir(), "corbits-mcp-disconnect-"));
Expand Down Expand Up @@ -44,7 +47,7 @@ await withMockedModule(
ok: true as const,
client: {
serverName: config.name,
tools: [{ name: "list", description: "List", inputSchema: {} }],
tools: connectedTools,
call: async () => "ok",
close: async () => {
closedClients.push(config.name);
Expand Down Expand Up @@ -105,6 +108,7 @@ beforeEach(() => {
connectOptions = [];
releaseDeferredConnect = undefined;
connectMode = "success";
connectedTools = [{ name: "list", description: "List", inputSchema: {} }];
});

async function waitForConnectStart(timeoutMs = 1000): Promise<void> {
Expand Down Expand Up @@ -175,6 +179,45 @@ describe("disconnectMCPServer", () => {
}
});

test("reconnect after the server's tools drift swaps the mounted set", async () => {
const toolset = await makeToolset();
const states: MCPServerState[] = [];
const announced: ReturnType<typeof toolset.dynamicRunner.currentDefinitions>[] = [];
try {
await toolset.connectMCPServer(acme, callbacks(states));
const acmeNames = (defs: ReturnType<typeof toolset.dynamicRunner.currentDefinitions>) =>
defs.map((d) => d.name).filter((name) => name.startsWith("mcp__acme__"));
expect(acmeNames(toolset.dynamicRunner.currentDefinitions())).toEqual(["mcp__acme__list"]);

// The server redeployed mid-session: same tool name, new schema, plus a
// new tool. Reconnect must mount exactly the drifted set.
connectedTools = [
{ name: "list", description: "List v2", inputSchema: { type: "object", required: ["q"] } },
{ name: "search", description: "Search", inputSchema: {} },
];
await toolset.disconnectMCPServer("acme", callbacks(states));
await toolset.connectMCPServer(acme, {
...callbacks(states),
onToolsChanged: (definitions) => announced.push(definitions),
});

const names = acmeNames(toolset.dynamicRunner.currentDefinitions());
expect(names).toEqual(["mcp__acme__list", "mcp__acme__search"]);

const list = toolset.dynamicRunner
.currentDefinitions()
.find((d) => d.name === "mcp__acme__list");
expect(list?.description).toBe("[acme] List v2");
expect(list?.inputSchema).toEqual({ type: "object", required: ["q"] });

// The stale generation's client was closed and the drift was announced.
expect(closedGenerations).toContain(1);
expect(acmeNames(announced.at(-1) ?? [])).toEqual(["mcp__acme__list", "mcp__acme__search"]);
} finally {
await toolset.dispose();
}
});

test("disconnecting lin does not drop linear tools", async () => {
const toolset = await makeToolset();
const states: MCPServerState[] = [];
Expand Down
Loading
Loading