fix(subagent): v0.16.3 — bind Anthropic SDK correctly + enable tsc in CI (#318)

* 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 <wintermute@openclaw.ai>

* 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<Message>` 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 <wintermute@garrytan.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Wintermute <wintermute@openclaw.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-22 01:34:22 -07:00
committed by GitHub
parent 418d955fd3
commit 96178d726e
48 changed files with 263 additions and 98 deletions

View File

@@ -28,4 +28,4 @@ jobs:
with:
bun-version: latest
- run: bun install
- run: bun test
- run: bun run test

View File

@@ -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.**

View File

@@ -1 +1 @@
0.16.2
0.16.3

View File

@@ -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=="],

6
bunfig.toml Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -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,
});

View File

@@ -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<number>();
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 <dir> [--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);

View File

@@ -217,10 +217,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
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);
}

View File

@@ -105,10 +105,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
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);
}

View File

@@ -129,10 +129,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
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);
}

View File

@@ -95,10 +95,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
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);
}

View File

@@ -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 }[];
}
/**

View File

@@ -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}

View File

@@ -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<typeof registry.register>[0]);
}
}

View File

@@ -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;
}
/**

View File

@@ -159,5 +159,5 @@ export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) =
const conn = getConnection();
return conn.begin(async (tx) => {
return fn(tx as unknown as ReturnType<typeof postgres>);
});
}) as Promise<T>;
}

View File

@@ -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;
}

View File

@@ -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 */

View File

@@ -269,7 +269,7 @@ export async function shellHandler(ctx: MinionJobContext): Promise<ShellJobResul
if (ctx.signal.aborted) sigAbort();
if (ctx.shutdownSignal.aborted) shutdownAbort();
const exitCode: number = await new Promise((resolve, reject) => {
const exitCode: number = await new Promise<number>((resolve, reject) => {
proc.on('error', (err) => {
reject(err);
});

View File

@@ -41,7 +41,7 @@ export interface AggregatorResult {
/** v0.15 aggregator: synchronous read from inbox, no LLM call. */
export async function subagentAggregatorHandler(ctx: MinionJobContext): Promise<AggregatorResult> {
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) {

View File

@@ -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<SubagentResult> {
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)');
}

View File

@@ -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). */

View File

@@ -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(

View File

@@ -113,7 +113,7 @@ export class PGLiteEngine implements BrainEngine {
async putPage(slug: string, page: PageInput): Promise<Page> {
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(

View File

@@ -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();

View File

@@ -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<typeof postgres>, writable: false });
return fn(txEngine);
});
}) as Promise<T>;
}
// 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<typeof sql.json>[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<Set<string>> {
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<string[]> {
@@ -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<typeof sql.unsafe>[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<string, unknown>));
}
async deleteChunks(slug: string): Promise<void> {
@@ -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<string, unknown>)}
SELECT id, ${source}, ${sql.json(data as Parameters<typeof sql.json>[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<string, unknown>) => rowToChunk(r, true));
return rows.map((r) => rowToChunk(r as Record<string, unknown>, true));
}
async executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
const conn = this.sql;
return conn.unsafe(sql, params) as unknown as T[];
return conn.unsafe(sql, params as Parameters<typeof conn.unsafe>[1]) as unknown as T[];
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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',

View File

@@ -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.

View File

@@ -102,7 +102,7 @@ beforeAll(async () => {
},
];
await engine.upsertChunks('concepts/ai-philosophy', aiChunks);
});
}, 60_000);
afterAll(async () => {
await engine.disconnect();

View File

@@ -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);

View File

@@ -201,7 +201,7 @@ describe('scanIntegrity', () => {
timeline: '',
frontmatter: { validate: false },
});
});
}, 60_000);
afterAll(async () => {
await engine.disconnect();

View File

@@ -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');

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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(),

View File

@@ -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<Anthropic.Message> {
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');
});
});

View File

@@ -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);
});

View File

@@ -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();