@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
22import { SessionV1 } from "@opencode-ai/core/v1/session"
33import { APICallError } from "ai"
44import { MessageV2 } from "../../src/session/message-v2"
5+ import { SessionRetry } from "../../src/session/retry"
56import { ProviderTransform } from "@/provider/transform"
67import type { Provider } from "@/provider/provider"
78
@@ -1552,6 +1553,130 @@ describe("session.message-v2.fromError", () => {
15521553
15531554 expect ( result . name ) . toBe ( "MessageAbortedError" )
15541555 } )
1556+
1557+ test ( "classifies user-cancelled AbortError as AbortedError when ctx.aborted is true" , ( ) => {
1558+ const err = new DOMException ( "Aborted" , "AbortError" )
1559+ const result = MessageV2 . fromError ( err , { providerID, aborted : true } )
1560+ expect ( result . name ) . toBe ( "MessageAbortedError" )
1561+ } )
1562+
1563+ test ( "classifies a bare AbortError as a cancel regardless of ctx.aborted" , ( ) => {
1564+ // A bare AbortError only comes from controller.abort() with no reason —
1565+ // i.e. a user/parent cancel. Transport timeouts surface as their own
1566+ // specific errors (TimeoutError/HeaderTimeoutError/ResponseStreamError),
1567+ // so a bare AbortError must NOT be reclassified as retryable — otherwise
1568+ // a cancel races the failure channel and gets retried.
1569+ const err = new DOMException ( "The operation was aborted" , "AbortError" )
1570+ const result = MessageV2 . fromError ( err , { providerID } )
1571+ expect ( result . name ) . toBe ( "MessageAbortedError" )
1572+ } )
1573+
1574+ test ( "classifies TimeoutError DOMException as retryable APIError" , ( ) => {
1575+ // AbortSignal.timeout() in modern fetch surfaces as TimeoutError.
1576+ const err = new DOMException ( "The operation timed out" , "TimeoutError" )
1577+ const result = MessageV2 . fromError ( err , { providerID } )
1578+ expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
1579+ expect ( ( result as SessionV1 . APIError ) . data . isRetryable ) . toBe ( true )
1580+ } )
1581+
1582+ test . each ( [
1583+ [ "ETIMEDOUT" , "Network error (ETIMEDOUT)" ] ,
1584+ [ "EAI_AGAIN" , "Network error (EAI_AGAIN)" ] ,
1585+ [ "EHOSTUNREACH" , "Network error (EHOSTUNREACH)" ] ,
1586+ [ "ENETUNREACH" , "Network error (ENETUNREACH)" ] ,
1587+ [ "EPIPE" , "Network error (EPIPE)" ] ,
1588+ [ "UND_ERR_CONNECT_TIMEOUT" , "Network error (UND_ERR_CONNECT_TIMEOUT)" ] ,
1589+ [ "UND_ERR_HEADERS_TIMEOUT" , "Network error (UND_ERR_HEADERS_TIMEOUT)" ] ,
1590+ [ "UND_ERR_BODY_TIMEOUT" , "Network error (UND_ERR_BODY_TIMEOUT)" ] ,
1591+ [ "UND_ERR_SOCKET" , "Network error (UND_ERR_SOCKET)" ] ,
1592+ ] ) ( "classifies %s SystemError as retryable APIError" , ( code , expectedMessage ) => {
1593+ const err = Object . assign ( new Error ( `${ code } from test` ) , { code } )
1594+ const result = MessageV2 . fromError ( err , { providerID } )
1595+ expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
1596+ expect ( ( result as SessionV1 . APIError ) . data . isRetryable ) . toBe ( true )
1597+ expect ( ( result as SessionV1 . APIError ) . data . message ) . toBe ( expectedMessage )
1598+ } )
1599+
1600+ test ( "classifies a fetch failure with a transient cause as retryable" , ( ) => {
1601+ const cause = Object . assign ( new Error ( "read ECONNRESET" ) , { code : "ECONNRESET" } )
1602+ const wrapper = Object . assign ( new TypeError ( "fetch failed" , { cause } ) , { code : "ERR_FETCH_FAILED" } )
1603+ const result = MessageV2 . fromError ( wrapper , { providerID } )
1604+ expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
1605+ expect ( ( result as SessionV1 . APIError ) . data . metadata ?. code ) . toBe ( "ECONNRESET" )
1606+ } )
1607+
1608+ test . each ( [
1609+ [ "ENOTFOUND" , "getaddrinfo" ] ,
1610+ [ "ECONNREFUSED" , "connect" ] ,
1611+ ] ) ( "overrides retryable APICallError caused by %s" , ( code , syscall ) => {
1612+ const cause = Object . assign ( new Error ( `${ syscall } ${ code } api.invalid` ) , { code, syscall } )
1613+ const wrapper = Object . assign ( new TypeError ( "fetch failed" , { cause } ) , { code : "ERR_FETCH_FAILED" } )
1614+ const error = new APICallError ( {
1615+ message : "Cannot connect to API" ,
1616+ url : "https://api.invalid/v1/messages" ,
1617+ requestBodyValues : { model : "test" } ,
1618+ cause : wrapper ,
1619+ isRetryable : true ,
1620+ } )
1621+
1622+ const result = MessageV2 . fromError ( error , { providerID } )
1623+ expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
1624+ if ( ! SessionV1 . APIError . isInstance ( result ) ) throw new Error ( "expected APIError" )
1625+ expect ( result . data . isRetryable ) . toBe ( false )
1626+ expect ( result . data . metadata ) . toMatchObject ( { code, syscall, url : "https://api.invalid/v1/messages" } )
1627+ expect ( SessionRetry . retryable ( result , "test" ) ) . toBeUndefined ( )
1628+ } )
1629+
1630+ test ( "gives a nested permanent code precedence over a transient wrapper" , ( ) => {
1631+ const cause = Object . assign ( new Error ( "getaddrinfo ENOTFOUND api.invalid" ) , { code : "ENOTFOUND" } )
1632+ const wrapper = Object . assign ( new Error ( "socket reset" , { cause } ) , { code : "ECONNRESET" } )
1633+ const result = MessageV2 . fromError ( wrapper , { providerID } )
1634+ expect ( result . name ) . toBe ( "UnknownError" )
1635+ } )
1636+
1637+ test ( "stops traversing cyclic error causes" , ( ) => {
1638+ const first = Object . assign ( new Error ( "first wrapper" ) , { code : "ERR_FIRST" } )
1639+ const second = Object . assign ( new Error ( "second wrapper" , { cause : first } ) , { code : "ERR_SECOND" } )
1640+ first . cause = second
1641+ const result = MessageV2 . fromError ( first , { providerID } )
1642+ expect ( result . name ) . toBe ( "UnknownError" )
1643+ } )
1644+
1645+ test ( "bounds deeply nested error causes" , ( ) => {
1646+ const cause = Object . assign ( new Error ( "read ECONNRESET" ) , { code : "ECONNRESET" } )
1647+ const wrapper = Array . from ( { length : 32 } , ( _ , index ) => index ) . reduce < Error > (
1648+ ( current , index ) => new Error ( `wrapper ${ index } ` , { cause : current } ) ,
1649+ cause ,
1650+ )
1651+ const result = MessageV2 . fromError ( wrapper , { providerID } )
1652+ expect ( result . name ) . toBe ( "UnknownError" )
1653+ } )
1654+
1655+ test . each ( [ "socket hang up" , "SSE read timed out" , "other side closed" , "terminated" ] ) (
1656+ "classifies '%s' bare Error message as retryable APIError" ,
1657+ ( message ) => {
1658+ const result = MessageV2 . fromError ( new Error ( message ) , { providerID } )
1659+ expect ( SessionV1 . APIError . isInstance ( result ) ) . toBe ( true )
1660+ expect ( ( result as SessionV1 . APIError ) . data . isRetryable ) . toBe ( true )
1661+ } ,
1662+ )
1663+
1664+ test . each ( [
1665+ "fetch failed" ,
1666+ "Failed to fetch account configuration" ,
1667+ "network error: certificate has expired" ,
1668+ "Network request failed because access is forbidden" ,
1669+ "connect error: invalid provider base URL" ,
1670+ "request terminated because the API key is invalid" ,
1671+ ] ) ( "does not treat '%s' as a transient transport error" , ( message ) => {
1672+ const result = MessageV2 . fromError ( new Error ( message ) , { providerID } )
1673+ expect ( result . name ) . toBe ( "UnknownError" )
1674+ } )
1675+
1676+ test ( "leaves unrelated Error messages classified as Unknown" , ( ) => {
1677+ const result = MessageV2 . fromError ( new Error ( "Some unrelated bug" ) , { providerID } )
1678+ expect ( result . name ) . toBe ( "UnknownError" )
1679+ } )
15551680} )
15561681
15571682describe ( "session.message-v2.latest" , ( ) => {
0 commit comments