refactor(session): centralize thread reset detection

Co-authored-by: Austin Mudd <austinm911@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-18 06:54:55 +00:00
parent b5ddf08763
commit f86b24c511
6 changed files with 201 additions and 41 deletions

View File

@@ -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));

View File

@@ -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

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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<typeof getSessionSnapshot>[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();
}
});
});

View File

@@ -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<typeof loadConfig>,
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,