Skip to content

Commit 89a21db

Browse files
authored
fix(gateway): dedupe session tool fanout
Dedupe gateway tool-event fanout so connections subscribed by both run and session receive the canonical run-scoped agent event only, while session-only subscribers keep the compatibility session.tool mirror.\n\nVerification:\n- node scripts/run-vitest.mjs src/gateway/server-chat.agent-events.test.ts\n- git diff --check\n- env -u OPENCLAW_TESTBOX pnpm check:changed\n- .agents/skills/autoreview/scripts/autoreview --mode local
1 parent d51f268 commit 89a21db

3 files changed

Lines changed: 69 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414

1515
### Fixes
1616

17+
- Gateway: avoid sending duplicate tool-event frames to Control UI connections that are subscribed by both run and session.
1718
- Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated.
1819
- Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037)
1920
- Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error.

src/gateway/server-chat.agent-events.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,7 @@ describe("agent event handler", () => {
107107
loadGatewaySessionRowForSnapshot: loadGatewaySessionRow,
108108
lifecycleErrorRetryGraceMs: params?.lifecycleErrorRetryGraceMs,
109109
isChatSendRunActive: params?.isChatSendRunActive,
110-
shouldBackoffLowPrioritySessionToolEvents:
111-
params?.shouldBackoffLowPrioritySessionToolEvents,
110+
shouldBackoffLowPrioritySessionToolEvents: params?.shouldBackoffLowPrioritySessionToolEvents,
112111
});
113112

114113
return {
@@ -1526,6 +1525,45 @@ describe("agent event handler", () => {
15261525
resetAgentRunContextForTest();
15271526
});
15281527

1528+
it("does not duplicate tool events to clients subscribed by run and session", () => {
1529+
const { broadcastToConnIds, sessionEventSubscribers, toolEventRecipients, handler } =
1530+
createHarness({
1531+
resolveSessionKeyForRun: () => "session-dedupe",
1532+
});
1533+
1534+
registerAgentRunContext("run-session-dedupe-tool", {
1535+
sessionKey: "session-dedupe",
1536+
verboseLevel: "off",
1537+
});
1538+
toolEventRecipients.add("run-session-dedupe-tool", "conn-overlap");
1539+
toolEventRecipients.add("run-session-dedupe-tool", "conn-run-only");
1540+
sessionEventSubscribers.subscribe("conn-overlap");
1541+
sessionEventSubscribers.subscribe("conn-session-only");
1542+
1543+
handler({
1544+
runId: "run-session-dedupe-tool",
1545+
seq: 1,
1546+
stream: "tool",
1547+
ts: 1_234,
1548+
data: {
1549+
phase: "start",
1550+
name: "exec",
1551+
toolCallId: "tool-session-dedupe-1",
1552+
args: { command: "echo hi" },
1553+
},
1554+
});
1555+
1556+
expect(broadcastToConnIds).toHaveBeenCalledTimes(2);
1557+
expect(requireMockArg(broadcastToConnIds, 0, 0, "run tool event")).toBe("agent");
1558+
expect(requireMockArg(broadcastToConnIds, 0, 2, "run tool recipients")).toEqual(
1559+
new Set(["conn-overlap", "conn-run-only"]),
1560+
);
1561+
expect(requireMockArg(broadcastToConnIds, 1, 0, "session tool event")).toBe("session.tool");
1562+
expect(requireMockArg(broadcastToConnIds, 1, 2, "session tool recipients")).toEqual(
1563+
new Set(["conn-session-only"]),
1564+
);
1565+
});
1566+
15291567
it("backs off session-scoped tool mirrors during queued gateway pressure", () => {
15301568
const { broadcastToConnIds, sessionEventSubscribers, toolEventRecipients, handler } =
15311569
createHarness({

src/gateway/server-chat.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,22 @@ function isTerminalToolEventPhase(phase: string): boolean {
183183
return TERMINAL_TOOL_EVENT_PHASES.has(phase);
184184
}
185185

186+
function excludeConnIds(
187+
connIds: ReadonlySet<string>,
188+
excludedConnIds: ReadonlySet<string> | undefined,
189+
): ReadonlySet<string> {
190+
if (!excludedConnIds || excludedConnIds.size === 0 || connIds.size === 0) {
191+
return connIds;
192+
}
193+
const filtered = new Set<string>();
194+
for (const connId of connIds) {
195+
if (!excludedConnIds.has(connId)) {
196+
filtered.add(connId);
197+
}
198+
}
199+
return filtered;
200+
}
201+
186202
type BroadcastDelta = { deltaText: string; replace?: true };
187203

188204
function resolveBroadcastDelta(params: {
@@ -957,12 +973,17 @@ export function createAgentEventHandler({
957973
// tool-events capability, regardless of verboseLevel. The verbose
958974
// setting only controls whether tool details are sent as channel
959975
// messages to messaging surfaces (Telegram, Discord, etc.).
960-
const recipients = toolEventRecipients.get(evt.runId);
961-
if (isControlUiVisible && !suppressHeartbeatToolEvents && recipients && recipients.size > 0) {
976+
const runToolRecipients = toolEventRecipients.get(evt.runId);
977+
if (
978+
isControlUiVisible &&
979+
!suppressHeartbeatToolEvents &&
980+
runToolRecipients &&
981+
runToolRecipients.size > 0
982+
) {
962983
broadcastToConnIds(
963984
"agent",
964985
sessionKey ? { ...agentPayload, ...buildSessionEventSnapshot(sessionKey) } : agentPayload,
965-
recipients,
986+
runToolRecipients,
966987
);
967988
}
968989
if (!isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) {
@@ -985,7 +1006,10 @@ export function createAgentEventHandler({
9851006
!suppressHeartbeatToolEvents &&
9861007
shouldMirrorSessionToolEvent
9871008
) {
988-
const sessionSubscribers = sessionEventSubscribers.getAll();
1009+
const sessionSubscribers = excludeConnIds(
1010+
sessionEventSubscribers.getAll(),
1011+
runToolRecipients,
1012+
);
9891013
if (sessionSubscribers.size > 0) {
9901014
broadcastToConnIds(
9911015
"session.tool",

0 commit comments

Comments
 (0)