fix(gateway): deliver scoped global session events (#121166)

Centralize global session subscription keys by agent, preserve the default-agent raw global compatibility path, and let authoritative observer audiences reach their selected sockets without changing the public gateway broadcast contract.
This commit is contained in:
Peter Steinberger
2026-08-09 18:50:08 -07:00
committed by GitHub
parent 620fcd0e27
commit 2f97e8c9eb
18 changed files with 465 additions and 131 deletions
+2
View File
@@ -606,6 +606,8 @@ describe("abortChatRunById", () => {
expect(result).toEqual({ aborted: true });
const payload = firstBroadcastPayload(ops) as ChatAbortPayload;
expect(payload.agentId).toBe("main");
const delivery = { sessionKeys: ["agent:main:global", "global"] };
expect(ops.broadcast).toHaveBeenCalledWith("chat", payload, delivery);
expect(ops.nodeSendToSession).toHaveBeenCalledWith("agent:main:global", "chat", payload);
expect(ops.nodeSendToSession).toHaveBeenCalledWith("global", "chat", payload);
});
+12 -9
View File
@@ -25,6 +25,10 @@ import {
type ChatRunPlanSnapshot,
type ChatRunState,
} from "./server-chat-state.js";
import {
resolveSessionSubscriptionKey,
resolveSessionSubscriptionKeys,
} from "./session-subscription-keys.js";
const DEFAULT_CHAT_RUN_ABORT_GRACE_MS = 60_000;
@@ -477,20 +481,19 @@ function resolveChatAbortDeliverySessionKeys(
sessionKey: string,
agentId: string | undefined,
): string[] {
if (sessionKey !== "global") {
return [sessionKey];
}
const scopedAgentId = normalizeActiveAgentId(agentId);
if (!scopedAgentId) {
return [sessionKey];
}
const keys = [`agent:${scopedAgentId}:global`];
const cfg = ops.getRuntimeConfig?.();
const defaultAgentId = cfg ? resolveDefaultAgentId(cfg) : undefined;
if (defaultAgentId && scopedAgentId === defaultAgentId) {
keys.push(sessionKey);
const canonicalKey = resolveSessionSubscriptionKey(sessionKey, scopedAgentId);
if (canonicalKey === sessionKey) {
return [canonicalKey];
}
return keys;
return resolveSessionSubscriptionKeys(
sessionKey,
scopedAgentId,
resolveDefaultGlobalAgentId(ops),
);
}
function broadcastChatAborted(
+171 -2
View File
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_CAPS } from "../../packages/gateway-protocol/src/client-info.js";
import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_IDS,
} from "../../packages/gateway-protocol/src/client-info.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import { createSessionMessageSubscriberRegistry } from "./server-chat-state.js";
import {
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
} from "./server-chat-state.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { createSessionObserverAudience } from "./session-observer-audience.js";
import { resolveSessionSubscriptionKeys } from "./session-subscription-keys.js";
type RecordingSocket = {
bufferedAmount: number;
@@ -132,6 +140,167 @@ describe("collaboration event scope guards", () => {
expect(unsubscribed.socket.events).toEqual([]);
});
it("delivers prepared global observer audiences exactly once", () => {
const main = makeClient("main", "operator", ["operator.read"]);
const legacy = makeClient("legacy", "operator", ["operator.read"]);
const both = makeClient("both", "operator", ["operator.read"]);
const work = makeClient("work", "operator", ["operator.read"]);
const workRaw = makeClient("work-raw", "operator", ["operator.read"]);
for (const entry of [main, legacy, both, work, workRaw]) {
entry.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
}
const subscribers = createSessionMessageSubscriberRegistry();
subscribers.subscribe(main.client.connId, "agent:main:global");
subscribers.subscribe(legacy.client.connId, "global");
subscribers.subscribe(both.client.connId, "agent:main:global");
subscribers.subscribe(both.client.connId, "global");
subscribers.subscribe(work.client.connId, "agent:work:global");
subscribers.subscribe(workRaw.client.connId, "global");
const audience = createSessionObserverAudience({
subscribers,
isVisible: () => true,
getDefaultAgentId: () => "main",
});
const { broadcastToConnIds } = createGatewayBroadcaster({
clients: new Set([main.client, legacy.client, both.client, work.client, workRaw.client]),
sessionMessageSubscribers: subscribers,
});
for (const agentId of ["main", "work"]) {
const sessionKeys = resolveSessionSubscriptionKeys(" GLOBAL ", agentId, "MAIN");
const recipients = audience.recipients("global", agentId);
broadcastToConnIds("session.observer", { sessionKey: "global", agentId }, recipients, {
sessionKeys,
agentId,
});
}
expect(main.socket.events).toEqual(["session.observer"]);
expect(legacy.socket.events).toEqual(["session.observer"]);
expect(both.socket.events).toEqual(["session.observer"]);
expect(work.socket.events).toEqual(["session.observer"]);
expect(workRaw.socket.events).toEqual(["session.observer"]);
});
it("preserves event-only recipients selected by the critical observer audience", () => {
const message = makeClient("message", "operator", ["operator.read"]);
const eventOnly = makeClient("event-only", "operator", ["operator.read"]);
const unrelated = makeClient("unrelated", "operator", ["operator.read"]);
for (const entry of [message, eventOnly, unrelated]) {
entry.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
}
const subscribers = createSessionMessageSubscriberRegistry();
const sessionEventSubscribers = createSessionEventSubscriberRegistry();
subscribers.subscribe(message.client.connId, "agent:main:global");
sessionEventSubscribers.subscribe(eventOnly.client.connId);
const audience = createSessionObserverAudience({
subscribers,
sessionEventSubscribers,
isVisible: () => true,
getDefaultAgentId: () => "main",
});
const { broadcastToConnIds } = createGatewayBroadcaster({
clients: new Set([message.client, eventOnly.client, unrelated.client]),
sessionMessageSubscribers: subscribers,
});
const recipients = audience.criticalRecipients("global", "main");
broadcastToConnIds(
"session.observer",
{ sessionKey: "global", agentId: "main" },
recipients,
audience.deliveryOptions("global", "main"),
);
expect(message.socket.events).toEqual(["session.observer"]);
expect(eventOnly.socket.events).toEqual(["session.observer"]);
expect(unrelated.socket.events).toEqual([]);
});
it.each(["agent", "chat", "chat.side_result"])(
"keeps global %s events scoped to their owning agent",
(event) => {
const work = makeClient("work", "operator", ["operator.read"]);
const main = makeClient("main", "operator", ["operator.read"]);
const bareGlobal = makeClient("bare-global", "operator", ["operator.read"]);
for (const entry of [work, main, bareGlobal]) {
entry.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
}
const subscribers = createSessionMessageSubscriberRegistry();
subscribers.subscribe(work.client.connId, "agent:work:global");
subscribers.subscribe(main.client.connId, "agent:main:global");
subscribers.subscribe(bareGlobal.client.connId, "global");
const { broadcast } = createGatewayBroadcaster({
clients: new Set([work.client, main.client, bareGlobal.client]),
sessionMessageSubscribers: subscribers,
});
broadcast(
event,
{ sessionKey: "global", agentId: "work" },
{
sessionKeys: resolveSessionSubscriptionKeys("global", "work", "main"),
agentId: "work",
},
);
expect(work.socket.events).toEqual([event]);
expect(main.socket.events).toEqual([]);
expect(bareGlobal.socket.events).toEqual([]);
},
);
it("subscription-gates Browser Copilot without relying on the capability bit", () => {
const subscribed = makeClient("subscribed", "operator", ["operator.read"]);
const unrelated = makeClient("unrelated", "operator", ["operator.read"]);
for (const entry of [subscribed, unrelated]) {
entry.client.connect.client = { id: GATEWAY_CLIENT_IDS.BROWSER_COPILOT } as never;
entry.client.connect.caps = [];
}
const subscribers = createSessionMessageSubscriberRegistry();
subscribers.subscribe(subscribed.client.connId, "agent:work:global");
subscribers.subscribe(unrelated.client.connId, "agent:other:global");
const { broadcast } = createGatewayBroadcaster({
clients: new Set([subscribed.client, unrelated.client]),
sessionMessageSubscribers: subscribers,
});
broadcast(
"chat",
{ sessionKey: "global", agentId: "work" },
{
sessionKeys: ["agent:work:global"],
agentId: "work",
},
);
expect(subscribed.socket.events).toEqual(["chat"]);
expect(unrelated.socket.events).toEqual([]);
});
it.each([
{ sessionKey: "agent:work:global", agentId: "work" },
{ sessionKey: "agent:work:other", agentId: "work" },
{ sessionKey: "global", agentId: undefined },
])("preserves exact subscription keys for $sessionKey", ({ sessionKey, agentId }) => {
const subscribed = makeClient("subscribed", "operator", ["operator.read"]);
const unrelated = makeClient("unrelated", "operator", ["operator.read"]);
subscribed.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
unrelated.client.connect.caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
const subscribers = createSessionMessageSubscriberRegistry();
subscribers.subscribe(subscribed.client.connId, sessionKey);
subscribers.subscribe(unrelated.client.connId, "agent:other:global");
const { broadcast } = createGatewayBroadcaster({
clients: new Set([subscribed.client, unrelated.client]),
sessionMessageSubscribers: subscribers,
});
broadcast("chat", { sessionKey, ...(agentId ? { agentId } : {}) });
expect(subscribed.socket.events).toEqual(["chat"]);
expect(unrelated.socket.events).toEqual([]);
});
it("guards suggestion and typing events and forwards payloads to visibility filtering", () => {
const pairing = makeClient("pairing", "operator", ["operator.pairing"]);
const reader = makeClient("reader", "operator", ["operator.read"]);
+4
View File
@@ -256,6 +256,9 @@ export function createGatewayBroadcaster(params: {
}
return frameBase;
};
const sessionSubscriptionVerified =
(opts as { sessionSubscriptionVerified?: boolean } | undefined)
?.sessionSubscriptionVerified === true;
for (const c of params.clients) {
if (c.invalidated === true) {
continue;
@@ -280,6 +283,7 @@ export function createGatewayBroadcaster(params: {
SESSION_SUBSCRIPTION_EVENTS.has(event));
if (
requiresSessionSubscription &&
!(isTargeted && sessionSubscriptionVerified) &&
(!sessionKeys.length ||
!sessionKeys.some((sessionKey) =>
params.sessionMessageSubscribers?.get(sessionKey).has(c.connId),
+33 -35
View File
@@ -60,6 +60,10 @@ import {
isRestartRecoveryLifecycleEvent,
isStaleLifecycleEventForSession,
} from "./session-lifecycle-state.js";
import {
resolveSessionSubscriptionKey,
resolveSessionSubscriptionKeys,
} from "./session-subscription-keys.js";
import { loadSessionEntryReadOnly } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
@@ -631,24 +635,13 @@ export function createAgentEventHandler({
};
};
const resolveSessionDeliveryKey = (sessionKey: string, agentId?: string) => {
if (sessionKey !== "global") {
return sessionKey;
}
const scopedAgentId = agentId ?? resolveDefaultAgentId(getRuntimeConfig());
return `agent:${scopedAgentId}:global`;
};
const resolveNodeSessionDeliveryKeys = (sessionKey: string, agentId?: string) => {
if (sessionKey !== "global") {
return [sessionKey];
const resolveSessionDeliveryKeys = (sessionKey: string, agentId?: string) => {
const canonicalKey = resolveSessionSubscriptionKey(sessionKey, agentId ?? "");
if (canonicalKey === sessionKey) {
return [canonicalKey];
}
const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig());
const scopedAgentId = agentId ?? defaultAgentId;
const keys = [`agent:${scopedAgentId}:global`];
if (scopedAgentId === defaultAgentId) {
keys.push("global");
}
return keys;
return resolveSessionSubscriptionKeys(sessionKey, agentId ?? defaultAgentId, defaultAgentId);
};
const sendNodeSessionPayloadForAgent = (
sessionKey: string,
@@ -656,7 +649,7 @@ export function createAgentEventHandler({
payload: unknown,
agentId?: string,
) => {
for (const deliverySessionKey of resolveNodeSessionDeliveryKeys(sessionKey, agentId)) {
for (const deliverySessionKey of resolveSessionDeliveryKeys(sessionKey, agentId)) {
nodeSendToSession(deliverySessionKey, event, payload);
}
};
@@ -725,9 +718,9 @@ export function createAgentEventHandler({
isChatAbortMarkerCurrent(chatRunState.runs.get(clientRunId)?.abortMarker, chatLink) ||
isChatAbortMarkerCurrent(chatRunState.runs.get(evt.runId)?.abortMarker, chatLink);
const lifecycleAborted = evt.data?.aborted === true;
const deliverySessionKey = sessionKey
? resolveSessionDeliveryKey(sessionKey, sessionAgentId)
: undefined;
const deliverySessionKeys = sessionKey
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId)
: [];
const restartRecoveryState =
opts?.restartRecoveryState ??
(restartRecoverySessionKey
@@ -760,7 +753,9 @@ export function createAgentEventHandler({
!suppressRestartRecoveryProjection &&
sessionKey &&
(isControlUiVisible ||
(deliverySessionKey ? sessionMessageSubscribers.get(deliverySessionKey).size > 0 : false))
deliverySessionKeys.some(
(deliverySessionKey) => sessionMessageSubscribers.get(deliverySessionKey).size > 0,
))
) {
if (!isAborted) {
const finished = chatLink ? chatRunState.registry.shift(evt.runId) : undefined;
@@ -1083,20 +1078,22 @@ export function createAgentEventHandler({
payload: unknown,
opts?: { agentId?: string; controlUiVisible?: boolean; dropIfSlow?: boolean },
) => {
const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
const deliverySessionKeys = resolveSessionDeliveryKeys(sessionKey, opts?.agentId);
if (opts?.controlUiVisible ?? true) {
broadcast("chat", payload, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: [deliverySessionKey],
sessionKeys: deliverySessionKeys,
});
sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId);
return;
}
const recipients = sessionMessageSubscribers.get(deliverySessionKey);
const recipients = new Set(
deliverySessionKeys.flatMap((deliveryKey) => [...sessionMessageSubscribers.get(deliveryKey)]),
);
if (recipients.size > 0) {
broadcastToConnIds("chat", payload, recipients, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: [deliverySessionKey],
sessionKeys: deliverySessionKeys,
});
}
};
@@ -1174,9 +1171,7 @@ export function createAgentEventHandler({
) => {
if (opts?.controlUiVisible ?? true) {
broadcast("agent", payload, {
sessionKeys: sessionKey
? [resolveSessionDeliveryKey(sessionKey, opts?.agentId)]
: undefined,
sessionKeys: sessionKey ? resolveSessionDeliveryKeys(sessionKey, opts?.agentId) : undefined,
});
if (sessionKey) {
sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId);
@@ -1186,12 +1181,14 @@ export function createAgentEventHandler({
if (!sessionKey) {
return;
}
const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
const recipients = sessionMessageSubscribers.get(deliverySessionKey);
const deliverySessionKeys = resolveSessionDeliveryKeys(sessionKey, opts?.agentId);
const recipients = new Set(
deliverySessionKeys.flatMap((deliveryKey) => [...sessionMessageSubscribers.get(deliveryKey)]),
);
if (recipients.size > 0) {
broadcastToConnIds("agent", payload, recipients, {
dropIfSlow: opts?.dropIfSlow,
sessionKeys: [deliverySessionKey],
sessionKeys: deliverySessionKeys,
});
}
};
@@ -1414,8 +1411,9 @@ export function createAgentEventHandler({
...(isHeartbeat !== undefined && { isHeartbeat }),
};
const hasSessionMessageSubscribers = sessionKey
? sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId)).size >
0
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId).some(
(deliverySessionKey) => sessionMessageSubscribers.get(deliverySessionKey).size > 0,
)
: false;
const last = agentRunSeq.get(evt.runId) ?? 0;
const isToolEvent = evt.stream === "tool";
@@ -1454,7 +1452,7 @@ export function createAgentEventHandler({
},
{
sessionKeys: sessionKey
? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)]
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId)
: undefined,
},
);
@@ -1557,7 +1555,7 @@ export function createAgentEventHandler({
runToolRecipients,
{
sessionKeys: sessionKey
? [resolveSessionDeliveryKey(sessionKey, sessionAgentId)]
? resolveSessionDeliveryKeys(sessionKey, sessionAgentId)
: undefined,
},
);
+23 -20
View File
@@ -1,7 +1,10 @@
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { projectChatDisplayMessage } from "../chat-display-projection.js";
import {
resolveSessionSubscriptionKey,
resolveSessionSubscriptionKeys,
} from "../session-subscription-keys.js";
import type { GatewayRequestContext } from "./types.js";
type ChatBroadcastContext = Pick<
@@ -27,21 +30,21 @@ function nextChatSeq(context: { agentRunSeq: Map<string, number> }, runId: strin
return next;
}
function resolveGlobalAwareNodeChatDeliveryKeys(params: {
cfg: OpenClawConfig;
function resolveChatSessionKeys(params: {
context: Partial<Pick<GatewayRequestContext, "getRuntimeConfig">>;
sessionKey: string;
agentId?: string;
}): string[] {
if (params.sessionKey !== "global") {
return [params.sessionKey];
const canonicalKey = resolveSessionSubscriptionKey(params.sessionKey, params.agentId ?? "");
if (canonicalKey === params.sessionKey) {
return [canonicalKey];
}
const defaultAgentId = resolveDefaultAgentId(params.cfg);
const scopedAgentId = params.agentId ?? defaultAgentId;
const keys = [`agent:${scopedAgentId}:global`];
if (scopedAgentId === defaultAgentId) {
keys.push("global");
}
return keys;
const defaultAgentId = resolveDefaultAgentId(params.context.getRuntimeConfig?.() ?? {});
return resolveSessionSubscriptionKeys(
params.sessionKey,
params.agentId ?? defaultAgentId,
defaultAgentId,
);
}
export function sendGlobalAwareNodeChatPayload(params: {
@@ -52,8 +55,8 @@ export function sendGlobalAwareNodeChatPayload(params: {
event: string;
payload: unknown;
}): void {
const deliveryKeys = resolveGlobalAwareNodeChatDeliveryKeys({
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
const deliveryKeys = resolveChatSessionKeys({
context: params.context,
sessionKey: params.sessionKey,
agentId: params.agentId,
});
@@ -80,8 +83,8 @@ export function broadcastChatFinal(params: {
message: projectChatDisplayMessage(params.message),
};
params.context.broadcast("chat", payload, {
sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
sessionKeys: resolveChatSessionKeys({
context: params.context,
sessionKey: params.sessionKey,
agentId: payloadAgentId,
}),
@@ -121,8 +124,8 @@ export function broadcastSideResult(params: {
seq,
};
params.context.broadcast("chat.side_result", payload, {
sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
sessionKeys: resolveChatSessionKeys({
context: params.context,
sessionKey: params.payload.sessionKey,
agentId: payloadAgentId,
}),
@@ -154,8 +157,8 @@ export function broadcastChatError(params: {
errorMessage: params.errorMessage,
};
params.context.broadcast("chat", payload, {
sessionKeys: resolveGlobalAwareNodeChatDeliveryKeys({
cfg: params.context.getRuntimeConfig?.() ?? ({} as OpenClawConfig),
sessionKeys: resolveChatSessionKeys({
context: params.context,
sessionKey: params.sessionKey,
agentId: payloadAgentId,
}),
+3 -2
View File
@@ -5,7 +5,7 @@ import {
validateChatInjectParams,
validateChatToolTitlesParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveDefaultAgentId, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveSessionWorkStartError } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js";
@@ -14,6 +14,7 @@ import {
projectChatDisplayMessage,
resolveEffectiveChatHistoryMaxChars,
} from "../chat-display-projection.js";
import { resolveSessionSubscriptionKeys } from "../session-subscription-keys.js";
import {
loadSessionEntry,
loadSessionEntryReadOnly,
@@ -214,7 +215,7 @@ export const chatHandlers: GatewayRequestHandlers = {
message,
};
context.broadcast("chat", chatPayload, {
sessionKeys: sessionKey === "global" && agentId ? [`agent:${agentId}:global`] : [sessionKey],
sessionKeys: resolveSessionSubscriptionKeys(sessionKey, agentId, resolveDefaultAgentId(cfg)),
});
sendGlobalAwareNodeChatPayload({
context,
@@ -9,8 +9,8 @@ import {
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { canReviewOperatorApproval } from "../operator-approval-authorization.js";
import { APPROVALS_SCOPE } from "../operator-scopes.js";
import { sessionObserverScopeKey } from "../session-observer-model.js";
import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js";
import { resolveSessionSubscriptionKey } from "../session-subscription-keys.js";
import { resolveSessionStoreKey } from "../session-utils.js";
import { requireSessionKey } from "./sessions-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -109,7 +109,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = {
sessionKey: key,
...(requestedAgentId ? { storeAgentId: requestedAgentId } : {}),
});
const subscriptionKey = sessionObserverScopeKey(
const subscriptionKey = resolveSessionSubscriptionKey(
canonicalKey,
requestedAgentId ?? resolveDefaultAgentId(cfg),
);
@@ -194,7 +194,7 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = {
sessionKey: key,
...(requestedAgentId ? { storeAgentId: requestedAgentId } : {}),
});
const subscriptionKey = sessionObserverScopeKey(
const subscriptionKey = resolveSessionSubscriptionKey(
canonicalKey,
requestedAgentId ?? resolveDefaultAgentId(cfg),
);
@@ -11,6 +11,7 @@ import {
type SessionSharingIdentity,
type SessionTypingEvent,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
addSessionSuggestion,
claimSessionSuggestionDispatch,
@@ -30,6 +31,7 @@ import {
resolveSessionSharingTarget,
resolveSessionVisibility,
} from "../session-sharing.js";
import { resolveSessionSubscriptionKeys as subscriptionKeys } from "../session-subscription-keys.js";
import { handleChatSend } from "./chat-send-handler.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js";
@@ -695,6 +697,7 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = {
if (liveIdentities.size < 2 || !liveIdentities.has(actor.id)) {
return false;
}
const defaultAgentId = resolveDefaultAgentId(context.getRuntimeConfig());
const event: SessionTypingEvent = {
sessionKey: target.canonicalKey,
sessionId: current.entry.sessionId,
@@ -704,7 +707,7 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = {
ts: Date.now(),
};
context.broadcast("session.typing", event, {
sessionKeys: [...sessionKeys].toSorted(),
sessionKeys: subscriptionKeys(current.canonicalKey, current.agentId, defaultAgentId),
agentId: target.agentId,
dropIfSlow: true,
});
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { sessionSuggestionHandlers } from "./sessions-suggestions.js";
@@ -42,9 +43,9 @@ function client(profileId: string, connId: string): GatewayClient {
};
}
function context(broadcast = vi.fn()): GatewayRequestContext {
function context(broadcast = vi.fn(), cfg: OpenClawConfig = {}): GatewayRequestContext {
return {
getRuntimeConfig: () => ({}),
getRuntimeConfig: () => cfg,
broadcast,
broadcastToConnIds: vi.fn(),
chatAbortControllers: new Map(),
@@ -56,6 +57,7 @@ async function callTyping(params: {
sessionKey: string;
sessionId: string;
typing: boolean;
agentId?: string;
client: GatewayClient;
context: GatewayRequestContext;
}) {
@@ -63,6 +65,7 @@ async function callTyping(params: {
const requestParams = {
sessionKey: params.sessionKey,
sessionId: params.sessionId,
...(params.agentId ? { agentId: params.agentId } : {}),
typing: params.typing,
};
await sessionSuggestionHandlers["session.typing"]?.({
@@ -87,6 +90,47 @@ afterEach(() => {
});
describe("session typing handler", () => {
it.each([
{ agentId: "main", expected: ["agent:main:global", "global"] },
{ agentId: "work", expected: ["agent:work:global"] },
])("uses the canonical global subscription keys for $agentId", async ({ agentId, expected }) => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const cfg = {
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
} satisfies OpenClawConfig;
await upsertSessionEntry(
{ agentId, sessionKey: "global" },
{
sessionId: `session-${agentId}`,
updatedAt: 1,
createdActor: { type: "human", id: "owner" },
visibility: "shared",
},
);
mocks.presence = [
{ user: { id: "alice" }, watchedSessions: ["global"] },
{ user: { id: "owner" }, watchedSessions: ["global"] },
];
const broadcast = vi.fn();
expect(
await callTyping({
sessionKey: "global",
sessionId: `session-${agentId}`,
agentId,
typing: true,
client: client("alice", `alice-${agentId}`),
context: context(broadcast, cfg),
}),
).toEqual({ ok: true, broadcast: true });
expect(broadcast).toHaveBeenCalledWith(
"session.typing",
expect.objectContaining({ agentId, sessionKey: "global" }),
expect.objectContaining({ agentId, sessionKeys: expected }),
);
});
});
it("keeps an identity typing until its last active connection stops", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
vi.useFakeTimers();
+11 -18
View File
@@ -28,6 +28,7 @@ import {
buildGatewaySessionEventFields,
buildGatewaySessionEventRow,
} from "./session-event-payload.js";
import { resolveSessionSubscriptionKeys } from "./session-subscription-keys.js";
import {
attachOpenClawTranscriptMeta,
readSessionMessageCountAsync,
@@ -91,23 +92,6 @@ function readTranscriptUpdateLifecycleOwner(
return lifecycleRevision ? { lifecycleRevision } : {};
}
function resolveSessionMessageBroadcastKeys(sessionKey: string, agentId?: string): string[] {
// Global sessions can be subscribed through either the raw global key or the
// default-agent scoped key; non-default agent global sessions stay scoped.
const normalizedAgentId = normalizeOptionalString(agentId);
if (sessionKey === "global") {
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig()));
if (normalizedAgentId) {
const scopedKey = `agent:${normalizeAgentId(normalizedAgentId)}:global`;
return normalizeAgentId(normalizedAgentId) === defaultAgentId
? [scopedKey, sessionKey]
: [scopedKey];
}
return [`agent:${defaultAgentId}:global`, sessionKey];
}
return [sessionKey];
}
function buildGatewaySessionSnapshot(params: {
sessionRow: GatewaySessionRow | null | undefined;
agentId?: string;
@@ -280,7 +264,16 @@ async function handleTranscriptUpdateBroadcast(
for (const connId of params.sessionEventSubscribers.getAll()) {
connIds.add(connId);
}
for (const broadcastKey of resolveSessionMessageBroadcastKeys(sessionKey, routingAgentId)) {
let broadcastKeys = [sessionKey];
if (sessionKey === "global") {
const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig());
broadcastKeys = resolveSessionSubscriptionKeys(
sessionKey,
routingAgentId ?? defaultAgentId,
defaultAgentId,
);
}
for (const broadcastKey of broadcastKeys) {
for (const connId of params.sessionMessageSubscribers.get(broadcastKey)) {
connIds.add(connId);
}
@@ -123,6 +123,21 @@ function waitForSessionMessageEvent(
);
}
function waitForSessionObserverEvent(
ws: Awaited<ReturnType<Awaited<ReturnType<typeof createGatewaySuiteHarness>>["openWs"]>>,
runId: string,
timeoutMs?: number,
) {
return onceMessage(
ws,
(message) =>
message.type === "event" &&
message.event === "session.observer" &&
(message.payload as { runId?: string } | undefined)?.runId === runId,
timeoutMs,
);
}
function waitForSessionsChangedMessagePhase(
ws: Awaited<ReturnType<Awaited<ReturnType<typeof createGatewaySuiteHarness>>["openWs"]>>,
sessionKey: string,
@@ -1998,6 +2013,77 @@ describe("session.message websocket events", () => {
}
});
test("routes a subscribed global observer event through the real gateway socket once", async () => {
const storePath = await createSessionStoreFile();
testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "work" }] };
await writeSessionStore({
entries: { global: { sessionId: "sess-work-observer", updatedAt: Date.now() } },
storePath,
agentId: "work",
});
const workWs = await harness.openWs();
const mainWs = await harness.openWs();
const runId = "run-work-global-observer";
const workEvents: unknown[] = [];
const mainEvents: unknown[] = [];
const collect = (target: unknown[]) => (data: RawData) => {
const message = JSON.parse(rawDataToString(data)) as { event?: string; payload?: unknown };
if (message.event === "session.observer") {
target.push(message.payload);
}
};
const collectWork = collect(workEvents);
const collectMain = collect(mainEvents);
workWs.on("message", collectWork);
mainWs.on("message", collectMain);
try {
const caps = [GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS];
await connectOk(workWs, { scopes: ["operator.read"], caps });
await connectOk(mainWs, { scopes: ["operator.read"], caps });
expect(
await rpcReq(workWs, "sessions.messages.subscribe", {
key: " GLOBAL ",
agentId: " WORK ",
}),
).toMatchObject({ ok: true, payload: { key: "global", subscribed: true } });
await rpcReq(mainWs, "sessions.messages.subscribe", { key: "global", agentId: "main" });
await rpcReq(workWs, "sessions.observer.visibility", { visible: true });
await rpcReq(mainWs, "sessions.observer.visibility", { visible: true });
const workEvent = waitForSessionObserverEvent(workWs, runId);
const noMainEvent = expectNoMessageWithin({
watch: (timeoutMs) => waitForSessionObserverEvent(mainWs, runId, timeoutMs),
timeoutMs: 250,
});
emitAgentEvent({
runId,
sessionKey: "global",
agentId: "work",
stream: "item",
data: {
kind: "preamble",
phase: "update",
progressText: "Inspecting the work session",
},
});
await workEvent;
await noMainEvent;
await new Promise<void>((resolve) => {
setTimeout(resolve, 50);
});
expect(workEvents).toHaveLength(1);
expect(mainEvents).toHaveLength(0);
} finally {
workWs.off("message", collectWork);
mainWs.off("message", collectMain);
workWs.close();
mainWs.close();
testState.agentsConfig = undefined;
testState.sessionStorePath = undefined;
}
});
test("routes unscoped global transcript events to default-agent global subscribers", async () => {
const storePath = await createSessionStoreFile();
const transcriptPath = path.join(path.dirname(storePath), "sess-default-global.jsonl");
+11 -14
View File
@@ -1,9 +1,8 @@
import { normalizeAgentId } from "../routing/session-key.js";
import type {
SessionEventSubscriberRegistry,
SessionMessageSubscriberRegistry,
} from "./server-chat-state.js";
import { sessionObserverScopeKey } from "./session-observer-model.js";
import { resolveSessionSubscriptionKeys } from "./session-subscription-keys.js";
export function createSessionObserverAudience(params: {
subscribers: SessionMessageSubscriberRegistry;
@@ -12,18 +11,7 @@ export function createSessionObserverAudience(params: {
getDefaultAgentId: () => string;
}) {
const messageSubscriberKeys = (sessionKey: string, agentId: string): string[] => {
// sessions.messages.subscribe canonicalizes selected-agent global aliases
// to this same qualified key before registering the connection.
const scopedKey = sessionObserverScopeKey(sessionKey, agentId);
if (
sessionKey === "global" &&
normalizeAgentId(agentId) === normalizeAgentId(params.getDefaultAgentId())
) {
// Keep legacy default-agent global subscribers while non-default global
// sessions remain confined to their agent-qualified stream.
return [scopedKey, sessionKey];
}
return [scopedKey];
return resolveSessionSubscriptionKeys(sessionKey, agentId, params.getDefaultAgentId());
};
const messageRecipients = (sessionKey: string, agentId: string): Set<string> => {
@@ -37,6 +25,15 @@ export function createSessionObserverAudience(params: {
};
return {
deliveryOptions(sessionKey: string, agentId: string) {
return {
agentId,
dropIfSlow: true,
sessionKeys: messageSubscriberKeys(sessionKey, agentId),
sessionSubscriptionVerified: true,
};
},
has(sessionKey: string, agentId: string): boolean {
for (const connId of messageRecipients(sessionKey, agentId)) {
if (params.isVisible(connId)) {
+2 -6
View File
@@ -24,11 +24,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AgentEventPayload } from "../infra/agent-events.js";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import { redactToolPayloadText } from "../logging/redact.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type {
SessionEventSubscriberRegistry,
SessionMessageSubscriberRegistry,
} from "./server-chat-state.js";
import { resolveSessionSubscriptionKey } from "./session-subscription-keys.js";
const HEADLINE_MAX_CHARS = 120;
const ASSESSMENT_MAX_CHARS = 320;
@@ -38,10 +38,6 @@ const MAX_DORMANT_RUNS = 256;
const MAX_DISABLED_RUNS = 512;
export const SESSION_OBSERVER_MODEL_MAX_TOKENS = 300;
export function sessionObserverScopeKey(sessionKey: string, agentId: string): string {
return sessionKey === "global" ? `agent:${normalizeAgentId(agentId)}:global` : sessionKey;
}
type PrepareModel = typeof prepareSimpleCompletionModelForAgent;
type CompleteModel = typeof completeWithPreparedSimpleCompletionModel;
type PreparedModel = Awaited<ReturnType<PrepareModel>>;
@@ -125,7 +121,7 @@ export function rememberSessionObserverDormantRun(
// map so a later resume cannot restart below an already broadcast revision.
rememberSessionObserverRevisionFloor(
floors,
sessionObserverScopeKey(evicted.sessionKey, evicted.agentId),
resolveSessionSubscriptionKey(evicted.sessionKey, evicted.agentId),
{
revision: evicted.revision,
previousDigest: evicted.previousDigest,
@@ -114,7 +114,12 @@ describe("session observer terminal, persistence, synthesis, and races", () => {
"session.observer",
expect.objectContaining({ health: "done" }),
harness.subscribers.get("agent:main:session-1"),
{ dropIfSlow: true },
expect.objectContaining({
agentId: "main",
dropIfSlow: true,
sessionKeys: ["agent:main:session-1"],
sessionSubscriptionVerified: true,
}),
);
});
+8 -4
View File
@@ -97,7 +97,7 @@ describe("session observer", () => {
"session.observer",
expect.objectContaining({ headline: "Inspecting mobile rows", health: "on-track" }),
new Set(["conn-1"]),
{ dropIfSlow: true },
expect.objectContaining({ dropIfSlow: true }),
);
expect(harness.persistDigest).toHaveBeenCalledOnce();
harness.observer.dispose();
@@ -108,9 +108,13 @@ describe("session observer", () => {
vi.setSystemTime(1_000);
const harness = createHarness({ subscribe: false });
harness.subscribers.subscribe("conn-main", "agent:main:global")?.commit();
harness.subscribers.subscribe("conn-legacy", "global")?.commit();
harness.subscribers.subscribe("conn-work", "agent:work:global")?.commit();
harness.subscribers.subscribe("conn-work-raw", "global")?.commit();
declareObserverVisibility(harness.observer, "conn-main");
declareObserverVisibility(harness.observer, "conn-legacy");
declareObserverVisibility(harness.observer, "conn-work");
declareObserverVisibility(harness.observer, "conn-work-raw");
harness.observer.handleEvent(
event({
@@ -136,14 +140,14 @@ describe("session observer", () => {
[
"session.observer",
expect.objectContaining({ agentId: "main", revision: 1, sessionKey: "global" }),
new Set(["conn-main"]),
{ dropIfSlow: true },
new Set(["conn-main", "conn-legacy", "conn-work-raw"]),
expect.objectContaining({ sessionKeys: ["agent:main:global", "global"] }),
],
[
"session.observer",
expect.objectContaining({ agentId: "work", revision: 1, sessionKey: "global" }),
new Set(["conn-work"]),
{ dropIfSlow: true },
expect.objectContaining({ sessionKeys: ["agent:work:global"] }),
],
]);
harness.observer.dispose();
+17 -14
View File
@@ -29,7 +29,6 @@ import {
rememberSessionObserverDisabledRun,
rememberSessionObserverDormantRun,
rememberSessionObserverRevisionFloor,
sessionObserverScopeKey,
synthesizeSessionObserverTerminalDigest,
} from "./session-observer-model.js";
import type {
@@ -41,6 +40,7 @@ import type {
import { createSessionObserverDigestPersister } from "./session-observer-persistence.js";
import { createSessionObserverPreamblePublisher } from "./session-observer-preamble.js";
import { resolveStoredSessionKeyForAgentStore as resolveStoreKey } from "./session-store-key.js";
import { resolveSessionSubscriptionKey } from "./session-subscription-keys.js";
const observerLog = createSubsystemLogger("gateway/session-observer");
@@ -76,7 +76,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
const cfg = deps.getConfig();
const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
const canonicalSessionKey = resolveStoreKey({ cfg, agentId, sessionKey });
const state = states.get(sessionObserverScopeKey(canonicalSessionKey, agentId));
const state = states.get(resolveSessionSubscriptionKey(canonicalSessionKey, agentId));
if (state) {
flushSessionActivityAssistantNote(state);
return {
@@ -105,7 +105,8 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
const runStillCurrent = (runId: string, sessionKey: string, agentId: string) => () =>
!disposed &&
!supersededRuns.has(runId) &&
(states.get(sessionObserverScopeKey(sessionKey, agentId))?.runId ?? runId) === runId;
(states.get(resolveSessionSubscriptionKey(sessionKey, agentId))?.runId ?? runId) === runId;
const persistAcceptedDigest = createSessionObserverDigestPersister({
now,
persistDigest,
@@ -132,7 +133,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
"session.observer",
digest,
audience.recipients(state.sessionKey, state.agentId),
{ dropIfSlow: true },
audience.deliveryOptions(state.sessionKey, state.agentId),
);
void persistAcceptedDigest(state, digest, false, "preamble");
},
@@ -174,9 +175,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
"session.observer",
digest,
audience.recipients(digest.sessionKey, agentId),
{
dropIfSlow: true,
},
audience.deliveryOptions(digest.sessionKey, agentId),
);
}
} catch (error) {
@@ -185,7 +184,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
}
const stateIsTracked = (state: SessionObserverState): boolean =>
states.get(sessionObserverScopeKey(state.sessionKey, state.agentId)) === state;
states.get(resolveSessionSubscriptionKey(state.sessionKey, state.agentId)) === state;
const dropState = (state: SessionObserverState) => {
preamblePublisher.clear(state);
@@ -193,9 +192,8 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
clearTimeoutFn(state.timer);
}
modelSlots.invalidateRequest(state);
const scopeKey = sessionObserverScopeKey(state.sessionKey, state.agentId);
if (stateIsTracked(state)) {
states.delete(scopeKey);
states.delete(resolveSessionSubscriptionKey(state.sessionKey, state.agentId));
}
};
@@ -417,7 +415,12 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
const recipients = criticalTransition
? audience.criticalRecipients(state.sessionKey, state.agentId)
: audience.recipients(state.sessionKey, state.agentId);
deps.broadcastToConnIds("session.observer", digest, recipients, { dropIfSlow: true });
deps.broadcastToConnIds(
"session.observer",
digest,
recipients,
audience.deliveryOptions(state.sessionKey, state.agentId),
);
await persistAcceptedDigest(state, digest, final);
if (final) {
dormantRuns.delete(state.runId);
@@ -479,7 +482,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
if (!agentId || !audience.has(sessionKey, agentId)) {
return undefined;
}
const scopeKey = sessionObserverScopeKey(sessionKey, agentId);
const scopeKey = resolveSessionSubscriptionKey(sessionKey, agentId);
const cfg = deps.getConfig();
if (cfg.gateway?.controlUi?.sessionObserver === false) {
return undefined;
@@ -592,7 +595,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
}
return;
}
const scopeKey = sessionObserverScopeKey(sessionKey, agentId);
const scopeKey = resolveSessionSubscriptionKey(sessionKey, agentId);
if (terminal && audience.recipients(sessionKey, agentId).size === 0) {
void synthesizeTerminalDigest({ event, state: states.get(scopeKey) });
dormantRuns.delete(event.runId);
@@ -621,7 +624,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve
const superseded = [...dormantRuns.values()]
.filter(
(run) =>
sessionObserverScopeKey(run.sessionKey, run.agentId) === scopeKey &&
resolveSessionSubscriptionKey(run.sessionKey, run.agentId) === scopeKey &&
run.runId !== event.runId,
)
.toSorted(
+23
View File
@@ -0,0 +1,23 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeAgentId } from "../routing/session-key.js";
export function resolveSessionSubscriptionKey(sessionKey: string, agentId: string): string {
return normalizeLowercaseStringOrEmpty(sessionKey) === "global"
? `agent:${normalizeAgentId(agentId)}:global`
: sessionKey;
}
export function resolveSessionSubscriptionKeys(
sessionKey: string,
agentId: string,
defaultAgentId?: string,
): string[] {
const canonicalKey = resolveSessionSubscriptionKey(sessionKey, agentId);
// Raw global is a legacy default-agent stream. Non-default agents must stay
// on their qualified key even when callers supplied the global alias.
return defaultAgentId &&
normalizeLowercaseStringOrEmpty(sessionKey) === "global" &&
normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)
? [canonicalKey, "global"]
: [canonicalKey];
}