266 lines
9.3 KiB
TypeScript
266 lines
9.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
|
|
vi.mock("../../config/sessions.js", () => ({
|
|
loadSessionStore: vi.fn(),
|
|
resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"),
|
|
evaluateSessionFreshness: vi.fn().mockReturnValue({ fresh: true }),
|
|
resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }),
|
|
}));
|
|
|
|
vi.mock("../../agents/bootstrap-cache.js", () => ({
|
|
clearBootstrapSnapshot: vi.fn(),
|
|
clearBootstrapSnapshotOnSessionRollover: vi.fn(({ sessionKey, previousSessionId }) => {
|
|
if (sessionKey && previousSessionId) {
|
|
clearBootstrapSnapshot(sessionKey);
|
|
}
|
|
}),
|
|
}));
|
|
|
|
import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js";
|
|
import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js";
|
|
import { resolveCronSession } from "./session.js";
|
|
|
|
const NOW_MS = 1_737_600_000_000;
|
|
|
|
type SessionStore = ReturnType<typeof loadSessionStore>;
|
|
type SessionStoreEntry = SessionStore[string];
|
|
type MockSessionStoreEntry = Partial<SessionStoreEntry>;
|
|
|
|
function resolveWithStoredEntry(params?: {
|
|
sessionKey?: string;
|
|
entry?: MockSessionStoreEntry;
|
|
forceNew?: boolean;
|
|
fresh?: boolean;
|
|
}) {
|
|
const sessionKey = params?.sessionKey ?? "webhook:stable-key";
|
|
const store: SessionStore = params?.entry
|
|
? ({ [sessionKey]: params.entry as SessionStoreEntry } as SessionStore)
|
|
: {};
|
|
vi.mocked(loadSessionStore).mockReturnValue(store);
|
|
vi.mocked(evaluateSessionFreshness).mockReturnValue({ fresh: params?.fresh ?? true });
|
|
|
|
return resolveCronSession({
|
|
cfg: {} as OpenClawConfig,
|
|
sessionKey,
|
|
agentId: "main",
|
|
nowMs: NOW_MS,
|
|
forceNew: params?.forceNew,
|
|
});
|
|
}
|
|
|
|
describe("resolveCronSession", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(clearBootstrapSnapshot).mockReset();
|
|
});
|
|
|
|
it("preserves modelOverride and providerOverride from existing session entry", () => {
|
|
const result = resolveWithStoredEntry({
|
|
sessionKey: "agent:main:cron:test-job",
|
|
entry: {
|
|
sessionId: "old-session-id",
|
|
updatedAt: 1000,
|
|
modelOverride: "deepseek-v3-4bit-mlx",
|
|
providerOverride: "inferencer",
|
|
thinkingLevel: "high",
|
|
model: "kimi-code",
|
|
},
|
|
});
|
|
|
|
expect(result.sessionEntry.modelOverride).toBe("deepseek-v3-4bit-mlx");
|
|
expect(result.sessionEntry.providerOverride).toBe("inferencer");
|
|
expect(result.sessionEntry.thinkingLevel).toBe("high");
|
|
// The model field (last-used model) should also be preserved
|
|
expect(result.sessionEntry.model).toBe("kimi-code");
|
|
});
|
|
|
|
it("handles missing modelOverride gracefully", () => {
|
|
const result = resolveWithStoredEntry({
|
|
sessionKey: "agent:main:cron:test-job",
|
|
entry: {
|
|
sessionId: "old-session-id",
|
|
updatedAt: 1000,
|
|
model: "claude-opus-4-5",
|
|
},
|
|
});
|
|
|
|
expect(result.sessionEntry.modelOverride).toBeUndefined();
|
|
expect(result.sessionEntry.providerOverride).toBeUndefined();
|
|
});
|
|
|
|
it("handles no existing session entry", () => {
|
|
const result = resolveWithStoredEntry({
|
|
sessionKey: "agent:main:cron:new-job",
|
|
});
|
|
|
|
expect(result.sessionEntry.modelOverride).toBeUndefined();
|
|
expect(result.sessionEntry.providerOverride).toBeUndefined();
|
|
expect(result.sessionEntry.model).toBeUndefined();
|
|
expect(result.isNewSession).toBe(true);
|
|
});
|
|
|
|
// New tests for session reuse behavior (#18027)
|
|
describe("session reuse for webhooks/cron", () => {
|
|
it("reuses existing sessionId when session is fresh", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
sessionId: "existing-session-id-123",
|
|
updatedAt: NOW_MS - 1000,
|
|
systemSent: true,
|
|
},
|
|
fresh: true,
|
|
});
|
|
|
|
expect(result.sessionEntry.sessionId).toBe("existing-session-id-123");
|
|
expect(result.isNewSession).toBe(false);
|
|
expect(result.systemSent).toBe(true);
|
|
expect(clearBootstrapSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates new sessionId when session is stale", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
sessionId: "old-session-id",
|
|
updatedAt: NOW_MS - 86_400_000, // 1 day ago
|
|
systemSent: true,
|
|
modelOverride: "gpt-4.1-mini",
|
|
providerOverride: "openai",
|
|
sendPolicy: "allow",
|
|
},
|
|
fresh: false,
|
|
});
|
|
|
|
expect(result.sessionEntry.sessionId).not.toBe("old-session-id");
|
|
expect(result.isNewSession).toBe(true);
|
|
expect(result.systemSent).toBe(false);
|
|
expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini");
|
|
expect(result.sessionEntry.providerOverride).toBe("openai");
|
|
expect(result.sessionEntry.sendPolicy).toBe("allow");
|
|
expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key");
|
|
});
|
|
|
|
it("creates new sessionId when forceNew is true", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
sessionId: "existing-session-id-456",
|
|
updatedAt: NOW_MS - 1000,
|
|
systemSent: true,
|
|
modelOverride: "sonnet-4",
|
|
providerOverride: "anthropic",
|
|
},
|
|
fresh: true,
|
|
forceNew: true,
|
|
});
|
|
|
|
expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-456");
|
|
expect(result.isNewSession).toBe(true);
|
|
expect(result.systemSent).toBe(false);
|
|
expect(result.sessionEntry.modelOverride).toBe("sonnet-4");
|
|
expect(result.sessionEntry.providerOverride).toBe("anthropic");
|
|
expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key");
|
|
});
|
|
|
|
it("clears delivery routing metadata and deliveryContext when forceNew is true", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
sessionId: "existing-session-id-789",
|
|
updatedAt: NOW_MS - 1000,
|
|
systemSent: true,
|
|
lastChannel: "slack" as never,
|
|
lastTo: "channel:C0XXXXXXXXX",
|
|
lastAccountId: "acct-123",
|
|
lastThreadId: "1737500000.123456",
|
|
deliveryContext: {
|
|
channel: "slack",
|
|
to: "channel:C0XXXXXXXXX",
|
|
threadId: "1737500000.123456",
|
|
},
|
|
modelOverride: "gpt-5.2",
|
|
},
|
|
fresh: true,
|
|
forceNew: true,
|
|
});
|
|
|
|
expect(result.isNewSession).toBe(true);
|
|
// Delivery routing state must be cleared to prevent thread leaking.
|
|
// deliveryContext must also be cleared because normalizeSessionEntryDelivery
|
|
// repopulates lastThreadId from deliveryContext.threadId on store writes.
|
|
expect(result.sessionEntry.lastChannel).toBeUndefined();
|
|
expect(result.sessionEntry.lastTo).toBeUndefined();
|
|
expect(result.sessionEntry.lastAccountId).toBeUndefined();
|
|
expect(result.sessionEntry.lastThreadId).toBeUndefined();
|
|
expect(result.sessionEntry.deliveryContext).toBeUndefined();
|
|
// Per-session overrides must be preserved
|
|
expect(result.sessionEntry.modelOverride).toBe("gpt-5.2");
|
|
});
|
|
|
|
it("clears delivery routing metadata when session is stale", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
sessionId: "old-session-id",
|
|
updatedAt: NOW_MS - 86_400_000,
|
|
lastChannel: "slack" as never,
|
|
lastTo: "channel:C0XXXXXXXXX",
|
|
lastThreadId: "1737500000.999999",
|
|
deliveryContext: {
|
|
channel: "slack",
|
|
to: "channel:C0XXXXXXXXX",
|
|
threadId: "1737500000.999999",
|
|
},
|
|
},
|
|
fresh: false,
|
|
});
|
|
|
|
expect(result.isNewSession).toBe(true);
|
|
expect(result.sessionEntry.lastChannel).toBeUndefined();
|
|
expect(result.sessionEntry.lastTo).toBeUndefined();
|
|
expect(result.sessionEntry.lastAccountId).toBeUndefined();
|
|
expect(result.sessionEntry.lastThreadId).toBeUndefined();
|
|
expect(result.sessionEntry.deliveryContext).toBeUndefined();
|
|
});
|
|
|
|
it("preserves delivery routing metadata when reusing fresh session", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
sessionId: "existing-session-id-101",
|
|
updatedAt: NOW_MS - 1000,
|
|
systemSent: true,
|
|
lastChannel: "slack" as never,
|
|
lastTo: "channel:C0XXXXXXXXX",
|
|
lastThreadId: "1737500000.123456",
|
|
deliveryContext: {
|
|
channel: "slack",
|
|
to: "channel:C0XXXXXXXXX",
|
|
threadId: "1737500000.123456",
|
|
},
|
|
},
|
|
fresh: true,
|
|
});
|
|
|
|
expect(result.isNewSession).toBe(false);
|
|
expect(result.sessionEntry.lastChannel).toBe("slack");
|
|
expect(result.sessionEntry.lastTo).toBe("channel:C0XXXXXXXXX");
|
|
expect(result.sessionEntry.lastThreadId).toBe("1737500000.123456");
|
|
expect(result.sessionEntry.deliveryContext).toEqual({
|
|
channel: "slack",
|
|
to: "channel:C0XXXXXXXXX",
|
|
threadId: "1737500000.123456",
|
|
});
|
|
});
|
|
|
|
it("creates new sessionId when entry exists but has no sessionId", () => {
|
|
const result = resolveWithStoredEntry({
|
|
entry: {
|
|
updatedAt: NOW_MS - 1000,
|
|
modelOverride: "some-model",
|
|
},
|
|
});
|
|
|
|
expect(result.sessionEntry.sessionId).toBeDefined();
|
|
expect(result.isNewSession).toBe(true);
|
|
// Should still preserve other fields from entry
|
|
expect(result.sessionEntry.modelOverride).toBe("some-model");
|
|
});
|
|
});
|
|
});
|