mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 07:21:52 +00:00
488 lines
15 KiB
TypeScript
488 lines
15 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { HarnessContextEngine as ContextEngine } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import { createSqliteSessionTranscriptLocator } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { CodexAppServerClient } from "./client.js";
|
|
import { maybeCompactCodexAppServerSession, __testing } from "./compact.js";
|
|
import type { CodexServerNotification } from "./protocol.js";
|
|
import {
|
|
clearCodexAppServerBinding,
|
|
readCodexAppServerBinding,
|
|
writeCodexAppServerBinding,
|
|
} from "./session-binding.js";
|
|
|
|
let tempDir: string;
|
|
|
|
function testSessionFile(suffix = "session-1"): string {
|
|
return createSqliteSessionTranscriptLocator({ agentId: "main", sessionId: suffix });
|
|
}
|
|
|
|
async function writeTestBinding(options: { authProfileId?: string } = {}): Promise<string> {
|
|
const sessionFile = testSessionFile();
|
|
await writeCodexAppServerBinding(
|
|
{ sessionKey: "agent:main:session-1", sessionFile },
|
|
{
|
|
threadId: "thread-1",
|
|
cwd: tempDir,
|
|
...options,
|
|
},
|
|
);
|
|
return sessionFile;
|
|
}
|
|
|
|
function startCompaction(sessionFile: string, options: { currentTokenCount?: number } = {}) {
|
|
return maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile,
|
|
workspaceDir: tempDir,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
describe("maybeCompactCodexAppServerSession", () => {
|
|
beforeEach(async () => {
|
|
await clearCodexAppServerBinding({ sessionKey: "agent:main:session-1" });
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-compact-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await clearCodexAppServerBinding({ sessionKey: "agent:main:session-1" });
|
|
__testing.resetCodexAppServerClientFactoryForTests();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("waits for native app-server compaction before reporting success", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
|
|
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
|
|
let settled = false;
|
|
void pendingResult.then(() => {
|
|
settled = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(settled).toBe(false);
|
|
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
const result = await pendingResult;
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
tokensBefore: 123,
|
|
details: {
|
|
backend: "codex-app-server",
|
|
threadId: "thread-1",
|
|
signal: "thread/compacted",
|
|
turnId: "turn-1",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("accepts native context-compaction item completion as success", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
|
|
const pendingResult = startCompaction(sessionFile);
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "item/completed",
|
|
params: {
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
item: { type: "contextCompaction", id: "compact-1" },
|
|
},
|
|
});
|
|
|
|
await expect(pendingResult).resolves.toMatchObject({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
details: {
|
|
signal: "item/completed",
|
|
itemId: "compact-1",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reuses the bound auth profile for native compaction", async () => {
|
|
const fake = createFakeCodexClient();
|
|
let seenAuthProfileId: string | undefined;
|
|
__testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => {
|
|
seenAuthProfileId = authProfileId;
|
|
return fake.client;
|
|
});
|
|
const sessionFile = await writeTestBinding({ authProfileId: "openai-codex:work" });
|
|
|
|
const pendingResult = startCompaction(sessionFile);
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
await pendingResult;
|
|
|
|
expect(seenAuthProfileId).toBe("openai-codex:work");
|
|
});
|
|
|
|
it("looks up native compaction bindings by OpenClaw session key", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
|
|
|
const pendingResult = startCompaction(sessionFile);
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
|
|
await expect(pendingResult).resolves.toMatchObject({
|
|
ok: true,
|
|
compacted: true,
|
|
});
|
|
});
|
|
|
|
it("fails closed when the persisted binding auth profile disagrees with the runtime request", async () => {
|
|
const fake = createFakeCodexClient();
|
|
const factory = vi.fn(async () => fake.client);
|
|
__testing.setCodexAppServerClientFactoryForTests(factory);
|
|
const sessionFile = testSessionFile("auth-profile-mismatch");
|
|
await writeCodexAppServerBinding(sessionFile, {
|
|
threadId: "thread-1",
|
|
cwd: tempDir,
|
|
authProfileId: "openai-codex:binding",
|
|
});
|
|
|
|
const result = await maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile,
|
|
workspaceDir: tempDir,
|
|
authProfileId: "openai-codex:runtime",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
compacted: false,
|
|
reason: "auth profile mismatch for session binding",
|
|
});
|
|
expect(factory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefers owning context-engine compaction and records native status separately", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
|
|
const contextEngine: ContextEngine = {
|
|
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
|
assemble: vi.fn() as never,
|
|
ingest: vi.fn() as never,
|
|
compact: vi.fn(async () => ({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: "engine summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 55,
|
|
details: { engine: "lossless-claw" },
|
|
},
|
|
})),
|
|
maintain,
|
|
};
|
|
|
|
const pendingResult = maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile,
|
|
workspaceDir: tempDir,
|
|
contextEngine,
|
|
contextTokenBudget: 777,
|
|
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
|
|
currentTokenCount: 123,
|
|
trigger: "manual",
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
|
|
await expect(pendingResult).resolves.toMatchObject({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: "engine summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 55,
|
|
details: {
|
|
engine: "lossless-claw",
|
|
codexNativeCompaction: {
|
|
ok: true,
|
|
compacted: true,
|
|
details: {
|
|
backend: "codex-app-server",
|
|
threadId: "thread-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(contextEngine.compact).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tokenBudget: 777,
|
|
currentTokenCount: 123,
|
|
compactionTarget: "threshold",
|
|
force: true,
|
|
runtimeContext: expect.objectContaining({
|
|
workspaceDir: tempDir,
|
|
provider: "codex",
|
|
}),
|
|
}),
|
|
);
|
|
expect(maintain).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
runtimeContext: expect.objectContaining({
|
|
workspaceDir: tempDir,
|
|
provider: "codex",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("still runs native compaction when context-engine maintenance fails", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
const contextEngine: ContextEngine = {
|
|
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
|
assemble: vi.fn() as never,
|
|
ingest: vi.fn() as never,
|
|
compact: vi.fn(async () => ({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: "engine summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 55,
|
|
},
|
|
})),
|
|
maintain: vi.fn(async () => {
|
|
throw new Error("maintenance boom");
|
|
}),
|
|
};
|
|
|
|
const pendingResult = maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile,
|
|
workspaceDir: tempDir,
|
|
contextEngine,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
|
|
await expect(pendingResult).resolves.toMatchObject({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
details: {
|
|
codexNativeCompaction: {
|
|
ok: true,
|
|
compacted: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("records native compaction status when primary compaction has no result payload", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
const contextEngine: ContextEngine = {
|
|
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
|
assemble: vi.fn() as never,
|
|
ingest: vi.fn() as never,
|
|
compact: vi.fn(async () => ({
|
|
ok: true,
|
|
compacted: false,
|
|
reason: "below threshold",
|
|
})),
|
|
};
|
|
|
|
const pendingResult = maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile,
|
|
workspaceDir: tempDir,
|
|
contextEngine,
|
|
currentTokenCount: 222,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
|
|
await expect(pendingResult).resolves.toMatchObject({
|
|
ok: true,
|
|
compacted: false,
|
|
reason: "below threshold",
|
|
result: {
|
|
tokensBefore: 222,
|
|
details: {
|
|
codexNativeCompaction: {
|
|
ok: true,
|
|
compacted: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reports context-engine compaction errors without skipping native compaction", async () => {
|
|
const fake = createFakeCodexClient();
|
|
__testing.setCodexAppServerClientFactoryForTests(async () => fake.client);
|
|
const sessionFile = await writeTestBinding();
|
|
const contextEngine: ContextEngine = {
|
|
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
|
assemble: vi.fn() as never,
|
|
ingest: vi.fn() as never,
|
|
compact: vi.fn(async () => {
|
|
throw new Error("engine boom");
|
|
}),
|
|
};
|
|
|
|
const pendingResult = maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile,
|
|
workspaceDir: tempDir,
|
|
contextEngine,
|
|
currentTokenCount: 222,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
|
});
|
|
fake.emit({
|
|
method: "thread/compacted",
|
|
params: { threadId: "thread-1", turnId: "turn-1" },
|
|
});
|
|
|
|
await expect(pendingResult).resolves.toMatchObject({
|
|
ok: false,
|
|
compacted: true,
|
|
reason: "context engine compaction failed: engine boom",
|
|
result: {
|
|
details: {
|
|
contextEngineCompaction: {
|
|
ok: false,
|
|
reason: "context engine compaction failed: engine boom",
|
|
},
|
|
codexNativeCompaction: {
|
|
ok: true,
|
|
compacted: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not fail owning context-engine compaction when Codex native compaction cannot run", async () => {
|
|
const contextEngine: ContextEngine = {
|
|
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
|
assemble: vi.fn() as never,
|
|
ingest: vi.fn() as never,
|
|
compact: vi.fn(async () => ({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: "engine summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 8,
|
|
},
|
|
})),
|
|
};
|
|
|
|
const result = await maybeCompactCodexAppServerSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile: path.join(tempDir, "missing-binding.jsonl"),
|
|
workspaceDir: tempDir,
|
|
contextEngine,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: "engine summary",
|
|
details: {
|
|
codexNativeCompaction: {
|
|
ok: false,
|
|
compacted: false,
|
|
reason: "no codex app-server thread binding",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
function createFakeCodexClient(): {
|
|
client: CodexAppServerClient;
|
|
request: ReturnType<typeof vi.fn>;
|
|
emit: (notification: CodexServerNotification) => void;
|
|
} {
|
|
const handlers = new Set<(notification: CodexServerNotification) => void>();
|
|
const request = vi.fn(async () => ({}));
|
|
return {
|
|
client: {
|
|
request,
|
|
addNotificationHandler(handler: (notification: CodexServerNotification) => void) {
|
|
handlers.add(handler);
|
|
return () => handlers.delete(handler);
|
|
},
|
|
} as unknown as CodexAppServerClient,
|
|
request,
|
|
emit(notification: CodexServerNotification): void {
|
|
for (const handler of handlers) {
|
|
handler(notification);
|
|
}
|
|
},
|
|
};
|
|
}
|