feat(gateway): inject isHeartbeat into agent event broadcast payload (#80610)

Merged via squash.

Prepared head SHA: cb254108a1
Co-authored-by: medns <1575008+medns@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
This commit is contained in:
Super Zheng
2026-05-12 19:41:14 +08:00
committed by GitHub
parent 4e29ee516b
commit 380daf2f50
5 changed files with 117 additions and 6 deletions

View File

@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
### Fixes

View File

@@ -500,6 +500,7 @@ public struct AgentEvent: Codable, Sendable {
public let stream: String
public let ts: Int
public let spawnedby: String?
public let isheartbeat: Bool?
public let data: [String: AnyCodable]
public init(
@@ -508,6 +509,7 @@ public struct AgentEvent: Codable, Sendable {
stream: String,
ts: Int,
spawnedby: String?,
isheartbeat: Bool?,
data: [String: AnyCodable])
{
self.runid = runid
@@ -515,6 +517,7 @@ public struct AgentEvent: Codable, Sendable {
self.stream = stream
self.ts = ts
self.spawnedby = spawnedby
self.isheartbeat = isheartbeat
self.data = data
}
@@ -524,6 +527,7 @@ public struct AgentEvent: Codable, Sendable {
case stream
case ts
case spawnedby = "spawnedBy"
case isheartbeat = "isHeartbeat"
case data
}
}

View File

@@ -31,6 +31,7 @@ export const AgentEventSchema = Type.Object(
stream: NonEmptyString,
ts: Type.Integer({ minimum: 0 }),
spawnedBy: Type.Optional(NonEmptyString),
isHeartbeat: Type.Optional(Type.Boolean()),
data: Type.Record(Type.String(), Type.Unknown()),
},
{ additionalProperties: false },

View File

@@ -247,6 +247,102 @@ describe("agent event handler", () => {
return payload;
}
it("injects isHeartbeat into agent broadcast payloads when present in run context", () => {
const harness = createHarness();
registerAgentRunContext("run-heartbeat-true", { sessionKey: "session-1", isHeartbeat: true });
registerAgentRunContext("run-heartbeat-false", { sessionKey: "session-2", isHeartbeat: false });
// 1. isHeartbeat: true
harness.handler({
runId: "run-heartbeat-true",
seq: 1,
stream: "assistant",
ts: 100,
data: { text: "hello" },
});
const agentPayload1 = harness.broadcast.mock.calls.find(
([event]) => event === "agent",
)?.[1] as Record<string, unknown>;
expect(agentPayload1).toBeDefined();
expect(agentPayload1.isHeartbeat).toBe(true);
// sessionKey is required for nodeSendToSession to be called
harness.chatRunState.registry.add("run-heartbeat-true", {
sessionKey: "session-1",
clientRunId: "run-heartbeat-true",
});
harness.handler({
runId: "run-heartbeat-true",
seq: 2,
stream: "assistant",
ts: 100,
data: { text: "hello" },
});
const nodeSendPayload1 = harness.nodeSendToSession.mock.calls.find(
([, event]) => event === "agent",
)?.[2] as Record<string, unknown>;
expect(nodeSendPayload1).toBeDefined();
expect(nodeSendPayload1.isHeartbeat).toBe(true);
harness.broadcast.mockClear();
harness.nodeSendToSession.mockClear();
// 2. isHeartbeat: false
harness.chatRunState.registry.add("run-heartbeat-false", {
sessionKey: "session-2",
clientRunId: "run-heartbeat-false",
});
harness.handler({
runId: "run-heartbeat-false",
seq: 1,
stream: "assistant",
ts: 101,
data: { text: "hello" },
});
const agentPayload2 = harness.broadcast.mock.calls.find(
([event]) => event === "agent",
)?.[1] as Record<string, unknown>;
expect(agentPayload2).toBeDefined();
expect(agentPayload2.isHeartbeat).toBe(false);
const nodeSendPayload2 = harness.nodeSendToSession.mock.calls.find(
([, event]) => event === "agent",
)?.[2] as Record<string, unknown>;
expect(nodeSendPayload2).toBeDefined();
expect(nodeSendPayload2.isHeartbeat).toBe(false);
harness.broadcast.mockClear();
harness.nodeSendToSession.mockClear();
// 3. isHeartbeat: undefined (absent)
harness.chatRunState.registry.add("run-normal", {
sessionKey: "session-3",
clientRunId: "run-normal",
});
harness.handler({
runId: "run-normal",
seq: 1,
stream: "assistant",
ts: 102,
data: { text: "hello" },
});
const normalBroadcast = harness.broadcast.mock.calls.find(
([event]) => event === "agent",
)?.[1] as Record<string, unknown>;
expect(normalBroadcast).toBeDefined();
expect("isHeartbeat" in normalBroadcast).toBe(false);
const normalNodeSend = harness.nodeSendToSession.mock.calls.find(
([, event]) => event === "agent",
)?.[2] as Record<string, unknown>;
expect(normalNodeSend).toBeDefined();
expect("isHeartbeat" in normalNodeSend).toBe(false);
});
it("emits chat delta for assistant text-only events", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),

View File

@@ -632,7 +632,9 @@ export function createAgentEventHandler({
const chatLink = chatRunState.registry.peek(evt.runId);
const eventSessionKey =
typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined;
const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true;
const runContext = getAgentRunContext(evt.runId);
const isControlUiVisible = runContext?.isControlUiVisible ?? true;
const isHeartbeat = runContext?.isHeartbeat;
const sessionKey =
chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId);
const clientRunId = chatLink?.clientRunId ?? evt.runId;
@@ -643,8 +645,16 @@ export function createAgentEventHandler({
// Include sessionKey so Control UI can filter tool streams per session.
const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null;
const agentPayload = sessionKey
? { ...eventForClients, sessionKey, ...(spawnedBy && { spawnedBy }) }
: eventForClients;
? {
...eventForClients,
sessionKey,
...(spawnedBy && { spawnedBy }),
...(isHeartbeat !== undefined && { isHeartbeat }),
}
: {
...eventForClients,
...(isHeartbeat !== undefined && { isHeartbeat }),
};
const last = agentRunSeq.get(evt.runId) ?? 0;
const isToolEvent = evt.stream === "tool";
const isItemEvent = evt.stream === "item";
@@ -657,9 +667,7 @@ export function createAgentEventHandler({
const data = evt.data ? { ...evt.data } : {};
delete data.result;
delete data.partialResult;
return sessionKey
? { ...eventForClients, sessionKey, data }
: { ...eventForClients, data };
return { ...agentPayload, data };
})()
: agentPayload;
if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) {
@@ -669,6 +677,7 @@ export function createAgentEventHandler({
ts: Date.now(),
sessionKey,
...(spawnedBy && { spawnedBy }),
...(isHeartbeat !== undefined && { isHeartbeat }),
data: {
reason: "seq gap",
expected: last + 1,