mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
test: dedupe memory and context suites
This commit is contained in:
@@ -265,26 +265,31 @@ describe("buildFileEntry", () => {
|
||||
expect(built?.structuredInputBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips lazy multimodal indexing when the file grows after discovery", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, "diagram.png");
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
it("skips lazy multimodal indexing when file state changes after discovery", async () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
name: "grows",
|
||||
mutate: async (target: string, entrySize: number) => {
|
||||
await fs.writeFile(target, Buffer.alloc(entrySize + 32, 1));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bytes change",
|
||||
mutate: async (target: string) => {
|
||||
await fs.writeFile(target, Buffer.from("gif"));
|
||||
},
|
||||
},
|
||||
] as const) {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, `${testCase.name}.png`);
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
await fs.writeFile(target, Buffer.alloc(entry!.size + 32, 1));
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
expect(entry, testCase.name).not.toBeNull();
|
||||
await testCase.mutate(target, entry!.size);
|
||||
|
||||
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("skips lazy multimodal indexing when file bytes change after discovery", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, "diagram.png");
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
await fs.writeFile(target, Buffer.from("gif"));
|
||||
|
||||
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
|
||||
await expect(buildMultimodalChunkForIndexing(entry!), testCase.name).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
||||
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";
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -24,25 +24,36 @@ function createMockChild() {
|
||||
return child;
|
||||
}
|
||||
|
||||
let fixtureRoot = "";
|
||||
let tempDir = "";
|
||||
let platformSpy: { mockRestore(): void } | null = null;
|
||||
let fixtureId = 0;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-"));
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-"));
|
||||
platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterAll(async () => {
|
||||
platformSpy?.mockRestore();
|
||||
platformSpy = null;
|
||||
if (fixtureRoot) {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(fixtureRoot, `case-${fixtureId++}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
spawnMock.mockReset();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
tempDir = "";
|
||||
});
|
||||
|
||||
describe("resolveCliSpawnInvocation", () => {
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js";
|
||||
|
||||
let fixtureRoot: string;
|
||||
let tmpDir: string;
|
||||
let originalStateDir: string | undefined;
|
||||
let fixtureId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-"));
|
||||
tmpDir = path.join(fixtureRoot, `case-${fixtureId++}`);
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("listSessionFilesForAgent", () => {
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("native hook relay registry", () => {
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
for (const event of ["pre_tool_use", "post_tool_use", "permission_request"] as const) {
|
||||
for (const event of ["pre_tool_use", "post_tool_use"] as const) {
|
||||
await expect(
|
||||
invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
|
||||
@@ -63,6 +63,12 @@ function makeMockMessage(role: "user" | "assistant" = "user", text = "hello"): A
|
||||
return { role, content: text, timestamp: Date.now() } as AgentMessage;
|
||||
}
|
||||
|
||||
let uniqueEngineIdCounter = 0;
|
||||
function uniqueEngineId(prefix: string): string {
|
||||
uniqueEngineIdCounter += 1;
|
||||
return `${prefix}-${uniqueEngineIdCounter}`;
|
||||
}
|
||||
|
||||
function registerPromptTrackingEngine(engineId: string) {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
registerContextEngine(engineId, () => ({
|
||||
@@ -701,13 +707,84 @@ describe("Invalid engine fallback", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to default engine when requested engine is not registered", async () => {
|
||||
const engine = await resolveContextEngine(configWithSlot("does-not-exist"));
|
||||
expect(engine.info.id).toBe("legacy");
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("does-not-exist"));
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("falling back to default engine"),
|
||||
);
|
||||
it("falls back to default engine for missing or invalid requested engines", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "missing registration",
|
||||
engineId: uniqueEngineId("does-not-exist"),
|
||||
register: () => undefined,
|
||||
expectedError: "does-not-exist",
|
||||
},
|
||||
{
|
||||
name: "factory throws",
|
||||
engineId: uniqueEngineId("factory-throw"),
|
||||
register: (engineId: string) => {
|
||||
registerContextEngine(engineId, () => {
|
||||
throw new Error("plugin version mismatch");
|
||||
});
|
||||
},
|
||||
expectedError: "plugin version mismatch",
|
||||
},
|
||||
{
|
||||
name: "missing info metadata",
|
||||
engineId: uniqueEngineId("invalid-info"),
|
||||
register: (engineId: string) => {
|
||||
registerContextEngine(
|
||||
engineId,
|
||||
() =>
|
||||
({
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble({ messages }: { messages: AgentMessage[] }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
}) as unknown as ContextEngine,
|
||||
);
|
||||
},
|
||||
expectedError: "missing info",
|
||||
},
|
||||
{
|
||||
name: "missing lifecycle methods",
|
||||
engineId: uniqueEngineId("invalid-methods"),
|
||||
register: (engineId: string) => {
|
||||
registerContextEngine(
|
||||
engineId,
|
||||
() =>
|
||||
({
|
||||
info: { id: engineId, name: "Broken Engine" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
}) as unknown as ContextEngine,
|
||||
);
|
||||
},
|
||||
expectedError: "missing assemble(), missing compact()",
|
||||
},
|
||||
{
|
||||
name: "contract validation throws",
|
||||
engineId: uniqueEngineId("validation-throw"),
|
||||
register: (engineId: string) => {
|
||||
registerContextEngine(engineId, () => 42n as unknown as ContextEngine);
|
||||
},
|
||||
expectedError: "contract validation threw",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
vi.mocked(console.error).mockClear();
|
||||
testCase.register(testCase.engineId);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(testCase.engineId));
|
||||
|
||||
expect(engine.info.id, testCase.name).toBe("legacy");
|
||||
expect(console.error, testCase.name).toHaveBeenCalledWith(
|
||||
expect.stringContaining(testCase.expectedError),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when the default engine itself is not registered", async () => {
|
||||
@@ -759,43 +836,6 @@ describe("Invalid engine fallback", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default engine when factory throws", async () => {
|
||||
const engineId = `factory-throw-${Date.now().toString(36)}`;
|
||||
registerContextEngine(engineId, () => {
|
||||
throw new Error("plugin version mismatch");
|
||||
});
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
expect(engine.info.id).toBe("legacy");
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("plugin version mismatch"));
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("falling back to default engine"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default engine when resolved engine omits info metadata", async () => {
|
||||
const engineId = `invalid-info-${Date.now().toString(36)}`;
|
||||
registerContextEngine(
|
||||
engineId,
|
||||
() =>
|
||||
({
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble({ messages }: { messages: AgentMessage[] }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
}) as unknown as ContextEngine,
|
||||
);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
expect(engine.info.id).toBe("legacy");
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("missing info"));
|
||||
});
|
||||
|
||||
it("accepts resolved engines whose info.id differs from the registered slot id (#66601)", async () => {
|
||||
// Regression for openclaw/openclaw#66601: third-party plugins like
|
||||
// lossless-claw register under an external slot id ("lossless-claw") but
|
||||
@@ -831,40 +871,6 @@ describe("Invalid engine fallback", () => {
|
||||
});
|
||||
expect(result.estimatedTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to default engine when resolved engine omits lifecycle methods", async () => {
|
||||
const engineId = `invalid-methods-${Date.now().toString(36)}`;
|
||||
registerContextEngine(
|
||||
engineId,
|
||||
() =>
|
||||
({
|
||||
info: { id: engineId, name: "Broken Engine" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
}) as unknown as ContextEngine,
|
||||
);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
expect(engine.info.id).toBe("legacy");
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("missing assemble(), missing compact()"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default engine when contract validation itself throws", async () => {
|
||||
const engineId = `validation-throw-${Date.now().toString(36)}`;
|
||||
// BigInt cannot be JSON.stringify'd — triggers a throw inside
|
||||
// describeResolvedContextEngineContractError when the factory returns
|
||||
// a non-object value that passes the typeof !== "object" branch.
|
||||
registerContextEngine(engineId, () => 42n as unknown as ContextEngine);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
expect(engine.info.id).toBe("legacy");
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("contract validation threw"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -913,54 +919,47 @@ describe("LegacyContextEngine parity", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("assemble() prompt forwarding", () => {
|
||||
it("forwards prompt to the underlying engine", async () => {
|
||||
const engineId = `prompt-fwd-${Date.now().toString(36)}`;
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
it("forwards prompt only when callers provide one", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "provided",
|
||||
params: { prompt: "hello" },
|
||||
expectedPrompt: "hello",
|
||||
},
|
||||
{
|
||||
name: "omitted",
|
||||
params: {},
|
||||
expectedPrompt: null,
|
||||
},
|
||||
{
|
||||
name: "conditional spread undefined",
|
||||
params: (() => {
|
||||
const callerPrompt: string | undefined = undefined;
|
||||
return callerPrompt !== undefined ? { prompt: callerPrompt } : {};
|
||||
})(),
|
||||
expectedPrompt: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
await engine.assemble({
|
||||
sessionId: "s1",
|
||||
messages: [makeMockMessage("user", "hello")],
|
||||
prompt: "hello",
|
||||
});
|
||||
for (const testCase of cases) {
|
||||
const engineId = uniqueEngineId(`prompt-${testCase.name.replace(/\s+/g, "-")}`);
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toHaveProperty("prompt", "hello");
|
||||
});
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
await engine.assemble({
|
||||
sessionId: "s1",
|
||||
messages: [makeMockMessage("user", "hello")],
|
||||
...testCase.params,
|
||||
});
|
||||
|
||||
it("omits prompt when not provided", async () => {
|
||||
const engineId = `prompt-omit-${Date.now().toString(36)}`;
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
await engine.assemble({
|
||||
sessionId: "s1",
|
||||
messages: [makeMockMessage("user", "hello")],
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).not.toHaveProperty("prompt");
|
||||
});
|
||||
|
||||
it("does not leak prompt key when caller spreads undefined", async () => {
|
||||
// Guards against the pattern `{ prompt: params.prompt }` when params.prompt
|
||||
// is undefined — JavaScript keeps the key present with value undefined,
|
||||
// which breaks engines that guard with `'prompt' in params`.
|
||||
const engineId = `prompt-undef-${Date.now().toString(36)}`;
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
// Simulate the attempt.ts call-site pattern: conditional spread
|
||||
const callerPrompt: string | undefined = undefined;
|
||||
await engine.assemble({
|
||||
sessionId: "s1",
|
||||
messages: [makeMockMessage("user", "hello")],
|
||||
...(callerPrompt !== undefined ? { prompt: callerPrompt } : {}),
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).not.toHaveProperty("prompt");
|
||||
expect(Object.keys(calls[0] as object)).not.toContain("prompt");
|
||||
expect(calls, testCase.name).toHaveLength(1);
|
||||
if (testCase.expectedPrompt === null) {
|
||||
expect(calls[0], testCase.name).not.toHaveProperty("prompt");
|
||||
expect(Object.keys(calls[0] as object), testCase.name).not.toContain("prompt");
|
||||
} else {
|
||||
expect(calls[0], testCase.name).toHaveProperty("prompt", testCase.expectedPrompt);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("retries strict legacy assemble without sessionKey and prompt", async () => {
|
||||
|
||||
@@ -152,32 +152,28 @@ describe("listMemoryFiles", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("skips root-memory repair backups from extra workspace paths", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||
const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23");
|
||||
await fs.mkdir(repairDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory");
|
||||
it("skips root-memory repair backups from workspace and explicit extra paths", async () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
name: "workspace extra path",
|
||||
extraPaths: (tmpDir: string) => [tmpDir],
|
||||
},
|
||||
{
|
||||
name: "explicit repair root",
|
||||
extraPaths: (tmpDir: string) => [path.join(tmpDir, ".openclaw-repair", "root-memory")],
|
||||
},
|
||||
] as const) {
|
||||
const tmpDir = getTmpDir();
|
||||
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||
const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23");
|
||||
await fs.mkdir(repairDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory");
|
||||
|
||||
const files = await listMemoryFiles(tmpDir, [tmpDir]);
|
||||
const files = await listMemoryFiles(tmpDir, testCase.extraPaths(tmpDir));
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md"));
|
||||
});
|
||||
|
||||
it("skips explicit root-memory repair directories from extra paths", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||
const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23");
|
||||
await fs.mkdir(repairDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory");
|
||||
|
||||
const files = await listMemoryFiles(tmpDir, [
|
||||
path.join(tmpDir, ".openclaw-repair", "root-memory"),
|
||||
]);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md"));
|
||||
expect(files, testCase.name).toHaveLength(1);
|
||||
expect(files[0], testCase.name).toBe(path.join(tmpDir, "MEMORY.md"));
|
||||
}
|
||||
});
|
||||
|
||||
it("handles relative paths in additional paths", async () => {
|
||||
@@ -324,37 +320,37 @@ describe("buildFileEntry", () => {
|
||||
expect(built?.structuredInputBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips lazy multimodal indexing when the file grows after discovery", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, "diagram.png");
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
it("skips lazy multimodal indexing when file state changes after discovery", async () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
name: "grows",
|
||||
mutate: async (target: string, entrySize: number) => {
|
||||
await fs.writeFile(target, Buffer.alloc(entrySize + 32, 1));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bytes change",
|
||||
mutate: async (target: string) => {
|
||||
await fs.writeFile(target, Buffer.from("gif"));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disappears",
|
||||
mutate: async (target: string) => {
|
||||
await fs.rm(target);
|
||||
},
|
||||
},
|
||||
] as const) {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, `${testCase.name}.png`);
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
await fs.writeFile(target, Buffer.alloc(entry!.size + 32, 1));
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
expect(entry, testCase.name).not.toBeNull();
|
||||
await testCase.mutate(target, entry!.size);
|
||||
|
||||
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("skips lazy multimodal indexing when file bytes change after discovery", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, "diagram.png");
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
await fs.writeFile(target, Buffer.from("gif"));
|
||||
|
||||
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("skips lazy multimodal indexing when the file disappears before loading bytes", async () => {
|
||||
const tmpDir = getTmpDir();
|
||||
const target = path.join(tmpDir, "diagram.png");
|
||||
await fs.writeFile(target, Buffer.from("png"));
|
||||
|
||||
const entry = await buildFileEntry(target, tmpDir, multimodal);
|
||||
await fs.rm(target);
|
||||
|
||||
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
|
||||
await expect(buildMultimodalChunkForIndexing(entry!), testCase.name).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ function expectNoUnpairedSurrogates(value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSessionJsonl(fileName: string, records: readonly unknown[]): Promise<string> {
|
||||
const filePath = path.join(tmpDir, fileName);
|
||||
await fs.writeFile(filePath, records.map((record) => JSON.stringify(record)).join("\n"));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("listSessionFilesForAgent", () => {
|
||||
it("includes reset and deleted transcripts in session file listing", async () => {
|
||||
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
|
||||
@@ -483,142 +489,118 @@ describe("buildSessionEntry", () => {
|
||||
expect(entry?.lineMap).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("drops generated system wrapper user messages but keeps the assistant reply", async () => {
|
||||
// Cross-message coupling (drop-next-assistant-when-prior-user-matched) was
|
||||
// removed because user-typed text can match the same patterns; see
|
||||
// PR #70737 review (aisle-research-bot). Real assistant content stays in
|
||||
// the corpus regardless of what the prior user message looked like.
|
||||
const jsonlLines = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"System (untrusted): [2026-04-15 14:45:20 PDT] Exec completed (quiet-fo, code 0) :: Converted: 1",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Handled internally.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "What changed in the sync?",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "One new session was converted.",
|
||||
},
|
||||
}),
|
||||
];
|
||||
const filePath = path.join(tmpDir, "system-wrapper-session.jsonl");
|
||||
await fs.writeFile(filePath, jsonlLines.join("\n"));
|
||||
it("drops generated runtime chatter while preserving real follow-up content", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "system wrapper",
|
||||
fileName: "system-wrapper-session.jsonl",
|
||||
records: [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"System (untrusted): [2026-04-15 14:45:20 PDT] Exec completed (quiet-fo, code 0) :: Converted: 1",
|
||||
},
|
||||
},
|
||||
{ type: "message", message: { role: "assistant", content: "Handled internally." } },
|
||||
{ type: "message", message: { role: "user", content: "What changed in the sync?" } },
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "One new session was converted." },
|
||||
},
|
||||
],
|
||||
content: [
|
||||
"Assistant: Handled internally.",
|
||||
"User: What changed in the sync?",
|
||||
"Assistant: One new session was converted.",
|
||||
].join("\n"),
|
||||
lineMap: [2, 3, 4],
|
||||
},
|
||||
{
|
||||
name: "cron prompt",
|
||||
fileName: "cron-prompt-session.jsonl",
|
||||
records: [
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "user", content: "[cron:job-1 Example] Run the nightly sync" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "Running the nightly sync now." },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "user", content: "Did the nightly sync actually change anything?" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "No, everything was already current." },
|
||||
},
|
||||
],
|
||||
content: [
|
||||
"Assistant: Running the nightly sync now.",
|
||||
"User: Did the nightly sync actually change anything?",
|
||||
"Assistant: No, everything was already current.",
|
||||
].join("\n"),
|
||||
lineMap: [2, 3, 4],
|
||||
},
|
||||
{
|
||||
name: "heartbeat ack",
|
||||
fileName: "heartbeat-session.jsonl",
|
||||
records: [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
},
|
||||
},
|
||||
{ type: "message", message: { role: "assistant", content: "HEARTBEAT_OK" } },
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "user", content: "Summarize what changed in the inbox today." },
|
||||
},
|
||||
],
|
||||
content: "User: Summarize what changed in the inbox today.",
|
||||
lineMap: [3],
|
||||
},
|
||||
{
|
||||
name: "internal runtime context",
|
||||
fileName: "internal-context-session.jsonl",
|
||||
records: [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"OpenClaw runtime context (internal):",
|
||||
"This context is runtime-generated, not user-authored. Keep internal details private.",
|
||||
"",
|
||||
"[Internal task completion event]",
|
||||
"source: subagent",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
{ type: "message", message: { role: "assistant", content: "NO_REPLY" } },
|
||||
{ type: "message", message: { role: "user", content: "Actual user text" } },
|
||||
],
|
||||
content: "User: Actual user text",
|
||||
lineMap: [3],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const entry = await buildSessionEntry(filePath);
|
||||
for (const testCase of cases) {
|
||||
const filePath = await writeSessionJsonl(testCase.fileName, testCase.records);
|
||||
const entry = await buildSessionEntry(filePath);
|
||||
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry?.content).toBe(
|
||||
[
|
||||
"Assistant: Handled internally.",
|
||||
"User: What changed in the sync?",
|
||||
"Assistant: One new session was converted.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(entry?.lineMap).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("drops direct cron-prompt user messages but keeps the assistant reply", async () => {
|
||||
const jsonlLines = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "[cron:job-1 Example] Run the nightly sync",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Running the nightly sync now.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "Did the nightly sync actually change anything?",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "No, everything was already current.",
|
||||
},
|
||||
}),
|
||||
];
|
||||
const filePath = path.join(tmpDir, "cron-prompt-session.jsonl");
|
||||
await fs.writeFile(filePath, jsonlLines.join("\n"));
|
||||
|
||||
const entry = await buildSessionEntry(filePath);
|
||||
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry?.content).toBe(
|
||||
[
|
||||
"Assistant: Running the nightly sync now.",
|
||||
"User: Did the nightly sync actually change anything?",
|
||||
"Assistant: No, everything was already current.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(entry?.lineMap).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("drops heartbeat prompt and the HEARTBEAT_OK ack via assistant-side detection", async () => {
|
||||
// The ack is dropped because `HEARTBEAT_OK` is recognised as an
|
||||
// assistant-side machinery token, not because the prior user message was
|
||||
// a heartbeat prompt. A real reply to a similarly-shaped user message
|
||||
// would still survive.
|
||||
const jsonlLines = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "HEARTBEAT_OK",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "Summarize what changed in the inbox today.",
|
||||
},
|
||||
}),
|
||||
];
|
||||
const filePath = path.join(tmpDir, "heartbeat-session.jsonl");
|
||||
await fs.writeFile(filePath, jsonlLines.join("\n"));
|
||||
|
||||
const entry = await buildSessionEntry(filePath);
|
||||
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry?.content).toBe("User: Summarize what changed in the inbox today.");
|
||||
expect(entry?.lineMap).toEqual([3]);
|
||||
expect(entry, testCase.name).not.toBeNull();
|
||||
expect(entry?.content, testCase.name).toBe(testCase.content);
|
||||
expect(entry?.lineMap, testCase.name).toEqual(testCase.lineMap);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a user-typed `[cron:...]` prompt suppress the next assistant reply (regression: PR #70737 review)", async () => {
|
||||
@@ -673,48 +655,6 @@ describe("buildSessionEntry", () => {
|
||||
expect(checkpointEntry?.lineMap).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips internal runtime context blocks before flattening session text", async () => {
|
||||
const jsonlLines = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"OpenClaw runtime context (internal):",
|
||||
"This context is runtime-generated, not user-authored. Keep internal details private.",
|
||||
"",
|
||||
"[Internal task completion event]",
|
||||
"source: subagent",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
].join("\n"),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "NO_REPLY",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "Actual user text",
|
||||
},
|
||||
}),
|
||||
];
|
||||
const filePath = path.join(tmpDir, "internal-context-session.jsonl");
|
||||
await fs.writeFile(filePath, jsonlLines.join("\n"));
|
||||
|
||||
const entry = await buildSessionEntry(filePath);
|
||||
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry?.content).toBe("User: Actual user text");
|
||||
expect(entry?.lineMap).toEqual([3]);
|
||||
});
|
||||
|
||||
it("does not flag transcripts when dreaming markers only appear mid-string", async () => {
|
||||
const jsonlLines = [
|
||||
JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user