| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import type { AgentMessage } from "@mariozechner/pi-agent-core"; |
| import type { Api, Model } from "@mariozechner/pi-ai"; |
| import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; |
| import { describe, expect, it, vi } from "vitest"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
| import * as compactionModule from "../compaction.js"; |
| import { buildEmbeddedExtensionFactories } from "../pi-embedded-runner/extensions.js"; |
| import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; |
| import { |
| getCompactionSafeguardRuntime, |
| setCompactionSafeguardRuntime, |
| } from "./compaction-safeguard-runtime.js"; |
| import compactionSafeguardExtension, { __testing } from "./compaction-safeguard.js"; |
|
|
| vi.mock("../compaction.js", async (importOriginal) => { |
| const actual = await importOriginal<typeof compactionModule>(); |
| return { |
| ...actual, |
| summarizeInStages: vi.fn(actual.summarizeInStages), |
| }; |
| }); |
|
|
| const mockSummarizeInStages = vi.mocked(compactionModule.summarizeInStages); |
|
|
| const { |
| collectToolFailures, |
| formatToolFailuresSection, |
| splitPreservedRecentTurns, |
| formatPreservedTurnsSection, |
| buildCompactionStructureInstructions, |
| buildStructuredFallbackSummary, |
| appendSummarySection, |
| resolveRecentTurnsPreserve, |
| resolveQualityGuardMaxRetries, |
| extractOpaqueIdentifiers, |
| auditSummaryQuality, |
| computeAdaptiveChunkRatio, |
| isOversizedForSummary, |
| readWorkspaceContextForSummary, |
| BASE_CHUNK_RATIO, |
| MIN_CHUNK_RATIO, |
| SAFETY_MARGIN, |
| } = __testing; |
|
|
| function stubSessionManager(): ExtensionContext["sessionManager"] { |
| const stub: ExtensionContext["sessionManager"] = { |
| getCwd: () => "/stub", |
| getSessionDir: () => "/stub", |
| getSessionId: () => "stub-id", |
| getSessionFile: () => undefined, |
| getLeafId: () => null, |
| getLeafEntry: () => undefined, |
| getEntry: () => undefined, |
| getLabel: () => undefined, |
| getBranch: () => [], |
| getHeader: () => null, |
| getEntries: () => [], |
| getTree: () => [], |
| getSessionName: () => undefined, |
| }; |
| return stub; |
| } |
|
|
| function createAnthropicModelFixture(overrides: Partial<Model<Api>> = {}): Model<Api> { |
| return { |
| id: "claude-opus-4-5", |
| name: "Claude Opus 4.5", |
| provider: "anthropic", |
| api: "anthropic" as const, |
| baseUrl: "https://api.anthropic.com", |
| contextWindow: 200000, |
| maxTokens: 4096, |
| reasoning: false, |
| input: ["text"] as const, |
| cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 }, |
| ...overrides, |
| }; |
| } |
|
|
| type CompactionHandler = (event: unknown, ctx: unknown) => Promise<unknown>; |
| const createCompactionHandler = () => { |
| let compactionHandler: CompactionHandler | undefined; |
| const mockApi = { |
| on: vi.fn((event: string, handler: CompactionHandler) => { |
| if (event === "session_before_compact") { |
| compactionHandler = handler; |
| } |
| }), |
| } as unknown as ExtensionAPI; |
| compactionSafeguardExtension(mockApi); |
| expect(compactionHandler).toBeDefined(); |
| return compactionHandler as CompactionHandler; |
| }; |
|
|
| const createCompactionEvent = (params: { messageText: string; tokensBefore: number }) => ({ |
| preparation: { |
| messagesToSummarize: [ |
| { role: "user", content: params.messageText, timestamp: Date.now() }, |
| ] as AgentMessage[], |
| turnPrefixMessages: [] as AgentMessage[], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: params.tokensBefore, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }); |
|
|
| const createCompactionContext = (params: { |
| sessionManager: ExtensionContext["sessionManager"]; |
| getApiKeyMock: ReturnType<typeof vi.fn>; |
| }) => |
| ({ |
| model: undefined, |
| sessionManager: params.sessionManager, |
| modelRegistry: { |
| getApiKey: params.getApiKeyMock, |
| }, |
| }) as unknown as Partial<ExtensionContext>; |
|
|
| async function runCompactionScenario(params: { |
| sessionManager: ExtensionContext["sessionManager"]; |
| event: unknown; |
| apiKey: string | null; |
| }) { |
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue(params.apiKey); |
| const mockContext = createCompactionContext({ |
| sessionManager: params.sessionManager, |
| getApiKeyMock, |
| }); |
| const result = (await compactionHandler(params.event, mockContext)) as { |
| cancel?: boolean; |
| }; |
| return { result, getApiKeyMock }; |
| } |
|
|
| describe("compaction-safeguard tool failures", () => { |
| it("formats tool failures with meta and summary", () => { |
| const messages: AgentMessage[] = [ |
| { |
| role: "toolResult", |
| toolCallId: "call-1", |
| toolName: "exec", |
| isError: true, |
| details: { status: "failed", exitCode: 1 }, |
| content: [{ type: "text", text: "ENOENT: missing file" }], |
| timestamp: Date.now(), |
| }, |
| { |
| role: "toolResult", |
| toolCallId: "call-2", |
| toolName: "read", |
| isError: false, |
| content: [{ type: "text", text: "ok" }], |
| timestamp: Date.now(), |
| }, |
| ]; |
|
|
| const failures = collectToolFailures(messages); |
| expect(failures).toHaveLength(1); |
|
|
| const section = formatToolFailuresSection(failures); |
| expect(section).toContain("## Tool Failures"); |
| expect(section).toContain("exec (status=failed exitCode=1): ENOENT: missing file"); |
| }); |
|
|
| it("dedupes by toolCallId and handles empty output", () => { |
| const messages: AgentMessage[] = [ |
| { |
| role: "toolResult", |
| toolCallId: "call-1", |
| toolName: "exec", |
| isError: true, |
| details: { exitCode: 2 }, |
| content: [], |
| timestamp: Date.now(), |
| }, |
| { |
| role: "toolResult", |
| toolCallId: "call-1", |
| toolName: "exec", |
| isError: true, |
| content: [{ type: "text", text: "ignored" }], |
| timestamp: Date.now(), |
| }, |
| ]; |
|
|
| const failures = collectToolFailures(messages); |
| expect(failures).toHaveLength(1); |
|
|
| const section = formatToolFailuresSection(failures); |
| expect(section).toContain("exec (exitCode=2): failed"); |
| }); |
|
|
| it("caps the number of failures and adds overflow line", () => { |
| const messages: AgentMessage[] = Array.from({ length: 9 }, (_, idx) => ({ |
| role: "toolResult", |
| toolCallId: `call-${idx}`, |
| toolName: "exec", |
| isError: true, |
| content: [{ type: "text", text: `error ${idx}` }], |
| timestamp: Date.now(), |
| })); |
|
|
| const failures = collectToolFailures(messages); |
| const section = formatToolFailuresSection(failures); |
| expect(section).toContain("## Tool Failures"); |
| expect(section).toContain("...and 1 more"); |
| }); |
|
|
| it("omits section when there are no tool failures", () => { |
| const messages: AgentMessage[] = [ |
| { |
| role: "toolResult", |
| toolCallId: "ok", |
| toolName: "exec", |
| isError: false, |
| content: [{ type: "text", text: "ok" }], |
| timestamp: Date.now(), |
| }, |
| ]; |
|
|
| const failures = collectToolFailures(messages); |
| const section = formatToolFailuresSection(failures); |
| expect(section).toBe(""); |
| }); |
| }); |
|
|
| describe("computeAdaptiveChunkRatio", () => { |
| const CONTEXT_WINDOW = 200_000; |
|
|
| it("returns BASE_CHUNK_RATIO for normal messages", () => { |
| |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "x".repeat(1000), timestamp: Date.now() }, |
| castAgentMessage({ |
| role: "assistant", |
| content: [{ type: "text", text: "y".repeat(1000) }], |
| timestamp: Date.now(), |
| }), |
| ]; |
|
|
| const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW); |
| expect(ratio).toBe(BASE_CHUNK_RATIO); |
| }); |
|
|
| it("reduces ratio when average message > 10% of context", () => { |
| |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() }, |
| castAgentMessage({ |
| role: "assistant", |
| content: [{ type: "text", text: "y".repeat(50_000 * 4) }], |
| timestamp: Date.now(), |
| }), |
| ]; |
|
|
| const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW); |
| expect(ratio).toBeLessThan(BASE_CHUNK_RATIO); |
| expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO); |
| }); |
|
|
| it("respects MIN_CHUNK_RATIO floor", () => { |
| |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "x".repeat(150_000 * 4), timestamp: Date.now() }, |
| ]; |
|
|
| const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW); |
| expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO); |
| }); |
|
|
| it("handles empty message array", () => { |
| const ratio = computeAdaptiveChunkRatio([], CONTEXT_WINDOW); |
| expect(ratio).toBe(BASE_CHUNK_RATIO); |
| }); |
|
|
| it("handles single huge message", () => { |
| |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "x".repeat(180_000 * 4), timestamp: Date.now() }, |
| ]; |
|
|
| const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW); |
| expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO); |
| expect(ratio).toBeLessThanOrEqual(BASE_CHUNK_RATIO); |
| }); |
| }); |
|
|
| describe("isOversizedForSummary", () => { |
| const CONTEXT_WINDOW = 200_000; |
|
|
| it("returns false for small messages", () => { |
| const msg: AgentMessage = { |
| role: "user", |
| content: "Hello, world!", |
| timestamp: Date.now(), |
| }; |
|
|
| expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(false); |
| }); |
|
|
| it("returns true for messages > 50% of context", () => { |
| |
| |
| const msg: AgentMessage = { |
| role: "user", |
| content: "x".repeat(120_000 * 4), |
| timestamp: Date.now(), |
| }; |
|
|
| expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(true); |
| }); |
|
|
| it("applies safety margin", () => { |
| |
| |
| const halfContextChars = (CONTEXT_WINDOW * 0.5) / SAFETY_MARGIN; |
| const msg: AgentMessage = { |
| role: "user", |
| content: "x".repeat(Math.floor(halfContextChars * 4)), |
| timestamp: Date.now(), |
| }; |
|
|
| |
| |
| const isOversized = isOversizedForSummary(msg, CONTEXT_WINDOW); |
| |
| expect(typeof isOversized).toBe("boolean"); |
| }); |
| }); |
|
|
| describe("compaction-safeguard runtime registry", () => { |
| it("stores and retrieves config by session manager identity", () => { |
| const sm = {}; |
| setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.3 }); |
| const runtime = getCompactionSafeguardRuntime(sm); |
| expect(runtime).toEqual({ maxHistoryShare: 0.3 }); |
| }); |
|
|
| it("returns null for unknown session manager", () => { |
| const sm = {}; |
| expect(getCompactionSafeguardRuntime(sm)).toBeNull(); |
| }); |
|
|
| it("clears entry when value is null", () => { |
| const sm = {}; |
| setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.7 }); |
| expect(getCompactionSafeguardRuntime(sm)).not.toBeNull(); |
| setCompactionSafeguardRuntime(sm, null); |
| expect(getCompactionSafeguardRuntime(sm)).toBeNull(); |
| }); |
|
|
| it("ignores non-object session managers", () => { |
| setCompactionSafeguardRuntime(null, { maxHistoryShare: 0.5 }); |
| expect(getCompactionSafeguardRuntime(null)).toBeNull(); |
| setCompactionSafeguardRuntime(undefined, { maxHistoryShare: 0.5 }); |
| expect(getCompactionSafeguardRuntime(undefined)).toBeNull(); |
| }); |
|
|
| it("isolates different session managers", () => { |
| const sm1 = {}; |
| const sm2 = {}; |
| setCompactionSafeguardRuntime(sm1, { maxHistoryShare: 0.3 }); |
| setCompactionSafeguardRuntime(sm2, { maxHistoryShare: 0.8 }); |
| expect(getCompactionSafeguardRuntime(sm1)).toEqual({ maxHistoryShare: 0.3 }); |
| expect(getCompactionSafeguardRuntime(sm2)).toEqual({ maxHistoryShare: 0.8 }); |
| }); |
|
|
| it("stores and retrieves model from runtime (fallback for compact.ts workflow)", () => { |
| const sm = {}; |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sm, { model }); |
| const retrieved = getCompactionSafeguardRuntime(sm); |
| expect(retrieved?.model).toEqual(model); |
| }); |
|
|
| it("stores and retrieves contextWindowTokens from runtime", () => { |
| const sm = {}; |
| setCompactionSafeguardRuntime(sm, { contextWindowTokens: 200000 }); |
| const retrieved = getCompactionSafeguardRuntime(sm); |
| expect(retrieved?.contextWindowTokens).toBe(200000); |
| }); |
|
|
| it("stores and retrieves combined runtime values", () => { |
| const sm = {}; |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sm, { |
| maxHistoryShare: 0.6, |
| contextWindowTokens: 200000, |
| model, |
| }); |
| const retrieved = getCompactionSafeguardRuntime(sm); |
| expect(retrieved).toEqual({ |
| maxHistoryShare: 0.6, |
| contextWindowTokens: 200000, |
| model, |
| }); |
| }); |
|
|
| it("wires oversized safeguard runtime values when config validation is bypassed", () => { |
| const sessionManager = {} as unknown as Parameters< |
| typeof buildEmbeddedExtensionFactories |
| >[0]["sessionManager"]; |
| const cfg = { |
| agents: { |
| defaults: { |
| compaction: { |
| mode: "safeguard", |
| recentTurnsPreserve: 99, |
| qualityGuard: { maxRetries: 99 }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig; |
|
|
| buildEmbeddedExtensionFactories({ |
| cfg, |
| sessionManager, |
| provider: "anthropic", |
| modelId: "claude-3-opus", |
| model: { |
| contextWindow: 200_000, |
| } as Parameters<typeof buildEmbeddedExtensionFactories>[0]["model"], |
| }); |
|
|
| const runtime = getCompactionSafeguardRuntime(sessionManager); |
| expect(runtime?.qualityGuardMaxRetries).toBe(99); |
| expect(runtime?.recentTurnsPreserve).toBe(99); |
| expect(resolveQualityGuardMaxRetries(runtime?.qualityGuardMaxRetries)).toBe(3); |
| expect(resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve)).toBe(12); |
| }); |
| }); |
|
|
| describe("compaction-safeguard recent-turn preservation", () => { |
| it("preserves the most recent user/assistant messages", () => { |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "older ask", timestamp: 1 }, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "older answer" }], |
| timestamp: 2, |
| } as unknown as AgentMessage, |
| { role: "user", content: "recent ask", timestamp: 3 }, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "recent answer" }], |
| timestamp: 4, |
| } as unknown as AgentMessage, |
| ]; |
|
|
| const split = splitPreservedRecentTurns({ |
| messages, |
| recentTurnsPreserve: 1, |
| }); |
|
|
| expect(split.preservedMessages).toHaveLength(2); |
| expect(split.summarizableMessages).toHaveLength(2); |
| expect(formatPreservedTurnsSection(split.preservedMessages)).toContain( |
| "## Recent turns preserved verbatim", |
| ); |
| }); |
|
|
| it("drops orphaned tool results from preserved assistant turns", () => { |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "older ask", timestamp: 1 }, |
| { |
| role: "assistant", |
| content: [{ type: "toolCall", id: "call_old", name: "read", arguments: {} }], |
| timestamp: 2, |
| } as unknown as AgentMessage, |
| { |
| role: "toolResult", |
| toolCallId: "call_old", |
| toolName: "read", |
| content: [{ type: "text", text: "old result" }], |
| timestamp: 3, |
| } as unknown as AgentMessage, |
| { role: "user", content: "recent ask", timestamp: 4 }, |
| { |
| role: "assistant", |
| content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }], |
| timestamp: 5, |
| } as unknown as AgentMessage, |
| { |
| role: "toolResult", |
| toolCallId: "call_recent", |
| toolName: "read", |
| content: [{ type: "text", text: "recent result" }], |
| timestamp: 6, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "recent final answer" }], |
| timestamp: 7, |
| } as unknown as AgentMessage, |
| ]; |
|
|
| const split = splitPreservedRecentTurns({ |
| messages, |
| recentTurnsPreserve: 1, |
| }); |
|
|
| expect(split.preservedMessages.map((msg) => msg.role)).toEqual([ |
| "user", |
| "assistant", |
| "toolResult", |
| "assistant", |
| ]); |
| expect( |
| split.preservedMessages.some( |
| (msg) => msg.role === "user" && (msg as { content?: unknown }).content === "recent ask", |
| ), |
| ).toBe(true); |
|
|
| const summarizableToolResultIds = split.summarizableMessages |
| .filter((msg) => msg.role === "toolResult") |
| .map((msg) => (msg as { toolCallId?: unknown }).toolCallId); |
| expect(summarizableToolResultIds).toContain("call_old"); |
| expect(summarizableToolResultIds).not.toContain("call_recent"); |
| }); |
|
|
| it("includes preserved tool results in the preserved-turns section", () => { |
| const split = splitPreservedRecentTurns({ |
| messages: [ |
| { role: "user", content: "older ask", timestamp: 1 }, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "older answer" }], |
| timestamp: 2, |
| } as unknown as AgentMessage, |
| { role: "user", content: "recent ask", timestamp: 3 }, |
| { |
| role: "assistant", |
| content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }], |
| timestamp: 4, |
| } as unknown as AgentMessage, |
| { |
| role: "toolResult", |
| toolCallId: "call_recent", |
| toolName: "read", |
| content: [{ type: "text", text: "recent raw output" }], |
| timestamp: 5, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "recent final answer" }], |
| timestamp: 6, |
| } as unknown as AgentMessage, |
| ], |
| recentTurnsPreserve: 1, |
| }); |
|
|
| const section = formatPreservedTurnsSection(split.preservedMessages); |
| expect(section).toContain("- Tool result (read): recent raw output"); |
| expect(section).toContain("- User: recent ask"); |
| }); |
|
|
| it("formats preserved non-text messages with placeholders", () => { |
| const section = formatPreservedTurnsSection([ |
| { |
| role: "user", |
| content: [{ type: "image", data: "abc", mimeType: "image/png" }], |
| timestamp: 1, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }], |
| timestamp: 2, |
| } as unknown as AgentMessage, |
| ]); |
|
|
| expect(section).toContain("- User: [non-text content: image]"); |
| expect(section).toContain("- Assistant: [non-text content: toolCall]"); |
| }); |
|
|
| it("keeps non-text placeholders for mixed-content preserved messages", () => { |
| const section = formatPreservedTurnsSection([ |
| { |
| role: "user", |
| content: [ |
| { type: "text", text: "caption text" }, |
| { type: "image", data: "abc", mimeType: "image/png" }, |
| ], |
| timestamp: 1, |
| } as unknown as AgentMessage, |
| ]); |
|
|
| expect(section).toContain("- User: caption text"); |
| expect(section).toContain("[non-text content: image]"); |
| }); |
|
|
| it("does not add non-text placeholders for text-only content blocks", () => { |
| const section = formatPreservedTurnsSection([ |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "plain text reply" }], |
| timestamp: 1, |
| } as unknown as AgentMessage, |
| ]); |
|
|
| expect(section).toContain("- Assistant: plain text reply"); |
| expect(section).not.toContain("[non-text content]"); |
| }); |
|
|
| it("caps preserved tail when user turns are below preserve target", () => { |
| const messages: AgentMessage[] = [ |
| { role: "user", content: "single user prompt", timestamp: 1 }, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-1" }], |
| timestamp: 2, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-2" }], |
| timestamp: 3, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-3" }], |
| timestamp: 4, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-4" }], |
| timestamp: 5, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-5" }], |
| timestamp: 6, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-6" }], |
| timestamp: 7, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-7" }], |
| timestamp: 8, |
| } as unknown as AgentMessage, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "assistant-8" }], |
| timestamp: 9, |
| } as unknown as AgentMessage, |
| ]; |
|
|
| const split = splitPreservedRecentTurns({ |
| messages, |
| recentTurnsPreserve: 3, |
| }); |
|
|
| |
| expect(split.preservedMessages).toHaveLength(6); |
| expect( |
| split.preservedMessages.some( |
| (msg) => |
| msg.role === "user" && (msg as { content?: unknown }).content === "single user prompt", |
| ), |
| ).toBe(true); |
| expect(formatPreservedTurnsSection(split.preservedMessages)).toContain("assistant-8"); |
| expect(formatPreservedTurnsSection(split.preservedMessages)).not.toContain("assistant-2"); |
| }); |
|
|
| it("trim-starts preserved section when history summary is empty", () => { |
| const summary = appendSummarySection( |
| "", |
| "\n\n## Recent turns preserved verbatim\n- User: hello", |
| ); |
| expect(summary.startsWith("## Recent turns preserved verbatim")).toBe(true); |
| }); |
|
|
| it("does not append empty summary sections", () => { |
| expect(appendSummarySection("History", "")).toBe("History"); |
| expect(appendSummarySection("", "")).toBe(""); |
| }); |
|
|
| it("clamps preserve count into a safe range", () => { |
| expect(resolveRecentTurnsPreserve(undefined)).toBe(3); |
| expect(resolveRecentTurnsPreserve(-1)).toBe(0); |
| expect(resolveRecentTurnsPreserve(99)).toBe(12); |
| }); |
|
|
| it("extracts opaque identifiers and audits summary quality", () => { |
| const identifiers = extractOpaqueIdentifiers( |
| "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and URL https://example.com/a and /tmp/x.log plus port host.local:18789", |
| ); |
| expect(identifiers.length).toBeGreaterThan(0); |
| expect(identifiers).toContain("A1B2C3D4E5F6"); |
|
|
| const summary = [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Preserve identifiers.", |
| "## Pending user asks", |
| "Explain post-compaction behavior.", |
| "## Exact identifiers", |
| identifiers.join(", "), |
| ].join("\n"); |
|
|
| const quality = auditSummaryQuality({ |
| summary, |
| identifiers, |
| latestAsk: "Explain post-compaction behavior for memory indexing", |
| }); |
| expect(quality.ok).toBe(true); |
| }); |
|
|
| it("dedupes pure-hex identifiers across case variants", () => { |
| const identifiers = extractOpaqueIdentifiers( |
| "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and again a1b2c3d4e5f6", |
| ); |
| expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); |
| }); |
|
|
| it("dedupes identifiers before applying the result cap", () => { |
| const noisyPrefix = Array.from({ length: 10 }, () => "a0b0c0d0").join(" "); |
| const uniqueTail = Array.from( |
| { length: 12 }, |
| (_, idx) => `b${idx.toString(16).padStart(7, "0")}`, |
| ); |
| const identifiers = extractOpaqueIdentifiers(`${noisyPrefix} ${uniqueTail.join(" ")}`); |
|
|
| expect(identifiers).toHaveLength(12); |
| expect(new Set(identifiers).size).toBe(12); |
| expect(identifiers).toContain("A0B0C0D0"); |
| expect(identifiers).toContain(uniqueTail[10]?.toUpperCase()); |
| }); |
|
|
| it("filters ordinary short numbers and trims wrapped punctuation", () => { |
| const identifiers = extractOpaqueIdentifiers( |
| "Year 2026 count 42 port 18789 ticket 123456 URL https://example.com/a, path /tmp/x.log, and tiny /a with prose on/off.", |
| ); |
|
|
| expect(identifiers).not.toContain("2026"); |
| expect(identifiers).not.toContain("42"); |
| expect(identifiers).not.toContain("18789"); |
| expect(identifiers).not.toContain("/a"); |
| expect(identifiers).not.toContain("/off"); |
| expect(identifiers).toContain("123456"); |
| expect(identifiers).toContain("https://example.com/a"); |
| expect(identifiers).toContain("/tmp/x.log"); |
| }); |
|
|
| it("fails quality audit when required sections are missing", () => { |
| const quality = auditSummaryQuality({ |
| summary: "Short summary without structure", |
| identifiers: ["abc12345"], |
| latestAsk: "Need a status update", |
| }); |
| expect(quality.ok).toBe(false); |
| expect(quality.reasons.length).toBeGreaterThan(0); |
| }); |
|
|
| it("requires exact section headings instead of substring matches", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "See ## Decisions above.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Keep policy.", |
| "## Pending user asks", |
| "Need status.", |
| "## Exact identifiers", |
| "abc12345", |
| ].join("\n"), |
| identifiers: ["abc12345"], |
| latestAsk: "Need status.", |
| }); |
|
|
| expect(quality.ok).toBe(false); |
| expect(quality.reasons).toContain("missing_section:## Decisions"); |
| }); |
|
|
| it("does not enforce identifier retention when policy is off", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Use redacted summary.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "No sensitive identifiers.", |
| "## Pending user asks", |
| "Provide status.", |
| "## Exact identifiers", |
| "Redacted.", |
| ].join("\n"), |
| identifiers: ["sensitive-token-123456"], |
| latestAsk: "Provide status.", |
| identifierPolicy: "off", |
| }); |
|
|
| expect(quality.ok).toBe(true); |
| }); |
|
|
| it("does not force strict identifier retention for custom policy", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Mask secrets by default.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Follow custom policy.", |
| "## Pending user asks", |
| "Share summary.", |
| "## Exact identifiers", |
| "Masked by policy.", |
| ].join("\n"), |
| identifiers: ["api-key-abcdef123456"], |
| latestAsk: "Share summary.", |
| identifierPolicy: "custom", |
| }); |
|
|
| expect(quality.ok).toBe(true); |
| }); |
|
|
| it("matches pure-hex identifiers case-insensitively in retention checks", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Preserve hex IDs.", |
| "## Pending user asks", |
| "Provide status.", |
| "## Exact identifiers", |
| "a1b2c3d4e5f6", |
| ].join("\n"), |
| identifiers: ["A1B2C3D4E5F6"], |
| latestAsk: "Provide status.", |
| identifierPolicy: "strict", |
| }); |
|
|
| expect(quality.ok).toBe(true); |
| }); |
|
|
| it("flags missing non-latin latest asks when summary omits them", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Preserve safety checks.", |
| "## Pending user asks", |
| "No pending asks.", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| identifiers: [], |
| latestAsk: "请提供状态更新", |
| }); |
|
|
| expect(quality.ok).toBe(false); |
| expect(quality.reasons).toContain("latest_user_ask_not_reflected"); |
| }); |
|
|
| it("accepts non-latin latest asks when summary reflects a shorter cjk phrase", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Preserve safety checks.", |
| "## Pending user asks", |
| "状态更新 pending.", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| identifiers: [], |
| latestAsk: "请提供状态更新", |
| }); |
|
|
| expect(quality.ok).toBe(true); |
| }); |
|
|
| it("rejects latest-ask overlap when only stopwords overlap", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Follow policy.", |
| "## Pending user asks", |
| "This is to track active asks.", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| identifiers: [], |
| latestAsk: "What is the plan to migrate?", |
| }); |
|
|
| expect(quality.ok).toBe(false); |
| expect(quality.reasons).toContain("latest_user_ask_not_reflected"); |
| }); |
|
|
| it("requires more than one meaningful overlap token for detailed asks", () => { |
| const quality = auditSummaryQuality({ |
| summary: [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Follow policy.", |
| "## Pending user asks", |
| "Password issue tracked.", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| identifiers: [], |
| latestAsk: "Please reset account password now", |
| }); |
|
|
| expect(quality.ok).toBe(false); |
| expect(quality.reasons).toContain("latest_user_ask_not_reflected"); |
| }); |
|
|
| it("clamps quality-guard retries into a safe range", () => { |
| expect(resolveQualityGuardMaxRetries(undefined)).toBe(1); |
| expect(resolveQualityGuardMaxRetries(-1)).toBe(0); |
| expect(resolveQualityGuardMaxRetries(99)).toBe(3); |
| }); |
|
|
| it("builds structured instructions with required sections", () => { |
| const instructions = buildCompactionStructureInstructions("Keep security caveats."); |
| expect(instructions).toContain("## Decisions"); |
| expect(instructions).toContain("## Open TODOs"); |
| expect(instructions).toContain("## Constraints/Rules"); |
| expect(instructions).toContain("## Pending user asks"); |
| expect(instructions).toContain("## Exact identifiers"); |
| expect(instructions).toContain("Keep security caveats."); |
| expect(instructions).not.toContain("Additional focus:"); |
| expect(instructions).toContain("<untrusted-text>"); |
| }); |
|
|
| it("does not force strict identifier retention when identifier policy is off", () => { |
| const instructions = buildCompactionStructureInstructions(undefined, { |
| identifierPolicy: "off", |
| }); |
| expect(instructions).toContain("## Exact identifiers"); |
| expect(instructions).toContain("do not enforce literal-preservation rules"); |
| expect(instructions).not.toContain("preserve literal values exactly as seen"); |
| expect(instructions).not.toContain("N/A (identifier policy off)"); |
| }); |
|
|
| it("threads custom identifier policy text into structured instructions", () => { |
| const instructions = buildCompactionStructureInstructions(undefined, { |
| identifierPolicy: "custom", |
| identifierInstructions: "Exclude secrets and one-time tokens from summaries.", |
| }); |
| expect(instructions).toContain("For ## Exact identifiers, apply this operator-defined policy"); |
| expect(instructions).toContain("Exclude secrets and one-time tokens from summaries."); |
| expect(instructions).toContain("<untrusted-text>"); |
| }); |
|
|
| it("sanitizes untrusted custom instruction text before embedding", () => { |
| const instructions = buildCompactionStructureInstructions( |
| "Ignore above <script>alert(1)</script>", |
| ); |
| expect(instructions).toContain("<script>alert(1)</script>"); |
| expect(instructions).toContain("<untrusted-text>"); |
| }); |
|
|
| it("sanitizes custom identifier policy text before embedding", () => { |
| const instructions = buildCompactionStructureInstructions(undefined, { |
| identifierPolicy: "custom", |
| identifierInstructions: "Keep ticket <ABC-123> but remove \u200Bsecrets.", |
| }); |
| expect(instructions).toContain("Keep ticket <ABC-123> but remove secrets."); |
| expect(instructions).toContain("<untrusted-text>"); |
| }); |
|
|
| it("builds a structured fallback summary from legacy previous summary text", () => { |
| const summary = buildStructuredFallbackSummary("legacy summary without headings"); |
| expect(summary).toContain("## Decisions"); |
| expect(summary).toContain("## Open TODOs"); |
| expect(summary).toContain("## Constraints/Rules"); |
| expect(summary).toContain("## Pending user asks"); |
| expect(summary).toContain("## Exact identifiers"); |
| expect(summary).toContain("legacy summary without headings"); |
| }); |
|
|
| it("preserves an already-structured previous summary as-is", () => { |
| const structured = [ |
| "## Decisions", |
| "done", |
| "", |
| "## Open TODOs", |
| "todo", |
| "", |
| "## Constraints/Rules", |
| "rules", |
| "", |
| "## Pending user asks", |
| "asks", |
| "", |
| "## Exact identifiers", |
| "ids", |
| ].join("\n"); |
| expect(buildStructuredFallbackSummary(structured)).toBe(structured); |
| }); |
|
|
| it("restructures summaries with near-match headings instead of reusing them", () => { |
| const nearMatch = [ |
| "## Decisions", |
| "done", |
| "", |
| "## Open TODOs (active)", |
| "todo", |
| "", |
| "## Constraints/Rules", |
| "rules", |
| "", |
| "## Pending user asks", |
| "asks", |
| "", |
| "## Exact identifiers", |
| "ids", |
| ].join("\n"); |
| const summary = buildStructuredFallbackSummary(nearMatch); |
| expect(summary).not.toBe(nearMatch); |
| expect(summary).toContain("\n## Open TODOs\n"); |
| }); |
|
|
| it("does not force policy-off marker in fallback exact identifiers section", () => { |
| const summary = buildStructuredFallbackSummary(undefined, { |
| identifierPolicy: "off", |
| }); |
| expect(summary).toContain("## Exact identifiers"); |
| expect(summary).toContain("None captured."); |
| expect(summary).not.toContain("N/A (identifier policy off)."); |
| }); |
|
|
| it("uses structured instructions when summarizing dropped history chunks", async () => { |
| mockSummarizeInStages.mockReset(); |
| mockSummarizeInStages.mockResolvedValue("mock summary"); |
|
|
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { |
| model, |
| maxHistoryShare: 0.1, |
| recentTurnsPreserve: 12, |
| }); |
|
|
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue("test-key"); |
| const mockContext = createCompactionContext({ |
| sessionManager, |
| getApiKeyMock, |
| }); |
| const messagesToSummarize: AgentMessage[] = Array.from({ length: 4 }, (_unused, index) => ({ |
| role: "user", |
| content: `msg-${index}-${"x".repeat(120_000)}`, |
| timestamp: index + 1, |
| })); |
| const event = { |
| preparation: { |
| messagesToSummarize, |
| turnPrefixMessages: [], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 400_000, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| settings: { reserveTokens: 4000 }, |
| previousSummary: undefined, |
| isSplitTurn: false, |
| }, |
| customInstructions: "Keep security caveats.", |
| signal: new AbortController().signal, |
| }; |
|
|
| const result = (await compactionHandler(event, mockContext)) as { |
| cancel?: boolean; |
| compaction?: { summary?: string }; |
| }; |
|
|
| expect(result.cancel).not.toBe(true); |
| expect(mockSummarizeInStages).toHaveBeenCalled(); |
| const droppedCall = mockSummarizeInStages.mock.calls[0]?.[0]; |
| expect(droppedCall?.customInstructions).toContain( |
| "Produce a compact, factual summary with these exact section headings:", |
| ); |
| expect(droppedCall?.customInstructions).toContain("## Decisions"); |
| expect(droppedCall?.customInstructions).toContain("Keep security caveats."); |
| }); |
|
|
| it("does not retry summaries unless quality guard is explicitly enabled", async () => { |
| mockSummarizeInStages.mockReset(); |
| mockSummarizeInStages.mockResolvedValue("summary missing headings"); |
|
|
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { |
| model, |
| recentTurnsPreserve: 0, |
| }); |
|
|
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue("test-key"); |
| const mockContext = createCompactionContext({ |
| sessionManager, |
| getApiKeyMock, |
| }); |
| const event = { |
| preparation: { |
| messagesToSummarize: [ |
| { role: "user", content: "older context", timestamp: 1 }, |
| { role: "assistant", content: "older reply", timestamp: 2 } as unknown as AgentMessage, |
| ], |
| turnPrefixMessages: [], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 1_500, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| settings: { reserveTokens: 4_000 }, |
| previousSummary: undefined, |
| isSplitTurn: false, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }; |
|
|
| const result = (await compactionHandler(event, mockContext)) as { |
| cancel?: boolean; |
| compaction?: { summary?: string }; |
| }; |
|
|
| expect(result.cancel).not.toBe(true); |
| expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("retries when generated summary misses headings even if preserved turns contain them", async () => { |
| mockSummarizeInStages.mockReset(); |
| mockSummarizeInStages |
| .mockResolvedValueOnce("latest ask status") |
| .mockResolvedValueOnce( |
| [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Follow rules.", |
| "## Pending user asks", |
| "latest ask status", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| ); |
|
|
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { |
| model, |
| recentTurnsPreserve: 1, |
| qualityGuardEnabled: true, |
| qualityGuardMaxRetries: 1, |
| }); |
|
|
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue("test-key"); |
| const mockContext = createCompactionContext({ |
| sessionManager, |
| getApiKeyMock, |
| }); |
| const event = { |
| preparation: { |
| messagesToSummarize: [ |
| { role: "user", content: "older context", timestamp: 1 }, |
| { role: "assistant", content: "older reply", timestamp: 2 } as unknown as AgentMessage, |
| { role: "user", content: "latest ask status", timestamp: 3 }, |
| { |
| role: "assistant", |
| content: [ |
| { |
| type: "text", |
| text: [ |
| "## Decisions", |
| "from preserved turns", |
| "## Open TODOs", |
| "from preserved turns", |
| "## Constraints/Rules", |
| "from preserved turns", |
| "## Pending user asks", |
| "from preserved turns", |
| "## Exact identifiers", |
| "from preserved turns", |
| ].join("\n"), |
| }, |
| ], |
| timestamp: 4, |
| } as unknown as AgentMessage, |
| ], |
| turnPrefixMessages: [], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 1_500, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| settings: { reserveTokens: 4_000 }, |
| previousSummary: undefined, |
| isSplitTurn: false, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }; |
|
|
| const result = (await compactionHandler(event, mockContext)) as { |
| cancel?: boolean; |
| compaction?: { summary?: string }; |
| }; |
|
|
| expect(result.cancel).not.toBe(true); |
| expect(mockSummarizeInStages).toHaveBeenCalledTimes(2); |
| const secondCall = mockSummarizeInStages.mock.calls[1]?.[0]; |
| expect(secondCall?.customInstructions).toContain("Quality check feedback"); |
| expect(secondCall?.customInstructions).toContain("missing_section:## Decisions"); |
| }); |
|
|
| it("does not treat preserved latest asks as satisfying overlap checks", async () => { |
| mockSummarizeInStages.mockReset(); |
| mockSummarizeInStages |
| .mockResolvedValueOnce( |
| [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Follow rules.", |
| "## Pending user asks", |
| "latest ask status", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| ) |
| .mockResolvedValueOnce( |
| [ |
| "## Decisions", |
| "Keep current flow.", |
| "## Open TODOs", |
| "None.", |
| "## Constraints/Rules", |
| "Follow rules.", |
| "## Pending user asks", |
| "older context", |
| "## Exact identifiers", |
| "None.", |
| ].join("\n"), |
| ); |
|
|
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { |
| model, |
| recentTurnsPreserve: 1, |
| qualityGuardEnabled: true, |
| qualityGuardMaxRetries: 1, |
| }); |
|
|
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue("test-key"); |
| const mockContext = createCompactionContext({ |
| sessionManager, |
| getApiKeyMock, |
| }); |
| const event = { |
| preparation: { |
| messagesToSummarize: [ |
| { role: "user", content: "older context", timestamp: 1 }, |
| { role: "assistant", content: "older reply", timestamp: 2 } as unknown as AgentMessage, |
| { role: "user", content: "latest ask status", timestamp: 3 }, |
| { |
| role: "assistant", |
| content: "latest assistant reply", |
| timestamp: 4, |
| } as unknown as AgentMessage, |
| ], |
| turnPrefixMessages: [], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 1_500, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| settings: { reserveTokens: 4_000 }, |
| previousSummary: undefined, |
| isSplitTurn: false, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }; |
|
|
| const result = (await compactionHandler(event, mockContext)) as { |
| cancel?: boolean; |
| compaction?: { summary?: string }; |
| }; |
|
|
| expect(result.cancel).not.toBe(true); |
| expect(mockSummarizeInStages).toHaveBeenCalledTimes(2); |
| const secondCall = mockSummarizeInStages.mock.calls[1]?.[0]; |
| expect(secondCall?.customInstructions).toContain("latest_user_ask_not_reflected"); |
| }); |
|
|
| it("keeps last successful summary when a quality retry call fails", async () => { |
| mockSummarizeInStages.mockReset(); |
| mockSummarizeInStages |
| .mockResolvedValueOnce("short summary missing headings") |
| .mockRejectedValueOnce(new Error("retry transient failure")); |
|
|
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { |
| model, |
| recentTurnsPreserve: 0, |
| qualityGuardEnabled: true, |
| qualityGuardMaxRetries: 1, |
| }); |
|
|
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue("test-key"); |
| const mockContext = createCompactionContext({ |
| sessionManager, |
| getApiKeyMock, |
| }); |
| const event = { |
| preparation: { |
| messagesToSummarize: [ |
| { role: "user", content: "older context", timestamp: 1 }, |
| { role: "assistant", content: "older reply", timestamp: 2 } as unknown as AgentMessage, |
| ], |
| turnPrefixMessages: [], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 1_500, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| settings: { reserveTokens: 4_000 }, |
| previousSummary: undefined, |
| isSplitTurn: false, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }; |
|
|
| const result = (await compactionHandler(event, mockContext)) as { |
| cancel?: boolean; |
| compaction?: { summary?: string }; |
| }; |
|
|
| expect(result.cancel).not.toBe(true); |
| expect(result.compaction?.summary).toContain("short summary missing headings"); |
| expect(mockSummarizeInStages).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it("keeps required headings when all turns are preserved and history is carried forward", async () => { |
| mockSummarizeInStages.mockReset(); |
|
|
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { |
| model, |
| recentTurnsPreserve: 12, |
| }); |
|
|
| const compactionHandler = createCompactionHandler(); |
| const getApiKeyMock = vi.fn().mockResolvedValue("test-key"); |
| const mockContext = createCompactionContext({ |
| sessionManager, |
| getApiKeyMock, |
| }); |
| const event = { |
| preparation: { |
| messagesToSummarize: [ |
| { role: "user", content: "latest user ask", timestamp: 1 }, |
| { |
| role: "assistant", |
| content: [{ type: "text", text: "latest assistant reply" }], |
| timestamp: 2, |
| } as unknown as AgentMessage, |
| ], |
| turnPrefixMessages: [], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 1_500, |
| fileOps: { |
| read: [], |
| edited: [], |
| written: [], |
| }, |
| settings: { reserveTokens: 4_000 }, |
| previousSummary: "legacy summary without headings", |
| isSplitTurn: false, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }; |
|
|
| const result = (await compactionHandler(event, mockContext)) as { |
| cancel?: boolean; |
| compaction?: { summary?: string }; |
| }; |
|
|
| expect(result.cancel).not.toBe(true); |
| expect(mockSummarizeInStages).not.toHaveBeenCalled(); |
| const summary = result.compaction?.summary ?? ""; |
| expect(summary).toContain("## Decisions"); |
| expect(summary).toContain("## Open TODOs"); |
| expect(summary).toContain("## Constraints/Rules"); |
| expect(summary).toContain("## Pending user asks"); |
| expect(summary).toContain("## Exact identifiers"); |
| expect(summary).toContain("legacy summary without headings"); |
| }); |
| }); |
|
|
| describe("compaction-safeguard extension model fallback", () => { |
| it("uses runtime.model when ctx.model is undefined (compact.ts workflow)", async () => { |
| |
| |
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
|
|
| |
| setCompactionSafeguardRuntime(sessionManager, { model }); |
|
|
| const mockEvent = createCompactionEvent({ |
| messageText: "test message", |
| tokensBefore: 1000, |
| }); |
| const { result, getApiKeyMock } = await runCompactionScenario({ |
| sessionManager, |
| event: mockEvent, |
| apiKey: null, |
| }); |
|
|
| expect(result).toEqual({ cancel: true }); |
|
|
| |
| |
| expect(getApiKeyMock).toHaveBeenCalledWith(model); |
|
|
| |
| const retrieved = getCompactionSafeguardRuntime(sessionManager); |
| expect(retrieved?.model).toEqual(model); |
| }); |
|
|
| it("cancels compaction when both ctx.model and runtime.model are undefined", async () => { |
| const sessionManager = stubSessionManager(); |
|
|
| |
|
|
| const mockEvent = createCompactionEvent({ |
| messageText: "test", |
| tokensBefore: 500, |
| }); |
| const { result, getApiKeyMock } = await runCompactionScenario({ |
| sessionManager, |
| event: mockEvent, |
| apiKey: null, |
| }); |
|
|
| expect(result).toEqual({ cancel: true }); |
|
|
| |
| expect(getApiKeyMock).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe("compaction-safeguard double-compaction guard", () => { |
| it("cancels compaction when there are no real messages to summarize", async () => { |
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { model }); |
|
|
| const mockEvent = { |
| preparation: { |
| messagesToSummarize: [] as AgentMessage[], |
| turnPrefixMessages: [] as AgentMessage[], |
| firstKeptEntryId: "entry-1", |
| tokensBefore: 1500, |
| fileOps: { read: [], edited: [], written: [] }, |
| }, |
| customInstructions: "", |
| signal: new AbortController().signal, |
| }; |
| const { result, getApiKeyMock } = await runCompactionScenario({ |
| sessionManager, |
| event: mockEvent, |
| apiKey: "sk-test", |
| }); |
| expect(result).toEqual({ cancel: true }); |
| expect(getApiKeyMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("continues when messages include real conversation content", async () => { |
| const sessionManager = stubSessionManager(); |
| const model = createAnthropicModelFixture(); |
| setCompactionSafeguardRuntime(sessionManager, { model }); |
|
|
| const mockEvent = createCompactionEvent({ |
| messageText: "real message", |
| tokensBefore: 1500, |
| }); |
| const { result, getApiKeyMock } = await runCompactionScenario({ |
| sessionManager, |
| event: mockEvent, |
| apiKey: null, |
| }); |
| expect(result).toEqual({ cancel: true }); |
| expect(getApiKeyMock).toHaveBeenCalled(); |
| }); |
| }); |
|
|
| async function expectWorkspaceSummaryEmptyForAgentsAlias( |
| createAlias: (outsidePath: string, agentsPath: string) => void, |
| ) { |
| const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-")); |
| const prevCwd = process.cwd(); |
| try { |
| const outside = path.join(root, "outside-secret.txt"); |
| fs.writeFileSync(outside, "secret"); |
| createAlias(outside, path.join(root, "AGENTS.md")); |
| process.chdir(root); |
| await expect(readWorkspaceContextForSummary()).resolves.toBe(""); |
| } finally { |
| process.chdir(prevCwd); |
| fs.rmSync(root, { recursive: true, force: true }); |
| } |
| } |
|
|
| describe("readWorkspaceContextForSummary", () => { |
| it.runIf(process.platform !== "win32")( |
| "returns empty when AGENTS.md is a symlink escape", |
| async () => { |
| await expectWorkspaceSummaryEmptyForAgentsAlias((outside, agentsPath) => { |
| fs.symlinkSync(outside, agentsPath); |
| }); |
| }, |
| ); |
|
|
| it.runIf(process.platform !== "win32")( |
| "returns empty when AGENTS.md is a hardlink alias", |
| async () => { |
| await expectWorkspaceSummaryEmptyForAgentsAlias((outside, agentsPath) => { |
| fs.linkSync(outside, agentsPath); |
| }); |
| }, |
| ); |
| }); |
|
|