2026-08-14 12:48:53 +08:00
import { afterEach , describe , expect , it , vi } from 'vitest'
import { mkdtempSync , rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
2026-08-19 13:03:32 +08:00
import { ToolCallId } from '@deepseek-ai/dsh-llm'
2026-08-14 12:48:53 +08:00
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { SessionId } from '@deepseek-ai/dsh-session'
2026-08-19 13:57:58 +08:00
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
2026-08-14 12:48:53 +08:00
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
2026-08-25 06:08:14 +08:00
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
2026-08-14 12:48:53 +08:00
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { MockAdapter , textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
2026-08-19 22:22:59 +08:00
import TeamService from '../../agent-team/src/index.ts'
2026-08-14 12:48:53 +08:00
import * as toolTeam from '../src/index.ts'
const SIGNAL = new AbortController ( ) . signal
const TOOL_NAMES = [
'spawn_teammate' ,
'send_message' ,
'list_agents' ,
'wait_agent' ,
'interrupt_agent' ,
'team_task_create' ,
'team_task_list' ,
'team_task_get' ,
'team_task_update' ,
] . sort ( )
const roots : string [ ] = [ ]
let callNumber = 0
2026-08-25 06:08:14 +08:00
/** Session query implementation whose search faces are outside these tests. */
class TestSessionQuery extends SessionQueryEngine {
override searchSessions ( ) : Promise < never > {
return Promise . reject ( new Error ( 'session search is not configured in this test' ) )
}
override searchEvents ( ) : Promise < never > {
return Promise . reject ( new Error ( 'event search is not configured in this test' ) )
}
}
2026-08-14 12:48:53 +08:00
afterEach ( ( ) = > {
for ( const root of roots . splice ( 0 ) ) rmSync ( root , { recursive : true , force : true } )
} )
async function setup ( script : ConstructorParameters < typeof MockAdapter > [ 0 ] , legacyControl = false ) {
const ctx = new Context ( )
await mountAgentLoopTestDependencies ( ctx )
2026-08-19 13:57:58 +08:00
await ctx . plugin ( SessionProjectionRegistry )
2026-08-14 12:48:53 +08:00
const storageRoot = mkdtempSync ( join ( tmpdir ( ) , 'dsh-tool-team-' ) )
roots . push ( storageRoot )
await ctx . plugin ( JsonlSessionPersistence , { root : storageRoot } )
2026-08-25 06:08:14 +08:00
await ctx . plugin ( TestSessionQuery )
2026-08-14 12:48:53 +08:00
await ctx . plugin ( AgentLoop , { agents : [ ] } )
await ctx . plugin ( SubagentService )
if ( legacyControl ) await ctx . plugin ( ToolSubagentControl )
await ctx . plugin ( SubagentSpawn , { providerName : 'spawn' } )
await ctx . plugin ( SubagentFork , { providerName : 'fork' } )
await ctx . plugin ( TeamService )
const fiber = await ctx . plugin ( toolTeam )
const adapter = new MockAdapter ( script )
ctx . llm . registerAdapter ( [ 'mock' ] , adapter )
refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
The persistence seam is now create/open/stat/list returning per-session
SessionHandles (read/append/flush/close); every log read and write flows
through the owning handle. The seam package exports only the service and
handle contracts, consumer-visible errors, and pure durable-data
validation helpers; each backend owns its complete storage runtime, and
the shared contract suites pin equivalent observable behavior. The
backend routes published sessions' live events by id into the active
write handle; agent-loop only acquires, seeds, and closes the handle.
Resume appends interruptedTurnClosers through its write handle;
session-query owns the revision-keyed cold cache. Legacy-only surfaces
are removed in the same swap: locate/readRaw/supportsRawArtifacts, the
legacy event-shape read migration, zstd torn-frame salvage,
DSH_SESSION_JSONL, and hook transcript_path population; a torn final
zstd frame is discarded whole; the session-list cold blank probe returns
on stat metadata (eventCount derived from the last physical row,
sizeBytes). The WebUI ZIP export serializes the logical log from a read
handle, so both backends export identically.
Refs #3245
2026-08-28 12:21:44 +08:00
const lead = await ctx . agentLoop . create ( SessionId ( 'tool-team-lead' ) , { provider : 'mock' , model : 'mock' } )
2026-08-14 12:48:53 +08:00
return { ctx , lead , fiber }
}
function execute (
ctx : Context ,
agent : Agent | undefined ,
name : string ,
args : unknown ,
signal : AbortSignal = SIGNAL ,
) {
return ctx . tools . execute ( {
2026-08-19 13:03:32 +08:00
callId : ToolCallId ( ` team-call- ${ ++ callNumber } ` ) ,
2026-08-14 12:48:53 +08:00
name ,
arguments : args ,
signal ,
. . . agent === undefined ? { } : { agent } ,
} )
}
function text ( result : Awaited < ReturnType < typeof execute > > ) : string {
return result . content . flatMap ( block = > block . type === 'text' ? [ block . text ] : [ ] ) . join ( '' )
}
function spawnedChildId ( result : Awaited < ReturnType < typeof execute > > ) : SessionId {
const parsed : unknown = JSON . parse ( text ( result ) )
if ( typeof parsed !== 'object' || parsed === null || ! ( 'member' in parsed ) ) {
throw new Error ( 'spawn_teammate result has no member' )
}
const member = parsed . member
if ( typeof member !== 'object' || member === null || ! ( 'id' in member ) || typeof member . id !== 'string' ) {
throw new Error ( 'spawn_teammate result has no member id' )
}
return SessionId ( member . id )
}
async function assembly ( ctx : Context , agent : Agent ) {
const scope = scopeOf ( agent . ctx )
if ( scope === undefined ) throw new Error ( 'expected Agent scope' )
return ctx . systemPrompt . assemble ( { scope } )
}
async function waitRunning ( ctx : Context , id : SessionId ) : Promise < Agent > {
return vi . waitFor ( ( ) = > {
const child = ctx . agents . get ( id )
expect ( child ? . status ) . toBe ( 'running' )
return child !
} , { timeout : 5_000 } )
}
async function waitNoAgent ( ctx : Context , id : SessionId ) : Promise < void > {
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( id ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
}
describe ( 'dsh-tool-team' , ( ) = > {
it ( 'installs the complete scoped schema and shared-checkout policy for roots and teammates' , async ( ) = > {
const { ctx , lead } = await setup ( [ 'hang' ] )
const leadAssembly = await assembly ( ctx , lead )
expect ( leadAssembly . tools . map ( schema = > schema . name ) . filter ( name = > TOOL_NAMES . includes ( name ) ) . sort ( ) )
. toEqual ( TOOL_NAMES )
const leadPrompt = renderPrompt ( leadAssembly )
expect ( leadPrompt ) . toContain ( 'create teammates only when the user explicitly asks' )
expect ( leadPrompt ) . toContain ( 'FS_STALE_VERSION' )
expect ( leadPrompt ) . toContain ( 'Bash, formatters, code generators, and scripts are not fully protected' )
expect ( leadPrompt ) . toContain ( 'Task readiness never starts an owner' )
expect ( leadPrompt ) . toContain ( 'returns noProgress immediately' )
expect ( leadPrompt ) . toContain ( 'Your Team role is lead' )
const spawned = await execute ( ctx , lead , 'spawn_teammate' , {
name : 'tool-worker' ,
description : 'exercise scoped tools' ,
prompt : 'stay available' ,
} )
expect ( spawned . isError ) . toBe ( false )
const childId = spawnedChildId ( spawned )
const child = await waitRunning ( ctx , childId )
const childAssembly = await assembly ( ctx , child )
expect ( childAssembly . tools . map ( schema = > schema . name ) . filter ( name = > TOOL_NAMES . includes ( name ) ) . sort ( ) )
. toEqual ( TOOL_NAMES )
expect ( renderPrompt ( childAssembly ) ) . toContain ( 'Your Team role is teammate; your Team name is tool-worker' )
2026-09-01 12:01:47 +08:00
const initialPrompt = child . session . snapshotEvents ( ) . find ( event = > event . type === 'user/message'
2026-08-31 11:16:00 +08:00
&& event . data . source . kind === 'user' )
expect ( initialPrompt ? . type === 'user/message'
? initialPrompt . data . content . flatMap ( block = > block . type === 'text' ? [ block . text ] : [ ] )
: [ ] ) . toEqual ( [ 'stay available' ] )
2026-08-14 12:48:53 +08:00
const denied = await execute ( ctx , child , 'spawn_teammate' , {
name : 'nested' , description : 'not allowed' , prompt : 'no' ,
} )
expect ( denied . isError ) . toBe ( true )
expect ( text ( denied ) ) . toContain ( 'only the Team Lead' )
await execute ( ctx , lead , 'interrupt_agent' , { target : 'tool-worker' } )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
} )
it ( 'returns actionable no-progress output and renders structured wait cancellation' , async ( ) = > {
const inactiveSetup = await setup ( [ textResponse ( 'worker done' ) ] )
const inactiveSpawn = await execute ( inactiveSetup . ctx , inactiveSetup . lead , 'spawn_teammate' , {
name : 'inactive-worker' , description : 'finish immediately' , prompt : 'finish' ,
} )
const inactiveId = spawnedChildId ( inactiveSpawn )
await waitNoAgent ( inactiveSetup . ctx , inactiveId )
const noProgress = await execute ( inactiveSetup . ctx , inactiveSetup . lead , 'wait_agent' , { timeout_ms : 3_600_000 } )
expect ( noProgress . isError ) . toBe ( false )
expect ( JSON . parse ( text ( noProgress ) ) ) . toEqual ( {
timedOut : false ,
noProgress : {
reason : 'no-active-peer' ,
2026-08-30 20:44:34 +08:00
message : 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.' ,
2026-08-14 12:48:53 +08:00
} ,
} )
for ( const timeout_ms of [ 9 _999 , 3 _600_001 , Number . MAX_SAFE_INTEGER + 1 ] ) {
const invalid = await execute ( inactiveSetup . ctx , inactiveSetup . lead , 'wait_agent' , { timeout_ms } )
expect ( invalid . isError ) . toBe ( true )
expect ( text ( invalid ) ) . toContain ( 'timeoutMs must be an integer from 10000 through 3600000' )
}
const activeSetup = await setup ( [ 'hang' ] )
const activeSpawn = await execute ( activeSetup . ctx , activeSetup . lead , 'spawn_teammate' , {
name : 'active-worker' , description : 'stay active' , prompt : 'wait' ,
} )
const activeId = spawnedChildId ( activeSpawn )
await waitRunning ( activeSetup . ctx , activeId )
const controller = new AbortController ( )
const waiting = execute ( activeSetup . ctx , activeSetup . lead , 'wait_agent' , { timeout_ms : 10_000 } , controller . signal )
await new Promise ( resolve = > setTimeout ( resolve , 0 ) )
controller . abort ( { kind : 'user' } )
const aborted = await waiting
expect ( aborted . isError ) . toBe ( true )
expect ( text ( aborted ) ) . toBe ( "Error: wait_agent aborted: { kind: 'user' }" )
await execute ( activeSetup . ctx , activeSetup . lead , 'interrupt_agent' , { target : 'active-worker' } )
await waitNoAgent ( activeSetup . ctx , activeId )
} )
it ( 'adapts roster, mailbox, wait, and task CAS operations to canonical JSON' , async ( ) = > {
const { ctx , lead } = await setup ( [ 'hang' , textResponse ( 'lead received wakeup' ) ] )
const spawned = await execute ( ctx , lead , 'spawn_teammate' , {
name : 'json-worker' , description : 'json worker' , prompt : 'wait' , context : 'fresh' ,
} )
const childId = spawnedChildId ( spawned )
const child = await waitRunning ( ctx , childId )
const roster = await execute ( ctx , child , 'list_agents' , { } )
expect ( JSON . parse ( text ( roster ) ) ) . toMatchObject ( [
{ name : 'lead' , role : 'lead' } ,
{ name : 'json-worker' , role : 'teammate' } ,
] )
// Every Team result reaches the model as compact JSON: indentation would
// spend tokens on every roster, task, and receipt without adding meaning.
expect ( text ( roster ) ) . toBe ( JSON . stringify ( JSON . parse ( text ( roster ) ) ) )
2026-08-30 20:44:34 +08:00
const peer = await execute ( ctx , child , 'send_message' , { target : 'lead' , message : 'progress report' } )
2026-08-14 12:48:53 +08:00
expect ( peer . isError ) . toBe ( false )
expect ( JSON . parse ( text ( peer ) ) ) . toMatchObject ( { status : 'accepted' } )
2026-08-30 20:44:34 +08:00
const followup = await execute ( ctx , child , 'send_message' , { target : 'lead' , message : 'review the report' } )
expect ( followup . isError ) . toBe ( false )
expect ( JSON . parse ( text ( followup ) ) ) . toMatchObject ( { status : 'accepted' } )
2026-08-14 12:48:53 +08:00
await lead . whenIdle ( )
const created = await execute ( ctx , lead , 'team_task_create' , {
subject : 'tool task' ,
description : 'created through tool' ,
blocked_by : [ ] ,
write_scopes : [ 'src/team' ] ,
} )
const task = JSON . parse ( text ( created ) ) as { id : string ; revision : number }
const listed = await execute ( ctx , child , 'team_task_list' , { ready : true , limit : 1 } )
expect ( JSON . parse ( text ( listed ) ) ) . toMatchObject ( { tasks : [ { id : task.id , ready : true } ] } )
const read = await execute ( ctx , child , 'team_task_get' , { task_id : task.id } )
expect ( JSON . parse ( text ( read ) ) ) . toMatchObject ( { id : task.id , revision : 1 } )
const claimed = await execute ( ctx , child , 'team_task_update' , {
task_id : task.id ,
expected_revision : task.revision ,
action : 'claim' ,
} )
expect ( JSON . parse ( text ( claimed ) ) ) . toMatchObject ( { status : 'in_progress' , ownerName : 'json-worker' } )
const stale = await execute ( ctx , lead , 'team_task_update' , {
task_id : task.id ,
expected_revision : task.revision ,
action : 'delete' ,
} )
expect ( stale . isError ) . toBe ( true )
expect ( text ( stale ) ) . toContain ( 'stale team task' )
const wait = execute ( ctx , lead , 'wait_agent' , { timeout_ms : 10_000 } )
const completedCall = new Promise < Awaited < ReturnType < typeof execute > > > ( ( resolve , reject ) = > {
setTimeout ( ( ) = > {
void execute ( ctx , child , 'team_task_update' , {
task_id : task.id ,
expected_revision : 2 ,
action : 'complete' ,
} ) . then ( resolve , reject )
} , 0 )
} )
await expect ( wait ) . resolves . toMatchObject ( { isError : false } )
expect ( ( await completedCall ) . isError ) . toBe ( false )
const childInterrupt = await execute ( ctx , child , 'interrupt_agent' , { target : 'json-worker' } )
expect ( childInterrupt . isError ) . toBe ( true )
await execute ( ctx , lead , 'interrupt_agent' , { target : 'json-worker' } )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
} )
it ( 'adapts optional task filters, mutations, pagination, and default waiting' , async ( ) = > {
const { ctx , lead } = await setup ( [ 'hang' ] )
const spawned = await execute ( ctx , lead , 'spawn_teammate' , {
name : 'fork-worker' , description : 'fork worker' , prompt : 'stay active' , context : 'fork' ,
} )
const childId = spawnedChildId ( spawned )
await waitRunning ( ctx , childId )
const firstResult = await execute ( ctx , lead , 'team_task_create' , {
subject : 'first' , description : 'first task' ,
} )
const secondResult = await execute ( ctx , lead , 'team_task_create' , {
subject : 'second' , description : 'second task' ,
} )
const first = JSON . parse ( text ( firstResult ) ) as { id : string ; revision : number }
const second = JSON . parse ( text ( secondResult ) ) as { id : string ; revision : number }
const claimed = await execute ( ctx , lead , 'team_task_update' , {
task_id : first.id , expected_revision : first.revision , action : 'claim' ,
} )
const claim = JSON . parse ( text ( claimed ) ) as { revision : number }
expect ( JSON . parse ( text ( await execute ( ctx , lead , 'team_task_list' , {
status : 'in_progress' , owner : 'lead' , cursor : 0 , limit : 1 ,
} ) ) ) ) . toMatchObject ( { tasks : [ { id : first.id } ] } )
expect ( JSON . parse ( text ( await execute ( ctx , lead , 'team_task_list' , {
owner : 'unowned' , limit : 1 ,
} ) ) ) ) . toMatchObject ( { tasks : [ { id : second.id } ] } )
expect ( JSON . parse ( text ( await execute ( ctx , lead , 'team_task_list' , {
cursor : 0 , limit : 1 ,
} ) ) ) ) . toMatchObject ( { nextCursor : 1 } )
expect ( JSON . parse ( text ( await execute ( ctx , lead , 'team_task_list' , {
cursor : 1 ,
} ) ) ) ) . not . toHaveProperty ( 'nextCursor' )
expect ( ( await execute ( ctx , lead , 'team_task_list' , { cursor : - 1 } ) ) . isError ) . toBe ( true )
expect ( ( await execute ( ctx , lead , 'team_task_list' , { limit : 101 } ) ) . isError ) . toBe ( true )
const edited = await execute ( ctx , lead , 'team_task_update' , {
task_id : first.id ,
expected_revision : claim.revision ,
action : 'edit' ,
subject : 'edited' ,
description : 'edited description' ,
write_scopes : [ 'src/team' ] ,
} )
const edit = JSON . parse ( text ( edited ) ) as { revision : number }
const dependencies = await execute ( ctx , lead , 'team_task_update' , {
task_id : first.id ,
expected_revision : edit.revision ,
action : 'set_dependencies' ,
blocked_by : [ second . id ] ,
} )
expect ( dependencies . isError ) . toBe ( false )
const dependency = JSON . parse ( text ( dependencies ) ) as { revision : number }
expect ( ( await execute ( ctx , lead , 'team_task_update' , {
task_id : first.id ,
expected_revision : dependency.revision ,
action : 'reassign' ,
owner : 'fork-worker' ,
} ) ) . isError ) . toBe ( true )
const wait = execute ( ctx , lead , 'wait_agent' , { } )
const wake = new Promise < Awaited < ReturnType < typeof execute > > > ( ( resolve , reject ) = > {
setTimeout ( ( ) = > {
void execute ( ctx , lead , 'team_task_create' , {
subject : 'wake' , description : 'wake default wait' ,
} ) . then ( resolve , reject )
} , 0 )
} )
expect ( ( await wait ) . isError ) . toBe ( false )
expect ( ( await wake ) . isError ) . toBe ( false )
await execute ( ctx , lead , 'interrupt_agent' , { target : 'fork-worker' } )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
} )
it ( 'removes and reinstalls every scoped registration across plugin HMR without stopping the child' , async ( ) = > {
const { ctx , lead , fiber } = await setup ( [ 'hang' ] )
const spawned = await execute ( ctx , lead , 'spawn_teammate' , {
name : 'hmr-worker' , description : 'hmr worker' , prompt : 'wait' ,
} )
const childId = spawnedChildId ( spawned )
const child = await waitRunning ( ctx , childId )
await fiber . dispose ( )
expect ( ( await assembly ( ctx , lead ) ) . tools . map ( schema = > schema . name ) . some ( name = > TOOL_NAMES . includes ( name ) ) ) . toBe ( false )
expect ( ( await assembly ( ctx , child ) ) . tools . map ( schema = > schema . name ) . some ( name = > TOOL_NAMES . includes ( name ) ) ) . toBe ( false )
expect ( ctx . agents . get ( childId ) ) . toBe ( child )
const replacement = await ctx . plugin ( toolTeam )
expect ( ( await assembly ( ctx , lead ) ) . tools . map ( schema = > schema . name ) . filter ( name = > TOOL_NAMES . includes ( name ) ) . sort ( ) )
. toEqual ( TOOL_NAMES )
expect ( ( await assembly ( ctx , child ) ) . tools . map ( schema = > schema . name ) . filter ( name = > TOOL_NAMES . includes ( name ) ) . sort ( ) )
. toEqual ( TOOL_NAMES )
await execute ( ctx , lead , 'interrupt_agent' , { target : 'hmr-worker' } )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
await replacement . dispose ( )
} )
it ( 'shadows legacy global control names only inside Team member scopes' , async ( ) = > {
const { ctx , lead , fiber } = await setup ( [ ] , true )
const teamSchema = ( await assembly ( ctx , lead ) ) . tools . find ( schema = > schema . name === 'send_message' )
expect ( JSON . stringify ( teamSchema ) ) . toContain ( 'target' )
expect ( JSON . stringify ( teamSchema ) ) . not . toContain ( 'subagent_id' )
await fiber . dispose ( )
const legacySchema = ( await assembly ( ctx , lead ) ) . tools . find ( schema = > schema . name === 'send_message' )
2026-08-30 14:14:46 +08:00
expect ( JSON . stringify ( legacySchema ) ) . toContain ( 'agent_id' )
2026-08-14 12:48:53 +08:00
} )
it ( 'rolls back partial scoped installation after a same-scope collision' , async ( ) = > {
const { ctx , lead , fiber } = await setup ( [ ] )
await fiber . dispose ( )
lead . ctx . tools . register ( defineContentToolFixture ( {
name : 'spawn_teammate' ,
description : 'intentional collision' ,
parameters : { } ,
async execute() { return [ { type : 'text' , text : 'collision' } ] } ,
} ) )
await expect ( ctx . plugin ( toolTeam ) ) . rejects . toThrow ( / a l r e a d y r e g i s t e r e d / u )
const assembled = await assembly ( ctx , lead )
expect ( assembled . tools . filter ( schema = > TOOL_NAMES . includes ( schema . name ) ) . map ( schema = > schema . name ) )
. toEqual ( [ 'spawn_teammate' ] )
expect ( renderPrompt ( assembled ) ) . not . toContain ( 'Your Team role is lead' )
} )
it ( 'resolves direct-apply defaults without Loader schema normalization' , async ( ) = > {
const { ctx , lead , fiber } = await setup ( [ textResponse ( 'ordinary child' ) ] )
await fiber . dispose ( )
toolTeam . apply ( ctx , { } )
expect ( ( await assembly ( ctx , lead ) ) . tools . map ( schema = > schema . name ) . filter ( name = > TOOL_NAMES . includes ( name ) ) . sort ( ) )
. toEqual ( TOOL_NAMES )
const ordinary = await ctx . subagents . startContinuable ( {
provider : 'spawn' ,
label : 'ordinary child' ,
request : { prompt : [ { type : 'text' , text : 'finish' } ] , parent : lead } ,
signal : SIGNAL ,
} )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( ordinary . childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
} )
it ( 'reinstalls Team scope before a cold-resumed teammate request' , async ( ) = > {
const { ctx , lead } = await setup ( [ textResponse ( 'first' ) , 'hang' ] )
const spawned = await execute ( ctx , lead , 'spawn_teammate' , {
name : 'cold-worker' , description : 'cold worker' , prompt : 'finish once' ,
} )
const childId = spawnedChildId ( spawned )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
2026-08-19 22:28:18 +08:00
await ctx . agentTeams . sendMessage ( lead , {
2026-08-14 12:48:53 +08:00
target : 'cold-worker' ,
content : [ { type : 'text' , text : 'resume with Team scope' } ] ,
signal : SIGNAL ,
} )
const resumed = await waitRunning ( ctx , childId )
expect ( ( await assembly ( ctx , resumed ) ) . tools . map ( schema = > schema . name )
. filter ( name = > TOOL_NAMES . includes ( name ) ) . sort ( ) ) . toEqual ( TOOL_NAMES )
expect ( renderPrompt ( await assembly ( ctx , resumed ) ) ) . toContain ( 'Your Team role is teammate; your Team name is cold-worker' )
await execute ( ctx , lead , 'interrupt_agent' , { target : 'cold-worker' } )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
} )
it ( 'fails safely without a calling Agent and has the function-plugin export shape' , async ( ) = > {
const { ctx } = await setup ( [ ] )
const result = await execute ( ctx , undefined , 'list_agents' , { } )
expect ( result . isError ) . toBe ( true )
expect ( text ( result ) ) . toContain ( 'unknown tool "list_agents"' )
expect ( 'default' in toolTeam ) . toBe ( false )
2026-08-19 22:28:18 +08:00
expect ( toolTeam . name ) . toBe ( 'tool-agent-team' )
expect ( toolTeam . inject ) . toEqual ( [ 'agents' , 'agentTeams' , 'tools' , 'systemPrompt' ] )
2026-08-14 12:48:53 +08:00
} )
it ( 'uses configured fresh and fork provider names' , async ( ) = > {
const { ctx , lead , fiber } = await setup ( [ textResponse ( 'custom' ) ] )
await fiber . dispose ( )
await ctx . plugin ( SubagentSpawn , { providerName : 'team-fresh' } )
await ctx . plugin ( toolTeam , { freshProvider : 'team-fresh' , forkProvider : 'fork' } )
const result = await execute ( ctx , lead , 'spawn_teammate' , {
name : 'custom-provider' , description : 'custom provider' , prompt : 'go' ,
} )
expect ( result . isError ) . toBe ( false )
const childId = spawnedChildId ( result )
await vi . waitFor ( ( ) = > { expect ( ctx . agents . get ( childId ) ) . toBeUndefined ( ) } , { timeout : 5_000 } )
2026-08-19 22:28:18 +08:00
expect ( ctx . agentTeams . listMembers ( lead ) [ 1 ] ) . toMatchObject ( { provider : 'team-fresh' } )
2026-08-14 12:48:53 +08:00
} )
} )