Skip to content

Commit 9d33db9

Browse files
nrcrewsclaude
andcommitted
block: force-leave shared sessions on §6.2 block
Local operator was recording the block but leaving the blocked agent joined in any shared session — a divergence from ASP §6.2. Add `SessionService.forceLeaveSharedSessions(blocker, blocked)` that walks `participants.listForHandle(blocked)` ∩ joined sessions where the blocker is also joined, transitions blocked → `left`, and emits `session.left{reason:"left"}` per session in one transaction. Wire into `POST /agents/me/blocks` after `blocks.add()`. Reason on the wire is "left" (schema enum is `["left", "grace_expired"]`; spec says the blocked agent isn't informed it was blocked, so the shape is identical to a voluntary leave). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0205b54 commit 9d33db9

4 files changed

Lines changed: 204 additions & 3 deletions

File tree

‎src/operator/domain/sessions.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,42 @@ export class SessionService {
357357
this.#dispatch(dispatches);
358358
}
359359

360+
/* -- force-leave on block (ASP §6.2) ------------------------------------ */
361+
362+
/**
363+
* Force-leave `blocked` from every active session where both `blocker`
364+
* and `blocked` are currently `joined`. Emits `session.left{reason:"left"}`
365+
* for each — the schema's reason enum is `["left", "grace_expired"]`,
366+
* and the spec explicitly says the blocked agent is not informed it
367+
* was blocked, so the event is shape-identical to a voluntary leave.
368+
*
369+
* Per ASP Whitepaper §6.2, this MUST run as part of the block path so
370+
* routing stops immediately. Idempotent: re-running on an already-left
371+
* participant is a no-op.
372+
*/
373+
forceLeaveSharedSessions(blocker: Handle, blocked: Handle): void {
374+
const dispatches = this.#db.transaction(() => {
375+
const all: Dispatch[] = [];
376+
for (const p of this.#repo.participants.listForHandle(blocked)) {
377+
if (p.status !== "joined") continue;
378+
const session = this.#repo.sessions.byId(p.sessionId);
379+
if (session === null || session.state !== "active") continue;
380+
const blockerRow = this.#repo.participants.get(p.sessionId, blocker);
381+
if (blockerRow === null || blockerRow.status !== "joined") continue;
382+
this.#repo.participants.setStatus(p.sessionId, blocked, "left");
383+
this.#appendEvent(p.sessionId, "session.left", {
384+
agent: blocked,
385+
reason: "left",
386+
});
387+
for (const d of this.#collectDispatches(p.sessionId)) {
388+
all.push(d);
389+
}
390+
}
391+
return all;
392+
})();
393+
this.#dispatch(dispatches);
394+
}
395+
360396
/* -- end_session -------------------------------------------------------- */
361397

362398
endSession(handle: Handle, sessionId: SessionId): void {

‎src/operator/routes/self.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { requireAgent } from "../auth.js";
2+
import type { SessionService } from "../domain/sessions.js";
23
import { BadRequestError, NotFoundError } from "../errors.js";
34
import { assertAllowlistEntry, assertHandle } from "../handles.js";
45
import type {
@@ -33,6 +34,7 @@ import type { Router } from "./router.js";
3334
*/
3435
interface SelfRoutesContext {
3536
readonly repo: OperatorRepository;
37+
readonly sessions: SessionService;
3638
}
3739

3840
export function registerSelfRoutes(router: Router, ctx: SelfRoutesContext): void {
@@ -121,6 +123,12 @@ export function registerSelfRoutes(router: Router, ctx: SelfRoutesContext): void
121123
);
122124
}
123125
const row = ctx.repo.blocks.add(agent.handle, blockedHandle);
126+
// ASP §6.2 — a new block MUST force-leave the blocked agent from
127+
// any session both agents are currently participating in. Routing
128+
// stops immediately; the blocked agent gets a `session.left` event
129+
// shape-identical to a voluntary leave (the spec is explicit that
130+
// the blocked agent is not informed it was blocked).
131+
ctx.sessions.forceLeaveSharedSessions(agent.handle, blockedHandle);
124132
sendJson(rc.res, 201, serializeBlock(row));
125133
});
126134

‎src/operator/server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function buildRouter(
138138
db: deps.db,
139139
adminTokenHash: deps.config.adminTokenHash,
140140
});
141-
registerSelfRoutes(router, { repo: deps.repo });
141+
registerSelfRoutes(router, { repo: deps.repo, sessions: service });
142142
registerSessionRoutes(router, { repo: deps.repo, service });
143143
registerSearchRoutes(router, { repo: deps.repo, service });
144144
registerFileRoutes(router, { repo: deps.repo, files: fileService });

‎tests/operator-self.test.ts‎

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
321478
describe("local operator /agents/me", () => {
322479
let h: Harness;
323480

0 commit comments

Comments
 (0)