@@ -3,15 +3,21 @@ import { CloudTaskEvent } from "./schemas";
33
44const mockNetFetch = vi . hoisted ( ( ) => vi . fn ( ) ) ;
55const mockStreamFetch = vi . hoisted ( ( ) => vi . fn ( ) ) ;
6+ const mockStreamTokenFetch = vi . hoisted ( ( ) => vi . fn ( ) ) ;
67
78// The service now uses global fetch for BOTH authenticated API calls (JSON)
89// and SSE streaming. The two used to be distinct (net.fetch vs global fetch).
9- // To preserve the existing test fixtures, route by URL: /stream/ → stream mock,
10- // everything else → API mock.
10+ // Route by URL: /stream_token/ → token mock (read-leg resolution), the stream leg
11+ // (Django /stream/ or proxy /v1/runs/:run/stream) → stream mock, everything else → API mock.
12+ // The token mock has a Django-path default so existing fixtures (which never set it) are untouched.
1113const fetchRouter = vi . hoisted ( ( ) =>
1214 vi . fn ( ( input : string | Request , init ?: RequestInit ) => {
1315 const url = typeof input === "string" ? input : input . url ;
14- const impl = url . includes ( "/stream/" ) ? mockStreamFetch : mockNetFetch ;
16+ const impl = url . includes ( "/stream_token/" )
17+ ? mockStreamTokenFetch
18+ : / \/ s t r e a m ( \/ | \? | $ ) / . test ( url )
19+ ? mockStreamFetch
20+ : mockNetFetch ;
1521 return impl ( input , init ) ;
1622 } ) ,
1723) ;
@@ -97,6 +103,13 @@ describe("CloudTaskService", () => {
97103 service = new CloudTaskService ( mockAuthService as never ) ;
98104 mockNetFetch . mockReset ( ) ;
99105 mockStreamFetch . mockReset ( ) ;
106+ mockStreamTokenFetch . mockReset ( ) ;
107+ // Default read-leg resolution: no proxy URL, so the stream reads from Django directly.
108+ mockStreamTokenFetch . mockImplementation ( ( ) =>
109+ Promise . resolve (
110+ createJsonResponse ( { token : "test-token" , stream_base_url : null } ) ,
111+ ) ,
112+ ) ;
100113 mockAuthService . authenticatedFetch . mockReset ( ) ;
101114 vi . stubGlobal ( "fetch" , fetchRouter ) ;
102115
@@ -604,6 +617,113 @@ describe("CloudTaskService", () => {
604617 ) . toBe ( true ) ;
605618 } ) ;
606619
620+ it ( "stops without reconnecting when the server emits stream-end on a non-terminal run" , async ( ) => {
621+ vi . useFakeTimers ( ) ;
622+
623+ // Run status stays non-terminal the whole time. Pre-durable-contract, a clean EOF on a
624+ // non-terminal run reconnects (see the test above); the stream-end sentinel must override that.
625+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
626+ const url = typeof input === "string" ? input : input . url ;
627+ if ( url . includes ( "/session_logs/" ) ) {
628+ return Promise . resolve (
629+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
630+ ) ;
631+ }
632+ return Promise . resolve (
633+ createJsonResponse ( {
634+ id : "run-1" ,
635+ status : "in_progress" ,
636+ stage : "build" ,
637+ output : null ,
638+ error_message : null ,
639+ branch : "main" ,
640+ updated_at : "2026-01-01T00:00:00Z" ,
641+ } ) ,
642+ ) ;
643+ } ) ;
644+
645+ mockStreamFetch . mockImplementation ( ( ) =>
646+ Promise . resolve (
647+ createSseResponse (
648+ 'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"hi"}}}\n\nevent: stream-end\ndata: {}\n\n' ,
649+ ) ,
650+ ) ,
651+ ) ;
652+
653+ service . watch ( {
654+ taskId : "task-1" ,
655+ runId : "run-1" ,
656+ apiHost : "https://app.example.com" ,
657+ teamId : 2 ,
658+ } ) ;
659+
660+ const hasWatcher = ( ) : boolean =>
661+ ( service as unknown as { watchers : Map < string , unknown > } ) . watchers . has (
662+ "task-1:run-1" ,
663+ ) ;
664+
665+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length === 1 ) ;
666+ // Let the reconnect delay (2s base) elapse; with stream-end honored, none is scheduled.
667+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
668+
669+ expect ( mockStreamFetch . mock . calls . length ) . toBe ( 1 ) ;
670+ await waitFor ( ( ) => ! hasWatcher ( ) ) ;
671+ } ) ;
672+
673+ it ( "reads via the agent-proxy with a Bearer token when the server resolves a base url" , async ( ) => {
674+ vi . useFakeTimers ( ) ;
675+
676+ mockStreamTokenFetch . mockImplementation ( ( ) =>
677+ Promise . resolve (
678+ createJsonResponse ( {
679+ token : "proxy-token" ,
680+ stream_base_url : "https://proxy.example" ,
681+ } ) ,
682+ ) ,
683+ ) ;
684+
685+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
686+ const url = typeof input === "string" ? input : input . url ;
687+ if ( url . includes ( "/session_logs/" ) ) {
688+ return Promise . resolve (
689+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
690+ ) ;
691+ }
692+ return Promise . resolve (
693+ createJsonResponse ( {
694+ id : "run-1" ,
695+ status : "in_progress" ,
696+ stage : "build" ,
697+ output : null ,
698+ error_message : null ,
699+ branch : "main" ,
700+ updated_at : "2026-01-01T00:00:00Z" ,
701+ } ) ,
702+ ) ;
703+ } ) ;
704+
705+ mockStreamFetch . mockImplementation ( ( ) =>
706+ Promise . resolve ( createSseResponse ( "event: stream-end\ndata: {}\n\n" ) ) ,
707+ ) ;
708+
709+ service . watch ( {
710+ taskId : "task-1" ,
711+ runId : "run-1" ,
712+ apiHost : "https://app.example.com" ,
713+ teamId : 2 ,
714+ } ) ;
715+
716+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length === 1 ) ;
717+
718+ const [ calledUrl , init ] = mockStreamFetch . mock . calls [ 0 ] ;
719+ expect ( String ( calledUrl ) ) . toMatch (
720+ / ^ h t t p s : \/ \/ p r o x y \. e x a m p l e \/ v 1 \/ r u n s \/ r u n - 1 \/ s t r e a m ( \? | $ ) / ,
721+ ) ;
722+ expect ( ( init ?. headers as Record < string , string > ) ?. Authorization ) . toBe (
723+ "Bearer proxy-token" ,
724+ ) ;
725+ } ) ;
726+
607727 it ( "fails the watcher after exhausting the cumulative reconnect budget on clean-EOF loops" , async ( ) => {
608728 vi . useFakeTimers ( ) ;
609729
@@ -720,10 +840,10 @@ describe("CloudTaskService", () => {
720840 10_000 ,
721841 ) ;
722842
723- expect ( mockStreamFetch . mock . calls . length ) . toBe ( 6 ) ;
724- // 2 bootstrap calls + 1 post- bootstrap status verification + 6
725- // handleStreamCompletion calls (one per stream error)
726- expect ( mockNetFetch ) . toHaveBeenCalledTimes ( 9 ) ;
843+ expect ( mockStreamFetch . mock . calls . length ) . toBe ( 10 ) ;
844+ // Status is no longer polled per reconnect. Only the 2 bootstrap calls plus the single
845+ // post-bootstrap verification touch the status endpoint; reconnects never do.
846+ expect ( mockNetFetch . mock . calls . length ) . toBeLessThanOrEqual ( 3 ) ;
727847 expect ( updates ) . toContainEqual ( {
728848 taskId : "task-1" ,
729849 runId : "run-1" ,
@@ -892,12 +1012,12 @@ describe("CloudTaskService", () => {
8921012 } ) ;
8931013 } ) ;
8941014
895- it ( "stops the watcher without reconnecting once the run is terminal" , async ( ) => {
1015+ it ( "reconnects on a clean EOF even after the run status goes terminal (status-unaware) " , async ( ) => {
8961016 vi . useFakeTimers ( ) ;
8971017
898- const updates : unknown [ ] = [ ] ;
899- service . on ( CloudTaskEvent . Update , ( payload ) => updates . push ( payload ) ) ;
900-
1018+ // Bootstrap sees an active run (so it streams); every later status fetch reports terminal.
1019+ // Pre-decoupling, a clean EOF on a terminal run stopped the watch. Now run status is never
1020+ // consulted to decide reconnects, so the clean EOFs keep reconnecting until stream-end.
9011021 let statusFetchCount = 0 ;
9021022 mockNetFetch . mockImplementation ( ( input : string | Request ) => {
9031023 const url = typeof input === "string" ? input : input . url ;
@@ -907,7 +1027,6 @@ describe("CloudTaskService", () => {
9071027 ) ;
9081028 }
9091029 statusFetchCount += 1 ;
910- // Bootstrap sees an active run; the post-stream status check sees terminal.
9111030 return Promise . resolve (
9121031 createJsonResponse ( {
9131032 id : "run-1" ,
@@ -936,22 +1055,14 @@ describe("CloudTaskService", () => {
9361055 } ) ;
9371056
9381057 await waitFor ( ( ) => mockStreamFetch . mock . calls . length === 1 ) ;
939- await vi . advanceTimersByTimeAsync ( 10_000 ) ;
1058+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length >= 3 , 20_000 ) ;
9401059
941- expect ( updates ) . toContainEqual (
942- expect . objectContaining ( {
943- taskId : "task-1" ,
944- runId : "run-1" ,
945- kind : "status" ,
946- status : "completed" ,
947- } ) ,
948- ) ;
949- expect ( mockStreamFetch . mock . calls . length ) . toBe ( 1 ) ;
1060+ // Terminal status did not stop the watch; the watcher is still reconnecting.
9501061 expect (
9511062 ( service as unknown as { watchers : Map < string , unknown > } ) . watchers . has (
9521063 "task-1:run-1" ,
9531064 ) ,
954- ) . toBe ( false ) ;
1065+ ) . toBe ( true ) ;
9551066 } ) ;
9561067
9571068 it ( "surfaces a retryable error when the backend errors even on a long-lived stream" , async ( ) => {
@@ -1013,8 +1124,8 @@ describe("CloudTaskService", () => {
10131124 } ) ;
10141125
10151126 await waitFor ( ( ) => mockStreamFetch . mock . calls . length === 1 ) ;
1016- // Drive >= 6 long-lived-then-backend-error cycles (65s open + backoff each).
1017- await vi . advanceTimersByTimeAsync ( 65_000 * 7 + 70_000 ) ;
1127+ // Drive >= 10 long-lived-then-backend-error cycles (65s open + backoff each).
1128+ await vi . advanceTimersByTimeAsync ( 65_000 * 11 + 70_000 ) ;
10181129 await waitFor (
10191130 ( ) =>
10201131 updates . some (
@@ -1322,16 +1433,19 @@ describe("CloudTaskService", () => {
13221433 expect ( getWatcher ( ) ?. failed ) . toBe ( false ) ;
13231434 } ) ;
13241435
1325- it ( "surfaces an error instead of retrying forever when run-state fetch keeps failing after a clean stream end " , async ( ) => {
1436+ it ( "does not poll run status per reconnect on clean EOFs (status-unaware) " , async ( ) => {
13261437 vi . useFakeTimers ( ) ;
13271438
1328- const updates : unknown [ ] = [ ] ;
1329- service . on ( CloudTaskEvent . Update , ( payload ) => updates . push ( payload ) ) ;
1330-
1331- // Bootstrap succeeds (run + empty backlog); every subsequent run-state
1332- // fetch returns 500 (a non-fatal status -> fetchTaskRun resolves null).
1333- mockNetFetch
1334- . mockResolvedValueOnce (
1439+ let statusFetchCount = 0 ;
1440+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
1441+ const url = typeof input === "string" ? input : input . url ;
1442+ if ( url . includes ( "/session_logs/" ) ) {
1443+ return Promise . resolve (
1444+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
1445+ ) ;
1446+ }
1447+ statusFetchCount += 1 ;
1448+ return Promise . resolve (
13351449 createJsonResponse ( {
13361450 id : "run-1" ,
13371451 status : "in_progress" ,
@@ -1341,85 +1455,26 @@ describe("CloudTaskService", () => {
13411455 branch : "main" ,
13421456 updated_at : "2026-01-01T00:00:00Z" ,
13431457 } ) ,
1344- ) // bootstrap: fetchTaskRun
1345- . mockResolvedValueOnce (
1346- createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
1347- ) // bootstrap: fetchSessionLogs
1348- . mockImplementation ( ( ) =>
1349- Promise . resolve ( createJsonResponse ( { detail : "boom" } , 500 ) ) ,
1350- ) ;
1351-
1352- // First connection is held open so bootstrap can finish; the test then
1353- // closes it cleanly. Every later connection ends cleanly on its own, so the
1354- // only thing that can fail is the post-stream run-state fetch (500).
1355- let streamCall = 0 ;
1356- const firstControllerRef : {
1357- current : ReadableStreamDefaultController < Uint8Array > | null ;
1358- } = { current : null } ;
1359- mockStreamFetch . mockImplementation ( ( ) => {
1360- streamCall += 1 ;
1361- const stream = new ReadableStream < Uint8Array > ( {
1362- start ( controller ) {
1363- if ( streamCall === 1 ) {
1364- firstControllerRef . current = controller ;
1365- } else {
1366- controller . close ( ) ;
1367- }
1368- } ,
1369- } ) ;
1370- return Promise . resolve (
1371- new Response ( stream , {
1372- status : 200 ,
1373- headers : { "Content-Type" : "text/event-stream" } ,
1374- } ) ,
13751458 ) ;
13761459 } ) ;
13771460
1461+ // Every connection ends cleanly with no stream-end sentinel, forcing reconnect after reconnect.
1462+ mockStreamFetch . mockImplementation ( ( ) =>
1463+ Promise . resolve ( createSseResponse ( "" ) ) ,
1464+ ) ;
1465+
13781466 service . watch ( {
13791467 taskId : "task-1" ,
13801468 runId : "run-1" ,
13811469 apiHost : "https://app.example.com" ,
13821470 teamId : 2 ,
13831471 } ) ;
13841472
1385- // Wait for bootstrap to emit its snapshot and hold the live connection open.
1386- await waitFor (
1387- ( ) =>
1388- ! ! firstControllerRef . current &&
1389- updates . some (
1390- ( u ) =>
1391- typeof u === "object" &&
1392- u !== null &&
1393- ( u as { kind ?: string } ) . kind === "snapshot" ,
1394- ) ,
1395- ) ;
1396-
1397- // Close the live stream cleanly: each clean end now fetches run state, which
1398- // 500s. The reconnect must charge the budget so it eventually gives up.
1399- firstControllerRef . current ?. close ( ) ;
1473+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length >= 5 , 20_000 ) ;
14001474
1401- // Budget is 5 attempts (2s + 4s + 8s + 16s + 30s + 30s of backoff).
1402- await vi . advanceTimersByTimeAsync ( 120_000 ) ;
1403- await waitFor (
1404- ( ) =>
1405- updates . some (
1406- ( u ) =>
1407- typeof u === "object" &&
1408- u !== null &&
1409- ( u as { kind ?: string } ) . kind === "error" ,
1410- ) ,
1411- 20_000 ,
1412- ) ;
1413-
1414- expect ( updates ) . toContainEqual ( {
1415- taskId : "task-1" ,
1416- runId : "run-1" ,
1417- kind : "error" ,
1418- errorTitle : "Cloud run state unavailable" ,
1419- errorMessage :
1420- "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect." ,
1421- retryable : true ,
1422- } ) ;
1475+ // Bootstrap fetches status once and the post-bootstrap verification once more; reconnects add
1476+ // none. Pre-decoupling, every clean EOF polled status, so this count would climb with reconnects.
1477+ expect ( statusFetchCount ) . toBeLessThanOrEqual ( 2 ) ;
14231478 } ) ;
14241479
14251480 const guardedFetchStatusExpectations = [
0 commit comments