2026-03-11 20:12:12 +09:00
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { afterEach , describe , expect , test } from "bun:test"
import { ConcurrencyManager } from "./concurrency"
import { BackgroundManager } from "./manager"
import type { BackgroundTask , LaunchInput } from "./types"
const managersToShutdown : BackgroundManager [ ] = [ ]
afterEach ( ( ) = > {
while ( managersToShutdown . length > 0 ) managersToShutdown . pop ( ) ? . shutdown ( )
} )
function createBackgroundManager ( config ? : { defaultConcurrency? : number } ) : BackgroundManager {
const directory = tmpdir ( )
const client = { session : { } as PluginInput [ "client" ] [ "session" ] } as PluginInput [ "client" ]
Reflect . set ( client . session , "abort" , async ( ) = > ( { data : true } ) )
Reflect . set ( client . session , "create" , async ( ) = > ( { data : { id : ` session- ${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } ` } } ) )
Reflect . set ( client . session , "get" , async ( ) = > ( { data : { directory } } ) )
Reflect . set ( client . session , "messages" , async ( ) = > ( { data : [ ] } ) )
Reflect . set ( client . session , "prompt" , async ( ) = > ( { data : { info : { } , parts : [ ] } } ) )
Reflect . set ( client . session , "promptAsync" , async ( ) = > ( { data : undefined } ) )
2026-05-02 03:01:03 +09:00
const manager = new BackgroundManager ( { pluginContext : {
2026-03-11 20:12:12 +09:00
$ : { } as PluginInput [ "$" ] ,
client ,
directory ,
project : { } as PluginInput [ "project" ] ,
serverUrl : new URL ( "http://localhost" ) ,
worktree : directory ,
2026-05-02 03:01:03 +09:00
} , config : config } )
2026-03-11 20:12:12 +09:00
managersToShutdown . push ( manager )
return manager
}
2026-05-02 03:01:03 +09:00
function createMockTask ( overrides : Partial < BackgroundTask > & { id : string ; parentSessionId : string } ) : BackgroundTask {
2026-03-11 20:12:12 +09:00
return {
id : overrides.id ,
2026-05-02 03:01:03 +09:00
sessionId : overrides.sessionId ,
parentSessionId : overrides.parentSessionId ,
parentMessageId : overrides.parentMessageId ? ? "parent-message-id" ,
2026-03-11 20:12:12 +09:00
description : overrides.description ? ? "test task" ,
prompt : overrides.prompt ? ? "test prompt" ,
agent : overrides.agent ? ? "test-agent" ,
status : overrides.status ? ? "running" ,
queuedAt : overrides.queuedAt ,
startedAt : overrides.startedAt ? ? new Date ( ) ,
completedAt : overrides.completedAt ,
error : overrides.error ,
model : overrides.model ,
concurrencyKey : overrides.concurrencyKey ,
concurrencyGroup : overrides.concurrencyGroup ,
progress : overrides.progress ,
}
}
function getTaskMap ( manager : BackgroundManager ) : Map < string , BackgroundTask > { return Reflect . get ( manager , "tasks" ) as Map < string , BackgroundTask > }
function getPendingByParent ( manager : BackgroundManager ) : Map < string , Set < string > > { return Reflect . get ( manager , "pendingByParent" ) as Map < string , Set < string > > }
function getQueuesByKey ( manager : BackgroundManager ) : Map < string , Array < { task : BackgroundTask ; input : LaunchInput } > > { return Reflect . get ( manager , "queuesByKey" ) as Map < string , Array < { task : BackgroundTask ; input : LaunchInput } > > }
function getConcurrencyManager ( manager : BackgroundManager ) : ConcurrencyManager { return Reflect . get ( manager , "concurrencyManager" ) as ConcurrencyManager }
function getCompletionTimers ( manager : BackgroundManager ) : Map < string , ReturnType < typeof setTimeout > > { return Reflect . get ( manager , "completionTimers" ) as Map < string , ReturnType < typeof setTimeout > > }
async function processKeyForTest ( manager : BackgroundManager , key : string ) : Promise < void > {
const processKey = Reflect . get ( manager , "processKey" ) as ( key : string ) = > Promise < void >
await processKey . call ( manager , key )
}
function runScheduledCleanup ( manager : BackgroundManager , taskId : string ) : void {
const timer = getCompletionTimers ( manager ) . get ( taskId )
if ( ! timer ) {
throw new Error ( ` Expected cleanup timer for task ${ taskId } ` )
}
const onTimeout = Reflect . get ( timer , "_onTimeout" ) as ( ( ) = > void ) | undefined
if ( ! onTimeout ) {
throw new Error ( ` Expected cleanup callback for task ${ taskId } ` )
}
onTimeout ( )
}
describe ( "BackgroundManager.cancelTask cleanup" , ( ) = > {
test ( "#given a running task in BackgroundManager #when cancelTask called with skipNotification=true #then task is eventually removed from this.tasks Map" , async ( ) = > {
// given
const manager = createBackgroundManager ( )
const task = createMockTask ( {
id : "task-skip-notification-cleanup" ,
2026-05-02 03:01:03 +09:00
parentSessionId : "parent-session-skip-notification-cleanup" ,
sessionId : "session-skip-notification-cleanup" ,
2026-03-11 20:12:12 +09:00
} )
getTaskMap ( manager ) . set ( task . id , task )
2026-05-02 03:01:03 +09:00
getPendingByParent ( manager ) . set ( task . parentSessionId , new Set ( [ task . id ] ) )
2026-03-11 20:12:12 +09:00
// when
const cancelled = await manager . cancelTask ( task . id , {
skipNotification : true ,
source : "test" ,
} )
// then
expect ( cancelled ) . toBe ( true )
2026-05-02 03:01:03 +09:00
expect ( getPendingByParent ( manager ) . get ( task . parentSessionId ) ) . toBeUndefined ( )
2026-03-11 20:12:12 +09:00
runScheduledCleanup ( manager , task . id )
expect ( manager . getTask ( task . id ) ) . toBeUndefined ( )
} )
test ( "#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed" , async ( ) = > {
// given
const manager = createBackgroundManager ( )
const task = createMockTask ( {
id : "task-notify-cleanup" ,
2026-05-02 03:01:03 +09:00
parentSessionId : "parent-session-notify-cleanup" ,
sessionId : "session-notify-cleanup" ,
2026-03-11 20:12:12 +09:00
} )
getTaskMap ( manager ) . set ( task . id , task )
2026-05-02 03:01:03 +09:00
getPendingByParent ( manager ) . set ( task . parentSessionId , new Set ( [ task . id ] ) )
2026-03-11 20:12:12 +09:00
// when
const cancelled = await manager . cancelTask ( task . id , {
skipNotification : false ,
source : "test" ,
} )
// then
expect ( cancelled ) . toBe ( true )
runScheduledCleanup ( manager , task . id )
expect ( manager . getTask ( task . id ) ) . toBeUndefined ( )
} )
test ( "#given a running task #when cancelTask called with skipNotification=true #then concurrency slot is freed and pending tasks can start" , async ( ) = > {
// given
const manager = createBackgroundManager ( { defaultConcurrency : 1 } )
const concurrencyManager = getConcurrencyManager ( manager )
const concurrencyKey = "test-provider/test-model"
await concurrencyManager . acquire ( concurrencyKey )
const runningTask = createMockTask ( {
id : "task-running-before-cancel" ,
2026-05-02 03:01:03 +09:00
parentSessionId : "parent-session-concurrency-cleanup" ,
sessionId : "session-running-before-cancel" ,
2026-03-11 20:12:12 +09:00
concurrencyKey ,
} )
const pendingTask = createMockTask ( {
id : "task-pending-after-cancel" ,
2026-05-02 03:01:03 +09:00
parentSessionId : runningTask.parentSessionId ,
2026-03-11 20:12:12 +09:00
status : "pending" ,
startedAt : undefined ,
queuedAt : new Date ( ) ,
model : { providerID : "test-provider" , modelID : "test-model" } ,
} )
const queuedInput : LaunchInput = {
agent : pendingTask.agent ,
description : pendingTask.description ,
model : pendingTask.model ,
2026-05-02 03:01:03 +09:00
parentMessageId : pendingTask.parentMessageId ,
parentSessionId : pendingTask.parentSessionId ,
2026-03-11 20:12:12 +09:00
prompt : pendingTask.prompt ,
}
getTaskMap ( manager ) . set ( runningTask . id , runningTask )
getTaskMap ( manager ) . set ( pendingTask . id , pendingTask )
2026-05-02 03:01:03 +09:00
getPendingByParent ( manager ) . set ( runningTask . parentSessionId , new Set ( [ runningTask . id , pendingTask . id ] ) )
2026-03-11 20:12:12 +09:00
getQueuesByKey ( manager ) . set ( concurrencyKey , [ { input : queuedInput , task : pendingTask } ] )
Reflect . set ( manager , "startTask" , async ( { task } : { task : BackgroundTask ; input : LaunchInput } ) = > {
task . status = "running"
task . startedAt = new Date ( )
2026-05-02 03:01:03 +09:00
task . sessionId = "session-started-after-cancel"
2026-03-11 20:12:12 +09:00
task . concurrencyKey = concurrencyKey
task . concurrencyGroup = concurrencyKey
} )
// when
const cancelled = await manager . cancelTask ( runningTask . id , {
abortSession : false ,
skipNotification : true ,
source : "test" ,
} )
await processKeyForTest ( manager , concurrencyKey )
// then
expect ( cancelled ) . toBe ( true )
expect ( concurrencyManager . getCount ( concurrencyKey ) ) . toBe ( 1 )
expect ( manager . getTask ( pendingTask . id ) ? . status ) . toBe ( "running" )
} )
} )