2026-02-11 00:44:21 +09:00
/// <reference types="bun-types" />
2025-12-30 17:41:03 +09:00
import { describe , expect , test , beforeEach , afterEach } from "bun:test"
import { existsSync , mkdirSync , rmSync , writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createRalphLoopHook } from "./index"
import { readState , writeState , clearState } from "./storage"
import type { RalphLoopState } from "./types"
2026-02-21 02:27:57 +09:00
import { parseRalphLoopArguments } from "./command-arguments"
2026-05-18 13:56:18 +09:00
import { DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS } from "../shared/prompt-async-gate"
2025-12-30 17:41:03 +09:00
describe ( "ralph-loop" , ( ) = > {
const TEST_DIR = join ( tmpdir ( ) , "ralph-loop-test-" + Date . now ( ) )
let promptCalls : Array < { sessionID : string ; text : string } >
let toastCalls : Array < { title : string ; message : string ; variant : string } >
2026-01-02 16:37:04 +09:00
let messagesCalls : Array < { sessionID : string } >
2026-02-21 02:27:57 +09:00
let createSessionCalls : Array < { parentID? : string ; title? : string ; directory? : string } >
2026-01-02 16:37:04 +09:00
let mockSessionMessages : Array < { info ? : { role? : string } ; parts? : Array < { type : string ; text? : string } > } >
2026-02-11 00:44:21 +09:00
let mockMessagesApiResponseShape : "data" | "array"
2025-12-30 17:41:03 +09:00
2026-04-27 17:52:45 +09:00
function createMockPluginInput ( ) : Parameters < typeof createRalphLoopHook > [ 0 ] {
2025-12-30 17:41:03 +09:00
return {
client : {
session : {
prompt : async ( opts : { path : { id : string } ; body : { parts : Array < { type : string ; text : string } > } } ) = > {
promptCalls . push ( {
sessionID : opts.path.id ,
text : opts.body.parts [ 0 ] . text ,
} )
return { }
} ,
2026-02-08 02:41:29 +09:00
promptAsync : async ( opts : { path : { id : string } ; body : { parts : Array < { type : string ; text : string } > } } ) = > {
promptCalls . push ( {
sessionID : opts.path.id ,
text : opts.body.parts [ 0 ] . text ,
} )
return { }
} ,
2026-01-02 16:37:04 +09:00
messages : async ( opts : { path : { id : string } } ) = > {
messagesCalls . push ( { sessionID : opts.path.id } )
2026-02-11 00:44:21 +09:00
return mockMessagesApiResponseShape === "array" ? mockSessionMessages : { data : mockSessionMessages }
2026-01-02 16:37:04 +09:00
} ,
2026-02-21 02:27:57 +09:00
create : async ( opts : {
body : { parentID? : string ; title? : string }
query ? : { directory? : string }
} ) = > {
createSessionCalls . push ( {
parentID : opts.body.parentID ,
title : opts.body.title ,
directory : opts.query?.directory ,
} )
return { data : { id : ` new-session- ${ createSessionCalls . length } ` } }
} ,
2025-12-30 17:41:03 +09:00
} ,
tui : {
showToast : async ( opts : { body : { title : string ; message : string ; variant : string } } ) = > {
toastCalls . push ( {
title : opts.body.title ,
message : opts.body.message ,
variant : opts.body.variant ,
} )
return { }
} ,
} ,
} ,
directory : TEST_DIR ,
2026-04-27 17:52:45 +09:00
} as Parameters < typeof createRalphLoopHook > [ 0 ]
2025-12-30 17:41:03 +09:00
}
beforeEach ( ( ) = > {
promptCalls = [ ]
toastCalls = [ ]
2026-01-02 16:37:04 +09:00
messagesCalls = [ ]
2026-02-21 02:27:57 +09:00
createSessionCalls = [ ]
2026-01-02 16:37:04 +09:00
mockSessionMessages = [ ]
2026-02-11 00:44:21 +09:00
mockMessagesApiResponseShape = "data"
2025-12-30 17:41:03 +09:00
if ( ! existsSync ( TEST_DIR ) ) {
mkdirSync ( TEST_DIR , { recursive : true } )
}
clearState ( TEST_DIR )
} )
afterEach ( ( ) = > {
clearState ( TEST_DIR )
if ( existsSync ( TEST_DIR ) ) {
rmSync ( TEST_DIR , { recursive : true , force : true } )
}
} )
describe ( "storage" , ( ) = > {
test ( "should write and read state correctly" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - a state object
2025-12-30 17:41:03 +09:00
const state : RalphLoopState = {
active : true ,
iteration : 1 ,
max_iterations : 50 ,
completion_promise : "DONE" ,
started_at : "2025-12-30T01:00:00Z" ,
prompt : "Build a REST API" ,
session_id : "test-session-123" ,
}
2026-02-01 16:47:50 +09:00
// when - write and read state
2025-12-30 17:41:03 +09:00
const writeSuccess = writeState ( TEST_DIR , state )
const readResult = readState ( TEST_DIR )
2026-02-01 16:47:50 +09:00
// then - state should match
2025-12-30 17:41:03 +09:00
expect ( writeSuccess ) . toBe ( true )
expect ( readResult ) . not . toBeNull ( )
expect ( readResult ? . active ) . toBe ( true )
expect ( readResult ? . iteration ) . toBe ( 1 )
expect ( readResult ? . max_iterations ) . toBe ( 50 )
expect ( readResult ? . completion_promise ) . toBe ( "DONE" )
expect ( readResult ? . prompt ) . toBe ( "Build a REST API" )
expect ( readResult ? . session_id ) . toBe ( "test-session-123" )
} )
2026-01-17 19:56:50 +09:00
test ( "should handle ultrawork field" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - a state object with ultrawork enabled
2026-01-17 19:56:50 +09:00
const state : RalphLoopState = {
active : true ,
iteration : 1 ,
max_iterations : 50 ,
completion_promise : "DONE" ,
started_at : "2025-12-30T01:00:00Z" ,
prompt : "Build a REST API" ,
session_id : "test-session-123" ,
ultrawork : true ,
}
2026-02-01 16:47:50 +09:00
// when - write and read state
2026-01-17 19:56:50 +09:00
writeState ( TEST_DIR , state )
const readResult = readState ( TEST_DIR )
2026-02-01 16:47:50 +09:00
// then - ultrawork field should be preserved
2026-01-17 19:56:50 +09:00
expect ( readResult ? . ultrawork ) . toBe ( true )
} )
2026-02-21 02:27:57 +09:00
test ( "should store and read strategy field" , ( ) = > {
// given - a state object with strategy
const state : RalphLoopState = {
active : true ,
iteration : 1 ,
max_iterations : 50 ,
completion_promise : "DONE" ,
started_at : "2025-12-30T01:00:00Z" ,
prompt : "Build a REST API" ,
strategy : "reset" ,
}
// when - write and read state
writeState ( TEST_DIR , state )
const readResult = readState ( TEST_DIR )
// then - strategy should be preserved
expect ( readResult ? . strategy ) . toBe ( "reset" )
} )
2025-12-30 17:41:03 +09:00
test ( "should return null for non-existent state" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - no state file exists
// when - read state
2025-12-30 17:41:03 +09:00
const result = readState ( TEST_DIR )
2026-02-01 16:47:50 +09:00
// then - should return null
2025-12-30 17:41:03 +09:00
expect ( result ) . toBeNull ( )
} )
test ( "should clear state correctly" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - existing state
2025-12-30 17:41:03 +09:00
const state : RalphLoopState = {
active : true ,
iteration : 1 ,
max_iterations : 50 ,
completion_promise : "DONE" ,
started_at : "2025-12-30T01:00:00Z" ,
prompt : "Test prompt" ,
}
writeState ( TEST_DIR , state )
2026-02-01 16:47:50 +09:00
// when - clear state
2025-12-30 17:41:03 +09:00
const clearSuccess = clearState ( TEST_DIR )
const readResult = readState ( TEST_DIR )
2026-02-01 16:47:50 +09:00
// then - state should be cleared
2025-12-30 17:41:03 +09:00
expect ( clearSuccess ) . toBe ( true )
expect ( readResult ) . toBeNull ( )
} )
test ( "should handle multiline prompts" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - state with multiline prompt
2025-12-30 17:41:03 +09:00
const state : RalphLoopState = {
active : true ,
iteration : 1 ,
max_iterations : 10 ,
completion_promise : "FINISHED" ,
started_at : "2025-12-30T02:00:00Z" ,
prompt : "Build a feature\nwith multiple lines\nand requirements" ,
}
2026-02-01 16:47:50 +09:00
// when - write and read
2025-12-30 17:41:03 +09:00
writeState ( TEST_DIR , state )
const readResult = readState ( TEST_DIR )
2026-02-01 16:47:50 +09:00
// then - multiline prompt preserved
2025-12-30 17:41:03 +09:00
expect ( readResult ? . prompt ) . toBe ( "Build a feature\nwith multiple lines\nand requirements" )
} )
} )
2026-02-21 02:27:57 +09:00
describe ( "command arguments" , ( ) = > {
test ( "should parse --strategy=reset flag" , ( ) = > {
// given - ralph-loop command arguments with reset strategy
const rawArguments = '"Build feature X" --strategy=reset --max-iterations=12'
// when - parse command arguments
const parsedArguments = parseRalphLoopArguments ( rawArguments )
// then - strategy should be parsed as reset
expect ( parsedArguments . strategy ) . toBe ( "reset" )
expect ( parsedArguments . prompt ) . toBe ( "Build feature X" )
expect ( parsedArguments . maxIterations ) . toBe ( 12 )
} )
test ( "should parse --strategy=continue flag" , ( ) = > {
// given - ralph-loop command arguments with continue strategy
const rawArguments = '"Build feature X" --strategy=continue'
// when - parse command arguments
const parsedArguments = parseRalphLoopArguments ( rawArguments )
// then - strategy should be parsed as continue
expect ( parsedArguments . strategy ) . toBe ( "continue" )
} )
} )
2025-12-30 17:41:03 +09:00
describe ( "hook" , ( ) = > {
test ( "should start loop and write state" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook instance
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
2026-02-01 16:47:50 +09:00
// when - start loop
2025-12-30 17:41:03 +09:00
const success = hook . startLoop ( "session-123" , "Build something" , {
maxIterations : 25 ,
completionPromise : "FINISHED" ,
} )
2026-02-01 16:47:50 +09:00
// then - state should be written
2025-12-30 17:41:03 +09:00
expect ( success ) . toBe ( true )
const state = hook . getState ( )
expect ( state ? . active ) . toBe ( true )
expect ( state ? . iteration ) . toBe ( 1 )
expect ( state ? . max_iterations ) . toBe ( 25 )
expect ( state ? . completion_promise ) . toBe ( "FINISHED" )
expect ( state ? . prompt ) . toBe ( "Build something" )
expect ( state ? . session_id ) . toBe ( "session-123" )
} )
2026-01-17 19:56:50 +09:00
test ( "should accept ultrawork option in startLoop" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook instance
2026-01-17 19:56:50 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
2026-02-01 16:47:50 +09:00
// when - start loop with ultrawork
2026-01-17 19:56:50 +09:00
hook . startLoop ( "session-123" , "Build something" , { ultrawork : true } )
2026-02-01 16:47:50 +09:00
// then - state should have ultrawork=true
2026-01-17 19:56:50 +09:00
const state = hook . getState ( )
expect ( state ? . ultrawork ) . toBe ( true )
} )
test ( "should handle missing ultrawork option in startLoop" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook instance
2026-01-17 19:56:50 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
2026-02-01 16:47:50 +09:00
// when - start loop without ultrawork
2026-01-17 19:56:50 +09:00
hook . startLoop ( "session-123" , "Build something" )
2026-02-01 16:47:50 +09:00
// then - state should have ultrawork=undefined
2026-01-17 19:56:50 +09:00
const state = hook . getState ( )
expect ( state ? . ultrawork ) . toBeUndefined ( )
} )
2025-12-30 17:41:03 +09:00
test ( "should inject continuation when loop active and no completion detected" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop state
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build a feature" , { maxIterations : 10 } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
2026-05-12 15:28:46 +09:00
properties : { sessionID : "session-123" , synthetic : true } ,
2025-12-30 17:41:03 +09:00
} ,
} )
2026-02-01 16:47:50 +09:00
// then - continuation should be injected
2025-12-30 17:41:03 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "session-123" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "RALPH LOOP" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "Build a feature" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "2/10" )
2026-02-01 16:47:50 +09:00
// then - iteration should be incremented
2025-12-30 17:41:03 +09:00
const state = hook . getState ( )
expect ( state ? . iteration ) . toBe ( 2 )
} )
2026-05-12 15:28:46 +09:00
test ( "#given synthetic and real idle arrive back-to-back #then only one continuation is injected for the same iteration" , async ( ) = > {
// given
const hook = createRalphLoopHook ( createMockPluginInput ( ) , { idleSettleMs : 0 } )
hook . startLoop ( "session-123" , "Build a feature" , { maxIterations : 10 } )
// when
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" , synthetic : true } ,
} ,
} )
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
// then
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "session-123" )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
} )
test ( "#given new activity after an idle continuation #when session idles again #then next iteration can continue" , async ( ) = > {
// given
2026-05-18 13:56:18 +09:00
const originalDateNow = Date . now
let currentNow = originalDateNow ( )
Date . now = ( ) = > currentNow
2026-05-12 15:28:46 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) , { idleSettleMs : 0 } )
2026-05-18 13:56:18 +09:00
try {
hook . startLoop ( "session-123" , "Build a feature" , { maxIterations : 10 } )
2026-05-12 15:28:46 +09:00
2026-05-18 13:56:18 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-05-12 15:28:46 +09:00
2026-05-18 13:56:18 +09:00
// when
await hook . event ( {
event : {
type : "message.part.updated" ,
properties : { sessionID : "session-123" } ,
} ,
} )
currentNow += DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS + 1
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-05-12 15:28:46 +09:00
2026-05-18 13:56:18 +09:00
// then
expect ( promptCalls . length ) . toBe ( 2 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 3 )
} finally {
Date . now = originalDateNow
}
2026-05-12 15:28:46 +09:00
} )
2026-05-12 11:48:18 +09:00
test ( "should inject continuation when idle event carries session id in info" , async ( ) = > {
// given - active loop state and nested session event shape
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-info-idle" , "Build a feature" , { maxIterations : 10 } )
// when - session goes idle with id under info
await hook . event ( {
event : {
type : "session.idle" ,
properties : { info : { id : "session-info-idle" } } ,
} ,
} )
// then - continuation should be injected for that session
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "session-info-idle" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "RALPH LOOP" )
} )
2026-05-10 12:46:00 +09:00
test ( "should settle idle before injecting continuation" , async ( ) = > {
// given - active loop state with a configured idle settle delay
const hook = createRalphLoopHook ( createMockPluginInput ( ) , { idleSettleMs : 25 } )
hook . startLoop ( "session-123" , "Build a feature" , { maxIterations : 10 } )
// when - session goes idle
const eventPromise = hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
await Promise . resolve ( )
// then - continuation should not be injected in the same event-loop turn
expect ( promptCalls . length ) . toBe ( 0 )
await eventPromise
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "session-123" )
} )
2026-05-07 17:47:53 +09:00
test ( "#given hanging toast #when session idles #then continuation still injects" , async ( ) = > {
// given - TUI toast never settles
const ctx = createMockPluginInput ( )
ctx . client . tui = {
showToast : ( ) = > new Promise ( ( ) = > { } ) ,
} as never
2026-05-10 12:46:00 +09:00
const hook = createRalphLoopHook ( ctx , { idleSettleMs : 0 } )
2026-05-07 17:47:53 +09:00
hook . startLoop ( "session-123" , "Build a feature" , { maxIterations : 10 } )
// when - session goes idle
const result = await Promise . race ( [
hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} ) . then ( ( ) = > "resolved" as const ) ,
new Promise < "timed-out" > ( ( resolvePromise ) = > setTimeout ( ( ) = > resolvePromise ( "timed-out" ) , 50 ) ) ,
] )
// then - continuation is not blocked by toast delivery
expect ( result ) . toBe ( "resolved" )
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "session-123" )
} )
2026-04-27 17:52:45 +09:00
test ( "should skip continuation when background task is running" , async ( ) = > {
// given - active loop state with a running background task
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
backgroundManager : {
getTasksByParentSession : ( sessionID : string ) = > sessionID === "session-123"
? [ { status : "running" } ]
: [ ] ,
} ,
} )
hook . startLoop ( "session-123" , "Build a feature" , { maxIterations : 10 } )
// when - session goes idle
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
// then - no continuation should be injected
expect ( promptCalls . length ) . toBe ( 0 )
// then - iteration should not be incremented
const state = hook . getState ( )
expect ( state ? . iteration ) . toBe ( 1 )
} )
2025-12-30 17:41:03 +09:00
test ( "should stop loop when max iterations reached" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - loop at max iteration
2026-05-10 12:46:00 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) , { idleSettleMs : 0 } )
2025-12-30 17:41:03 +09:00
hook . startLoop ( "session-123" , "Build something" , { maxIterations : 2 } )
const state = hook . getState ( ) !
state . iteration = 2
writeState ( TEST_DIR , state )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - no continuation injected
2025-12-30 17:41:03 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
2026-02-01 16:47:50 +09:00
// then - warning toast shown
2025-12-30 17:41:03 +09:00
expect ( toastCalls . length ) . toBe ( 1 )
expect ( toastCalls [ 0 ] . title ) . toBe ( "Ralph Loop Stopped" )
expect ( toastCalls [ 0 ] . variant ) . toBe ( "warning" )
2026-02-01 16:47:50 +09:00
// then - state should be cleared
2025-12-30 17:41:03 +09:00
expect ( hook . getState ( ) ) . toBeNull ( )
} )
test ( "should cancel loop via cancelLoop" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Test task" )
2026-02-01 16:47:50 +09:00
// when - cancel loop
2025-12-30 17:41:03 +09:00
const success = hook . cancelLoop ( "session-123" )
2026-02-01 16:47:50 +09:00
// then - loop cancelled
2025-12-30 17:41:03 +09:00
expect ( success ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
} )
test ( "should not cancel loop for different session" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop for session-123
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Test task" )
2026-02-01 16:47:50 +09:00
// when - try to cancel for different session
2025-12-30 17:41:03 +09:00
const success = hook . cancelLoop ( "session-456" )
2026-02-01 16:47:50 +09:00
// then - cancel should fail
2025-12-30 17:41:03 +09:00
expect ( success ) . toBe ( false )
expect ( hook . getState ( ) ) . not . toBeNull ( )
} )
2026-04-27 18:22:53 +09:00
test ( "should continue after non-abort session error" , async ( ) = > {
// given - active loop and non-abort session error
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Test task" )
await hook . event ( {
event : {
type : "session.error" ,
properties : { sessionID : "session-123" , error : new Error ( "test" ) } ,
} ,
} )
2026-04-27 18:22:53 +09:00
// when - session goes idle immediately after the error
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-04-27 18:22:53 +09:00
// then - continuation is injected without a recovery skip
expect ( promptCalls . length ) . toBe ( 1 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
2025-12-30 17:41:03 +09:00
} )
test ( "should clear state on session deletion" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Test task" )
2026-02-01 16:47:50 +09:00
// when - session deleted
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.deleted" ,
properties : { info : { id : "session-123" } } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - state should be cleared
2025-12-30 17:41:03 +09:00
expect ( hook . getState ( ) ) . toBeNull ( )
} )
test ( "should not inject for different session than loop owner" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - loop owned by session-123
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Test task" )
2026-02-01 16:47:50 +09:00
// when - different session goes idle
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-456" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - no continuation injected
2025-12-30 17:41:03 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
} )
2026-01-03 14:11:59 +09:00
test ( "should clear orphaned state when original session no longer exists" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - state file exists from a previous session that no longer exists
2026-01-03 14:11:59 +09:00
const state : RalphLoopState = {
active : true ,
iteration : 3 ,
max_iterations : 50 ,
completion_promise : "DONE" ,
started_at : "2025-12-30T01:00:00Z" ,
prompt : "Build something" ,
session_id : "orphaned-session-999" , // This session no longer exists
}
writeState ( TEST_DIR , state )
// Mock sessionExists to return false for the orphaned session
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
checkSessionExists : async ( sessionID : string ) = > {
// Orphaned session doesn't exist, current session does
return sessionID !== "orphaned-session-999"
} ,
} )
2026-02-01 16:47:50 +09:00
// when - a new session goes idle (different from the orphaned session in state)
2026-01-03 14:11:59 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "new-session-456" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - orphaned state should be cleared
2026-01-03 14:11:59 +09:00
expect ( hook . getState ( ) ) . toBeNull ( )
2026-02-01 16:47:50 +09:00
// then - no continuation injected (state was cleared, not resumed)
2026-01-03 14:11:59 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
} )
test ( "should NOT clear state when original session still exists (different active session)" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - state file exists from a session that still exists
2026-01-03 14:11:59 +09:00
const state : RalphLoopState = {
active : true ,
iteration : 2 ,
max_iterations : 50 ,
completion_promise : "DONE" ,
started_at : "2025-12-30T01:00:00Z" ,
prompt : "Build something" ,
session_id : "active-session-123" , // This session still exists
}
writeState ( TEST_DIR , state )
// Mock sessionExists to return true for the active session
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
checkSessionExists : async ( sessionID : string ) = > {
// Original session still exists
return sessionID === "active-session-123" || sessionID === "new-session-456"
} ,
} )
2026-02-01 16:47:50 +09:00
// when - a different session goes idle
2026-01-03 14:11:59 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "new-session-456" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - state should NOT be cleared (original session still active)
2026-01-03 14:11:59 +09:00
expect ( hook . getState ( ) ) . not . toBeNull ( )
expect ( hook . getState ( ) ? . session_id ) . toBe ( "active-session-123" )
2026-02-01 16:47:50 +09:00
// then - no continuation injected (it's a different session's loop)
2026-01-03 14:11:59 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
} )
2025-12-30 17:41:03 +09:00
test ( "should use default config values" , ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook with config
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
config : {
enabled : true ,
default_max_iterations : 200 ,
2026-02-26 20:58:55 +09:00
default_strategy : "continue" ,
2025-12-30 17:41:03 +09:00
} ,
} )
2026-02-01 16:47:50 +09:00
// when - start loop without options
2025-12-30 17:41:03 +09:00
hook . startLoop ( "session-123" , "Test task" )
2026-02-01 16:47:50 +09:00
// then - should use config defaults
2025-12-30 17:41:03 +09:00
const state = hook . getState ( )
expect ( state ? . max_iterations ) . toBe ( 200 )
} )
2026-02-21 02:27:57 +09:00
test ( "should default strategy to continue when not specified" , ( ) = > {
// given - hook with no strategy option
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
// when - start loop without strategy
hook . startLoop ( "session-123" , "Test task" )
// then - strategy should default to continue
const state = hook . getState ( )
expect ( state ? . strategy ) . toBe ( "continue" )
} )
test ( "should create new session for reset strategy" , async ( ) = > {
// given - hook with reset strategy
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build a feature" , { strategy : "reset" } )
// when - session goes idle
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
// then - new session should be created and continuation injected there
expect ( createSessionCalls . length ) . toBe ( 1 )
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "new-session-1" )
expect ( hook . getState ( ) ? . session_id ) . toBe ( "new-session-1" )
} )
2025-12-30 17:41:03 +09:00
test ( "should not inject when no loop is active" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - no active loop
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - no continuation injected
2025-12-30 17:41:03 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
} )
test ( "should detect completion promise and stop loop" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop with transcript containing completion
2025-12-30 23:08:30 +09:00
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
2025-12-30 17:41:03 +09:00
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "COMPLETE" } )
2026-03-28 15:57:27 +09:00
writeFileSync ( transcriptPath , JSON . stringify ( { type : "assistant" , content : "Task done <promise>COMPLETE</promise>" } ) + "\n" )
2025-12-30 17:41:03 +09:00
2026-02-01 16:47:50 +09:00
// when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
2025-12-30 23:08:30 +09:00
properties : { sessionID : "session-123" } ,
2025-12-30 17:41:03 +09:00
} ,
} )
2026-02-01 16:47:50 +09:00
// then - loop completed, no continuation
2025-12-30 17:41:03 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
} )
2026-01-02 16:37:04 +09:00
test ( "should detect completion promise via session messages API" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop with assistant message containing completion promise
2026-01-02 16:37:04 +09:00
mockSessionMessages = [
{ info : { role : "user" } , parts : [ { type : "text" , text : "Build something" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "I have completed the task. <promise>API_DONE</promise>" } ] } ,
]
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "API_DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-02 16:37:04 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - loop completed via API detection, no continuation
2026-01-02 16:37:04 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
2026-02-01 16:47:50 +09:00
// then - messages API was called with correct session ID
2026-02-27 03:08:30 +09:00
expect ( messagesCalls . length ) . toBe ( 2 )
2026-01-02 16:37:04 +09:00
expect ( messagesCalls [ 0 ] . sessionID ) . toBe ( "session-123" )
} )
2026-02-11 00:44:21 +09:00
test ( "should detect completion promise via session messages API when API returns array" , async ( ) = > {
// given - active loop with assistant message containing completion promise
mockMessagesApiResponseShape = "array"
mockSessionMessages = [
{ info : { role : "user" } , parts : [ { type : "text" , text : "Build something" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "I have completed the task. <promise>API_DONE</promise>" } ] } ,
]
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "API_DONE" } )
// when - session goes idle
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
// then - loop completed via API detection, no continuation
expect ( promptCalls . length ) . toBe ( 0 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
// then - messages API was called with correct session ID
2026-02-27 03:08:30 +09:00
expect ( messagesCalls . length ) . toBe ( 2 )
2026-02-11 00:44:21 +09:00
expect ( messagesCalls [ 0 ] . sessionID ) . toBe ( "session-123" )
} )
2026-05-11 14:48:38 +09:00
test ( "#given completion lands during continuation dispatch #when idle returns #then completion wins over iteration toast" , async ( ) = > {
// given - active loop whose completion promise appears while dispatch is in progress
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const pluginInput = createMockPluginInput ( )
Object . defineProperty ( pluginInput . client . session , "promptAsync" , {
value : async ( opts : { path : { id : string } ; body : { parts : Array < { type : string ; text : string } > } } ) = > {
promptCalls . push ( {
sessionID : opts.path.id ,
text : opts.body.parts [ 0 ] . text ,
} )
writeFileSync (
transcriptPath ,
JSON . stringify ( {
type : "assistant" ,
timestamp : new Date ( ) . toISOString ( ) ,
content : "Task finished <promise>DONE</promise>" ,
} ) + "\n" ,
)
return { }
} ,
} )
const hook = createRalphLoopHook ( pluginInput , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
hook . startLoop ( "session-123" , "Build something" , {
completionPromise : "DONE" ,
maxIterations : 5 ,
} )
// when - idle handler begins continuation, then completion appears before dispatch returns
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
// then - loop completes without publishing a stale iteration toast
expect ( promptCalls . length ) . toBe ( 1 )
expect ( hook . getState ( ) ) . toBeNull ( )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect (
toastCalls . some ( ( t ) = > t . title === "Ralph Loop" && t . message . includes ( "Iteration" ) ) ,
) . toBe ( false )
} )
2026-02-11 00:44:21 +09:00
test ( "should ignore completion promise in reasoning part via session messages API" , async ( ) = > {
2026-02-08 14:50:36 +09:00
//#given - active loop with assistant reasoning containing completion promise
mockSessionMessages = [
{ info : { role : "user" } , parts : [ { type : "text" , text : "Build something" } ] } ,
{
2026-05-19 17:43:37 +09:00
info : { role : "assistant" , finish : "end_turn" } ,
2026-02-08 14:50:36 +09:00
parts : [
{ type : "reasoning" , text : "I am done now. <promise>REASONING_DONE</promise>" } ,
] ,
} ,
]
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
} )
hook . startLoop ( "session-123" , "Build something" , {
completionPromise : "REASONING_DONE" ,
2026-02-11 00:44:21 +09:00
maxIterations : 10 ,
2026-02-08 14:50:36 +09:00
} )
//#when - session goes idle
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-02-11 00:44:21 +09:00
//#then - completion promise in reasoning is ignored, continuation injected
expect ( promptCalls . length ) . toBe ( 1 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( false )
const state = hook . getState ( )
expect ( state ) . not . toBeNull ( )
expect ( state ? . iteration ) . toBe ( 2 )
2026-02-08 14:50:36 +09:00
} )
2026-05-15 13:19:10 +09:00
test ( "#given duplicate real idle fires before assistant activity #then loop state is preserved without another prompt" , async ( ) = > {
// given - active loop
const hook = createRalphLoopHook ( createMockPluginInput ( ) , { idleSettleMs : 0 } )
hook . startLoop ( "session-123" , "Build feature" , { maxIterations : 5 } )
// when - duplicate idle events arrive without any intervening activity
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
// then - the second dispatch is deferred, not treated as loop failure
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
expect ( promptCalls . length ) . toBe ( 1 )
} )
2026-05-18 13:56:18 +09:00
test ( "#given assistant activity follows an idle continuation #when stale idle arrives before dispatch hold expires #then no duplicate prompt is sent" , async ( ) = > {
// given - active loop with deterministic dispatch hold time
const originalDateNow = Date . now
let currentNow = originalDateNow ( )
Date . now = ( ) = > currentNow
const hook = createRalphLoopHook ( createMockPluginInput ( ) , { idleSettleMs : 0 } )
hook . startLoop ( "session-123" , "Build feature" , { maxIterations : 5 } )
try {
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
// when - assistant activity arrives, followed by an immediate stale idle
await hook . event ( {
event : { type : "message.part.updated" , properties : { sessionID : "session-123" } } ,
} )
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
// then - activity did not release the prompt gate reservation
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
expect ( promptCalls . length ) . toBe ( 1 )
} finally {
Date . now = originalDateNow
}
} )
2025-12-30 17:41:03 +09:00
test ( "should handle multiple iterations correctly" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop
2026-05-18 13:56:18 +09:00
const originalDateNow = Date . now
let currentNow = originalDateNow ( )
Date . now = ( ) = > currentNow
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build feature" , { maxIterations : 5 } )
2026-05-18 13:56:18 +09:00
try {
// when - multiple idle events separated by the dispatch hold expiring
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
await hook . event ( {
event : { type : "message.part.updated" , properties : { sessionID : "session-123" } } ,
} )
currentNow += DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS + 1
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2025-12-30 17:41:03 +09:00
2026-05-18 13:56:18 +09:00
// then - iteration incremented correctly
expect ( hook . getState ( ) ? . iteration ) . toBe ( 3 )
expect ( promptCalls . length ) . toBe ( 2 )
} finally {
Date . now = originalDateNow
}
2025-12-30 17:41:03 +09:00
} )
test ( "should include prompt and promise in continuation message" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - loop with specific prompt and promise
2025-12-30 17:41:03 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Create a calculator app" , {
completionPromise : "CALCULATOR_DONE" ,
maxIterations : 10 ,
} )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2025-12-30 17:41:03 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2026-02-01 16:47:50 +09:00
// then - continuation includes original task and promise
2025-12-30 17:41:03 +09:00
expect ( promptCalls [ 0 ] . text ) . toContain ( "Create a calculator app" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "<promise>CALCULATOR_DONE</promise>" )
} )
2026-01-03 09:55:12 +09:00
2026-02-26 20:58:55 +09:00
test ( "should skip concurrent idle events for same session when handler is in flight" , async ( ) = > {
// given - active loop with delayed prompt injection
let releasePromptAsync : ( ( ) = > void ) | undefined
const promptAsyncBlocked = new Promise < void > ( ( resolve ) = > {
releasePromptAsync = resolve
} )
let firstPromptStartedResolve : ( ( ) = > void ) | undefined
const firstPromptStarted = new Promise < void > ( ( resolve ) = > {
firstPromptStartedResolve = resolve
} )
const mockInput = createMockPluginInput ( ) as {
client : {
session : {
promptAsync : ( opts : { path : { id : string } ; body : { parts : Array < { type : string ; text : string } > } } ) = > Promise < unknown >
}
}
}
const originalPromptAsync = mockInput . client . session . promptAsync
let promptAsyncCalls = 0
mockInput . client . session . promptAsync = async ( opts ) = > {
promptAsyncCalls += 1
if ( promptAsyncCalls === 1 ) {
firstPromptStartedResolve ? . ( )
}
await promptAsyncBlocked
return originalPromptAsync ( opts )
}
const hook = createRalphLoopHook ( mockInput as Parameters < typeof createRalphLoopHook > [ 0 ] )
hook . startLoop ( "session-123" , "Build feature" , { maxIterations : 10 } )
// when - second idle arrives while first idle processing is still in flight
const firstIdle = hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
await firstPromptStarted
const secondIdle = hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
releasePromptAsync ? . ( )
await Promise . all ( [ firstIdle , secondIdle ] )
// then - only one continuation should be injected
expect ( promptAsyncCalls ) . toBe ( 1 )
expect ( promptCalls . length ) . toBe ( 1 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
} )
2026-01-03 09:55:12 +09:00
test ( "should clear loop state on user abort (MessageAbortedError)" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop
2026-01-03 09:55:12 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build something" )
expect ( hook . getState ( ) ) . not . toBeNull ( )
2026-02-01 16:47:50 +09:00
// when - user aborts (Ctrl+C)
2026-01-03 09:55:12 +09:00
await hook . event ( {
event : {
type : "session.error" ,
properties : {
sessionID : "session-123" ,
error : { name : "MessageAbortedError" , message : "User aborted" } ,
} ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - loop state should be cleared immediately
2026-01-03 09:55:12 +09:00
expect ( hook . getState ( ) ) . toBeNull ( )
} )
test ( "should NOT set recovery mode on user abort" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop
2026-01-03 09:55:12 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build something" )
2026-02-01 16:47:50 +09:00
// when - user aborts (Ctrl+C)
2026-01-03 09:55:12 +09:00
await hook . event ( {
event : {
type : "session.error" ,
properties : {
sessionID : "session-123" ,
error : { name : "MessageAbortedError" } ,
} ,
} ,
} )
// Start a new loop
hook . startLoop ( "session-123" , "New task" )
2026-02-01 16:47:50 +09:00
// when - session goes idle immediately (should work, no recovery mode)
2026-01-03 09:55:12 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2026-02-01 16:47:50 +09:00
// then - continuation should be injected (not blocked by recovery)
2026-01-03 09:55:12 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
} )
2026-02-08 14:50:36 +09:00
test ( "should check last 3 assistant messages for completion" , async ( ) = > {
// given - multiple assistant messages, promise in recent (not last) assistant message
2026-01-03 09:55:12 +09:00
mockSessionMessages = [
{ info : { role : "user" } , parts : [ { type : "text" , text : "Start task" } ] } ,
2026-02-08 14:50:36 +09:00
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "Working on it." } ] } ,
2026-01-03 09:55:12 +09:00
{ info : { role : "user" } , parts : [ { type : "text" , text : "Continue" } ] } ,
2026-02-08 14:50:36 +09:00
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "Nearly there... <promise>DONE</promise>" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "(extra output after promise)" } ] } ,
2026-01-03 09:55:12 +09:00
]
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-03 09:55:12 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2026-02-08 14:50:36 +09:00
// then - loop should complete (promise found within last 3 assistant messages)
expect ( promptCalls . length ) . toBe ( 0 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
2026-01-03 09:55:12 +09:00
} )
2026-02-26 20:58:55 +09:00
test ( "should detect completion even when promise is older than previous narrow window" , async ( ) = > {
// given - promise appears in an older assistant message with additional assistant output after it
2026-01-03 09:55:12 +09:00
mockSessionMessages = [
{ info : { role : "user" } , parts : [ { type : "text" , text : "Start task" } ] } ,
2026-02-08 14:50:36 +09:00
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "Promise early <promise>DONE</promise>" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "More work 1" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "More work 2" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "More work 3" } ] } ,
2026-01-03 09:55:12 +09:00
]
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-03 09:55:12 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2026-02-26 20:58:55 +09:00
// then - loop should complete because all assistant messages are scanned
expect ( promptCalls . length ) . toBe ( 0 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
} )
test ( "should detect completion when many assistant messages are emitted after promise" , async ( ) = > {
// given - completion promise followed by long assistant output sequence
mockSessionMessages = [
{ info : { role : "user" } , parts : [ { type : "text" , text : "Start task" } ] } ,
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "Done now <promise>DONE</promise>" } ] } ,
]
for ( let index = 1 ; index <= 25 ; index += 1 ) {
mockSessionMessages . push ( {
info : { role : "assistant" } ,
parts : [ { type : "text" , text : ` Post-completion assistant output ${ index } ` } ] ,
} )
}
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
// when - session goes idle
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
// then - loop should complete despite large trailing output
expect ( promptCalls . length ) . toBe ( 0 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
expect ( hook . getState ( ) ) . toBeNull ( )
2026-01-03 09:55:12 +09:00
} )
2026-01-09 16:39:53 +09:00
test ( "should allow starting new loop while previous loop is active (different session)" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop in session A
2026-01-09 16:39:53 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-A" , "First task" , { maxIterations : 10 } )
expect ( hook . getState ( ) ? . session_id ) . toBe ( "session-A" )
expect ( hook . getState ( ) ? . prompt ) . toBe ( "First task" )
2026-02-01 16:47:50 +09:00
// when - start new loop in session B (without completing A)
2026-01-09 16:39:53 +09:00
hook . startLoop ( "session-B" , "Second task" , { maxIterations : 20 } )
2026-02-01 16:47:50 +09:00
// then - state should be overwritten with session B's loop
2026-01-09 16:39:53 +09:00
expect ( hook . getState ( ) ? . session_id ) . toBe ( "session-B" )
expect ( hook . getState ( ) ? . prompt ) . toBe ( "Second task" )
expect ( hook . getState ( ) ? . max_iterations ) . toBe ( 20 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 1 )
2026-02-01 16:47:50 +09:00
// when - session B goes idle
2026-01-09 16:39:53 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-B" } } ,
} )
2026-02-01 16:47:50 +09:00
// then - continuation should be injected for session B
2026-01-09 16:39:53 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . sessionID ) . toBe ( "session-B" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "Second task" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "2/20" )
2026-02-01 16:47:50 +09:00
// then - iteration incremented
2026-01-09 16:39:53 +09:00
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
} )
test ( "should allow starting new loop in same session (restart)" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - active loop in session A at iteration 5
2026-05-18 13:56:18 +09:00
const originalDateNow = Date . now
let currentNow = originalDateNow ( )
Date . now = ( ) = > currentNow
2026-01-09 16:39:53 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
2026-05-18 13:56:18 +09:00
try {
hook . startLoop ( "session-A" , "First task" , { maxIterations : 10 } )
2026-01-09 16:39:53 +09:00
2026-05-18 13:56:18 +09:00
// Simulate some iterations
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-A" } } ,
} )
await hook . event ( {
event : { type : "message.part.updated" , properties : { sessionID : "session-A" } } ,
} )
currentNow += DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS + 1
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-A" } } ,
} )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 3 )
expect ( promptCalls . length ) . toBe ( 2 )
// when - start NEW loop in same session (restart)
hook . startLoop ( "session-A" , "Restarted task" , { maxIterations : 50 } )
// then - state should be reset to iteration 1 with new prompt
expect ( hook . getState ( ) ? . session_id ) . toBe ( "session-A" )
expect ( hook . getState ( ) ? . prompt ) . toBe ( "Restarted task" )
expect ( hook . getState ( ) ? . max_iterations ) . toBe ( 50 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 1 )
// when - session goes idle
promptCalls = [ ] // Reset to check new continuation
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-A" } } ,
} )
2026-01-09 16:39:53 +09:00
2026-05-18 13:56:18 +09:00
// then - continuation should use new task
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . text ) . toContain ( "Restarted task" )
expect ( promptCalls [ 0 ] . text ) . toContain ( "2/50" )
} finally {
Date . now = originalDateNow
}
2026-01-09 16:39:53 +09:00
} )
2026-01-25 12:34:42 +09:00
test ( "should NOT detect completion from user message in transcript (issue #622)" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - transcript contains user message with template text that includes completion promise
2026-01-25 12:34:42 +09:00
// This reproduces the bug where the RALPH_LOOP_TEMPLATE instructional text
// containing `<promise>DONE</promise>` is recorded as a user message and
// falsely triggers completion detection
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const templateText = ` You are starting a Ralph Loop...
Output <promise>DONE</promise> when fully complete `
const userEntry = JSON . stringify ( {
type : "user" ,
timestamp : new Date ( ) . toISOString ( ) ,
content : templateText ,
} )
writeFileSync ( transcriptPath , userEntry + "\n" )
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-25 12:34:42 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - loop should CONTINUE (user message completion promise is instructional, not actual)
2026-01-25 12:34:42 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
} )
test ( "should NOT detect completion from continuation prompt in transcript (issue #622)" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - transcript contains continuation prompt (also a user message) with completion promise
2026-01-25 12:34:42 +09:00
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const continuationText = ` RALPH LOOP 2/100
When FULLY complete, output: <promise>DONE</promise>
Original task: Build something `
const userEntry = JSON . stringify ( {
type : "user" ,
timestamp : new Date ( ) . toISOString ( ) ,
content : continuationText ,
} )
writeFileSync ( transcriptPath , userEntry + "\n" )
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-25 12:34:42 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-02-01 16:47:50 +09:00
// then - loop should CONTINUE (continuation prompt text is not actual completion)
2026-01-25 12:34:42 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
} )
2026-03-28 15:57:27 +09:00
test ( "should NOT detect completion from tool_result entry in transcript" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - transcript contains a tool_result with completion promise
2026-01-25 12:34:42 +09:00
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const toolResultEntry = JSON . stringify ( {
type : "tool_result" ,
tool_name : "write" ,
tool_input : { } ,
tool_output : { output : "Task complete! <promise>DONE</promise>" } ,
} )
writeFileSync ( transcriptPath , toolResultEntry + "\n" )
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-25 12:34:42 +09:00
await hook . event ( {
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
} )
2026-03-28 15:57:27 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( toastCalls . some ( ( t ) = > t . title === "Ralph Loop Complete!" ) ) . toBe ( false )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
2026-01-25 12:34:42 +09:00
} )
2026-01-03 09:55:12 +09:00
test ( "should check transcript BEFORE API to optimize performance" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - transcript has completion promise
2026-01-03 09:55:12 +09:00
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
2026-03-28 15:57:27 +09:00
writeFileSync ( transcriptPath , JSON . stringify ( { type : "assistant" , content : "<promise>DONE</promise>" } ) + "\n" )
2026-01-03 09:55:12 +09:00
mockSessionMessages = [
{ info : { role : "assistant" } , parts : [ { type : "text" , text : "No promise here" } ] } ,
]
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
hook . startLoop ( "session-123" , "Build something" , { completionPromise : "DONE" } )
2026-02-01 16:47:50 +09:00
// when - session goes idle
2026-01-03 09:55:12 +09:00
await hook . event ( {
2026-01-17 19:56:50 +09:00
event : {
type : "session.idle" ,
properties : { sessionID : "session-123" } ,
} ,
2026-01-03 09:55:12 +09:00
} )
2026-02-01 16:47:50 +09:00
// then - should complete via transcript (API not called when transcript succeeds)
2026-01-03 09:55:12 +09:00
expect ( promptCalls . length ) . toBe ( 0 )
expect ( hook . getState ( ) ) . toBeNull ( )
// API should NOT be called since transcript found completion
2026-02-27 03:08:30 +09:00
expect ( messagesCalls . length ) . toBe ( 1 )
2026-01-03 09:55:12 +09:00
} )
2026-01-17 19:56:50 +09:00
2026-03-06 22:00:21 +09:00
test ( "should require oracle verification toast for ultrawork completion promise" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook with ultrawork mode and completion in transcript
2026-01-17 19:56:50 +09:00
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
2026-03-28 15:57:27 +09:00
writeFileSync ( transcriptPath , JSON . stringify ( { type : "assistant" , content : "<promise>DONE</promise>" } ) + "\n" )
2026-01-17 19:56:50 +09:00
hook . startLoop ( "test-id" , "Build API" , { ultrawork : true } )
2026-02-01 16:47:50 +09:00
// when - idle event triggered
2026-01-17 19:56:50 +09:00
await hook . event ( { event : { type : "session.idle" , properties : { sessionID : "test-id" } } } )
2026-03-06 22:00:21 +09:00
const verificationToast = toastCalls . find ( t = > t . title === "ULTRAWORK LOOP" )
expect ( verificationToast ) . toBeDefined ( )
expect ( verificationToast ! . message ) . toMatch ( /Oracle verification is now required/ )
2026-01-17 19:56:50 +09:00
} )
2026-05-13 18:59:07 +09:00
test ( "#given loop-start message count resolves late after progress #when ulw DONE appears #then oracle verification still starts" , async ( ) = > {
// given - the initial message-count request is delayed past the first continuation
let messageCallCount = 0
let resolveInitialMessages : ( ( value : { data : typeof mockSessionMessages } ) = > void ) | undefined
const delayedMock = createMockPluginInput ( )
Object . defineProperty ( delayedMock . client . session , "messages" , {
value : async ( opts : { path : { id : string } } ) = > {
messagesCalls . push ( { sessionID : opts.path.id } )
messageCallCount += 1
if ( messageCallCount === 1 ) {
return new Promise < { data : typeof mockSessionMessages } > ( ( resolve ) = > {
resolveInitialMessages = resolve
} )
}
return { data : mockSessionMessages }
} ,
} )
const hook = createRalphLoopHook ( delayedMock , {
getTranscriptPath : ( ) = > join ( TEST_DIR , "missing-transcript.jsonl" ) ,
idleSettleMs : 0 ,
} )
hook . startLoop ( "session-123" , "Build API" , { ultrawork : true } )
await hook . event ( { event : { type : "session.idle" , properties : { sessionID : "session-123" } } } )
expect ( hook . getState ( ) ? . iteration ) . toBe ( 2 )
mockSessionMessages = [
{
2026-05-19 17:43:37 +09:00
info : { role : "assistant" , finish : "end_turn" } ,
2026-05-13 18:59:07 +09:00
parts : [ { type : "text" , text : "All work is complete. <promise>DONE</promise>" } ] ,
} ,
]
// when - delayed start snapshot resolves after the loop has already advanced
resolveInitialMessages ? . ( { data : mockSessionMessages } )
await new Promise ( ( resolve ) = > setTimeout ( resolve , 0 ) )
2026-05-15 13:19:10 +09:00
await hook . event ( { event : { type : "message.part.updated" , properties : { sessionID : "session-123" } } } )
2026-05-13 18:59:07 +09:00
await hook . event ( { event : { type : "session.idle" , properties : { sessionID : "session-123" } } } )
// then - the late snapshot must not hide the DONE message from verification gating
expect ( hook . getState ( ) ? . verification_pending ) . toBe ( true )
expect ( hook . getState ( ) ? . completion_promise ) . toBe ( "VERIFIED" )
expect ( promptCalls [ promptCalls . length - 1 ] ? . text ) . toContain ( 'task(subagent_type="oracle"' )
} )
2026-01-17 19:56:50 +09:00
test ( "should show regular completion toast when ultrawork disabled" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook without ultrawork
2026-01-17 19:56:50 +09:00
const transcriptPath = join ( TEST_DIR , "transcript.jsonl" )
const hook = createRalphLoopHook ( createMockPluginInput ( ) , {
getTranscriptPath : ( ) = > transcriptPath ,
} )
2026-03-28 15:57:27 +09:00
writeFileSync ( transcriptPath , JSON . stringify ( { type : "assistant" , content : "<promise>DONE</promise>" } ) + "\n" )
2026-01-17 19:56:50 +09:00
hook . startLoop ( "test-id" , "Build API" )
2026-02-01 16:47:50 +09:00
// when - idle event triggered
2026-01-17 19:56:50 +09:00
await hook . event ( { event : { type : "session.idle" , properties : { sessionID : "test-id" } } } )
2026-02-01 16:47:50 +09:00
// then - regular toast shown
2026-01-17 19:56:50 +09:00
expect ( toastCalls . some ( t = > t . title === "Ralph Loop Complete!" ) ) . toBe ( true )
} )
test ( "should prepend ultrawork to continuation prompt when ultrawork=true" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook with ultrawork mode enabled
2026-01-17 19:56:50 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build API" , { ultrawork : true } )
2026-02-01 16:47:50 +09:00
// when - session goes idle (continuation triggered)
2026-01-17 19:56:50 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2026-02-01 16:47:50 +09:00
// then - prompt should start with "ultrawork "
2026-01-17 19:56:50 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . text ) . toMatch ( /^ultrawork / )
} )
test ( "should NOT prepend ultrawork to continuation prompt when ultrawork=false" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook without ultrawork mode
2026-01-17 19:56:50 +09:00
const hook = createRalphLoopHook ( createMockPluginInput ( ) )
hook . startLoop ( "session-123" , "Build API" )
2026-02-01 16:47:50 +09:00
// when - session goes idle (continuation triggered)
2026-01-17 19:56:50 +09:00
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
2026-02-01 16:47:50 +09:00
// then - prompt should NOT start with "ultrawork "
2026-01-17 19:56:50 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
expect ( promptCalls [ 0 ] . text ) . not . toMatch ( /^ultrawork / )
} )
2026-01-03 09:55:12 +09:00
} )
describe ( "API timeout protection" , ( ) = > {
2026-01-28 14:17:56 +09:00
test ( "should not hang when session.messages() throws" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - API that throws (simulates timeout error)
2026-01-28 14:17:56 +09:00
let apiCallCount = 0
2026-04-27 17:52:45 +09:00
const errorMock = createMockPluginInput ( )
Object . defineProperty ( errorMock . client . session , "messages" , {
value : async ( ) = > {
apiCallCount ++
throw new Error ( "API timeout" )
2026-01-03 09:55:12 +09:00
} ,
2026-04-27 17:52:45 +09:00
} )
const hook = createRalphLoopHook ( errorMock , {
2026-01-03 09:55:12 +09:00
getTranscriptPath : ( ) = > join ( TEST_DIR , "nonexistent.jsonl" ) ,
2026-01-28 14:17:56 +09:00
apiTimeout : 100 ,
2026-01-03 09:55:12 +09:00
} )
hook . startLoop ( "session-123" , "Build something" )
2026-02-01 16:47:50 +09:00
// when - session goes idle (API will throw)
2026-01-03 09:55:12 +09:00
const startTime = Date . now ( )
await hook . event ( {
event : { type : "session.idle" , properties : { sessionID : "session-123" } } ,
} )
const elapsed = Date . now ( ) - startTime
2026-02-01 16:47:50 +09:00
// then - should complete quickly (not hang for 10s)
2026-02-08 14:50:36 +09:00
expect ( elapsed ) . toBeLessThan ( 6000 )
2026-02-01 16:47:50 +09:00
// then - loop should continue (API error = no completion detected)
2026-01-03 09:55:12 +09:00
expect ( promptCalls . length ) . toBe ( 1 )
2026-01-28 14:17:56 +09:00
expect ( apiCallCount ) . toBeGreaterThan ( 0 )
2026-01-03 09:55:12 +09:00
} )
2025-12-30 17:41:03 +09:00
} )
} )