mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
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 () => {
|
||||
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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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 {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user