Spaces:
Paused
Paused
| import type { AgentMessage } from "@mariozechner/pi-agent-core"; | |
| import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; | |
| import { describe, expect, it } from "vitest"; | |
| import { | |
| computeEffectiveSettings, | |
| default as contextPruningExtension, | |
| DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| pruneContextMessages, | |
| } from "./context-pruning.js"; | |
| import { getContextPruningRuntime, setContextPruningRuntime } from "./context-pruning/runtime.js"; | |
| function toolText(msg: AgentMessage): string { | |
| if (msg.role !== "toolResult") { | |
| throw new Error("expected toolResult"); | |
| } | |
| const first = msg.content.find((b) => b.type === "text"); | |
| if (!first || first.type !== "text") { | |
| return ""; | |
| } | |
| return first.text; | |
| } | |
| function findToolResult(messages: AgentMessage[], toolCallId: string): AgentMessage { | |
| const msg = messages.find((m) => m.role === "toolResult" && m.toolCallId === toolCallId); | |
| if (!msg) { | |
| throw new Error(`missing toolResult: ${toolCallId}`); | |
| } | |
| return msg; | |
| } | |
| function makeToolResult(params: { | |
| toolCallId: string; | |
| toolName: string; | |
| text: string; | |
| }): AgentMessage { | |
| return { | |
| role: "toolResult", | |
| toolCallId: params.toolCallId, | |
| toolName: params.toolName, | |
| content: [{ type: "text", text: params.text }], | |
| isError: false, | |
| timestamp: Date.now(), | |
| }; | |
| } | |
| function makeImageToolResult(params: { | |
| toolCallId: string; | |
| toolName: string; | |
| text: string; | |
| }): AgentMessage { | |
| return { | |
| role: "toolResult", | |
| toolCallId: params.toolCallId, | |
| toolName: params.toolName, | |
| content: [ | |
| { type: "image", data: "AA==", mimeType: "image/png" }, | |
| { type: "text", text: params.text }, | |
| ], | |
| isError: false, | |
| timestamp: Date.now(), | |
| }; | |
| } | |
| function makeAssistant(text: string): AgentMessage { | |
| return { | |
| role: "assistant", | |
| content: [{ type: "text", text }], | |
| api: "openai-responses", | |
| provider: "openai", | |
| model: "fake", | |
| usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, total: 2 }, | |
| stopReason: "stop", | |
| timestamp: Date.now(), | |
| }; | |
| } | |
| function makeUser(text: string): AgentMessage { | |
| return { role: "user", content: text, timestamp: Date.now() }; | |
| } | |
| describe("context-pruning", () => { | |
| it("mode off disables pruning", () => { | |
| expect(computeEffectiveSettings({ mode: "off" })).toBeNull(); | |
| expect(computeEffectiveSettings({})).toBeNull(); | |
| }); | |
| it("does not touch tool results after the last N assistants", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeAssistant("a1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| makeUser("u2"), | |
| makeAssistant("a2"), | |
| makeToolResult({ | |
| toolCallId: "t2", | |
| toolName: "exec", | |
| text: "y".repeat(20_000), | |
| }), | |
| makeUser("u3"), | |
| makeAssistant("a3"), | |
| makeToolResult({ | |
| toolCallId: "t3", | |
| toolName: "exec", | |
| text: "z".repeat(20_000), | |
| }), | |
| makeUser("u4"), | |
| makeAssistant("a4"), | |
| makeToolResult({ | |
| toolCallId: "t4", | |
| toolName: "exec", | |
| text: "w".repeat(20_000), | |
| }), | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 3, | |
| softTrimRatio: 0.0, | |
| hardClearRatio: 0.0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }; | |
| const ctx = { | |
| model: { contextWindow: 1000 }, | |
| } as unknown as ExtensionContext; | |
| const next = pruneContextMessages({ messages, settings, ctx }); | |
| expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000)); | |
| expect(toolText(findToolResult(next, "t3"))).toContain("z".repeat(20_000)); | |
| expect(toolText(findToolResult(next, "t4"))).toContain("w".repeat(20_000)); | |
| expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); | |
| }); | |
| it("never prunes tool results before the first user message", () => { | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0.0, | |
| hardClearRatio: 0.0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }; | |
| const messages: AgentMessage[] = [ | |
| makeAssistant("bootstrap tool calls"), | |
| makeToolResult({ | |
| toolCallId: "t0", | |
| toolName: "read", | |
| text: "x".repeat(20_000), | |
| }), | |
| makeAssistant("greeting"), | |
| makeUser("u1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "y".repeat(20_000), | |
| }), | |
| ]; | |
| const next = pruneContextMessages({ | |
| messages, | |
| settings, | |
| ctx: { model: { contextWindow: 1000 } } as unknown as ExtensionContext, | |
| isToolPrunable: () => true, | |
| contextWindowTokensOverride: 1000, | |
| }); | |
| expect(toolText(findToolResult(next, "t0"))).toBe("x".repeat(20_000)); | |
| expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); | |
| }); | |
| it("hard-clear removes eligible tool results before cutoff", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeAssistant("a1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| makeToolResult({ | |
| toolCallId: "t2", | |
| toolName: "exec", | |
| text: "y".repeat(20_000), | |
| }), | |
| makeUser("u2"), | |
| makeAssistant("a2"), | |
| makeToolResult({ | |
| toolCallId: "t3", | |
| toolName: "exec", | |
| text: "z".repeat(20_000), | |
| }), | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 1, | |
| softTrimRatio: 10.0, | |
| hardClearRatio: 0.0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| }; | |
| const ctx = { | |
| model: { contextWindow: 1000 }, | |
| } as unknown as ExtensionContext; | |
| const next = pruneContextMessages({ messages, settings, ctx }); | |
| expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); | |
| expect(toolText(findToolResult(next, "t2"))).toBe("[cleared]"); | |
| // Tool results after the last assistant are protected. | |
| expect(toolText(findToolResult(next, "t3"))).toContain("z".repeat(20_000)); | |
| }); | |
| it("uses contextWindow override when ctx.model is missing", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeAssistant("a1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| makeAssistant("a2"), | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0, | |
| hardClearRatio: 0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }; | |
| const next = pruneContextMessages({ | |
| messages, | |
| settings, | |
| ctx: { model: undefined } as unknown as ExtensionContext, | |
| contextWindowTokensOverride: 1000, | |
| }); | |
| expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]"); | |
| }); | |
| it("reads per-session settings from registry", async () => { | |
| const sessionManager = {}; | |
| setContextPruningRuntime(sessionManager, { | |
| settings: { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0, | |
| hardClearRatio: 0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }, | |
| contextWindowTokens: 1000, | |
| isToolPrunable: () => true, | |
| lastCacheTouchAt: Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000, | |
| }); | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeAssistant("a1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| makeAssistant("a2"), | |
| ]; | |
| let handler: | |
| | (( | |
| event: { messages: AgentMessage[] }, | |
| ctx: ExtensionContext, | |
| ) => { messages: AgentMessage[] } | undefined) | |
| | undefined; | |
| const api = { | |
| on: (name: string, fn: unknown) => { | |
| if (name === "context") { | |
| handler = fn as typeof handler; | |
| } | |
| }, | |
| appendEntry: (_type: string, _data?: unknown) => {}, | |
| } as unknown as ExtensionAPI; | |
| contextPruningExtension(api); | |
| if (!handler) { | |
| throw new Error("missing context handler"); | |
| } | |
| const result = handler({ messages }, { | |
| model: undefined, | |
| sessionManager, | |
| } as unknown as ExtensionContext); | |
| if (!result) { | |
| throw new Error("expected handler to return messages"); | |
| } | |
| expect(toolText(findToolResult(result.messages, "t1"))).toBe("[cleared]"); | |
| }); | |
| it("cache-ttl prunes once and resets the ttl window", () => { | |
| const sessionManager = {}; | |
| const lastTouch = Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000; | |
| setContextPruningRuntime(sessionManager, { | |
| settings: { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0, | |
| hardClearRatio: 0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }, | |
| contextWindowTokens: 1000, | |
| isToolPrunable: () => true, | |
| lastCacheTouchAt: lastTouch, | |
| }); | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeAssistant("a1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| ]; | |
| let handler: | |
| | (( | |
| event: { messages: AgentMessage[] }, | |
| ctx: ExtensionContext, | |
| ) => { messages: AgentMessage[] } | undefined) | |
| | undefined; | |
| const api = { | |
| on: (name: string, fn: unknown) => { | |
| if (name === "context") { | |
| handler = fn as typeof handler; | |
| } | |
| }, | |
| appendEntry: (_type: string, _data?: unknown) => {}, | |
| } as unknown as ExtensionAPI; | |
| contextPruningExtension(api); | |
| if (!handler) { | |
| throw new Error("missing context handler"); | |
| } | |
| const first = handler({ messages }, { | |
| model: undefined, | |
| sessionManager, | |
| } as unknown as ExtensionContext); | |
| if (!first) { | |
| throw new Error("expected first prune"); | |
| } | |
| expect(toolText(findToolResult(first.messages, "t1"))).toBe("[cleared]"); | |
| const runtime = getContextPruningRuntime(sessionManager); | |
| if (!runtime?.lastCacheTouchAt) { | |
| throw new Error("expected lastCacheTouchAt"); | |
| } | |
| expect(runtime.lastCacheTouchAt).toBeGreaterThan(lastTouch); | |
| const second = handler({ messages }, { | |
| model: undefined, | |
| sessionManager, | |
| } as unknown as ExtensionContext); | |
| expect(second).toBeUndefined(); | |
| }); | |
| it("respects tools allow/deny (deny wins; wildcards supported)", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "Exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| makeToolResult({ | |
| toolCallId: "t2", | |
| toolName: "Browser", | |
| text: "y".repeat(20_000), | |
| }), | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0.0, | |
| hardClearRatio: 0.0, | |
| minPrunableToolChars: 0, | |
| tools: { allow: ["ex*"], deny: ["exec"] }, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }; | |
| const ctx = { | |
| model: { contextWindow: 1000 }, | |
| } as unknown as ExtensionContext; | |
| const next = pruneContextMessages({ messages, settings, ctx }); | |
| // Deny wins => exec is not pruned, even though allow matches. | |
| expect(toolText(findToolResult(next, "t1"))).toContain("x".repeat(20_000)); | |
| // allow is non-empty and browser is not allowed => never pruned. | |
| expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000)); | |
| }); | |
| it("skips tool results that contain images (no soft trim, no hard clear)", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeImageToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "x".repeat(20_000), | |
| }), | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0.0, | |
| hardClearRatio: 0.0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 3, tailChars: 3 }, | |
| }; | |
| const ctx = { | |
| model: { contextWindow: 1000 }, | |
| } as unknown as ExtensionContext; | |
| const next = pruneContextMessages({ messages, settings, ctx }); | |
| const tool = findToolResult(next, "t1"); | |
| if (!tool || tool.role !== "toolResult") { | |
| throw new Error("unexpected pruned message list shape"); | |
| } | |
| expect(tool.content.some((b) => b.type === "image")).toBe(true); | |
| expect(toolText(tool)).toContain("x".repeat(20_000)); | |
| }); | |
| it("soft-trims across block boundaries", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| { | |
| role: "toolResult", | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| content: [ | |
| { type: "text", text: "AAAAA" }, | |
| { type: "text", text: "BBBBB" }, | |
| ], | |
| isError: false, | |
| timestamp: Date.now(), | |
| } as unknown as AgentMessage, | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0.0, | |
| hardClearRatio: 10.0, | |
| softTrim: { maxChars: 5, headChars: 7, tailChars: 3 }, | |
| }; | |
| const ctx = { | |
| model: { contextWindow: 1000 }, | |
| } as unknown as ExtensionContext; | |
| const next = pruneContextMessages({ messages, settings, ctx }); | |
| const text = toolText(findToolResult(next, "t1")); | |
| expect(text).toContain("AAAAA\nB"); | |
| expect(text).toContain("BBB"); | |
| expect(text).toContain("[Tool result trimmed:"); | |
| }); | |
| it("soft-trims oversized tool results and preserves head/tail with a note", () => { | |
| const messages: AgentMessage[] = [ | |
| makeUser("u1"), | |
| makeToolResult({ | |
| toolCallId: "t1", | |
| toolName: "exec", | |
| text: "abcdefghij".repeat(1000), | |
| }), | |
| ]; | |
| const settings = { | |
| ...DEFAULT_CONTEXT_PRUNING_SETTINGS, | |
| keepLastAssistants: 0, | |
| softTrimRatio: 0.0, | |
| hardClearRatio: 10.0, | |
| minPrunableToolChars: 0, | |
| hardClear: { enabled: true, placeholder: "[cleared]" }, | |
| softTrim: { maxChars: 10, headChars: 6, tailChars: 6 }, | |
| }; | |
| const ctx = { | |
| model: { contextWindow: 1000 }, | |
| } as unknown as ExtensionContext; | |
| const next = pruneContextMessages({ messages, settings, ctx }); | |
| const tool = findToolResult(next, "t1"); | |
| const text = toolText(tool); | |
| expect(text).toContain("abcdef"); | |
| expect(text).toContain("efghij"); | |
| expect(text).toContain("[Tool result trimmed:"); | |
| }); | |
| }); | |