Skip to content

Commit 380daf2

Browse files
mednsodysseus0
andauthored
feat(gateway): inject isHeartbeat into agent event broadcast payload (#80610)
Merged via squash. Prepared head SHA: cb25410 Co-authored-by: medns <1575008+medns@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0
1 parent 4e29ee5 commit 380daf2

5 files changed

Lines changed: 117 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
9494
- Plugin SDK/media-understanding: add `extractStructuredWithModel(...)` plus the optional provider-side `extractStructured(...)` seam so trusted plugins can run bounded image-first structured extraction with optional supplemental text context through provider-owned runtimes such as Codex.
9595
- Exec approvals: add `tools.exec.commandHighlighting` so parser-derived command highlighting in approval prompts can be enabled globally or per agent. (#79348) Thanks @jesse-merhi.
9696
- Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.
97+
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
9798

9899
### Fixes
99100

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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,102 @@ describe("agent event handler", () => {
247247
return payload;
248248
}
249249

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

src/gateway/server-chat.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,9 @@ export function createAgentEventHandler({
632632
const chatLink = chatRunState.registry.peek(evt.runId);
633633
const eventSessionKey =
634634
typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined;
635-
const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true;
635+
const runContext = getAgentRunContext(evt.runId);
636+
const isControlUiVisible = runContext?.isControlUiVisible ?? true;
637+
const isHeartbeat = runContext?.isHeartbeat;
636638
const sessionKey =
637639
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
638640
const clientRunId = chatLink?.clientRunId ?? evt.runId;
@@ -643,8 +645,16 @@ export function createAgentEventHandler({
643645
// Include sessionKey so Control UI can filter tool streams per session.
644646
const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null;
645647
const agentPayload = sessionKey
646-
? { ...eventForClients, sessionKey, ...(spawnedBy && { spawnedBy }) }
647-
: eventForClients;
648+
? {
649+
...eventForClients,
650+
sessionKey,
651+
...(spawnedBy && { spawnedBy }),
652+
...(isHeartbeat !== undefined && { isHeartbeat }),
653+
}
654+
: {
655+
...eventForClients,
656+
...(isHeartbeat !== undefined && { isHeartbeat }),
657+
};
648658
const last = agentRunSeq.get(evt.runId) ?? 0;
649659
const isToolEvent = evt.stream === "tool";
650660
const isItemEvent = evt.stream === "item";
@@ -657,9 +667,7 @@ export function createAgentEventHandler({
657667
const data = evt.data ? { ...evt.data } : {};
658668
delete data.result;
659669
delete data.partialResult;
660-
return sessionKey
661-
? { ...eventForClients, sessionKey, data }
662-
: { ...eventForClients, data };
670+
return { ...agentPayload, data };
663671
})()
664672
: agentPayload;
665673
if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) {
@@ -669,6 +677,7 @@ export function createAgentEventHandler({
669677
ts: Date.now(),
670678
sessionKey,
671679
...(spawnedBy && { spawnedBy }),
680+
...(isHeartbeat !== undefined && { isHeartbeat }),
672681
data: {
673682
reason: "seq gap",
674683
expected: last + 1,

0 commit comments

Comments
 (0)