Skip to content

Commit 5c82a18

Browse files
committed
feat(gateway): inject isHeartbeat into agent event broadcast payload
This allows external callers to identify whether an agent event originated from a heartbeat run without polluting the core AgentEventPayload type.
1 parent 3aa43c1 commit 5c82a18

4 files changed

Lines changed: 95 additions & 6 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ public struct AgentEvent: Codable, Sendable {
500500
public let stream: String
501501
public let ts: Int
502502
public let spawnedby: String?
503+
public let isheartbeat: Bool?
503504
public let data: [String: AnyCodable]
504505

505506
public init(
@@ -508,13 +509,15 @@ public struct AgentEvent: Codable, Sendable {
508509
stream: String,
509510
ts: Int,
510511
spawnedby: String?,
512+
isheartbeat: Bool?,
511513
data: [String: AnyCodable])
512514
{
513515
self.runid = runid
514516
self.seq = seq
515517
self.stream = stream
516518
self.ts = ts
517519
self.spawnedby = spawnedby
520+
self.isheartbeat = isheartbeat
518521
self.data = data
519522
}
520523

@@ -524,6 +527,7 @@ public struct AgentEvent: Codable, Sendable {
524527
case stream
525528
case ts
526529
case spawnedby = "spawnedBy"
530+
case isheartbeat = "isHeartbeat"
527531
case data
528532
}
529533
}

src/gateway/protocol/schema/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const AgentEventSchema = Type.Object(
3131
stream: NonEmptyString,
3232
ts: Type.Integer({ minimum: 0 }),
3333
spawnedBy: Type.Optional(NonEmptyString),
34+
isHeartbeat: Type.Optional(Type.Boolean()),
3435
data: Type.Record(Type.String(), Type.Unknown()),
3536
},
3637
{ additionalProperties: false },

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,81 @@ describe("agent event handler", () => {
253253
return payload;
254254
}
255255

256+
it("injects isHeartbeat into agent broadcast payloads when present in run context", () => {
257+
const harness = createHarness();
258+
registerAgentRunContext("run-heartbeat-true", { sessionKey: "session-1", isHeartbeat: true });
259+
registerAgentRunContext("run-heartbeat-false", { sessionKey: "session-2", isHeartbeat: false });
260+
261+
// 1. isHeartbeat: true
262+
harness.handler({
263+
runId: "run-heartbeat-true",
264+
seq: 1,
265+
stream: "assistant",
266+
ts: 100,
267+
data: { text: "hello" },
268+
});
269+
270+
const agentPayload1 = harness.broadcast.mock.calls.find(([event]) => event === "agent")?.[1] as Record<string, unknown>;
271+
expect(agentPayload1).toBeDefined();
272+
expect(agentPayload1.isHeartbeat).toBe(true);
273+
274+
// sessionKey is required for nodeSendToSession to be called
275+
harness.chatRunState.registry.add("run-heartbeat-true", { sessionKey: "session-1", clientRunId: "run-heartbeat-true" });
276+
harness.handler({
277+
runId: "run-heartbeat-true",
278+
seq: 2,
279+
stream: "assistant",
280+
ts: 100,
281+
data: { text: "hello" },
282+
});
283+
284+
const nodeSendPayload1 = harness.nodeSendToSession.mock.calls.find(([, event]) => event === "agent")?.[2] as Record<string, unknown>;
285+
expect(nodeSendPayload1).toBeDefined();
286+
expect(nodeSendPayload1.isHeartbeat).toBe(true);
287+
288+
harness.broadcast.mockClear();
289+
harness.nodeSendToSession.mockClear();
290+
291+
// 2. isHeartbeat: false
292+
harness.chatRunState.registry.add("run-heartbeat-false", { sessionKey: "session-2", clientRunId: "run-heartbeat-false" });
293+
harness.handler({
294+
runId: "run-heartbeat-false",
295+
seq: 1,
296+
stream: "assistant",
297+
ts: 101,
298+
data: { text: "hello" },
299+
});
300+
301+
const agentPayload2 = harness.broadcast.mock.calls.find(([event]) => event === "agent")?.[1] as Record<string, unknown>;
302+
expect(agentPayload2).toBeDefined();
303+
expect(agentPayload2.isHeartbeat).toBe(false);
304+
305+
const nodeSendPayload2 = harness.nodeSendToSession.mock.calls.find(([, event]) => event === "agent")?.[2] as Record<string, unknown>;
306+
expect(nodeSendPayload2).toBeDefined();
307+
expect(nodeSendPayload2.isHeartbeat).toBe(false);
308+
309+
harness.broadcast.mockClear();
310+
harness.nodeSendToSession.mockClear();
311+
312+
// 3. isHeartbeat: undefined (absent)
313+
harness.chatRunState.registry.add("run-normal", { sessionKey: "session-3", clientRunId: "run-normal" });
314+
harness.handler({
315+
runId: "run-normal",
316+
seq: 1,
317+
stream: "assistant",
318+
ts: 102,
319+
data: { text: "hello" },
320+
});
321+
322+
const normalBroadcast = harness.broadcast.mock.calls.find(([event]) => event === "agent")?.[1] as Record<string, unknown>;
323+
expect(normalBroadcast).toBeDefined();
324+
expect("isHeartbeat" in normalBroadcast).toBe(false);
325+
326+
const normalNodeSend = harness.nodeSendToSession.mock.calls.find(([, event]) => event === "agent")?.[2] as Record<string, unknown>;
327+
expect(normalNodeSend).toBeDefined();
328+
expect("isHeartbeat" in normalNodeSend).toBe(false);
329+
});
330+
256331
it("emits chat delta for assistant text-only events", () => {
257332
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
258333
createHarness({ now: 1_000 }),

src/gateway/server-chat.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,9 @@ export function createAgentEventHandler({
596596
const chatLink = chatRunState.registry.peek(evt.runId);
597597
const eventSessionKey =
598598
typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined;
599-
const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true;
599+
const runContext = getAgentRunContext(evt.runId);
600+
const isControlUiVisible = runContext?.isControlUiVisible ?? true;
601+
const isHeartbeat = runContext?.isHeartbeat;
600602
const sessionKey =
601603
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
602604
const clientRunId = chatLink?.clientRunId ?? evt.runId;
@@ -607,8 +609,16 @@ export function createAgentEventHandler({
607609
// Include sessionKey so Control UI can filter tool streams per session.
608610
const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null;
609611
const agentPayload = sessionKey
610-
? { ...eventForClients, sessionKey, ...(spawnedBy && { spawnedBy }) }
611-
: eventForClients;
612+
? {
613+
...eventForClients,
614+
sessionKey,
615+
...(spawnedBy && { spawnedBy }),
616+
...(isHeartbeat !== undefined && { isHeartbeat }),
617+
}
618+
: {
619+
...eventForClients,
620+
...(isHeartbeat !== undefined && { isHeartbeat }),
621+
};
612622
const last = agentRunSeq.get(evt.runId) ?? 0;
613623
const isToolEvent = evt.stream === "tool";
614624
const isItemEvent = evt.stream === "item";
@@ -621,9 +631,7 @@ export function createAgentEventHandler({
621631
const data = evt.data ? { ...evt.data } : {};
622632
delete data.result;
623633
delete data.partialResult;
624-
return sessionKey
625-
? { ...eventForClients, sessionKey, data }
626-
: { ...eventForClients, data };
634+
return { ...agentPayload, data };
627635
})()
628636
: agentPayload;
629637
if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) {
@@ -633,6 +641,7 @@ export function createAgentEventHandler({
633641
ts: Date.now(),
634642
sessionKey,
635643
...(spawnedBy && { spawnedBy }),
644+
...(isHeartbeat !== undefined && { isHeartbeat }),
636645
data: {
637646
reason: "seq gap",
638647
expected: last + 1,

0 commit comments

Comments
 (0)