11import { describe , expect , spyOn , test } from "bun:test" ;
22import { EventEmitter } from "node:events" ;
33import { AgentContextLockError , type Agent } from "@intx/agent" ;
4+ import { getLogger } from "@intx/log" ;
45import type { InferenceSource } from "@intx/types/runtime" ;
56
67import * as codexSession from "../../auth/codex/session.js" ;
@@ -17,6 +18,7 @@ import {
1718} from "../../subagent/session-store.js" ;
1819import type { ReactorInboundEvent } from "@intx/types/runtime" ;
1920import { defined } from "../../../testkit/defined.js" ;
21+ import { LOG_NAMESPACE_ROOT } from "../../branding.js" ;
2022import {
2123 stubReactorCapabilities ,
2224 stubReactorState ,
@@ -31,10 +33,12 @@ import {
3133 createDeliveryGeneration ,
3234 createSessionOperationQueue ,
3335} from "../delivery-queue.js" ;
36+ import * as sessionState from "../../session/state.js" ;
3437import {
3538 agentRebuildFailure ,
3639 closeAgentForRebuild ,
3740 createRunLifecycle ,
41+ createRunPersistence ,
3842 finalizeTUIRun ,
3943 observeRecoveryAttempts ,
4044 resetSessionForRotation ,
@@ -976,6 +980,62 @@ describe("rebuild close helpers", () => {
976980 ) . toMatch ( / r e s t a r t / i) ;
977981 } ) ;
978982
983+ test ( "a rejecting saveState on the turn-boundary snapshot does not emit unhandledRejection" , async ( ) => {
984+ const save = spyOn ( sessionState , "saveState" ) . mockRejectedValue (
985+ new Error ( "disk full" ) ,
986+ ) ;
987+ const warn = spyOn ( getLogger ( [ LOG_NAMESPACE_ROOT , "tui" ] ) , "warn" ) ;
988+ const state = {
989+ runTaskTitle : "t" ,
990+ liveSource : { id : "p" , model : "m" , credentialId : "cred" } ,
991+ startedAt : 1 ,
992+ connectedMcpServers : [ ] ,
993+ config : { cwd : "/tmp" } ,
994+ sessionId : "sess-boundary" ,
995+ } as unknown as RunnerState ;
996+ const services = {
997+ runSink : { getTurnCount : ( ) => 2 } ,
998+ activatedToolNames : { list : ( ) => [ ] } ,
999+ activeRunHandle : {
1000+ sessionId : "sess-boundary" ,
1001+ cwd : "/tmp" ,
1002+ task : "t" ,
1003+ startedAt : 1 ,
1004+ turnsUsed : 0 ,
1005+ } ,
1006+ crashGuard : { isFinalized : ( ) => false } ,
1007+ } as unknown as RunnerServices ;
1008+ state . persistRunSnapshot = createRunPersistence (
1009+ state ,
1010+ services ,
1011+ ) . persistRunSnapshot ;
1012+
1013+ let unhandled : unknown = null ;
1014+ const onUnhandledRejection = ( reason : unknown ) : void => {
1015+ unhandled = reason ;
1016+ } ;
1017+ process . on ( "unhandledRejection" , onUnhandledRejection ) ;
1018+ try {
1019+ // session.ts onTurnBoundarySnapshot: fire-and-forget running snapshot.
1020+ void state . persistRunSnapshot ?.( "running" ) ;
1021+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
1022+ expect ( unhandled ) . toBeNull ( ) ;
1023+ expect ( save ) . toHaveBeenCalled ( ) ;
1024+ expect ( warn ) . toHaveBeenCalledWith (
1025+ "saveState failed for session {sessionId} status={status}: {error}" ,
1026+ {
1027+ sessionId : "sess-boundary" ,
1028+ status : "running" ,
1029+ error : "disk full" ,
1030+ } ,
1031+ ) ;
1032+ } finally {
1033+ process . off ( "unhandledRejection" , onUnhandledRejection ) ;
1034+ save . mockRestore ( ) ;
1035+ warn . mockRestore ( ) ;
1036+ }
1037+ } ) ;
1038+
9791039 // Overlay accept/decline tests stub bump() inside resolveSuspended, so
9801040 // deleting the interrupt-site bump would not fail them. Drive the interrupt
9811041 // helper itself.
0 commit comments