From 22f2c1947bc14eb1e6a31843d4607a480b99cb37 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 13 Jul 2026 01:40:18 +0200 Subject: [PATCH] test(session): validated auto-compaction guard and recovery behavior - Added test coverage for auto-compaction entry warning attribution during dead-end scenarios. - Verified automatic continuation and warning suppression when image-drop recovery successfully clears context pressure. - Mocked session context usage and image-drop functionality to simulate high-usage state recovery. --- .../src/session/session-context.test.ts | 15 +++-- ...ion-auto-compaction-progress-guard.test.ts | 64 +++++++++++++++++++ .../test/compaction-lifecycle.test.ts | 3 + 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/session/session-context.test.ts b/packages/coding-agent/src/session/session-context.test.ts index 4744c69cb..914434999 100644 --- a/packages/coding-agent/src/session/session-context.test.ts +++ b/packages/coding-agent/src/session/session-context.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import * as snapcompact from "@oh-my-pi/snapcompact"; import type { CompactionSummaryMessage } from "./messages"; -import { buildSessionContext } from "./session-context"; +import { buildSessionContext, type StrippedToolCallsMarker } from "./session-context"; import type { SessionEntry } from "./session-entries"; const timestamp = "2026-07-09T00:00:00.000Z"; @@ -129,12 +129,16 @@ function danglingCallIds(messages: AgentMessage[]): string[] { } describe("buildSessionContext dangling toolCalls", () => { - it("strips a dangling toolCall-only assistant turn from the transcript by default", () => { + it("strips a dangling toolCall from the transcript but keeps the turn with a stripped marker", () => { const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { transcript: true }); expect(danglingCallIds(context.messages)).toEqual([]); - // The turn had nothing but the dangling call, so the whole message drops. - expect(context.messages.some(message => message.role === "assistant")).toBe(false); + // The turn survives (even content-less) carrying the marker so the TUI + // renders a placeholder row instead of silently erasing the activity. + const assistant = context.messages.find(message => message.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant?.content).toEqual([]); + expect((assistant as AgentMessage & StrippedToolCallsMarker).strippedToolCalls).toBe(1); }); it("keeps a dangling toolCall in transcript mode with keepDanglingToolCalls", () => { @@ -146,11 +150,12 @@ describe("buildSessionContext dangling toolCalls", () => { expect(danglingCallIds(context.messages)).toEqual(["call-1"]); }); - it("always strips dangling toolCalls from the LLM context", () => { + it("always strips dangling toolCalls from the LLM context and drops the emptied turn", () => { const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { keepDanglingToolCalls: true, }); expect(danglingCallIds(context.messages)).toEqual([]); + expect(context.messages.some(message => message.role === "assistant")).toBe(false); }); }); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index f90ced886..f39cec861 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -11,6 +11,7 @@ import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensio import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { CompactionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; @@ -1230,5 +1231,68 @@ describe("AgentSession auto-compaction progress guard", () => { expect(noProgress[0].level).toBe("warning"); const recovery = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes("dead-end recovery")); expect(recovery.length).toBe(0); + // The dead-end is also stamped on the compaction entry so the transcript + // divider badges the pause and carries the warning across rebuilds/resume. + const compactionEntry = sessionManager + .getEntries() + .filter((e): e is CompactionEntry => e.type === "compaction") + .at(-1); + expect(compactionEntry?.warning).toContain(NO_PROGRESS_FRAGMENT); + }); + + it("auto-continues (no warning) when the image-drop tier frees an image-only tail", async () => { + // Elide cannot touch image content (collectShakeRegions skips image-only + // tool results and user-message images), so the rescue's second tier drops + // attached images — the automated `/shake images` remedy — and re-tests + // the recovery band before the guard is allowed to pause. + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + let imagesDropped = false; + vi.spyOn(session, "getContextUsage").mockImplementation(() => + imagesDropped + ? { tokens: 1000, contextWindow: 200000, percent: 0.5 } + : { tokens: 190000, contextWindow: 200000, percent: 95 }, + ); + // Nothing elide-eligible in the oversized tail. + vi.spyOn(session, "shake").mockResolvedValue({ + mode: "elide", + toolResultsDropped: 0, + blocksDropped: 0, + tokensFreed: 0, + }); + const dropSpy = vi.spyOn(session, "dropImages").mockImplementation(async () => { + imagesDropped = true; + return { removed: 2 }; + }); + + const notices = collectNotices(); + + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + + const assistantMsg = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + await compactionDone; + await session.waitForIdle(); + + expect(dropSpy).toHaveBeenCalledTimes(1); + expect(promptSpy).toHaveBeenCalledTimes(1); + const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); + expect(noProgress.length).toBe(0); + const recovery = notices.filter( + n => n.source === NOTICE_SOURCE && n.message.includes("dropped 2 attached images"), + ); + expect(recovery.length).toBe(1); + expect(recovery[0].level).toBe("info"); + // A rescued pass must not stamp the dead-end warning on the entry. + const compactionEntry = sessionManager + .getEntries() + .filter((e): e is CompactionEntry => e.type === "compaction") + .at(-1); + expect(compactionEntry?.warning).toBeUndefined(); }); }); diff --git a/packages/coding-agent/test/compaction-lifecycle.test.ts b/packages/coding-agent/test/compaction-lifecycle.test.ts index b6275266d..8658a5e8d 100644 --- a/packages/coding-agent/test/compaction-lifecycle.test.ts +++ b/packages/coding-agent/test/compaction-lifecycle.test.ts @@ -48,6 +48,9 @@ function buildCtx(compact: InteractiveModeContext["session"]["compact"]) { updateEditorTopBorder: vi.fn(), showError, flushCompactionQueue: vi.fn(async () => undefined), + // executeCompaction consults display.collapseCompacted on the ok path to + // decide whether the rebuild replaces the terminal transcript. + settings: { get: vi.fn(() => true) }, } as unknown as InteractiveModeContext; return {