From f1f00cbf1d587371cd6d89f7984c94913ecec5a0 Mon Sep 17 00:00:00 2001 From: anagnorisis2peripeteia <129746152+anagnorisis2peripeteia@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:52:40 +0100 Subject: [PATCH] fix: capture cron wake origin session Capture the originating sessionKey and agentId for cron wake tool calls so non-main session and multi-agent wakes return to the conversation lane that requested them. Carry stored delivery context through queued wake events so topic/thread replies route correctly, while preserving the default no-origin wake behavior and explicit target:none opt-out. Refs #46886. Refs #64556. Thanks @anagnorisis2peripeteia. Co-authored-by: Cameron Beeley --- .../OpenClawProtocol/GatewayModels.swift | 6 +- packages/gateway-protocol/src/index.test.ts | 29 ++++ packages/gateway-protocol/src/schema/agent.ts | 6 + scripts/proof-cron-wake-origin.mts | 142 ++++++++++++++++ src/agents/tools/cron-tool.test.ts | 142 ++++++++++++++++ src/agents/tools/cron-tool.ts | 78 ++++++++- src/cron/service-contract.ts | 7 +- src/cron/service.ts | 2 +- src/cron/service/ops.ts | 2 +- src/cron/service/state.ts | 11 ++ src/cron/service/wake-origin-delivery.test.ts | 151 ++++++++++++++++++ src/cron/service/wake-origin.test.ts | 118 ++++++++++++++ src/cron/service/wake.ts | 43 ++++- src/gateway/server-cron.ts | 14 ++ src/gateway/server-methods/cron.ts | 28 ++++ .../server-methods/cron.validation.test.ts | 28 ++++ src/infra/outbound/targets.test.ts | 49 ++++++ src/infra/outbound/targets.ts | 12 ++ .../codex-dynamic-tools.discord-group.json | 8 +- .../codex-dynamic-tools.heartbeat-turn.json | 8 +- .../codex-dynamic-tools.telegram-direct.json | 8 +- .../discord-group-codex-message-tool.md | 8 +- .../telegram-direct-codex-message-tool.md | 8 +- .../telegram-heartbeat-codex-tool.md | 8 +- 24 files changed, 889 insertions(+), 27 deletions(-) create mode 100644 scripts/proof-cron-wake-origin.mts create mode 100644 src/cron/service/wake-origin-delivery.test.ts create mode 100644 src/cron/service/wake-origin.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index b4b03a4d9b13..61ca68ed4690 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -982,21 +982,25 @@ public struct WakeParams: Codable, Sendable { public let mode: AnyCodable public let text: String public let sessionkey: String? + public let agentid: String? public init( mode: AnyCodable, text: String, - sessionkey: String?) + sessionkey: String?, + agentid: String? = nil) { self.mode = mode self.text = text self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case mode case text case sessionkey = "sessionKey" + case agentid = "agentId" } } diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 2a7b8df21bd0..74d1b43ed5d6 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -637,6 +637,35 @@ describe("validateWakeParams", () => { }), ).toBe(true); }); + + it("accepts optional sessionKey and agentId so per-session wakes can be routed", () => { + // Origin-capture fix for #46886 / #64556 — wakes that name an explicit + // session/agent must validate so the gateway handler can forward them + // through to the cron service. + expect( + validateWakeParams({ + mode: "now", + text: "follow up on the report", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + }), + ).toBe(true); + expect( + validateWakeParams({ + mode: "next-heartbeat", + text: "tick", + sessionKey: "agent:main:discord:guild123:thread456", + }), + ).toBe(true); + }); + + it("rejects sessionKey or agentId when they are present but empty strings", () => { + // NonEmptyString — caller must omit the field entirely to fall back to + // the default routing. Explicit empties are an error rather than a + // silent no-op. + expect(validateWakeParams({ mode: "now", text: "x", sessionKey: "" })).toBe(false); + expect(validateWakeParams({ mode: "now", text: "x", agentId: "" })).toBe(false); + }); }); describe("validateChatEvent", () => { diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index d4a9dac1d669..5f3afb72fa8a 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -275,6 +275,12 @@ export const WakeParamsSchema = Type.Object( // Typed field; misspelled variants remain opaque metadata because wake // senders already rely on additionalProperties. sessionKey: Type.Optional(NonEmptyString), + /** + * Optional agent id paired with `sessionKey`. Routes multi-agent setups + * to the agent that owns the targeted session — closes the related half + * of #46886 ("always routes to default agent"). + */ + agentId: Type.Optional(NonEmptyString), }, { additionalProperties: true }, // external wake senders may attach opaque metadata ); diff --git a/scripts/proof-cron-wake-origin.mts b/scripts/proof-cron-wake-origin.mts new file mode 100644 index 000000000000..52b6a6a12907 --- /dev/null +++ b/scripts/proof-cron-wake-origin.mts @@ -0,0 +1,142 @@ +// Live-proof harness for PR #83738 (cron wake origin capture). +// +// Drives the patched gateway wake handler (validateWakeParams + isSubagentSessionKey +// guard) into the patched cron service wake() (wake.ts) with deps wired to LOG +// every enqueueSystemEvent / requestHeartbeat call. Captures stdout that +// demonstrates a non-main cron wake routing to the originating session/agent +// rather than the heartbeat/main default. +// +// Run: pnpm exec tsx scripts/proof-cron-wake-origin.mts +// +// All identifiers in this script are synthetic. Real Telegram chat ids / +// session keys are not used. + +import { cronHandlers } from "../src/gateway/server-methods/cron.js"; +import { wake as cronServiceWake } from "../src/cron/service/wake.js"; +import type { CronServiceState } from "../src/cron/service/state.js"; + +type EnqueueArgs = [string, { sessionKey?: string; agentId?: string } | undefined]; +type HeartbeatArgs = [ + { source: string; intent: string; reason: string; sessionKey?: string; agentId?: string }, +]; + +const log = (...parts: unknown[]) => { + console.log(...parts); +}; + +function makeShimmedState(): { + state: CronServiceState; + recorder: { enqueue: EnqueueArgs[]; heartbeat: HeartbeatArgs[] }; +} { + const recorder = { enqueue: [] as EnqueueArgs[], heartbeat: [] as HeartbeatArgs[] }; + const state = { + deps: { + enqueueSystemEvent: (...args: EnqueueArgs) => { + recorder.enqueue.push(args); + const [text, opts] = args; + log( + `[gateway/cron] enqueueSystemEvent text=${JSON.stringify(text)} opts=${JSON.stringify(opts)}`, + ); + }, + requestHeartbeat: (...args: HeartbeatArgs) => { + recorder.heartbeat.push(args); + log(`[gateway/heartbeat] requestHeartbeat ${JSON.stringify(args[0])}`); + }, + }, + } as unknown as CronServiceState; + return { state, recorder }; +} + +type ScenarioResult = { ok: boolean; payload?: unknown; error?: unknown }; + +async function drive(label: string, params: unknown): Promise { + log(""); + log(`=== ${label} ===`); + log(`> wake params: ${JSON.stringify(params)}`); + const { state } = makeShimmedState(); + let response: ScenarioResult = { ok: false }; + const respond = (ok: boolean, payload: unknown, error: unknown) => { + response = { ok, payload, error }; + }; + const context = { + cron: { + wake: ( + opts: { + mode: "now" | "next-heartbeat"; + text: string; + sessionKey?: string; + agentId?: string; + }, + ) => cronServiceWake(state, opts), + }, + } as unknown as Parameters[0]["context"]; + + // cronHandlers.wake is sync (calls respond synchronously) but typed as + // returning void; await on a Promise wrapper to flush console.log ordering. + await Promise.resolve( + cronHandlers.wake({ + params, + respond, + context, + request: {} as never, + requestId: 1 as never, + logger: undefined as never, + } as never), + ); + log(`< wake result: ok=${response.ok} payload=${JSON.stringify(response.payload)}`); + if (response.error) { + log(`< wake error: ${JSON.stringify(response.error)}`); + } + return response; +} + +async function main() { + log("=== PR #83738 cron wake origin-capture: live-proof harness ==="); + log("Driving the patched gateway wake handler through cron.wake() with"); + log("logging deps. All ids below are synthetic."); + + // Scenario 1: real-world bug-reproduction case — a wake fired from inside + // a non-main Telegram topic session for a non-default agent. + await drive("non-main session + non-default agent (the bug-fix case)", { + mode: "now", + text: "follow up on report", + sessionKey: "agent:coding:telegram::topic:", + agentId: "coding", + }); + + // Scenario 2: backwards-compatible — no origin → default routing. + await drive("no origin (backwards-compatible default routing)", { + mode: "now", + text: "ping", + }); + + // Scenario 3: next-heartbeat + sessionKey collapses to a targeted-immediate + // heartbeat because the regularly-scheduled heartbeat fires for the + // agent's main session, never peeking the targeted lane's queue. + await drive("next-heartbeat + sessionKey collapses to targeted-immediate", { + mode: "next-heartbeat", + text: "check the queue", + sessionKey: "agent:coding:discord:", + agentId: "coding", + }); + + // Scenario 4: subagent sessionKey rejected at the gateway handler. + await drive("subagent sessionKey rejected by gateway handler guard", { + mode: "now", + text: "wake my subagent", + sessionKey: "subagent:scratch:", + }); + + // Scenario 5: whitespace-only origin falls through to default routing. + await drive("whitespace-only origin falls through (defence-in-depth)", { + mode: "now", + text: "x", + sessionKey: " ", + agentId: "\t", + }); + + log(""); + log("=== Done. ==="); +} + +void main(); diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 4fda5a738f49..6fe07d51d50b 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -447,6 +447,148 @@ describe("cron tool", () => { expect(params).toEqual({ includeDisabled: true, agentId: "ops" }); }); + describe("wake routing", () => { + // Pin the agentId / sessionKey resolution contract for `action: "wake"`. + // The gateway target resolver treats `agentId` as authoritative, so + // pairing the caller's inferred agentId with a foreign explicit + // sessionKey would canonicalize the wake back to the caller agent's + // main lane. + + it("infers sessionKey + agentId from the calling agent's session when neither is supplied", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await tool.execute("call-wake-default", { action: "wake", text: "ping" }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toEqual({ + mode: "next-heartbeat", + text: "ping", + sessionKey: "agent:agent-123:telegram:direct:channing", + agentId: "agent-123", + }); + }); + + it("derives agentId from an explicit cross-agent sessionKey instead of the caller's agentId", async () => { + // A caller in agent-123 explicitly waking an agent-456 session must + // NOT have agent-123's agentId paired with agent-456's sessionKey — + // that would canonicalize back to agent-123's main lane on the + // gateway side. + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await tool.execute("call-wake-cross-agent", { + action: "wake", + text: "follow up", + sessionKey: "agent:agent-456:discord:thread-xyz", + }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toEqual({ + mode: "next-heartbeat", + text: "follow up", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "agent-456", + }); + }); + + it("rejects a contradictory explicit agentId + agent-prefixed sessionKey pair", async () => { + // The gateway target resolver treats agentId as authoritative, so a + // contradictory pair would silently canonicalize the wake onto a session + // the caller never named. The tool rejects instead of guessing. + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await expect( + tool.execute("call-wake-explicit-pair", { + action: "wake", + text: "manual", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "ops", + }), + ).rejects.toThrow(/contradicts/); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("accepts an explicit agentId that matches the agent owning the explicit sessionKey", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await tool.execute("call-wake-matching-pair", { + action: "wake", + text: "manual", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "agent-456", + }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toEqual({ + mode: "next-heartbeat", + text: "manual", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "agent-456", + }); + }); + + it("omits agentId when explicit sessionKey is not in agent::* form and no explicit agentId is given", async () => { + // Defence-in-depth: if the explicit sessionKey can't be parsed for an + // agentId, we'd rather omit it (gateway falls back to default routing + // for that session) than incorrectly attach the caller's agentId. + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await tool.execute("call-wake-unparseable", { + action: "wake", + text: "x", + sessionKey: "subagent:weird:format", + }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toEqual({ + mode: "next-heartbeat", + text: "x", + sessionKey: "subagent:weird:format", + // No agentId — explicit sessionKey wasn't parseable + no explicit + // override, so we deliberately drop agentId rather than inherit + // the caller's. + }); + }); + + it("requires text for action wake", async () => { + // Mutation-test survivor: `required: true` -> false silently sent an + // undefined-text wake. Pin the guard. + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await expect(tool.execute("call-wake-no-text", { action: "wake" })).rejects.toThrow(); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("sends a bare wake when no calling-session context exists", async () => { + // Mutation-test survivor: `opts?.agentSessionKey` -> `opts.agentSessionKey` + // crashed context-less callers. A tool created without session context + // must fall through to default routing, not throw. + const tool = createTestCronTool(); + await tool.execute("call-wake-no-context", { action: "wake", text: "ping" }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toEqual({ mode: "next-heartbeat", text: "ping" }); + }); + + it('honours an explicit mode: "next-heartbeat"', async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await tool.execute("call-wake-nh", { action: "wake", text: "tick", mode: "next-heartbeat" }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toMatchObject({ mode: "next-heartbeat", text: "tick" }); + }); + + it('threads mode: "now" through unchanged', async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + await tool.execute("call-wake-now", { action: "wake", text: "ping", mode: "now" }); + const params = expectSingleGatewayCallMethod("wake"); + expect(params).toMatchObject({ mode: "now", text: "ping" }); + }); + }); + it("documents deferred follow-up guidance in the tool description", () => { const tool = createTestCronTool(); expect(tool.description).toContain( diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 4c6372a9e04d..4ea385abffeb 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -11,6 +11,7 @@ import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-targe import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import type { CronDelivery } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; import type { DeliveryContext } from "../../utils/delivery-context.shared.js"; @@ -308,7 +309,18 @@ export function createCronToolSchema(): TSchema { contextMessages: Type.Optional( Type.Integer({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }), ), - agentId: Type.Optional(Type.String({ description: "List filter: agent id" })), + agentId: Type.Optional( + Type.String({ + description: + 'List filter for `action: "list"`; wake target override for `action: "wake"` (defaults to the calling agent when omitted on wake)', + }), + ), + sessionKey: Type.Optional( + Type.String({ + description: + 'Wake target override for `action: "wake"`: route the event to the named session rather than the calling agent\'s current session. Defaults to the resolved calling-session key when omitted.', + }), + ), }, { additionalProperties: true }, ); @@ -525,7 +537,7 @@ ACTIONS: - remove: delete job; needs jobId - run: trigger now; needs jobId - runs: run history; needs jobId -- wake: send wake event; needs text, optional mode +- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane. JOB SCHEMA (for add action): { @@ -831,8 +843,68 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me params.mode === "now" || params.mode === "next-heartbeat" ? params.mode : "next-heartbeat"; + // Resolve the calling agent's session key into the internal form + // the cron service routes by (mirrors the `add` action above). + // Without this, the wake gateway call goes through with no session + // key and the system event lands on the heartbeat / main default + // rather than the originating conversation lane. Closes the + // upstream half of openclaw/openclaw#46886 (#64556 — agentId/ + // sessionKey silently ignored for `action: "wake"`). Explicit + // params on the tool call still take precedence over the inferred + // value, so call sites that want to wake a different session can + // pass `sessionKey` / `agentId` directly. + const cfg = getRuntimeConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const explicitSessionKey = readStringParam(params, "sessionKey"); + const explicitAgentId = readStringParam(params, "agentId"); + const inferredSessionKey = opts?.agentSessionKey + ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) + : undefined; + const inferredAgentId = opts?.agentSessionKey + ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) + : undefined; + const sessionKey = explicitSessionKey ?? inferredSessionKey; + // When a caller supplies an explicit cross-agent sessionKey without + // an explicit agentId, the gateway target resolver treats agentId as + // authoritative — pairing the caller's inferred agentId with a + // foreign session key would canonicalize the wake back to the + // caller's main lane. Derive the agentId from the explicit canonical + // session key instead; only fall through to the inferred + // caller-agent when no explicit sessionKey was supplied. + const agentIdFromExplicitSessionKey = explicitSessionKey + ? parseAgentSessionKey(explicitSessionKey)?.agentId + : undefined; + // A contradictory explicit pair (agentId X + a sessionKey owned by + // agent Y) is ambiguous: the gateway target resolver treats agentId + // as authoritative and would silently canonicalize the wake onto a + // session under X that the caller never named. Reject instead of + // guessing one canonical owner. + if ( + explicitAgentId && + agentIdFromExplicitSessionKey && + normalizeLowercaseStringOrEmpty(explicitAgentId) !== + normalizeLowercaseStringOrEmpty(agentIdFromExplicitSessionKey) + ) { + throw new Error( + `wake agentId "${explicitAgentId}" contradicts the agent that owns sessionKey ` + + `("${agentIdFromExplicitSessionKey}"); pass a single canonical wake target`, + ); + } + const agentId = + explicitAgentId ?? + (explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId); return jsonResult( - await callGateway("wake", gatewayOpts, { mode, text }, { expectFinal: false }), + await callGateway( + "wake", + gatewayOpts, + { + mode, + text, + ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), + }, + { expectFinal: false }, + ), ); } default: diff --git a/src/cron/service-contract.ts b/src/cron/service-contract.ts index 5c6a2a1e15de..78372c9a03d9 100644 --- a/src/cron/service-contract.ts +++ b/src/cron/service-contract.ts @@ -34,5 +34,10 @@ export interface CronServiceContract { getJob(id: string): CronJob | undefined; readJob(id: string): Promise; getDefaultAgentId(): string | undefined; - wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult; + wake(opts: { + mode: CronWakeMode; + text: string; + sessionKey?: string; + agentId?: string; + }): CronWakeResult; } diff --git a/src/cron/service.ts b/src/cron/service.ts index 7cf8b12bd6ec..843fb11be310 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -77,7 +77,7 @@ export class CronService implements CronServiceContract { return this.state.deps.defaultAgentId; } - wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }) { + wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string; agentId?: string }) { return ops.wakeNow(this.state, opts); } } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 9ff643d99f35..79eabce1f4d6 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -962,7 +962,7 @@ export async function enqueueRun(state: CronServiceState, id: string, mode?: "du /** Enqueues manual wake text through the cron wake API. */ export function wakeNow( state: CronServiceState, - opts: { mode: CronWakeMode; text: string; sessionKey?: string }, + opts: { mode: CronWakeMode; text: string; sessionKey?: string; agentId?: string }, ) { return wake(state, opts); } diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 525687ffad5c..6ce22ef009d7 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -91,6 +91,17 @@ export type CronServiceDeps = { deliveryContext?: DeliveryContext; }, ) => void; + /** + * Resolve the channel-correct origin delivery context for a session key (the + * value the channel's send expects, e.g. Telegram message_thread_id), sourced + * from the session store entry the wake targets. Used to carry the bound + * thread/topic onto manual wake system events. Optional: when unset, wakes + * route as before. Returning `undefined` is also a no-op (default routing). + */ + resolveOriginDeliveryContext?: (params: { + sessionKey?: string; + agentId?: string; + }) => DeliveryContext | undefined; requestHeartbeat: (opts: HeartbeatWakeRequest) => void; runHeartbeatOnce?: (opts?: { source?: HeartbeatWakeRequest["source"]; diff --git a/src/cron/service/wake-origin-delivery.test.ts b/src/cron/service/wake-origin-delivery.test.ts new file mode 100644 index 000000000000..8d851ab9d33f --- /dev/null +++ b/src/cron/service/wake-origin-delivery.test.ts @@ -0,0 +1,151 @@ +// Covers the "capture origin delivery context, carry it to the wake event" +// half of the cron wake origin fix: a sessionKey-targeted wake() must thread +// the bound channel thread/topic (e.g. Telegram topic 4052) onto the enqueued +// system event's deliveryContext so the delivered heartbeat routes back into +// the originating thread instead of the chat root. +// +// The channel-correct threadId is sourced via the resolveOriginDeliveryContext +// dep (implemented in server-cron from the session store), NOT by splitting the +// composite session-key thread suffix. The tests mock that dep so they exercise +// only wake()'s carry behavior. Scheduled main-session cron jobs resolve their +// delivery context natively in timer.ts (resolveMainSessionCronDeliveryContext) +// and are covered there. +import { describe, expect, it, vi } from "vitest"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; +import type { CronServiceState } from "./state.js"; +import { wake } from "./wake.js"; + +const TOPIC_DELIVERY_CONTEXT: DeliveryContext = { + channel: "telegram", + to: "telegram:8661849123:topic:4052", + accountId: "default", + threadId: "4052", +}; + +function makeStateWithMocks( + resolveOriginDeliveryContext?: (params: { + sessionKey?: string; + agentId?: string; + }) => DeliveryContext | undefined, +): { + state: CronServiceState; + enqueueSystemEvent: ReturnType; + requestHeartbeat: ReturnType; + resolveOriginDeliveryContext: ReturnType; +} { + const enqueueSystemEvent = vi.fn(); + const requestHeartbeat = vi.fn(); + const resolveOrigin = vi.fn(resolveOriginDeliveryContext ?? (() => undefined)); + const state = { + deps: { + enqueueSystemEvent, + requestHeartbeat, + resolveOriginDeliveryContext: resolveOrigin, + }, + } as unknown as CronServiceState; + return { + state, + enqueueSystemEvent, + requestHeartbeat, + resolveOriginDeliveryContext: resolveOrigin, + }; +} + +describe("cron wake() origin delivery-context carry", () => { + it("threads the resolved deliveryContext onto a sessionKey-targeted wake", () => { + const { state, enqueueSystemEvent, resolveOriginDeliveryContext } = makeStateWithMocks( + () => TOPIC_DELIVERY_CONTEXT, + ); + + const result = wake(state, { + mode: "now", + text: "check the queue", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + }); + + expect(result).toEqual({ ok: true }); + expect(resolveOriginDeliveryContext).toHaveBeenCalledWith({ + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", { + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + deliveryContext: TOPIC_DELIVERY_CONTEXT, + }); + }); + + it("resolves and carries deliveryContext for a sessionKey-only wake (no agentId)", () => { + // Caught by mutation testing: `sessionKey || agentId` -> `&&` in the + // resolver guard survived because every resolver-wired test passed both + // fields. A sessionKey-only wake (the common tool-path shape for + // single-agent setups) must still consult the resolver and carry the + // stored topic/thread context. + const { state, enqueueSystemEvent, resolveOriginDeliveryContext } = makeStateWithMocks( + () => TOPIC_DELIVERY_CONTEXT, + ); + + wake(state, { + mode: "now", + text: "check the queue", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + }); + + expect(resolveOriginDeliveryContext).toHaveBeenCalledExactlyOnceWith({ + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: undefined, + }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", { + sessionKey: "agent:main:telegram:8661849123:topic:4052", + deliveryContext: TOPIC_DELIVERY_CONTEXT, + }); + }); + + it("omits deliveryContext when no origin context resolves (unchanged default routing)", () => { + const { state, enqueueSystemEvent } = makeStateWithMocks(() => undefined); + + wake(state, { + mode: "now", + text: "check the queue", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + }); + + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", { + sessionKey: "agent:main:telegram:8661849123:topic:4052", + }); + const [, options] = enqueueSystemEvent.mock.calls[0] as [string, Record]; + expect(options).not.toHaveProperty("deliveryContext"); + }); + + it("works when no resolveOriginDeliveryContext dep is wired (legacy deps)", () => { + const { state, enqueueSystemEvent } = makeStateWithMocks(); + // Drop the dep entirely to mirror a deployment whose adapter predates the fix. + (state.deps as { resolveOriginDeliveryContext?: unknown }).resolveOriginDeliveryContext = + undefined; + + const result = wake(state, { + mode: "now", + text: "check the queue", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + }); + + expect(result).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", { + sessionKey: "agent:main:telegram:8661849123:topic:4052", + }); + }); + + it("keeps the no-origin call shape (enqueueSystemEvent(text, undefined)) when untargeted", () => { + const { state, enqueueSystemEvent, resolveOriginDeliveryContext } = makeStateWithMocks( + () => TOPIC_DELIVERY_CONTEXT, + ); + + wake(state, { mode: "now", text: "no origin" }); + + // Untargeted wakes must not even consult the resolver, preserving the exact + // pre-fix default-sessionKey binding behavior. + expect(resolveOriginDeliveryContext).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("no origin", undefined); + }); +}); diff --git a/src/cron/service/wake-origin.test.ts b/src/cron/service/wake-origin.test.ts new file mode 100644 index 000000000000..758aa085ea8a --- /dev/null +++ b/src/cron/service/wake-origin.test.ts @@ -0,0 +1,118 @@ +// Regression coverage for cron wake origin capture (openclaw/openclaw#46886, +// #64556): wake must thread sessionKey + agentId through to enqueueSystemEvent +// and the heartbeat request so multi-agent / non-main-session wakes land on the +// originating conversation lane. Base sessionKey threading and the no-origin +// default shape are covered by wake.test.ts; these tests pin the agentId half. +import { describe, expect, it, vi } from "vitest"; +import type { CronServiceState } from "./state.js"; +import { wake } from "./wake.js"; + +// Minimal CronServiceState shim — `wake` only touches `state.deps` so the +// other state fields aren't relevant. Cast through `unknown` to avoid +// pulling in the full state factory just to exercise two callbacks. +function makeStateWithMocks(): { + state: CronServiceState; + enqueueSystemEvent: ReturnType; + requestHeartbeat: ReturnType; +} { + const enqueueSystemEvent = vi.fn(); + const requestHeartbeat = vi.fn(); + const state = { + deps: { enqueueSystemEvent, requestHeartbeat }, + } as unknown as CronServiceState; + return { state, enqueueSystemEvent, requestHeartbeat }; +} + +describe("cron service wake() origin capture", () => { + it("forwards sessionKey + agentId to enqueueSystemEvent so the event lands on the originating session", () => { + // Prior to this change the wake function forwarded only sessionKey, so + // multi-agent setups routed every wake to the default agent regardless + // of which agent owned the originating session. + const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks(); + const result = wake(state, { + mode: "now", + text: "follow up on the report", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + }); + expect(result).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("follow up on the report", { + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + }); + expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({ + source: "manual", + intent: "immediate", + reason: "wake", + sessionKey: "agent:main:telegram:8661849123:topic:4052", + agentId: "main", + }); + }); + + it("threads sessionKey + agentId into the targeted-immediate heartbeat for next-heartbeat+sessionKey too", () => { + // wake() collapses --mode now and --mode next-heartbeat into the same + // targeted-immediate behavior when sessionKey is present — the regularly + // scheduled heartbeat fires for the agent's main session, so a non-main + // wake needs an explicit targeted nudge to peek the session's queue. + // agentId must thread through that nudge too. + const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks(); + const result = wake(state, { + mode: "next-heartbeat", + text: "check the queue", + sessionKey: "agent:coding:discord:thread123", + agentId: "coding", + }); + expect(result).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", { + sessionKey: "agent:coding:discord:thread123", + agentId: "coding", + }); + expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({ + source: "manual", + intent: "immediate", + reason: "wake", + sessionKey: "agent:coding:discord:thread123", + agentId: "coding", + }); + }); + + it("forwards an agentId-only wake so the event reaches that agent's default lane", () => { + // Caught by mutation testing: `sessionKey || agentId` -> `&&` survived + // because no test exercised agentId without sessionKey. An agentId-only + // wake must still build enqueue opts (the gateway resolves the agent's + // default session from agentId) rather than fall back to the global + // default lane. + const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks(); + const result = wake(state, { mode: "now", text: "agent only", agentId: "ops" }); + expect(result).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("agent only", { + agentId: "ops", + }); + expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({ + source: "manual", + intent: "immediate", + reason: "wake", + agentId: "ops", + }); + }); + + it("drops whitespace-only sessionKey / agentId rather than routing to a meaningless lane", () => { + // Defence-in-depth: gateway handler already trims, but the wake function + // is also reachable directly by other in-process call sites. Empty / + // whitespace fields must fall through to default routing, not route + // the event to a session named " " (which would silently drop it). + const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks(); + wake(state, { + mode: "now", + text: "x", + sessionKey: " ", + agentId: "\t", + }); + expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("x", undefined); + expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({ + source: "manual", + intent: "immediate", + reason: "wake", + }); + }); +}); diff --git a/src/cron/service/wake.ts b/src/cron/service/wake.ts index ca6fb3cad322..40a0562ce663 100644 --- a/src/cron/service/wake.ts +++ b/src/cron/service/wake.ts @@ -5,23 +5,61 @@ import type { CronServiceState } from "./state.js"; /** Enqueues a manual cron wake event and optionally pokes the targeted heartbeat loop. */ export function wake( state: CronServiceState, - opts: { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string }, + opts: { + mode: "now" | "next-heartbeat"; + text: string; + /** + * Internal session key to enqueue the system event against. When omitted, + * the dep's default (heartbeat / main) is used — wakes from a non-main + * session would otherwise route to the wrong place. Callers wiring an + * agent-tool `wake` should thread the resolved session key (e.g. from + * `cron-tool`'s `resolveInternalSessionKey`) so the event lands on the + * originating conversation lane. + */ + sessionKey?: string; + /** + * Agent id paired with `sessionKey`. Forwarded to `enqueueSystemEvent` + * and the heartbeat request so multi-agent setups route to the agent + * that owns the targeted session — fixes the related half of #46886 + * ("always routes to default agent"). + */ + agentId?: string; + }, ) { const text = opts.text.trim(); if (!text) { return { ok: false } as const; } const sessionKey = opts.sessionKey?.trim() || undefined; + const agentId = opts.agentId?.trim() || undefined; if (sessionKey && isSubagentSessionKey(sessionKey)) { return { ok: false, reason: "unwakeable-session-key" } as const; } - state.deps.enqueueSystemEvent(text, sessionKey ? { sessionKey } : undefined); + // Carry the originating session's channel-correct delivery context (e.g. the + // bound Telegram topic/thread) so a wake routes back into that thread instead + // of the chat root. Only attempt this when an origin session is targeted; a + // no-origin wake keeps the exact pre-fix `enqueueSystemEvent(text, undefined)` + // shape so its default-sessionKey binding still kicks in. + const originDeliveryContext = + sessionKey || agentId + ? state.deps.resolveOriginDeliveryContext?.({ sessionKey, agentId }) + : undefined; + const enqueueOpts = + sessionKey || agentId + ? { + ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), + ...(originDeliveryContext ? { deliveryContext: originDeliveryContext } : {}), + } + : undefined; + state.deps.enqueueSystemEvent(text, enqueueOpts); if (opts.mode === "now") { state.deps.requestHeartbeat({ source: "manual", intent: "immediate", reason: "wake", ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), }); } else if (sessionKey) { // next-heartbeat + sessionKey still needs a targeted immediate wake. @@ -41,6 +79,7 @@ export function wake( intent: "immediate", reason: "wake", sessionKey, + ...(agentId ? { agentId } : {}), }); } return { ok: true } as const; diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 391e29d8c3a0..97f81ef16f0c 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -16,6 +16,7 @@ import { resolveStorePath } from "../config/sessions/paths.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { runCronCommandJob } from "../cron/command-runner.js"; +import { resolveCronStoredDeliveryContext } from "../cron/delivery-context.js"; import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/delivery.js"; import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; import { appendCronRunLog, resolveCronRunLogPruneOptions } from "../cron/run-log.js"; @@ -328,6 +329,19 @@ export function buildGatewayCronService(params: { deliveryContext: opts?.deliveryContext, }); }, + resolveOriginDeliveryContext: (opts) => { + // Resolve the wake target the same way the enqueue/heartbeat deps do, + // then read the channel-correct delivery context from that session's + // store entry (NOT by string-splitting the composite session key). + const { runtimeConfig, sessionKey } = resolveCronTarget({ + ...opts, + preserveUntargeted: true, + }); + if (!sessionKey) { + return undefined; + } + return resolveCronStoredDeliveryContext({ cfg: runtimeConfig, sessionKey }); + }, requestHeartbeat: (opts) => { const { agentId, sessionKey } = resolveCronTarget({ ...opts, preserveUntargeted: true }); requestHeartbeat({ diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 07b2367abb42..126c9dd3980d 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -33,6 +33,7 @@ import { } from "../../infra/outbound/channel-target-prefix.js"; import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; @@ -255,12 +256,19 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } + // Caller-supplied sessionKey / agentId thread through to `cron.wake` so + // multi-session deployments wake the originating conversation lane + // instead of the heartbeat / main default. Empty strings are dropped + // (schema permits omission; presence with empty payload should not + // override the default). const p = params as { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string; + agentId?: string; }; const sessionKey = p.sessionKey?.trim() || undefined; + const agentId = p.agentId?.trim() || undefined; if (sessionKey && isSubagentSessionKey(sessionKey)) { // Wake requests resume user-visible sessions only; subagent sessions are // internal task execution targets and should not receive operator wakes. @@ -271,10 +279,30 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } + // Mirror the cron tool's contradictory-pair guard for direct RPC callers + // and generated clients: the cron target resolver treats agentId as + // authoritative, so an agentId that disagrees with the agent owning an + // agent-prefixed sessionKey would silently wake a lane the caller never + // named. Reject instead of guessing a canonical owner. + const sessionKeyAgentId = sessionKey + ? parseAgentSessionKey(sessionKey)?.agentId?.trim().toLowerCase() + : undefined; + if (agentId && sessionKeyAgentId && agentId.toLowerCase() !== sessionKeyAgentId) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "wake agentId contradicts the agent that owns sessionKey; pass a single canonical wake target", + ), + ); + return; + } const result = context.cron.wake({ mode: p.mode, text: p.text, ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), }); respond(true, result, undefined); }, diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 442418c21921..ad9e298cdf14 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -1033,6 +1033,34 @@ describe("cron method validation", () => { expectResponseError(respond, { code: "INVALID_REQUEST", messageIncludes: "sessionKey" }); }); + it("rejects a contradictory explicit agentId + agent-prefixed sessionKey pair", async () => { + // The cron target resolver treats agentId as authoritative; a + // contradictory pair would silently wake a lane the caller never named. + const { context, respond } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "ops", + }); + expect(context.cron.wake).not.toHaveBeenCalled(); + expectResponseError(respond, { code: "INVALID_REQUEST", messageIncludes: "contradicts" }); + }); + + it("accepts an explicit agentId matching the agent that owns the sessionKey", async () => { + const { context } = await invokeWake({ + mode: "now", + text: "ping", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "agent-456", + }); + expect(context.cron.wake).toHaveBeenCalledWith({ + mode: "now", + text: "ping", + sessionKey: "agent:agent-456:discord:thread-xyz", + agentId: "agent-456", + }); + }); + it("treats whitespace-only sessionKey as omitted at the handler boundary", async () => { const { context, respond } = await invokeWake({ mode: "now", diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 7e8537fa027d..8605505f2a58 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -493,6 +493,55 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("room:ops:topic:1008013"); }); + it("delivers an origin-carrying event when no heartbeat target is configured", () => { + // A wake/cron event that explicitly carried its origin delivery context + // names its own destination; the reply must not be dropped just because + // the deployment never configured agents.defaults.heartbeat. + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-origin-no-config", + updatedAt: 1, + lastChannel: "alpha", + lastTo: "chat:one", + }, + turnSource: { channel: "alpha", to: "chat:one", threadId: "77" }, + }); + expect(resolved.channel).toBe("alpha"); + expect(resolved.to).toBe("chat:one"); + expect(resolved.threadId).toBe("77"); + }); + + it("keeps an explicit target:none suppressing origin-carrying events", () => { + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-origin-target-none", + updatedAt: 1, + lastChannel: "alpha", + lastTo: "chat:one", + }, + heartbeat: { target: "none" }, + turnSource: { channel: "alpha", to: "chat:one" }, + }); + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("target-none"); + }); + + it("stays suppressed with unset heartbeat config and no origin turn source", () => { + const resolved = resolveHeartbeatDeliveryTarget({ + cfg: {}, + entry: { + sessionId: "sess-no-config-no-origin", + updatedAt: 1, + lastChannel: "alpha", + lastTo: "chat:one", + }, + }); + expect(resolved.channel).toBe("none"); + expect(resolved.reason).toBe("target-none"); + }); + const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") => resolveHeartbeatDeliveryTarget({ cfg: {}, diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index a09bf44e3210..b494f71b507e 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -111,6 +111,18 @@ export function resolveHeartbeatDeliveryTarget(params: { if (normalized) { target = normalized; } + } else if ( + rawTarget === undefined && + params.turnSource?.to && + params.turnSource.channel && + isDeliverableMessageChannel(params.turnSource.channel) + ) { + // No heartbeat target configured, but this run drains an event that + // explicitly carried its origin delivery context (e.g. a cron wake from a + // channel thread/topic). The event named its destination, so deliver to it + // instead of silently dropping the reply. An explicit `target: "none"` + // still suppresses delivery (operator opt-out above takes precedence). + target = "last"; } if (target === "none") { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 9a96dedddb95..805947b68108 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -150,7 +150,7 @@ }, { "deferLoading": true, - "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.", + "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.", "inputSchema": { "additionalProperties": true, "properties": { @@ -159,7 +159,7 @@ "type": "string" }, "agentId": { - "description": "List filter: agent id", + "description": "List filter for `action: \"list\"`; wake target override for `action: \"wake\"` (defaults to the calling agent when omitted on wake)", "type": "string" }, "contextMessages": { @@ -745,6 +745,10 @@ "enum": ["due", "force"], "type": "string" }, + "sessionKey": { + "description": "Wake target override for `action: \"wake\"`: route the event to the named session rather than the calling agent's current session. Defaults to the resolved calling-session key when omitted.", + "type": "string" + }, "text": { "type": "string" }, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index 161faff25a4f..3848fc38023c 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -150,7 +150,7 @@ }, { "deferLoading": true, - "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.", + "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.", "inputSchema": { "additionalProperties": true, "properties": { @@ -159,7 +159,7 @@ "type": "string" }, "agentId": { - "description": "List filter: agent id", + "description": "List filter for `action: \"list\"`; wake target override for `action: \"wake\"` (defaults to the calling agent when omitted on wake)", "type": "string" }, "contextMessages": { @@ -745,6 +745,10 @@ "enum": ["due", "force"], "type": "string" }, + "sessionKey": { + "description": "Wake target override for `action: \"wake\"`: route the event to the named session rather than the calling agent's current session. Defaults to the resolved calling-session key when omitted.", + "type": "string" + }, "text": { "type": "string" }, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 8712ecdf50b6..c60a9dcd0f97 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -150,7 +150,7 @@ }, { "deferLoading": true, - "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.", + "description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": , \"anchorMs\": }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"\", \"tz\": \"\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"\", \"model\": \"\", \"thinking\": \"\", \"timeoutSeconds\": }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"\", \"to\": \"\", \"threadId\": \"\", \"bestEffort\": }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.", "inputSchema": { "additionalProperties": true, "properties": { @@ -159,7 +159,7 @@ "type": "string" }, "agentId": { - "description": "List filter: agent id", + "description": "List filter for `action: \"list\"`; wake target override for `action: \"wake\"` (defaults to the calling agent when omitted on wake)", "type": "string" }, "contextMessages": { @@ -745,6 +745,10 @@ "enum": ["due", "force"], "type": "string" }, + "sessionKey": { + "description": "Wake target override for `action: \"wake\"`: route the event to the named session rather than the calling agent's current session. Defaults to the resolved calling-session key when omitted.", + "type": "string" + }, "text": { "type": "string" }, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index c42a3455897f..4eed05c6cfb2 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 44406, - "roughTokens": 11102 + "chars": 44908, + "roughTokens": 11227 }, "openClawDeveloperInstructions": { "chars": 2988, @@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6925 }, "totalWithDynamicToolsJson": { - "chars": 72108, - "roughTokens": 18027 + "chars": 72610, + "roughTokens": 18153 }, "userInputText": { "chars": 1629, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index ee123f3d197b..ce3b2802ad7a 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 44127, - "roughTokens": 11032 + "chars": 44629, + "roughTokens": 11158 }, "openClawDeveloperInstructions": { "chars": 1964, @@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6544 }, "totalWithDynamicToolsJson": { - "chars": 70305, - "roughTokens": 17577 + "chars": 70807, + "roughTokens": 17702 }, "userInputText": { "chars": 1129, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 827b688095aa..3e60cd5a7567 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -224,8 +224,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 45222, - "roughTokens": 11306 + "chars": 45724, + "roughTokens": 11431 }, "openClawDeveloperInstructions": { "chars": 1983, @@ -236,8 +236,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6780 }, "totalWithDynamicToolsJson": { - "chars": 72343, - "roughTokens": 18086 + "chars": 72845, + "roughTokens": 18212 }, "userInputText": { "chars": 1367,