2026-04-28 10:47:36 +09:00
/// <reference path="../../bun-test.d.ts" />
2026-04-07 22:49:37 +09:00
import { describe , it , expect , afterEach , mock , spyOn } from "bun:test"
2026-04-28 10:47:36 +09:00
import type { PluginInput } from "@opencode-ai/plugin"
2026-02-10 19:09:22 +09:00
2026-04-28 15:29:33 +09:00
import { createEventHandler , extractErrorMessage } from "./event"
2026-03-17 15:17:34 +09:00
import { createChatMessageHandler } from "./chat-message"
2026-04-07 22:49:37 +09:00
import * as openclawRuntimeDispatch from "../openclaw/runtime-dispatch"
2026-04-28 10:47:36 +09:00
import { _resetForTesting , setMainSession , subagentSessions } from "../features/claude-code-session-state"
2026-03-17 15:17:34 +09:00
import { clearPendingModelFallback , createModelFallbackHook } from "../hooks/model-fallback/hook"
2026-03-18 14:21:27 +01:00
import { getSessionPromptParams , setSessionPromptParams } from "../shared/session-prompt-params-state"
2026-05-15 16:26:57 +09:00
import * as sharedTmuxOriginal from "../shared/tmux"
const sharedTmuxSnapshot = { . . . sharedTmuxOriginal }
2026-02-10 19:09:22 +09:00
2026-03-17 15:17:34 +09:00
type EventInput = { event : { type : string ; properties? : unknown } }
2026-04-03 17:37:17 +09:00
type EventHandlerArgs = Parameters < typeof createEventHandler > [ 0 ]
type EventHandlerInput = Parameters < ReturnType < typeof createEventHandler > > [ 0 ]
type ChatMessageHandlerArgs = Parameters < typeof createChatMessageHandler > [ 0 ]
2026-04-28 19:09:02 +09:00
function cast < T > ( value : unknown ) : T {
return value as T
}
2026-04-03 17:37:17 +09:00
function asEventHandlerInput ( input : EventInput ) : EventHandlerInput {
2026-04-28 19:09:02 +09:00
return cast < EventHandlerInput > ( input )
2026-04-03 17:37:17 +09:00
}
function asEventHandlerContext ( ctx : unknown ) : EventHandlerArgs [ "ctx" ] {
2026-04-28 19:09:02 +09:00
return cast < EventHandlerArgs [ "ctx" ] > ( ctx )
2026-04-03 17:37:17 +09:00
}
function asChatMessageHandlerContext ( ctx : unknown ) : ChatMessageHandlerArgs [ "ctx" ] {
2026-04-28 19:09:02 +09:00
return cast < ChatMessageHandlerArgs [ "ctx" ] > ( ctx )
2026-04-03 17:37:17 +09:00
}
function asPluginConfig ( config : unknown ) : EventHandlerArgs [ "pluginConfig" ] {
2026-04-28 19:09:02 +09:00
return cast < EventHandlerArgs [ "pluginConfig" ] > ( config )
2026-04-03 17:37:17 +09:00
}
function asChatPluginConfig ( config : unknown ) : ChatMessageHandlerArgs [ "pluginConfig" ] {
2026-04-28 19:09:02 +09:00
return cast < ChatMessageHandlerArgs [ "pluginConfig" ] > ( config )
2026-04-03 17:37:17 +09:00
}
2026-04-28 10:47:36 +09:00
function asPluginInput ( input : unknown ) : PluginInput {
return input as PluginInput
}
2026-04-03 17:37:17 +09:00
function createEventHandlerManagers (
overrides : Record < string , unknown > = { } ,
) : EventHandlerArgs [ "managers" ] {
2026-04-28 19:09:02 +09:00
return cast < EventHandlerArgs [ "managers" ] > ( {
2026-04-03 17:37:17 +09:00
tmuxSessionManager : {
2026-04-28 10:47:36 +09:00
onEvent : ( ) = > { } ,
2026-04-03 17:37:17 +09:00
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
. . . overrides ,
2026-04-28 19:09:02 +09:00
} )
2026-04-03 17:37:17 +09:00
}
function createEventHandlerHooks (
2026-04-28 19:09:02 +09:00
overrides : Record < string , unknown > = { } ,
2026-04-03 17:37:17 +09:00
) : EventHandlerArgs [ "hooks" ] {
2026-04-28 19:09:02 +09:00
return cast < EventHandlerArgs [ "hooks" ] > ( overrides )
2026-04-03 17:37:17 +09:00
}
function createChatMessageHandlerHooks (
2026-04-28 19:09:02 +09:00
overrides : Record < string , unknown > = { } ,
2026-04-03 17:37:17 +09:00
) : ChatMessageHandlerArgs [ "hooks" ] {
2026-04-28 19:09:02 +09:00
return cast < ChatMessageHandlerArgs [ "hooks" ] > ( overrides )
}
async function wait ( ms : number ) : Promise < void > {
await new Promise ( ( resolve ) = > setTimeout ( resolve , ms ) )
2026-04-03 17:37:17 +09:00
}
2026-03-17 15:17:34 +09:00
2026-05-15 17:54:43 +09:00
async function waitUntil ( predicate : ( ) = > boolean , timeoutMs : number = 500 ) : Promise < void > {
const startedAt = Date . now ( )
while ( ! predicate ( ) ) {
if ( Date . now ( ) - startedAt >= timeoutMs ) {
return
}
await wait ( 5 )
}
}
2026-04-12 02:30:11 +09:00
function createIdleTrackingEventHandler ( dispatchCalls : EventInput [ ] ) : ReturnType < typeof createEventHandler > {
return createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( { } ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( {
autoUpdateChecker : {
event : async ( input : EventInput ) = > {
if ( input . event . type === "session.idle" ) {
dispatchCalls . push ( input )
}
} ,
} ,
} ) ,
} )
}
2026-04-28 10:47:36 +09:00
function createIdleDedupSpyEventHandler ( args : {
onEvent : ( event : EventInput [ "event" ] ) = > void
sessionNotification : ( input : EventInput ) = > Promise < void >
} ) : ReturnType < typeof createEventHandler > {
return createEventHandler ( {
ctx : asEventHandlerContext ( {
directory : "/tmp" ,
client : {
session : { } ,
} ,
} ) ,
pluginConfig : asPluginConfig ( {
tmux : { enabled : true } ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
tmuxSessionManager : {
onEvent : args.onEvent ,
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( {
sessionNotification : args.sessionNotification ,
} ) ,
} )
}
async function flushMicrotasks ( turns : number = 5 ) : Promise < void > {
for ( let index = 0 ; index < turns ; index += 1 ) {
await Promise . resolve ( )
}
}
2026-03-17 15:17:34 +09:00
afterEach ( ( ) = > {
2026-04-07 22:49:37 +09:00
mock . restore ( )
2026-05-15 16:26:57 +09:00
mock . module ( "../shared/tmux" , ( ) = > sharedTmuxSnapshot )
2026-03-17 15:17:34 +09:00
_resetForTesting ( )
} )
2026-02-10 19:09:22 +09:00
2026-04-28 15:29:33 +09:00
describe ( "event error extraction" , ( ) = > {
it ( "prefers nested APIError message over generic top-level message" , async ( ) = > {
const error = {
name : "APIError" ,
message : "Error" ,
data : { message : "Forbidden: Selected provider is forbidden" } ,
}
const result = extractErrorMessage ( error )
expect ( result ) . toBe ( "Forbidden: Selected provider is forbidden" )
} )
} )
2026-04-28 19:09:02 +09:00
describe ( "createEventHandler - idle deduplication" , ( ) = > {
2026-04-28 10:47:36 +09:00
it ( "#given tmux integration enabled #when session.idle arrives #then it forwards the event to tmuxSessionManager.onEvent" , async ( ) = > {
//#given
const onEvent = mock < ( event : EventInput [ "event" ] ) = > void > ( ( ) = > { } )
const idleEvent = {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_tmux_idle" ,
} ,
} ,
}
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( {
directory : "/tmp" ,
client : {
session : { } ,
} ,
} ) ,
pluginConfig : asPluginConfig ( {
tmux : { enabled : true } ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
tmuxSessionManager : {
onEvent ,
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
//#when
await eventHandler ( asEventHandlerInput ( idleEvent ) )
//#then
expect ( onEvent ) . toHaveBeenCalledTimes ( 1 )
expect ( onEvent . mock . calls [ 0 ] ? . [ 0 ] ) . toEqual ( idleEvent . event )
} )
it ( "#given a readiness retry is pending #when session.idle arrives through the plugin handler #then tmux retry spawns the pane" , async ( ) = > {
//#given
const sessionStatusData : Record < string , { type : string } > = { }
const sessionStatusResult = {
data : sessionStatusData ,
}
const spawnTmuxPane = mock ( async ( _sessionId : string ) = > ( {
success : true ,
paneId : "%mock" ,
} ) )
let waitForSessionReadyCallCount = 0
2026-05-15 17:54:43 +09:00
const executeActions = mock ( async ( actions : Array < { type : string ; sessionId : string } > ) = > {
for ( const action of actions ) {
if ( action . type === "spawn" ) {
await spawnTmuxPane ( action . sessionId )
2026-04-28 10:47:36 +09:00
}
2026-05-15 17:54:43 +09:00
}
2026-04-28 10:47:36 +09:00
2026-05-15 17:54:43 +09:00
return {
success : true ,
spawnedPaneId : "%mock" ,
results : [ ] ,
}
} )
const executeAction = mock ( async ( ) = > ( { success : true } ) )
const queryWindowState = mock ( async ( ) = > ( {
windowWidth : 220 ,
windowHeight : 44 ,
mainPane : {
paneId : "%0" ,
width : 110 ,
height : 44 ,
left : 0 ,
top : 0 ,
title : "main" ,
isActive : true ,
2026-04-28 10:47:36 +09:00
} ,
2026-05-15 17:54:43 +09:00
agentPanes : [ ] ,
2026-04-28 10:47:36 +09:00
} ) )
2026-05-15 17:54:43 +09:00
const waitForSessionReady = mock ( async ( ) = > {
waitForSessionReadyCallCount += 1
if ( waitForSessionReadyCallCount === 1 ) {
throw new Error ( "session readiness timed out" )
}
2026-04-28 10:47:36 +09:00
2026-05-15 17:54:43 +09:00
return true
} )
2026-04-28 10:47:36 +09:00
2026-05-15 17:54:43 +09:00
const { TmuxSessionManager } = await import ( ` ../features/tmux-subagent/manager?test= ${ crypto . randomUUID ( ) } ` )
2026-04-28 10:47:36 +09:00
const managerContext = asPluginInput ( {
serverUrl : new URL ( "http://localhost:4096" ) ,
directory : "/tmp" ,
project : "/tmp" ,
worktree : "/tmp" ,
$ : { } ,
client : {
session : {
status : async ( ) = > sessionStatusResult ,
messages : async ( ) = > ( { data : [ ] } ) ,
} ,
} ,
} )
const manager = new TmuxSessionManager ( managerContext , {
enabled : true ,
isolation : "inline" ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 80 ,
agent_pane_min_width : 40 ,
2026-05-15 17:54:43 +09:00
} , {
isInsideTmux : ( ) = > true ,
getCurrentPaneId : ( ) = > "%0" ,
queryWindowState ,
waitForSessionReady ,
executeActions ,
executeAction ,
log : ( ) = > { } ,
2026-04-28 10:47:36 +09:00
} )
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( {
directory : "/tmp" ,
client : {
session : { } ,
} ,
} ) ,
pluginConfig : asPluginConfig ( {
tmux : { enabled : true } ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
tmuxSessionManager : manager ,
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
//#when
await manager . onSessionCreated ( {
type : "session.created" ,
properties : {
info : {
id : "ses_retry_via_plugin" ,
parentID : "ses_parent" ,
title : "Retry Via Plugin Event" ,
} ,
} ,
} )
//#then
expect ( spawnTmuxPane ) . toHaveBeenCalledTimes ( 0 )
//#when
sessionStatusData . ses_retry_via_plugin = { type : "idle" }
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_retry_via_plugin" ,
} ,
} ,
} ) )
await flushMicrotasks ( 20 )
2026-05-15 17:54:43 +09:00
await waitUntil ( ( ) = > spawnTmuxPane . mock . calls . length === 1 )
2026-04-28 10:47:36 +09:00
//#then
expect ( spawnTmuxPane ) . toHaveBeenCalledTimes ( 1 )
} )
2026-05-10 14:54:01 +09:00
it ( "does NOT dedup real-idle-after-synthetic-idle within 500ms" , async ( ) = > {
2026-04-28 10:47:36 +09:00
//#given
2026-02-10 19:09:22 +09:00
const dispatchCalls : EventInput [ ] = [ ]
2026-04-12 02:30:11 +09:00
const eventHandler = createIdleTrackingEventHandler ( dispatchCalls )
2026-02-10 19:09:22 +09:00
const sessionId = "ses_test123"
2026-04-12 02:30:11 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-02-10 19:09:22 +09:00
event : {
type : "session.status" ,
properties : {
sessionID : sessionId ,
status : { type : "idle" } ,
} ,
} ,
2026-04-12 02:30:11 +09:00
} ) )
await eventHandler ( asEventHandlerInput ( {
2026-02-10 19:09:22 +09:00
event : {
type : "session.idle" ,
properties : {
sessionID : sessionId ,
} ,
} ,
2026-04-12 02:30:11 +09:00
} ) )
2026-04-28 10:47:36 +09:00
//#then
2026-05-10 14:54:01 +09:00
expect ( dispatchCalls ) . toHaveLength ( 2 )
2026-04-12 02:30:11 +09:00
expect ( dispatchCalls [ 0 ] ? . event . type ) . toBe ( "session.idle" )
expect ( ( dispatchCalls [ 0 ] ? . event . properties as { sessionID? : string } | undefined ) ? . sessionID ) . toBe ( sessionId )
2026-05-10 14:54:01 +09:00
expect ( dispatchCalls [ 1 ] ? . event . type ) . toBe ( "session.idle" )
expect ( ( dispatchCalls [ 1 ] ? . event . properties as { sessionID? : string } | undefined ) ? . sessionID ) . toBe ( sessionId )
2026-02-10 19:09:22 +09:00
} )
2026-05-17 15:08:39 +09:00
it ( "#given idle recovery handles an interrupted tool turn #when session.idle arrives #then later idle hooks are skipped for that event" , async ( ) = > {
const callOrder : string [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp" } ) ,
pluginConfig : asPluginConfig ( { } ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
sessionRecovery : {
handleInterruptedToolResultsOnIdle : async ( ) = > {
callOrder . push ( "sessionRecovery" )
return true
} ,
} ,
todoContinuationEnforcer : {
handler : async ( ) = > {
callOrder . push ( "todoContinuationEnforcer" )
} ,
} ,
} ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : { sessionID : "ses_interrupted_idle" } ,
} ,
} ) )
expect ( callOrder ) . toEqual ( [ "sessionRecovery" ] )
} )
2026-05-17 15:42:58 +09:00
it ( "#given idle recovery handles an interrupted tool turn #when session.status normalizes to idle #then synthetic idle hooks are skipped" , async ( ) = > {
const callOrder : string [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp" } ) ,
pluginConfig : asPluginConfig ( { } ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
sessionRecovery : {
handleInterruptedToolResultsOnIdle : async ( ) = > {
callOrder . push ( "sessionRecovery" )
return true
} ,
} ,
2026-05-17 16:17:36 +09:00
backgroundNotificationHook : {
event : async ( ) = > {
callOrder . push ( "backgroundNotificationHook" )
} ,
} ,
2026-05-17 15:42:58 +09:00
todoContinuationEnforcer : {
handler : async ( input : EventInput ) = > {
if ( input . event . type === "session.idle" ) {
callOrder . push ( "todoContinuationEnforcer" )
}
} ,
} ,
} ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.status" ,
properties : {
sessionID : "ses_interrupted_status_idle" ,
status : { type : "idle" } ,
} ,
} ,
} ) )
expect ( callOrder ) . toEqual ( [ "sessionRecovery" ] )
} )
2026-05-17 16:17:36 +09:00
it ( "#given idle recovery handles a real idle #when another real idle arrives immediately #then dedup state does not suppress the later idle" , async ( ) = > {
//#given
const originalDateNow = Date . now
Date . now = ( ) = > 40 _000
const dispatchCalls : EventInput [ ] = [ ]
let recoveryCalls = 0
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp" } ) ,
pluginConfig : asPluginConfig ( { } ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
sessionRecovery : {
handleInterruptedToolResultsOnIdle : async ( ) = > {
recoveryCalls += 1
return recoveryCalls === 1
} ,
} ,
autoUpdateChecker : {
event : async ( input : EventInput ) = > {
if ( input . event . type === "session.idle" ) {
dispatchCalls . push ( input )
}
} ,
} ,
} ) ,
} )
try {
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : { sessionID : "ses_recovered_then_real" } ,
} ,
} ) )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : { sessionID : "ses_recovered_then_real" } ,
} ,
} ) )
//#then
expect ( recoveryCalls ) . toBe ( 2 )
expect ( dispatchCalls ) . toHaveLength ( 1 )
expect ( ( dispatchCalls [ 0 ] ? . event . properties as { sessionID? : string } | undefined ) ? . sessionID ) . toBe (
"ses_recovered_then_real" ,
)
} finally {
Date . now = originalDateNow
}
} )
it ( "#given idle recovery handles a real idle #when a synthetic idle arrives immediately #then dedup state does not suppress the synthetic idle" , async ( ) = > {
//#given
const originalDateNow = Date . now
Date . now = ( ) = > 50 _000
const dispatchCalls : EventInput [ ] = [ ]
let recoveryCalls = 0
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp" } ) ,
pluginConfig : asPluginConfig ( { } ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
sessionRecovery : {
handleInterruptedToolResultsOnIdle : async ( ) = > {
recoveryCalls += 1
return recoveryCalls === 1
} ,
} ,
autoUpdateChecker : {
event : async ( input : EventInput ) = > {
if ( input . event . type === "session.idle" ) {
dispatchCalls . push ( input )
}
} ,
} ,
} ) ,
} )
try {
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : { sessionID : "ses_recovered_then_synthetic" } ,
} ,
} ) )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.status" ,
properties : {
sessionID : "ses_recovered_then_synthetic" ,
status : { type : "idle" } ,
} ,
} ,
} ) )
//#then
expect ( recoveryCalls ) . toBe ( 2 )
expect ( dispatchCalls ) . toHaveLength ( 1 )
expect ( ( dispatchCalls [ 0 ] ? . event . properties as { sessionID? : string } | undefined ) ? . sessionID ) . toBe (
"ses_recovered_then_synthetic" ,
)
} finally {
Date . now = originalDateNow
}
} )
2026-05-11 08:45:34 +09:00
it ( "keeps other session dedup state untouched when bypassing synthetic-idle for current session" , async ( ) = > {
//#given
const originalDateNow = Date . now
let currentNow = 30 _000
Date . now = ( ) = > currentNow
const dispatchedSessionIds : string [ ] = [ ]
const eventHandler = createIdleDedupSpyEventHandler ( {
onEvent : ( ) = > { } ,
sessionNotification : async ( input : EventInput ) = > {
if ( input . event . type !== "session.idle" ) {
return
}
const props = input . event . properties as { sessionID? : string } | undefined
if ( props ? . sessionID ) {
dispatchedSessionIds . push ( props . sessionID )
}
} ,
} )
try {
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.status" ,
properties : {
sessionID : "ses_a" ,
status : { type : "idle" } ,
} ,
} ,
} ) )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_b" ,
} ,
} ,
} ) )
currentNow += 100
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_a" ,
} ,
} ,
} ) )
currentNow += 100
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_b" ,
} ,
} ,
} ) )
//#then
expect ( dispatchedSessionIds ) . toEqual ( [ "ses_a" , "ses_b" , "ses_a" ] )
} finally {
Date . now = originalDateNow
}
} )
2026-04-28 10:47:36 +09:00
it ( "dedups back-to-back real session.idle events for the same sessionID within 500ms" , async ( ) = > {
//#given
const originalDateNow = Date . now
let currentNow = 10 _000
Date . now = ( ) = > currentNow
const onEvent = mock < ( event : EventInput [ "event" ] ) = > void > ( ( ) = > { } )
const sessionNotification = mock ( async ( _input : EventInput ) = > { } )
const eventHandler = createIdleDedupSpyEventHandler ( {
onEvent ,
sessionNotification ,
} )
const sessionId = "ses_same_idle"
try {
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : sessionId ,
} ,
} ,
} ) )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : sessionId ,
} ,
} ,
} ) )
//#then
expect ( onEvent ) . toHaveBeenCalledTimes ( 1 )
expect ( sessionNotification ) . toHaveBeenCalledTimes ( 1 )
//#when
currentNow += 501
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : sessionId ,
} ,
} ,
} ) )
//#then
expect ( onEvent ) . toHaveBeenCalledTimes ( 2 )
expect ( sessionNotification ) . toHaveBeenCalledTimes ( 2 )
} finally {
Date . now = originalDateNow
}
} )
it ( "still dedups synthetic-idle-after-real-idle as before" , async ( ) = > {
//#given
2026-02-10 19:09:22 +09:00
const dispatchCalls : EventInput [ ] = [ ]
2026-04-12 02:30:11 +09:00
const eventHandler = createIdleTrackingEventHandler ( dispatchCalls )
2026-02-10 19:09:22 +09:00
const sessionId = "ses_test456"
2026-04-12 02:30:11 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-02-10 19:09:22 +09:00
event : {
type : "session.idle" ,
properties : {
sessionID : sessionId ,
} ,
} ,
2026-04-12 02:30:11 +09:00
} ) )
await eventHandler ( asEventHandlerInput ( {
2026-02-10 19:09:22 +09:00
event : {
type : "session.status" ,
properties : {
sessionID : sessionId ,
status : { type : "idle" } ,
} ,
} ,
2026-04-12 02:30:11 +09:00
} ) )
expect ( dispatchCalls ) . toHaveLength ( 1 )
expect ( dispatchCalls [ 0 ] ? . event . type ) . toBe ( "session.idle" )
expect ( ( dispatchCalls [ 0 ] ? . event . properties as { sessionID? : string } | undefined ) ? . sessionID ) . toBe ( sessionId )
2026-02-10 19:09:22 +09:00
} )
2026-04-28 10:47:36 +09:00
it ( "does NOT dedup session.idle events for DIFFERENT sessionIDs" , async ( ) = > {
//#given
const originalDateNow = Date . now
let currentNow = 20 _000
Date . now = ( ) = > currentNow
const onEvent = mock < ( event : EventInput [ "event" ] ) = > void > ( ( ) = > { } )
const sessionNotification = mock ( async ( _input : EventInput ) = > { } )
const eventHandler = createIdleDedupSpyEventHandler ( {
onEvent ,
sessionNotification ,
} )
try {
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_first_idle" ,
} ,
} ,
} ) )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_second_idle" ,
} ,
} ,
} ) )
//#then
expect ( onEvent ) . toHaveBeenCalledTimes ( 2 )
expect ( sessionNotification ) . toHaveBeenCalledTimes ( 2 )
} finally {
Date . now = originalDateNow
}
} )
it ( "both maps pruned on every event" , async ( ) = > {
//#given
2026-02-10 19:09:22 +09:00
const eventHandler = createEventHandler ( {
2026-05-01 02:11:39 +09:00
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-02-10 19:09:22 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-05-01 02:11:39 +09:00
managers : createEventHandlerManagers ( {
2026-02-10 19:09:22 +09:00
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
2026-05-01 02:11:39 +09:00
} ) ,
hooks : createEventHandlerHooks ( {
2026-02-10 19:09:22 +09:00
autoUpdateChecker : { event : async ( ) = > { } } ,
claudeCodeHooks : { event : async ( ) = > { } } ,
backgroundNotificationHook : { event : async ( ) = > { } } ,
sessionNotification : async ( ) = > { } ,
todoContinuationEnforcer : { handler : async ( ) = > { } } ,
unstableAgentBabysitter : { event : async ( ) = > { } } ,
contextWindowMonitor : { event : async ( ) = > { } } ,
directoryAgentsInjector : { event : async ( ) = > { } } ,
directoryReadmeInjector : { event : async ( ) = > { } } ,
rulesInjector : { event : async ( ) = > { } } ,
thinkMode : { event : async ( ) = > { } } ,
anthropicContextWindowLimitRecovery : { event : async ( ) = > { } } ,
agentUsageReminder : { event : async ( ) = > { } } ,
categorySkillReminder : { event : async ( ) = > { } } ,
interactiveBashSession : { event : async ( ) = > { } } ,
ralphLoop : { event : async ( ) = > { } } ,
stopContinuationGuard : { event : async ( ) = > { } } ,
compactionTodoPreserver : { event : async ( ) = > { } } ,
atlasHook : { handler : async ( ) = > { } } ,
2026-05-01 02:11:39 +09:00
} ) ,
2026-02-10 19:09:22 +09:00
} )
await eventHandler ( {
event : {
type : "session.status" ,
properties : {
sessionID : "ses_stale_1" ,
status : { type : "idle" } ,
} ,
} ,
} )
await eventHandler ( {
event : {
type : "session.status" ,
properties : {
sessionID : "ses_stale_2" ,
status : { type : "idle" } ,
} ,
} ,
} )
await eventHandler ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_stale_3" ,
} ,
} ,
} )
await eventHandler ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_stale_4" ,
} ,
} ,
} )
2026-04-28 19:09:02 +09:00
await wait ( 600 )
2026-02-10 19:09:22 +09:00
2026-05-01 02:11:39 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-02-10 19:09:22 +09:00
event : {
type : "message.updated" ,
} ,
2026-05-01 02:11:39 +09:00
} ) )
2026-02-10 19:09:22 +09:00
const dispatchCalls : EventInput [ ] = [ ]
const eventHandlerWithMock = createEventHandler ( {
2026-05-01 02:11:39 +09:00
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-02-10 19:09:22 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-05-01 02:11:39 +09:00
managers : createEventHandlerManagers ( {
2026-02-10 19:09:22 +09:00
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
2026-05-01 02:11:39 +09:00
} ) ,
hooks : createEventHandlerHooks ( {
2026-02-10 19:09:22 +09:00
autoUpdateChecker : {
event : async ( input : EventInput ) = > {
dispatchCalls . push ( input )
} ,
} ,
claudeCodeHooks : { event : async ( ) = > { } } ,
backgroundNotificationHook : { event : async ( ) = > { } } ,
sessionNotification : async ( ) = > { } ,
todoContinuationEnforcer : { handler : async ( ) = > { } } ,
unstableAgentBabysitter : { event : async ( ) = > { } } ,
contextWindowMonitor : { event : async ( ) = > { } } ,
directoryAgentsInjector : { event : async ( ) = > { } } ,
directoryReadmeInjector : { event : async ( ) = > { } } ,
rulesInjector : { event : async ( ) = > { } } ,
thinkMode : { event : async ( ) = > { } } ,
anthropicContextWindowLimitRecovery : { event : async ( ) = > { } } ,
agentUsageReminder : { event : async ( ) = > { } } ,
categorySkillReminder : { event : async ( ) = > { } } ,
interactiveBashSession : { event : async ( ) = > { } } ,
ralphLoop : { event : async ( ) = > { } } ,
stopContinuationGuard : { event : async ( ) = > { } } ,
compactionTodoPreserver : { event : async ( ) = > { } } ,
atlasHook : { handler : async ( ) = > { } } ,
2026-05-01 02:11:39 +09:00
} ) ,
2026-02-10 19:09:22 +09:00
} )
await eventHandlerWithMock ( {
event : {
type : "session.idle" ,
properties : {
sessionID : "ses_stale_1" ,
} ,
} ,
} )
expect ( dispatchCalls . length ) . toBe ( 1 )
expect ( dispatchCalls [ 0 ] . event . type ) . toBe ( "session.idle" )
} )
2026-04-28 19:09:02 +09:00
it ( "dispatches both idle events once the dedup window expires" , async ( ) = > {
2026-02-10 19:09:22 +09:00
const dispatchCalls : EventInput [ ] = [ ]
const eventHandler = createEventHandler ( {
2026-05-01 02:11:39 +09:00
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-02-10 19:09:22 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-05-01 02:11:39 +09:00
managers : createEventHandlerManagers ( {
2026-02-10 19:09:22 +09:00
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
2026-05-01 02:11:39 +09:00
} ) ,
hooks : createEventHandlerHooks ( {
2026-02-10 19:09:22 +09:00
autoUpdateChecker : {
event : async ( input : EventInput ) = > {
if ( input . event . type === "session.idle" ) {
dispatchCalls . push ( input )
}
} ,
} ,
claudeCodeHooks : { event : async ( ) = > { } } ,
backgroundNotificationHook : { event : async ( ) = > { } } ,
sessionNotification : async ( ) = > { } ,
todoContinuationEnforcer : { handler : async ( ) = > { } } ,
unstableAgentBabysitter : { event : async ( ) = > { } } ,
contextWindowMonitor : { event : async ( ) = > { } } ,
directoryAgentsInjector : { event : async ( ) = > { } } ,
directoryReadmeInjector : { event : async ( ) = > { } } ,
rulesInjector : { event : async ( ) = > { } } ,
thinkMode : { event : async ( ) = > { } } ,
anthropicContextWindowLimitRecovery : { event : async ( ) = > { } } ,
agentUsageReminder : { event : async ( ) = > { } } ,
categorySkillReminder : { event : async ( ) = > { } } ,
interactiveBashSession : { event : async ( ) = > { } } ,
ralphLoop : { event : async ( ) = > { } } ,
stopContinuationGuard : { event : async ( ) = > { } } ,
compactionTodoPreserver : { event : async ( ) = > { } } ,
atlasHook : { handler : async ( ) = > { } } ,
2026-05-01 02:11:39 +09:00
} ) ,
2026-02-10 19:09:22 +09:00
} )
const sessionId = "ses_outside_window"
await eventHandler ( {
event : {
type : "session.status" ,
properties : {
sessionID : sessionId ,
status : { type : "idle" } ,
} ,
} ,
} )
expect ( dispatchCalls . length ) . toBe ( 1 )
2026-04-28 19:09:02 +09:00
await wait ( 600 )
2026-02-10 19:09:22 +09:00
await eventHandler ( {
event : {
type : "session.idle" ,
properties : {
sessionID : sessionId ,
} ,
} ,
} )
expect ( dispatchCalls . length ) . toBe ( 2 )
expect ( dispatchCalls [ 0 ] . event . type ) . toBe ( "session.idle" )
expect ( dispatchCalls [ 1 ] . event . type ) . toBe ( "session.idle" )
} )
} )
2026-02-18 16:48:48 -03:00
describe ( "createEventHandler - event forwarding" , ( ) = > {
2026-04-04 18:48:03 +09:00
it ( "forwards message activity events to tmux session manager" , async ( ) = > {
const forwardedEvents : EventInput [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
2026-04-05 13:27:50 +09:00
pluginConfig : asPluginConfig ( {
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
2026-04-04 18:48:03 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onEvent : ( event : EventInput [ "event" ] ) = > {
forwardedEvents . push ( { event } )
} ,
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "message.part.delta" ,
properties : { sessionID : "ses_tmux_activity" , field : "text" , delta : "x" } ,
} ,
} ) )
expect ( forwardedEvents . length ) . toBe ( 1 )
expect ( forwardedEvents [ 0 ] ? . event . type ) . toBe ( "message.part.delta" )
} )
2026-05-12 11:48:18 +09:00
it ( "forwards legacy message.part.updated activity with part-only session id to tmux session manager" , async ( ) = > {
const forwardedEvents : EventInput [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( {
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onEvent : ( event : EventInput [ "event" ] ) = > {
forwardedEvents . push ( { event } )
} ,
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "message.part.updated" ,
properties : {
part : {
id : "part-1" ,
messageID : "msg-1" ,
sessionID : "ses_tmux_part_only" ,
type : "text" ,
text : "x" ,
} ,
} ,
} ,
} ) )
expect ( forwardedEvents . length ) . toBe ( 1 )
expect ( forwardedEvents [ 0 ] ? . event . type ) . toBe ( "message.part.updated" )
} )
2026-04-05 13:27:50 +09:00
it ( "does not forward tmux activity events when tmux integration is disabled" , async ( ) = > {
const forwardedEvents : EventInput [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( {
tmux : {
enabled : false ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onEvent : ( event : EventInput [ "event" ] ) = > {
forwardedEvents . push ( { event } )
} ,
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "message.part.delta" ,
properties : { sessionID : "ses_tmux_disabled" , field : "text" , delta : "x" } ,
} ,
} ) )
expect ( forwardedEvents ) . toHaveLength ( 0 )
} )
it ( "does not forward session.created to tmux session manager when tmux integration is disabled" , async ( ) = > {
const createdSessions : string [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( {
tmux : {
enabled : false ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onSessionCreated : async ( event : { properties ? : { info ? : { id? : string } } } ) = > {
const sessionId = event . properties ? . info ? . id
if ( sessionId ) {
createdSessions . push ( sessionId )
}
} ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
properties : { info : { id : "ses_tmux_disabled" , parentID : "ses_parent" } } ,
} ,
} ) )
expect ( createdSessions ) . toHaveLength ( 0 )
} )
2026-04-28 10:47:36 +09:00
it ( "skips tmux dispatch for subagent sessions marked only via subagentSessions (no parentID)" , async ( ) = > {
//#given
type SessionCreatedEvent = {
type ? : string
properties ? : {
info ? : {
id? : string
parentID? : string
title? : string
}
}
}
const onSessionCreated = mock ( async ( event : SessionCreatedEvent ) = > event )
subagentSessions . add ( "ses_marked_subagent" )
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( {
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onSessionCreated ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
properties : { info : { id : "ses_marked_subagent" , title : "Child" } } ,
} ,
} ) )
//#then
expect ( onSessionCreated ) . not . toHaveBeenCalled ( )
} )
it ( "still dispatches for a primary session not in subagentSessions" , async ( ) = > {
//#given
type SessionCreatedEvent = {
type ? : string
properties ? : {
info ? : {
id? : string
parentID? : string
title? : string
}
}
}
const onSessionCreated = mock ( async ( event : SessionCreatedEvent ) = > event )
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( {
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onSessionCreated ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
properties : { info : { id : "ses_primary" , title : "Primary" } } ,
} ,
} ) )
//#then
expect ( onSessionCreated ) . toHaveBeenCalledTimes ( 1 )
expect ( onSessionCreated ) . toHaveBeenCalledWith ( {
type : "session.created" ,
properties : { info : { id : "ses_primary" , title : "Primary" } } ,
} )
} )
it ( "Path A skips dispatch even when subagentSessions Set is populated only AFTER the event arrives (parentID covers it)" , async ( ) = > {
//#given
type SessionCreatedEvent = {
type ? : string
properties ? : {
info ? : {
id? : string
parentID? : string
title? : string
}
}
}
const onSessionCreated = mock ( async ( event : SessionCreatedEvent ) = > event )
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { } ) ,
pluginConfig : asPluginConfig ( {
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onSessionCreated ,
onSessionDeleted : async ( ) = > { } ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
//#when
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
properties : { info : { id : "ses_parent_marked" , parentID : "ses_parent" , title : "Child" } } ,
} ,
} ) )
//#then
expect ( onSessionCreated ) . not . toHaveBeenCalled ( )
//#when
subagentSessions . add ( "ses_parent_marked" )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
properties : { info : { id : "ses_parent_marked" , title : "Child" } } ,
} ,
} ) )
//#then
expect ( onSessionCreated ) . not . toHaveBeenCalled ( )
} )
2026-04-10 18:47:18 +09:00
it ( "dispatches OpenClaw after session.created for main sessions (no parentID)" , async ( ) = > {
2026-04-28 10:47:36 +09:00
//#given
const openClawSpy = spyOn ( openclawRuntimeDispatch , "dispatchOpenClawEvent" )
openClawSpy . mockResolvedValue ( null )
2026-04-07 22:49:37 +09:00
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp/project-created" } ) ,
pluginConfig : asPluginConfig ( {
openclaw : { enabled : true , gateways : { } , hooks : { } } ,
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : { disconnectSession : async ( ) = > { } } ,
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
getTrackedPaneId : ( sessionID : string ) = > ( sessionID === "ses_openclaw_created" ? "%9" : undefined ) ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
2026-04-10 18:47:18 +09:00
properties : { info : { id : "ses_openclaw_created" } } ,
2026-04-07 22:49:37 +09:00
} ,
} ) )
2026-04-28 10:47:36 +09:00
//#then - OpenClaw dispatch called for main session
const call = openClawSpy . mock . calls [ 0 ] ? . [ 0 ] as
| {
rawEvent? : string
context ? : { sessionId? : string ; projectPath? : string ; tmuxPaneId? : string }
}
| undefined
expect ( call ? . rawEvent ) . toBe ( "session.created" )
expect ( call ? . context ) . toEqual ( {
sessionId : "ses_openclaw_created" ,
projectPath : "/tmp/project-created" ,
tmuxPaneId : "%9" ,
2026-04-07 22:49:37 +09:00
} )
} )
2026-04-28 10:47:36 +09:00
it ( "does NOT dispatch OpenClaw for subagent sessions (with parentID)" , async ( ) = > {
//#given
const openClawSpy = spyOn ( openclawRuntimeDispatch , "dispatchOpenClawEvent" )
openClawSpy . mockResolvedValue ( null )
2026-04-10 18:47:18 +09:00
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp/project-created" } ) ,
pluginConfig : asPluginConfig ( {
openclaw : { enabled : true , gateways : { } , hooks : { } } ,
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : { disconnectSession : async ( ) = > { } } ,
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
getTrackedPaneId : ( sessionID : string ) = > ( sessionID === "ses_subagent" ? "%10" : undefined ) ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.created" ,
properties : { info : { id : "ses_subagent" , parentID : "ses_parent" } } ,
} ,
} ) )
expect ( openClawSpy . mock . calls . length ) . toBe ( 0 )
} )
2026-02-18 16:48:48 -03:00
it ( "forwards session.deleted to write-existing-file-guard hook" , async ( ) = > {
const forwardedEvents : EventInput [ ] = [ ]
const disconnectedSessions : string [ ] = [ ]
const deletedSessions : string [ ] = [ ]
const eventHandler = createEventHandler ( {
ctx : { } as never ,
2026-04-05 13:27:50 +09:00
pluginConfig : asPluginConfig ( {
tmux : {
enabled : true ,
layout : "main-vertical" ,
main_pane_size : 60 ,
main_pane_min_width : 120 ,
agent_pane_min_width : 40 ,
isolation : "inline" ,
} ,
} ) ,
2026-02-18 16:48:48 -03:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : {
skillMcpManager : {
disconnectSession : async ( sessionID : string ) = > {
disconnectedSessions . push ( sessionID )
} ,
} ,
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( { sessionID } : { sessionID : string } ) = > {
deletedSessions . push ( sessionID )
} ,
} ,
} as never ,
hooks : {
writeExistingFileGuard : {
event : async ( input : EventInput ) = > {
forwardedEvents . push ( input )
} ,
} ,
} as never ,
} )
const sessionID = "ses_forward_delete_event"
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-02-18 16:48:48 -03:00
event : {
type : "session.deleted" ,
properties : { info : { id : sessionID } } ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-02-18 16:48:48 -03:00
expect ( forwardedEvents . length ) . toBe ( 1 )
expect ( forwardedEvents [ 0 ] ? . event . type ) . toBe ( "session.deleted" )
expect ( disconnectedSessions ) . toEqual ( [ sessionID ] )
expect ( deletedSessions ) . toEqual ( [ sessionID ] )
} )
2026-03-18 14:21:27 +01:00
2026-04-07 22:49:37 +09:00
it ( "dispatches OpenClaw for synthetic session.idle events" , async ( ) = > {
2026-04-28 10:47:36 +09:00
const openClawSpy = spyOn ( openclawRuntimeDispatch , "dispatchOpenClawEvent" )
openClawSpy . mockResolvedValue ( null )
2026-04-07 22:49:37 +09:00
const eventHandler = createEventHandler ( {
ctx : asEventHandlerContext ( { directory : "/tmp/project-idle" } ) ,
pluginConfig : asPluginConfig ( { openclaw : { enabled : true , gateways : { } , hooks : { } } } ) ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : createEventHandlerManagers ( {
skillMcpManager : { disconnectSession : async ( ) = > { } } ,
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
getTrackedPaneId : ( sessionID : string ) = > ( sessionID === "ses_openclaw_idle" ? "%3" : undefined ) ,
} ,
} ) ,
hooks : createEventHandlerHooks ( { } ) ,
} )
await eventHandler ( asEventHandlerInput ( {
event : {
type : "session.status" ,
properties : { sessionID : "ses_openclaw_idle" , status : { type : "idle" } } ,
} ,
} ) )
2026-04-28 10:47:36 +09:00
const call = openClawSpy . mock . calls [ 0 ] ? . [ 0 ] as
| {
rawEvent? : string
context ? : { sessionId? : string ; projectPath? : string ; tmuxPaneId? : string }
}
| undefined
expect ( call ? . rawEvent ) . toBe ( "session.idle" )
expect ( call ? . context ) . toEqual ( {
sessionId : "ses_openclaw_idle" ,
projectPath : "/tmp/project-idle" ,
tmuxPaneId : "%3" ,
2026-04-07 22:49:37 +09:00
} )
} )
2026-03-18 14:21:27 +01:00
it ( "clears stored prompt params on session.deleted" , async ( ) = > {
const eventHandler = createEventHandler ( {
ctx : { } as never ,
pluginConfig : { } as never ,
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
managers : {
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
tmuxSessionManager : {
onSessionCreated : async ( ) = > { } ,
onSessionDeleted : async ( ) = > { } ,
} ,
} as never ,
hooks : { } as never ,
} )
const sessionID = "ses_prompt_params_deleted"
setSessionPromptParams ( sessionID , {
temperature : 0.4 ,
topP : 0.7 ,
options : { reasoningEffort : "high" } ,
} )
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-03-18 14:21:27 +01:00
event : {
type : "session.deleted" ,
properties : { info : { id : sessionID } } ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-03-18 14:21:27 +01:00
expect ( getSessionPromptParams ( sessionID ) ) . toBeUndefined ( )
} )
2026-02-18 16:48:48 -03:00
} )
2026-03-17 15:17:34 +09:00
describe ( "createEventHandler - retry dedupe lifecycle" , ( ) = > {
it ( "re-handles same retry key after session recovers to idle status" , async ( ) = > {
const sessionID = "ses_retry_recovery_rearm"
setMainSession ( sessionID )
const abortCalls : string [ ] = [ ]
const promptCalls : string [ ] = [ ]
const modelFallback = createModelFallbackHook ( )
2026-04-18 02:35:46 +09:00
clearPendingModelFallback ( modelFallback , sessionID )
2026-03-17 15:17:34 +09:00
const eventHandler = createEventHandler ( {
2026-04-03 17:37:17 +09:00
ctx : asEventHandlerContext ( {
2026-03-17 15:17:34 +09:00
directory : "/tmp" ,
client : {
session : {
abort : async ( { path } : { path : { id : string } } ) = > {
abortCalls . push ( path . id )
return { }
} ,
prompt : async ( { path } : { path : { id : string } } ) = > {
promptCalls . push ( path . id )
return { }
} ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-03-17 15:17:34 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-04-03 17:37:17 +09:00
managers : createEventHandlerManagers ( {
2026-03-17 15:17:34 +09:00
skillMcpManager : {
disconnectSession : async ( ) = > { } ,
} ,
2026-04-03 17:37:17 +09:00
} ) ,
hooks : createEventHandlerHooks ( {
2026-03-17 15:17:34 +09:00
modelFallback ,
stopContinuationGuard : { isStopped : ( ) = > false } ,
2026-04-03 17:37:17 +09:00
} ) ,
2026-03-17 15:17:34 +09:00
} )
const chatMessageHandler = createChatMessageHandler ( {
2026-04-03 17:37:17 +09:00
ctx : asChatMessageHandlerContext ( {
2026-03-17 15:17:34 +09:00
client : {
tui : {
showToast : async ( ) = > ( { } ) ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) ,
pluginConfig : asChatPluginConfig ( { } ) ,
2026-03-17 15:17:34 +09:00
firstMessageVariantGate : {
shouldOverride : ( ) = > false ,
markApplied : ( ) = > { } ,
} ,
2026-04-03 17:37:17 +09:00
hooks : createChatMessageHandlerHooks ( {
2026-03-17 15:17:34 +09:00
modelFallback ,
stopContinuationGuard : null ,
keywordDetector : null ,
claudeCodeHooks : null ,
autoSlashCommand : null ,
startWork : null ,
ralphLoop : null ,
2026-04-03 17:37:17 +09:00
} ) ,
2026-03-17 15:17:34 +09:00
} )
const retryStatus = {
type : "retry" ,
attempt : 1 ,
2026-04-17 14:51:52 +09:00
message : "All credentials for model claude-opus-4-7-thinking are cooling down [retrying in 7m 56s attempt #1]" ,
2026-03-17 15:17:34 +09:00
next : 476 ,
} as const
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-03-17 15:17:34 +09:00
event : {
type : "message.updated" ,
properties : {
info : {
id : "msg_user_retry_rearm" ,
sessionID ,
role : "user" ,
2026-04-17 14:51:52 +09:00
modelID : "claude-opus-4-7-thinking" ,
2026-03-17 15:17:34 +09:00
providerID : "anthropic" ,
2026-04-07 10:08:04 +09:00
agent : "Sisyphus - Ultraworker" ,
2026-03-17 15:17:34 +09:00
} ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
await eventHandler ( asEventHandlerInput ( {
2026-03-17 15:17:34 +09:00
event : {
type : "session.status" ,
properties : {
sessionID ,
status : retryStatus ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-03-17 15:17:34 +09:00
const firstOutput = { message : { } , parts : [ ] as Array < { type : string ; text? : string } > }
await chatMessageHandler (
{
sessionID ,
agent : "sisyphus" ,
2026-04-17 14:51:52 +09:00
model : { providerID : "anthropic" , modelID : "claude-opus-4-7-thinking" } ,
2026-03-17 15:17:34 +09:00
} ,
firstOutput ,
)
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-03-17 15:17:34 +09:00
event : {
type : "session.status" ,
properties : {
sessionID ,
status : { type : "idle" } ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
await eventHandler ( asEventHandlerInput ( {
2026-03-17 15:17:34 +09:00
event : {
type : "session.status" ,
properties : {
sessionID ,
status : retryStatus ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-03-17 15:17:34 +09:00
expect ( abortCalls ) . toEqual ( [ sessionID , sessionID ] )
expect ( promptCalls ) . toEqual ( [ sessionID , sessionID ] )
} )
} )
2026-03-23 18:12:51 +09:00
describe ( "createEventHandler - session recovery compaction" , ( ) = > {
it ( "triggers compaction before sending continue after session error recovery" , async ( ) = > {
const sessionID = "ses_recovery_compaction"
setMainSession ( sessionID )
const callOrder : string [ ] = [ ]
2026-05-13 13:36:25 +09:00
const promptBodies : Array < {
body ? : {
noReply? : boolean
parts? : Array < {
synthetic? : boolean
metadata? : Record < string , unknown >
} >
}
} > = [ ]
2026-03-23 18:12:51 +09:00
const eventHandler = createEventHandler ( {
2026-04-03 17:37:17 +09:00
ctx : asEventHandlerContext ( {
2026-03-23 18:12:51 +09:00
directory : "/tmp" ,
client : {
session : {
abort : async ( ) = > ( { } ) ,
summarize : async ( ) = > {
callOrder . push ( "summarize" )
return { }
} ,
2026-05-13 13:36:25 +09:00
prompt : async ( input : { body ? : { noReply? : boolean ; parts? : Array < { synthetic? : boolean ; metadata? : Record < string , unknown > } > } } ) = > {
2026-03-23 18:12:51 +09:00
callOrder . push ( "prompt" )
2026-05-13 13:36:25 +09:00
promptBodies . push ( input )
2026-03-23 18:12:51 +09:00
return { }
} ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-03-23 18:12:51 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-04-03 17:37:17 +09:00
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
2026-03-23 18:12:51 +09:00
sessionRecovery : {
isRecoverableError : ( ) = > true ,
handleSessionRecovery : async ( ) = > true ,
} ,
stopContinuationGuard : { isStopped : ( ) = > false } ,
2026-04-03 17:37:17 +09:00
} ) ,
2026-03-23 18:12:51 +09:00
} )
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-03-23 18:12:51 +09:00
event : {
type : "session.error" ,
properties : {
sessionID ,
messageID : "msg_123" ,
error : { name : "Error" , message : "tool_result block(s) that are not immediately" } ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-03-23 18:12:51 +09:00
expect ( callOrder ) . toEqual ( [ "summarize" , "prompt" ] )
2026-05-13 13:36:25 +09:00
expect ( promptBodies [ 0 ] ? . body ? . noReply ) . toBeUndefined ( )
expect ( promptBodies [ 0 ] ? . body ? . parts ? . [ 0 ] ? . synthetic ) . toBe ( true )
expect ( promptBodies [ 0 ] ? . body ? . parts ? . [ 0 ] ? . metadata ? . compaction_continue ) . toBe ( true )
2026-03-23 18:12:51 +09:00
} )
it ( "sends continue even if compaction fails" , async ( ) = > {
const sessionID = "ses_recovery_compaction_fail"
setMainSession ( sessionID )
const callOrder : string [ ] = [ ]
2026-05-13 13:36:25 +09:00
const promptBodies : Array < {
body ? : {
noReply? : boolean
parts? : Array < {
synthetic? : boolean
metadata? : Record < string , unknown >
} >
}
} > = [ ]
2026-03-23 18:12:51 +09:00
const eventHandler = createEventHandler ( {
2026-04-03 17:37:17 +09:00
ctx : asEventHandlerContext ( {
2026-03-23 18:12:51 +09:00
directory : "/tmp" ,
client : {
session : {
abort : async ( ) = > ( { } ) ,
summarize : async ( ) = > {
callOrder . push ( "summarize" )
throw new Error ( "compaction failed" )
} ,
2026-05-13 13:36:25 +09:00
prompt : async ( input : { body ? : { noReply? : boolean ; parts? : Array < { synthetic? : boolean ; metadata? : Record < string , unknown > } > } } ) = > {
2026-03-23 18:12:51 +09:00
callOrder . push ( "prompt" )
2026-05-13 13:36:25 +09:00
promptBodies . push ( input )
2026-03-23 18:12:51 +09:00
return { }
} ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-03-23 18:12:51 +09:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-04-03 17:37:17 +09:00
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
2026-03-23 18:12:51 +09:00
sessionRecovery : {
isRecoverableError : ( ) = > true ,
handleSessionRecovery : async ( ) = > true ,
} ,
stopContinuationGuard : { isStopped : ( ) = > false } ,
2026-04-03 17:37:17 +09:00
} ) ,
2026-03-23 18:12:51 +09:00
} )
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-03-23 18:12:51 +09:00
event : {
type : "session.error" ,
properties : {
sessionID ,
messageID : "msg_456" ,
error : { name : "Error" , message : "tool_result block(s) that are not immediately" } ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-03-23 18:12:51 +09:00
expect ( callOrder ) . toEqual ( [ "summarize" , "prompt" ] )
2026-05-13 13:36:25 +09:00
expect ( promptBodies [ 0 ] ? . body ? . noReply ) . toBeUndefined ( )
expect ( promptBodies [ 0 ] ? . body ? . parts ? . [ 0 ] ? . synthetic ) . toBe ( true )
expect ( promptBodies [ 0 ] ? . body ? . parts ? . [ 0 ] ? . metadata ? . compaction_continue ) . toBe ( true )
2026-03-23 18:12:51 +09:00
} )
2026-03-27 09:30:43 +01:00
it ( "continues dispatching later event hooks when an earlier hook throws" , async ( ) = > {
const runtimeFallbackCalls : EventInput [ ] = [ ]
const eventHandler = createEventHandler ( {
2026-04-03 17:37:17 +09:00
ctx : asEventHandlerContext ( {
2026-03-27 09:30:43 +01:00
directory : "/tmp" ,
client : {
session : {
abort : async ( ) = > ( { } ) ,
prompt : async ( ) = > ( { } ) ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) ,
pluginConfig : asPluginConfig ( { } ) ,
2026-03-27 09:30:43 +01:00
firstMessageVariantGate : {
markSessionCreated : ( ) = > { } ,
clear : ( ) = > { } ,
} ,
2026-04-03 17:37:17 +09:00
managers : createEventHandlerManagers ( ) ,
hooks : createEventHandlerHooks ( {
2026-03-27 09:30:43 +01:00
autoUpdateChecker : {
event : async ( ) = > {
throw new Error ( "upstream hook failed" )
} ,
} ,
runtimeFallback : {
event : async ( input : EventInput ) = > {
runtimeFallbackCalls . push ( input )
} ,
} ,
stopContinuationGuard : { isStopped : ( ) = > false } ,
2026-04-03 17:37:17 +09:00
} ) ,
2026-03-27 09:30:43 +01:00
} )
let thrownError : unknown
try {
2026-04-03 17:37:17 +09:00
await eventHandler ( asEventHandlerInput ( {
2026-03-27 09:30:43 +01:00
event : {
type : "session.error" ,
properties : {
sessionID : "ses_hook_isolation" ,
error : { name : "Error" , message : "retry me" } ,
} ,
} ,
2026-04-03 17:37:17 +09:00
} ) )
2026-03-27 09:30:43 +01:00
} catch ( error ) {
thrownError = error
}
expect ( thrownError ) . toBeUndefined ( )
expect ( runtimeFallbackCalls ) . toHaveLength ( 1 )
expect ( runtimeFallbackCalls [ 0 ] ? . event . type ) . toBe ( "session.error" )
} )
2026-03-23 18:12:51 +09:00
} )