Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates
Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
import { generateResult, textChunks, usageOf } from './helpers';
|
|
import { expect, test } from 'bun:test';
|
|
import { APICallError } from 'ai';
|
|
import type { LanguageModelV4, LanguageModelV4CallOptions, LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
|
import { simulateReadableStream } from 'ai/test';
|
|
import { withFallback, type FallbackEvent } from '../src/fallback';
|
|
|
|
const usage = usageOf(1);
|
|
|
|
const okStream = (body: string) => ({
|
|
stream: simulateReadableStream<LanguageModelV4StreamPart>({
|
|
chunks: textChunks(body, usage),
|
|
chunkDelayInMs: null,
|
|
initialDelayInMs: null,
|
|
}),
|
|
});
|
|
|
|
const apiError = (statusCode: number, message: string, isRetryable = false) =>
|
|
new APICallError({ message, url: 'http://x/v1', requestBodyValues: {}, statusCode, isRetryable });
|
|
|
|
function model(name: string, behaviour: () => Promise<any>): LanguageModelV4 {
|
|
return {
|
|
specificationVersion: 'v4',
|
|
provider: 'test',
|
|
modelId: name,
|
|
supportedUrls: {},
|
|
doGenerate: behaviour,
|
|
doStream: behaviour,
|
|
};
|
|
}
|
|
|
|
const opts: LanguageModelV4CallOptions = { prompt: [] };
|
|
|
|
const REAL_MESSAGE =
|
|
"Function tools with reasoning_effort are not supported for gpt-5.6-sol in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.";
|
|
|
|
test('a single model is returned unwrapped', async () => {
|
|
const only = model('solo', async () => okStream('hi'));
|
|
expect(withFallback([only])).toBe(only);
|
|
});
|
|
|
|
test('withFallback with no models is a programming error', () => {
|
|
expect(() => withFallback([])).toThrow(/needs at least one model/);
|
|
});
|
|
|
|
test('the primary is used when it works and no fallback is reported', async () => {
|
|
const events: FallbackEvent[] = [];
|
|
let secondCalls = 0;
|
|
const wrapped = withFallback(
|
|
[
|
|
model('chat', async () => okStream('from chat')),
|
|
model('responses', async () => {
|
|
secondCalls++;
|
|
return okStream('from responses');
|
|
}),
|
|
],
|
|
(e) => events.push(e),
|
|
);
|
|
|
|
await wrapped.doStream(opts);
|
|
expect(secondCalls).toBe(0);
|
|
expect(events).toEqual([]);
|
|
});
|
|
|
|
test('the real gpt-5.6 400 falls through to the second endpoint', async () => {
|
|
const events: FallbackEvent[] = [];
|
|
const wrapped = withFallback(
|
|
[
|
|
model('chat', async () => {
|
|
throw apiError(400, REAL_MESSAGE);
|
|
}),
|
|
model('responses', async () => okStream('worked on responses')),
|
|
],
|
|
(e) => events.push(e),
|
|
);
|
|
|
|
const result = await wrapped.doStream(opts);
|
|
expect(result.stream).toBeDefined();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.from).toBe('test/chat');
|
|
expect(events[0]?.to).toBe('test/responses');
|
|
expect(events[0]?.reason).toContain('400');
|
|
expect(events[0]?.reason).toContain('/v1/responses');
|
|
});
|
|
|
|
test('doGenerate falls back on the same condition as doStream', async () => {
|
|
const wrapped = withFallback([
|
|
model('chat', async () => {
|
|
throw apiError(400, REAL_MESSAGE);
|
|
}),
|
|
model('responses', async () => generateResult('ok', usage)),
|
|
]);
|
|
|
|
const out = await wrapped.doGenerate(opts);
|
|
expect(out.content[0]).toMatchObject({ type: 'text', text: 'ok' });
|
|
});
|
|
|
|
test('a 401 is not a shape mismatch, so it propagates untouched', async () => {
|
|
let secondCalls = 0;
|
|
const wrapped = withFallback([
|
|
model('chat', async () => {
|
|
throw apiError(401, 'invalid api key');
|
|
}),
|
|
model('responses', async () => {
|
|
secondCalls++;
|
|
return okStream('should not happen');
|
|
}),
|
|
]);
|
|
|
|
expect(wrapped.doStream(opts)).rejects.toThrow(/invalid api key/);
|
|
await Bun.sleep(5);
|
|
expect(secondCalls).toBe(0);
|
|
});
|
|
|
|
test('a retryable 500 is left to the SDK retry, not the fallback chain', async () => {
|
|
let secondCalls = 0;
|
|
const wrapped = withFallback([
|
|
model('chat', async () => {
|
|
throw apiError(500, 'upstream down', true);
|
|
}),
|
|
model('responses', async () => {
|
|
secondCalls++;
|
|
return okStream('nope');
|
|
}),
|
|
]);
|
|
|
|
expect(wrapped.doStream(opts)).rejects.toThrow(/upstream down/);
|
|
await Bun.sleep(5);
|
|
expect(secondCalls).toBe(0);
|
|
});
|
|
|
|
test('a non-API error propagates without switching endpoints', async () => {
|
|
let secondCalls = 0;
|
|
const wrapped = withFallback([
|
|
model('chat', async () => {
|
|
throw new TypeError('bug in our code');
|
|
}),
|
|
model('responses', async () => {
|
|
secondCalls++;
|
|
return okStream('nope');
|
|
}),
|
|
]);
|
|
|
|
expect(wrapped.doStream(opts)).rejects.toThrow(/bug in our code/);
|
|
await Bun.sleep(5);
|
|
expect(secondCalls).toBe(0);
|
|
});
|
|
|
|
test('when every endpoint rejects the shape, the last error surfaces', async () => {
|
|
const events: FallbackEvent[] = [];
|
|
const wrapped = withFallback(
|
|
[
|
|
model('a', async () => {
|
|
throw apiError(400, 'a rejected');
|
|
}),
|
|
model('b', async () => {
|
|
throw apiError(404, 'b has no such route');
|
|
}),
|
|
],
|
|
(e) => events.push(e),
|
|
);
|
|
|
|
expect(wrapped.doStream(opts)).rejects.toThrow(/b has no such route/);
|
|
await Bun.sleep(5);
|
|
expect(events).toHaveLength(1);
|
|
});
|
|
|
|
test('the switch is sticky: the rejecting endpoint is not probed again', async () => {
|
|
let chatCalls = 0;
|
|
let respCalls = 0;
|
|
const events: FallbackEvent[] = [];
|
|
const wrapped = withFallback(
|
|
[
|
|
model('chat', async () => {
|
|
chatCalls++;
|
|
throw apiError(400, REAL_MESSAGE);
|
|
}),
|
|
model('responses', async () => {
|
|
respCalls++;
|
|
return okStream('ok');
|
|
}),
|
|
],
|
|
(e) => events.push(e),
|
|
);
|
|
|
|
await wrapped.doStream(opts);
|
|
await wrapped.doStream(opts);
|
|
await wrapped.doStream(opts);
|
|
|
|
expect(chatCalls).toBe(1);
|
|
expect(respCalls).toBe(3);
|
|
expect(events).toHaveLength(1);
|
|
});
|
|
|
|
test('the wrapper reports the primary provider and model id', () => {
|
|
const wrapped = withFallback([model('chat-id', async () => okStream('x')), model('resp-id', async () => okStream('y'))]);
|
|
expect(wrapped.modelId).toBe('chat-id');
|
|
expect(wrapped.provider).toBe('test');
|
|
expect(wrapped.specificationVersion).toBe('v4');
|
|
});
|