diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1647d3b58..93a11b77510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc. +- Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker. - Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai. - Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin. - Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin. diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 8cfb6b5e7d9..b0feaca4a23 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1457,6 +1457,61 @@ describe("initSessionState preserves behavior overrides across /new and /reset", archiveSpy.mockRestore(); }); + it("archives the old session transcript on daily/scheduled reset (stale session)", async () => { + // Daily resets occur when the session becomes stale (not via /new or /reset command). + // Previously, previousSessionEntry was only set when resetTriggered=true, leaving + // old transcript files orphaned on disk. Refs #35481. + vi.useFakeTimers(); + try { + // Simulate: it is 5am, session was last active at 3am (before 4am daily boundary) + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + const storePath = await createStorePath("openclaw-stale-archive-"); + const sessionKey = "agent:main:telegram:dm:archive-stale-user"; + const existingSessionId = "stale-session-to-be-archived"; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const sessionUtils = await import("../../gateway/session-utils.fs.js"); + const archiveSpy = vi.spyOn(sessionUtils, "archiveSessionTranscripts"); + + const cfg = { session: { store: storePath } } as OpenClawConfig; + const result = await initSessionState({ + ctx: { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "user-stale", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(false); + expect(result.sessionId).not.toBe(existingSessionId); + expect(archiveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: existingSessionId, + storePath, + reason: "reset", + }), + ); + archiveSpy.mockRestore(); + } finally { + vi.useRealTimers(); + } + }); + it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { const storePath = await createStorePath("openclaw-idle-no-preserve-"); const sessionKey = "agent:main:telegram:dm:new-user"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 60bcc78135b..a0e730334e2 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -328,7 +328,6 @@ export async function initSessionState(params: { sessionStore[retiredLegacyMainDelivery.key] = retiredLegacyMainDelivery.entry; } const entry = sessionStore[sessionKey]; - const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined; const now = Date.now(); const isThread = resolveThreadFlag({ sessionKey, @@ -354,6 +353,11 @@ export async function initSessionState(params: { const freshEntry = entry ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh : false; + // Capture the current session entry before any reset so its transcript can be + // archived afterward. We need to do this for both explicit resets (/new, /reset) + // and for scheduled/daily resets where the session has become stale (!freshEntry). + // Without this, daily-reset transcripts are left as orphaned files on disk (#35481). + const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined; if (!isNewSession && freshEntry) { sessionId = entry.sessionId;