diff --git a/src/auto-reply/reply/queue.dedupe.test.ts b/src/auto-reply/reply/queue.dedupe.test.ts index 5148bcd3c5e..22fc37e6085 100644 --- a/src/auto-reply/reply/queue.dedupe.test.ts +++ b/src/auto-reply/reply/queue.dedupe.test.ts @@ -14,6 +14,32 @@ import { installQueueRuntimeErrorSilencer(); +const collectSettings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", +}; + +function createFollowupCollector(expectedCalls = 1): { + calls: FollowupRun[]; + done: ReturnType>; + runFollowup: (run: FollowupRun) => Promise; +} { + const calls: FollowupRun[] = []; + const done = createDeferred(); + return { + calls, + done, + runFollowup: async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }, + }; +} + describe("followup queue deduplication", () => { beforeEach(() => { resetRecentQueuedMessageIdDedupe(); @@ -21,21 +47,7 @@ describe("followup queue deduplication", () => { it("deduplicates messages with same Discord message_id", async () => { const key = `test-dedup-message-id-${Date.now()}`; - const calls: FollowupRun[] = []; - const done = createDeferred(); - const expectedCalls = 1; - const runFollowup = async (run: FollowupRun) => { - calls.push(run); - if (calls.length >= expectedCalls) { - done.resolve(); - } - }; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; + const { calls, done, runFollowup } = createFollowupCollector(); const first = enqueueFollowupRun( key, @@ -45,7 +57,7 @@ describe("followup queue deduplication", () => { originatingChannel: "discord", originatingTo: "channel:123", }), - settings, + collectSettings, ); expect(first).toBe(true); @@ -57,7 +69,7 @@ describe("followup queue deduplication", () => { originatingChannel: "discord", originatingTo: "channel:123", }), - settings, + collectSettings, ); expect(second).toBe(false); @@ -69,7 +81,7 @@ describe("followup queue deduplication", () => { originatingChannel: "discord", originatingTo: "channel:123", }), - settings, + collectSettings, ); expect(third).toBe(true); @@ -80,18 +92,7 @@ describe("followup queue deduplication", () => { it("deduplicates same message_id after queue drain restarts", async () => { const key = `test-dedup-after-drain-${Date.now()}`; - const calls: FollowupRun[] = []; - const done = createDeferred(); - const runFollowup = async (run: FollowupRun) => { - calls.push(run); - done.resolve(); - }; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; + const { calls, done, runFollowup } = createFollowupCollector(); const first = enqueueFollowupRun( key, @@ -101,7 +102,7 @@ describe("followup queue deduplication", () => { originatingChannel: "signal", originatingTo: "+10000000000", }), - settings, + collectSettings, ); expect(first).toBe(true); @@ -116,7 +117,7 @@ describe("followup queue deduplication", () => { originatingChannel: "signal", originatingTo: "+10000000000", }), - settings, + collectSettings, ); expect(redelivery).toBe(false); @@ -134,18 +135,7 @@ describe("followup queue deduplication", () => { ); const { clearSessionQueues } = await import("./queue.js"); const key = `test-dedup-cross-module-${Date.now()}`; - const calls: FollowupRun[] = []; - const done = createDeferred(); - const runFollowup = async (run: FollowupRun) => { - calls.push(run); - done.resolve(); - }; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; + const { calls, done, runFollowup } = createFollowupCollector(); enqueueA.resetRecentQueuedMessageIdDedupe(); enqueueB.resetRecentQueuedMessageIdDedupe(); @@ -160,7 +150,7 @@ describe("followup queue deduplication", () => { originatingChannel: "signal", originatingTo: "+10000000000", }), - settings, + collectSettings, ), ).toBe(true); @@ -177,7 +167,7 @@ describe("followup queue deduplication", () => { originatingChannel: "signal", originatingTo: "+10000000000", }), - settings, + collectSettings, ), ).toBe(false); expect(calls).toHaveLength(1); @@ -190,18 +180,7 @@ describe("followup queue deduplication", () => { it("does not collide recent message-id keys when routing contains delimiters", async () => { const key = `test-dedup-key-collision-${Date.now()}`; - const calls: FollowupRun[] = []; - const done = createDeferred(); - const runFollowup = async (run: FollowupRun) => { - calls.push(run); - done.resolve(); - }; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; + const { done, runFollowup } = createFollowupCollector(); const first = enqueueFollowupRun( key, @@ -211,7 +190,7 @@ describe("followup queue deduplication", () => { originatingChannel: "signal|group", originatingTo: "peer", }), - settings, + collectSettings, ); expect(first).toBe(true); @@ -226,19 +205,13 @@ describe("followup queue deduplication", () => { originatingChannel: "signal", originatingTo: "group|peer", }), - settings, + collectSettings, ); expect(second).toBe(true); }); it("deduplicates exact prompt when routing matches and no message id", async () => { const key = `test-dedup-whatsapp-${Date.now()}`; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; const first = enqueueFollowupRun( key, @@ -247,7 +220,7 @@ describe("followup queue deduplication", () => { originatingChannel: "whatsapp", originatingTo: "+1234567890", }), - settings, + collectSettings, ); expect(first).toBe(true); @@ -258,7 +231,7 @@ describe("followup queue deduplication", () => { originatingChannel: "whatsapp", originatingTo: "+1234567890", }), - settings, + collectSettings, ); expect(second).toBe(true); @@ -269,19 +242,13 @@ describe("followup queue deduplication", () => { originatingChannel: "whatsapp", originatingTo: "+1234567890", }), - settings, + collectSettings, ); expect(third).toBe(true); }); it("does not deduplicate across different providers without message id", async () => { const key = `test-dedup-cross-provider-${Date.now()}`; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; const first = enqueueFollowupRun( key, @@ -290,7 +257,7 @@ describe("followup queue deduplication", () => { originatingChannel: "whatsapp", originatingTo: "+1234567890", }), - settings, + collectSettings, ); expect(first).toBe(true); @@ -301,19 +268,13 @@ describe("followup queue deduplication", () => { originatingChannel: "discord", originatingTo: "channel:123", }), - settings, + collectSettings, ); expect(second).toBe(true); }); it("can opt-in to prompt-based dedupe when message id is absent", async () => { const key = `test-dedup-prompt-mode-${Date.now()}`; - const settings: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", - }; const first = enqueueFollowupRun( key, @@ -322,7 +283,7 @@ describe("followup queue deduplication", () => { originatingChannel: "whatsapp", originatingTo: "+1234567890", }), - settings, + collectSettings, "prompt", ); expect(first).toBe(true); @@ -334,7 +295,7 @@ describe("followup queue deduplication", () => { originatingChannel: "whatsapp", originatingTo: "+1234567890", }), - settings, + collectSettings, "prompt", ); expect(second).toBe(false); diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index dc13e136757..9eefe18453e 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -53,6 +53,50 @@ async function writeTranscript( return transcriptPath; } +async function createStoredSession(params: { + prefix: string; + sessionKey: string; + sessionId: string; + text?: string; + updatedAt?: number; +}): Promise<{ storePath: string; transcriptPath: string }> { + const storePath = await createStorePath(params.prefix); + const transcriptPath = await writeTranscript(storePath, params.sessionId, params.text); + await writeStore(storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + sessionFile: transcriptPath, + updatedAt: params.updatedAt ?? Date.now(), + }, + }); + return { storePath, transcriptPath }; +} + +type SessionResetConfig = NonNullable["reset"]>; + +async function initStoredSessionState(params: { + prefix: string; + sessionKey: string; + sessionId: string; + text: string; + updatedAt: number; + reset?: SessionResetConfig; +}): Promise { + const { storePath } = await createStoredSession(params); + const cfg = { + session: { + store: storePath, + ...(params.reset ? { reset: params.reset } : {}), + }, + } as OpenClawConfig; + + await initSessionState({ + ctx: { Body: "hello", SessionKey: params.sessionKey }, + cfg, + commandAuthorized: true, + }); +} + describe("session hook context wiring", () => { beforeEach(() => { hookRunnerMocks.hasHooks.mockReset(); @@ -90,14 +134,10 @@ describe("session hook context wiring", () => { it("passes sessionKey to session_end hook context on reset", async () => { const sessionKey = "agent:main:telegram:direct:123"; - const storePath = await createStorePath("openclaw-session-hook-end"); - const transcriptPath = await writeTranscript(storePath, "old-session"); - await writeStore(storePath, { - [sessionKey]: { - sessionId: "old-session", - sessionFile: transcriptPath, - updatedAt: Date.now(), - }, + const { storePath } = await createStoredSession({ + prefix: "openclaw-session-hook-end", + sessionKey, + sessionId: "old-session", }); const cfg = { session: { store: storePath } } as OpenClawConfig; @@ -127,14 +167,11 @@ describe("session hook context wiring", () => { it("marks explicit /reset rollovers with reason reset", async () => { const sessionKey = "agent:main:telegram:direct:456"; - const storePath = await createStorePath("openclaw-session-hook-explicit-reset"); - const transcriptPath = await writeTranscript(storePath, "reset-session", "reset me"); - await writeStore(storePath, { - [sessionKey]: { - sessionId: "reset-session", - sessionFile: transcriptPath, - updatedAt: Date.now(), - }, + const { storePath } = await createStoredSession({ + prefix: "openclaw-session-hook-explicit-reset", + sessionKey, + sessionId: "reset-session", + text: "reset me", }); const cfg = { session: { store: storePath } } as OpenClawConfig; @@ -150,14 +187,11 @@ describe("session hook context wiring", () => { it("maps custom reset trigger aliases to the new-session reason", async () => { const sessionKey = "agent:main:telegram:direct:alias"; - const storePath = await createStorePath("openclaw-session-hook-reset-alias"); - const transcriptPath = await writeTranscript(storePath, "alias-session", "alias me"); - await writeStore(storePath, { - [sessionKey]: { - sessionId: "alias-session", - sessionFile: transcriptPath, - updatedAt: Date.now(), - }, + const { storePath } = await createStoredSession({ + prefix: "openclaw-session-hook-reset-alias", + sessionKey, + sessionId: "alias-session", + text: "alias me", }); const cfg = { session: { @@ -181,21 +215,12 @@ describe("session hook context wiring", () => { try { vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); const sessionKey = "agent:main:telegram:direct:daily"; - const storePath = await createStorePath("openclaw-session-hook-daily"); - const transcriptPath = await writeTranscript(storePath, "daily-session", "daily"); - await writeStore(storePath, { - [sessionKey]: { - sessionId: "daily-session", - sessionFile: transcriptPath, - updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), - }, - }); - const cfg = { session: { store: storePath } } as OpenClawConfig; - - await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, + await initStoredSessionState({ + prefix: "openclaw-session-hook-daily", + sessionKey, + sessionId: "daily-session", + text: "daily", + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), }); const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; @@ -216,30 +241,17 @@ describe("session hook context wiring", () => { try { vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); const sessionKey = "agent:main:telegram:direct:idle"; - const storePath = await createStorePath("openclaw-session-hook-idle"); - const transcriptPath = await writeTranscript(storePath, "idle-session", "idle"); - await writeStore(storePath, { - [sessionKey]: { - sessionId: "idle-session", - sessionFile: transcriptPath, - updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + await initStoredSessionState({ + prefix: "openclaw-session-hook-idle", + sessionKey, + sessionId: "idle-session", + text: "idle", + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + reset: { + mode: "idle", + idleMinutes: 30, }, }); - const cfg = { - session: { - store: storePath, - reset: { - mode: "idle", - idleMinutes: 30, - }, - }, - } as OpenClawConfig; - - await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; expect(event).toMatchObject({ reason: "idle" }); @@ -253,31 +265,18 @@ describe("session hook context wiring", () => { try { vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); const sessionKey = "agent:main:telegram:direct:overlap"; - const storePath = await createStorePath("openclaw-session-hook-overlap"); - const transcriptPath = await writeTranscript(storePath, "overlap-session", "overlap"); - await writeStore(storePath, { - [sessionKey]: { - sessionId: "overlap-session", - sessionFile: transcriptPath, - updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + await initStoredSessionState({ + prefix: "openclaw-session-hook-overlap", + sessionKey, + sessionId: "overlap-session", + text: "overlap", + updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + reset: { + mode: "daily", + atHour: 4, + idleMinutes: 30, }, }); - const cfg = { - session: { - store: storePath, - reset: { - mode: "daily", - atHour: 4, - idleMinutes: 30, - }, - }, - } as OpenClawConfig; - - await initSessionState({ - ctx: { Body: "hello", SessionKey: sessionKey }, - cfg, - commandAuthorized: true, - }); const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; expect(event).toMatchObject({ reason: "idle" }); diff --git a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts index 1de668bab4f..6a478234a2b 100644 --- a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts +++ b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { saveSessionStore } from "../../config/sessions/store.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; import type { MsgContext } from "../templating.js"; import { initSessionState } from "./session.js"; @@ -63,19 +62,22 @@ describe("initSessionState - heartbeat should not trigger session reset", () => ...overrides, }); + const saveExistingSession = async (sessionId: string, updatedAt: number): Promise => { + await saveSessionStore(storePath, { + "main:user123": { + sessionId, + updatedAt, + systemSent: true, + }, + }); + }; + it("should NOT reset session when Provider is 'heartbeat'", async () => { // Setup: Create a session entry that is "stale" (older than idle timeout) const now = Date.now(); const staleTime = now - 10 * 60 * 1000; // 10 minutes ago (exceeds 5min idle timeout) - const initialStore: Record = { - "main:user123": { - sessionId: "original-session-id-12345", - updatedAt: staleTime, - systemSent: true, - }, - }; - await saveSessionStore(storePath, initialStore); + await saveExistingSession("original-session-id-12345", staleTime); const cfg = createBaseConfig(); const ctx = createBaseCtx({ @@ -101,14 +103,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => const now = Date.now(); const staleTime = now - 10 * 60 * 1000; // 10 minutes ago (exceeds 5min idle timeout) - const initialStore: Record = { - "main:user123": { - sessionId: "original-session-id-12345", - updatedAt: staleTime, - systemSent: true, - }, - }; - await saveSessionStore(storePath, initialStore); + await saveExistingSession("original-session-id-12345", staleTime); const cfg = createBaseConfig(); const ctx = createBaseCtx({ @@ -133,14 +128,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => const now = Date.now(); const yesterday = now - 25 * 60 * 60 * 1000; // 25 hours ago - const initialStore: Record = { - "main:user123": { - sessionId: "original-session-id-67890", - updatedAt: yesterday, - systemSent: true, - }, - }; - await saveSessionStore(storePath, initialStore); + await saveExistingSession("original-session-id-67890", yesterday); const cfg = createBaseConfig(); cfg.session!.reset = { @@ -169,14 +157,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => const now = Date.now(); const staleTime = now - 10 * 60 * 1000; - const initialStore: Record = { - "main:user123": { - sessionId: "cron-session-id-abcde", - updatedAt: staleTime, - systemSent: true, - }, - }; - await saveSessionStore(storePath, initialStore); + await saveExistingSession("cron-session-id-abcde", staleTime); const cfg = createBaseConfig(); const ctx = createBaseCtx({ @@ -200,14 +181,7 @@ describe("initSessionState - heartbeat should not trigger session reset", () => const now = Date.now(); const staleTime = now - 10 * 60 * 1000; - const initialStore: Record = { - "main:user123": { - sessionId: "exec-session-id-fghij", - updatedAt: staleTime, - systemSent: true, - }, - }; - await saveSessionStore(storePath, initialStore); + await saveExistingSession("exec-session-id-fghij", staleTime); const cfg = createBaseConfig(); const ctx = createBaseCtx({