Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/desktop/test/context-compaction.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
117 changes: 117 additions & 0 deletions packages/agent-runtime/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 67 additions & 5 deletions packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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,
),
Expand Down Expand Up @@ -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."
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading