diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts index cba874db37bf..b6013475e67f 100644 --- a/src/gateway/server-broadcast.board.test.ts +++ b/src/gateway/server-broadcast.board.test.ts @@ -164,6 +164,38 @@ describe("collaboration event scope guards", () => { expect(unsubscribed.socket.events).toEqual([]); }); + it("suppresses session.tool mirrors for scoped clients without a matching subscription", () => { + const subscribed = makeClient("subscribed", "operator", ["operator.read"]); + const otherSession = makeClient("other-session", "operator", ["operator.read"]); + const unscoped = makeClient("unscoped", "operator", ["operator.read"]); + for (const entry of [subscribed, otherSession]) { + entry.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS]; + } + const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + sessionMessageSubscribers.subscribe(subscribed.client.connId, "agent:main:main"); + sessionMessageSubscribers.subscribe(otherSession.client.connId, "agent:main:other"); + const { broadcastToConnIds } = createGatewayBroadcaster({ + clients: new Set([subscribed.client, otherSession.client, unscoped.client]), + sessionMessageSubscribers, + }); + + // Mirrors the server-chat session.tool fanout: targeted at all session + // subscribers, session identity carried in the payload. + broadcastToConnIds( + "session.tool", + { sessionKey: "agent:main:main", tool: { name: "exec", args: { command: "ls" } } }, + new Set(["subscribed", "other-session", "unscoped"]), + { dropIfSlow: true }, + ); + + expect(subscribed.socket.events).toEqual(["session.tool"]); + // The scoped client subscribed to a different session must not receive + // another session's tool args via the mirror event. + expect(otherSession.socket.events).toEqual([]); + // Unscoped Control UI clients keep full fanout. + expect(unscoped.socket.events).toEqual(["session.tool"]); + }); + it("delivers prepared global observer audiences exactly once", () => { const main = makeClient("main", "operator", ["operator.read"]); const legacy = makeClient("legacy", "operator", ["operator.read"]); diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 12b81f22c684..98a880d874cc 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -103,6 +103,10 @@ const SESSION_SUBSCRIPTION_EVENTS = new Set([ "chat", "chat.side_result", "session.observer", + // Mirrors the raw agent tool event (full args/result snapshots) onto + // session subscribers; omitting it here would hand scoped clients the + // exact payload the registry gate suppresses on the `agent` event. + "session.tool", ]); function serializeFrameField(name: "payload" | "stateVersion", value: unknown): string {