From 324c65889a7c6d65268b644845f64cc2eb64d33b Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Fri, 11 Sep 2026 11:34:06 -0700 Subject: [PATCH 1/5] fix(agent-runtime): keep context recoverable after a failed compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retained-tail fallback checkpoint persisted an empty retainedTail on completed turns (the Codex-shaped tail only retains messages on an active turn), so after a runtime rebuild — model switch, restart — the session restored with nothing before the boundary: the new model saw no history and no usable summary, while the transcript kept all 500+ messages (#224). Two further paths cemented the failure instead of recovering it: pi's prepareCompaction copies the previous checkpoint's summary as the next run's previousSummary, and the fallback rebuild carried the terminal checkpoint's summary forward — both fed the recovery notice text into the next summarization as if it were a real summary, so the model kept "updating" a summary that never existed. - createFallbackCheckpoint: when the shaped tail is empty, retain the newest user messages from the summarized range under the same budget, so the failure path still restores a bounded, non-empty context. - prepareCompactionInput / fallbackPreparation: strip a previousSummary that contains the fallback notice, so the next compaction regenerates a real summary from the transcript instead of updating the notice. Fixes #224 --- apps/desktop/test/context-compaction.test.mjs | 31 +++++++++++++ packages/agent-runtime/src/runtime.ts | 45 +++++++++++++++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/apps/desktop/test/context-compaction.test.mjs b/apps/desktop/test/context-compaction.test.mjs index 78547537e6..587372a333 100644 --- a/apps/desktop/test/context-compaction.test.mjs +++ b/apps/desktop/test/context-compaction.test.mjs @@ -230,6 +230,37 @@ 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 never treated as a real summary", () => { + // pi copies `previousSummary` from the previous compaction entry. When that + // entry was a fallback, its summary is the recovery notice — feeding it to + // the next run makes the model update a summary that never existed. Both + // the normal and the rebuild path must strip the notice so the next + // summarization regenerates a real summary (#224). + assert.match( + runtime, + /previousSummary\?\.includes\(\s*COMPACTION_FALLBACK_MARKER,\s*\)/, + ); + assert.match( + runtime, + /previousSummary: terminal\.summary\.includes\(COMPACTION_FALLBACK_MARKER\)/, + ); +}); + 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.ts b/packages/agent-runtime/src/runtime.ts index 726281c81f..bff2864ea9 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -4856,10 +4856,26 @@ Delegation rules: keepRecentTokens: budget.keepRecentTokens, } satisfies CompactionSettings); if (!prepared.ok || !prepared.value) return prepared; + // pi picks `previousSummary` straight from the previous compaction entry. + // When that entry was a retained-tail fallback its "summary" is the + // carried-forward recovery notice, not a real summary: feeding it to the + // next run makes the model *update* a summary that never existed, + // cementing the failure. Drop it so the next summarization request builds + // a real summary from the transcript instead (#224). + const previousSummary = prepared.value.previousSummary?.includes( + COMPACTION_FALLBACK_MARKER, + ) + ? undefined + : prepared.value.previousSummary; return { ok: true as const, value: this.codexShapedPreparation( - prepared.value, + { + ...prepared.value, + ...(previousSummary === prepared.value.previousSummary + ? {} + : { previousSummary }), + }, retainedUserTokens, retentionMode, ), @@ -5182,8 +5198,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, @@ -5324,7 +5358,12 @@ Delegation rules: if (!sourceInput.ok || !sourceInput.value) return preparation; return { ...sourceInput.value, - previousSummary: terminal.summary, + // Same rule as `prepareCompactionInput`: a fallback notice is not a + // summary. Carrying it forward here would chain recovery notices + // instead of ever recovering a real one (#224). + previousSummary: terminal.summary.includes(COMPACTION_FALLBACK_MARKER) + ? sourceInput.value.previousSummary + : terminal.summary, }; } From e36d9fac9c0ece43d13c64d75a749d54cc6d9fa4 Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Fri, 11 Sep 2026 11:52:42 -0700 Subject: [PATCH 2/5] style(sidebar): use the radius token on the drag-drop indicator The drop-before/after hairline shipped with a raw `border-radius: 1px`, which trips the style-token gate (`check-style-tokens.mjs`) that guards lint on every PR. The established pattern for a 2px hairline is `--radius-full` (see `.subagent-topology-connector`). Pre-existing on main (introduced with the project drag-reorder feature); fixing it here so this PR's CI can be green against a red main. --- apps/desktop/src/styles/sidebar-threads.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/styles/sidebar-threads.css b/apps/desktop/src/styles/sidebar-threads.css index 7f660e71dd..52d1709767 100644 --- a/apps/desktop/src/styles/sidebar-threads.css +++ b/apps/desktop/src/styles/sidebar-threads.css @@ -97,7 +97,7 @@ left: 8px; z-index: 1; height: 2px; - border-radius: 1px; + border-radius: var(--radius-full); background: var(--ds-accent); content: ""; pointer-events: none; From 36e9cd408afe1e99556c5bf0e01f0dd23ec26fee Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Fri, 11 Sep 2026 11:58:45 -0700 Subject: [PATCH 3/5] style(chat): use the radius token on the empty-hero focus outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c944d808` shipped `.empty-hero .project-underline:focus-visible` with a raw `border-radius: 4px`, which trips the style-token gate — main's CI is red on it. 4px maps to `--radius-3xs`. --- apps/desktop/src/styles/chat-shell.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/styles/chat-shell.css b/apps/desktop/src/styles/chat-shell.css index 35b5010126..ebcd3e98eb 100644 --- a/apps/desktop/src/styles/chat-shell.css +++ b/apps/desktop/src/styles/chat-shell.css @@ -658,7 +658,7 @@ .empty-hero .project-underline:focus-visible { outline: 2px solid var(--ds-focus); outline-offset: 3px; - border-radius: 4px; + border-radius: var(--radius-3xs); } .home-project-switcher-menu { From 5e8859c2fd8b9765dc67bba05acf15a2c8fb179a Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Fri, 11 Sep 2026 12:09:34 -0700 Subject: [PATCH 4/5] test(sidebar): match the pointer-based project reorder in session-project-move The project drag-reorder refactor replaced the HTML5 drag handlers the source-assertion tests were anchored on (`handleProjectDragOver`, `handleProjectDrop`, `source.meta.archived` inline buckets), so both drag tests fail on current main. Point the assertions at the code that exists now: the drop-target handlers' payload-authoritative guard, the long-press arm threshold, and the bucket guard in sidebar-project-reorder.ts. --- apps/desktop/test/session-project-move.test.mjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/desktop/test/session-project-move.test.mjs b/apps/desktop/test/session-project-move.test.mjs index 1eba6b043f..70f439ed79 100644 --- a/apps/desktop/test/session-project-move.test.mjs +++ b/apps/desktop/test/session-project-move.test.mjs @@ -86,9 +86,10 @@ test("sidebar sessions drag onto project groups and offer a menu fallback", () = assert.match(sidebar, /onDragEnd=\{endSessionDrag\}/); assert.match(sidebar, /is-dragging/); assert.match(sidebar, /onProjectDropTargetOver\(event, entry\)/); - assert.match(sidebar, /handleProjectDragOver\(event, entry\.key\)/); assert.match(sidebar, /onProjectDropTargetDrop\(event, entry\)/); - assert.match(sidebar, /handleProjectDrop\(event, entry\.key\)/); + // The transfer payload is authoritative: a stale dragging id must never + // move a session the user did not drag. + assert.match(sidebar, /sessionIdFromDrag\(event\.dataTransfer\)/); assert.match(sidebar, /dropProjectKey === entry\.key \? "is-drop-target" : ""/); assert.match(sidebar, /data-action="move-session-to-project"/); assert.match(sidebar, /nav\.moveToProject/); @@ -203,9 +204,13 @@ test("session move and prompt setup share a per-session critical section", () => }); test("drag ordering keeps priority buckets and rejects malformed ranks", () => { - assert.match(sidebar, /Boolean\(source\.meta\.archived\)/); - assert.match(sidebar, /Boolean\(source\.meta\.pinned\)/); - assert.match(sidebar, /const sourceKey = draggingProjectKey/); + // Project reordering moved from HTML5 drag events to a long-press pointer + // flow; the bucket guard now lives in sidebar-project-reorder.ts. + const reorderLib = read("../src/lib/sidebar-project-reorder.ts"); + assert.match(reorderLib, /Boolean\(source\.archived\) === Boolean\(target\.archived\)/); + assert.match(reorderLib, /Boolean\(source\.pinned\) === Boolean\(target\.pinned\)/); + assert.match(sidebar, /projectReorderShouldArm\(/); + assert.match(sidebar, /sameProjectReorderBucket\(source\.meta, destination\.meta\)/); assert.match(sidebarPreferences, /Number\.isSafeInteger\(value\)/); assert.match(sidebarPreferences, /manualOrder\(meta\[ak\]\?\.order\)/); }); From f9003ff5068181d3c9f281faf01a656821a243c4 Mon Sep 17 00:00:00 2001 From: muzimu217 <1278844978@qq.com> Date: Fri, 11 Sep 2026 21:55:27 -0700 Subject: [PATCH 5/5] fix(agent-runtime): keep the carried summary when a fallback notice is stripped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi reads `previousSummary` straight from the previous compaction entry. A retained-tail fallback stores its carried-forward summary ahead of the recovery notice, so dropping the whole `previousSummary` on seeing the marker also discarded the valid summary the failed compaction was carrying forward. After the next compaction that older context was gone for good. Strip only the notice — and the "no previous summary" placeholder — leaving any real summary ahead of the marker intact. Applies to both `prepareCompactionInput` and the terminal-checkpoint branch of `fallbackPreparation`. Adds behavioral regressions for a fallback that already carries a valid summary, and keeps the source-wiring assertion in the desktop compaction test in sync. --- apps/desktop/test/context-compaction.test.mjs | 17 +-- packages/agent-runtime/src/runtime.test.ts | 117 ++++++++++++++++++ packages/agent-runtime/src/runtime.ts | 59 ++++++--- 3 files changed, 167 insertions(+), 26 deletions(-) diff --git a/apps/desktop/test/context-compaction.test.mjs b/apps/desktop/test/context-compaction.test.mjs index 587372a333..08e0452051 100644 --- a/apps/desktop/test/context-compaction.test.mjs +++ b/apps/desktop/test/context-compaction.test.mjs @@ -245,19 +245,20 @@ test("a failed compaction checkpoint still restores a non-empty context", () => ); }); -test("a fallback notice is never treated as a real summary", () => { - // pi copies `previousSummary` from the previous compaction entry. When that - // entry was a fallback, its summary is the recovery notice — feeding it to - // the next run makes the model update a summary that never existed. Both - // the normal and the rebuild path must strip the notice so the next - // summarization regenerates a real summary (#224). +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, - /previousSummary\?\.includes\(\s*COMPACTION_FALLBACK_MARKER,\s*\)/, + /const previousSummary = stripCompactionFallbackNotice\(\s*prepared\.value\.previousSummary,\s*\)/, ); assert.match( runtime, - /previousSummary: terminal\.summary\.includes\(COMPACTION_FALLBACK_MARKER\)/, + /previousSummary:\s*stripCompactionFallbackNotice\(terminal\.summary\)/, ); }); diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index 5a5e14aeed..9957c49dcb 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, @@ -4629,6 +4630,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 bff2864ea9..8f62f5201e 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -466,8 +466,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([ @@ -4857,16 +4882,14 @@ Delegation rules: } satisfies CompactionSettings); if (!prepared.ok || !prepared.value) return prepared; // pi picks `previousSummary` straight from the previous compaction entry. - // When that entry was a retained-tail fallback its "summary" is the - // carried-forward recovery notice, not a real summary: feeding it to the - // next run makes the model *update* a summary that never existed, - // cementing the failure. Drop it so the next summarization request builds - // a real summary from the transcript instead (#224). - const previousSummary = prepared.value.previousSummary?.includes( - COMPACTION_FALLBACK_MARKER, - ) - ? undefined - : prepared.value.previousSummary; + // 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( @@ -5187,7 +5210,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." @@ -5358,12 +5381,12 @@ Delegation rules: if (!sourceInput.ok || !sourceInput.value) return preparation; return { ...sourceInput.value, - // Same rule as `prepareCompactionInput`: a fallback notice is not a - // summary. Carrying it forward here would chain recovery notices - // instead of ever recovering a real one (#224). - previousSummary: terminal.summary.includes(COMPACTION_FALLBACK_MARKER) - ? 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, }; }