From f86b24c511382961135ae12c2d83d80a97c5bc8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 06:54:55 +0000 Subject: [PATCH] refactor(session): centralize thread reset detection Co-authored-by: Austin Mudd --- src/auto-reply/reply/session.test.ts | 100 ++++++++++++++++++++ src/auto-reply/reply/session.ts | 15 +-- src/config/sessions/reset.ts | 14 +++ src/config/zod-schema.session.ts | 38 ++------ src/web/auto-reply/session-snapshot.test.ts | 45 +++++++++ src/web/auto-reply/session-snapshot.ts | 30 ++++-- 6 files changed, 201 insertions(+), 41 deletions(-) create mode 100644 src/web/auto-reply/session-snapshot.test.ts diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index d6ad30ee2..bdc2adbd0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -202,6 +202,36 @@ describe("initSessionState reset policy", () => { } }); + it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 3, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-edge-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:S-edge"; + const existingSessionId = "daily-edge-session"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(), + }, + }); + + const cfg = { session: { store: storePath } } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); + it("expires sessions when idle timeout wins over daily reset", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); @@ -273,6 +303,76 @@ describe("initSessionState reset policy", () => { } }); + it("detects thread sessions without thread key suffix", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-nosuffix-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:discord:channel:C1"; + const existingSessionId = "thread-nosuffix"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + resetByType: { thread: { mode: "idle", idleMinutes: 180 } }, + }, + } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Discord thread" }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(false); + expect(result.sessionId).toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); + + it("defaults to daily resets when only resetByType is configured", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-type-default-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:S4"; + const existingSessionId = "type-default-session"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + resetByType: { thread: { mode: "idle", idleMinutes: 60 } }, + }, + } as ClawdbotConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + } finally { + vi.useRealTimers(); + } + }); + it("keeps legacy idleMinutes behavior without reset config", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index bef1b2f1b..412bbb285 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -9,9 +9,9 @@ import { DEFAULT_RESET_TRIGGERS, deriveSessionMetaPatch, evaluateSessionFreshness, - isThreadSessionKey, type GroupKeyResolution, loadSessionStore, + resolveThreadFlag, resolveSessionResetPolicy, resolveSessionResetType, resolveGroupSessionKey, @@ -173,12 +173,13 @@ export async function initSessionState(params: { const entry = sessionStore[sessionKey]; const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined; const now = Date.now(); - const isThread = - ctx.MessageThreadId != null || - Boolean(ctx.ThreadLabel?.trim()) || - Boolean(ctx.ThreadStarterBody?.trim()) || - Boolean(ctx.ParentSessionKey?.trim()) || - isThreadSessionKey(sessionKey); + const isThread = resolveThreadFlag({ + sessionKey, + messageThreadId: ctx.MessageThreadId, + threadLabel: ctx.ThreadLabel, + threadStarterBody: ctx.ThreadStarterBody, + parentSessionKey: ctx.ParentSessionKey, + }); const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread }); const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); const freshEntry = entry diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index eb40b659c..eecccaf11 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -40,6 +40,20 @@ export function resolveSessionResetType(params: { return "dm"; } +export function resolveThreadFlag(params: { + sessionKey?: string | null; + messageThreadId?: string | number | null; + threadLabel?: string | null; + threadStarterBody?: string | null; + parentSessionKey?: string | null; +}): boolean { + if (params.messageThreadId != null) return true; + if (params.threadLabel?.trim()) return true; + if (params.threadStarterBody?.trim()) return true; + if (params.parentSessionKey?.trim()) return true; + return isThreadSessionKey(params.sessionKey); +} + export function resolveDailyResetAtMs(now: number, atHour: number): number { const normalizedAtHour = normalizeResetAtHour(atHour); const resetAt = new Date(now); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 5b70e83bb..db4badd05 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -7,6 +7,12 @@ import { QueueSchema, } from "./zod-schema.core.js"; +const SessionResetConfigSchema = z.object({ + mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), + atHour: z.number().int().min(0).max(23).optional(), + idleMinutes: z.number().int().positive().optional(), +}); + export const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), @@ -17,36 +23,12 @@ export const SessionSchema = z resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(), - reset: z - .object({ - mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), - atHour: z.number().int().min(0).max(23).optional(), - idleMinutes: z.number().int().positive().optional(), - }) - .optional(), + reset: SessionResetConfigSchema.optional(), resetByType: z .object({ - dm: z - .object({ - mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), - atHour: z.number().int().min(0).max(23).optional(), - idleMinutes: z.number().int().positive().optional(), - }) - .optional(), - group: z - .object({ - mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), - atHour: z.number().int().min(0).max(23).optional(), - idleMinutes: z.number().int().positive().optional(), - }) - .optional(), - thread: z - .object({ - mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), - atHour: z.number().int().min(0).max(23).optional(), - idleMinutes: z.number().int().positive().optional(), - }) - .optional(), + dm: SessionResetConfigSchema.optional(), + group: SessionResetConfigSchema.optional(), + thread: SessionResetConfigSchema.optional(), }) .optional(), store: z.string().optional(), diff --git a/src/web/auto-reply/session-snapshot.test.ts b/src/web/auto-reply/session-snapshot.test.ts new file mode 100644 index 000000000..82fa5dbf1 --- /dev/null +++ b/src/web/auto-reply/session-snapshot.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { saveSessionStore } from "../../config/sessions.js"; +import { getSessionSnapshot } from "./session-snapshot.js"; + +describe("getSessionSnapshot", () => { + it("uses heartbeat idle override while daily reset still applies", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-snapshot-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:S1"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: "snapshot-session", + updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), + }, + }); + + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4, idleMinutes: 240 }, + heartbeatIdleMinutes: 30, + }, + } as Parameters[0]; + + const snapshot = getSessionSnapshot(cfg, "whatsapp:+15550001111", true, { + sessionKey, + }); + + expect(snapshot.resetPolicy.idleMinutes).toBe(30); + expect(snapshot.fresh).toBe(false); + expect(snapshot.dailyResetAt).toBe(new Date(2026, 0, 18, 4, 0, 0).getTime()); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/web/auto-reply/session-snapshot.ts b/src/web/auto-reply/session-snapshot.ts index 94072e451..051c29972 100644 --- a/src/web/auto-reply/session-snapshot.ts +++ b/src/web/auto-reply/session-snapshot.ts @@ -2,6 +2,7 @@ import type { loadConfig } from "../../config/config.js"; import { evaluateSessionFreshness, loadSessionStore, + resolveThreadFlag, resolveSessionResetPolicy, resolveSessionResetType, resolveSessionKey, @@ -13,17 +14,34 @@ export function getSessionSnapshot( cfg: ReturnType, from: string, isHeartbeat = false, + ctx?: { + sessionKey?: string | null; + isGroup?: boolean; + messageThreadId?: string | number | null; + threadLabel?: string | null; + threadStarterBody?: string | null; + parentSessionKey?: string | null; + }, ) { const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; - const key = resolveSessionKey( - scope, - { From: from, To: "", Body: "" }, - normalizeMainKey(sessionCfg?.mainKey), - ); + const key = + ctx?.sessionKey?.trim() ?? + resolveSessionKey( + scope, + { From: from, To: "", Body: "" }, + normalizeMainKey(sessionCfg?.mainKey), + ); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; - const resetType = resolveSessionResetType({ sessionKey: key }); + const isThread = resolveThreadFlag({ + sessionKey: key, + messageThreadId: ctx?.messageThreadId ?? null, + threadLabel: ctx?.threadLabel ?? null, + threadStarterBody: ctx?.threadStarterBody ?? null, + parentSessionKey: ctx?.parentSessionKey ?? null, + }); + const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread }); const idleMinutesOverride = isHeartbeat ? sessionCfg?.heartbeatIdleMinutes : undefined; const resetPolicy = resolveSessionResetPolicy({ sessionCfg,