diff --git a/apps/desktop/test/context-compaction.test.mjs b/apps/desktop/test/context-compaction.test.mjs index 7a585098b5..e04c24a2a7 100644 --- a/apps/desktop/test/context-compaction.test.mjs +++ b/apps/desktop/test/context-compaction.test.mjs @@ -239,6 +239,38 @@ test("every compaction announces itself once, on top of the specific toasts", () assert.match(enLocale, /longThreadWarning:/); }); +test("a failed compaction checkpoint still restores a non-empty context", () => { + // A retained-tail fallback must not persist an empty tail on a completed + // turn: with no real summary to carry the boundary, an empty tail restores + // as an empty context after a runtime rebuild (model switch / restart) — + // the session reads as if it had just started (#224). + assert.match( + runtime, + /const retainedTail =\s*preparation\.retainedTail\.length > 0\s*\? preparation\.retainedTail\s*: selectRetainedUserMessages\(/, + ); + assert.match( + runtime, + /fallback: "retained_tail" satisfies ContextCompactionFallback,/, + ); +}); + +test("a fallback notice is stripped without discarding its carried summary", () => { + // pi copies `previousSummary` from the previous compaction entry. A fallback + // entry stores its carried-forward summary ahead of the recovery notice, so + // the notice must be stripped without taking the real summary with it — + // otherwise older task context is silently lost (#224). Both the normal and + // the rebuild path share one extraction helper. + assert.match(runtime, /function stripCompactionFallbackNotice\(/); + assert.match( + runtime, + /const previousSummary = stripCompactionFallbackNotice\(\s*prepared\.value\.previousSummary,\s*\)/, + ); + assert.match( + runtime, + /previousSummary:\s*stripCompactionFallbackNotice\(terminal\.summary\)/, + ); +}); + test("the transcript shows one row per compaction, the inspector the newest", () => { assert.match(types, /type ContextCompactionMark = ContextCompactionStatus & \{/); assert.match(types, /mark\?: ContextCompactionMark/); diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index 638be0c9e4..c557f4e56a 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { estimateTokens } from "@earendil-works/pi-agent-core"; import { buildSessionContext } from "./session-context.js"; import { + COMPACTION_FALLBACK_MARKER, DesktopAgentRuntime, PATH_INSTRUCTION_RESOLUTION_TIMEOUT_MS, looksLikePseudoToolCall, @@ -4639,6 +4640,122 @@ describe("DesktopAgentRuntime per-turn context protection", () => { await runtime.dispose(); }); + it("keeps the summary a fallback checkpoint carries forward", async () => { + // A fallback checkpoint stores any carried-forward summary ahead of its + // recovery notice (see `createFallbackCheckpoint`). The next preparation + // must strip only the notice: pi's `prepareCompaction` cannot rebuild the + // older context from the transcript on its own, so dropping the carried + // summary would lose it permanently (#224). + const runtime = createRuntime(); + (runtime as any).fullEntries = [ + { + type: "message", + id: "anchor-user", + seq: 0, + parentId: null, + timestamp: Date.parse("2026-08-01T00:00:00Z"), + message: { role: "user", content: "anchor ask", timestamp: 1 }, + }, + { + type: "message", + id: "later-user", + seq: 1, + parentId: "anchor-user", + timestamp: Date.parse("2026-08-01T00:00:01Z"), + message: { role: "user", content: "later ask", timestamp: 2 }, + }, + ]; + (runtime as any).activeCompaction = { + id: "fallback-1", + summary: [ + "The earlier task summary.", + COMPACTION_FALLBACK_MARKER, + "The automatic summary request did not complete.", + ].join("\n\n"), + firstKeptMessageId: "anchor-user", + throughMessageId: "anchor-user", + tokensBefore: 240_000, + retainedTail: [{ role: "user", content: "remembered ask", timestamp: 0 }], + details: { generation: 1, fallback: "retained_tail" }, + providerId: "local", + modelId: "local-model", + createdAt: "2026-08-01T00:00:00Z", + }; + + const entries = (runtime as any).entriesWithCompaction(); + const budget = (runtime as any).contextBudget( + buildSessionContext(entries).messages, + ); + const preparation = (runtime as any).prepareCompactionInput( + entries, + budget, + 20_000, + "active_turn", + ); + + expect(preparation.value.previousSummary).toBe("The earlier task summary."); + await runtime.dispose(); + }); + + it("keeps the carried summary when a fallback checkpoint is the terminal entry", async () => { + // The rebuild path (`fallbackPreparation`) carries the terminal summary + // into a smaller-tail checkpoint. When that terminal entry is itself a + // fallback, the notice must be stripped without losing the summary it + // carries, or the older context disappears for good (#224). + const host = { call: vi.fn().mockResolvedValue(undefined) }; + const runtime = createRuntime({ + host, + history: [ + { + id: "old-user", + role: "user", + content: "older task context", + createdAt: "2026-08-01T00:00:00Z", + status: "complete", + }, + { + id: "recent-user", + role: "user", + content: "recent context", + createdAt: "2026-08-01T00:00:01Z", + status: "complete", + }, + ], + compaction: { + id: "fallback-1", + summary: [ + "The earlier task summary.", + COMPACTION_FALLBACK_MARKER, + "The automatic summary request did not complete.", + ].join("\n\n"), + throughMessageId: "recent-user", + tokensBefore: 220_000, + retainedTail: [ + { role: "user", content: "recent context", timestamp: 2 }, + ], + details: { generation: 1, fallback: "retained_tail" }, + createdAt: "2026-08-01T00:00:02Z", + }, + }); + const generateCompaction = vi.spyOn(runtime as any, "generateCompaction"); + + await expect((runtime as any).runCompaction("threshold", false)).resolves.toBe( + true, + ); + + expect(generateCompaction).not.toHaveBeenCalled(); + expect(host.call).toHaveBeenCalledWith( + "session.appendCompaction", + expect.objectContaining({ + compaction: expect.objectContaining({ + details: expect.objectContaining({ fallback: "retained_tail" }), + summary: expect.stringContaining("The earlier task summary."), + }), + }), + ); + await runtime.dispose(); + }); + it("keeps manual compaction failures terminal instead of silently dropping context", async () => { const host = { call: vi.fn().mockResolvedValue(undefined) }; const onEvent = vi.fn(); diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 3e483ca918..330839b8f4 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -467,8 +467,33 @@ const COMPACTION_RETAINED_USER_MESSAGE_MAX_TOKENS = 20_000; const COMPACTION_FALLBACK_KEEP_RECENT_RATIO = 0.25; const COMPACTION_FALLBACK_MAX_SUMMARY_CHARS = 12_000; const COMPACTION_SUMMARY_PROMPT_SAFETY_TOKENS = 2_048; -const COMPACTION_FALLBACK_MARKER = +export const COMPACTION_FALLBACK_MARKER = "[automatic context recovery: older context was omitted after summary generation failed]"; +/** Stored in place of a carried-forward summary when a fallback had none. */ +const COMPACTION_FALLBACK_NO_SUMMARY = + "No previous context checkpoint is available."; + +/** + * A retained-tail fallback stores any carried-forward summary ahead of the + * recovery notice, separated by `COMPACTION_FALLBACK_MARKER` (see + * `createFallbackCheckpoint`). Only the notice is synthetic: the text before + * the marker is the real summary the failed compaction was carrying forward. + * Strip the notice — and the "no previous summary" placeholder — so the next + * summarization rebuilds from that real summary instead of updating a notice + * that never was a summary (#224), without discarding the history it carried. + */ +function stripCompactionFallbackNotice( + summary: string | undefined, +): string | undefined { + if (!summary) return undefined; + const markerIndex = summary.indexOf(COMPACTION_FALLBACK_MARKER); + if (markerIndex === -1) return summary; + const carried = summary.slice(0, markerIndex).trim(); + if (carried.length === 0 || carried === COMPACTION_FALLBACK_NO_SUMMARY) { + return undefined; + } + return carried; +} /** Path-scoped rules are best-effort and must not stall a file tool turn. */ export const PATH_INSTRUCTION_RESOLUTION_TIMEOUT_MS = 2_000; const PATH_SCOPED_INSTRUCTION_TOOLS = new Set([ @@ -4865,10 +4890,24 @@ Delegation rules: keepRecentTokens: budget.keepRecentTokens, } satisfies CompactionSettings); if (!prepared.ok || !prepared.value) return prepared; + // pi picks `previousSummary` straight from the previous compaction entry. + // A retained-tail fallback stores its carried-forward summary ahead of a + // recovery notice; feeding the notice to the next run makes the model + // *update* a summary that never existed and cements the failure. Strip the + // notice while keeping the carried-forward summary, so the next + // summarization request still sees the history it was carrying (#224). + const previousSummary = stripCompactionFallbackNotice( + prepared.value.previousSummary, + ); return { ok: true as const, value: this.codexShapedPreparation( - prepared.value, + { + ...prepared.value, + ...(previousSummary === prepared.value.previousSummary + ? {} + : { previousSummary }), + }, retainedUserTokens, retentionMode, ), @@ -5180,7 +5219,7 @@ Delegation rules: preparation.previousSummary, Math.min(COMPACTION_FALLBACK_MAX_SUMMARY_CHARS, maxSummaryChars), ) - : "No previous context checkpoint is available."; + : COMPACTION_FALLBACK_NO_SUMMARY; const continuation = retentionMode === "active_turn" ? "The provider is continuing the active turn. Use the one retained latest user request as the source of truth for that continuation." @@ -5191,8 +5230,26 @@ Delegation rules: "The automatic summary request did not complete. Older messages before this checkpoint are omitted from the next model request.", `The complete transcript remains available in the session. ${continuation}`, ].join("\n\n"); + // A completed-turn checkpoint normally retains no naked user messages, but + // an empty tail plus a carried-forward (or absent) summary leaves the next + // model request with nothing before the boundary: after a runtime rebuild + // — model switch, restart — the session restores as if it had just started. + // Fall back to the newest user messages under the same budget so the + // failure path still restores a bounded, non-empty context (#224). + const retainedTail = + preparation.retainedTail.length > 0 + ? preparation.retainedTail + : selectRetainedUserMessages( + preparation.messagesToSummarize.filter( + (message): message is UserMessage => message.role === "user", + ), + preparation.settings.keepRecentTokens, + ); return this.createCheckpoint( - preparation, + { + ...preparation, + retainedTail, + }, throughMessageId, summary, undefined, @@ -5333,7 +5390,12 @@ Delegation rules: if (!sourceInput.ok || !sourceInput.value) return preparation; return { ...sourceInput.value, - previousSummary: terminal.summary, + // Same rule as `prepareCompactionInput`: strip a fallback notice but keep + // the summary it carries forward, so a chained fallback cannot bake in + // the notice or discard the real history along with it (#224). + previousSummary: + stripCompactionFallbackNotice(terminal.summary) ?? + sourceInput.value.previousSummary, }; }