@@ -16,6 +16,8 @@ import type {
1616} from "@intx/types/runtime" ;
1717import { LOG_NAMESPACE_ROOT } from "../../branding.js" ;
1818import { defined } from "../../../tests/helpers/defined.js" ;
19+ import { withMockedHomedir } from "../../../tests/helpers/mock-module.js" ;
20+ import { createTempDirs } from "../../../tests/helpers/temporary-dirs.js" ;
1921import {
2022 createDeliveryGeneration ,
2123 createSessionOperationQueue ,
@@ -30,6 +32,10 @@ import {
3032 COMPACTION_ABORTED_REASON ,
3133 createCompactionLifecycle ,
3234} from "../../session/compaction-lifecycle.js" ;
35+ import {
36+ printResumeHint ,
37+ resetResumeHintForTests ,
38+ } from "../../session/resume-hint.js" ;
3339import type { RunnerServices , RunnerState } from "./state.js" ;
3440
3541function stubQuit ( args : {
@@ -196,6 +202,104 @@ describe("finalizeTUIRun quit order", () => {
196202 } ) ;
197203} ) ;
198204
205+ describe ( "finalizeTUIRun resume hint" , ( ) => {
206+ test ( "prints the resume command with the exited session id" , async ( ) => {
207+ resetResumeHintForTests ( ) ;
208+ const dirs = createTempDirs (
209+ "corbits-resume-hint-cwd-" ,
210+ "corbits-resume-hint-home-" ,
211+ ) ;
212+ const sessionId = "123e4567-e89b-12d3-a456-426614174000" ;
213+ const { state, services } = stubQuit ( {
214+ awaitTail : async ( ) => undefined ,
215+ shutdownRuntime : async ( ) => undefined ,
216+ } ) ;
217+ state . sessionId = sessionId ;
218+ (
219+ services as unknown as { activatedToolNames : { list : ( ) => string [ ] } }
220+ ) . activatedToolNames = { list : ( ) => [ ] } ;
221+ ( state . config as { cwd : string } ) . cwd = dirs . cwd ;
222+ const outWrites : string [ ] = [ ] ;
223+ const errWrites : string [ ] = [ ] ;
224+ const stdoutSpy = spyOn ( process . stdout , "write" ) . mockImplementation ( ( (
225+ chunk : unknown ,
226+ ) => {
227+ outWrites . push ( String ( chunk ) ) ;
228+ return true ;
229+ } ) as typeof process . stdout . write ) ;
230+ const stderrSpy = spyOn ( process . stderr , "write" ) . mockImplementation ( ( (
231+ chunk : unknown ,
232+ ) => {
233+ errWrites . push ( String ( chunk ) ) ;
234+ return true ;
235+ } ) as typeof process . stderr . write ) ;
236+ try {
237+ const code = await withMockedHomedir ( dirs . home , ( ) =>
238+ finalizeTUIRun ( state , services ) ,
239+ ) ;
240+ expect ( code ) . toBe ( 0 ) ;
241+ } finally {
242+ stdoutSpy . mockRestore ( ) ;
243+ stderrSpy . mockRestore ( ) ;
244+ dirs . cleanup ( ) ;
245+ }
246+ // stderr, not stdout: a piped stdout (JSON consumers) must stay clean.
247+ expect (
248+ errWrites . some ( ( w ) => w === `Run corbits resume ${ sessionId } \n` ) ,
249+ ) . toBe ( true ) ;
250+ expect ( outWrites . some ( ( w ) => w . includes ( "resume" ) ) ) . toBe ( false ) ;
251+ } ) ;
252+
253+ test ( "an external signal racing finalize prints the hint exactly once" , async ( ) => {
254+ resetResumeHintForTests ( ) ;
255+ const dirs = createTempDirs (
256+ "corbits-resume-hint-race-cwd-" ,
257+ "corbits-resume-hint-race-home-" ,
258+ ) ;
259+ const sessionId = "123e4567-e89b-12d3-a456-426614174000" ;
260+ let releaseTail : ( ( ) => void ) | undefined ;
261+ const gatedTail = new Promise < void > ( ( resolve ) => {
262+ releaseTail = resolve ;
263+ } ) ;
264+ const { state, services } = stubQuit ( {
265+ awaitTail : ( ) => gatedTail ,
266+ shutdownRuntime : async ( ) => undefined ,
267+ } ) ;
268+ state . sessionId = sessionId ;
269+ (
270+ services as unknown as { activatedToolNames : { list : ( ) => string [ ] } }
271+ ) . activatedToolNames = { list : ( ) => [ ] } ;
272+ ( state . config as { cwd : string } ) . cwd = dirs . cwd ;
273+ const errWrites : string [ ] = [ ] ;
274+ const stderrSpy = spyOn ( process . stderr , "write" ) . mockImplementation ( ( (
275+ chunk : unknown ,
276+ ) => {
277+ errWrites . push ( String ( chunk ) ) ;
278+ return true ;
279+ } ) as typeof process . stderr . write ) ;
280+ try {
281+ const pending = withMockedHomedir ( dirs . home , ( ) =>
282+ finalizeTUIRun ( state , services ) ,
283+ ) ;
284+ // Simulate the signal handler firing mid-finalize: both paths funnel
285+ // through printResumeHint, so the shared once-flag keeps exactly one
286+ // line. (The process-level `terminating` guard covers signal-vs-signal
287+ // only — it cannot see the finalize tail already in flight.)
288+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
289+ printResumeHint ( sessionId ) ;
290+ defined ( releaseTail , "releaseTail" ) ( ) ;
291+ expect ( await pending ) . toBe ( 0 ) ;
292+ } finally {
293+ stderrSpy . mockRestore ( ) ;
294+ dirs . cleanup ( ) ;
295+ }
296+ const hintLines = errWrites . filter (
297+ ( w ) => w === `Run corbits resume ${ sessionId } \n` ,
298+ ) ;
299+ expect ( hintLines ) . toHaveLength ( 1 ) ;
300+ } ) ;
301+ } ) ;
302+
199303const liveSource : InferenceSource = {
200304 id : "codex/work" ,
201305 provider : "openai" ,
0 commit comments