mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5dc0ac399 | ||
|
|
c11ec1166f | ||
|
|
393805ab2c |
@@ -18,6 +18,12 @@
|
||||
# R4: any file that creates `new PGLiteEngine(` must call `.disconnect(`
|
||||
# inside an `afterAll(` block. Without disconnect, engines leak across
|
||||
# file boundaries within a shard process.
|
||||
# R5: any file that calls `configureGateway(` or
|
||||
# `__setEmbedTransportForTests(` must also call `resetGateway()` and
|
||||
# have an `afterAll(`/`afterEach(` hook. The gateway is module-global;
|
||||
# a configured remote provider + fake key left past the file boundary
|
||||
# makes the next embed-triggering file in the shard fire a live HTTP
|
||||
# call (issue #3066; master shard 6 broke twice this way).
|
||||
#
|
||||
# Scope:
|
||||
# - Recursively scans `test/**/*.test.ts`.
|
||||
@@ -132,6 +138,22 @@ while IFS= read -r f; do
|
||||
emit_violation "$f" "R4" "creates PGLiteEngine but missing afterAll(() => engine.disconnect()); engine leaks across files in the shard process" ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# R5: gateway configuration requires resetGateway + an afterAll/afterEach
|
||||
# hook. Same loose two-grep shape as R4 — `resetGateway(` present plus at
|
||||
# least one afterAll(/afterEach( — but on comment-stripped lines: a prose
|
||||
# mention like "a test that calls resetGateway()" must not satisfy the
|
||||
# rule (that exact false pass hid the cycle-consolidate leaker).
|
||||
# No [[:space:]]* before the paren on the trigger side: prose in a test
|
||||
# name ("works WITHOUT configureGateway (reads registry...)") must not
|
||||
# trigger the rule; real call sites are always `configureGateway(`.
|
||||
r5_code=$(grep -vE '^[[:space:]]*(//|\*)' "$f" 2>/dev/null || true)
|
||||
if printf '%s\n' "$r5_code" | grep -qE 'configureGateway\(|__setEmbedTransportForTests\('; then
|
||||
if ! printf '%s\n' "$r5_code" | grep -qE 'resetGateway\(' \
|
||||
|| ! printf '%s\n' "$r5_code" | grep -qE 'afterAll[[:space:]]*\(|afterEach[[:space:]]*\('; then
|
||||
emit_violation "$f" "R5" "configures the AI gateway but never calls resetGateway() in afterAll/afterEach; gateway state (provider, fake keys, transports) leaks across files in the shard process" ""
|
||||
fi
|
||||
fi
|
||||
done <<EOF
|
||||
$FILE_LIST
|
||||
EOF
|
||||
|
||||
@@ -7090,11 +7090,16 @@ export async function buildChecks(
|
||||
);
|
||||
if (factsExists[0]?.exists) {
|
||||
const health = await engine.getFactsHealth('default');
|
||||
const status: 'ok' | 'warn' = health.total_active >= 0 ? 'ok' : 'warn';
|
||||
const top = health.top_entities
|
||||
.slice(0, 3)
|
||||
.map(t => `${t.entity_slug}:${t.count}`)
|
||||
.join(', ') || '—';
|
||||
// #3062: "0 active facts" used to read [OK] even when the chat
|
||||
// gateway had never been reachable — indistinguishable from a brain
|
||||
// with genuinely nothing to extract. Distinguish the two.
|
||||
const { unavailableReason } = await import('../core/ai/gateway.ts');
|
||||
const chatDown = health.total_active === 0 ? unavailableReason('chat') : null;
|
||||
const status: 'ok' | 'warn' = chatDown ? 'warn' : health.total_active >= 0 ? 'ok' : 'warn';
|
||||
checks.push({
|
||||
name: 'facts_health',
|
||||
status,
|
||||
@@ -7102,7 +7107,8 @@ export async function buildChecks(
|
||||
`facts_health(default): ${health.total_active} active, ` +
|
||||
`${health.total_today} today, ${health.total_week} this week, ` +
|
||||
`${health.total_consolidated} consolidated, ` +
|
||||
`top entities ${top}`,
|
||||
`top entities ${top}` +
|
||||
(chatDown ? ` — extraction has no chat gateway: ${chatDown}` : ''),
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
|
||||
+40
-2
@@ -469,15 +469,53 @@ export async function extractLinksFromFile(
|
||||
|
||||
// --- Timeline extraction ---
|
||||
|
||||
/**
|
||||
* Index of the first dash (—, –, -) that can serve as the Source — Summary
|
||||
* delimiter: it must have whitespace on both sides and sit outside every
|
||||
* markdown-link span. Hyphens inside link targets
|
||||
* (`../people/alice-example.md`) and dashes inside link labels
|
||||
* (`[Deals — Q1 Review](...)`) are content, not delimiters — splitting on
|
||||
* them shatters one entry into two fragments whose halves re-insert on
|
||||
* every sync (the (page_id, date, summary, source) uniqueness sees each
|
||||
* fragment shape as a new row). Returns -1 when the line has no delimiter.
|
||||
*/
|
||||
function findDelimiterOutsideLinks(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '[' || c === '(') depth++;
|
||||
else if (c === ']' || c === ')') { if (depth > 0) depth--; }
|
||||
else if (
|
||||
depth === 0 &&
|
||||
(c === '—' || c === '–' || c === '-') &&
|
||||
i > 0 && /\s/.test(text[i - 1]) &&
|
||||
i + 1 < text.length && /\s/.test(text[i + 1])
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Extract timeline entries from markdown content */
|
||||
export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] {
|
||||
const entries: ExtractedTimelineEntry[] = [];
|
||||
|
||||
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
|
||||
// The delimiter search is link-aware (see findDelimiterOutsideLinks); a
|
||||
// bullet with no delimiter (e.g. an auto-generated backlink line
|
||||
// `- **date** | Referenced in [X](y.md)`) is kept whole as the summary
|
||||
// rather than dropped or fragmented.
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+)$/gm;
|
||||
let match;
|
||||
while ((match = bulletPattern.exec(content)) !== null) {
|
||||
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
|
||||
const rest = match[2].trim();
|
||||
const at = findDelimiterOutsideLinks(rest);
|
||||
if (at >= 0) {
|
||||
entries.push({ slug, date: match[1], source: rest.slice(0, at).trim(), summary: rest.slice(at + 1).trim() });
|
||||
} else {
|
||||
entries.push({ slug, date: match[1], source: 'markdown', summary: rest });
|
||||
}
|
||||
}
|
||||
|
||||
// Format 2: Header — ### YYYY-MM-DD — Title
|
||||
|
||||
+48
-1
@@ -621,6 +621,7 @@ export function resetGateway(): void {
|
||||
_embedTransportInstalled = false;
|
||||
_chatTransport = null;
|
||||
_warnedRecipes.clear();
|
||||
_warnedUnavailable.clear();
|
||||
_extendedModels.clear();
|
||||
}
|
||||
|
||||
@@ -887,6 +888,47 @@ export function isAvailable(touchpoint: TouchpointKind, modelOverride?: string):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable reason a chat-capable touchpoint is unavailable, or null
|
||||
* when it IS available. Names the configured model, the missing
|
||||
* `auth_env.required` keys, and the recipe's `setup_url` so silent-degrade
|
||||
* guards (#3062: facts extraction, query expansion) can warn with an
|
||||
* actionable message instead of no-oping invisibly.
|
||||
*/
|
||||
export function unavailableReason(touchpoint: 'chat' | 'expansion'): string | null {
|
||||
if (isAvailable(touchpoint)) return null;
|
||||
if (!_config) return `${touchpoint} gateway not configured (no AI gateway config loaded)`;
|
||||
try {
|
||||
const modelStr = touchpoint === 'expansion' ? getExpansionModel() : getChatModel();
|
||||
const { recipe } = resolveRecipe(modelStr);
|
||||
if (!recipe.touchpoints[touchpoint]) {
|
||||
return `${touchpoint} model ${modelStr}: provider recipe "${recipe.id}" does not support the ${touchpoint} touchpoint`;
|
||||
}
|
||||
const missing = (recipe.auth_env?.required ?? []).filter(k => !_config!.env[k]);
|
||||
if (missing.length > 0) {
|
||||
const setup = recipe.auth_env?.setup_url ? ` (get a key: ${recipe.auth_env.setup_url})` : '';
|
||||
return `${touchpoint} model ${modelStr} needs ${missing.join(', ')}${setup}`;
|
||||
}
|
||||
return `${touchpoint} model ${modelStr} is unavailable`;
|
||||
} catch (e) {
|
||||
return `${touchpoint} gateway unavailable: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once-per-process memo for silent-degrade warnings (#3062). Cleared by
|
||||
* resetGateway() so a reconfigure gets a fresh warning if still broken.
|
||||
*/
|
||||
const _warnedUnavailable = new Set<string>();
|
||||
export function warnUnavailableOnce(touchpoint: 'chat' | 'expansion', context: string): void {
|
||||
if (_warnedUnavailable.has(touchpoint)) return;
|
||||
const reason = unavailableReason(touchpoint);
|
||||
if (!reason) return;
|
||||
_warnedUnavailable.add(touchpoint);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[ai.gateway] WARN: ${context} — ${reason}`);
|
||||
}
|
||||
|
||||
// ---- Embedding ----
|
||||
|
||||
/**
|
||||
@@ -2306,7 +2348,12 @@ const ExpansionSchema = z.object({
|
||||
*/
|
||||
export async function expand(query: string): Promise<string[]> {
|
||||
if (!query || !query.trim()) return [query];
|
||||
if (!isAvailable('expansion')) return [query];
|
||||
if (!isAvailable('expansion')) {
|
||||
// #3062: tokenmax's headline knob silently degrading to single-query
|
||||
// was invisible. Warn once per process; still degrade gracefully.
|
||||
warnUnavailableOnce('expansion', 'query expansion is configured but inert; searches run single-query');
|
||||
return [query];
|
||||
}
|
||||
|
||||
// Guardrail seam: classify the query before the expansion model call.
|
||||
await classifyGatewayGuardrail({
|
||||
|
||||
@@ -78,7 +78,7 @@ export type FactsBackstopResult =
|
||||
mode: 'queue';
|
||||
enqueued: boolean;
|
||||
queueDepth: number;
|
||||
skipped?: 'extraction_disabled' | 'queue_overflow' | 'queue_shutdown' | `eligibility_failed:${string}`;
|
||||
skipped?: 'extraction_disabled' | 'chat_unavailable' | 'queue_overflow' | 'queue_shutdown' | `eligibility_failed:${string}`;
|
||||
}
|
||||
| {
|
||||
mode: 'inline';
|
||||
@@ -86,7 +86,7 @@ export type FactsBackstopResult =
|
||||
duplicate: number;
|
||||
superseded: number;
|
||||
fact_ids: number[];
|
||||
skipped?: 'extraction_disabled' | `eligibility_failed:${string}`;
|
||||
skipped?: 'extraction_disabled' | 'chat_unavailable' | `eligibility_failed:${string}`;
|
||||
};
|
||||
|
||||
interface ParsedPageInput {
|
||||
@@ -155,6 +155,18 @@ export async function runFactsBackstop(
|
||||
: { mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [], skipped };
|
||||
}
|
||||
|
||||
// #3062: no chat gateway → extraction is guaranteed to yield nothing. The
|
||||
// result was previously byte-identical to a genuine empty extraction
|
||||
// (inserted: 0, no `skipped`), and queue mode enqueued jobs doomed to
|
||||
// no-op. Record WHY, warn once per process, and skip the queue entirely.
|
||||
const { isAvailable, warnUnavailableOnce } = await import('../ai/gateway.ts');
|
||||
if (!isAvailable('chat')) {
|
||||
warnUnavailableOnce('chat', 'facts extraction skipped');
|
||||
return mode === 'queue'
|
||||
? { mode: 'queue', enqueued: false, queueDepth: 0, skipped: 'chat_unavailable' }
|
||||
: { mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [], skipped: 'chat_unavailable' };
|
||||
}
|
||||
|
||||
// --- Mode dispatch ---
|
||||
if (mode === 'queue') {
|
||||
// Local patch 2026-06-11: in a one-shot CLI process the in-process queue
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* gateway-down errors are absorbed into NULL-embedding rows.
|
||||
*/
|
||||
|
||||
import { chat, embedOne, isAvailable } from '../ai/gateway.ts';
|
||||
import { chat, embedOne, isAvailable, warnUnavailableOnce } from '../ai/gateway.ts';
|
||||
import type { ChatResult } from '../ai/gateway.ts';
|
||||
import { INJECTION_PATTERNS } from '../think/sanitize.ts';
|
||||
import { resolveModel } from '../model-config.ts';
|
||||
@@ -175,7 +175,10 @@ export async function extractFactsFromTurn(input: ExtractInput): Promise<Extract
|
||||
|
||||
if (!isAvailable('chat')) {
|
||||
// No chat gateway → no extraction. Caller still inserts facts via direct
|
||||
// `gbrain take add` paths.
|
||||
// `gbrain take add` paths. #3062: warn once per process — silently
|
||||
// returning [] here made an unauthenticated brain byte-identical to a
|
||||
// genuine empty extraction across every health surface.
|
||||
warnUnavailableOnce('chat', 'facts extraction skipped');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
* the bug made you believe was sufficient.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
|
||||
import {
|
||||
chat,
|
||||
configureGateway,
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
__setGenerateTextTransportForTests,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
|
||||
// R5 shard hygiene: leave no configured gateway past the file boundary.
|
||||
afterAll(() => {
|
||||
resetGateway();
|
||||
__setGenerateTextTransportForTests(null);
|
||||
});
|
||||
|
||||
describe('gbrain#2490 — Anthropic cache breakpoint placement', () => {
|
||||
beforeEach(() => {
|
||||
resetGateway();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* `generateText` import via Bun's module-replace pattern.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||
import { describe, test, expect, beforeEach, mock, afterAll } from 'bun:test';
|
||||
import {
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
@@ -30,6 +30,12 @@ import { parseModelId, resolveRecipe, assertTouchpoint } from '../../src/core/ai
|
||||
import { AIConfigError } from '../../src/core/ai/errors.ts';
|
||||
import { listRecipes, getRecipe } from '../../src/core/ai/recipes/index.ts';
|
||||
|
||||
// R5 shard hygiene: leave no configured gateway past the file boundary.
|
||||
afterAll(() => {
|
||||
resetGateway();
|
||||
__setGenerateTextTransportForTests(null);
|
||||
});
|
||||
|
||||
describe('chat touchpoint — recipe registry', () => {
|
||||
test('all six chat-capable providers ship a chat touchpoint with supports_subagent_loop', () => {
|
||||
const expected = ['anthropic', 'openai', 'google', 'deepseek', 'groq', 'together'];
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* nothing), and config `provider_chat_options` overrides the derived key
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
|
||||
import {
|
||||
chat,
|
||||
configureGateway,
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
__setGenerateTextTransportForTests,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
|
||||
// R5 shard hygiene: leave no configured gateway past the file boundary.
|
||||
afterAll(() => {
|
||||
resetGateway();
|
||||
__setGenerateTextTransportForTests(null);
|
||||
});
|
||||
|
||||
describe('openAIPromptCacheKey — derivation', () => {
|
||||
test('same system + same tools → identical stable key (sticky routing)', () => {
|
||||
const a = openAIPromptCacheKey({ system: 'SYS', toolNames: ['search', 'put_page'] });
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway } from '../src/core/ai/gateway.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import { runPhaseConsolidate } from '../src/core/cycle/phases/consolidate.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
@@ -37,6 +37,7 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway } from '../src/core/ai/gateway.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import { checkFederationHealth } from '../src/commands/doctor.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
@@ -33,6 +33,7 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway } from '../src/core/ai/gateway.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import {
|
||||
detectRegressions,
|
||||
computeDriftScore,
|
||||
@@ -45,6 +45,7 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { hybridSearch } from '../src/core/search/hybrid.ts';
|
||||
import { __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';
|
||||
import { __setEmbedTransportForTests, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import { parseQuestionsJsonl, runRetrievalQuality, evaluateGate, type SearchFn } from '../src/eval/retrieval-quality/harness.ts';
|
||||
import type { ChunkInput } from '../src/core/types.ts';
|
||||
|
||||
@@ -60,7 +60,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
__setEmbedTransportForTests(null);
|
||||
resetGateway();
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
|
||||
@@ -136,6 +136,38 @@ describe('extractTimelineFromContent', () => {
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not split on hyphens inside markdown link targets', () => {
|
||||
const content = `- **2025-03-18** | Referenced in [Alice](../people/alice-example.md)`;
|
||||
const entries = extractTimelineFromContent(content, 'companies/acme-example');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('markdown');
|
||||
expect(entries[0].summary).toBe('Referenced in [Alice](../people/alice-example.md)');
|
||||
});
|
||||
|
||||
it('does not split on spaced dashes inside link labels', () => {
|
||||
const content = `- **2025-03-18** | Referenced in [Deals — Q1 Review](../deals/q1-review.md)`;
|
||||
const entries = extractTimelineFromContent(content, 'companies/acme-example');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('markdown');
|
||||
expect(entries[0].summary).toBe('Referenced in [Deals — Q1 Review](../deals/q1-review.md)');
|
||||
});
|
||||
|
||||
it('splits on the first spaced dash outside links', () => {
|
||||
const content = `- **2025-03-18** | [Board notes](../meetings/2025-03-18-board.md) — Approved the hire`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('[Board notes](../meetings/2025-03-18-board.md)');
|
||||
expect(entries[0].summary).toBe('Approved the hire');
|
||||
});
|
||||
|
||||
it('keeps delimiterless bullet lines whole instead of dropping them', () => {
|
||||
const content = `- **2025-03-18** | Imported from legacy tracker`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('markdown');
|
||||
expect(entries[0].summary).toBe('Imported from legacy tracker');
|
||||
});
|
||||
|
||||
it('extracts inline citation format entries', () => {
|
||||
const content = `Closed the seed round with fund-a leading. [Source: board meeting notes, 2025-04-02]`;
|
||||
const entries = extractTimelineFromContent(content, 'deals/acme-seed');
|
||||
|
||||
@@ -83,16 +83,16 @@ describe('put_page facts backstop', () => {
|
||||
'note/substantive',
|
||||
`---\ntype: note\ntitle: Substantive\n---\n${'this is some real content with meaningful claims. '.repeat(10)}`,
|
||||
);
|
||||
// Either queued (gateway configured) or skipped due to gateway absence
|
||||
// is acceptable; we only insist the gating doesn't reject on the
|
||||
// happy path.
|
||||
// Either queued (gateway configured) or skipped: 'chat_unavailable'
|
||||
// (#3062: the reset gateway has no chat credential, and the backstop
|
||||
// now records that instead of enqueueing a job doomed to no-op) is
|
||||
// acceptable; we only insist the gating doesn't reject on the happy path.
|
||||
expect(result).toBeDefined();
|
||||
const r = result!;
|
||||
if ('queued' in r) {
|
||||
expect(r.queued).toBe(true);
|
||||
} else {
|
||||
// 'backstop_error' or 'queue_shutdown' would be a real failure.
|
||||
expect(r.skipped).toMatch(/^(queue_shutdown|backstop_error)?$/);
|
||||
expect(r.skipped).toBe('chat_unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -25,8 +25,11 @@ import {
|
||||
resetGateway,
|
||||
__setChatTransportForTests,
|
||||
getChatModel,
|
||||
unavailableReason,
|
||||
warnUnavailableOnce,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import { extractFactsFromTurn } from '../src/core/facts/extract.ts';
|
||||
import { runFactsBackstop } from '../src/core/facts/backstop.ts';
|
||||
|
||||
beforeEach(() => {
|
||||
resetGateway();
|
||||
@@ -147,3 +150,89 @@ describe('facts extract — silent-no-op regression (v0.31.6 bug class)', () =>
|
||||
expect(chatCalled).toBe(true); // ← THE bug-class assertion
|
||||
});
|
||||
});
|
||||
|
||||
// #3062 — an unauthenticated chat gateway must be DIAGNOSABLE, not a
|
||||
// silent success-shaped no-op. Three surfaces pinned here:
|
||||
// 1. unavailableReason() names the missing auth_env key + model.
|
||||
// 2. warnUnavailableOnce() writes exactly one stderr warning per process.
|
||||
// 3. runFactsBackstop() records skipped: 'chat_unavailable' (and queue
|
||||
// mode declines to enqueue a job that is guaranteed to no-op).
|
||||
describe('#3062 — chat-unavailable is diagnosable, not silent', () => {
|
||||
test('unavailableReason names the missing auth env key and the model', () => {
|
||||
configureGateway({
|
||||
chat_model: 'anthropic:claude-sonnet-4-6',
|
||||
env: {},
|
||||
});
|
||||
expect(isAvailable('chat')).toBe(false);
|
||||
const reason = unavailableReason('chat');
|
||||
expect(reason).toContain('ANTHROPIC_API_KEY');
|
||||
expect(reason).toContain('anthropic:claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
test('unavailableReason is null when the touchpoint is available', () => {
|
||||
configureGateway({
|
||||
chat_model: 'anthropic:claude-sonnet-4-6',
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
|
||||
});
|
||||
expect(unavailableReason('chat')).toBeNull();
|
||||
});
|
||||
|
||||
test('warnUnavailableOnce warns exactly once per process per touchpoint', () => {
|
||||
configureGateway({
|
||||
chat_model: 'anthropic:claude-sonnet-4-6',
|
||||
env: {},
|
||||
});
|
||||
const seen: string[] = [];
|
||||
const orig = console.warn;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn = (msg: unknown) => { seen.push(String(msg)); };
|
||||
try {
|
||||
warnUnavailableOnce('chat', 'facts extraction skipped');
|
||||
warnUnavailableOnce('chat', 'facts extraction skipped');
|
||||
} finally {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn = orig;
|
||||
}
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toContain('ANTHROPIC_API_KEY');
|
||||
});
|
||||
|
||||
test('runFactsBackstop records skipped: chat_unavailable instead of a success-shaped empty result', async () => {
|
||||
configureGateway({
|
||||
chat_model: 'anthropic:claude-sonnet-4-6',
|
||||
env: {},
|
||||
});
|
||||
// The gate fires before any engine use beyond the kill-switch config
|
||||
// read, so a getConfig stub suffices — no PGLite needed.
|
||||
const stubEngine = { getConfig: async () => null } as unknown as import('../src/core/engine.ts').BrainEngine;
|
||||
const page = {
|
||||
slug: 'note/eligible',
|
||||
type: 'note' as const,
|
||||
compiled_truth: 'this is some real content with meaningful claims. '.repeat(10),
|
||||
frontmatter: {},
|
||||
};
|
||||
const inline = await runFactsBackstop(page, {
|
||||
engine: stubEngine,
|
||||
sourceId: 'default',
|
||||
sessionId: null,
|
||||
source: 'mcp:put_page',
|
||||
mode: 'inline',
|
||||
});
|
||||
expect(inline).toEqual({
|
||||
mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [],
|
||||
skipped: 'chat_unavailable',
|
||||
});
|
||||
|
||||
const queued = await runFactsBackstop(page, {
|
||||
engine: stubEngine,
|
||||
sourceId: 'default',
|
||||
sessionId: null,
|
||||
source: 'sync:import',
|
||||
mode: 'queue',
|
||||
});
|
||||
expect(queued).toEqual({
|
||||
mode: 'queue', enqueued: false, queueDepth: 0,
|
||||
skipped: 'chat_unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - parseExtractorOutput unit tests for the raw JSON parser
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import {
|
||||
runPhaseProposeTakes,
|
||||
parseExtractorOutput,
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
type ProposedTake,
|
||||
} from '../src/core/cycle/propose-takes.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
|
||||
// R5 shard hygiene: leave no configured gateway past the file boundary.
|
||||
afterAll(() => {
|
||||
resetGateway();
|
||||
});
|
||||
import { BudgetMeter } from '../src/core/cycle/budget-meter.ts';
|
||||
import type { OperationContext } from '../src/core/operations.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
@@ -199,6 +199,84 @@ describe('check-test-isolation.sh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('R5 — gateway configuration requires resetGateway teardown (#3066)', () => {
|
||||
it('flags configureGateway without resetGateway in a teardown hook', () => {
|
||||
const r = runLintIn([
|
||||
{
|
||||
path: 'gateway-leak.test.ts',
|
||||
contents:
|
||||
`import { beforeAll, test, expect } from 'bun:test';\n` +
|
||||
`import { configureGateway } from '../src/core/ai/gateway.ts';\n` +
|
||||
`beforeAll(() => { configureGateway({ env: {} }); });\n` +
|
||||
`test('x', () => expect(1).toBe(1));\n`,
|
||||
},
|
||||
]);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stdout).toContain('R5');
|
||||
expect(r.stdout).toContain('gateway-leak.test.ts');
|
||||
});
|
||||
|
||||
it('flags __setEmbedTransportForTests without resetGateway', () => {
|
||||
const r = runLintIn([
|
||||
{
|
||||
path: 'transport-leak.test.ts',
|
||||
contents:
|
||||
`import { afterAll, test, expect } from 'bun:test';\n` +
|
||||
`import { __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';\n` +
|
||||
`__setEmbedTransportForTests(async () => ({ embeddings: [] }));\n` +
|
||||
`afterAll(() => { /* disconnect only */ });\n` +
|
||||
`test('x', () => expect(1).toBe(1));\n`,
|
||||
},
|
||||
]);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stdout).toContain('R5');
|
||||
});
|
||||
|
||||
it('passes when resetGateway is called and an afterAll hook exists', () => {
|
||||
const r = runLintIn([
|
||||
{
|
||||
path: 'gateway-clean.test.ts',
|
||||
contents:
|
||||
`import { beforeAll, afterAll, test, expect } from 'bun:test';\n` +
|
||||
`import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';\n` +
|
||||
`beforeAll(() => { configureGateway({ env: {} }); });\n` +
|
||||
`afterAll(() => { resetGateway(); });\n` +
|
||||
`test('x', () => expect(1).toBe(1));\n`,
|
||||
},
|
||||
]);
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
it('a comment mentioning resetGateway() does not satisfy the rule', () => {
|
||||
const r = runLintIn([
|
||||
{
|
||||
path: 'gateway-comment-leak.test.ts',
|
||||
contents:
|
||||
`import { beforeAll, afterAll, test, expect } from 'bun:test';\n` +
|
||||
`import { configureGateway } from '../src/core/ai/gateway.ts';\n` +
|
||||
`// a co-sharded test that calls resetGateway() would clear this\n` +
|
||||
`beforeAll(() => { configureGateway({ env: {} }); });\n` +
|
||||
`afterAll(() => { /* disconnect only */ });\n` +
|
||||
`test('x', () => expect(1).toBe(1));\n`,
|
||||
},
|
||||
]);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stdout).toContain('R5');
|
||||
});
|
||||
|
||||
it('a test name mentioning "configureGateway (" does not trigger the rule', () => {
|
||||
const r = runLintIn([
|
||||
{
|
||||
path: 'gateway-prose.test.ts',
|
||||
contents:
|
||||
`import { test, expect } from 'bun:test';\n` +
|
||||
`test('works WITHOUT configureGateway (reads registry)', () => expect(1).toBe(1));\n`,
|
||||
},
|
||||
]);
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope', () => {
|
||||
it('skips *.serial.test.ts files entirely', () => {
|
||||
const r = runLintIn([
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import { __setEmbedTransportForTests } from '../../src/core/ai/gateway.ts';
|
||||
import { __setEmbedTransportForTests, resetGateway } from '../../src/core/ai/gateway.ts';
|
||||
import { runSearchDiagnose } from '../../src/commands/search-diagnose.ts';
|
||||
import type { ChunkInput } from '../../src/core/types.ts';
|
||||
|
||||
@@ -35,7 +35,7 @@ beforeAll(async () => {
|
||||
await engine.setPageAliases('projects/mingtang', 'default', ['hall of light']);
|
||||
});
|
||||
|
||||
afterAll(async () => { __setEmbedTransportForTests(null); await engine.disconnect(); });
|
||||
afterAll(async () => { resetGateway(); await engine.disconnect(); });
|
||||
|
||||
describe('search diagnose', () => {
|
||||
test('alias query: trace shows alias match + hybrid rank 1', async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import { configureGateway } from '../../src/core/ai/gateway.ts';
|
||||
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
|
||||
import type { ChunkInput } from '../../src/core/types.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
@@ -95,6 +95,7 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
describe('searchVector per-page max-pool (T1)', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
||||
import { hybridSearch } from '../../src/core/search/hybrid.ts';
|
||||
import { buildOrFallbackWebsearchQuery } from '../../src/core/search/sql-ranking.ts';
|
||||
import { configureGateway } from '../../src/core/ai/gateway.ts';
|
||||
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
@@ -48,6 +48,7 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
resetGateway();
|
||||
// Restore the preload-equivalent gateway for sibling files in this shard.
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
|
||||
Reference in New Issue
Block a user