diff --git a/extensions/browser/src/browser/extension-relay/page-share.test.ts b/extensions/browser/src/browser/extension-relay/page-share.test.ts index 7d02eae977a7..b2c3b3cb389b 100644 --- a/extensions/browser/src/browser/extension-relay/page-share.test.ts +++ b/extensions/browser/src/browser/extension-relay/page-share.test.ts @@ -5,15 +5,24 @@ import { setPageShareSink, } from "./page-share.js"; -function createSink() { +function createSink(sessionKey = "agent:main:main") { const enqueueSystemEvent = vi.fn(); const requestHeartbeat = vi.fn(); + const resolveDefaultAgentId = vi.fn(() => "main"); + const resolveMainSessionKey = vi.fn(() => sessionKey); const sink = { enqueueSystemEvent, requestHeartbeat, - resolveMainSessionKey: () => "agent:main:main", + resolveDefaultAgentId, + resolveMainSessionKey, + }; + return { + enqueueSystemEvent, + requestHeartbeat, + resolveDefaultAgentId, + resolveMainSessionKey, + sink, }; - return { enqueueSystemEvent, requestHeartbeat, sink }; } afterEach(() => { @@ -21,8 +30,14 @@ afterEach(() => { }); describe("page share delivery", () => { - it("formats metadata, keeps the note trusted, and wraps selected page text", async () => { - const { enqueueSystemEvent, requestHeartbeat, sink } = createSink(); + it("formats metadata, keeps the note trusted, and wakes a scoped session", async () => { + const { + enqueueSystemEvent, + requestHeartbeat, + resolveDefaultAgentId, + resolveMainSessionKey, + sink, + } = createSink("agent:ops:main"); setPageShareSink(sink); await deliverPageShare({ @@ -34,8 +49,10 @@ describe("page share delivery", () => { }); expect(enqueueSystemEvent).toHaveBeenCalledOnce(); + expect(resolveDefaultAgentId).not.toHaveBeenCalled(); + expect(resolveMainSessionKey).toHaveBeenCalledOnce(); const [text, options] = enqueueSystemEvent.mock.calls[0] as [string, { sessionKey: string }]; - expect(options).toEqual({ sessionKey: "agent:main:main" }); + expect(options).toEqual({ sessionKey: "agent:ops:main" }); expect(text).toContain( "Page shared from the OpenClaw Chrome extension.\nNote: Summarize for me", ); @@ -50,10 +67,33 @@ describe("page share delivery", () => { const boundaryStart = text.indexOf("<< { + const { requestHeartbeat, resolveDefaultAgentId, sink } = createSink("global"); + setPageShareSink(sink); + + await deliverPageShare({ + url: "https://example.com", + title: "Example", + content: "full page content", + }); + + expect(resolveDefaultAgentId).toHaveBeenCalledOnce(); + expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({ + source: "notifications-event", + intent: "immediate", + reason: "wake", + agentId: "main", + sessionKey: "global", + heartbeat: { target: "last" }, }); }); diff --git a/extensions/browser/src/browser/extension-relay/page-share.ts b/extensions/browser/src/browser/extension-relay/page-share.ts index 3dff304a4704..4338602421e0 100644 --- a/extensions/browser/src/browser/extension-relay/page-share.ts +++ b/extensions/browser/src/browser/extension-relay/page-share.ts @@ -1,9 +1,11 @@ +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime"; import { wrapExternalContent } from "openclaw/plugin-sdk/security-runtime"; import { enqueueSystemEvent, resolveMainSessionKeyFromConfig, } from "openclaw/plugin-sdk/system-event-runtime"; +import { getRuntimeConfig } from "../../sdk-config.js"; import type { PageSharePayload } from "./relay-protocol.js"; export const PAGE_SHARE_GATEWAY_REQUIRED_ERROR = @@ -11,7 +13,15 @@ export const PAGE_SHARE_GATEWAY_REQUIRED_ERROR = type PageShareSink = { enqueueSystemEvent(text: string, opts: { sessionKey: string }): unknown; - requestHeartbeat(opts: { source: "other"; intent: "immediate"; reason: string }): unknown; + requestHeartbeat(opts: { + source: "notifications-event"; + intent: "immediate"; + reason: "wake"; + agentId?: string; + sessionKey: string; + heartbeat: { target: "last" }; + }): unknown; + resolveDefaultAgentId(): string; resolveMainSessionKey(): string; }; @@ -27,6 +37,7 @@ export function createGatewayPageShareSink(): PageShareSink { return { enqueueSystemEvent, requestHeartbeat, + resolveDefaultAgentId: () => resolveDefaultAgentId(getRuntimeConfig()), resolveMainSessionKey: resolveMainSessionKeyFromConfig, }; } @@ -51,10 +62,14 @@ export async function deliverPageShare(payload: PageSharePayload): Promise ].join("\n"); const text = `${header}\n\n${wrapped}`; - await sink.enqueueSystemEvent(text, { sessionKey: sink.resolveMainSessionKey() }); + const sessionKey = sink.resolveMainSessionKey(); + await sink.enqueueSystemEvent(text, { sessionKey }); await sink.requestHeartbeat({ - source: "other", + source: "notifications-event", intent: "immediate", - reason: "browser-page-share", + reason: "wake", + ...(sessionKey === "global" ? { agentId: sink.resolveDefaultAgentId() } : {}), + sessionKey, + heartbeat: { target: "last" }, }); } diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index ceec7614a7f7..59559f01ca1e 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -986,37 +986,49 @@ describe("startHeartbeatRunner", () => { runner.stop(); }); - it("runs a targeted notification wake for an agent without a heartbeat schedule", async () => { + it.each([ + { + name: "an agent without a heartbeat schedule", + cfg: { + agents: { list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "ops" }] }, + } as OpenClawConfig, + agentId: "ops", + sessionKey: "agent:ops:main", + heartbeat: { target: "last" }, + }, + { + name: "the global main session when periodic heartbeats are disabled", + cfg: { + agents: { defaults: { heartbeat: { every: "0m" } }, list: [{ id: "main" }] }, + session: { scope: "global" }, + } as OpenClawConfig, + agentId: "main", + sessionKey: "global", + heartbeat: { every: "0m", target: "last" }, + }, + ])("runs one targeted notification wake for $name", async (testCase) => { useFakeHeartbeatTime(); const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); - const runner = startHeartbeatRunner({ - cfg: { - agents: { - list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "ops" }], - }, - } as OpenClawConfig, - runOnce: runSpy, - stableSchedulerSeed: TEST_SCHEDULER_SEED, - }); - - requestHeartbeat({ - source: "notifications-event", - intent: "immediate", - reason: "wake", - sessionKey: "agent:ops:main", - heartbeat: { target: "last" }, - coalesceMs: 0, - }); - await vi.advanceTimersByTimeAsync(1); - - expect(runSpy).toHaveBeenCalledTimes(1); - expectRunCallFields(runSpy, 0, { - agentId: "ops", - source: "notifications-event", - intent: "immediate", - reason: "wake", - sessionKey: "agent:ops:main", - heartbeat: { target: "last" }, + const runner = await expectWakeDispatch({ + cfg: testCase.cfg, + runSpy, + wake: { + source: "notifications-event", + intent: "immediate", + reason: "wake", + ...(testCase.sessionKey === "global" ? { agentId: testCase.agentId } : {}), + sessionKey: testCase.sessionKey, + heartbeat: { target: "last" }, + coalesceMs: 0, + }, + expectedCall: { + agentId: testCase.agentId, + source: "notifications-event", + intent: "immediate", + reason: "wake", + sessionKey: testCase.sessionKey, + heartbeat: testCase.heartbeat, + }, }); runner.stop(); });