diff --git a/.changeset/unhandled-rejection-stream-abort.md b/.changeset/unhandled-rejection-stream-abort.md new file mode 100644 index 0000000..70dba83 --- /dev/null +++ b/.changeset/unhandled-rejection-stream-abort.md @@ -0,0 +1,5 @@ +--- +'@openrouter/agent': patch +--- + +Prevent unhandled promise rejection crash when aborting a tool-enabled stream. diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index bbc4f04..f57b181 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -1098,6 +1098,9 @@ export class ModelResult< } broadcaster.complete(); }); + // Attach an immediate rejection handler so early consumer exit (such as an abort) + // does not leave executionPromise as an unhandled promise rejection. + void executionPromise.catch(() => {}); return { consumer, executionPromise, diff --git a/packages/agent/tests/unit/run-cancellation.test.ts b/packages/agent/tests/unit/run-cancellation.test.ts index 1f5ca21..0f8b3d3 100644 --- a/packages/agent/tests/unit/run-cancellation.test.ts +++ b/packages/agent/tests/unit/run-cancellation.test.ts @@ -389,4 +389,64 @@ describe('per-request timeoutMs composition', () => { expect(text).toBe('done'); expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); }); + + it('does not emit unhandledRejection when aborting a tool-enabled stream', async () => { + const unhandledRejections: unknown[] = []; + const onUnhandled = (err: unknown) => { + unhandledRejections.push(err); + }; + process.on('unhandledRejection', onUnhandled); + + try { + const controller = new AbortController(); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(c) { + streamController = c; + }, + }); + + mockBetaResponsesSend.mockImplementation(async (_client, _req, options) => { + options.signal?.addEventListener('abort', () => { + streamController.error(options.signal?.reason ?? new Error('Aborted')); + }); + return { + ok: true, + value: stream, + }; + }); + + const result = callModel(client, { + model: 'test-model', + input: 'hello', + tools: [ + echoTool, + ], + signal: controller.signal, + }); + + let streamError: unknown; + try { + streamController.enqueue({ + type: 'response.output_text.delta', + delta: 'chunk', + sequenceNumber: 0, + } as models.StreamEvents); + + for await (const _event of result.getFullResponsesStream()) { + controller.abort(new DOMException('This operation was aborted', 'AbortError')); + } + } catch (err) { + streamError = err; + } + + expect(streamError).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(unhandledRejections).toHaveLength(0); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); });