mirror of https://github.com/openclaw/openclaw.git
770 lines
25 KiB
TypeScript
770 lines
25 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getCustomApiRegistrySourceId } from "../custom-api-registry.js";
|
|
import {
|
|
contextEngineCompactMock,
|
|
ensureRuntimePluginsLoaded,
|
|
estimateTokensMock,
|
|
getMemorySearchManagerMock,
|
|
hookRunner,
|
|
loadCompactHooksHarness,
|
|
resolveContextEngineMock,
|
|
resolveMemorySearchConfigMock,
|
|
resolveModelMock,
|
|
resolveSessionAgentIdMock,
|
|
resetCompactHooksHarnessMocks,
|
|
resetCompactSessionStateMocks,
|
|
sessionAbortCompactionMock,
|
|
sessionMessages,
|
|
sessionCompactImpl,
|
|
triggerInternalHook,
|
|
} from "./compact.hooks.harness.js";
|
|
|
|
let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect;
|
|
let compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession;
|
|
let compactTesting: typeof import("./compact.js").__testing;
|
|
let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate;
|
|
|
|
const TEST_SESSION_ID = "session-1";
|
|
const TEST_SESSION_KEY = "agent:main:session-1";
|
|
const TEST_SESSION_FILE = "/tmp/session.jsonl";
|
|
const TEST_WORKSPACE_DIR = "/tmp";
|
|
const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions";
|
|
type SessionHookEvent = {
|
|
type?: string;
|
|
action?: string;
|
|
sessionKey?: string;
|
|
context?: Record<string, unknown>;
|
|
};
|
|
type PostCompactionSyncParams = {
|
|
reason: string;
|
|
sessionFiles: string[];
|
|
};
|
|
type PostCompactionSync = (params?: unknown) => Promise<void>;
|
|
type Deferred<T> = {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
};
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((promiseResolve) => {
|
|
resolve = promiseResolve;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function mockResolvedModel() {
|
|
resolveModelMock.mockReset();
|
|
resolveModelMock.mockReturnValue({
|
|
model: { provider: "openai", api: "responses", id: "fake", input: [] },
|
|
error: null,
|
|
authStorage: { setRuntimeApiKey: vi.fn() },
|
|
modelRegistry: {},
|
|
});
|
|
}
|
|
|
|
function compactionConfig(mode: "await" | "off" | "async") {
|
|
return {
|
|
agents: {
|
|
defaults: {
|
|
compaction: {
|
|
postIndexSync: mode,
|
|
},
|
|
},
|
|
},
|
|
} as never;
|
|
}
|
|
|
|
function wrappedCompactionArgs(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
sessionId: TEST_SESSION_ID,
|
|
sessionKey: TEST_SESSION_KEY,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
workspaceDir: TEST_WORKSPACE_DIR,
|
|
customInstructions: TEST_CUSTOM_INSTRUCTIONS,
|
|
enqueue: async <T>(task: () => Promise<T> | T) => await task(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const sessionHook = (action: string): SessionHookEvent | undefined =>
|
|
triggerInternalHook.mock.calls.find((call) => {
|
|
const event = call[0] as SessionHookEvent | undefined;
|
|
return event?.type === "session" && event.action === action;
|
|
})?.[0] as SessionHookEvent | undefined;
|
|
|
|
async function runCompactionHooks(params: { sessionKey?: string; messageProvider?: string }) {
|
|
const originalMessages = sessionMessages.slice(1) as AgentMessage[];
|
|
const currentMessages = sessionMessages.slice(1) as AgentMessage[];
|
|
const beforeMetrics = compactTesting.buildBeforeCompactionHookMetrics({
|
|
originalMessages,
|
|
currentMessages,
|
|
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
|
|
});
|
|
|
|
const hookState = await compactTesting.runBeforeCompactionHooks({
|
|
hookRunner,
|
|
sessionId: TEST_SESSION_ID,
|
|
sessionKey: params.sessionKey,
|
|
sessionAgentId: "main",
|
|
workspaceDir: TEST_WORKSPACE_DIR,
|
|
messageProvider: params.messageProvider,
|
|
metrics: beforeMetrics,
|
|
});
|
|
|
|
await compactTesting.runAfterCompactionHooks({
|
|
hookRunner,
|
|
sessionId: TEST_SESSION_ID,
|
|
sessionAgentId: "main",
|
|
hookSessionKey: hookState.hookSessionKey,
|
|
missingSessionKey: hookState.missingSessionKey,
|
|
workspaceDir: TEST_WORKSPACE_DIR,
|
|
messageProvider: params.messageProvider,
|
|
messageCountAfter: 1,
|
|
tokensAfter: 10,
|
|
compactedCount: 1,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
summaryLength: "summary".length,
|
|
tokensBefore: 120,
|
|
firstKeptEntryId: "entry-1",
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const loaded = await loadCompactHooksHarness();
|
|
compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect;
|
|
compactEmbeddedPiSession = loaded.compactEmbeddedPiSession;
|
|
compactTesting = loaded.__testing;
|
|
onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetCompactHooksHarnessMocks();
|
|
});
|
|
|
|
describe("compactEmbeddedPiSessionDirect hooks", () => {
|
|
beforeEach(() => {
|
|
ensureRuntimePluginsLoaded.mockReset();
|
|
triggerInternalHook.mockClear();
|
|
hookRunner.hasHooks.mockReset();
|
|
hookRunner.runBeforeCompaction.mockReset();
|
|
hookRunner.runAfterCompaction.mockReset();
|
|
mockResolvedModel();
|
|
sessionCompactImpl.mockReset();
|
|
sessionCompactImpl.mockResolvedValue({
|
|
summary: "summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 120,
|
|
details: { ok: true },
|
|
});
|
|
resetCompactSessionStateMocks();
|
|
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
|
|
});
|
|
|
|
it("bootstraps runtime plugins with the resolved workspace", async () => {
|
|
// This assertion only cares about bootstrap wiring, so stop before the
|
|
// rest of the compaction pipeline can pull in unrelated runtime surfaces.
|
|
resolveModelMock.mockReturnValue({
|
|
model: undefined,
|
|
error: "stop after bootstrap",
|
|
authStorage: { setRuntimeApiKey: vi.fn() },
|
|
modelRegistry: {},
|
|
} as never);
|
|
|
|
await compactEmbeddedPiSessionDirect({
|
|
sessionId: "session-1",
|
|
sessionFile: "/tmp/session.jsonl",
|
|
workspaceDir: "/tmp/workspace",
|
|
});
|
|
|
|
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
|
config: undefined,
|
|
workspaceDir: "/tmp/workspace",
|
|
});
|
|
});
|
|
|
|
it("forwards gateway subagent binding opt-in during compaction bootstrap", async () => {
|
|
// Coding-tool forwarding is covered elsewhere; this compaction test only
|
|
// owns the runtime bootstrap wiring.
|
|
resolveModelMock.mockReturnValue({
|
|
model: undefined,
|
|
error: "stop after bootstrap",
|
|
authStorage: { setRuntimeApiKey: vi.fn() },
|
|
modelRegistry: {},
|
|
} as never);
|
|
|
|
await compactEmbeddedPiSessionDirect({
|
|
sessionId: "session-1",
|
|
sessionFile: "/tmp/session.jsonl",
|
|
workspaceDir: "/tmp/workspace",
|
|
allowGatewaySubagentBinding: true,
|
|
});
|
|
|
|
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
|
config: undefined,
|
|
workspaceDir: "/tmp/workspace",
|
|
allowGatewaySubagentBinding: true,
|
|
});
|
|
});
|
|
|
|
it("emits internal + plugin compaction hooks with counts", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(true);
|
|
await runCompactionHooks({
|
|
sessionKey: TEST_SESSION_KEY,
|
|
messageProvider: "telegram",
|
|
});
|
|
|
|
expect(sessionHook("compact:before")).toMatchObject({
|
|
type: "session",
|
|
action: "compact:before",
|
|
});
|
|
const beforeContext = sessionHook("compact:before")?.context;
|
|
const afterContext = sessionHook("compact:after")?.context;
|
|
|
|
expect(beforeContext).toMatchObject({
|
|
messageCount: 2,
|
|
tokenCount: 20,
|
|
messageCountOriginal: 2,
|
|
tokenCountOriginal: 20,
|
|
});
|
|
expect(afterContext).toMatchObject({
|
|
messageCount: 1,
|
|
compactedCount: 1,
|
|
});
|
|
expect(afterContext?.compactedCount).toBe(
|
|
(beforeContext?.messageCountOriginal as number) - (afterContext?.messageCount as number),
|
|
);
|
|
|
|
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageCount: 2,
|
|
tokenCount: 20,
|
|
}),
|
|
expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }),
|
|
);
|
|
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
|
|
{
|
|
messageCount: 1,
|
|
tokenCount: 10,
|
|
compactedCount: 1,
|
|
sessionFile: "/tmp/session.jsonl",
|
|
},
|
|
expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }),
|
|
);
|
|
});
|
|
|
|
it("uses sessionId as hook session key fallback when sessionKey is missing", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(true);
|
|
await runCompactionHooks({});
|
|
|
|
expect(sessionHook("compact:before")?.sessionKey).toBe("session-1");
|
|
expect(sessionHook("compact:after")?.sessionKey).toBe("session-1");
|
|
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({ sessionKey: "session-1" }),
|
|
);
|
|
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({ sessionKey: "session-1" }),
|
|
);
|
|
});
|
|
|
|
it("applies validated transcript before hooks even when it becomes empty", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(true);
|
|
const beforeMetrics = compactTesting.buildBeforeCompactionHookMetrics({
|
|
originalMessages: [],
|
|
currentMessages: [],
|
|
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
|
|
});
|
|
await compactTesting.runBeforeCompactionHooks({
|
|
hookRunner,
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionAgentId: "main",
|
|
workspaceDir: "/tmp",
|
|
metrics: beforeMetrics,
|
|
});
|
|
|
|
const beforeContext = sessionHook("compact:before")?.context;
|
|
expect(beforeContext).toMatchObject({
|
|
messageCountOriginal: 0,
|
|
tokenCountOriginal: 0,
|
|
messageCount: 0,
|
|
tokenCount: 0,
|
|
});
|
|
});
|
|
it("emits a transcript update after successful compaction", async () => {
|
|
const listener = vi.fn();
|
|
const cleanup = onSessionTranscriptUpdate(listener);
|
|
|
|
try {
|
|
await compactTesting.runPostCompactionSideEffects({
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile: " /tmp/session.jsonl ",
|
|
});
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener).toHaveBeenCalledWith({ sessionFile: "/tmp/session.jsonl" });
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("preserves tokensAfter when full-session context exceeds result.tokensBefore", async () => {
|
|
estimateTokensMock.mockImplementation((message: unknown) => {
|
|
const role = (message as { role?: string }).role;
|
|
if (role === "user") {
|
|
return 30;
|
|
}
|
|
if (role === "assistant") {
|
|
return 20;
|
|
}
|
|
return 5;
|
|
});
|
|
const tokensAfter = compactTesting.estimateTokensAfterCompaction({
|
|
messagesAfter: [{ role: "user", content: "kept ask" }] as AgentMessage[],
|
|
fullSessionTokensBefore: 55,
|
|
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
|
|
});
|
|
|
|
expect(tokensAfter).toBe(30);
|
|
});
|
|
|
|
it("treats pre-compaction token estimation failures as a no-op sanity check", async () => {
|
|
estimateTokensMock.mockImplementation((message: unknown) => {
|
|
const role = (message as { role?: string }).role;
|
|
if (role === "assistant") {
|
|
throw new Error("legacy message");
|
|
}
|
|
if (role === "user") {
|
|
return 30;
|
|
}
|
|
return 5;
|
|
});
|
|
const beforeMetrics = compactTesting.buildBeforeCompactionHookMetrics({
|
|
originalMessages: sessionMessages as AgentMessage[],
|
|
currentMessages: sessionMessages as AgentMessage[],
|
|
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
|
|
});
|
|
const tokensAfter = compactTesting.estimateTokensAfterCompaction({
|
|
messagesAfter: [{ role: "user", content: "kept ask" }] as AgentMessage[],
|
|
fullSessionTokensBefore: 0,
|
|
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
|
|
});
|
|
|
|
expect(beforeMetrics.tokenCountOriginal).toBeUndefined();
|
|
expect(beforeMetrics.tokenCountBefore).toBeUndefined();
|
|
expect(tokensAfter).toBe(30);
|
|
});
|
|
|
|
it("skips sync in await mode when postCompactionForce is false", async () => {
|
|
const sync = vi.fn(async () => {});
|
|
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
|
resolveMemorySearchConfigMock.mockReturnValue({
|
|
sources: ["sessions"],
|
|
sync: {
|
|
sessions: {
|
|
postCompactionForce: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
await compactTesting.runPostCompactionSideEffects({
|
|
config: compactionConfig("await"),
|
|
sessionKey: TEST_SESSION_KEY,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
});
|
|
|
|
expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({
|
|
sessionKey: TEST_SESSION_KEY,
|
|
config: expect.any(Object),
|
|
});
|
|
expect(getMemorySearchManagerMock).not.toHaveBeenCalled();
|
|
expect(sync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => {
|
|
const syncStarted = createDeferred<PostCompactionSyncParams>();
|
|
const syncRelease = createDeferred<void>();
|
|
const sync = vi.fn<PostCompactionSync>(async (params) => {
|
|
syncStarted.resolve(params as PostCompactionSyncParams);
|
|
await syncRelease.promise;
|
|
});
|
|
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
|
let settled = false;
|
|
|
|
const resultPromise = compactTesting.runPostCompactionSideEffects({
|
|
config: compactionConfig("await"),
|
|
sessionKey: TEST_SESSION_KEY,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
});
|
|
|
|
void resultPromise.then(() => {
|
|
settled = true;
|
|
});
|
|
await expect(syncStarted.promise).resolves.toEqual({
|
|
reason: "post-compaction",
|
|
sessionFiles: [TEST_SESSION_FILE],
|
|
});
|
|
expect(settled).toBe(false);
|
|
syncRelease.resolve(undefined);
|
|
await resultPromise;
|
|
expect(settled).toBe(true);
|
|
});
|
|
|
|
it("skips post-compaction memory sync when the mode is off", async () => {
|
|
const sync = vi.fn(async () => {});
|
|
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
|
|
|
await compactTesting.runPostCompactionSideEffects({
|
|
config: compactionConfig("off"),
|
|
sessionKey: TEST_SESSION_KEY,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
});
|
|
|
|
expect(resolveSessionAgentIdMock).not.toHaveBeenCalled();
|
|
expect(getMemorySearchManagerMock).not.toHaveBeenCalled();
|
|
expect(sync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fires post-compaction memory sync without awaiting it in async mode", async () => {
|
|
const sync = vi.fn<PostCompactionSync>(async () => {});
|
|
const managerRequested = createDeferred<void>();
|
|
const managerGate = createDeferred<{ manager: { sync: PostCompactionSync } }>();
|
|
const syncStarted = createDeferred<PostCompactionSyncParams>();
|
|
sync.mockImplementation(async (params) => {
|
|
syncStarted.resolve(params as PostCompactionSyncParams);
|
|
});
|
|
getMemorySearchManagerMock.mockImplementation(async () => {
|
|
managerRequested.resolve(undefined);
|
|
return await managerGate.promise;
|
|
});
|
|
let settled = false;
|
|
|
|
const resultPromise = compactTesting.runPostCompactionSideEffects({
|
|
config: compactionConfig("async"),
|
|
sessionKey: TEST_SESSION_KEY,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
});
|
|
|
|
await managerRequested.promise;
|
|
void resultPromise.then(() => {
|
|
settled = true;
|
|
});
|
|
await resultPromise;
|
|
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1);
|
|
expect(settled).toBe(true);
|
|
expect(sync).not.toHaveBeenCalled();
|
|
managerGate.resolve({ manager: { sync } });
|
|
await expect(syncStarted.promise).resolves.toEqual({
|
|
reason: "post-compaction",
|
|
sessionFiles: [TEST_SESSION_FILE],
|
|
});
|
|
});
|
|
|
|
it("skips compaction when the transcript only contains boilerplate replies and tool output", async () => {
|
|
const messages = [
|
|
{ role: "user", content: "<b>HEARTBEAT_OK</b>", timestamp: 1 },
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "t1",
|
|
toolName: "exec",
|
|
content: [{ type: "text", text: "checked" }],
|
|
isError: false,
|
|
timestamp: 2,
|
|
},
|
|
] as AgentMessage[];
|
|
|
|
expect(compactTesting.containsRealConversationMessages(messages)).toBe(false);
|
|
});
|
|
|
|
it("skips compaction when the transcript only contains heartbeat boilerplate and reasoning blocks", async () => {
|
|
const messages = [
|
|
{ role: "user", content: "<b>HEARTBEAT_OK</b>", timestamp: 1 },
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "thinking", thinking: "checking" }],
|
|
timestamp: 2,
|
|
},
|
|
] as AgentMessage[];
|
|
|
|
expect(compactTesting.containsRealConversationMessages(messages)).toBe(false);
|
|
});
|
|
|
|
it("does not treat assistant-only tool-call blocks as meaningful conversation", () => {
|
|
expect(
|
|
compactTesting.hasMeaningfulConversationContent({
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }],
|
|
} as AgentMessage),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("counts tool output as real only when a meaningful user ask exists in the lookback window", () => {
|
|
const heartbeatToolResultWindow = [
|
|
{ role: "user", content: "<b>HEARTBEAT_OK</b>" },
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "t1",
|
|
toolName: "exec",
|
|
content: [{ type: "text", text: "checked" }],
|
|
},
|
|
] as AgentMessage[];
|
|
expect(
|
|
compactTesting.hasRealConversationContent(
|
|
heartbeatToolResultWindow[1],
|
|
heartbeatToolResultWindow,
|
|
1,
|
|
),
|
|
).toBe(false);
|
|
|
|
const realAskToolResultWindow = [
|
|
{ role: "assistant", content: "NO_REPLY" },
|
|
{ role: "user", content: "please inspect the failing PR" },
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "t2",
|
|
toolName: "exec",
|
|
content: [{ type: "text", text: "checked" }],
|
|
},
|
|
] as AgentMessage[];
|
|
expect(
|
|
compactTesting.hasRealConversationContent(
|
|
realAskToolResultWindow[2],
|
|
realAskToolResultWindow,
|
|
2,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("registers the Ollama api provider before compaction", async () => {
|
|
resolveModelMock.mockReturnValue({
|
|
model: {
|
|
provider: "ollama",
|
|
api: "ollama",
|
|
id: "qwen3:8b",
|
|
input: ["text"],
|
|
baseUrl: "http://127.0.0.1:11434",
|
|
headers: { Authorization: "Bearer ollama-cloud" },
|
|
},
|
|
error: null,
|
|
authStorage: { setRuntimeApiKey: vi.fn() },
|
|
modelRegistry: {},
|
|
} as never);
|
|
sessionCompactImpl.mockImplementation(async () => {
|
|
expect(getApiProvider("ollama" as Parameters<typeof getApiProvider>[0])).toBeDefined();
|
|
return {
|
|
summary: "summary",
|
|
firstKeptEntryId: "entry-1",
|
|
tokensBefore: 120,
|
|
details: { ok: true },
|
|
};
|
|
});
|
|
|
|
const result = await compactEmbeddedPiSessionDirect({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:session-1",
|
|
sessionFile: "/tmp/session.jsonl",
|
|
workspaceDir: "/tmp",
|
|
customInstructions: "focus on decisions",
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("aborts in-flight compaction when the caller abort signal fires", async () => {
|
|
const { compactWithSafetyTimeout } = await vi.importActual<
|
|
typeof import("./compaction-safety-timeout.js")
|
|
>("./compaction-safety-timeout.js");
|
|
const controller = new AbortController();
|
|
const compactStarted = createDeferred<void>();
|
|
|
|
const resultPromise = compactWithSafetyTimeout(
|
|
async () => {
|
|
compactStarted.resolve(undefined);
|
|
return await new Promise<never>(() => {});
|
|
},
|
|
30_000,
|
|
{
|
|
abortSignal: controller.signal,
|
|
onCancel: () => {
|
|
sessionAbortCompactionMock();
|
|
},
|
|
},
|
|
);
|
|
|
|
await compactStarted.promise;
|
|
controller.abort(new Error("request timed out"));
|
|
|
|
await expect(resultPromise).rejects.toThrow("request timed out");
|
|
expect(sessionAbortCompactionMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
|
|
beforeEach(() => {
|
|
hookRunner.hasHooks.mockReset();
|
|
hookRunner.runBeforeCompaction.mockReset();
|
|
hookRunner.runAfterCompaction.mockReset();
|
|
resolveContextEngineMock.mockReset();
|
|
resolveContextEngineMock.mockResolvedValue({
|
|
info: { ownsCompaction: true },
|
|
compact: contextEngineCompactMock,
|
|
});
|
|
contextEngineCompactMock.mockReset();
|
|
contextEngineCompactMock.mockResolvedValue({
|
|
ok: true,
|
|
compacted: true,
|
|
reason: undefined,
|
|
result: { summary: "engine-summary", tokensAfter: 50 },
|
|
});
|
|
mockResolvedModel();
|
|
});
|
|
|
|
it("fires before_compaction with sentinel -1 and after_compaction on success", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(true);
|
|
|
|
const result = await compactEmbeddedPiSession(
|
|
wrappedCompactionArgs({
|
|
messageChannel: "telegram",
|
|
}),
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.compacted).toBe(true);
|
|
|
|
expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith(
|
|
{ messageCount: -1, sessionFile: TEST_SESSION_FILE },
|
|
expect.objectContaining({
|
|
sessionKey: TEST_SESSION_KEY,
|
|
messageProvider: "telegram",
|
|
}),
|
|
);
|
|
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
|
|
{
|
|
messageCount: -1,
|
|
compactedCount: -1,
|
|
tokenCount: 50,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
},
|
|
expect.objectContaining({
|
|
sessionKey: TEST_SESSION_KEY,
|
|
messageProvider: "telegram",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("emits a transcript update and post-compaction memory sync on the engine-owned path", async () => {
|
|
const listener = vi.fn();
|
|
const cleanup = onSessionTranscriptUpdate(listener);
|
|
const sync = vi.fn(async () => {});
|
|
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
|
|
|
try {
|
|
const result = await compactEmbeddedPiSession(
|
|
wrappedCompactionArgs({
|
|
sessionFile: ` ${TEST_SESSION_FILE} `,
|
|
config: compactionConfig("await"),
|
|
}),
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener).toHaveBeenCalledWith({ sessionFile: TEST_SESSION_FILE });
|
|
expect(sync).toHaveBeenCalledWith({
|
|
reason: "post-compaction",
|
|
sessionFiles: [TEST_SESSION_FILE],
|
|
});
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("runs maintain after successful compaction with a transcript rewrite helper", async () => {
|
|
const maintain = vi.fn(async (_params?: unknown) => ({
|
|
changed: false,
|
|
bytesFreed: 0,
|
|
rewrittenEntries: 0,
|
|
}));
|
|
resolveContextEngineMock.mockResolvedValue({
|
|
info: { ownsCompaction: true },
|
|
compact: contextEngineCompactMock,
|
|
maintain,
|
|
} as never);
|
|
|
|
const result = await compactEmbeddedPiSession(wrappedCompactionArgs());
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(maintain).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey: TEST_SESSION_KEY,
|
|
sessionFile: TEST_SESSION_FILE,
|
|
runtimeContext: expect.objectContaining({
|
|
workspaceDir: TEST_WORKSPACE_DIR,
|
|
}),
|
|
}),
|
|
);
|
|
const runtimeContext = (
|
|
maintain.mock.calls[0]?.[0] as { runtimeContext?: Record<string, unknown> } | undefined
|
|
)?.runtimeContext;
|
|
expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function");
|
|
});
|
|
|
|
it("does not fire after_compaction when compaction fails", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(true);
|
|
const sync = vi.fn(async () => {});
|
|
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
|
contextEngineCompactMock.mockResolvedValue({
|
|
ok: false,
|
|
compacted: false,
|
|
reason: "nothing to compact",
|
|
result: undefined,
|
|
});
|
|
|
|
const result = await compactEmbeddedPiSession(wrappedCompactionArgs());
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(hookRunner.runBeforeCompaction).toHaveBeenCalled();
|
|
expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled();
|
|
expect(sync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not duplicate transcript updates or sync in the wrapper when the engine delegates compaction", async () => {
|
|
const listener = vi.fn();
|
|
const cleanup = onSessionTranscriptUpdate(listener);
|
|
const sync = vi.fn(async () => {});
|
|
getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } });
|
|
resolveContextEngineMock.mockResolvedValue({
|
|
info: { ownsCompaction: false },
|
|
compact: contextEngineCompactMock,
|
|
});
|
|
|
|
try {
|
|
const result = await compactEmbeddedPiSession(
|
|
wrappedCompactionArgs({
|
|
config: compactionConfig("await"),
|
|
}),
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect(sync).not.toHaveBeenCalled();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("catches and logs hook exceptions without aborting compaction", async () => {
|
|
hookRunner.hasHooks.mockReturnValue(true);
|
|
hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom"));
|
|
|
|
const result = await compactEmbeddedPiSession(wrappedCompactionArgs());
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.compacted).toBe(true);
|
|
expect(contextEngineCompactMock).toHaveBeenCalled();
|
|
});
|
|
});
|