mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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 <cameron.beeley@gmail.com>
This commit is contained in:
committed by
GitHub
parent
e6b0a22f36
commit
f1f00cbf1d
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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<ScenarioResult> {
|
||||
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<typeof cronHandlers.wake>[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:<chat-id-redacted>:topic:<topic-id-redacted>",
|
||||
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:<thread-redacted>",
|
||||
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:<id-redacted>",
|
||||
});
|
||||
|
||||
// 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();
|
||||
@@ -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:<id>:* 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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -34,5 +34,10 @@ export interface CronServiceContract {
|
||||
getJob(id: string): CronJob | undefined;
|
||||
readJob(id: string): Promise<CronJob | undefined>;
|
||||
getDefaultAgentId(): string | undefined;
|
||||
wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult;
|
||||
wake(opts: {
|
||||
mode: CronWakeMode;
|
||||
text: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
}): CronWakeResult;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
requestHeartbeat: ReturnType<typeof vi.fn>;
|
||||
resolveOriginDeliveryContext: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
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<string, unknown>];
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
requestHeartbeat: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+6
-2
@@ -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:<id>\",\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:<id>\": 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\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\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\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\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:<id>\",\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:<id>\": 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\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\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\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\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"
|
||||
},
|
||||
|
||||
+6
-2
@@ -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:<id>\",\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:<id>\": 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\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\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\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\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:<id>\",\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:<id>\": 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\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\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\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\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"
|
||||
},
|
||||
|
||||
+6
-2
@@ -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:<id>\",\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:<id>\": 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\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\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\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\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:<id>\",\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:<id>\": 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\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\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\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\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"
|
||||
},
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user