@@ -5,6 +5,7 @@ import { getUndeliveredMessages } from './db/messages-out.js';
55import { getPendingMessages } from './db/messages-in.js' ;
66import { getContinuation , setContinuation } from './db/session-state.js' ;
77import { MockProvider } from './providers/mock.js' ;
8+ import type { ProviderExchange } from './providers/types.js' ;
89import { runPollLoop } from './poll-loop.js' ;
910
1011beforeEach ( ( ) => {
@@ -304,6 +305,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
304305 provider,
305306 providerName : 'mock' ,
306307 cwd : '/tmp' ,
308+ signal,
307309 } ) ,
308310 new Promise < void > ( ( _ , reject ) => {
309311 signal . addEventListener ( 'abort' , ( ) => reject ( new Error ( 'aborted' ) ) ) ;
@@ -324,6 +326,86 @@ function sleep(ms: number): Promise<void> {
324326 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
325327}
326328
329+ describe ( 'poll loop — exchange hook (onExchangeComplete)' , ( ) => {
330+ // A provider that declares the per-exchange hook. The hook call is the
331+ // wiring under test — these tests go red if the poll-loop seam is severed.
332+ // What the provider DOES with an exchange (e.g. write markdown into
333+ // conversations/) ships with the provider, not the runner.
334+ class HookedMockProvider extends MockProvider {
335+ readonly exchanges : ProviderExchange [ ] = [ ] ;
336+ onExchangeComplete ( exchange : ProviderExchange ) : void {
337+ this . exchanges . push ( exchange ) ;
338+ }
339+ }
340+
341+ it ( 'reports each exchange to a provider that declares the hook' , async ( ) => {
342+ insertMessage ( 'm1' , { sender : 'Alice' , text : 'please archive this' } , { platformId : 'chan-1' , channelType : 'discord' } ) ;
343+
344+ const provider = new HookedMockProvider ( { } , ( ) => '<message to="discord-test">archived answer</message>' ) ;
345+ const controller = new AbortController ( ) ;
346+ const loopPromise = runPollLoopWithTimeout ( provider , controller . signal , 2000 ) ;
347+
348+ await waitFor ( ( ) => provider . exchanges . length > 0 , 2000 ) ;
349+ controller . abort ( ) ;
350+
351+ expect ( provider . exchanges . length ) . toBe ( 1 ) ;
352+ const exchange = provider . exchanges [ 0 ] ;
353+ expect ( exchange . prompt ) . toContain ( 'please archive this' ) ;
354+ expect ( exchange . result ) . toContain ( 'archived answer' ) ;
355+ expect ( exchange . continuation ) . toStartWith ( 'mock-session-' ) ;
356+ expect ( exchange . status ) . toBe ( 'completed' ) ;
357+
358+ await loopPromise . catch ( ( ) => { } ) ;
359+ } ) ;
360+
361+ it ( 'does not report the internal wrapping-retry nudge as a user prompt' , async ( ) => {
362+ insertMessage ( 'm1' , { sender : 'Alice' , text : 'wrap this later' } , { platformId : 'chan-1' , channelType : 'discord' } ) ;
363+
364+ let calls = 0 ;
365+ const provider = new HookedMockProvider ( { } , ( ) => {
366+ calls += 1 ;
367+ // First result is unwrapped (triggers the retry nudge), second is wrapped.
368+ return calls === 1 ? 'unwrapped text' : '<message to="discord-test">wrapped now</message>' ;
369+ } ) ;
370+ const controller = new AbortController ( ) ;
371+ const loopPromise = runPollLoopWithTimeout ( provider , controller . signal , 3000 ) ;
372+
373+ await waitFor ( ( ) => provider . exchanges . length >= 2 , 3000 ) ;
374+ controller . abort ( ) ;
375+
376+ // Both exchanges attribute themselves to the real user prompt, never the nudge.
377+ for ( const exchange of provider . exchanges ) {
378+ expect ( exchange . prompt ) . not . toContain ( 'Your response was not delivered' ) ;
379+ expect ( exchange . prompt ) . toContain ( 'wrap this later' ) ;
380+ }
381+ expect ( provider . exchanges . map ( ( e ) => e . status ) ) . toEqual ( [ 'undelivered' , 'completed' ] ) ;
382+
383+ await loopPromise . catch ( ( ) => { } ) ;
384+ } ) ;
385+
386+ it ( 'a throwing hook never breaks delivery' , async ( ) => {
387+ insertMessage ( 'm1' , { sender : 'Alice' , text : 'still deliver this' } , { platformId : 'chan-1' , channelType : 'discord' } ) ;
388+
389+ class ThrowingHookProvider extends MockProvider {
390+ onExchangeComplete ( ) : void {
391+ throw new Error ( 'hook exploded' ) ;
392+ }
393+ }
394+ const provider = new ThrowingHookProvider ( { } , ( ) => '<message to="discord-test">delivered anyway</message>' ) ;
395+ const controller = new AbortController ( ) ;
396+ const loopPromise = runPollLoopWithTimeout ( provider , controller . signal , 2000 ) ;
397+
398+ await waitFor ( ( ) => getUndeliveredMessages ( ) . length > 0 , 2000 ) ;
399+ controller . abort ( ) ;
400+
401+ const out = getUndeliveredMessages ( ) ;
402+ expect ( out . length ) . toBe ( 1 ) ;
403+ expect ( out [ 0 ] . content ) . toContain ( 'delivered anyway' ) ;
404+
405+ await loopPromise . catch ( ( ) => { } ) ;
406+ } ) ;
407+ } ) ;
408+
327409describe ( 'poll loop — provider error recovery' , ( ) => {
328410 it ( 'writes error to outbound and continues loop on provider throw' , async ( ) => {
329411 insertMessage ( 'm1' , { sender : 'Alice' , text : 'trigger error' } , { platformId : 'chan-1' , channelType : 'discord' } ) ;
@@ -462,3 +544,76 @@ class InvalidSessionProvider {
462544 } ;
463545 }
464546}
547+
548+ describe ( 'poll loop — slash command during active query' , ( ) => {
549+ it ( 'aborts the active query when /clear arrives as a follow-up' , async ( ) => {
550+ insertMessage ( 'm-active' , { sender : 'Alice' , text : 'long running request' } , { platformId : 'chan-1' , channelType : 'discord' } ) ;
551+
552+ const provider = new BlockingProvider ( ) ;
553+ const controller = new AbortController ( ) ;
554+ const loopPromise = runPollLoopWithTimeout ( provider as unknown as MockProvider , controller . signal , 3000 ) ;
555+
556+ await waitFor ( ( ) => provider . queries === 1 , 2000 ) ;
557+ insertMessage ( 'm-clear-active' , { sender : 'Alice' , text : '/clear' } , { platformId : 'chan-1' , channelType : 'discord' } ) ;
558+
559+ await waitFor ( ( ) => provider . aborts === 1 , 2000 ) ;
560+ await waitFor (
561+ ( ) => getUndeliveredMessages ( ) . some ( ( msg ) => JSON . parse ( msg . content ) . text === 'Session cleared.' ) ,
562+ 2000 ,
563+ ) ;
564+ controller . abort ( ) ;
565+
566+ expect ( provider . ends ) . toBe ( 0 ) ;
567+ expect ( getContinuation ( 'mock' ) ) . toBeUndefined ( ) ;
568+ expect ( getPendingMessages ( ) ) . toHaveLength ( 0 ) ;
569+
570+ await loopPromise . catch ( ( ) => { } ) ;
571+ } ) ;
572+ } ) ;
573+
574+ /**
575+ * Provider whose query never completes until ended/aborted — for testing how
576+ * the loop interrupts an active stream.
577+ */
578+ class BlockingProvider {
579+ readonly supportsNativeSlashCommands = false ;
580+ queries = 0 ;
581+ aborts = 0 ;
582+ ends = 0 ;
583+
584+ isSessionInvalid ( ) : boolean {
585+ return false ;
586+ }
587+
588+ query ( ) {
589+ const owner = this ;
590+ this . queries += 1 ;
591+ let wake : ( ( ) => void ) | null = null ;
592+ let ended = false ;
593+ let aborted = false ;
594+
595+ return {
596+ push ( ) { } ,
597+ end : ( ) => {
598+ owner . ends += 1 ;
599+ ended = true ;
600+ wake ?.( ) ;
601+ } ,
602+ abort : ( ) => {
603+ owner . aborts += 1 ;
604+ aborted = true ;
605+ wake ?.( ) ;
606+ } ,
607+ events : ( async function * ( ) {
608+ yield { type : 'activity' as const } ;
609+ yield { type : 'init' as const , continuation : 'blocking-session' } ;
610+ while ( ! ended && ! aborted ) {
611+ await new Promise < void > ( ( resolve ) => {
612+ wake = resolve ;
613+ } ) ;
614+ wake = null ;
615+ }
616+ } ) ( ) ,
617+ } ;
618+ }
619+ }
0 commit comments