@@ -76,14 +76,21 @@ async function makeHarness(): Promise<Harness> {
7676 } ;
7777}
7878
79- async function adminRegister ( h : Harness , agentHandle : string ) : Promise < string > {
79+ async function adminRegister (
80+ h : Harness ,
81+ agentHandle : string ,
82+ opts : { readonly policy ?: "open" | "allowlist" } = { } ,
83+ ) : Promise < string > {
8084 const reg = await fetch ( `${ h . baseUrl } /_admin/agents` , {
8185 method : "POST" ,
8286 headers : {
8387 Authorization : `Bearer ${ h . adminToken } ` ,
8488 "Content-Type" : "application/json" ,
8589 } ,
86- body : JSON . stringify ( { handle : agentHandle } ) ,
90+ body : JSON . stringify ( {
91+ handle : agentHandle ,
92+ ...( opts . policy !== undefined ? { policy : opts . policy } : { } ) ,
93+ } ) ,
8794 } ) ;
8895 if ( reg . status !== 201 ) {
8996 throw new Error ( `register failed: ${ reg . status } ${ await reg . text ( ) } ` ) ;
@@ -318,6 +325,156 @@ describe("local operator /blocks", () => {
318325 } ) ;
319326} ) ;
320327
328+ describe ( "local operator /blocks — force-leave shared sessions (ASP §6.2)" , ( ) => {
329+ let h : Harness ;
330+
331+ beforeEach ( async ( ) => {
332+ h = await makeHarness ( ) ;
333+ // Open inbound policy so the agents can invite each other without
334+ // first wiring an allowlist; the force-leave behavior is independent
335+ // of inbound policy.
336+ await adminRegister ( h , "@alice.bot" , { policy : "open" } ) ;
337+ await adminRegister ( h , "@bob.bot" , { policy : "open" } ) ;
338+ await adminRegister ( h , "@carol.bot" , { policy : "open" } ) ;
339+ } ) ;
340+
341+ afterEach ( async ( ) => {
342+ await h . cleanup ( ) ;
343+ } ) ;
344+
345+ async function createJoinedSession (
346+ creator : string ,
347+ invitee : string ,
348+ ) : Promise < string > {
349+ const create = await fetch ( `${ h . baseUrl } /sessions` , {
350+ method : "POST" ,
351+ headers : agentHeaders ( h , creator , true ) ,
352+ body : JSON . stringify ( { invite : [ invitee ] } ) ,
353+ } ) ;
354+ assert . equal ( create . status , 201 ) ;
355+ const { session_id } = ( await create . json ( ) ) as { session_id : string } ;
356+ const join = await fetch ( `${ h . baseUrl } /sessions/${ session_id } /join` , {
357+ method : "POST" ,
358+ headers : agentHeaders ( h , invitee ) ,
359+ } ) ;
360+ assert . equal ( join . status , 200 ) ;
361+ return session_id ;
362+ }
363+
364+ async function showSession ( viewer : string , sessionId : string ) {
365+ const res = await fetch ( `${ h . baseUrl } /sessions/${ sessionId } ` , {
366+ headers : agentHeaders ( h , viewer ) ,
367+ } ) ;
368+ assert . equal ( res . status , 200 ) ;
369+ return ( await res . json ( ) ) as {
370+ participants : Array < { handle : string ; status : string } > ;
371+ } ;
372+ }
373+
374+ async function listEvents ( viewer : string , sessionId : string ) {
375+ const res = await fetch (
376+ `${ h . baseUrl } /sessions/${ sessionId } /events?after_sequence=0&limit=50` ,
377+ { headers : agentHeaders ( h , viewer ) } ,
378+ ) ;
379+ assert . equal ( res . status , 200 ) ;
380+ return ( await res . json ( ) ) as {
381+ events : Array < { type : string ; payload : Record < string , unknown > } > ;
382+ } ;
383+ }
384+
385+ it ( "force-leaves the blocked agent from a shared session and emits session.left" , async ( ) => {
386+ const sessionId = await createJoinedSession ( "@alice.bot" , "@bob.bot" ) ;
387+ const before = await showSession ( "@alice.bot" , sessionId ) ;
388+ assert . deepEqual (
389+ before . participants
390+ . filter ( ( p ) => p . handle === "@bob.bot" )
391+ . map ( ( p ) => p . status ) ,
392+ [ "joined" ] ,
393+ ) ;
394+
395+ const block = await fetch ( `${ h . baseUrl } /agents/me/blocks` , {
396+ method : "POST" ,
397+ headers : agentHeaders ( h , "@alice.bot" , true ) ,
398+ body : JSON . stringify ( { handle : "@bob.bot" } ) ,
399+ } ) ;
400+ assert . equal ( block . status , 201 ) ;
401+
402+ const after = await showSession ( "@alice.bot" , sessionId ) ;
403+ const bob = after . participants . find ( ( p ) => p . handle === "@bob.bot" ) ;
404+ assert . equal ( bob ?. status , "left" ) ;
405+
406+ const events = await listEvents ( "@alice.bot" , sessionId ) ;
407+ const left = events . events . filter (
408+ ( e ) => e . type === "session.left" && e . payload . agent === "@bob.bot" ,
409+ ) ;
410+ assert . equal ( left . length , 1 ) ;
411+ // Schema enum is ["left", "grace_expired"]; the spec says the blocked
412+ // agent is not informed it was blocked, so the reason is "left".
413+ assert . equal ( left [ 0 ] ! . payload . reason , "left" ) ;
414+ } ) ;
415+
416+ it ( "force-leaves across every shared active session" , async ( ) => {
417+ const s1 = await createJoinedSession ( "@alice.bot" , "@bob.bot" ) ;
418+ const s2 = await createJoinedSession ( "@bob.bot" , "@alice.bot" ) ;
419+
420+ const block = await fetch ( `${ h . baseUrl } /agents/me/blocks` , {
421+ method : "POST" ,
422+ headers : agentHeaders ( h , "@alice.bot" , true ) ,
423+ body : JSON . stringify ( { handle : "@bob.bot" } ) ,
424+ } ) ;
425+ assert . equal ( block . status , 201 ) ;
426+
427+ for ( const sid of [ s1 , s2 ] ) {
428+ const view = await showSession ( "@alice.bot" , sid ) ;
429+ const bob = view . participants . find ( ( p ) => p . handle === "@bob.bot" ) ;
430+ assert . equal ( bob ?. status , "left" , `expected @bob.bot left in ${ sid } ` ) ;
431+ }
432+ } ) ;
433+
434+ it ( "does not touch sessions where the blocker is not a current participant" , async ( ) => {
435+ // @bob .bot and @carol.bot share a session; @alice.bot is not on it.
436+ // Alice blocking @bob .bot must not affect that session.
437+ const sessionId = await createJoinedSession ( "@bob.bot" , "@carol.bot" ) ;
438+
439+ const block = await fetch ( `${ h . baseUrl } /agents/me/blocks` , {
440+ method : "POST" ,
441+ headers : agentHeaders ( h , "@alice.bot" , true ) ,
442+ body : JSON . stringify ( { handle : "@bob.bot" } ) ,
443+ } ) ;
444+ assert . equal ( block . status , 201 ) ;
445+
446+ const view = await showSession ( "@bob.bot" , sessionId ) ;
447+ const bob = view . participants . find ( ( p ) => p . handle === "@bob.bot" ) ;
448+ const carol = view . participants . find ( ( p ) => p . handle === "@carol.bot" ) ;
449+ assert . equal ( bob ?. status , "joined" ) ;
450+ assert . equal ( carol ?. status , "joined" ) ;
451+ } ) ;
452+
453+ it ( "is a no-op when the blocked agent has already left the shared session" , async ( ) => {
454+ const sessionId = await createJoinedSession ( "@alice.bot" , "@bob.bot" ) ;
455+ // @bob .bot voluntarily leaves first.
456+ const leave = await fetch ( `${ h . baseUrl } /sessions/${ sessionId } /leave` , {
457+ method : "POST" ,
458+ headers : agentHeaders ( h , "@bob.bot" ) ,
459+ } ) ;
460+ assert . ok ( [ 200 , 204 ] . includes ( leave . status ) , `leave status ${ leave . status } ` ) ;
461+
462+ const block = await fetch ( `${ h . baseUrl } /agents/me/blocks` , {
463+ method : "POST" ,
464+ headers : agentHeaders ( h , "@alice.bot" , true ) ,
465+ body : JSON . stringify ( { handle : "@bob.bot" } ) ,
466+ } ) ;
467+ assert . equal ( block . status , 201 ) ;
468+
469+ // Exactly one session.left for @bob .bot — the voluntary one.
470+ const events = await listEvents ( "@alice.bot" , sessionId ) ;
471+ const left = events . events . filter (
472+ ( e ) => e . type === "session.left" && e . payload . agent === "@bob.bot" ,
473+ ) ;
474+ assert . equal ( left . length , 1 ) ;
475+ } ) ;
476+ } ) ;
477+
321478describe ( "local operator /agents/me" , ( ) => {
322479 let h : Harness ;
323480
0 commit comments