Surface Assistant stream timeouts as errors

This commit is contained in:
rcourtman
2026-06-08 05:19:09 +01:00
parent 37bb716e89
commit fb085763ab
4 changed files with 77 additions and 1 deletions
@@ -1146,6 +1146,35 @@ describe('AIChatAPI', () => {
clearTimeoutSpy.mockRestore();
});
it('rejects stalled chat stream reads instead of emitting synthetic completion', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const read = vi.fn(
() => new Promise<ReadableStreamReadResult<Uint8Array>>(() => undefined),
);
const releaseLock = vi.fn();
const onEvent = vi.fn();
apiFetchMock.mockResolvedValueOnce({
ok: true,
body: {
getReader: () => ({ read, releaseLock }),
},
} as unknown as Response);
const streamPromise = AIChatAPI.chat('hello', undefined, undefined, onEvent);
const expectedRejection = expect(streamPromise).rejects.toThrow(
'Pulse Assistant stream timed out waiting for provider data.',
);
await flushMicrotasks();
await vi.advanceTimersByTimeAsync(300000);
await expectedRejection;
expect(read).toHaveBeenCalledTimes(1);
expect(releaseLock).toHaveBeenCalledTimes(1);
expect(onEvent).not.toHaveBeenCalledWith({ type: 'done' });
expect(logger.warn).toHaveBeenCalledWith('[AI Chat] Stream timeout');
});
it('ignores invalid chat stream events through the shared JSON-text helper', async () => {
const encoder = new TextEncoder();
const read = vi
@@ -235,4 +235,39 @@ describe('consumeJSONEventStream', () => {
await streamPromise;
expect(events).toEqual(['tool_start', 'tool_progress', 'done']);
});
it('surfaces stalled read timeouts without reporting normal completion', async () => {
vi.useFakeTimers();
const read = vi.fn(
() => new Promise<ReadableStreamReadResult<Uint8Array>>(() => undefined),
);
const releaseLock = vi.fn();
const onEvent = vi.fn();
const onTimeout = vi.fn();
const onComplete = vi.fn();
const streamPromise = consumeJSONEventStream<{ type: string }>(
{
body: {
getReader: () => ({ read, releaseLock }),
},
} as unknown as Response,
{
onEvent,
onTimeout,
onComplete,
timeoutMs: 1000,
},
);
await flushMicrotasks();
await vi.advanceTimersByTimeAsync(1000);
await streamPromise;
expect(read).toHaveBeenCalledTimes(1);
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(onComplete).not.toHaveBeenCalled();
expect(onEvent).not.toHaveBeenCalled();
expect(releaseLock).toHaveBeenCalledTimes(1);
});
});
+1
View File
@@ -408,6 +408,7 @@ export class AIChatAPI {
},
onTimeout: () => {
logger.warn('[AI Chat] Stream timeout');
throw new Error('Pulse Assistant stream timed out waiting for provider data.');
},
onComplete: () => {
onEvent({ type: 'done' });
+12 -1
View File
@@ -65,6 +65,12 @@ export async function consumeJSONEventStream<T>(
let buffer = '';
const timeoutMs = options.timeoutMs ?? 300000;
let lastEventTime = Date.now();
let timedOut = false;
const markTimedOut = () => {
timedOut = true;
options.onTimeout?.();
};
const readWithTimeout = async (): Promise<ReadableStreamReadResult<Uint8Array>> => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -122,7 +128,7 @@ export async function consumeJSONEventStream<T>(
try {
for (;;) {
if (Date.now() - lastEventTime > timeoutMs) {
options.onTimeout?.();
markTimedOut();
break;
}
@@ -131,6 +137,7 @@ export async function consumeJSONEventStream<T>(
result = await readWithTimeout();
} catch (error) {
if ((error as Error).message === 'Read timeout') {
markTimedOut();
break;
}
throw error;
@@ -148,6 +155,10 @@ export async function consumeJSONEventStream<T>(
}
}
if (timedOut) {
return;
}
const trailing = buffer.trim();
if (trailing.startsWith('data: ')) {
const event = parseJSONTextSafe<T>(trailing.slice(6));