diff --git a/CHANGELOG.md b/CHANGELOG.md index 082b10ae4c8..785a9c952a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ef401e073cd..38541a33c74 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 } } diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 26218fc1e89..143392f313a 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -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 }, diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index f5006c41f8d..6071d9d3005 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -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; + 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; + 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; + expect(agentPayload2).toBeDefined(); + expect(agentPayload2.isHeartbeat).toBe(false); + + const nodeSendPayload2 = harness.nodeSendToSession.mock.calls.find( + ([, event]) => event === "agent", + )?.[2] as Record; + 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; + expect(normalBroadcast).toBeDefined(); + expect("isHeartbeat" in normalBroadcast).toBe(false); + + const normalNodeSend = harness.nodeSendToSession.mock.calls.find( + ([, event]) => event === "agent", + )?.[2] as Record; + 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 }), diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 21484ae0dc1..ce898fa1d59 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -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,