Merge branch 'main' of github.com:openclaw/openclaw

This commit is contained in:
Vignesh Natarajan 2026-02-14 17:05:52 -08:00
commit 289272f16a
10 changed files with 422 additions and 376 deletions

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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);

View File

@ -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 {

View File

@ -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");

View File

@ -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 () => {

View File

@ -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));
});
});

View File

@ -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) {

View File

@ -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 () => {