Files
gbrain/test/minions-quiet-hours.test.ts
T
96178d726e 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>
2026-04-22 01:34:22 -07:00

216 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Quiet-hours + stagger tests — pure primitives + migration verification.
*
* Worker-loop integration (claim → release on quiet verdict) is covered by
* the existing Minions resilience E2E when combined with this unit coverage:
* the worker path only reads the evaluator result, and the evaluator is
* exhaustively tested here.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { tmpdir } from 'os';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import {
evaluateQuietHours,
localHour,
type QuietHoursConfig,
} from '../src/core/minions/quiet-hours.ts';
import { staggerMinuteOffset, staggerSecondOffset } from '../src/core/minions/stagger.ts';
// ---------------------------------------------------------------------------
// Pure: evaluateQuietHours
// ---------------------------------------------------------------------------
describe('evaluateQuietHours', () => {
const tz = 'UTC'; // deterministic across CI
test('null config → allow', () => {
expect(evaluateQuietHours(null)).toBe('allow');
});
test('undefined config → allow', () => {
expect(evaluateQuietHours(undefined)).toBe('allow');
});
test('invalid config (out-of-range hour) → allow (fail-open)', () => {
expect(evaluateQuietHours({ start: 99, end: 1, tz })).toBe('allow');
});
test('invalid config (zero-width) → allow', () => {
expect(evaluateQuietHours({ start: 3, end: 3, tz })).toBe('allow');
});
test('invalid tz → allow (fail-open)', () => {
expect(evaluateQuietHours({ start: 22, end: 6, tz: 'Not/A_Real_TZ' })).toBe('allow');
});
test('straight-line window: inside → defer by default', () => {
// 02:00 UTC
const when = new Date(Date.UTC(2026, 0, 1, 2, 0, 0));
const cfg: QuietHoursConfig = { start: 1, end: 5, tz };
expect(evaluateQuietHours(cfg, when)).toBe('defer');
});
test('straight-line window: outside → allow', () => {
const when = new Date(Date.UTC(2026, 0, 1, 10, 0, 0));
const cfg: QuietHoursConfig = { start: 1, end: 5, tz };
expect(evaluateQuietHours(cfg, when)).toBe('allow');
});
test('straight-line window: end is exclusive', () => {
const when = new Date(Date.UTC(2026, 0, 1, 5, 0, 0));
const cfg: QuietHoursConfig = { start: 1, end: 5, tz };
expect(evaluateQuietHours(cfg, when)).toBe('allow');
});
test('wrap-around window: inside (after midnight) → defer', () => {
// 01:00 UTC, window 22:00 - 07:00
const when = new Date(Date.UTC(2026, 0, 1, 1, 0, 0));
const cfg: QuietHoursConfig = { start: 22, end: 7, tz };
expect(evaluateQuietHours(cfg, when)).toBe('defer');
});
test('wrap-around window: inside (before midnight) → defer', () => {
// 23:30 UTC, window 22:00 - 07:00
const when = new Date(Date.UTC(2026, 0, 1, 23, 30, 0));
const cfg: QuietHoursConfig = { start: 22, end: 7, tz };
expect(evaluateQuietHours(cfg, when)).toBe('defer');
});
test('wrap-around window: outside → allow', () => {
// 10:00 UTC, window 22:00 - 07:00
const when = new Date(Date.UTC(2026, 0, 1, 10, 0, 0));
const cfg: QuietHoursConfig = { start: 22, end: 7, tz };
expect(evaluateQuietHours(cfg, when)).toBe('allow');
});
test('policy "skip" returns skip verdict', () => {
const when = new Date(Date.UTC(2026, 0, 1, 2, 0, 0));
const cfg: QuietHoursConfig = { start: 1, end: 5, tz, policy: 'skip' };
expect(evaluateQuietHours(cfg, when)).toBe('skip');
});
test('timezone difference changes window position', () => {
// 14:00 UTC = 09:00 LA (PDT in summer). If the config is start:22 end:7 in LA,
// 14:00 UTC is outside → allow.
const when = new Date(Date.UTC(2026, 5, 15, 14, 0, 0)); // June → PDT
const cfg: QuietHoursConfig = { start: 22, end: 7, tz: 'America/Los_Angeles' };
expect(evaluateQuietHours(cfg, when)).toBe('allow');
});
test('timezone difference puts job inside window', () => {
// 06:00 UTC = 22:00 prev day in LA (summer, PDT offset -7).
// Wait — 06:00 UTC in June = 23:00 previous day LA (UTC-7).
// Config start:22 end:7 → 23:00 is inside → defer.
const when = new Date(Date.UTC(2026, 5, 15, 6, 0, 0));
const cfg: QuietHoursConfig = { start: 22, end: 7, tz: 'America/Los_Angeles' };
expect(evaluateQuietHours(cfg, when)).toBe('defer');
});
});
describe('localHour', () => {
test('UTC formatting matches Date.getUTCHours', () => {
const when = new Date(Date.UTC(2026, 0, 1, 15, 30, 0));
expect(localHour(when, 'UTC')).toBe(15);
});
test('invalid tz returns null', () => {
expect(localHour(new Date(), 'Not/Real')).toBeNull();
});
test('LA timezone shifts hour correctly (winter PST = UTC-8)', () => {
// Noon UTC in January = 04:00 LA
const when = new Date(Date.UTC(2026, 0, 1, 12, 0, 0));
expect(localHour(when, 'America/Los_Angeles')).toBe(4);
});
});
// ---------------------------------------------------------------------------
// Pure: staggerMinuteOffset
// ---------------------------------------------------------------------------
describe('staggerMinuteOffset', () => {
test('empty or non-string → 0', () => {
expect(staggerMinuteOffset('')).toBe(0);
// @ts-expect-error: runtime guard
expect(staggerMinuteOffset(null)).toBe(0);
});
test('returns 059', () => {
for (const k of ['social-radar', 'x-ingest', 'perplexity', 'sync-all']) {
const v = staggerMinuteOffset(k);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(60);
}
});
test('deterministic: same key always same offset', () => {
const a = staggerMinuteOffset('social-radar');
const b = staggerMinuteOffset('social-radar');
expect(a).toBe(b);
});
test('different keys produce different offsets (most of the time)', () => {
const keys = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
const offsets = new Set(keys.map(staggerMinuteOffset));
// With 10 distinct keys and 60 buckets, expect at least 5 unique
// (collision rate stays well under 50% at this small sample size)
expect(offsets.size).toBeGreaterThanOrEqual(5);
});
test('second offset is 60x minute offset', () => {
const key = 'social-radar';
expect(staggerSecondOffset(key)).toBe(staggerMinuteOffset(key) * 60);
});
});
// ---------------------------------------------------------------------------
// Schema migration v12 applies
// ---------------------------------------------------------------------------
describe('schema migration v12 — minion_quiet_hours_stagger', () => {
let engine: BrainEngine;
let dbDir: string;
beforeAll(async () => {
dbDir = mkdtempSync(join(tmpdir(), 'm12-'));
engine = new PGLiteEngine();
await engine.connect({ engine: 'pglite', database_path: dbDir });
await engine.initSchema();
}, 60_000);
afterAll(async () => {
await engine.disconnect();
rmSync(dbDir, { recursive: true, force: true });
});
test('minion_jobs has quiet_hours column', async () => {
const rows = await engine.executeRaw<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'minion_jobs' AND column_name = 'quiet_hours'`,
);
expect(rows.length).toBe(1);
});
test('minion_jobs has stagger_key column', async () => {
const rows = await engine.executeRaw<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'minion_jobs' AND column_name = 'stagger_key'`,
);
expect(rows.length).toBe(1);
});
test('stagger_key index exists', async () => {
const rows = await engine.executeRaw<{ indexname: string }>(
`SELECT indexname FROM pg_indexes
WHERE tablename = 'minion_jobs' AND indexname = 'idx_minion_jobs_stagger_key'`,
);
expect(rows.length).toBe(1);
});
});