@@ -1820,6 +1820,9 @@ describe("AwsBedrockHandler", () => {
18201820 expect ( isAdaptiveThinkingModel ( "anthropic.claude-3-5-sonnet-20241022-v2:0" ) ) . toBe ( false )
18211821 expect ( isAdaptiveThinkingModel ( "amazon.nova-lite-v1:0" ) ) . toBe ( false )
18221822 } )
1823+ } )
1824+
1825+ describe ( "completePrompt and createMessage: abort signal and listener lifecycle" , ( ) => {
18231826 it ( "should pass abort signal through to client.send" , async ( ) => {
18241827 const mockSend = vi . fn ( )
18251828
@@ -1893,6 +1896,9 @@ describe("AwsBedrockHandler", () => {
18931896 const sendOptions = mockSend . mock . calls [ 0 ] [ 1 ]
18941897 expect ( sendOptions ) . toBeDefined ( )
18951898 expect ( sendOptions ?. abortSignal ) . toBeDefined ( )
1899+ // The signal must not be aborted yet (i.e. a real timeout signal was created,
1900+ // not a no-op placeholder)
1901+ expect ( sendOptions ?. abortSignal . aborted ) . toBe ( false )
18961902 } )
18971903
18981904 it ( "completePrompt should merge abortSignal and timeoutMs" , async ( ) => {
@@ -1918,42 +1924,62 @@ describe("AwsBedrockHandler", () => {
19181924 expect ( mockSend ) . toHaveBeenCalled ( )
19191925 const sendOptions = mockSend . mock . calls [ 0 ] [ 1 ]
19201926 expect ( sendOptions ?. abortSignal ) . toBeDefined ( )
1927+ // AbortSignal.any() returns a new composite object; if the merge were skipped and
1928+ // the external signal returned directly, this assertion would fail
1929+ expect ( sendOptions ?. abortSignal ) . not . toBe ( controller . signal )
1930+ // The merged signal propagates the external abort
1931+ controller . abort ( )
1932+ expect ( sendOptions ?. abortSignal . aborted ) . toBe ( true )
19211933 } )
19221934
1923- it ( "should abort internal controller when external abortSignal is triggered" , async ( ) => {
1924- const mockResult = {
1925- output : { message : { content : [ { type : "text" , text : "response" } ] } , stopReason : null } ,
1926- }
1927- const mockSend = vi . fn ( ) . mockResolvedValue ( mockResult )
1928-
1935+ it ( "should abort the merged signal mid-flight when the external abortSignal fires" , async ( ) => {
1936+ // This test keeps client.send pending so it can verify that aborting the external
1937+ // signal while the request is in flight propagates through the composite signal
1938+ // and cancels the SDK call — a post-completion check cannot prove this.
19291939 const handler = new AwsBedrockHandler ( {
19301940 apiModelId : "anthropic.claude-3-5-sonnet-20241022-v2:0" ,
19311941 awsAccessKey : "test-access-key" ,
19321942 awsSecretKey : "test-secret-key" ,
19331943 awsRegion : "us-east-1" ,
19341944 } )
19351945
1936- const clientInstance = handler [ "client" ]
1937- clientInstance . send = mockSend
1938-
19391946 const controller = new AbortController ( )
19401947 let internalSignalCaptured : AbortSignal | undefined
19411948
1942- // Spy on the send call to capture the abortSignal
1943- mockSend . mockImplementation ( async ( _command : unknown , options ?: { abortSignal ?: AbortSignal } ) => {
1944- internalSignalCaptured = options ?. abortSignal
1945- return mockResult
1949+ const mockSend = vi
1950+ . fn ( )
1951+ . mockImplementation ( ( _command : unknown , options ?: { abortSignal ?: AbortSignal } ) => {
1952+ internalSignalCaptured = options ?. abortSignal
1953+ return new Promise < unknown > ( ( _resolve , reject ) => {
1954+ internalSignalCaptured ?. addEventListener (
1955+ "abort" ,
1956+ ( ) => reject ( new DOMException ( "The operation was aborted." , "AbortError" ) ) ,
1957+ { once : true } ,
1958+ )
1959+ } )
1960+ } )
1961+ handler [ "client" ] . send = mockSend
1962+
1963+ // Pass timeoutMs so mergeAbortSignalAndTimeout creates a composite via AbortSignal.any()
1964+ const sendPromise = handler . completePrompt ( "test prompt" , {
1965+ abortSignal : controller . signal ,
1966+ timeoutMs : 5000 ,
19461967 } )
19471968
1948- await handler . completePrompt ( "test prompt" , { abortSignal : controller . signal } )
1969+ // Wait until client.send is in flight and the composite signal is captured
1970+ await vi . waitFor ( ( ) => {
1971+ expect ( internalSignalCaptured ) . toBeDefined ( )
1972+ } )
19491973
1950- expect ( internalSignalCaptured ) . toBeDefined ( )
1951- expect ( internalSignalCaptured ) . toBeInstanceOf ( AbortSignal )
1974+ // The composite is a distinct object — not the same reference as the external signal
1975+ expect ( internalSignalCaptured ) . not . toBe ( controller . signal )
1976+ expect ( internalSignalCaptured ?. aborted ) . toBe ( false )
19521977
1953- // Abort the external signal and verify it propagates to the captured signal
1978+ // Abort mid-flight; the composite must propagate it immediately (synchronous)
19541979 controller . abort ( )
1955- await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) )
19561980 expect ( internalSignalCaptured ?. aborted ) . toBe ( true )
1981+
1982+ await expect ( sendPromise ) . rejects . toMatchObject ( { name : "AbortError" } )
19571983 } )
19581984
19591985 it ( "should abort immediately when signal is already aborted and timeoutMs > 0" , async ( ) => {
@@ -1982,6 +2008,9 @@ describe("AwsBedrockHandler", () => {
19822008 const sendOptions = mockSend . mock . calls [ 0 ] [ 1 ]
19832009 expect ( sendOptions ?. abortSignal ) . toBeDefined ( )
19842010 expect ( sendOptions ?. abortSignal . aborted ) . toBe ( true )
2011+ // AbortSignal.any() always returns a new composite; this distinguishes the merged
2012+ // path from a mutation that returns the pre-aborted external signal directly
2013+ expect ( sendOptions ?. abortSignal ) . not . toBe ( controller . signal )
19852014 } )
19862015
19872016 it ( "should return undefined sendOptions when timeoutMs is 0 and no signal" , async ( ) => {
@@ -2087,6 +2116,54 @@ describe("AwsBedrockHandler", () => {
20872116 expect ( result ) . toBe ( "" )
20882117 } )
20892118
2119+ it ( "completePrompt should reject with AbortError when the signal is aborted while client.send is in flight" , async ( ) => {
2120+ const handler = new AwsBedrockHandler ( {
2121+ apiModelId : "anthropic.claude-3-5-sonnet-20241022-v2:0" ,
2122+ awsAccessKey : "test-access-key" ,
2123+ awsSecretKey : "test-secret-key" ,
2124+ awsRegion : "us-east-1" ,
2125+ } )
2126+
2127+ const controller = new AbortController ( )
2128+ let rejectSend : ( ( err : unknown ) => void ) | undefined
2129+
2130+ // mockSend hangs until the abort signal fires
2131+ const mockSend = vi
2132+ . fn ( )
2133+ . mockImplementation ( ( _command : unknown , options ?: { abortSignal ?: AbortSignal } ) => {
2134+ return new Promise < unknown > ( ( _resolve , reject ) => {
2135+ rejectSend = reject
2136+ options ?. abortSignal ?. addEventListener (
2137+ "abort" ,
2138+ ( ) => {
2139+ const abortError = new DOMException ( "The operation was aborted." , "AbortError" )
2140+ reject ( abortError )
2141+ } ,
2142+ { once : true } ,
2143+ )
2144+ } )
2145+ } )
2146+ handler [ "client" ] . send = mockSend
2147+
2148+ const sendPromise = handler . completePrompt ( "test prompt" , { abortSignal : controller . signal } )
2149+
2150+ // Wait until send is in flight
2151+ await vi . waitFor ( ( ) => {
2152+ expect ( rejectSend ) . toBeDefined ( )
2153+ } )
2154+
2155+ // Abort mid-flight; completePrompt must reject with AbortError.
2156+ // Also assert the ABORT classification is applied: the error message must come
2157+ // from the ABORT template ("Request was aborted"), not the GENERIC fallback.
2158+ // This proves "ABORT" sits in errorTypeOrder before competing patterns.
2159+ controller . abort ( )
2160+
2161+ await expect ( sendPromise ) . rejects . toMatchObject ( {
2162+ name : "AbortError" ,
2163+ message : expect . stringContaining ( "Request was aborted" ) ,
2164+ } )
2165+ } )
2166+
20902167 it ( "createMessage should reject with an AbortError when the external signal is already aborted" , async ( ) => {
20912168 const handler = new AwsBedrockHandler ( {
20922169 apiModelId : "anthropic.claude-3-5-sonnet-20241022-v2:0" ,
@@ -2163,28 +2240,42 @@ describe("AwsBedrockHandler", () => {
21632240
21642241 const controller = new AbortController ( )
21652242
2166- const generator = handler . createMessage (
2167- "You are a helpful assistant" ,
2168- [ { role : "user" , content : "Hello" } ] ,
2169- makeCreateMessageMetadata ( { abortSignal : controller . signal } ) ,
2170- )
2243+ // Spy before createMessage so we capture the exact listener the production code registers
2244+ const addSpy = vi . spyOn ( controller . signal , "addEventListener" )
2245+ const removeSpy = vi . spyOn ( controller . signal , "removeEventListener" )
21712246
2172- const consumed = ( async ( ) => {
2173- for await ( const _chunk of generator ) {
2174- // ignore chunks
2175- }
2176- } ) ( )
2247+ try {
2248+ const generator = handler . createMessage (
2249+ "You are a helpful assistant" ,
2250+ [ { role : "user" , content : "Hello" } ] ,
2251+ makeCreateMessageMetadata ( { abortSignal : controller . signal } ) ,
2252+ )
2253+
2254+ const consumed = ( async ( ) => {
2255+ for await ( const _chunk of generator ) {
2256+ // ignore chunks
2257+ }
2258+ } ) ( )
21772259
2178- // Wait until the request is in flight and the internal signal is captured
2179- await vi . waitFor ( ( ) => {
2180- expect ( internalSignal ) . toBeDefined ( )
2181- } )
2182- expect ( internalSignal ?. aborted ) . toBe ( false )
2260+ // Wait until the request is in flight and the internal signal is captured
2261+ await vi . waitFor ( ( ) => {
2262+ expect ( internalSignal ) . toBeDefined ( )
2263+ } )
2264+ expect ( internalSignal ?. aborted ) . toBe ( false )
21832265
2184- // Abort the external signal mid-flight; the stream must reject with an AbortError
2185- controller . abort ( )
2266+ // Abort the external signal mid-flight; the stream must reject with an AbortError
2267+ controller . abort ( )
21862268
2187- await expect ( consumed ) . rejects . toMatchObject ( { name : "AbortError" } )
2269+ await expect ( consumed ) . rejects . toMatchObject ( { name : "AbortError" } )
2270+
2271+ // Verify the finally block removed the exact listener it registered (error path cleanup)
2272+ const registeredListener = addSpy . mock . calls . find ( ( [ type ] ) => type === "abort" ) ?. [ 1 ]
2273+ expect ( registeredListener ) . toBeDefined ( )
2274+ expect ( removeSpy ) . toHaveBeenCalledWith ( "abort" , registeredListener )
2275+ } finally {
2276+ addSpy . mockRestore ( )
2277+ removeSpy . mockRestore ( )
2278+ }
21882279 } )
21892280
21902281 it ( "createMessage should detach the external abort listener when the request completes" , async ( ) => {
@@ -2210,8 +2301,12 @@ describe("AwsBedrockHandler", () => {
22102301 } )
22112302 handler [ "client" ] . send = mockSend
22122303
2213- // First request completes normally with its own external signal
2304+ // First request completes normally with its own external signal.
2305+ // Spy on both add and remove so we can assert the exact same function
2306+ // reference was registered and then detached — expect.any(Function) would
2307+ // pass even if a different listener were removed, leaving the real one attached.
22142308 const firstController = new AbortController ( )
2309+ const firstAddSpy = vi . spyOn ( firstController . signal , "addEventListener" )
22152310 const firstRemoveSpy = vi . spyOn ( firstController . signal , "removeEventListener" )
22162311 const firstGenerator = handler . createMessage (
22172312 "You are a helpful assistant" ,
@@ -2229,8 +2324,13 @@ describe("AwsBedrockHandler", () => {
22292324 } ) ( )
22302325 expect ( firstText ) . toBe ( "hello" )
22312326
2232- // The bridge listener must be detached as soon as the request completes
2233- expect ( firstRemoveSpy ) . toHaveBeenCalledWith ( "abort" , expect . any ( Function ) )
2327+ // The bridge listener must be detached as soon as the request completes.
2328+ // Extract the exact function reference that was registered so we can assert
2329+ // the same reference (not just any function) was passed to removeEventListener.
2330+ const abortAddCall = firstAddSpy . mock . calls . find ( ( [ type ] ) => type === "abort" )
2331+ const registeredAbortListener = abortAddCall ?. [ 1 ]
2332+ expect ( registeredAbortListener ) . toBeDefined ( )
2333+ expect ( firstRemoveSpy ) . toHaveBeenCalledWith ( "abort" , registeredAbortListener )
22342334
22352335 // Second request starts with a DIFFERENT external signal
22362336 const secondController = new AbortController ( )
@@ -2255,6 +2355,7 @@ describe("AwsBedrockHandler", () => {
22552355 expect ( secondSendSignal ) . toBeDefined ( )
22562356 expect ( secondSendSignal ?. aborted ) . toBe ( false )
22572357
2358+ firstAddSpy . mockRestore ( )
22582359 firstRemoveSpy . mockRestore ( )
22592360 } )
22602361
0 commit comments