2026-04-05 15:34:22 +09:00
/// <reference types="bun-types" />
2026-01-16 11:30:54 +09:00
import { describe , expect , test , beforeEach , afterEach , spyOn } from "bun:test"
2026-01-09 02:24:43 +09:00
import { existsSync , mkdirSync , rmSync , writeFileSync } from "node:fs"
import { join } from "node:path"
2026-03-31 18:52:09 -07:00
import { tmpdir } from "node:os"
2026-02-01 18:33:06 +09:00
import { randomUUID } from "node:crypto"
2026-01-09 02:24:43 +09:00
import { createStartWorkHook } from "./index"
2026-04-05 15:34:22 +09:00
import { createAtlasHook } from "../atlas"
2026-01-09 02:24:43 +09:00
import {
writeBoulderState ,
clearBoulderState ,
2026-02-26 00:40:01 +09:00
readBoulderState ,
2026-01-09 02:24:43 +09:00
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
2026-01-16 11:30:54 +09:00
import * as sessionState from "../../features/claude-code-session-state"
2026-02-26 00:40:01 +09:00
import * as worktreeDetector from "./worktree-detector"
2026-01-09 02:24:43 +09:00
describe ( "start-work hook" , ( ) = > {
2026-02-01 18:33:06 +09:00
let testDir : string
let sisyphusDir : string
2026-01-09 02:24:43 +09:00
function createMockPluginInput() {
return {
2026-02-01 18:33:06 +09:00
directory : testDir ,
2026-01-09 02:24:43 +09:00
client : { } ,
} as Parameters < typeof createStartWorkHook > [ 0 ]
}
2026-03-31 20:02:00 -07:00
function createStartWorkPrompt ( options ? : {
sessionContext? : string
userRequest? : string
} ) : string {
const sessionContext = options ? . sessionContext ? ? ""
const userRequest = options ? . userRequest ? ? ""
return ` <command-instruction>
You are starting a Sisyphus work session.
</command-instruction>
<session-context> ${ sessionContext } </session-context> ${ userRequest ? `
<user-request> ${ userRequest } </user-request> ` : "" } `
}
2026-01-09 02:24:43 +09:00
beforeEach ( ( ) = > {
2026-03-28 15:24:18 +09:00
sessionState . _resetForTesting ( )
sessionState . registerAgentName ( "atlas" )
sessionState . registerAgentName ( "sisyphus" )
2026-02-01 18:33:06 +09:00
testDir = join ( tmpdir ( ) , ` start-work-test- ${ randomUUID ( ) } ` )
sisyphusDir = join ( testDir , ".sisyphus" )
if ( ! existsSync ( testDir ) ) {
mkdirSync ( testDir , { recursive : true } )
2026-01-09 02:24:43 +09:00
}
2026-02-01 18:33:06 +09:00
if ( ! existsSync ( sisyphusDir ) ) {
mkdirSync ( sisyphusDir , { recursive : true } )
2026-01-09 02:24:43 +09:00
}
2026-02-01 18:33:06 +09:00
clearBoulderState ( testDir )
2026-01-09 02:24:43 +09:00
} )
afterEach ( ( ) = > {
2026-03-28 15:24:18 +09:00
sessionState . _resetForTesting ( )
2026-02-01 18:33:06 +09:00
clearBoulderState ( testDir )
if ( existsSync ( testDir ) ) {
rmSync ( testDir , { recursive : true , force : true } )
2026-01-09 02:24:43 +09:00
}
} )
describe ( "chat.message handler" , ( ) = > {
test ( "should ignore non-start-work commands" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook and non-start-work message
2026-01-09 02:24:43 +09:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [ { type : "text" , text : "Just a regular message" } ] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - output should be unchanged
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toBe ( "Just a regular message" )
} )
2026-03-31 20:02:00 -07:00
test ( "should ignore plain session-context blocks without the start-work marker" , async ( ) = > {
// given
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [ { type : "text" , text : "<session-context>Some context here</session-context>" } ] ,
}
// when
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
// then
expect ( output . parts [ 0 ] . text ) . toBe ( "<session-context>Some context here</session-context>" )
expect ( readBoulderState ( testDir ) ) . toBeNull ( )
} )
2026-01-09 02:24:43 +09:00
test ( "should detect start-work command via session-context tag" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook and start-work message
2026-01-09 02:24:43 +09:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { sessionContext : "Some context here" } ) ,
2026-01-09 02:24:43 +09:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - output should be modified with context info
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "---" )
} )
test ( "should inject resume info when existing boulder state found" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - existing boulder state with incomplete plan
2026-02-01 18:33:06 +09:00
const planPath = join ( testDir , "test-plan.md" )
2026-01-09 02:24:43 +09:00
writeFileSync ( planPath , "# Plan\n- [ ] Task 1\n- [x] Task 2" )
const state : BoulderState = {
active_plan : planPath ,
started_at : "2026-01-02T10:00:00Z" ,
session_ids : [ "session-1" ] ,
plan_name : "test-plan" ,
}
2026-02-01 18:33:06 +09:00
writeBoulderState ( testDir , state )
2026-01-09 02:24:43 +09:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-01-09 02:24:43 +09:00
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should show resuming status
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "RESUMING" )
expect ( output . parts [ 0 ] . text ) . toContain ( "test-plan" )
} )
test ( "should replace $SESSION_ID placeholder" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook and message with placeholder
2026-01-09 02:24:43 +09:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { sessionContext : "Session: $SESSION_ID" } ) ,
2026-01-09 02:24:43 +09:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "ses-abc123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - placeholder should be replaced
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "ses-abc123" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "$SESSION_ID" )
} )
test ( "should replace $TIMESTAMP placeholder" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - hook and message with placeholder
2026-01-09 02:24:43 +09:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { sessionContext : "Time: $TIMESTAMP" } ) ,
2026-01-09 02:24:43 +09:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - placeholder should be replaced with ISO timestamp
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . not . toContain ( "$TIMESTAMP" )
expect ( output . parts [ 0 ] . text ) . toMatch ( /\d{4}-\d{2}-\d{2}T/ )
} )
test ( "should auto-select when only one incomplete plan among multiple plans" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - multiple plans but only one incomplete
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-09 02:24:43 +09:00
mkdirSync ( plansDir , { recursive : true } )
// Plan 1: complete (all checked)
const plan1Path = join ( plansDir , "plan-complete.md" )
writeFileSync ( plan1Path , "# Plan Complete\n- [x] Task 1\n- [x] Task 2" )
// Plan 2: incomplete (has unchecked)
const plan2Path = join ( plansDir , "plan-incomplete.md" )
writeFileSync ( plan2Path , "# Plan Incomplete\n- [ ] Task 1\n- [x] Task 2" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-01-09 02:24:43 +09:00
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should auto-select the incomplete plan, not ask user
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "Auto-Selected Plan" )
expect ( output . parts [ 0 ] . text ) . toContain ( "plan-incomplete" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "Multiple Plans Found" )
} )
test ( "should wrap multiple plans message in system-reminder tag" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - multiple incomplete plans
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-09 02:24:43 +09:00
mkdirSync ( plansDir , { recursive : true } )
const plan1Path = join ( plansDir , "plan-a.md" )
writeFileSync ( plan1Path , "# Plan A\n- [ ] Task 1" )
const plan2Path = join ( plansDir , "plan-b.md" )
writeFileSync ( plan2Path , "# Plan B\n- [ ] Task 2" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-01-09 02:24:43 +09:00
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should use system-reminder tag format
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "<system-reminder>" )
expect ( output . parts [ 0 ] . text ) . toContain ( "</system-reminder>" )
expect ( output . parts [ 0 ] . text ) . toContain ( "Multiple Plans Found" )
} )
test ( "should use 'ask user' prompt style for multiple plans" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - multiple incomplete plans
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-09 02:24:43 +09:00
mkdirSync ( plansDir , { recursive : true } )
const plan1Path = join ( plansDir , "plan-x.md" )
writeFileSync ( plan1Path , "# Plan X\n- [ ] Task 1" )
const plan2Path = join ( plansDir , "plan-y.md" )
writeFileSync ( plan2Path , "# Plan Y\n- [ ] Task 2" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-01-09 02:24:43 +09:00
}
2026-02-01 16:47:50 +09:00
// when
2026-01-09 02:24:43 +09:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should prompt agent to ask user, not ask directly
2026-01-09 02:24:43 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "Ask the user" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "Which plan would you like to work on?" )
} )
2026-01-15 16:55:44 +07:00
test ( "should select explicitly specified plan name from user-request, ignoring existing boulder state" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - existing boulder state pointing to old plan
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-15 16:55:44 +07:00
mkdirSync ( plansDir , { recursive : true } )
// Old plan (in boulder state)
const oldPlanPath = join ( plansDir , "old-plan.md" )
writeFileSync ( oldPlanPath , "# Old Plan\n- [ ] Old Task 1" )
// New plan (user wants this one)
const newPlanPath = join ( plansDir , "new-plan.md" )
writeFileSync ( newPlanPath , "# New Plan\n- [ ] New Task 1" )
// Set up stale boulder state pointing to old plan
const staleState : BoulderState = {
active_plan : oldPlanPath ,
started_at : "2026-01-01T10:00:00Z" ,
session_ids : [ "old-session" ] ,
plan_name : "old-plan" ,
}
2026-02-01 18:33:06 +09:00
writeBoulderState ( testDir , staleState )
2026-01-15 16:55:44 +07:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { userRequest : "new-plan" } ) ,
2026-01-15 16:55:44 +07:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when - user explicitly specifies new-plan
2026-01-15 16:55:44 +07:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should select new-plan, NOT resume old-plan
2026-01-15 16:55:44 +07:00
expect ( output . parts [ 0 ] . text ) . toContain ( "new-plan" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "RESUMING" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "old-plan" )
} )
test ( "should strip ultrawork/ulw keywords from plan name argument" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - plan with ultrawork keyword in user-request
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-15 16:55:44 +07:00
mkdirSync ( plansDir , { recursive : true } )
const planPath = join ( plansDir , "my-feature-plan.md" )
writeFileSync ( planPath , "# My Feature Plan\n- [ ] Task 1" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { userRequest : "my-feature-plan ultrawork" } ) ,
2026-01-15 16:55:44 +07:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when - user specifies plan with ultrawork keyword
2026-01-15 16:55:44 +07:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should find plan without ultrawork suffix
2026-01-15 16:55:44 +07:00
expect ( output . parts [ 0 ] . text ) . toContain ( "my-feature-plan" )
expect ( output . parts [ 0 ] . text ) . toContain ( "Auto-Selected Plan" )
} )
test ( "should strip ulw keyword from plan name argument" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - plan with ulw keyword in user-request
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-15 16:55:44 +07:00
mkdirSync ( plansDir , { recursive : true } )
const planPath = join ( plansDir , "api-refactor.md" )
writeFileSync ( planPath , "# API Refactor\n- [ ] Task 1" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { userRequest : "api-refactor ulw" } ) ,
2026-01-15 16:55:44 +07:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-15 16:55:44 +07:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should find plan without ulw suffix
2026-01-15 16:55:44 +07:00
expect ( output . parts [ 0 ] . text ) . toContain ( "api-refactor" )
expect ( output . parts [ 0 ] . text ) . toContain ( "Auto-Selected Plan" )
} )
test ( "should match plan by partial name" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given - user specifies partial plan name
2026-02-01 18:33:06 +09:00
const plansDir = join ( testDir , ".sisyphus" , "plans" )
2026-01-15 16:55:44 +07:00
mkdirSync ( plansDir , { recursive : true } )
const planPath = join ( plansDir , "2026-01-15-feature-implementation.md" )
writeFileSync ( planPath , "# Feature Implementation\n- [ ] Task 1" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
2026-03-31 20:02:00 -07:00
text : createStartWorkPrompt ( { userRequest : "feature-implementation" } ) ,
2026-01-15 16:55:44 +07:00
} ,
] ,
}
2026-02-01 16:47:50 +09:00
// when
2026-01-15 16:55:44 +07:00
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output
)
2026-02-01 16:47:50 +09:00
// then - should find plan by partial match
2026-01-15 16:55:44 +07:00
expect ( output . parts [ 0 ] . text ) . toContain ( "2026-01-15-feature-implementation" )
expect ( output . parts [ 0 ] . text ) . toContain ( "Auto-Selected Plan" )
} )
2026-04-07 19:04:58 +09:00
test ( "should match quoted human-readable plan names to slugged filenames" , async ( ) = > {
// given - saved plan uses a slugged filename
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
const planPath = join ( plansDir , "my-feature-plan.md" )
writeFileSync ( planPath , "# My Feature Plan\n- [ ] Task 1" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
parts : [
{
type : "text" ,
text : createStartWorkPrompt ( { userRequest : "\"my feature plan\"" } ) ,
} ,
] ,
}
// when
await hook [ "chat.message" ] (
{ sessionID : "session-123" } ,
output ,
)
// then
expect ( output . parts [ 0 ] . text ) . toContain ( "my-feature-plan" )
expect ( output . parts [ 0 ] . text ) . toContain ( "Auto-Selected Plan" )
} )
2026-01-09 02:24:43 +09:00
} )
2026-01-16 11:30:54 +09:00
describe ( "session agent management" , ( ) = > {
2026-01-20 19:10:21 +09:00
test ( "should update session agent to Atlas when start-work command is triggered" , async ( ) = > {
2026-02-01 16:47:50 +09:00
// given
2026-01-20 15:23:36 +09:00
const updateSpy = spyOn ( sessionState , "updateSessionAgent" )
2026-01-16 11:30:54 +09:00
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-01-16 11:30:54 +09:00
}
2026-02-01 16:47:50 +09:00
// when
2026-01-16 11:30:54 +09:00
await hook [ "chat.message" ] (
{ sessionID : "ses-prometheus-to-sisyphus" } ,
output
)
2026-02-01 16:47:50 +09:00
// then
2026-01-24 02:39:12 +09:00
expect ( updateSpy ) . toHaveBeenCalledWith ( "ses-prometheus-to-sisyphus" , "atlas" )
2026-01-20 15:23:36 +09:00
updateSpy . mockRestore ( )
2026-01-16 11:30:54 +09:00
} )
2026-03-15 19:04:20 -04:00
2026-04-08 15:55:58 +09:00
test ( "should stamp the outgoing message with Atlas config key so OpenCode can resolve the agent" , async ( ) = > {
2026-03-15 19:04:20 -04:00
// given
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-28 15:24:18 +09:00
message : { } as Record < string , unknown > ,
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-03-15 19:04:20 -04:00
}
// when
await hook [ "chat.message" ] (
{ sessionID : "ses-prometheus-to-atlas" } ,
output
)
2026-04-08 15:55:58 +09:00
// then - config key, not display name (matches no-sisyphus-gpt / boulder-continuation-injector convention)
expect ( output . message . agent ) . toBe ( "atlas" )
2026-03-15 19:04:20 -04:00
} )
2026-03-28 15:24:18 +09:00
2026-04-06 19:55:04 +09:00
test ( "should switch to Atlas even when current session is Sisyphus (regression: #3155)" , async ( ) = > {
// given: user runs /start-work while in a Sisyphus session
// atlas is registered, so /start-work must always hand off to atlas
sessionState . updateSessionAgent ( "ses-sisyphus-to-atlas" , "sisyphus" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
message : { } as Record < string , unknown > ,
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
}
await hook [ "chat.message" ] (
{ sessionID : "ses-sisyphus-to-atlas" } ,
output
)
// atlas is registered in beforeEach, so it must be selected
2026-04-08 15:55:58 +09:00
expect ( output . message . agent ) . toBe ( "atlas" )
2026-04-06 19:55:04 +09:00
expect ( sessionState . getSessionAgent ( "ses-sisyphus-to-atlas" ) ) . toBe ( "atlas" )
} )
2026-03-28 15:24:18 +09:00
test ( "should keep the current agent when Atlas is unavailable" , async ( ) = > {
// given
sessionState . _resetForTesting ( )
sessionState . registerAgentName ( "sisyphus" )
sessionState . updateSessionAgent ( "ses-prometheus-to-sisyphus" , "sisyphus" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
message : { } as Record < string , unknown > ,
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-03-28 15:24:18 +09:00
}
// when
await hook [ "chat.message" ] (
{ sessionID : "ses-prometheus-to-sisyphus" } ,
output
)
// then
2026-04-08 15:55:58 +09:00
expect ( output . message . agent ) . toBe ( "sisyphus" )
2026-03-28 15:24:18 +09:00
expect ( sessionState . getSessionAgent ( "ses-prometheus-to-sisyphus" ) ) . toBe ( "sisyphus" )
} )
2026-03-31 18:52:09 -07:00
test ( "should fall back to Sisyphus instead of keeping Prometheus when Atlas is unavailable" , async ( ) = > {
// given
sessionState . _resetForTesting ( )
sessionState . registerAgentName ( "prometheus" )
sessionState . registerAgentName ( "sisyphus" )
sessionState . updateSessionAgent ( "ses-prometheus-to-worker" , "prometheus" )
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "worker-plan.md" ) , "# Plan\n- [ ] Task 1" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
message : { } as Record < string , unknown > ,
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-03-31 18:52:09 -07:00
}
// when
await hook [ "chat.message" ] (
{ sessionID : "ses-prometheus-to-worker" } ,
output
)
// then
2026-04-08 15:55:58 +09:00
expect ( output . message . agent ) . toBe ( "sisyphus" )
2026-03-31 18:52:09 -07:00
expect ( sessionState . getSessionAgent ( "ses-prometheus-to-worker" ) ) . toBe ( "sisyphus" )
expect ( readBoulderState ( testDir ) ? . agent ) . toBe ( "sisyphus" )
} )
test ( "should rewrite stale Prometheus boulder state to Sisyphus when resuming without Atlas" , async ( ) = > {
// given
sessionState . _resetForTesting ( )
sessionState . registerAgentName ( "prometheus" )
sessionState . registerAgentName ( "sisyphus" )
sessionState . updateSessionAgent ( "ses-prometheus-resume" , "prometheus" )
const planPath = join ( testDir , "resume-plan.md" )
writeFileSync ( planPath , "# Plan\n- [ ] Task 1" )
writeBoulderState ( testDir , {
active_plan : planPath ,
started_at : "2026-01-02T10:00:00Z" ,
session_ids : [ "old-session" ] ,
plan_name : "resume-plan" ,
agent : "prometheus" ,
} )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
message : { } as Record < string , unknown > ,
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-03-31 18:52:09 -07:00
}
// when
await hook [ "chat.message" ] (
{ sessionID : "ses-prometheus-resume" } ,
output
)
// then
2026-04-08 15:55:58 +09:00
expect ( output . message . agent ) . toBe ( "sisyphus" )
2026-03-31 18:52:09 -07:00
expect ( readBoulderState ( testDir ) ? . agent ) . toBe ( "sisyphus" )
} )
2026-04-05 15:34:22 +09:00
test ( "#given start-work hands the session to Atlas #when Atlas later receives session.idle #then the same session continues the selected plan" , async ( ) = > {
// given
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "atlas-plan.md" ) , "# Plan\n- [ ] Task 1\n- [ ] Task 2" )
const promptAsyncMock = spyOn ( {
promptAsync : async ( _request : unknown ) = > undefined ,
} , "promptAsync" )
const ctx = {
directory : testDir ,
client : {
session : {
promptAsync : promptAsyncMock ,
prompt : async ( _request : unknown ) = > undefined ,
messages : async ( ) = > ( { data : [ ] } ) ,
} ,
} ,
} as unknown as Parameters < typeof createAtlasHook > [ 0 ]
const startWorkHook = createStartWorkHook ( ctx )
const atlasHook = createAtlasHook ( ctx )
const output = {
message : { } as Record < string , unknown > ,
parts : [ { type : "text" , text : createStartWorkPrompt ( { userRequest : "atlas-plan" } ) } ] ,
}
// when
await startWorkHook [ "chat.message" ] ( { sessionID : "session-123" } , output )
await atlasHook . handler ( { event : { type : "session.idle" , properties : { sessionID : "session-123" } } } )
// then
2026-04-08 15:55:58 +09:00
expect ( output . message . agent ) . toBe ( "atlas" )
2026-04-05 15:34:22 +09:00
expect ( readBoulderState ( testDir ) ? . session_ids ) . toContain ( "session-123" )
expect ( readBoulderState ( testDir ) ? . agent ) . toBe ( "atlas" )
expect ( promptAsyncMock ) . toHaveBeenCalledTimes ( 1 )
promptAsyncMock . mockRestore ( )
} )
test ( "#given start-work hands the session to Atlas but background work is still running #when that work finishes #then Atlas resumes via retry for the same session" , async ( ) = > {
// given
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "atlas-plan.md" ) , "# Plan\n- [ ] Task 1\n- [ ] Task 2" )
const capturedTimers = new Map < number , { callback : Function ; cleared : boolean } > ( )
let nextTimerId = 4000
let backgroundRunning = true
const originalSetTimeout = globalThis . setTimeout
const originalClearTimeout = globalThis . clearTimeout
const originalDateNow = Date . now
let fakeNow = 10000
const promptAsyncMock = spyOn ( {
promptAsync : async ( _request : unknown ) = > undefined ,
} , "promptAsync" )
globalThis . setTimeout = ( ( callback : Function , delay? : number , . . . args : unknown [ ] ) = > {
const normalized = typeof delay === "number" ? delay : 0
if ( normalized >= 5000 ) {
const id = nextTimerId ++
capturedTimers . set ( id , { callback : ( ) = > callback ( . . . args ) , cleared : false } )
return id as unknown as ReturnType < typeof setTimeout >
}
return originalSetTimeout ( callback as Parameters < typeof originalSetTimeout > [ 0 ] , delay )
} ) as unknown as typeof setTimeout
globalThis . clearTimeout = ( ( id? : number | ReturnType < typeof setTimeout > ) = > {
if ( typeof id === "number" && capturedTimers . has ( id ) ) {
capturedTimers . get ( id ) ! . cleared = true
capturedTimers . delete ( id )
return
}
originalClearTimeout ( id as Parameters < typeof originalClearTimeout > [ 0 ] )
} ) as unknown as typeof clearTimeout
Date . now = ( ) = > fakeNow
const ctx = {
directory : testDir ,
client : {
session : {
promptAsync : promptAsyncMock ,
prompt : async ( _request : unknown ) = > undefined ,
messages : async ( ) = > ( { data : [ ] } ) ,
} ,
} ,
} as unknown as Parameters < typeof createAtlasHook > [ 0 ]
const startWorkHook = createStartWorkHook ( ctx )
const atlasHook = createAtlasHook ( ctx , {
directory : testDir ,
backgroundManager : {
getTasksByParentSession : ( ) = > backgroundRunning ? [ { status : "running" } ] : [ ] ,
} as unknown as NonNullable < Parameters < typeof createAtlasHook > [ 1 ] > [ "backgroundManager" ] ,
} )
const output = {
message : { } as Record < string , unknown > ,
parts : [ { type : "text" , text : createStartWorkPrompt ( { userRequest : "atlas-plan" } ) } ] ,
}
async function firePendingTimers ( ) : Promise < void > {
for ( const [ id , entry ] of capturedTimers ) {
if ( ! entry . cleared ) {
capturedTimers . delete ( id )
fakeNow += 6000
await entry . callback ( )
}
}
}
try {
// when
await startWorkHook [ "chat.message" ] ( { sessionID : "session-123" } , output )
await atlasHook . handler ( { event : { type : "session.idle" , properties : { sessionID : "session-123" } } } )
expect ( promptAsyncMock ) . toHaveBeenCalledTimes ( 0 )
expect ( capturedTimers . size ) . toBe ( 1 )
backgroundRunning = false
await firePendingTimers ( )
// then
2026-04-08 15:55:58 +09:00
expect ( output . message . agent ) . toBe ( "atlas" )
2026-04-05 15:34:22 +09:00
expect ( readBoulderState ( testDir ) ? . session_ids ) . toContain ( "session-123" )
expect ( readBoulderState ( testDir ) ? . agent ) . toBe ( "atlas" )
expect ( promptAsyncMock ) . toHaveBeenCalledTimes ( 1 )
} finally {
globalThis . setTimeout = originalSetTimeout
globalThis . clearTimeout = originalClearTimeout
Date . now = originalDateNow
promptAsyncMock . mockRestore ( )
}
} )
2026-01-16 11:30:54 +09:00
} )
2026-02-26 00:40:01 +09:00
describe ( "worktree support" , ( ) = > {
let detectSpy : ReturnType < typeof spyOn >
beforeEach ( ( ) = > {
detectSpy = spyOn ( worktreeDetector , "detectWorktreePath" ) . mockReturnValue ( null )
} )
afterEach ( ( ) = > {
detectSpy . mockRestore ( )
} )
2026-03-06 14:20:32 +09:00
test ( "should NOT inject worktree instructions when no --worktree flag" , async ( ) = > {
2026-02-26 00:40:01 +09:00
// given - single plan, no worktree flag
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "my-plan.md" ) , "# Plan\n- [ ] Task 1" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-02-26 00:40:01 +09:00
}
// when
await hook [ "chat.message" ] ( { sessionID : "session-123" } , output )
2026-03-06 14:20:32 +09:00
// then - no worktree instructions should appear
expect ( output . parts [ 0 ] . text ) . not . toContain ( "Worktree Setup Required" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "Worktree Active" )
expect ( output . parts [ 0 ] . text ) . not . toContain ( "git worktree list --porcelain" )
2026-02-26 00:40:01 +09:00
} )
test ( "should inject worktree path when --worktree flag is valid" , async ( ) = > {
// given - single plan + valid worktree path
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "my-plan.md" ) , "# Plan\n- [ ] Task 1" )
detectSpy . mockReturnValue ( "/validated/worktree" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( { userRequest : "--worktree /validated/worktree" } ) } ] ,
2026-02-26 00:40:01 +09:00
}
// when
await hook [ "chat.message" ] ( { sessionID : "session-123" } , output )
2026-03-06 14:20:32 +09:00
// then - strong worktree active instructions shown
expect ( output . parts [ 0 ] . text ) . toContain ( "Worktree Active" )
expect ( output . parts [ 0 ] . text ) . toContain ( "/validated/worktree" )
expect ( output . parts [ 0 ] . text ) . toContain ( "subagent" )
2026-02-26 00:40:01 +09:00
expect ( output . parts [ 0 ] . text ) . not . toContain ( "Worktree Setup Required" )
} )
test ( "should store worktree_path in boulder when --worktree is valid" , async ( ) = > {
// given - plan + valid worktree
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "my-plan.md" ) , "# Plan\n- [ ] Task 1" )
detectSpy . mockReturnValue ( "/valid/wt" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( { userRequest : "--worktree /valid/wt" } ) } ] ,
2026-02-26 00:40:01 +09:00
}
// when
await hook [ "chat.message" ] ( { sessionID : "session-123" } , output )
// then - boulder.json has worktree_path
const state = readBoulderState ( testDir )
expect ( state ? . worktree_path ) . toBe ( "/valid/wt" )
} )
test ( "should NOT store worktree_path when --worktree path is invalid" , async ( ) = > {
// given - plan + invalid worktree path (detectWorktreePath returns null)
const plansDir = join ( testDir , ".sisyphus" , "plans" )
mkdirSync ( plansDir , { recursive : true } )
writeFileSync ( join ( plansDir , "my-plan.md" ) , "# Plan\n- [ ] Task 1" )
// detectSpy already returns null by default
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( { userRequest : "--worktree /nonexistent/wt" } ) } ] ,
2026-02-26 00:40:01 +09:00
}
// when
await hook [ "chat.message" ] ( { sessionID : "session-123" } , output )
// then - worktree_path absent, setup instructions present
const state = readBoulderState ( testDir )
expect ( state ? . worktree_path ) . toBeUndefined ( )
expect ( output . parts [ 0 ] . text ) . toContain ( "needs setup" )
expect ( output . parts [ 0 ] . text ) . toContain ( "git worktree add /nonexistent/wt" )
} )
test ( "should update boulder worktree_path on resume when new --worktree given" , async ( ) = > {
// given - existing boulder with old worktree, user provides new worktree
const planPath = join ( testDir , "plan.md" )
writeFileSync ( planPath , "# Plan\n- [ ] Task 1" )
const existingState : BoulderState = {
active_plan : planPath ,
started_at : "2026-01-01T00:00:00Z" ,
session_ids : [ "old-session" ] ,
plan_name : "plan" ,
worktree_path : "/old/wt" ,
}
writeBoulderState ( testDir , existingState )
detectSpy . mockReturnValue ( "/new/wt" )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( { userRequest : "--worktree /new/wt" } ) } ] ,
2026-02-26 00:40:01 +09:00
}
// when
await hook [ "chat.message" ] ( { sessionID : "session-456" } , output )
// then - boulder reflects updated worktree and new session appended
const state = readBoulderState ( testDir )
expect ( state ? . worktree_path ) . toBe ( "/new/wt" )
expect ( state ? . session_ids ) . toContain ( "session-456" )
} )
test ( "should show existing worktree on resume when no --worktree flag" , async ( ) = > {
// given - existing boulder already has worktree_path, no flag given
const planPath = join ( testDir , "plan.md" )
writeFileSync ( planPath , "# Plan\n- [ ] Task 1" )
const existingState : BoulderState = {
active_plan : planPath ,
started_at : "2026-01-01T00:00:00Z" ,
session_ids : [ "old-session" ] ,
plan_name : "plan" ,
worktree_path : "/existing/wt" ,
}
writeBoulderState ( testDir , existingState )
const hook = createStartWorkHook ( createMockPluginInput ( ) )
const output = {
2026-03-31 20:02:00 -07:00
parts : [ { type : "text" , text : createStartWorkPrompt ( ) } ] ,
2026-02-26 00:40:01 +09:00
}
// when
await hook [ "chat.message" ] ( { sessionID : "session-789" } , output )
2026-03-06 14:20:32 +09:00
// then - shows strong worktree active instructions
expect ( output . parts [ 0 ] . text ) . toContain ( "Worktree Active" )
2026-02-26 00:40:01 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "/existing/wt" )
2026-03-06 14:20:32 +09:00
expect ( output . parts [ 0 ] . text ) . toContain ( "subagent" )
2026-02-26 00:40:01 +09:00
expect ( output . parts [ 0 ] . text ) . not . toContain ( "Worktree Setup Required" )
} )
} )
2026-01-09 02:24:43 +09:00
} )