mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 15:54:47 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user