From b3ef62b97358d2d6aa52a0512f081174caf4ed7b Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 2 Apr 2026 15:55:13 -0700 Subject: [PATCH] fix: preserve session_end reasons --- CHANGELOG.md | 1 + .../reply/session-hooks-context.test.ts | 66 +++++++++++++++++++ src/auto-reply/reply/session.ts | 18 ++--- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3293f4c3f..67aecf377f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380. - Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit. - Agents/tool policy: preserve restrictive plugin-only allowlists instead of silently widening access to core tools, and keep allowlist warnings aligned with the enforced policy. (#58476) Thanks @eleqtrizit. +- Hooks/session_end: preserve deterministic reason metadata for custom reset aliases and overlapping idle-plus-daily rollovers so plugins can rely on lifecycle reason reporting. (#59715) Thanks @jalehman. ## 2026.4.2 diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index 2966e17cc3f..c7fe1ce5dcb 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -150,6 +150,34 @@ describe("session hook context wiring", () => { expect(event).toMatchObject({ reason: "reset" }); }); + it("maps custom reset trigger aliases to the new-session reason", async () => { + const sessionKey = "agent:main:telegram:direct:alias"; + const storePath = await createStorePath("openclaw-session-hook-reset-alias"); + const transcriptPath = await writeTranscript(storePath, "alias-session", "alias me"); + await writeStore(storePath, { + [sessionKey]: { + sessionId: "alias-session", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }); + const cfg = { + session: { + store: storePath, + resetTriggers: ["/fresh"], + }, + } as OpenClawConfig; + + await initSessionState({ + ctx: { Body: "/fresh", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; + expect(event).toMatchObject({ reason: "new" }); + }); + it("marks daily stale rollovers and exposes the archived transcript path", async () => { vi.useFakeTimers(); try { @@ -221,4 +249,42 @@ describe("session hook context wiring", () => { vi.useRealTimers(); } }); + + it("prefers idle over daily when both rollover conditions are true", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const sessionKey = "agent:main:telegram:direct:overlap"; + const storePath = await createStorePath("openclaw-session-hook-overlap"); + const transcriptPath = await writeTranscript(storePath, "overlap-session", "overlap"); + await writeStore(storePath, { + [sessionKey]: { + sessionId: "overlap-session", + sessionFile: transcriptPath, + updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + }, + }); + const cfg = { + session: { + store: storePath, + reset: { + mode: "daily", + atHour: 4, + idleMinutes: 30, + }, + }, + } as OpenClawConfig; + + await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; + expect(event).toMatchObject({ reason: "idle" }); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 614b506eb34..d82761682ff 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -63,14 +63,7 @@ function loadSessionArchiveRuntime() { function resolveExplicitSessionEndReason( matchedResetTriggerLower?: string, ): PluginHookSessionEndReason { - switch (matchedResetTriggerLower) { - case "/new": - return "new"; - case "/reset": - return "reset"; - default: - return "unknown"; - } + return matchedResetTriggerLower === "/reset" ? "reset" : "new"; } function resolveStaleSessionEndReason(params: { @@ -85,10 +78,13 @@ function resolveStaleSessionEndReason(params: { params.freshness.dailyResetAt != null && params.entry.updatedAt < params.freshness.dailyResetAt; const staleIdle = params.freshness.idleExpiresAt != null && params.now > params.freshness.idleExpiresAt; - if (staleDaily === staleIdle) { - return staleDaily ? "unknown" : undefined; + if (staleIdle) { + return "idle"; } - return staleDaily ? "daily" : "idle"; + if (staleDaily) { + return "daily"; + } + return undefined; } export type SessionInitResult = {