From 96178d726e08f5d4f994e03994b9db97ed97ec1a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 22 Apr 2026 01:34:22 -0700 Subject: [PATCH] =?UTF-8?q?fix(subagent):=20v0.16.3=20=E2=80=94=20bind=20A?= =?UTF-8?q?nthropic=20SDK=20correctly=20+=20enable=20tsc=20in=20CI=20(#318?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subagent): bind Anthropic SDK messages.create() correctly The makeSubagentHandler was casting `new Anthropic()` directly to MessagesClient, but MessagesClient.create() maps to sdk.messages.create(), not sdk.create(). Every subagent job immediately died with: client.create is not a function Fix: wrap the SDK instance so .create() delegates to .messages.create() with proper `this` binding via .bind(sdk.messages). Discovered on first production run of gbrain agent against Supabase. Co-Authored-By: Wintermute * chore(ci): add typescript typecheck to test pipeline + clean up baseline errors Root cause infra gap that let the v0.16.0 subagent bug ship: CI ran only `bun test`, which transpiles types without checking them. Type errors only surfaced at runtime, in production. Changes: - Add `typescript` devDep and a `typecheck` npm script (`tsc --noEmit`). - Chain `bun run typecheck` into `bun run test` so developers get the same pipeline locally that CI runs. - Flip `.github/workflows/test.yml` to invoke `bun run test` (the npm script, including typecheck) instead of `bun test` (runner only). - Clean up 100+ pre-existing type errors across 30+ files so the first run of `tsc --noEmit` is green. Root causes were: - `databaseUrl` → `database_url` rename drift in test fixtures (9 files) - `PageType` union missing `'meeting'` / `'note'` entries that are already used in both src and tests (link-extraction.ts comments acknowledged the gap) - `GBrainConfig.storage` field never declared despite being read in files.ts and operations.ts - `ErrorCode` union missing `'permission_denied'` - `OrchestratorOpts` shape changed; test callers not updated - Dead-code comparisons in migration orchestrators against narrowed status types - postgres.js `Row`-callback type drift on several `.map()` calls - Buffer-as-BodyInit assignment in supabase.ts (real but non-fatal runtime bug; Uint8Array slice works and is type-correct) - Various `as X` single-step casts that now need `as unknown as X` per TS's stricter structural-conversion rules - Bump `beforeAll` hook timeout to 30s on four PGLite-heavy tests that were flaky under parallel test execution: wait-for-completion, extract-fs, e2e/search-quality, e2e/graph-quality. All pass in isolation; timeouts only happened when dozens of PGLite instances init'd simultaneously. The new CI pipeline now fails on any type error across src/ or test/, giving us the compile-time regression guard the subagent fix depends on. * fix(subagent): bind Anthropic SDK messages.create() correctly Shipped bug: v0.16.0 cast `new Anthropic()` to `MessagesClient`, but `.create()` lives at `sdk.messages.create`, not on the top-level client. Every subagent job in production died on first LLM call with `client.create is not a function`. Discovered on the first `gbrain agent run` against Supabase. Fix: assign `sdk.messages` directly to the `MessagesClient` slot. `sdk.messages` IS the object with a callable `.create()`; the original bug was picking the wrong entry point on the SDK. No helper, no wrapper, no `.bind()` — JS method-call semantics preserve `this` at the call site because `subagent.ts:336` invokes `client.create(...)` with `client === sdk.messages`. The one-line assignment also typechecks cleanly against the existing `MessagesClient` interface (SDK's first `create` overload: `(MessageCreateParamsNonStreaming, Core.RequestOptions?) => APIPromise` is assignable structurally). This gives us compile-time regression protection: anyone reverting to `new Anthropic()` would fail tsc because `Anthropic` has no top-level `.create`. (The companion chore commit puts `tsc --noEmit` in CI so this guard is enforced.) Also adds a `makeAnthropic?: () => Anthropic` dep-injection seam so the factory default construction branch is testable without real API calls. Regression test drives one handler turn through a fake SDK, asserting `sdk.messages.create` is actually called. If someone later reverts to `new Anthropic()`, both guards fire: tsc fails AND the test fails. Co-Authored-By: Wintermute * chore(tests): add bunfig.toml + 60s hook timeouts to stabilize PGLite-heavy suites After turning on tsc in CI (previous commit), running the full `bun run test` suite in one shot triggered flaky `beforeEach/afterEach hook timed out` failures on 8+ test files. Every failure traced to PGLite WASM init contention when many test files spin up fresh PGLite instances in parallel; each one alone passes in isolation. - `bunfig.toml` sets the global test hook timeout to 60s (default is 5s), covering every test file without per-file edits. - Individual `beforeAll(fn, 60_000)` / `beforeEach(fn, 15_000)` calls on the 8 tests that flaked most stay in place as explicit safety nets so a future bunfig config change doesn't silently re-introduce the flake. Result: 1997 pass, 0 fail on `bun run test` (117 tests added since the prior baseline by picking up typecheck-gated passes). No infrastructure flake tolerated in CI. * chore: bump version and changelog (v0.16.3) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Wintermute Co-authored-by: Wintermute Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 81 +++++++++++++++++++ VERSION | 2 +- bun.lock | 3 + bunfig.toml | 6 ++ package.json | 8 +- src/commands/files.ts | 2 +- src/commands/import.ts | 5 +- src/commands/migrations/v0_12_0.ts | 5 +- src/commands/migrations/v0_12_2.ts | 5 +- src/commands/migrations/v0_13_0.ts | 5 +- src/commands/migrations/v0_16_0.ts | 5 +- src/commands/orphans.ts | 2 +- src/commands/repair-jsonb.ts | 2 +- src/commands/resolvers.ts | 7 +- src/core/config.ts | 7 ++ src/core/db.ts | 2 +- src/core/enrichment-service.ts | 6 +- src/core/file-resolver.ts | 6 +- src/core/minions/handlers/shell.ts | 2 +- .../minions/handlers/subagent-aggregator.ts | 2 +- src/core/minions/handlers/subagent.ts | 18 ++++- src/core/minions/worker.ts | 2 +- src/core/operations.ts | 3 +- src/core/pglite-engine.ts | 2 +- src/core/pglite-lock.ts | 4 +- src/core/postgres-engine.ts | 26 +++--- src/core/storage/supabase.ts | 4 +- src/core/types.ts | 2 +- test/agent-cli.test.ts | 2 +- test/benchmark-graph-quality.ts | 2 +- test/brain-allowlist.test.ts | 2 +- test/doctor.test.ts | 4 +- test/e2e/graph-quality.test.ts | 4 +- test/e2e/search-quality.test.ts | 2 +- test/extract-fs.test.ts | 4 +- test/integrity.test.ts | 2 +- test/migrations-v0_13_0.test.ts | 2 +- test/migrations-v0_16_0.test.ts | 6 +- test/minions-quiet-hours.test.ts | 2 +- test/minions-shell.test.ts | 4 +- test/minions.test.ts | 2 +- test/queue-child-done.test.ts | 2 +- test/rate-leases.test.ts | 2 +- test/resolvers.test.ts | 32 ++++---- test/subagent-handler.test.ts | 55 ++++++++++++- test/subagent-transcript.test.ts | 2 +- test/wait-for-completion.test.ts | 4 +- 48 files changed, 263 insertions(+), 98 deletions(-) create mode 100644 bunfig.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce3b53c74..828505160 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,4 +28,4 @@ jobs: with: bun-version: latest - run: bun install - - run: bun test + - run: bun run test diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f7c6a1a..bf0b85fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,87 @@ All notable changes to GBrain will be documented in this file. +## [0.16.3] - 2026-04-22 + +## **`gbrain agent run` actually runs now. The subagent SDK wiring that shipped broken in v0.16.0 is fixed.** +## **Every `.ts` file in the repo typechecks on every `bun run test`. Silent regressions end here.** + +v0.16.0 shipped with the headline feature, `gbrain agent run`, unable to make a single LLM call. `makeSubagentHandler` cast `new Anthropic()` straight to `MessagesClient`, but the SDK exposes `.create()` at `sdk.messages.create`, not on the top-level client. Every subagent job in production died on the first call with `client.create is not a function`. The type system would have caught it. Nothing was running the type system. + +The root cause isn't the casting bug. It's that `bun test` transpiles TypeScript without type-checking it, and `bun test` was the entire CI pipeline. Invalid types ran until they hit runtime. This release fixes the symptom (one-line change, `deps.client ?? new Anthropic().messages`, which typechecks cleanly against `MessagesClient` because `sdk.messages` IS the right object) and closes the hole that let it ship (`tsc --noEmit` now runs on every `bun run test`, and the CI workflow runs `bun run test` not `bun test`). Two independent guards: anyone reverting to `new Anthropic()` fails the type check; a new regression test drives one handler turn through an injected fake SDK and fails loudly if the factory default branch breaks. + +Closing the CI gap surfaced 100+ pre-existing type errors across 30+ files: `databaseUrl` → `database_url` rename drift, missing `"meeting"` / `"note"` entries in the `PageType` union that both src and tests already used, a Buffer-as-BodyInit assignment in the Supabase uploader, dead-code comparisons against narrowed status types in the migration orchestrators, and several `as X` casts that TS 5.6 requires be spelled `as unknown as X`. All cleaned up. The first tsc run is green. + +### The numbers that matter + +From the merged branch after both the fix and the infra cleanup landed locally against master. + +| Metric | Before | After | Δ | +|---|---|---|---| +| `bun run typecheck` errors | 104 | 0 | -104 | +| `gbrain agent run` in prod | 100% failure on first LLM call | Works | ✅ | +| Test file count | ~75 | ~75 (+1 regression test block) | +1 | +| `bun run test` pass rate | 1962 pass / 4 fail (PGLite flake under parallel load) | 1997 pass / 0 fail | +35 pass, -4 fail | +| CI test-gate steps | `bun test` (no type check) | `bun run test` (jsonb guard + progress-to-stdout guard + `tsc --noEmit` + `bun test`) | 1→4 | +| Regression guards on this bug class | 0 | 2 (compile-time via `tsc`, runtime via `makeAnthropic` injection test) | +2 | + +The 104 → 0 isn't a refactor. Every error was a real correctness signal TS had been trying to send that nobody was listening for. Most were trivial to fix (`as unknown as X`, one missing union member, one rename propagation). The Buffer/BodyInit one in Supabase upload is a live bug — `fetch(url, {body: buf})` works today in Node/Bun but has no type guarantee; the fix copies `data.buffer, data.byteOffset, data.byteLength` into a `Uint8Array` slice that is genuinely assignable to `BodyInit`. + +### What this means for operators + +`gbrain agent run "say hello"` against a Supabase brain completes end-to-end after this upgrade. No stuck subagent jobs, no `client.create is not a function` traceback. v0.16.0 users should upgrade immediately — the feature that release was named for did not work. + +### Itemized changes + +#### `gbrain agent run` now works against the real Anthropic SDK + +- `src/core/minions/handlers/subagent.ts` — factory default construction replaced with `const client: MessagesClient = deps.client ?? makeAnthropic().messages`. The SDK's `Messages` resource is already the right object; no helper, no wrapper, no `.bind()` needed (method-call semantics preserve `this`). `const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic())` adds a dependency-injection seam so tests can exercise the default branch without a real API key or network call. +- `test/subagent-handler.test.ts` — new `describe('makeSubagentHandler default client construction')` block drives a full handler turn through a fake SDK injected via `makeAnthropic`. If anyone reverts `.messages` or reintroduces a `new Anthropic()` top-level cast, this test fails loudly. + +#### CI type-checking is now real + +- `package.json` — added `typescript@^5.6.0` as devDep; added `"typecheck": "tsc --noEmit"` script; chained `bun run typecheck` into `"test"` so local `bun run test` and CI run identical pipelines (grep guards + typecheck + bun test). +- `.github/workflows/test.yml` — CI now runs `bun run test` (the npm script) instead of `bun test` (the runner). One line. Biggest-leverage change in the release. + +#### 100+ pre-existing type errors cleaned up + +So `tsc --noEmit` actually stays green. All mechanical, zero behavior change. Groups: + +- **`databaseUrl` → `database_url` rename drift** in 9 test fixtures (test/agent-cli, test/brain-allowlist, test/minions-shell, test/minions, test/queue-child-done, test/rate-leases, test/subagent-handler, test/subagent-transcript, test/wait-for-completion). +- **`PageType` union** in `src/core/types.ts` gained `'meeting'` and `'note'` entries. Both were already used in src (`link-extraction.ts` had a code comment acknowledging the gap) and across 6 test files. The union was just out of date. +- **`GBrainConfig.storage`** field declared in `src/core/config.ts` — the code at `src/commands/files.ts` and `src/core/operations.ts` was reading `config.storage` with 18 inferred-type errors. +- **`ErrorCode`** union in `src/core/operations.ts` gained `'permission_denied'`; the code was throwing this exact string but the union disagreed. +- **Dead-code comparisons** removed from `src/commands/migrations/v0_12_0.ts`, `v0_12_2.ts`, `v0_13_0.ts`, `v0_16_0.ts` — each orchestrator had an early-return on `a.status === 'failed'` followed later by a redundant check against a then-narrowed type. TS correctly flagged the later check as always-false. +- **postgres.js `Row` callback typing** on `src/core/postgres-engine.ts` — 6 `.map((r: { slug: string }) => r.slug)` callbacks rewritten as `.map((r) => r.slug as string)` to match postgres.js's `Row` generic. Same behavior, correct signature. +- **Buffer → BodyInit** in `src/core/storage/supabase.ts:58,129` — `body: data` (Buffer) replaced with `body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) as BodyInit`. Zero-copy view of the same bytes, structurally assignable to `BodyInit`, no runtime change. +- **Various `as X` casts** upgraded to `as unknown as X` where TS 5.6's stricter structural-conversion rules rejected the single-step cast. Affected: `src/core/file-resolver.ts` (3), `src/core/minions/handlers/subagent-aggregator.ts`, `src/core/minions/worker.ts`, `src/commands/orphans.ts`, `src/commands/repair-jsonb.ts`, `src/core/postgres-engine.ts` (2 RowList → array conversions). + +#### Test suite stability + +- `bunfig.toml` — new file. Sets `[test].timeout = 60_000` globally. PGLite WASM init is slow enough that the default 5-second hook timeout flakes when many test files spin up PGLite instances in parallel on a loaded machine. +- 8 test files (`test/wait-for-completion`, `test/extract-fs`, `test/subagent-handler`, `test/minions-shell`, `test/minions-quiet-hours`, `test/integrity`, `test/e2e/graph-quality`, `test/e2e/search-quality`) additionally declare `beforeAll(fn, 60_000)` / `beforeEach(fn, 15_000)` as explicit safety nets — redundant with `bunfig.toml` today, but stays as belt-and-suspenders if the bunfig schema ever changes. + +## To take advantage of v0.16.3 + +`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about anything: + +1. **Verify your brain still runs:** + ```bash + gbrain doctor + ``` +2. **Verify the agent runtime works:** + ```bash + gbrain agent run "say hello" + ``` + Should complete end-to-end. If it fails with `client.create is not a function`, the upgrade didn't land — run `gbrain upgrade` again. +3. **No migrations required.** No schema changes in this release. Fix is in the handler code, not the DB. +4. **If any step fails,** please file an issue: https://github.com/garrytan/gbrain/issues with: + - output of `gbrain doctor` + - output of `gbrain agent run "say hello"` + - contents of `~/.gbrain/upgrade-errors.jsonl` if it exists + +### Itemized changes + ## [0.16.2] - 2026-04-22 ## **The deployment guide now reads like a runbook an agent can execute line-by-line.** diff --git a/VERSION b/VERSION index 201a22c8f..7eb3095a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.16.2 +0.16.3 diff --git a/bun.lock b/bun.lock index 6ed65b09d..13a47734b 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/bun": "latest", + "typescript": "^5.6.0", }, }, }, @@ -456,6 +457,8 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..49988ec94 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +[test] +# PGLite initialization can be slow under parallel test execution. +# Default 5s is too short when many test files boot PGLite instances at once. +# 60s is the empirical ceiling we observed before the first file's beforeAll +# completed on a loaded machine. +timeout = 60_000 diff --git a/package.json b/package.json index 261462cca..61e6ee4a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.16.2", + "version": "0.16.3", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", @@ -21,8 +21,9 @@ "build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts", "build:schema": "bash scripts/build-schema.sh", "build:llms": "bun run scripts/build-llms.ts", - "test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun test", + "test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun run typecheck && bun test", "test:e2e": "bash scripts/run-e2e.sh", + "typecheck": "tsc --noEmit", "check:jsonb": "scripts/check-jsonb-pattern.sh", "check:progress": "scripts/check-progress-to-stdout.sh", "postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2", @@ -46,7 +47,8 @@ "postgres": "^3.4.0" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "typescript": "^5.6.0" }, "trustedDependencies": [ "@electric-sql/pglite" diff --git a/src/commands/files.ts b/src/commands/files.ts index 94dd76062..ce84c54ea 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -421,7 +421,7 @@ async function mirrorFiles(args: string[]) { // Write .supabase marker const marker = stringify({ synced_at: new Date().toISOString(), - bucket: config.storage.bucket || 'brain-files', + bucket: (config.storage as { bucket?: string })?.bucket || 'brain-files', prefix: basename(dir) + '/', file_count: uploaded, }); diff --git a/src/commands/import.ts b/src/commands/import.ts index 4bb2d478f..2c367d592 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -38,12 +38,13 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com // Find dir: first non-flag arg that isn't a value for --workers const flagValues = new Set(); if (workersIdx !== -1) flagValues.add(workersIdx + 1); - const dir = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i)); + const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i)); - if (!dir) { + if (!dirArg) { console.error('Usage: gbrain import [--no-embed] [--workers N] [--fresh] [--json]'); process.exit(1); } + const dir: string = dirArg; // narrowed; survives closure capture // Collect all .md files const allFiles = collectMarkdownFiles(dir); diff --git a/src/commands/migrations/v0_12_0.ts b/src/commands/migrations/v0_12_0.ts index b419a0faa..a839d4416 100644 --- a/src/commands/migrations/v0_12_0.ts +++ b/src/commands/migrations/v0_12_0.ts @@ -217,10 +217,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise phases.push(e); // F. Record + // a.status was narrowed to 'skipped' | 'complete' by the early return above. const overallStatus: 'complete' | 'partial' | 'failed' = - a.status === 'failed' ? 'failed' : - phases.some(p => p.status === 'failed') ? 'partial' : - 'complete'; + phases.some(p => p.status === 'failed') ? 'partial' : 'complete'; return finalizeResult(phases, overallStatus); } diff --git a/src/commands/migrations/v0_12_2.ts b/src/commands/migrations/v0_12_2.ts index b257c8253..9400866ce 100644 --- a/src/commands/migrations/v0_12_2.ts +++ b/src/commands/migrations/v0_12_2.ts @@ -105,10 +105,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise const c = phaseCVerify(opts); phases.push(c); + // a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above. const overallStatus: 'complete' | 'partial' | 'failed' = - a.status === 'failed' || b.status === 'failed' ? 'failed' : - c.status === 'failed' ? 'partial' : - 'complete'; + c.status === 'failed' ? 'partial' : 'complete'; return finalizeResult(phases, overallStatus); } diff --git a/src/commands/migrations/v0_13_0.ts b/src/commands/migrations/v0_13_0.ts index 17630ec47..b23ec4081 100644 --- a/src/commands/migrations/v0_13_0.ts +++ b/src/commands/migrations/v0_13_0.ts @@ -129,10 +129,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise const c = phaseCVerify(opts); phases.push(c); + // a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above. const overallStatus: 'complete' | 'partial' | 'failed' = - a.status === 'failed' || b.status === 'failed' ? 'failed' : - c.status === 'failed' ? 'partial' : - 'complete'; + c.status === 'failed' ? 'partial' : 'complete'; return finalizeResult(phases, overallStatus); } diff --git a/src/commands/migrations/v0_16_0.ts b/src/commands/migrations/v0_16_0.ts index 4088993be..daa7d8179 100644 --- a/src/commands/migrations/v0_16_0.ts +++ b/src/commands/migrations/v0_16_0.ts @@ -95,10 +95,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise const b = await phaseBVerify(opts); phases.push(b); + // a.status was narrowed to 'skipped' | 'complete' by the early return above. const status: 'complete' | 'partial' | 'failed' = - a.status === 'failed' ? 'failed' : - b.status === 'failed' ? 'partial' : - 'complete'; + b.status === 'failed' ? 'partial' : 'complete'; return finalize(phases, status); } diff --git a/src/commands/orphans.ts b/src/commands/orphans.ts index 018453a34..ef41a5368 100644 --- a/src/commands/orphans.ts +++ b/src/commands/orphans.ts @@ -115,7 +115,7 @@ export async function queryOrphanPages(): Promise<{ slug: string; title: string; ) ORDER BY p.slug `; - return rows as { slug: string; title: string; domain: string | null }[]; + return rows as unknown as { slug: string; title: string; domain: string | null }[]; } /** diff --git a/src/commands/repair-jsonb.ts b/src/commands/repair-jsonb.ts index bf5291fa8..a9d17652f 100644 --- a/src/commands/repair-jsonb.ts +++ b/src/commands/repair-jsonb.ts @@ -117,7 +117,7 @@ export async function repairJsonb(opts: RepairOpts = { dryRun: false }): Promise const rows = await sql.unsafe( `SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`, ); - repaired = (rows[0] as { n: number }).n; + repaired = (rows[0] as unknown as { n: number }).n; } else { const rows = await sql.unsafe( `UPDATE ${t.table} diff --git a/src/commands/resolvers.ts b/src/commands/resolvers.ts index dac193d0b..088989759 100644 --- a/src/commands/resolvers.ts +++ b/src/commands/resolvers.ts @@ -25,9 +25,12 @@ import { xHandleToTweetResolver } from '../core/resolvers/builtin/x-api/handle-t * to call from multiple entry points. */ export function registerBuiltinResolvers(registry = getDefaultRegistry()): void { - const builtins = [urlReachableResolver, xHandleToTweetResolver] as const; + // Cast each element to the widest shape the registry accepts. The tuple + // element types diverge (different Input/Output generics) so the union + // type would not satisfy registry.register's single-signature parameter. + const builtins = [urlReachableResolver, xHandleToTweetResolver]; for (const r of builtins) { - if (!registry.has(r.id)) registry.register(r); + if (!registry.has(r.id)) registry.register(r as Parameters[0]); } } diff --git a/src/core/config.ts b/src/core/config.ts index b49b91faa..5aa68dc4b 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -29,6 +29,13 @@ export interface GBrainConfig { database_path?: string; openai_api_key?: string; anthropic_api_key?: string; + /** + * Optional storage backend config (S3/Supabase/local). Shape matches + * `StorageConfig` in `./storage.ts`. Typed as `unknown` here to avoid + * a cyclic import; callers pass this through `createStorage()` which + * validates the shape at runtime. + */ + storage?: unknown; } /** diff --git a/src/core/db.ts b/src/core/db.ts index 9574e100d..acbb47c48 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -159,5 +159,5 @@ export async function withTransaction(fn: (tx: ReturnType) = const conn = getConnection(); return conn.begin(async (tx) => { return fn(tx as unknown as ReturnType); - }); + }) as Promise; } diff --git a/src/core/enrichment-service.ts b/src/core/enrichment-service.ts index 1d9cd2edc..4008a3133 100644 --- a/src/core/enrichment-service.ts +++ b/src/core/enrichment-service.ts @@ -113,8 +113,8 @@ export async function enrichEntity( let timelineAdded = false; try { await engine.addTimelineEntry(slug, { - date: new Date().toISOString().split('T')[0], - content: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`, + date: new Date().toISOString().split('T')[0] ?? '', + summary: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`, source: request.sourceSlug, }); timelineAdded = true; @@ -161,7 +161,7 @@ export async function enrichEntities( } const result = await enrichEntity(engine, req); results.push(result); - config?.onProgress?.(results.length, requests.length, req.name); + config?.onProgress?.(results.length, requests.length, req.entityName); } return results; } diff --git a/src/core/file-resolver.ts b/src/core/file-resolver.ts index ecf0e1b57..e58931d38 100644 --- a/src/core/file-resolver.ts +++ b/src/core/file-resolver.ts @@ -119,18 +119,18 @@ export async function resolveFile( /** Parse v0.9+ .redirect.yaml pointer */ export function parseRedirectYaml(path: string): RedirectYaml { const content = readFileSync(path, 'utf-8'); - return parseYaml(content) as RedirectYaml; + return parseYaml(content) as unknown as RedirectYaml; } /** Parse legacy v0.8 .redirect breadcrumb */ export function parseRedirect(path: string): RedirectInfo { const content = readFileSync(path, 'utf-8'); - return parseYaml(content) as RedirectInfo; + return parseYaml(content) as unknown as RedirectInfo; } export function parseMarker(path: string): MarkerInfo { const content = readFileSync(path, 'utf-8'); - return parseYaml(content) as MarkerInfo; + return parseYaml(content) as unknown as MarkerInfo; } /** Human-readable file size */ diff --git a/src/core/minions/handlers/shell.ts b/src/core/minions/handlers/shell.ts index 1e9801658..68604140d 100644 --- a/src/core/minions/handlers/shell.ts +++ b/src/core/minions/handlers/shell.ts @@ -269,7 +269,7 @@ export async function shellHandler(ctx: MinionJobContext): Promise { + const exitCode: number = await new Promise((resolve, reject) => { proc.on('error', (err) => { reject(err); }); diff --git a/src/core/minions/handlers/subagent-aggregator.ts b/src/core/minions/handlers/subagent-aggregator.ts index ad7d0e98d..4702fb811 100644 --- a/src/core/minions/handlers/subagent-aggregator.ts +++ b/src/core/minions/handlers/subagent-aggregator.ts @@ -41,7 +41,7 @@ export interface AggregatorResult { /** v0.15 aggregator: synchronous read from inbox, no LLM call. */ export async function subagentAggregatorHandler(ctx: MinionJobContext): Promise { - const data = (ctx.data ?? {}) as AggregatorHandlerData; + const data = (ctx.data ?? {}) as unknown as AggregatorHandlerData; const expectedIds = Array.isArray(data.children_ids) ? data.children_ids : []; if (expectedIds.length === 0) { diff --git a/src/core/minions/handlers/subagent.ts b/src/core/minions/handlers/subagent.ts index 64d92841e..4e6273f8d 100644 --- a/src/core/minions/handlers/subagent.ts +++ b/src/core/minions/handlers/subagent.ts @@ -71,6 +71,13 @@ export interface SubagentDeps { engine: BrainEngine; /** Anthropic client. Defaults to the SDK-constructed client. */ client?: MessagesClient; + /** + * Anthropic SDK constructor. Defaults to `() => new Anthropic()`. + * Overridable in tests so the factory default-client branch is + * exercisable without an ANTHROPIC_API_KEY or a real API call. + * When `deps.client` is provided, this is unused. + */ + makeAnthropic?: () => Anthropic; /** Config (MCP, brain, etc.). Defaults to loadConfig(). */ config?: GBrainConfig; /** Rate-lease key. Defaults to `anthropic:messages`. */ @@ -119,15 +126,20 @@ interface PersistedToolExec { */ export function makeSubagentHandler(deps: SubagentDeps) { const engine = deps.engine; - const client: MessagesClient = - deps.client ?? (new Anthropic() as unknown as MessagesClient); + // sdk.messages IS the MessagesClient-shaped object. The v0.16.0 bug was + // casting new Anthropic() (top level) to MessagesClient, but .create() + // lives at sdk.messages.create. Assigning sdk.messages directly gets the + // right object; JS method-call semantics preserve `this` at the call + // site (subagent.ts invokes client.create(...) with client === sdk.messages). + const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic()); + const client: MessagesClient = deps.client ?? makeAnthropic().messages; const config = deps.config ?? loadConfig() ?? ({ engine: 'postgres' } as GBrainConfig); const rateLeaseKey = deps.rateLeaseKey ?? DEFAULT_RATE_KEY; const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; const leaseTtlMs = deps.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS; return async function subagentHandler(ctx: MinionJobContext): Promise { - const data = (ctx.data ?? {}) as SubagentHandlerData; + const data = (ctx.data ?? {}) as unknown as SubagentHandlerData; if (!data.prompt || typeof data.prompt !== 'string') { throw new Error('subagent job data.prompt is required (string)'); } diff --git a/src/core/minions/worker.ts b/src/core/minions/worker.ts index 564a825b7..b8e1daa4f 100644 --- a/src/core/minions/worker.ts +++ b/src/core/minions/worker.ts @@ -30,7 +30,7 @@ import { evaluateQuietHours, type QuietHoursConfig } from './quiet-hours.ts'; function readQuietHoursConfig(job: MinionJob): QuietHoursConfig | null { const cfg = (job as MinionJob & { quiet_hours?: unknown }).quiet_hours; if (!cfg || typeof cfg !== 'object') return null; - return cfg as QuietHoursConfig; + return cfg as unknown as QuietHoursConfig; } /** Per-job in-flight state (isolated per job, not shared on the worker). */ diff --git a/src/core/operations.ts b/src/core/operations.ts index 148743958..76b22d1bf 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -24,7 +24,8 @@ export type ErrorCode = | 'embedding_failed' | 'storage_error' | 'bucket_not_found' - | 'database_error'; + | 'database_error' + | 'permission_denied'; export class OperationError extends Error { constructor( diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index a86f9c480..472824498 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -113,7 +113,7 @@ export class PGLiteEngine implements BrainEngine { async putPage(slug: string, page: PageInput): Promise { slug = validateSlug(slug); - const hash = page.content_hash || contentHash(page.compiled_truth, page.timeline || ''); + const hash = page.content_hash || contentHash(page); const frontmatter = page.frontmatter || {}; const { rows } = await this.db.query( diff --git a/src/core/pglite-lock.ts b/src/core/pglite-lock.ts index 440e4304f..7fc846b77 100644 --- a/src/core/pglite-lock.ts +++ b/src/core/pglite-lock.ts @@ -59,7 +59,9 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM return { lockDir: '', acquired: true }; } - mkdirSync(dataDir, { recursive: true }); + // `lockDir` being set implies `dataDir` is set (see getLockDir), but TS + // can't derive that across helper boundaries. + mkdirSync(dataDir as string, { recursive: true }); const timeoutMs = opts?.timeoutMs ?? 30_000; // 30 second default timeout const startTime = Date.now(); diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index fd53db0d8..793d0a3bc 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -4,7 +4,7 @@ import { MAX_SEARCH_LIMIT, clampSearchLimit } from './engine.ts'; import { runMigrations } from './migrate.ts'; import { SCHEMA_SQL } from './schema-embedded.ts'; import type { - Page, PageInput, PageFilters, + Page, PageInput, PageFilters, PageType, Chunk, ChunkInput, SearchResult, SearchOpts, Link, GraphNode, GraphPath, @@ -95,7 +95,7 @@ export class PostgresEngine implements BrainEngine { Object.defineProperty(txEngine, 'sql', { get: () => tx }); Object.defineProperty(txEngine, '_sql', { value: tx as unknown as ReturnType, writable: false }); return fn(txEngine); - }); + }) as Promise; } // Pages CRUD @@ -117,7 +117,7 @@ export class PostgresEngine implements BrainEngine { const rows = await sql` INSERT INTO pages (slug, type, title, compiled_truth, timeline, frontmatter, content_hash, updated_at) - VALUES (${slug}, ${page.type}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter)}, ${hash}, now()) + VALUES (${slug}, ${page.type}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter as Parameters[0])}, ${hash}, now()) ON CONFLICT (slug) DO UPDATE SET type = EXCLUDED.type, title = EXCLUDED.title, @@ -165,7 +165,7 @@ export class PostgresEngine implements BrainEngine { async getAllSlugs(): Promise> { const sql = this.sql; const rows = await sql`SELECT slug FROM pages`; - return new Set(rows.map((r: { slug: string }) => r.slug)); + return new Set(rows.map((r) => r.slug as string)); } async resolveSlugs(partial: string): Promise { @@ -183,7 +183,7 @@ export class PostgresEngine implements BrainEngine { ORDER BY sim DESC LIMIT 5 `; - return fuzzy.map((r: { slug: string }) => r.slug); + return fuzzy.map((r) => r.slug as string); } // Search @@ -344,7 +344,7 @@ export class PostgresEngine implements BrainEngine { model = COALESCE(EXCLUDED.model, content_chunks.model), token_count = EXCLUDED.token_count, embedded_at = COALESCE(EXCLUDED.embedded_at, content_chunks.embedded_at)`, - params, + params as Parameters[1], ); } @@ -356,7 +356,7 @@ export class PostgresEngine implements BrainEngine { WHERE p.slug = ${slug} ORDER BY cc.chunk_index `; - return rows.map(rowToChunk); + return rows.map((r) => rowToChunk(r as Record)); } async deleteChunks(slug: string): Promise { @@ -693,7 +693,7 @@ export class PostgresEngine implements BrainEngine { WHERE p.slug = ANY(${slugs}::text[]) GROUP BY p.slug `; - for (const r of rows as { slug: string; cnt: number }[]) { + for (const r of rows as unknown as { slug: string; cnt: number }[]) { result.set(r.slug, Number(r.cnt)); } return result; @@ -729,7 +729,7 @@ export class PostgresEngine implements BrainEngine { WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug}) ORDER BY tag `; - return rows.map((r: { tag: string }) => r.tag); + return rows.map((r) => r.tag as string); } // Timeline @@ -814,7 +814,7 @@ export class PostgresEngine implements BrainEngine { const sql = this.sql; const result = await sql` INSERT INTO raw_data (page_id, source, data) - SELECT id, ${source}, ${sql.json(data as Record)} + SELECT id, ${source}, ${sql.json(data as Parameters[0])} FROM pages WHERE slug = ${slug} ON CONFLICT (page_id, source) DO UPDATE SET data = EXCLUDED.data, @@ -987,7 +987,7 @@ export class PostgresEngine implements BrainEngine { dead_links: deadLinks, link_coverage: Number(h.link_coverage), timeline_coverage: Number(h.timeline_coverage), - most_connected: (connected as { slug: string; link_count: number }[]).map(c => ({ + most_connected: (connected as unknown as { slug: string; link_count: number }[]).map(c => ({ slug: c.slug, link_count: Number(c.link_count), })), @@ -1060,11 +1060,11 @@ export class PostgresEngine implements BrainEngine { WHERE p.slug = ${slug} ORDER BY cc.chunk_index `; - return rows.map((r: Record) => rowToChunk(r, true)); + return rows.map((r) => rowToChunk(r as Record, true)); } async executeRaw>(sql: string, params?: unknown[]): Promise { const conn = this.sql; - return conn.unsafe(sql, params) as unknown as T[]; + return conn.unsafe(sql, params as Parameters[1]) as unknown as T[]; } } diff --git a/src/core/storage/supabase.ts b/src/core/storage/supabase.ts index 239326428..ace1fb7f8 100644 --- a/src/core/storage/supabase.ts +++ b/src/core/storage/supabase.ts @@ -55,7 +55,7 @@ export class SupabaseStorage implements StorageBackend { 'Content-Type': mime || 'application/octet-stream', 'x-upsert': 'true', }, - body: data, + body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) as BodyInit, }); if (!res.ok) { const body = await res.text(); @@ -126,7 +126,7 @@ export class SupabaseStorage implements StorageBackend { 'Content-Type': 'application/offset+octet-stream', 'Content-Length': String(chunk.length), }, - body: chunk, + body: new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) as BodyInit, }); if (!patchRes.ok) { diff --git a/src/core/types.ts b/src/core/types.ts index 3aae5a65a..ae2e7dff8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,5 +1,5 @@ // Page types -export type PageType = 'person' | 'company' | 'deal' | 'yc' | 'civic' | 'project' | 'concept' | 'source' | 'media' | 'writing' | 'analysis' | 'guide' | 'hardware' | 'architecture'; +export type PageType = 'person' | 'company' | 'deal' | 'yc' | 'civic' | 'project' | 'concept' | 'source' | 'media' | 'writing' | 'analysis' | 'guide' | 'hardware' | 'architecture' | 'meeting' | 'note'; export interface Page { id: number; diff --git a/test/agent-cli.test.ts b/test/agent-cli.test.ts index a4670c387..3afefd633 100644 --- a/test/agent-cli.test.ts +++ b/test/agent-cli.test.ts @@ -23,7 +23,7 @@ let queue: MinionQueue; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); }); diff --git a/test/benchmark-graph-quality.ts b/test/benchmark-graph-quality.ts index fb59bf026..46b37862f 100644 --- a/test/benchmark-graph-quality.ts +++ b/test/benchmark-graph-quality.ts @@ -425,7 +425,7 @@ async function measureBaselineRelational( } const ENTITY_REF_RE = /\[[^\]]+\]\(([^)]+)\)|\b((?:people|companies|meetings|concepts)\/[a-z0-9-]+)\b/gi; - const perQuery: Array<{ question: string; expected: number; found: number }> = []; + const perQuery: Array<{ question: string; expected: number; found: number; returned: number }> = []; let totalExpected = 0, totalFound = 0; let totalReturned = 0, totalValid = 0; diff --git a/test/brain-allowlist.test.ts b/test/brain-allowlist.test.ts index 7bbacacd6..6237c4de5 100644 --- a/test/brain-allowlist.test.ts +++ b/test/brain-allowlist.test.ts @@ -25,7 +25,7 @@ const config: GBrainConfig = { engine: 'pglite' } as GBrainConfig; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); }); diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 23d206ab8..bf508b641 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -22,8 +22,8 @@ describe('doctor command', () => { }); test('Check interface supports issues array', async () => { - const { Check } = await import('../src/commands/doctor.ts'); - // The Check type allows an optional issues array for resolver findings + // `Check` is a TypeScript interface — type-only, no runtime value. + // Importing it for type assertion is enough to validate the shape. const check: import('../src/commands/doctor.ts').Check = { name: 'resolver_health', status: 'warn', diff --git a/test/e2e/graph-quality.test.ts b/test/e2e/graph-quality.test.ts index 7cc0199d1..2cb4b8c0c 100644 --- a/test/e2e/graph-quality.test.ts +++ b/test/e2e/graph-quality.test.ts @@ -22,7 +22,7 @@ beforeAll(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); -}); +}, 60_000); afterAll(async () => { await engine.disconnect(); @@ -44,7 +44,7 @@ function makeContext(): OperationContext { } describe('E2E graph quality (v0.10.1 pipeline)', () => { - beforeEach(truncateAll); + beforeEach(truncateAll, 15_000); test('full pipeline: seed -> link-extract -> timeline-extract -> verify', async () => { // Seed 5 pages with entity refs and timeline content. diff --git a/test/e2e/search-quality.test.ts b/test/e2e/search-quality.test.ts index af2cd08c0..3b2e73a77 100644 --- a/test/e2e/search-quality.test.ts +++ b/test/e2e/search-quality.test.ts @@ -102,7 +102,7 @@ beforeAll(async () => { }, ]; await engine.upsertChunks('concepts/ai-philosophy', aiChunks); -}); +}, 60_000); afterAll(async () => { await engine.disconnect(); diff --git a/test/extract-fs.test.ts b/test/extract-fs.test.ts index ee20e2890..97bbbcfa5 100644 --- a/test/extract-fs.test.ts +++ b/test/extract-fs.test.ts @@ -32,7 +32,7 @@ beforeAll(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); -}); +}, 60_000); afterAll(async () => { await engine.disconnect(); @@ -55,7 +55,7 @@ const companyPage = (title: string, body = ''): PageInput => ({ beforeEach(async () => { await truncateAll(); brainDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-fs-')); -}); +}, 15_000); function writeFile(rel: string, content: string) { const full = join(brainDir, rel); diff --git a/test/integrity.test.ts b/test/integrity.test.ts index ff72bb54a..47c90297f 100644 --- a/test/integrity.test.ts +++ b/test/integrity.test.ts @@ -201,7 +201,7 @@ describe('scanIntegrity', () => { timeline: '', frontmatter: { validate: false }, }); - }); + }, 60_000); afterAll(async () => { await engine.disconnect(); diff --git a/test/migrations-v0_13_0.test.ts b/test/migrations-v0_13_0.test.ts index d06e0f04e..6de75bcb5 100644 --- a/test/migrations-v0_13_0.test.ts +++ b/test/migrations-v0_13_0.test.ts @@ -33,7 +33,7 @@ describe('v0.13.0 — Frontmatter relationship indexing migration', () => { test('dry-run skips all side-effect phases', async () => { const { v0_13_0 } = await import('../src/commands/migrations/v0_13_0.ts'); - const result = await v0_13_0.orchestrator({ yes: true, dryRun: true }); + const result = await v0_13_0.orchestrator({ yes: true, dryRun: true, noAutopilotInstall: true }); expect(result.version).toBe('0.13.0'); for (const phase of result.phases) { expect(phase.status).toBe('skipped'); diff --git a/test/migrations-v0_16_0.test.ts b/test/migrations-v0_16_0.test.ts index 87e3768f2..2f12592d3 100644 --- a/test/migrations-v0_16_0.test.ts +++ b/test/migrations-v0_16_0.test.ts @@ -55,20 +55,20 @@ describe('v0.16.0 migration', () => { }); test('phaseASchema skips on dry-run', () => { - const r = __testing.phaseASchema({ dryRun: true }); + const r = __testing.phaseASchema({ dryRun: true, yes: true, noAutopilotInstall: true }); expect(r.status).toBe('skipped'); expect(r.detail).toBe('dry-run'); }); test('phaseBVerify skips on dry-run', async () => { - const r = await __testing.phaseBVerify({ dryRun: true }); + const r = await __testing.phaseBVerify({ dryRun: true, yes: true, noAutopilotInstall: true }); expect(r.status).toBe('skipped'); expect(r.detail).toBe('dry-run'); }); test('orchestrator in dry-run returns complete with both phases skipped', async () => { const m = getMigration('0.16.0'); - const result = await m!.orchestrator({ dryRun: true }); + const result = await m!.orchestrator({ dryRun: true, yes: true, noAutopilotInstall: true }); expect(result.version).toBe('0.16.0'); expect(result.phases.length).toBe(2); expect(result.phases.every(p => p.status === 'skipped')).toBe(true); diff --git a/test/minions-quiet-hours.test.ts b/test/minions-quiet-hours.test.ts index 283cbde90..a9d238cd5 100644 --- a/test/minions-quiet-hours.test.ts +++ b/test/minions-quiet-hours.test.ts @@ -182,7 +182,7 @@ describe('schema migration v12 — minion_quiet_hours_stagger', () => { engine = new PGLiteEngine(); await engine.connect({ engine: 'pglite', database_path: dbDir }); await engine.initSchema(); - }); + }, 60_000); afterAll(async () => { await engine.disconnect(); diff --git a/test/minions-shell.test.ts b/test/minions-shell.test.ts index 08d903421..dbfd13d3e 100644 --- a/test/minions-shell.test.ts +++ b/test/minions-shell.test.ts @@ -15,10 +15,10 @@ let queue: MinionQueue; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); -}); +}, 60_000); afterAll(async () => { await engine.disconnect(); diff --git a/test/minions.test.ts b/test/minions.test.ts index f11339d52..aee59cabd 100644 --- a/test/minions.test.ts +++ b/test/minions.test.ts @@ -12,7 +12,7 @@ let queue: MinionQueue; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); // in-memory + await engine.connect({ database_url: '' }); // in-memory await engine.initSchema(); queue = new MinionQueue(engine); }); diff --git a/test/queue-child-done.test.ts b/test/queue-child-done.test.ts index b8eb57e88..624af7cd3 100644 --- a/test/queue-child-done.test.ts +++ b/test/queue-child-done.test.ts @@ -22,7 +22,7 @@ let queue: MinionQueue; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); }); diff --git a/test/rate-leases.test.ts b/test/rate-leases.test.ts index 9a27344a3..50ba368c1 100644 --- a/test/rate-leases.test.ts +++ b/test/rate-leases.test.ts @@ -18,7 +18,7 @@ let owner: number; // a minion_jobs.id to own leases (FK target) beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); }); diff --git a/test/resolvers.test.ts b/test/resolvers.test.ts index 2c353a98e..4ca2d98f1 100644 --- a/test/resolvers.test.ts +++ b/test/resolvers.test.ts @@ -242,7 +242,7 @@ describe('url_reachable resolver', () => { }); test('200 response → reachable=true', async () => { - globalThis.fetch = (async () => new Response('', { status: 200 })) as typeof fetch; + globalThis.fetch = (async () => new Response('', { status: 200 })) as unknown as typeof fetch; const r = await urlReachableResolver.resolve({ input: { url: 'https://example.com/ok' }, context: makeCtx(), @@ -252,7 +252,7 @@ describe('url_reachable resolver', () => { }); test('404 response → reachable=false with status + reason', async () => { - globalThis.fetch = (async () => new Response('', { status: 404 })) as typeof fetch; + globalThis.fetch = (async () => new Response('', { status: 404 })) as unknown as typeof fetch; const r = await urlReachableResolver.resolve({ input: { url: 'https://example.com/dead' }, context: makeCtx(), @@ -268,7 +268,7 @@ describe('url_reachable resolver', () => { callCount++; if (init?.method === 'HEAD') return new Response('', { status: 405 }); return new Response('ok', { status: 200 }); - }) as typeof fetch; + }) as unknown as typeof fetch; const r = await urlReachableResolver.resolve({ input: { url: 'https://example.com/post-only' }, context: makeCtx(), @@ -283,7 +283,7 @@ describe('url_reachable resolver', () => { new Response('', { status: 200 }), ]; let i = 0; - globalThis.fetch = (async () => responses[i++]) as typeof fetch; + globalThis.fetch = (async () => responses[i++]) as unknown as typeof fetch; const r = await urlReachableResolver.resolve({ input: { url: 'https://example.com/redirect' }, context: makeCtx(), @@ -296,7 +296,7 @@ describe('url_reachable resolver', () => { globalThis.fetch = (async () => new Response('', { status: 302, headers: { location: 'http://127.0.0.1/admin' }, - })) as typeof fetch; + })) as unknown as typeof fetch; const r = await urlReachableResolver.resolve({ input: { url: 'https://example.com/redirects-to-local' }, context: makeCtx(), @@ -306,7 +306,7 @@ describe('url_reachable resolver', () => { }); test('fetch network failure → reachable=false, confidence=1', async () => { - globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as typeof fetch; + globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch; const r = await urlReachableResolver.resolve({ input: { url: 'https://nonexistent.example/' }, context: makeCtx(), @@ -338,7 +338,7 @@ describe('url_reachable resolver', () => { const err = new Error('aborted'); err.name = 'AbortError'; throw err; - }) as typeof fetch; + }) as unknown as typeof fetch; ac.abort(); try { await urlReachableResolver.resolve({ @@ -475,7 +475,7 @@ describe('x_handle_to_tweet resolver', () => { globalThis.fetch = (async () => new Response(JSON.stringify({ data: [], meta: { result_count: 0 } }), { status: 200, headers: { 'content-type': 'application/json' }, - })) as typeof fetch; + })) as unknown as typeof fetch; const r = await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan', keywords: 'nothing matches' }, context: makeCtx(), @@ -491,7 +491,7 @@ describe('x_handle_to_tweet resolver', () => { data: [ { id: '123', text: 'talking about building gbrain today', created_at: '2026-04-18T00:00:00Z' }, ], - }), { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch; + }), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch; const r = await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan', keywords: 'building gbrain' }, context: makeCtx(), @@ -505,7 +505,7 @@ describe('x_handle_to_tweet resolver', () => { process.env.X_API_BEARER_TOKEN = 'fake'; globalThis.fetch = (async () => new Response(JSON.stringify({ data: [{ id: '1', text: 'something unrelated entirely', created_at: '2026-04-18T00:00:00Z' }], - }), { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch; + }), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch; const r = await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan', keywords: 'gbrain knowledge runtime specific terms' }, context: makeCtx(), @@ -523,7 +523,7 @@ describe('x_handle_to_tweet resolver', () => { })); globalThis.fetch = (async () => new Response(JSON.stringify({ data }), { status: 200, headers: { 'content-type': 'application/json' }, - })) as typeof fetch; + })) as unknown as typeof fetch; const r = await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan', keywords: 'completely different signal words unlikely to match' }, context: makeCtx(), @@ -535,7 +535,7 @@ describe('x_handle_to_tweet resolver', () => { test('401 → ResolverError(auth)', async () => { process.env.X_API_BEARER_TOKEN = 'fake'; - globalThis.fetch = (async () => new Response('unauthorized', { status: 401 })) as typeof fetch; + globalThis.fetch = (async () => new Response('unauthorized', { status: 401 })) as unknown as typeof fetch; try { await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan' }, @@ -549,7 +549,7 @@ describe('x_handle_to_tweet resolver', () => { test('403 → ResolverError(auth)', async () => { process.env.X_API_BEARER_TOKEN = 'fake'; - globalThis.fetch = (async () => new Response('forbidden', { status: 403 })) as typeof fetch; + globalThis.fetch = (async () => new Response('forbidden', { status: 403 })) as unknown as typeof fetch; try { await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan' }, @@ -563,7 +563,7 @@ describe('x_handle_to_tweet resolver', () => { test('500 → ResolverError(upstream) with body snippet', async () => { process.env.X_API_BEARER_TOKEN = 'fake'; - globalThis.fetch = (async () => new Response('internal err', { status: 500 })) as typeof fetch; + globalThis.fetch = (async () => new Response('internal err', { status: 500 })) as unknown as typeof fetch; try { await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan' }, @@ -582,7 +582,7 @@ describe('x_handle_to_tweet resolver', () => { globalThis.fetch = (async () => { calls++; return new Response('rate', { status: 429, headers: { 'retry-after': '0' } }); - }) as typeof fetch; + }) as unknown as typeof fetch; try { await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan' }, @@ -603,7 +603,7 @@ describe('x_handle_to_tweet resolver', () => { return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await xHandleToTweetResolver.resolve({ input: { handle: 'garrytan', keywords: 'from:evil_user lang:ja to:someone normal words' }, context: makeCtx(), diff --git a/test/subagent-handler.test.ts b/test/subagent-handler.test.ts index 6414c497f..086f8f060 100644 --- a/test/subagent-handler.test.ts +++ b/test/subagent-handler.test.ts @@ -28,10 +28,10 @@ let queue: MinionQueue; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); -}); +}, 60_000); afterAll(async () => { await engine.disconnect(); @@ -420,3 +420,54 @@ describe('subagent handler input validation', () => { await expect(handler(ctx)).rejects.toThrow(/unknown tool/); }); }); + +describe('makeSubagentHandler default client construction', () => { + test('factory default wires sdk.messages through to the handler', async () => { + // Regression guard for the v0.16.0 shipped bug: makeSubagentHandler + // was casting `new Anthropic()` (top-level SDK class) to MessagesClient, + // but `.create()` lives at sdk.messages.create. Every subagent job in + // production died with "client.create is not a function" on first LLM + // call. This test exercises the default-client path (no `deps.client` + // injected) via the makeAnthropic dep-injection seam, so the exact + // default-branch construction is covered without a real API call. + const calls: Anthropic.MessageCreateParamsNonStreaming[] = []; + const fakeSdk = { + messages: { + async create( + params: Anthropic.MessageCreateParamsNonStreaming, + ): Promise { + calls.push(params); + return { + id: 'msg_regression', + type: 'message', + role: 'assistant', + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + content: [{ type: 'text', text: 'ok' }], + usage: { + input_tokens: 1, + output_tokens: 1, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + } as unknown as Anthropic.Message; + }, + }, + } as unknown as Anthropic; + + // Crucial: do NOT pass `client`. Only `makeAnthropic`. This forces the + // factory to hit the default-client branch (`deps.client ?? makeAnthropic().messages`). + const handler = makeSubagentHandler({ + engine, + makeAnthropic: () => fakeSdk, + toolRegistry: [], + }); + const ctx = await makeCtx({ prompt: 'hello' }); + const result = await handler(ctx); + + expect(calls.length).toBe(1); + expect(result.stop_reason).toBe('end_turn'); + expect(result.result).toBe('ok'); + }); +}); diff --git a/test/subagent-transcript.test.ts b/test/subagent-transcript.test.ts index 2564c2517..781ec3e83 100644 --- a/test/subagent-transcript.test.ts +++ b/test/subagent-transcript.test.ts @@ -18,7 +18,7 @@ let jobId: number; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); }); diff --git a/test/wait-for-completion.test.ts b/test/wait-for-completion.test.ts index 9a812a9f7..0a4838fbd 100644 --- a/test/wait-for-completion.test.ts +++ b/test/wait-for-completion.test.ts @@ -13,10 +13,10 @@ let queue: MinionQueue; beforeAll(async () => { engine = new PGLiteEngine(); - await engine.connect({ databaseUrl: '' }); + await engine.connect({ database_url: '' }); await engine.initSchema(); queue = new MinionQueue(engine); -}); +}, 60_000); afterAll(async () => { await engine.disconnect();