- Wording: every comment now says the stream timeouts are RAISED to a
generous-but-finite ~15-min silence timeout, not "disabled (0)" (the stale
comments contradicted the code, which uses AI_STREAM_TIMEOUT_MS, default
900000ms).
- Architecture (the load-bearing-temporary trap): the streaming fetch reached
the chat provider only by riding the "temporary DIAGNOSTIC" telemetry, so
deleting the telemetry by its own label would silently revert the timeout fix.
Legitimize it: rename ai-http-diagnostics.ts -> ai-provider-http.ts,
createDiagnosticFetch -> createInstrumentedFetch, field aiDiagnosticFetch ->
aiProviderFetch, drop the "temporary" labels, and document the chat transport
(streaming fetch + instrumentation) as one intentional construct.
- Docs: AI_STREAM_TIMEOUT_MS added to .env.example next to AI_EMBEDDING_TIMEOUT_MS.
- Tests:
- ai-provider-http.spec: createInstrumentedFetch delegates to the injected
baseFetch with the same input/init, returns the Response untouched, rethrows
the error, and defaults to global fetch — covering the baseFetch seam.
- ai-streaming-fetch.spec: the delayed-server test is now LOAD-BEARING — with
AI_STREAM_TIMEOUT_MS set below the 1.5s server delay the call actually rejects
(a lost dispatcher -> global 300s default would NOT), proving the configured
dispatcher is wired; plus the default-timeout happy path.
server tsc clean; ai-streaming-fetch / ai-provider-http / ai.service / mcp-servers
/ ai-error specs green (41).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
41 lines
1.8 KiB
TypeScript
41 lines
1.8 KiB
TypeScript
import { createInstrumentedFetch } from './ai-provider-http';
|
|
|
|
/**
|
|
* createInstrumentedFetch must be behavior-neutral: it delegates to the supplied
|
|
* baseFetch with the SAME input/init, returns the Response object untouched (so
|
|
* the streamed SSE body is never read/cloned), and rethrows the same error. The
|
|
* baseFetch injection is the seam that carries the streaming fetch (#175) onto
|
|
* the chat provider, so it is tested directly.
|
|
*/
|
|
describe('createInstrumentedFetch', () => {
|
|
it('delegates to the injected baseFetch with the same input/init', async () => {
|
|
const fakeResponse = new Response('ok', { status: 200 });
|
|
const baseFetch = jest.fn().mockResolvedValue(fakeResponse);
|
|
const instrumented = createInstrumentedFetch('test', baseFetch as never);
|
|
|
|
const init = { method: 'POST', body: '{"q":1}' };
|
|
const res = await instrumented('https://example.com/v1/chat', init);
|
|
|
|
expect(baseFetch).toHaveBeenCalledTimes(1);
|
|
expect(baseFetch).toHaveBeenCalledWith('https://example.com/v1/chat', init);
|
|
// The Response is returned UNTOUCHED (same reference — never read/cloned).
|
|
expect(res).toBe(fakeResponse);
|
|
});
|
|
|
|
it('rethrows the base fetch error unchanged (pre-response failure)', async () => {
|
|
const err = Object.assign(new TypeError('fetch failed'), {
|
|
cause: { code: 'ECONNRESET' },
|
|
});
|
|
const baseFetch = jest.fn().mockRejectedValue(err);
|
|
const instrumented = createInstrumentedFetch('test', baseFetch as never);
|
|
|
|
await expect(instrumented('https://example.com/')).rejects.toBe(err);
|
|
});
|
|
|
|
it('defaults to the global fetch when no baseFetch is given', () => {
|
|
// Constructing without a baseFetch must not throw — it simply wraps global
|
|
// fetch (the non-chat default).
|
|
expect(() => createInstrumentedFetch('test')).not.toThrow();
|
|
});
|
|
});
|