@@ -418,7 +418,7 @@ type TestTelegramUpdate = {
418418 update_id : number ;
419419 message : {
420420 text : string ;
421- chat : { id : number ; type : "supergroup" } ;
421+ chat : { id : number ; type : "private" | " supergroup" } ;
422422 message_thread_id ?: number ;
423423 is_topic_message ?: boolean ;
424424 } ;
@@ -436,6 +436,16 @@ function topicUpdate(updateId: number, threadId: number, text: string): TestTele
436436 } ;
437437}
438438
439+ function directUpdate ( updateId : number , chatId : number , text : string ) : TestTelegramUpdate {
440+ return {
441+ update_id : updateId ,
442+ message : {
443+ text,
444+ chat : { id : chatId , type : "private" } ,
445+ } ,
446+ } ;
447+ }
448+
439449async function waitForAbortSignal ( signal : AbortSignal ) : Promise < void > {
440450 if ( signal . aborted ) {
441451 return ;
@@ -1795,6 +1805,93 @@ describe("TelegramPollingSession", () => {
17951805 } ) ;
17961806 } ) ;
17971807
1808+ for ( const scenario of [
1809+ {
1810+ name : "topic" ,
1811+ conflict : topicUpdate ( 42 , 10 , "retryable session init conflict" ) ,
1812+ blocked : topicUpdate ( 43 , 10 , "same topic must wait behind retry backoff" ) ,
1813+ other : topicUpdate ( 44 , 11 , "other topic can continue" ) ,
1814+ conflictEvent : "topic10:conflict" ,
1815+ blockedEvent : "topic10:overtook" ,
1816+ otherEvent : "topic11" ,
1817+ error : "reply session initialization conflicted for agent:main:telegram:group:-100:topic:10" ,
1818+ } ,
1819+ {
1820+ name : "direct message" ,
1821+ conflict : directUpdate ( 42 , 100 , "retryable session init conflict" ) ,
1822+ blocked : directUpdate ( 43 , 100 , "same DM must wait behind retry backoff" ) ,
1823+ other : directUpdate ( 44 , 101 , "other DM can continue" ) ,
1824+ conflictEvent : "dm100:conflict" ,
1825+ blockedEvent : "dm100:overtook" ,
1826+ otherEvent : "dm101" ,
1827+ error : "reply session initialization conflicted for agent:main:telegram:direct:100" ,
1828+ } ,
1829+ ] ) {
1830+ it ( `backs off retryable reply session init conflicts for ${ scenario . name } lanes` , async ( ) => {
1831+ vi . useFakeTimers ( { shouldAdvanceTime : true } ) ;
1832+ try {
1833+ await withTempSpool ( async ( tempDir ) => {
1834+ const abort = new AbortController ( ) ;
1835+ const log = vi . fn ( ) ;
1836+ let attempts = 0 ;
1837+ const events : string [ ] = [ ] ;
1838+ await writeSpooledTestUpdates ( tempDir , [
1839+ scenario . conflict ,
1840+ scenario . blocked ,
1841+ scenario . other ,
1842+ ] ) ;
1843+
1844+ const { runPromise, stopWorker } = startIsolatedIngressSession ( {
1845+ abort,
1846+ spoolDir : tempDir ,
1847+ log,
1848+ drainIntervalMs : 100 ,
1849+ handleUpdate : async ( update ) => {
1850+ if ( update . update_id === scenario . conflict . update_id ) {
1851+ attempts += 1 ;
1852+ events . push ( `${ scenario . conflictEvent } :${ attempts } ` ) ;
1853+ throw new Error ( scenario . error ) ;
1854+ }
1855+ if ( update . update_id === scenario . blocked . update_id ) {
1856+ events . push ( scenario . blockedEvent ) ;
1857+ return ;
1858+ }
1859+ if ( update . update_id === scenario . other . update_id ) {
1860+ events . push ( scenario . otherEvent ) ;
1861+ }
1862+ } ,
1863+ } ) ;
1864+
1865+ await vi . waitFor ( ( ) => expect ( attempts ) . toBe ( 1 ) ) ;
1866+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
1867+ expect ( attempts ) . toBe ( 1 ) ;
1868+ await vi . waitFor ( ( ) =>
1869+ expect ( events ) . toEqual ( [ `${ scenario . conflictEvent } :1` , scenario . otherEvent ] ) ,
1870+ ) ;
1871+ expect ( await pendingUpdateIds ( tempDir , "all" ) ) . toEqual ( [
1872+ scenario . conflict . update_id ,
1873+ scenario . blocked . update_id ,
1874+ ] ) ;
1875+ expect ( await failedUpdateIds ( tempDir ) ) . toEqual ( [ ] ) ;
1876+
1877+ await vi . advanceTimersByTimeAsync ( 4_500 ) ;
1878+ await vi . waitFor ( ( ) => expect ( attempts ) . toBe ( 2 ) ) ;
1879+ expect ( events ) . not . toContain ( scenario . blockedEvent ) ;
1880+ expectLogIncludes (
1881+ log ,
1882+ `spooled update ${ scenario . conflict . update_id } failed; keeping for retry` ,
1883+ ) ;
1884+
1885+ abort . abort ( ) ;
1886+ stopWorker ( ) ;
1887+ await runPromise ;
1888+ } ) ;
1889+ } finally {
1890+ vi . useRealTimers ( ) ;
1891+ }
1892+ } ) ;
1893+ }
1894+
17981895 it ( "dead-letters wrapped missing harness failures" , async ( ) => {
17991896 await withTempSpool ( async ( tempDir ) => {
18001897 const abort = new AbortController ( ) ;
0 commit comments