Files
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
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.
2026-09-17 17:49:19 +07:00

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