refactor(session): centralize thread reset detection
Co-authored-by: Austin Mudd <austinm911@gmail.com>
This commit is contained in:
@@ -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 () => {
|
it("expires sessions when idle timeout wins over daily reset", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
|
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 () => {
|
it("keeps legacy idleMinutes behavior without reset config", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
DEFAULT_RESET_TRIGGERS,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
deriveSessionMetaPatch,
|
deriveSessionMetaPatch,
|
||||||
evaluateSessionFreshness,
|
evaluateSessionFreshness,
|
||||||
isThreadSessionKey,
|
|
||||||
type GroupKeyResolution,
|
type GroupKeyResolution,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveThreadFlag,
|
||||||
resolveSessionResetPolicy,
|
resolveSessionResetPolicy,
|
||||||
resolveSessionResetType,
|
resolveSessionResetType,
|
||||||
resolveGroupSessionKey,
|
resolveGroupSessionKey,
|
||||||
@@ -173,12 +173,13 @@ export async function initSessionState(params: {
|
|||||||
const entry = sessionStore[sessionKey];
|
const entry = sessionStore[sessionKey];
|
||||||
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
|
const previousSessionEntry = resetTriggered && entry ? { ...entry } : undefined;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isThread =
|
const isThread = resolveThreadFlag({
|
||||||
ctx.MessageThreadId != null ||
|
sessionKey,
|
||||||
Boolean(ctx.ThreadLabel?.trim()) ||
|
messageThreadId: ctx.MessageThreadId,
|
||||||
Boolean(ctx.ThreadStarterBody?.trim()) ||
|
threadLabel: ctx.ThreadLabel,
|
||||||
Boolean(ctx.ParentSessionKey?.trim()) ||
|
threadStarterBody: ctx.ThreadStarterBody,
|
||||||
isThreadSessionKey(sessionKey);
|
parentSessionKey: ctx.ParentSessionKey,
|
||||||
|
});
|
||||||
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
|
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
|
||||||
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
||||||
const freshEntry = entry
|
const freshEntry = entry
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ export function resolveSessionResetType(params: {
|
|||||||
return "dm";
|
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 {
|
export function resolveDailyResetAtMs(now: number, atHour: number): number {
|
||||||
const normalizedAtHour = normalizeResetAtHour(atHour);
|
const normalizedAtHour = normalizeResetAtHour(atHour);
|
||||||
const resetAt = new Date(now);
|
const resetAt = new Date(now);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
QueueSchema,
|
QueueSchema,
|
||||||
} from "./zod-schema.core.js";
|
} 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
|
export const SessionSchema = z
|
||||||
.object({
|
.object({
|
||||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
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(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||||
reset: z
|
reset: SessionResetConfigSchema.optional(),
|
||||||
.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(),
|
|
||||||
resetByType: z
|
resetByType: z
|
||||||
.object({
|
.object({
|
||||||
dm: z
|
dm: SessionResetConfigSchema.optional(),
|
||||||
.object({
|
group: SessionResetConfigSchema.optional(),
|
||||||
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
|
thread: SessionResetConfigSchema.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(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
store: z.string().optional(),
|
store: z.string().optional(),
|
||||||
|
|||||||
45
src/web/auto-reply/session-snapshot.test.ts
Normal file
45
src/web/auto-reply/session-snapshot.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { loadConfig } from "../../config/config.js";
|
|||||||
import {
|
import {
|
||||||
evaluateSessionFreshness,
|
evaluateSessionFreshness,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveThreadFlag,
|
||||||
resolveSessionResetPolicy,
|
resolveSessionResetPolicy,
|
||||||
resolveSessionResetType,
|
resolveSessionResetType,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
@@ -13,17 +14,34 @@ export function getSessionSnapshot(
|
|||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
from: string,
|
from: string,
|
||||||
isHeartbeat = false,
|
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 sessionCfg = cfg.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
const key = resolveSessionKey(
|
const key =
|
||||||
scope,
|
ctx?.sessionKey?.trim() ??
|
||||||
{ From: from, To: "", Body: "" },
|
resolveSessionKey(
|
||||||
normalizeMainKey(sessionCfg?.mainKey),
|
scope,
|
||||||
);
|
{ From: from, To: "", Body: "" },
|
||||||
|
normalizeMainKey(sessionCfg?.mainKey),
|
||||||
|
);
|
||||||
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
||||||
const entry = store[key];
|
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 idleMinutesOverride = isHeartbeat ? sessionCfg?.heartbeatIdleMinutes : undefined;
|
||||||
const resetPolicy = resolveSessionResetPolicy({
|
const resetPolicy = resolveSessionResetPolicy({
|
||||||
sessionCfg,
|
sessionCfg,
|
||||||
|
|||||||
Reference in New Issue
Block a user