@@ -53,6 +53,7 @@ const WORKBENCH_WIRE = {
5353function stubFetch (
5454 sentMessages ?: unknown [ ] ,
5555 workbench : typeof WORKBENCH_WIRE = WORKBENCH_WIRE ,
56+ options : { readonly turnsFail ?: boolean } = { } ,
5657) {
5758 globalThis . EventSource = StubEventSource as unknown as typeof EventSource ;
5859 globalThis . fetch = ( async ( input : RequestInfo | URL , init ?: RequestInit ) => {
@@ -81,6 +82,18 @@ function stubFetch(
8182 if ( / \/ c h a t \/ w o r k b e n c h e s \/ [ ^ / ] + \/ i n v i t a b l e $ / . test ( path ) ) {
8283 return json ( { items : [ ] } ) ;
8384 }
85+ // CL-6380 catch-up: empty list = nothing running. Returning this by
86+ // default keeps agent-participant mounts from treating an unstubbed
87+ // turns path as a resume failure (CL-6833).
88+ if ( / \/ c h a t \/ w o r k b e n c h e s \/ [ ^ / ] + \/ t u r n s (?: \/ | $ | \? ) / . test ( path ) ) {
89+ if ( options . turnsFail === true ) {
90+ return new Response ( JSON . stringify ( { error : "turns unavailable" } ) , {
91+ status : 500 ,
92+ headers : { "content-type" : "application/json" } ,
93+ } ) ;
94+ }
95+ return json ( { items : [ ] } ) ;
96+ }
8497 if ( / \/ c h a t \/ w o r k b e n c h e s \/ [ ^ / ] + \/ s e t t i n g s $ / . test ( path ) ) {
8598 return json ( {
8699 ...workbench ,
@@ -1729,6 +1742,96 @@ describe("Workbench header polish (CL-6106)", () => {
17291742 } ) ;
17301743} ) ;
17311744
1745+ describe ( "CL-6833: running-turn resume failure is visible, never silent idle" , ( ) => {
1746+ test ( "a failed catch-up fetch shows the resume-failed banner with Retry" , async ( ) => {
1747+ stubFetch ( undefined , WORKBENCH_WITH_AGENT_WIRE , { turnsFail : true } ) ;
1748+ const harness = await mount ( {
1749+ tenant : { kind : "ready" , tenantId : "tnt_1" } ,
1750+ workbenchId : "ch_1" ,
1751+ } ) ;
1752+ await harness . settle ( ) ;
1753+ await harness . settle ( ) ;
1754+
1755+ const banner = harness . container . querySelector (
1756+ ".chat-resume-failed-banner" ,
1757+ ) ;
1758+ expect ( banner ) . not . toBeNull ( ) ;
1759+ expect ( banner ?. getAttribute ( "role" ) ) . toBe ( "alert" ) ;
1760+ expect ( harness . container . textContent ) . toContain (
1761+ "Couldn't resume the running reply" ,
1762+ ) ;
1763+ expect (
1764+ [ ...harness . container . querySelectorAll ( "button" ) ] . some (
1765+ ( button ) => button . textContent ?. trim ( ) === "Retry" ,
1766+ ) ,
1767+ ) . toBe ( true ) ;
1768+ harness . unmount ( ) ;
1769+ } ) ;
1770+
1771+ test ( "a successful empty catch-up leaves the room without a resume banner" , async ( ) => {
1772+ stubFetch ( undefined , WORKBENCH_WITH_AGENT_WIRE ) ;
1773+ const harness = await mount ( {
1774+ tenant : { kind : "ready" , tenantId : "tnt_1" } ,
1775+ workbenchId : "ch_1" ,
1776+ } ) ;
1777+ await harness . settle ( ) ;
1778+ await harness . settle ( ) ;
1779+
1780+ expect (
1781+ harness . container . querySelector ( ".chat-resume-failed-banner" ) ,
1782+ ) . toBeNull ( ) ;
1783+ harness . unmount ( ) ;
1784+ } ) ;
1785+
1786+ test ( "Retry re-asks the turns endpoint after a resume failure" , async ( ) => {
1787+ let turnsCalls = 0 ;
1788+ stubFetch ( undefined , WORKBENCH_WITH_AGENT_WIRE , { turnsFail : true } ) ;
1789+ const failingFetch = globalThis . fetch ;
1790+ globalThis . fetch = ( async (
1791+ input : RequestInfo | URL ,
1792+ init ?: RequestInit ,
1793+ ) => {
1794+ const path = typeof input === "string" ? input : String ( input ) ;
1795+ if ( / \/ c h a t \/ w o r k b e n c h e s \/ [ ^ / ] + \/ t u r n s (?: \/ | $ | \? ) / . test ( path ) ) {
1796+ turnsCalls += 1 ;
1797+ if ( turnsCalls >= 2 ) {
1798+ return new Response ( JSON . stringify ( { items : [ ] } ) , {
1799+ status : 200 ,
1800+ headers : { "content-type" : "application/json" } ,
1801+ } ) ;
1802+ }
1803+ }
1804+ return failingFetch ( input , init ) ;
1805+ } ) as typeof fetch ;
1806+
1807+ const harness = await mount ( {
1808+ tenant : { kind : "ready" , tenantId : "tnt_1" } ,
1809+ workbenchId : "ch_1" ,
1810+ } ) ;
1811+ await harness . settle ( ) ;
1812+ await harness . settle ( ) ;
1813+
1814+ expect (
1815+ harness . container . querySelector ( ".chat-resume-failed-banner" ) ,
1816+ ) . not . toBeNull ( ) ;
1817+
1818+ const retry = [ ...harness . container . querySelectorAll ( "button" ) ] . find (
1819+ ( button ) => button . textContent ?. trim ( ) === "Retry" ,
1820+ ) as HTMLButtonElement ;
1821+ await act ( async ( ) => {
1822+ retry . click ( ) ;
1823+ await sleep ( 30 ) ;
1824+ } ) ;
1825+ await harness . settle ( ) ;
1826+
1827+ expect ( turnsCalls ) . toBeGreaterThanOrEqual ( 2 ) ;
1828+ expect (
1829+ harness . container . querySelector ( ".chat-resume-failed-banner" ) ,
1830+ ) . toBeNull ( ) ;
1831+ harness . unmount ( ) ;
1832+ } ) ;
1833+ } ) ;
1834+
17321835describe ( "switching workbenches never carries a stale root-thread id across" , ( ) => {
17331836 // CL-6067/6069 regression, still guarded after CL-6313 made it
17341837 // structurally impossible: the timeline no longer fetches by thread id
0 commit comments