@@ -8,6 +8,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
88import { setCliSessionBinding } from "../../agents/cli-session.js" ;
99import type { OpenClawConfig } from "../../config/config.js" ;
1010import type { SessionEntry } from "../../config/sessions/types.js" ;
11+ import { MESSAGE_TOOL_ONLY_UNDELIVERED_FINAL_CUSTOM_TYPE } from "../../config/sessions/undelivered-final-notice.js" ;
1112import {
1213 createUserTurnTranscriptRecorder ,
1314 type PersistedUserTurnMessage ,
@@ -58,6 +59,7 @@ const FOLLOWUP_TEST_QUEUES = new Map<
5859> ( ) ;
5960const FOLLOWUP_TEST_SESSION_STORES = new Map < string , Record < string , SessionEntry > > ( ) ;
6061const FOLLOWUP_TEST_SESSION_STORE_PATHS = new Set < string > ( ) ;
62+ let inspectEnqueueFollowupRunForTest : ( ( ) => void ) | undefined ;
6163
6264function debugFollowupTest ( message : string ) : void {
6365 if ( ! FOLLOWUP_DEBUG ) {
@@ -242,6 +244,7 @@ function enqueueFollowupRunForFollowupTest(
242244 _restartIfIdle ?: unknown ,
243245 options ?: { position ?: "tail" | "front" } ,
244246) : boolean {
247+ inspectEnqueueFollowupRunForTest ?.( ) ;
245248 if ( options ?. position === "front" ) {
246249 run . protectFromQueueOverflow = true ;
247250 }
@@ -387,6 +390,7 @@ async function loadFreshFollowupRunnerModuleForTest() {
387390 release : async ( ) => { } ,
388391 } ) ) ,
389392 resolveSessionLockMaxHoldFromTimeout : vi . fn ( ( ) => 1 ) ,
393+ resolveSessionWriteLockOptions : vi . fn ( ( ) => ( { } ) ) ,
390394 } ) ) ;
391395 vi . doMock ( "../../agents/embedded-agent.js" , ( ) => ( {
392396 abortEmbeddedAgentRun : vi . fn ( async ( ) => false ) ,
@@ -604,6 +608,7 @@ beforeEach(() => {
604608 clearFollowupQueue ( "main" ) ;
605609 FOLLOWUP_TEST_QUEUES . clear ( ) ;
606610 FOLLOWUP_TEST_SESSION_STORES . clear ( ) ;
611+ inspectEnqueueFollowupRunForTest = undefined ;
607612} ) ;
608613
609614afterEach ( ( ) => {
@@ -5750,46 +5755,82 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
57505755 expect ( onBlockReply ) . not . toHaveBeenCalled ( ) ;
57515756 } ) ;
57525757
5753- it ( "enqueues a one-shot recovery retry for substantive message-tool-only queued followup finals" , async ( ) => {
5758+ it ( "persists queued message-tool-only undelivered finals before enqueueing recovery " , async ( ) => {
57545759 const finalText =
57555760 "Here is the answer the queued user asked for. It includes enough detail to be a visible response, and it has another sentence so the substantive-final detector treats it as a real reply." ;
57565761 const parentOnComplete = vi . fn ( ) ;
57575762 const parentLifecycle = { onComplete : parentOnComplete } ;
57585763 const queued = baseQueuedRun ( "discord" ) ;
5759- const { onBlockReply } = await runMessagingCase ( {
5760- agentResult : {
5761- payloads : [ { text : finalText } ] ,
5762- meta : { finalAssistantVisibleText : finalText } ,
5763- } ,
5764- queued : {
5765- ...queued ,
5766- originatingChannel : "discord" ,
5767- originatingTo : "channel:C1" ,
5768- queuedLifecycle : parentLifecycle ,
5769- run : {
5770- ...queued . run ,
5771- sourceReplyDeliveryMode : "message_tool_only" ,
5764+ const sessionFile = path . join ( tmpdir ( ) , "openclaw-followup-stranded-session.jsonl" ) ;
5765+ const storePath = path . join ( tmpdir ( ) , "openclaw-followup-stranded-store.json" ) ;
5766+ const sessionEntry : SessionEntry = {
5767+ sessionId : queued . run . sessionId ,
5768+ sessionFile,
5769+ updatedAt : Date . now ( ) ,
5770+ } ;
5771+ const sessionStore = { main : sessionEntry } ;
5772+ await fs . writeFile (
5773+ sessionFile ,
5774+ `${ JSON . stringify ( {
5775+ type : "session" ,
5776+ id : sessionEntry . sessionId ,
5777+ timestamp : new Date ( ) . toISOString ( ) ,
5778+ cwd : queued . run . workspaceDir ,
5779+ } ) } \n`,
5780+ "utf8" ,
5781+ ) ;
5782+ let transcriptAtEnqueue = "" ;
5783+ inspectEnqueueFollowupRunForTest = ( ) => {
5784+ transcriptAtEnqueue = fsSync . existsSync ( sessionFile )
5785+ ? fsSync . readFileSync ( sessionFile , "utf8" )
5786+ : "" ;
5787+ } ;
5788+
5789+ try {
5790+ const { onBlockReply } = await runMessagingCase ( {
5791+ agentResult : {
5792+ payloads : [ { text : finalText } ] ,
5793+ meta : { finalAssistantVisibleText : finalText } ,
57725794 } ,
5773- } as FollowupRun ,
5774- } ) ;
5795+ runnerOverrides : {
5796+ sessionEntry,
5797+ sessionStore,
5798+ sessionKey : "main" ,
5799+ storePath,
5800+ } ,
5801+ queued : {
5802+ ...queued ,
5803+ originatingChannel : "discord" ,
5804+ originatingTo : "channel:C1" ,
5805+ queuedLifecycle : parentLifecycle ,
5806+ run : {
5807+ ...queued . run ,
5808+ sessionFile,
5809+ sourceReplyDeliveryMode : "message_tool_only" ,
5810+ } ,
5811+ } as FollowupRun ,
5812+ } ) ;
57755813
5776- expect ( onBlockReply ) . not . toHaveBeenCalled ( ) ;
5777- expect ( routeReplyMock ) . not . toHaveBeenCalled ( ) ;
5778- const retry = FOLLOWUP_TEST_QUEUES . get ( "main" ) ?. items [ 0 ] ;
5779- expect ( retry ?. summaryLine ) . toBe ( "stranded-reply-retry" ) ;
5780- expect ( retry ?. strandedReplyRetry ) . toBe ( true ) ;
5781- expect ( retry ?. disableCollectBatching ) . toBe ( true ) ;
5782- expect ( retry ?. protectFromQueueOverflow ) . toBe ( true ) ;
5783- expect ( retry ?. transcriptPrompt ) . toBeUndefined ( ) ;
5784- expect ( retry ?. userTurnTranscriptRecorder ) . toBeUndefined ( ) ;
5785- expect ( retry ?. currentInboundContext ) . toBeUndefined ( ) ;
5786- expect ( retry ?. run . suppressNextUserMessagePersistence ) . toBe ( true ) ;
5787- expect ( retry ?. run . sourceReplyDeliveryMode ) . toBe ( "message_tool_only" ) ;
5788- expect ( retry ?. prompt ) . toContain ( "message(action=send)" ) ;
5789- expect ( retry ?. prompt ) . toContain ( finalText ) ;
5790- // System retry detaches from the client turn lifecycle; parent completion owns onComplete once.
5791- expect ( retry ?. queuedLifecycle ) . toBeUndefined ( ) ;
5792- expect ( parentOnComplete ) . toHaveBeenCalledTimes ( 1 ) ;
5814+ expect ( transcriptAtEnqueue ) . toContain ( MESSAGE_TOOL_ONLY_UNDELIVERED_FINAL_CUSTOM_TYPE ) ;
5815+ expect ( onBlockReply ) . not . toHaveBeenCalled ( ) ;
5816+ expect ( routeReplyMock ) . not . toHaveBeenCalled ( ) ;
5817+ const retry = FOLLOWUP_TEST_QUEUES . get ( "main" ) ?. items [ 0 ] ;
5818+ expect ( retry ?. summaryLine ) . toBe ( "stranded-reply-retry" ) ;
5819+ expect ( retry ?. strandedReplyRetry ) . toBe ( true ) ;
5820+ expect ( retry ?. disableCollectBatching ) . toBe ( true ) ;
5821+ expect ( retry ?. protectFromQueueOverflow ) . toBe ( true ) ;
5822+ expect ( retry ?. transcriptPrompt ) . toBeUndefined ( ) ;
5823+ expect ( retry ?. userTurnTranscriptRecorder ) . toBeUndefined ( ) ;
5824+ expect ( retry ?. currentInboundContext ) . toBeUndefined ( ) ;
5825+ expect ( retry ?. run . suppressNextUserMessagePersistence ) . toBe ( true ) ;
5826+ expect ( retry ?. run . sourceReplyDeliveryMode ) . toBe ( "message_tool_only" ) ;
5827+ expect ( retry ?. prompt ) . toContain ( "message(action=send)" ) ;
5828+ expect ( retry ?. prompt ) . toContain ( finalText ) ;
5829+ expect ( retry ?. queuedLifecycle ) . toBeUndefined ( ) ;
5830+ expect ( parentOnComplete ) . toHaveBeenCalledTimes ( 1 ) ;
5831+ } finally {
5832+ await fs . rm ( sessionFile , { force : true } ) ;
5833+ }
57935834 } ) ;
57945835
57955836 it ( "excludes raw trace and status payloads from queued stranded recovery prompts" , async ( ) => {
0 commit comments