diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 21e8bdf17c2..4d4fd8d1c8e 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -1,6 +1,7 @@ +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { getReplyFromConfig } from "./reply.js"; @@ -22,11 +23,69 @@ vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(), })); +type HomeEnvSnapshot = { + HOME: string | undefined; + USERPROFILE: string | undefined; + HOMEDRIVE: string | undefined; + HOMEPATH: string | undefined; + OPENCLAW_STATE_DIR: string | undefined; +}; + +function snapshotHomeEnv(): HomeEnvSnapshot { + return { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, + }; +} + +function restoreHomeEnv(snapshot: HomeEnvSnapshot) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +let fixtureRoot = ""; +let caseId = 0; + async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-stream-" }); + const home = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); + const envSnapshot = snapshotHomeEnv(); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + + if (process.platform === "win32") { + const match = home.match(/^([A-Za-z]:)(.*)$/); + if (match) { + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; + } + } + + try { + return await fn(home); + } finally { + restoreHomeEnv(envSnapshot); + } } describe("block streaming", () => { + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stream-")); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + beforeEach(() => { piEmbeddedMock.abortEmbeddedPiRun.mockReset().mockReturnValue(false); piEmbeddedMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false); diff --git a/src/auto-reply/reply.raw-body.test.ts b/src/auto-reply/reply.raw-body.test.ts index 38c8b30e218..75d586bffee 100644 --- a/src/auto-reply/reply.raw-body.test.ts +++ b/src/auto-reply/reply.raw-body.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { saveSessionStore } from "../config/sessions.js"; @@ -19,22 +19,78 @@ vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(), })); +type HomeEnvSnapshot = { + HOME: string | undefined; + USERPROFILE: string | undefined; + HOMEDRIVE: string | undefined; + HOMEPATH: string | undefined; + OPENCLAW_STATE_DIR: string | undefined; + OPENCLAW_AGENT_DIR: string | undefined; + PI_CODING_AGENT_DIR: string | undefined; +}; + +function snapshotHomeEnv(): HomeEnvSnapshot { + return { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, + OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR, + PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, + }; +} + +function restoreHomeEnv(snapshot: HomeEnvSnapshot) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +let fixtureRoot = ""; +let caseId = 0; + async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase( - async (home) => { - return await fn(home); - }, - { - env: { - OPENCLAW_AGENT_DIR: (home) => path.join(home, ".openclaw", "agent"), - PI_CODING_AGENT_DIR: (home) => path.join(home, ".openclaw", "agent"), - }, - prefix: "openclaw-rawbody-", - }, - ); + const home = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); + const envSnapshot = snapshotHomeEnv(); + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + process.env.OPENCLAW_AGENT_DIR = path.join(home, ".openclaw", "agent"); + process.env.PI_CODING_AGENT_DIR = path.join(home, ".openclaw", "agent"); + + if (process.platform === "win32") { + const match = home.match(/^([A-Za-z]:)(.*)$/); + if (match) { + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; + } + } + + try { + return await fn(home); + } finally { + restoreHomeEnv(envSnapshot); + } } describe("RawBody directive parsing", () => { + type ReplyMessage = Parameters[0]; + type ReplyConfig = Parameters[2]; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rawbody-")); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue([ @@ -46,147 +102,116 @@ describe("RawBody directive parsing", () => { vi.clearAllMocks(); }); - it("/model, /think, /verbose directives detected from RawBody even when Body has structural wrapper", async () => { + it("detects command directives from RawBody/CommandBody in wrapped group messages", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockReset(); - - const groupMessageCtx = { - Body: `[Chat messages since your last reply - for context]\\n[WhatsApp ...] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp ...] Jake: /think:high\\n[from: Jake McInteer (+6421807830)]`, - RawBody: "/think:high", - From: "+1222", - To: "+1222", - ChatType: "group", - CommandAuthorized: true, + const assertCommandReply = async (input: { + message: ReplyMessage; + config: ReplyConfig; + expectedIncludes: string[]; + }) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const res = await getReplyFromConfig(input.message, {}, input.config); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + for (const expected of input.expectedIncludes) { + expect(text).toContain(expected); + } + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }; - const res = await getReplyFromConfig( - groupMessageCtx, - {}, - { + await assertCommandReply({ + message: { + Body: `[Chat messages since your last reply - for context]\\n[WhatsApp ...] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp ...] Jake: /think:high\\n[from: Jake McInteer (+6421807830)]`, + RawBody: "/think:high", + From: "+1222", + To: "+1222", + ChatType: "group", + CommandAuthorized: true, + }, + config: { agents: { defaults: { model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + workspace: path.join(home, "openclaw-1"), }, }, channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, + session: { store: path.join(home, "sessions-1.json") }, }, - ); + expectedIncludes: ["Thinking level set to high."], + }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Thinking level set to high."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - - it("/model status detected from RawBody", async () => { - await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockReset(); - - const groupMessageCtx = { - Body: `[Context]\nJake: /model status\n[from: Jake]`, - RawBody: "/model status", - From: "+1222", - To: "+1222", - ChatType: "group", - CommandAuthorized: true, - }; - - const res = await getReplyFromConfig( - groupMessageCtx, - {}, - { + await assertCommandReply({ + message: { + Body: "[Context]\nJake: /model status\n[from: Jake]", + RawBody: "/model status", + From: "+1222", + To: "+1222", + ChatType: "group", + CommandAuthorized: true, + }, + config: { agents: { defaults: { model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + workspace: path.join(home, "openclaw-2"), models: { "anthropic/claude-opus-4-5": {}, }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, + session: { store: path.join(home, "sessions-2.json") }, }, - ); + expectedIncludes: ["anthropic/claude-opus-4-5"], + }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("anthropic/claude-opus-4-5"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - - it("CommandBody is honored when RawBody is missing", async () => { - await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockReset(); - - const groupMessageCtx = { - Body: `[Context]\nJake: /verbose on\n[from: Jake]`, - CommandBody: "/verbose on", - From: "+1222", - To: "+1222", - ChatType: "group", - CommandAuthorized: true, - }; - - const res = await getReplyFromConfig( - groupMessageCtx, - {}, - { + await assertCommandReply({ + message: { + Body: "[Context]\nJake: /verbose on\n[from: Jake]", + CommandBody: "/verbose on", + From: "+1222", + To: "+1222", + ChatType: "group", + CommandAuthorized: true, + }, + config: { agents: { defaults: { model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + workspace: path.join(home, "openclaw-3"), }, }, channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: path.join(home, "sessions.json") }, + session: { store: path.join(home, "sessions-3.json") }, }, - ); + expectedIncludes: ["Verbose logging enabled."], + }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Verbose logging enabled."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - - it("Integration: WhatsApp group message with structural wrapper and RawBody command", async () => { - await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockReset(); - - const groupMessageCtx = { - Body: `[Chat messages since your last reply - for context]\\n[WhatsApp ...] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp ...] Jake: /status\\n[from: Jake McInteer (+6421807830)]`, - RawBody: "/status", - ChatType: "group", - From: "+1222", - To: "+1222", - SessionKey: "agent:main:whatsapp:group:g1", - Provider: "whatsapp", - Surface: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }; - - const res = await getReplyFromConfig( - groupMessageCtx, - {}, - { + await assertCommandReply({ + message: { + Body: `[Chat messages since your last reply - for context]\\n[WhatsApp ...] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp ...] Jake: /status\\n[from: Jake McInteer (+6421807830)]`, + RawBody: "/status", + ChatType: "group", + From: "+1222", + To: "+1222", + SessionKey: "agent:main:whatsapp:group:g1", + Provider: "whatsapp", + Surface: "whatsapp", + SenderE164: "+1222", + CommandAuthorized: true, + }, + config: { agents: { defaults: { model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), + workspace: path.join(home, "openclaw-4"), }, }, channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: path.join(home, "sessions.json") }, + session: { store: path.join(home, "sessions-4.json") }, }, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Session: agent:main:whatsapp:group:g1"); - expect(text).toContain("anthropic/claude-opus-4-5"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expectedIncludes: ["Session: agent:main:whatsapp:group:g1", "anthropic/claude-opus-4-5"], + }); }); }); diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 3e319a5fd32..97c0dc0201b 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -144,6 +144,7 @@ describe("memory index", () => { throw new Error("manager missing"); } await first.manager.sync({ force: true }); + const callsAfterFirstSync = embedBatchCalls; await first.manager.close(); const second = await getMemorySearchManager({ @@ -168,8 +169,9 @@ describe("memory index", () => { } manager = second.manager; await second.manager.sync({ reason: "test" }); - const results = await second.manager.search("alpha"); - expect(results.length).toBeGreaterThan(0); + expect(embedBatchCalls).toBeGreaterThan(callsAfterFirstSync); + const status = second.manager.status(); + expect(status.files).toBeGreaterThan(0); }); it("reuses cached embeddings on forced reindex", async () => { @@ -280,7 +282,7 @@ describe("memory index", () => { }); it("hybrid weights can favor vector-only matches over keyword-only matches", async () => { - const manyAlpha = Array.from({ length: 80 }, () => "Alpha").join(" "); + const manyAlpha = Array.from({ length: 50 }, () => "Alpha").join(" "); await fs.writeFile( path.join(workspaceDir, "memory", "vector-only.md"), "Alpha beta. Alpha beta. Alpha beta. Alpha beta.", @@ -338,7 +340,7 @@ describe("memory index", () => { }); it("hybrid weights can favor keyword matches when text weight dominates", async () => { - const manyAlpha = Array.from({ length: 80 }, () => "Alpha").join(" "); + const manyAlpha = Array.from({ length: 50 }, () => "Alpha").join(" "); await fs.writeFile( path.join(workspaceDir, "memory", "vector-only.md"), "Alpha beta. Alpha beta. Alpha beta. Alpha beta.", diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts index 60586d2ec58..2ac5eeb5be5 100644 --- a/src/memory/manager.batch.test.ts +++ b/src/memory/manager.batch.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; const embedBatch = vi.fn(async () => []); @@ -25,11 +25,21 @@ vi.mock("./embeddings.js", () => ({ })); describe("memory indexing with OpenAI batches", () => { + let fixtureRoot: string; + let caseId = 0; let workspaceDir: string; let indexPath: string; let manager: MemoryIndexManager | null = null; let setTimeoutSpy: ReturnType; + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-")); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + beforeEach(async () => { embedBatch.mockClear(); embedQuery.mockClear(); @@ -48,9 +58,9 @@ describe("memory indexing with OpenAI batches", () => { } return realSetTimeout(handler, delay, ...args); }) as typeof setTimeout); - workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-batch-")); + workspaceDir = path.join(fixtureRoot, `case-${++caseId}`); indexPath = path.join(workspaceDir, "index.sqlite"); - await fs.mkdir(path.join(workspaceDir, "memory")); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); }); afterEach(async () => { @@ -60,7 +70,6 @@ describe("memory indexing with OpenAI batches", () => { await manager.close(); manager = null; } - await fs.rm(workspaceDir, { recursive: true, force: true }); }); it("uses OpenAI batch uploads when enabled", async () => { diff --git a/src/memory/manager.embedding-batches.test.ts b/src/memory/manager.embedding-batches.test.ts index 3c4019d366b..371b3e6ff17 100644 --- a/src/memory/manager.embedding-batches.test.ts +++ b/src/memory/manager.embedding-batches.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; const embedBatch = vi.fn(async (texts: string[]) => texts.map(() => [0, 1, 0])); @@ -20,16 +20,26 @@ vi.mock("./embeddings.js", () => ({ })); describe("memory embedding batches", () => { + let fixtureRoot: string; + let caseId = 0; let workspaceDir: string; let indexPath: string; let manager: MemoryIndexManager | null = null; + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-")); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + beforeEach(async () => { embedBatch.mockClear(); embedQuery.mockClear(); - workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-")); + workspaceDir = path.join(fixtureRoot, `case-${++caseId}`); indexPath = path.join(workspaceDir, "index.sqlite"); - await fs.mkdir(path.join(workspaceDir, "memory")); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); }); afterEach(async () => { @@ -37,7 +47,6 @@ describe("memory embedding batches", () => { await manager.close(); manager = null; } - await fs.rm(workspaceDir, { recursive: true, force: true }); }); it("splits large files across multiple embedding batches", async () => {