mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix: think --model fails loud on unresolvable model; never persist empty synthesis (#1698) Slash-form model ids (anthropic/claude-sonnet-4-6) silently degraded to the no-LLM stub and wrote empty synthesis pages with exit 0 (reporter saw 200). Three compounding defects, fixed: - normalizeModelId (src/core/model-id.ts): one shared provider:model normalizer replacing 4 colon-only inlines; slash→colon, bare→default, malformed leading-separator (:foo) returned unchanged so resolveRecipe throws loud. - validateModelId + probeChatModel (gateway.ts): shared id-validity + key probe; runThink hard-errors on an explicit --model it can't run (no silent degrade). - synthesisOk + persist-skip (think/index.ts): empty/malformed/empty-JSON synthesis is never persisted; --save with no synthesis exits 1. - auto-think (cycle/auto-think.ts): empty synthesis no longer counts complete or advances the cooldown (the autonomous third caller of persistSynthesis). - hasAnthropicKey consolidated into src/core/ai/anthropic-key.ts (3 copies → 1). MCP think op sets modelExplicit; saved_slug '' maps to null. * chore: bump version and changelog (v0.42.4.0) #1698 think --model fail-loud wave. Also files the P3 follow-up TODO for the provider-symmetric early gate (D1 accept-as-is). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
/**
|
|
* Gateway-adapter tests for the dream-cycle significance judge (T5 + T6 wave).
|
|
*
|
|
* Replaces the v0.23-era `new Anthropic()` direct-SDK construction with a
|
|
* gateway-routed JudgeClient that works for any provider with a registered
|
|
* recipe (Anthropic, DeepSeek, OpenRouter, Voyage, Ollama, llama-server, ...).
|
|
*
|
|
* Mirrors the test pattern from test/think-gateway-adapter.test.ts for parity
|
|
* with src/core/think/index.ts (v0.35.5.0). The IRON RULE regression R3 lives
|
|
* here too — given identical canned LLM text, judgeSignificance produces the
|
|
* same {worth_processing, reasons} via the gateway-adapter shape as it would
|
|
* via the legacy Anthropic SDK shape. The contract that matters is parsed-
|
|
* verdict SEMANTIC PARITY (not byte-identical Anthropic.Message struct, which
|
|
* codex correctly flagged as a meaningless gate).
|
|
*/
|
|
|
|
import { describe, test, expect, afterEach } from 'bun:test';
|
|
import {
|
|
__setChatTransportForTests,
|
|
resetGateway,
|
|
type ChatResult,
|
|
} from '../../src/core/ai/gateway.ts';
|
|
import { AIConfigError } from '../../src/core/ai/errors.ts';
|
|
import { makeJudgeClient, judgeSignificance, type JudgeClient } from '../../src/core/cycle/synthesize.ts';
|
|
import { withEnv } from '../helpers/with-env.ts';
|
|
import type { DiscoveredTranscript } from '../../src/core/cycle/transcript-discovery.ts';
|
|
|
|
afterEach(() => {
|
|
__setChatTransportForTests(null);
|
|
resetGateway();
|
|
});
|
|
|
|
// Canned "worth processing" LLM text used by the parsed-verdict parity tests.
|
|
// Mirrors what a well-tuned Haiku would emit for a substantive transcript.
|
|
const WORTH_PROCESSING_JSON = JSON.stringify({
|
|
worth_processing: true,
|
|
reasons: ['user reflects on portfolio framework', 'concrete strategic call'],
|
|
});
|
|
|
|
// Synthetic transcript fixture for judgeSignificance — only `content` and
|
|
// `basename` are read by the judge.
|
|
const FIXTURE_TRANSCRIPT: DiscoveredTranscript = {
|
|
filePath: '/dev/null/fixture.txt',
|
|
basename: 'fixture',
|
|
content: 'Synthetic transcript content for gateway-adapter parity tests.',
|
|
contentHash: 'sha-fixture-1',
|
|
inferredDate: '2026-05-24',
|
|
};
|
|
|
|
describe('makeJudgeClient — construction-time provider probe', () => {
|
|
test('A1: returns null when verdict model is anthropic and no API key is configured', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
|
|
// Use a synthetic config path to avoid surfacing a stored anthropic_api_key.
|
|
await withEnv({ GBRAIN_HOME: '/tmp/nonexistent-gbrain-home-for-A1' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
expect(judge).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
test('A2: returns a JudgeClient when chat provider is reachable (anthropic key set)', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A2' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
expect(judge).not.toBeNull();
|
|
expect(typeof judge?.create).toBe('function');
|
|
});
|
|
});
|
|
|
|
test('A8: returns null when verdict model has unknown provider prefix', async () => {
|
|
// resolveRecipe throws AIConfigError on unknown provider id;
|
|
// makeJudgeClient catches it and returns null.
|
|
const judge = makeJudgeClient('notarealprovider:some-model');
|
|
expect(judge).toBeNull();
|
|
});
|
|
|
|
test('A9: returns a JudgeClient for non-anthropic providers without probing env (delegates to gateway)', async () => {
|
|
// Non-anthropic providers don't get the hasAnthropicKey() short-circuit.
|
|
// The deepseek recipe declares DEEPSEEK_API_KEY in auth_env.required;
|
|
// makeJudgeClient delegates that probe to gateway.chat at call time
|
|
// (where it would throw AIConfigError, caught per-transcript by the loop).
|
|
// #1698 C1: this MUST stay green — validateModelId (id-validity only) does NOT
|
|
// reject a non-anthropic provider for a missing key (no isAvailable here).
|
|
await withEnv({ DEEPSEEK_API_KEY: undefined }, async () => {
|
|
const judge = makeJudgeClient('deepseek:deepseek-chat');
|
|
expect(judge).not.toBeNull();
|
|
expect(typeof judge?.create).toBe('function');
|
|
});
|
|
});
|
|
|
|
test('A8b (#1698): typo native model → null at construction (validateModelId unknown_model)', async () => {
|
|
// Pre-#1698 makeJudgeClient only checked the provider (resolveRecipe) and would
|
|
// have returned a client that failed at call time. Now the shared validateModelId
|
|
// core runs assertTouchpoint, so a typo'd native model is rejected up front.
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A8b' }, async () => {
|
|
const judge = makeJudgeClient('anthropic:claude-bogus-9');
|
|
expect(judge).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('JudgeClient.create — gateway routing + shape adapter', () => {
|
|
test('A3: routes through gateway.chat (verified via __setChatTransportForTests stub)', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A3' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
expect(judge).not.toBeNull();
|
|
|
|
let transportCalled = false;
|
|
let receivedSystem: string | undefined;
|
|
let receivedModel: string | undefined;
|
|
__setChatTransportForTests(async (opts): Promise<ChatResult> => {
|
|
transportCalled = true;
|
|
receivedSystem = opts.system;
|
|
receivedModel = opts.model;
|
|
return {
|
|
text: WORTH_PROCESSING_JSON,
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 10, output_tokens: 20, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'test:stub',
|
|
providerId: 'test',
|
|
};
|
|
});
|
|
|
|
const result = await judge!.create({
|
|
model: 'claude-haiku-4-5-20251001',
|
|
max_tokens: 200,
|
|
system: 'judge system prompt',
|
|
messages: [{ role: 'user', content: 'judge this' }],
|
|
});
|
|
|
|
expect(transportCalled).toBe(true);
|
|
expect(receivedSystem).toBe('judge system prompt');
|
|
// Gateway model gets the anthropic: prefix normalized
|
|
expect(receivedModel).toBe('anthropic:claude-haiku-4-5-20251001');
|
|
// Anthropic.Message shape returned
|
|
expect(result.content?.[0]?.type).toBe('text');
|
|
expect((result.content?.[0] as { type: string; text: string }).text).toBe(WORTH_PROCESSING_JSON);
|
|
});
|
|
});
|
|
|
|
test('A4: ChatResult.text → Anthropic.Message.content[0].text mapping', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A4' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => ({
|
|
text: 'mapped text content',
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 5, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'test:stub',
|
|
providerId: 'test',
|
|
}));
|
|
|
|
const result = await judge!.create({
|
|
model: 'claude-haiku-4-5-20251001',
|
|
max_tokens: 100,
|
|
system: 's',
|
|
messages: [{ role: 'user', content: 'u' }],
|
|
});
|
|
|
|
expect(result.role).toBe('assistant');
|
|
expect(result.type).toBe('message');
|
|
expect(result.content?.[0]?.type).toBe('text');
|
|
expect((result.content?.[0] as { type: string; text: string }).text).toBe('mapped text content');
|
|
expect(result.usage.input_tokens).toBe(5);
|
|
expect(result.usage.output_tokens).toBe(5);
|
|
});
|
|
});
|
|
|
|
test('A5: empty text from gateway → returns Anthropic.Message with empty text content (graceful)', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A5' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => ({
|
|
text: '',
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'test:stub',
|
|
providerId: 'test',
|
|
}));
|
|
|
|
const result = await judge!.create({
|
|
model: 'claude-haiku-4-5-20251001',
|
|
max_tokens: 100,
|
|
system: 's',
|
|
messages: [{ role: 'user', content: 'u' }],
|
|
});
|
|
|
|
// Doesn't throw; produces a well-shaped Anthropic.Message with empty text.
|
|
expect(result.content?.[0]?.type).toBe('text');
|
|
expect((result.content?.[0] as { type: string; text: string }).text).toBe('');
|
|
});
|
|
});
|
|
|
|
test('A6: non-AIConfigError from gateway propagates to caller (no swallowing)', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A6' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => {
|
|
throw new Error('network blip');
|
|
});
|
|
|
|
let caught: unknown = null;
|
|
try {
|
|
await judge!.create({
|
|
model: 'claude-haiku-4-5-20251001',
|
|
max_tokens: 100,
|
|
system: 's',
|
|
messages: [{ role: 'user', content: 'u' }],
|
|
});
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as Error).message).toBe('network blip');
|
|
});
|
|
});
|
|
|
|
test('A7: AIConfigError from gateway propagates as AIConfigError (caught by verdict loop in production)', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-A7' }, async () => {
|
|
const judge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => {
|
|
throw new AIConfigError('anthropic_api_key revoked mid-run');
|
|
});
|
|
|
|
let caught: unknown = null;
|
|
try {
|
|
await judge!.create({
|
|
model: 'claude-haiku-4-5-20251001',
|
|
max_tokens: 100,
|
|
system: 's',
|
|
messages: [{ role: 'user', content: 'u' }],
|
|
});
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
expect(caught).toBeInstanceOf(AIConfigError);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('R3 — parsed-verdict semantic parity (IRON RULE regression)', () => {
|
|
/**
|
|
* The contract that matters: given identical canned LLM text content,
|
|
* judgeSignificance produces the same {worth_processing, reasons} parsed
|
|
* values whether the JudgeClient is a gateway-routed adapter or a hand-
|
|
* rolled stub matching the pre-v0.40.x Anthropic SDK shape. Byte-identity
|
|
* of the underlying Anthropic.Message struct is NOT the contract (per
|
|
* codex outside-voice review of the wave plan).
|
|
*/
|
|
test('R3: gateway-routed JudgeClient produces same parsed verdict as legacy SDK-shape JudgeClient', async () => {
|
|
// The "legacy" path — a JudgeClient that returns an Anthropic.Message
|
|
// shape directly, bypassing the gateway. This is the shape
|
|
// makeHaikuClient() used to construct via `new Anthropic()`.
|
|
const legacyJudge: JudgeClient = {
|
|
create: async () => ({
|
|
id: 'msg_legacy',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-haiku-4-5-20251001',
|
|
content: [{ type: 'text', text: WORTH_PROCESSING_JSON }],
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 100, output_tokens: 50 },
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any),
|
|
};
|
|
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-R3' }, async () => {
|
|
const gatewayJudge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
expect(gatewayJudge).not.toBeNull();
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => ({
|
|
text: WORTH_PROCESSING_JSON,
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-haiku-4-5-20251001',
|
|
providerId: 'anthropic',
|
|
}));
|
|
|
|
const [legacyVerdict, gatewayVerdict] = await Promise.all([
|
|
judgeSignificance(legacyJudge, FIXTURE_TRANSCRIPT, 'claude-haiku-4-5-20251001'),
|
|
judgeSignificance(gatewayJudge!, FIXTURE_TRANSCRIPT, 'claude-haiku-4-5-20251001'),
|
|
]);
|
|
|
|
// The parsed-verdict semantic-parity contract.
|
|
expect(gatewayVerdict.worth_processing).toBe(legacyVerdict.worth_processing);
|
|
expect(gatewayVerdict.reasons).toEqual(legacyVerdict.reasons);
|
|
// Sanity: both produced the expected verdict (not just both empty).
|
|
expect(legacyVerdict.worth_processing).toBe(true);
|
|
expect(legacyVerdict.reasons.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
test('R3 corollary: unparseable LLM output → both paths return cheap-fallback verdict', async () => {
|
|
// Pre-rework AND post-rework both fall through to the
|
|
// "judge response unparseable" branch when content isn't JSON.
|
|
const legacyJudge: JudgeClient = {
|
|
create: async () => ({
|
|
id: 'msg_legacy_garbage',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-haiku-4-5-20251001',
|
|
content: [{ type: 'text', text: 'not json at all' }],
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 100, output_tokens: 50 },
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any),
|
|
};
|
|
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-R3b' }, async () => {
|
|
const gatewayJudge = makeJudgeClient('claude-haiku-4-5-20251001');
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => ({
|
|
text: 'not json at all',
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-haiku-4-5-20251001',
|
|
providerId: 'anthropic',
|
|
}));
|
|
|
|
const [legacyVerdict, gatewayVerdict] = await Promise.all([
|
|
judgeSignificance(legacyJudge, FIXTURE_TRANSCRIPT, 'claude-haiku-4-5-20251001'),
|
|
judgeSignificance(gatewayJudge!, FIXTURE_TRANSCRIPT, 'claude-haiku-4-5-20251001'),
|
|
]);
|
|
|
|
expect(legacyVerdict.worth_processing).toBe(false);
|
|
expect(gatewayVerdict.worth_processing).toBe(false);
|
|
expect(gatewayVerdict.reasons).toEqual(legacyVerdict.reasons);
|
|
});
|
|
});
|
|
});
|