test: share auto-reply session fixtures

This commit is contained in:
Peter Steinberger
2026-04-20 19:47:51 +01:00
parent cf7b906216
commit 82e6501f89
3 changed files with 144 additions and 210 deletions

View File

@@ -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<typeof createDeferred<void>>;
runFollowup: (run: FollowupRun) => Promise<void>;
} {
const calls: FollowupRun[] = [];
const done = createDeferred<void>();
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<void>();
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<void>();
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<void>();
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<void>();
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);

View File

@@ -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<NonNullable<OpenClawConfig["session"]>["reset"]>;
async function initStoredSessionState(params: {
prefix: string;
sessionKey: string;
sessionId: string;
text: string;
updatedAt: number;
reset?: SessionResetConfig;
}): Promise<void> {
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" });

View File

@@ -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<void> => {
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<string, SessionEntry> = {
"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<string, SessionEntry> = {
"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<string, SessionEntry> = {
"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<string, SessionEntry> = {
"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<string, SessionEntry> = {
"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({