diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e15a973c6f..4fad88abcc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras. - Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write. - Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted. +- Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI. ## 2026.3.11 diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts index 2d0e8900b8c..c18d439e632 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts @@ -257,6 +257,14 @@ const testModel = { input: ["text"], } as unknown as Model; +const cacheTtlEligibleModel = { + api: "anthropic", + provider: "anthropic", + compat: {}, + contextWindow: 8192, + input: ["text"], +} as unknown as Model; + describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { const tempPaths: string[] = []; @@ -382,6 +390,123 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => { }); }); +describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => { + const tempPaths: string[] = []; + + beforeEach(() => { + hoisted.createAgentSessionMock.mockReset(); + hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); + hoisted.resolveSandboxContextMock.mockReset(); + hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({ + release: async () => {}, + }); + hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); + hoisted.sessionManager.branch.mockReset(); + hoisted.sessionManager.resetLeaf.mockReset(); + hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] }); + hoisted.sessionManager.appendCustomEntry.mockReset(); + }); + + afterEach(async () => { + while (tempPaths.length > 0) { + const target = tempPaths.pop(); + if (target) { + await fs.rm(target, { recursive: true, force: true }); + } + } + }); + + async function runAttemptWithCacheTtl(compactionCount: number) { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cache-ttl-workspace-")); + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cache-ttl-agent-")); + const sessionFile = path.join(workspaceDir, "session.jsonl"); + tempPaths.push(workspaceDir, agentDir); + await fs.writeFile(sessionFile, "", "utf8"); + + hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(() => ({ + ...createSubscriptionMock(), + getCompactionCount: () => compactionCount, + })); + + hoisted.createAgentSessionMock.mockImplementation(async () => { + const session: MutableSession = { + sessionId: "embedded-session", + messages: [], + isCompacting: false, + isStreaming: false, + agent: { + replaceMessages: (messages: unknown[]) => { + session.messages = [...messages]; + }, + }, + prompt: async () => { + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + abort: async () => {}, + dispose: () => {}, + steer: async () => {}, + }; + + return { session }; + }); + + return await runEmbeddedAttempt({ + sessionId: "embedded-session", + sessionKey: "agent:main:test-cache-ttl", + sessionFile, + workspaceDir, + agentDir, + config: { + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + }, + }, + }, + }, + prompt: "hello", + timeoutMs: 10_000, + runId: `run-cache-ttl-${compactionCount}`, + provider: "anthropic", + modelId: "claude-sonnet-4-20250514", + model: cacheTtlEligibleModel, + authStorage: {} as AuthStorage, + modelRegistry: {} as ModelRegistry, + thinkLevel: "off", + senderIsOwner: true, + disableMessageTool: true, + }); + } + + it("skips cache-ttl append when compaction completed during the attempt", async () => { + const result = await runAttemptWithCacheTtl(1); + + expect(result.promptError).toBeNull(); + expect(hoisted.sessionManager.appendCustomEntry).not.toHaveBeenCalledWith( + "openclaw.cache-ttl", + expect.anything(), + ); + }); + + it("appends cache-ttl when no compaction completed during the attempt", async () => { + const result = await runAttemptWithCacheTtl(0); + + expect(result.promptError).toBeNull(); + expect(hoisted.sessionManager.appendCustomEntry).toHaveBeenCalledWith( + "openclaw.cache-ttl", + expect.objectContaining({ + provider: "anthropic", + modelId: "claude-sonnet-4-20250514", + timestamp: expect.any(Number), + }), + ); + }); +}); + describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { const tempPaths: string[] = []; const sessionKey = "agent:main:discord:channel:test-ctx-engine";