@@ -2,15 +2,17 @@ import { describe, expect, test } from "bun:test"
22import type { NamedError } from "@opencode-ai/shared/util/error"
33import { APICallError } from "ai"
44import { setTimeout as sleep } from "node:timers/promises"
5- import { Effect , Schedule } from "effect"
5+ import { Effect , Exit , Layer , Schedule } from "effect"
66import { SessionRetry } from "../../src/session/retry"
77import { MessageV2 } from "../../src/session/message-v2"
8+ import { SSEStallError } from "../../src/provider/provider"
89import { ProviderID } from "../../src/provider/schema"
910import { AppRuntime } from "../../src/effect/app-runtime"
1011import { SessionID } from "../../src/session/schema"
1112import { SessionStatus } from "../../src/session/status"
1213import { Instance } from "../../src/project/instance"
1314import { tmpdir } from "../fixture/fixture"
15+ import { testEffect } from "../lib/effect"
1416
1517const providerID = ProviderID . make ( "test" )
1618
@@ -232,6 +234,76 @@ describe("session.retry.retryable", () => {
232234 } )
233235} )
234236
237+ describe ( "SessionRetry.retryable — SSE stall round-trip" , ( ) => {
238+ test ( "retries SSEStallError after MessageV2.fromError round-trip" , ( ) => {
239+ const err = new SSEStallError ( "SSE read timed out" )
240+ const obj = MessageV2 . fromError ( err , { providerID } )
241+ expect ( MessageV2 . SSEStallError . isInstance ( obj ) ) . toBe ( true )
242+ expect ( SessionRetry . retryable ( obj ) ) . toBe ( "SSE read timed out" )
243+ } )
244+ } )
245+
246+ describe ( "SessionRetry.retryable — narrow transport substrings" , ( ) => {
247+ test ( "retries ETIMEDOUT" , ( ) => {
248+ expect ( SessionRetry . retryable ( wrap ( "connect ETIMEDOUT 140.82.114.6:443" ) ) ) . toContain ( "ETIMEDOUT" )
249+ } )
250+
251+ test ( "retries ECONNRESET" , ( ) => {
252+ expect ( SessionRetry . retryable ( wrap ( "read ECONNRESET" ) ) ) . toContain ( "ECONNRESET" )
253+ } )
254+
255+ test ( "retries ECONNREFUSED" , ( ) => {
256+ expect ( SessionRetry . retryable ( wrap ( "connect ECONNREFUSED 127.0.0.1" ) ) ) . toContain ( "ECONNREFUSED" )
257+ } )
258+
259+ test ( "retries EAI_AGAIN" , ( ) => {
260+ expect ( SessionRetry . retryable ( wrap ( "getaddrinfo EAI_AGAIN api.example.com" ) ) ) . toContain ( "EAI_AGAIN" )
261+ } )
262+
263+ test ( "retries socket hang up" , ( ) => {
264+ expect ( SessionRetry . retryable ( wrap ( "socket hang up" ) ) ) . toContain ( "socket hang up" )
265+ } )
266+
267+ test ( "does NOT retry EPIPE (often user-initiated abort)" , ( ) => {
268+ expect ( SessionRetry . retryable ( wrap ( "write EPIPE" ) ) ) . toBeUndefined ( )
269+ } )
270+
271+ test ( "does NOT retry the phrase 'network error' broadly" , ( ) => {
272+ expect ( SessionRetry . retryable ( wrap ( "upstream returned a network error" ) ) ) . toBeUndefined ( )
273+ } )
274+
275+ test ( "does NOT retry arbitrary agent errors" , ( ) => {
276+ expect ( SessionRetry . retryable ( wrap ( "agent not found: explore" ) ) ) . toBeUndefined ( )
277+ } )
278+ } )
279+
280+ describe ( "SessionRetry.policy — transport retry budget" , ( ) => {
281+ const it = testEffect ( Layer . empty )
282+
283+ it . live (
284+ "stops after exactly 6 total attempts on transport error (TRANSPORT_RETRY_CAP=5 + initial)" ,
285+ ( ) =>
286+ Effect . gen ( function * ( ) {
287+ let setCalls = 0
288+ const step = yield * Schedule . toStep (
289+ SessionRetry . policy ( {
290+ parse : ( err ) => err as ReturnType < NamedError [ "toObject" ] > ,
291+ set : ( _info ) =>
292+ Effect . sync ( ( ) => {
293+ setCalls ++
294+ } ) ,
295+ } ) ,
296+ )
297+ const now = 0
298+ for ( let i = 0 ; i < 10 ; i ++ ) {
299+ const exit = yield * Effect . exit ( step ( now , wrap ( "connect ETIMEDOUT 1.2.3.4" ) ) )
300+ if ( ! Exit . isSuccess ( exit ) ) break
301+ }
302+ expect ( setCalls ) . toBe ( 5 )
303+ } ) ,
304+ )
305+ } )
306+
235307describe ( "session.message-v2.fromError" , ( ) => {
236308 test . concurrent (
237309 "converts ECONNRESET socket errors to retryable APIError" ,
0 commit comments