Skip to content

Commit c061740

Browse files
committed
fix(cron): de-duplicate main-session systemEvent in heartbeat model input
A sessionTarget:"main" cron systemEvent was surfaced to the model twice: the reply pipeline rendered it as a raw System: line and the heartbeat also wrapped it via buildCronEventPrompt. selectGenericSystemEvents excluded exec-completion events (which own a dedicated heartbeat prompt) but not cron events, which own buildCronEventPrompt. Exclude cron:<jobId>-tagged events so the heartbeat path is their single owner, symmetric with exec-completions. Closes #44922
1 parent 2a21de6 commit c061740

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

src/auto-reply/reply/session-system-events.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,21 @@ import {
1818
type SystemEvent,
1919
} from "../../infra/system-events.js";
2020

21+
function isCronContextSystemEvent(event: SystemEvent): boolean {
22+
return event.contextKey?.startsWith("cron:") ?? false;
23+
}
24+
2125
function selectGenericSystemEvents(events: readonly SystemEvent[]): SystemEvent[] {
22-
return events.filter((event) => !isExecCompletionEvent(event.text));
26+
// Exec completions and tagged cron events each own a dedicated heartbeat prompt
27+
// (buildExecEventPrompt / buildCronEventPrompt) that surfaces them to the model
28+
// once. Rendering them here as raw `System:` lines too double-surfaces the same
29+
// text: a `sessionTarget: "main"` cron systemEvent (tagged `cron:<jobId>`) would
30+
// appear both as a `System:` line and inside the heartbeat reminder wrapper
31+
// (#44922). Leave them queued so the heartbeat path stays the single owner that
32+
// consumes and renders them.
33+
return events.filter(
34+
(event) => !isExecCompletionEvent(event.text) && !isCronContextSystemEvent(event),
35+
);
2336
}
2437

2538
function compactSystemEvent(line: string): string | null {

src/auto-reply/reply/session.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3225,6 +3225,34 @@ describe("drainFormattedSystemEvents", () => {
32253225
expect(line).toMatch(/^System:/);
32263226
}
32273227
});
3228+
3229+
it("leaves tagged cron events queued for the heartbeat wrapper instead of re-rendering them (#44922)", async () => {
3230+
try {
3231+
// A `sessionTarget: "main"` cron systemEvent is enqueued tagged `cron:<jobId>`
3232+
// and is surfaced by the heartbeat's dedicated reminder prompt. The generic
3233+
// render must not also emit it as a raw `System:` line, or the model sees the
3234+
// same text twice.
3235+
enqueueSystemEvent("Reminder: rotate API keys", {
3236+
sessionKey: "agent:main:main",
3237+
contextKey: "cron:rotate-keys",
3238+
});
3239+
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
3240+
3241+
const result = await drainFormattedSystemEvents({
3242+
cfg: {} as OpenClawConfig,
3243+
sessionKey: "agent:main:main",
3244+
isMainSession: true,
3245+
isNewSession: false,
3246+
});
3247+
3248+
expect(result).toContain("Model switched.");
3249+
expect(result).not.toContain("rotate API keys");
3250+
// The cron event stays queued so the heartbeat path remains its single owner.
3251+
expect(peekSystemEvents("agent:main:main")).toEqual(["Reminder: rotate API keys"]);
3252+
} finally {
3253+
resetSystemEventsForTest();
3254+
}
3255+
});
32283256
});
32293257

32303258
describe("persistSessionUsageUpdate", () => {

0 commit comments

Comments
 (0)