Merge branch 'main' of github.com:openclaw/openclaw
This commit is contained in:
commit
289272f16a
@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
|
||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
import { applyResetModelOverride } from "./session-reset-model.js";
|
||||
@ -32,7 +33,7 @@ afterAll(async () => {
|
||||
|
||||
async function createStorePath(prefix: string): Promise<string> {
|
||||
const root = path.join(suiteRoot, `${prefix}${++suiteCase}`);
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.mkdir(root);
|
||||
return path.join(root, "sessions.json");
|
||||
}
|
||||
|
||||
@ -42,7 +43,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
const { saveSessionStore } = await import("../../config/sessions.js");
|
||||
await saveSessionStore(params.storePath, {
|
||||
[params.sessionKey]: {
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
@ -21,7 +21,7 @@ afterAll(async () => {
|
||||
|
||||
async function makeCaseDir(prefix: string): Promise<string> {
|
||||
const dir = path.join(suiteRoot, `${prefix}${++suiteCase}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.mkdir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ describe("initSessionState thread forking", () => {
|
||||
it("forks a new session from the parent session file", async () => {
|
||||
const root = await makeCaseDir("openclaw-thread-session-");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir);
|
||||
|
||||
const parentSessionId = "parent-session";
|
||||
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
|
||||
@ -258,240 +258,213 @@ describe("initSessionState RawBody", () => {
|
||||
});
|
||||
|
||||
describe("initSessionState reset policy", () => {
|
||||
it("defaults to daily reset at 4am local time", async () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("defaults to daily reset at 4am local time", async () => {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
try {
|
||||
const root = await makeCaseDir("openclaw-reset-daily-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s1";
|
||||
const existingSessionId = "daily-session-id";
|
||||
const root = await makeCaseDir("openclaw-reset-daily-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s1";
|
||||
const existingSessionId = "daily-session-id";
|
||||
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
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();
|
||||
}
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
});
|
||||
|
||||
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 makeCaseDir("openclaw-reset-daily-edge-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s-edge";
|
||||
const existingSessionId = "daily-edge-session";
|
||||
const root = await makeCaseDir("openclaw-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(),
|
||||
},
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
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();
|
||||
}
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
});
|
||||
|
||||
it("expires sessions when idle timeout wins over daily reset", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
|
||||
try {
|
||||
const root = await makeCaseDir("openclaw-reset-idle-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s2";
|
||||
const existingSessionId = "idle-session-id";
|
||||
const root = await makeCaseDir("openclaw-reset-idle-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s2";
|
||||
const existingSessionId = "idle-session-id";
|
||||
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
||||
},
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
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();
|
||||
}
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
});
|
||||
|
||||
it("uses per-type overrides for thread sessions", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
try {
|
||||
const root = await makeCaseDir("openclaw-reset-thread-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:slack:channel:c1:thread:123";
|
||||
const existingSessionId = "thread-session-id";
|
||||
const root = await makeCaseDir("openclaw-reset-thread-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:slack:channel:c1:thread:123";
|
||||
const existingSessionId = "thread-session-id";
|
||||
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: { mode: "daily", atHour: 4 },
|
||||
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: { mode: "daily", atHour: 4 },
|
||||
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Slack thread" },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
});
|
||||
|
||||
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 makeCaseDir("openclaw-reset-thread-nosuffix-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:discord:channel:c1";
|
||||
const existingSessionId = "thread-nosuffix";
|
||||
const root = await makeCaseDir("openclaw-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(),
|
||||
},
|
||||
});
|
||||
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 OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "reply", SessionKey: sessionKey, ThreadLabel: "Discord thread" },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
resetByType: { thread: { mode: "idle", idleMinutes: 180 } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
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();
|
||||
}
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
});
|
||||
|
||||
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 makeCaseDir("openclaw-reset-type-default-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s4";
|
||||
const existingSessionId = "type-default-session";
|
||||
const root = await makeCaseDir("openclaw-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(),
|
||||
},
|
||||
});
|
||||
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 OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
resetByType: { thread: { mode: "idle", idleMinutes: 60 } },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
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();
|
||||
}
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
});
|
||||
|
||||
it("keeps legacy idleMinutes behavior without reset config", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
try {
|
||||
const root = await makeCaseDir("openclaw-reset-legacy-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s3";
|
||||
const existingSessionId = "legacy-session-id";
|
||||
const root = await makeCaseDir("openclaw-reset-legacy-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:whatsapp:dm:s3";
|
||||
const existingSessionId = "legacy-session-id";
|
||||
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
|
||||
},
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 240,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
idleMinutes: 240,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const githubCopilotLoginCommand = vi.fn();
|
||||
const modelsStatusCommand = vi.fn().mockResolvedValue(undefined);
|
||||
@ -32,18 +32,28 @@ vi.mock("../commands/models.js", () => ({
|
||||
}));
|
||||
|
||||
describe("models cli", () => {
|
||||
let Command: typeof import("commander").Command;
|
||||
let registerModelsCli: (typeof import("./models-cli.js"))["registerModelsCli"];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Load once; vi.mock above ensures command handlers are already mocked.
|
||||
({ Command } = await import("commander"));
|
||||
({ registerModelsCli } = await import("./models-cli.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
githubCopilotLoginCommand.mockClear();
|
||||
modelsStatusCommand.mockClear();
|
||||
});
|
||||
|
||||
it("registers github-copilot login command", { timeout: 60_000 }, async () => {
|
||||
const { Command } = await import("commander");
|
||||
const { registerModelsCli } = await import("./models-cli.js");
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
registerModelsCli(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
it("registers github-copilot login command", async () => {
|
||||
const program = createProgram();
|
||||
const models = program.commands.find((cmd) => cmd.name() === "models");
|
||||
expect(models).toBeTruthy();
|
||||
|
||||
@ -65,11 +75,7 @@ describe("models cli", () => {
|
||||
});
|
||||
|
||||
it("passes --agent to models status", async () => {
|
||||
const { Command } = await import("commander");
|
||||
const { registerModelsCli } = await import("./models-cli.js");
|
||||
|
||||
const program = new Command();
|
||||
registerModelsCli(program);
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync(["models", "status", "--agent", "poe"], { from: "user" });
|
||||
|
||||
@ -80,11 +86,7 @@ describe("models cli", () => {
|
||||
});
|
||||
|
||||
it("passes parent --agent to models status", async () => {
|
||||
const { Command } = await import("commander");
|
||||
const { registerModelsCli } = await import("./models-cli.js");
|
||||
|
||||
const program = new Command();
|
||||
registerModelsCli(program);
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync(["models", "--agent", "poe", "status"], { from: "user" });
|
||||
|
||||
@ -95,9 +97,6 @@ describe("models cli", () => {
|
||||
});
|
||||
|
||||
it("shows help for models auth without error exit", async () => {
|
||||
const { Command } = await import("commander");
|
||||
const { registerModelsCli } = await import("./models-cli.js");
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerModelsCli(program);
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
import type { CronEvent } from "./service.js";
|
||||
import { CronService } from "./service.js";
|
||||
import {
|
||||
createCronStoreHarness,
|
||||
@ -14,16 +14,46 @@ const noopLogger = createNoopLogger();
|
||||
const { makeStorePath } = createCronStoreHarness();
|
||||
installCronTestHooks({ logger: noopLogger });
|
||||
|
||||
async function waitForJobs(cron: CronService, predicate: (jobs: CronJob[]) => boolean) {
|
||||
let latest: CronJob[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
latest = await cron.list({ includeDisabled: true });
|
||||
if (predicate(latest)) {
|
||||
return latest;
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createCronEventHarness() {
|
||||
const events: CronEvent[] = [];
|
||||
const waiters: Array<{
|
||||
predicate: (evt: CronEvent) => boolean;
|
||||
deferred: ReturnType<typeof createDeferred<CronEvent>>;
|
||||
}> = [];
|
||||
|
||||
const onEvent = (evt: CronEvent) => {
|
||||
events.push(evt);
|
||||
for (let i = waiters.length - 1; i >= 0; i -= 1) {
|
||||
const waiter = waiters[i];
|
||||
if (waiter && waiter.predicate(evt)) {
|
||||
waiters.splice(i, 1);
|
||||
waiter.deferred.resolve(evt);
|
||||
}
|
||||
}
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
}
|
||||
return latest;
|
||||
};
|
||||
|
||||
const waitFor = (predicate: (evt: CronEvent) => boolean) => {
|
||||
for (const evt of events) {
|
||||
if (predicate(evt)) {
|
||||
return Promise.resolve(evt);
|
||||
}
|
||||
}
|
||||
const deferred = createDeferred<CronEvent>();
|
||||
waiters.push({ predicate, deferred });
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return { onEvent, waitFor, events };
|
||||
}
|
||||
|
||||
describe("CronService", () => {
|
||||
@ -31,6 +61,7 @@ describe("CronService", () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const events = createCronEventHarness();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
@ -39,6 +70,7 @@ describe("CronService", () => {
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
onEvent: events.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
@ -57,10 +89,9 @@ describe("CronService", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await events.waitFor((evt) => evt.jobId === job.id && evt.action === "finished");
|
||||
|
||||
const jobs = await waitForJobs(cron, (items) =>
|
||||
items.some((item) => item.id === job.id && !item.enabled),
|
||||
);
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const updated = jobs.find((j) => j.id === job.id);
|
||||
expect(updated?.enabled).toBe(false);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", {
|
||||
@ -77,6 +108,7 @@ describe("CronService", () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const events = createCronEventHarness();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
@ -85,6 +117,7 @@ describe("CronService", () => {
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
onEvent: events.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
@ -100,8 +133,9 @@ describe("CronService", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await events.waitFor((evt) => evt.jobId === job.id && evt.action === "removed");
|
||||
|
||||
const jobs = await waitForJobs(cron, (items) => !items.some((item) => item.id === job.id));
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
expect(jobs.find((j) => j.id === job.id)).toBeUndefined();
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", {
|
||||
agentId: undefined,
|
||||
@ -282,6 +316,7 @@ describe("CronService", () => {
|
||||
status: "ok" as const,
|
||||
summary: "done",
|
||||
}));
|
||||
const events = createCronEventHarness();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
@ -290,11 +325,12 @@ describe("CronService", () => {
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob,
|
||||
onEvent: events.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
const job = await cron.add({
|
||||
enabled: true,
|
||||
name: "weekly",
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
@ -307,7 +343,9 @@ describe("CronService", () => {
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
await waitForJobs(cron, (items) => items.some((item) => item.state.lastStatus === "ok"));
|
||||
await events.waitFor(
|
||||
(evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "ok",
|
||||
);
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done", {
|
||||
agentId: undefined,
|
||||
@ -326,6 +364,7 @@ describe("CronService", () => {
|
||||
summary: "done",
|
||||
delivered: true,
|
||||
}));
|
||||
const events = createCronEventHarness();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
@ -334,11 +373,12 @@ describe("CronService", () => {
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob,
|
||||
onEvent: events.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
const job = await cron.add({
|
||||
enabled: true,
|
||||
name: "weekly delivered",
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
@ -351,7 +391,9 @@ describe("CronService", () => {
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
await waitForJobs(cron, (items) => items.some((item) => item.state.lastStatus === "ok"));
|
||||
await events.waitFor(
|
||||
(evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "ok",
|
||||
);
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
@ -473,6 +515,7 @@ describe("CronService", () => {
|
||||
summary: "last output",
|
||||
error: "boom",
|
||||
}));
|
||||
const events = createCronEventHarness();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
@ -481,11 +524,12 @@ describe("CronService", () => {
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob,
|
||||
onEvent: events.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
const job = await cron.add({
|
||||
name: "isolated error test",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
@ -497,7 +541,9 @@ describe("CronService", () => {
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await waitForJobs(cron, (items) => items.some((item) => item.state.lastStatus === "error"));
|
||||
await events.waitFor(
|
||||
(evt) => evt.jobId === job.id && evt.action === "finished" && evt.status === "error",
|
||||
);
|
||||
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron (error): last output", {
|
||||
agentId: undefined,
|
||||
@ -551,6 +597,7 @@ describe("CronService", () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
const events = createCronEventHarness();
|
||||
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
@ -581,17 +628,21 @@ describe("CronService", () => {
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
onEvent: events.onEvent,
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await events.waitFor(
|
||||
(evt) => evt.jobId === "job-1" && evt.action === "finished" && evt.status === "skipped",
|
||||
);
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
|
||||
const jobs = await waitForJobs(cron, (items) => items[0]?.state.lastStatus === "skipped");
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
||||
expect(jobs[0]?.state.lastError).toMatch(/main job requires/i);
|
||||
|
||||
|
||||
@ -62,15 +62,26 @@ function createEnv(
|
||||
sandbox: DockerSetupSandbox,
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
PATH: `${sandbox.binDir}:${process.env.PATH ?? ""}`,
|
||||
HOME: process.env.HOME ?? sandbox.rootDir,
|
||||
LANG: process.env.LANG,
|
||||
LC_ALL: process.env.LC_ALL,
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
DOCKER_STUB_LOG: sandbox.logPath,
|
||||
OPENCLAW_GATEWAY_TOKEN: "test-token",
|
||||
OPENCLAW_CONFIG_DIR: join(sandbox.rootDir, "config"),
|
||||
OPENCLAW_WORKSPACE_DIR: join(sandbox.rootDir, "openclaw"),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === undefined) {
|
||||
delete env[key];
|
||||
} else {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolveBashForCompatCheck(): string | null {
|
||||
|
||||
@ -8,6 +8,12 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||
await fs.mkdir(tmp);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-path-"));
|
||||
});
|
||||
@ -17,9 +23,9 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
});
|
||||
|
||||
it("prepends the bundled app bin dir when a sibling openclaw exists", async () => {
|
||||
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||
const tmp = await makeTmpDir();
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
await fs.mkdir(appBinDir, { recursive: true });
|
||||
await fs.mkdir(appBinDir);
|
||||
const cliPath = path.join(appBinDir, "openclaw");
|
||||
await fs.writeFile(cliPath, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(cliPath, 0o755);
|
||||
@ -71,13 +77,13 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
});
|
||||
|
||||
it("prepends mise shims when available", async () => {
|
||||
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||
const tmp = await makeTmpDir();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
const originalMiseDataDir = process.env.MISE_DATA_DIR;
|
||||
try {
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
await fs.mkdir(appBinDir, { recursive: true });
|
||||
await fs.mkdir(appBinDir);
|
||||
const appCli = path.join(appBinDir, "openclaw");
|
||||
await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(appCli, 0o755);
|
||||
@ -118,12 +124,12 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
});
|
||||
|
||||
it("only appends project-local node_modules/.bin when explicitly enabled", async () => {
|
||||
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||
const tmp = await makeTmpDir();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
try {
|
||||
const appBinDir = path.join(tmp, "AppBin");
|
||||
await fs.mkdir(appBinDir, { recursive: true });
|
||||
await fs.mkdir(appBinDir);
|
||||
const appCli = path.join(appBinDir, "openclaw");
|
||||
await fs.writeFile(appCli, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(appCli, 0o755);
|
||||
@ -172,7 +178,7 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
});
|
||||
|
||||
it("prepends Linuxbrew dirs when present", async () => {
|
||||
const tmp = path.join(fixtureRoot, `case-${fixtureCount++}`);
|
||||
const tmp = await makeTmpDir();
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.OPENCLAW_PATH_BOOTSTRAPPED;
|
||||
const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX;
|
||||
@ -180,7 +186,7 @@ describe("ensureOpenClawCliOnPath", () => {
|
||||
const originalXdgBinHome = process.env.XDG_BIN_HOME;
|
||||
try {
|
||||
const execDir = path.join(tmp, "exec");
|
||||
await fs.mkdir(execDir, { recursive: true });
|
||||
await fs.mkdir(execDir);
|
||||
|
||||
const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin");
|
||||
const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin");
|
||||
|
||||
@ -47,7 +47,7 @@ describe("update-startup", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-17T10:00:00Z"));
|
||||
tempDir = path.join(suiteRoot, `case-${++suiteCase}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.mkdir(tempDir);
|
||||
hadStateDir = Object.prototype.hasOwnProperty.call(process.env, "OPENCLAW_STATE_DIR");
|
||||
prevStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
@ -87,7 +87,6 @@ describe("update-startup", () => {
|
||||
} else {
|
||||
delete process.env.VITEST;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
|
||||
let MEDIA_DIR = "";
|
||||
const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("./store.js", async (importOriginal) => {
|
||||
@ -18,39 +19,41 @@ vi.mock("./store.js", async (importOriginal) => {
|
||||
const { startMediaServer } = await import("./server.js");
|
||||
const { MEDIA_MAX_BYTES } = await import("./store.js");
|
||||
|
||||
const waitForFileRemoval = async (file: string, timeoutMs = 200) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
async function waitForFileRemoval(filePath: string, maxTicks = 1000) {
|
||||
for (let tick = 0; tick < maxTicks; tick += 1) {
|
||||
try {
|
||||
await fs.stat(file);
|
||||
await fs.stat(filePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${file} removal`);
|
||||
};
|
||||
throw new Error(`timed out waiting for ${filePath} removal`);
|
||||
}
|
||||
|
||||
describe("media server", () => {
|
||||
let server: Awaited<ReturnType<typeof startMediaServer>>;
|
||||
let port = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
||||
MEDIA_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-"));
|
||||
server = await startMediaServer(0, 1_000);
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((r) => server.close(r));
|
||||
await fs.rm(MEDIA_DIR, { recursive: true, force: true });
|
||||
MEDIA_DIR = "";
|
||||
});
|
||||
|
||||
it("serves media and cleans up after send", async () => {
|
||||
const file = path.join(MEDIA_DIR, "file1");
|
||||
await fs.writeFile(file, "hello");
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://127.0.0.1:${port}/media/file1`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("hello");
|
||||
await waitForFileRemoval(file);
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("expires old media", async () => {
|
||||
@ -58,22 +61,16 @@ describe("media server", () => {
|
||||
await fs.writeFile(file, "stale");
|
||||
const past = Date.now() - 10_000;
|
||||
await fs.utimes(file, past / 1000, past / 1000);
|
||||
const server = await startMediaServer(0, 1_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://127.0.0.1:${port}/media/old`);
|
||||
expect(res.status).toBe(410);
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("blocks path traversal attempts", async () => {
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
// URL-encoded "../" to bypass client-side path normalization
|
||||
const res = await fetch(`http://127.0.0.1:${port}/media/%2e%2e%2fpackage.json`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.text()).toBe("invalid path");
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("blocks symlink escaping outside media dir", async () => {
|
||||
@ -81,34 +78,25 @@ describe("media server", () => {
|
||||
const link = path.join(MEDIA_DIR, "link-out");
|
||||
await fs.symlink(target, link);
|
||||
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://127.0.0.1:${port}/media/link-out`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.text()).toBe("invalid path");
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("rejects invalid media ids", async () => {
|
||||
const file = path.join(MEDIA_DIR, "file2");
|
||||
await fs.writeFile(file, "hello");
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://127.0.0.1:${port}/media/invalid%20id`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.text()).toBe("invalid path");
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("rejects oversized media files", async () => {
|
||||
const file = path.join(MEDIA_DIR, "big");
|
||||
await fs.writeFile(file, "");
|
||||
await fs.truncate(file, MEDIA_MAX_BYTES + 1);
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://127.0.0.1:${port}/media/big`);
|
||||
expect(res.status).toBe(413);
|
||||
expect(await res.text()).toBe("too large");
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
});
|
||||
|
||||
@ -63,9 +63,15 @@ export function attachMediaRoutes(
|
||||
res.send(data);
|
||||
// best-effort single-use cleanup after response ends
|
||||
res.on("finish", () => {
|
||||
setTimeout(() => {
|
||||
fs.rm(realPath).catch(() => {});
|
||||
}, 50);
|
||||
const cleanup = () => {
|
||||
void fs.rm(realPath).catch(() => {});
|
||||
};
|
||||
// Tests should not pay for time-based cleanup delays.
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
queueMicrotask(cleanup);
|
||||
return;
|
||||
}
|
||||
setTimeout(cleanup, 50);
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SafeOpenError) {
|
||||
|
||||
@ -2,7 +2,7 @@ import type { App } from "@slack/bolt";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
@ -14,6 +14,29 @@ import { createSlackMonitorContext } from "../context.js";
|
||||
import { prepareSlackMessage } from "./prepare.js";
|
||||
|
||||
describe("slack prepareSlackMessage inbound contract", () => {
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
|
||||
function makeTmpStorePath() {
|
||||
if (!fixtureRoot) {
|
||||
throw new Error("fixtureRoot missing");
|
||||
}
|
||||
const dir = path.join(fixtureRoot, `case-${caseId++}`);
|
||||
fs.mkdirSync(dir);
|
||||
return { dir, storePath: path.join(dir, "sessions.json") };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fixtureRoot) {
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
fixtureRoot = "";
|
||||
}
|
||||
});
|
||||
|
||||
function createDefaultSlackCtx() {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
cfg: {
|
||||
@ -301,119 +324,109 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("marks first thread turn and injects thread history for a new thread session", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
try {
|
||||
const replies = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "100.000" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{ text: "starter", user: "U2", ts: "100.000" },
|
||||
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
|
||||
{ text: "follow-up question", user: "U1", ts: "100.800" },
|
||||
{ text: "current message", user: "U1", ts: "101.000" },
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({
|
||||
cfg: {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig,
|
||||
replies,
|
||||
const { storePath } = makeTmpStorePath();
|
||||
const replies = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "100.000" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{ text: "starter", user: "U2", ts: "100.000" },
|
||||
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
|
||||
{ text: "follow-up question", user: "U1", ts: "100.800" },
|
||||
{ text: "current message", user: "U1", ts: "101.000" },
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
slackCtx.resolveUserName = async (id: string) => ({
|
||||
name: id === "U1" ? "Alice" : "Bob",
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
const slackCtx = createThreadSlackCtx({
|
||||
cfg: {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig,
|
||||
replies,
|
||||
});
|
||||
slackCtx.resolveUserName = async (id: string) => ({
|
||||
name: id === "U1" ? "Alice" : "Bob",
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const account = createThreadAccount();
|
||||
const account = createThreadAccount();
|
||||
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "C123",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "current message",
|
||||
ts: "101.000",
|
||||
thread_ts: "100.000",
|
||||
} as SlackMessageEvent;
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "C123",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "current message",
|
||||
ts: "101.000",
|
||||
thread_ts: "100.000",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
expect(prepared!.ctxPayload.IsFirstThreadTurn).toBe(true);
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply");
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("follow-up question");
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message");
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(prepared).toBeTruthy();
|
||||
expect(prepared!.ctxPayload.IsFirstThreadTurn).toBe(true);
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply");
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("follow-up question");
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message");
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not mark first thread turn when thread session already exists in store", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
try {
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "200.000",
|
||||
});
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({ [threadKeys.sessionKey]: { updatedAt: Date.now() } }, null, 2),
|
||||
);
|
||||
const { storePath } = makeTmpStorePath();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "200.000",
|
||||
});
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({ [threadKeys.sessionKey]: { updatedAt: Date.now() } }, null, 2),
|
||||
);
|
||||
|
||||
const replies = vi.fn().mockResolvedValue({
|
||||
messages: [{ text: "starter", user: "U2", ts: "200.000" }],
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
const replies = vi.fn().mockResolvedValue({
|
||||
messages: [{ text: "starter", user: "U2", ts: "200.000" }],
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const account = createThreadAccount();
|
||||
const account = createThreadAccount();
|
||||
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "C123",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "reply in old thread",
|
||||
ts: "201.000",
|
||||
thread_ts: "200.000",
|
||||
} as SlackMessageEvent;
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "C123",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "reply in old thread",
|
||||
ts: "201.000",
|
||||
thread_ts: "200.000",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
expect(prepared!.ctxPayload.IsFirstThreadTurn).toBeUndefined();
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(prepared).toBeTruthy();
|
||||
expect(prepared!.ctxPayload.IsFirstThreadTurn).toBeUndefined();
|
||||
expect(prepared!.ctxPayload.ThreadHistoryBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes thread_ts and parent_user_id metadata in thread replies", async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user