Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a51fdcf
🤖 fix: persist post-compaction attachments as a durable history row
ethanndickson Oct 28, 2026
dc29e58
fix: mock AI and agent transition skip rows appended after the prompt
ethanndickson Oct 28, 2026
b6d680e
fix: address review of the durable post-compaction attachments row
ethanndickson Oct 28, 2026
36832f5
fix: reuse the attachments row only while history ends where the turn…
ethanndickson Oct 28, 2026
b8a5950
fix: keep the attachments row across edits and failed deletes
ethanndickson Oct 28, 2026
cee66e8
fix: treat the attachments row as context in every history consumer
ethanndickson Oct 28, 2026
7484a75
fix: do not count the dropped attachments row against the rolling tail
ethanndickson Oct 28, 2026
60175db
Merge remote-tracking branch 'origin/main' into durable-post-compacti…
ethanndickson Oct 29, 2026
8b087f6
revert: drop the durable attachments row before the boundary-header r…
ethanndickson Oct 29, 2026
a4ab88f
🤖 fix: store the post-compaction context as a boundary-row header
ethanndickson Oct 29, 2026
c213bc1
🤖 refactor: default missing compaction state once in the continuous h…
ethanndickson Oct 29, 2026
030ebe4
🤖 fix: baseline the plan before the header reads it, and let replay m…
ethanndickson Oct 29, 2026
0144d40
🤖 fix: track the plan after compaction only when the boundary header …
ethanndickson Oct 29, 2026
8477583
🤖 fix: discard the acknowledged carryover when the retry leaves the h…
ethanndickson Oct 29, 2026
7d172ac
🤖 tests: pass the history to consumePendingPostCompactionState in the…
ethanndickson Oct 29, 2026
5827707
🤖 fix: time-box the header build inside the builder and baseline the …
ethanndickson Oct 29, 2026
4c01302
🤖 fix: baseline the plan the header rendered, at its real path, insid…
ethanndickson Oct 29, 2026
83c6ace
🤖 fix: do not retry without the header when the carryover discard fails
ethanndickson Oct 29, 2026
6dadf7d
🤖 fix: let a turn's retry recover after a failed carryover discard
ethanndickson Oct 29, 2026
b33b133
🤖 tests: expect readPlan's plan state in the FIFO plan integration test
ethanndickson Oct 29, 2026
9d8597d
🤖 fix: persist the header omission and the plan baseline on the bound…
ethanndickson Oct 29, 2026
2c3e21e
🤖 tests: avoid unsafe any in the FIFO plan integration test
ethanndickson Oct 29, 2026
0f577a8
🤖 fix: keep the swapped boundary row and gate the header retry to nor…
ethanndickson Oct 30, 2026
a6a78d7
🤖 fix: drop an omitted header's tokens from the boundary's usage
ethanndickson Oct 30, 2026
5675315
🤖 fix: render a left-out header again after a cut before its retry
ethanndickson Oct 30, 2026
42718e9
🤖 revert: keep a left-out header out after a cut before its retry
ethanndickson Oct 30, 2026
e53d3bf
🤖 fix: count only the header a continuous boundary keeps
ethanndickson Oct 30, 2026
942e080
🤖 fix: size-check the swap header and keep newer plan state
ethanndickson Oct 30, 2026
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
8 changes: 4 additions & 4 deletions src/browser/features/RightSidebar/PostCompactionSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function getFileName(filePath: string): string {
}

/**
* Displays what context will be injected after compaction.
* Displays the context that compaction saves with its summary.
* Collapsible section in the right sidebar below the context usage bar.
*/
export const PostCompactionSection: React.FC<PostCompactionSectionProps> = (props) => {
Expand Down Expand Up @@ -70,7 +70,7 @@ export const PostCompactionSection: React.FC<PostCompactionSectionProps> = (prop
// Count how many items are included (not excluded)
const includedFilesCount = formattedFiles.filter((f) => !f.isExcluded).length;

// Don't render if nothing will be injected
// Don't render if there is nothing to save
if (!planExists && trackedFilesCount === 0) {
return null;
}
Expand All @@ -91,8 +91,8 @@ export const PostCompactionSection: React.FC<PostCompactionSectionProps> = (prop
</span>
</TooltipTrigger>
<TooltipContent side="top" showArrow={false}>
After compaction, included artifacts in this list (plan + selected file diffs) are
attached to your next message so the agent keeps important earlier context.
Compaction saves the included artifacts in this list (plan + selected file diffs) with
its summary so the agent keeps important earlier context.
</TooltipContent>
</Tooltip>
</div>
Expand Down
11 changes: 9 additions & 2 deletions src/browser/utils/messages/attachmentRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
return `${SYSTEM_UPDATE_OPEN}${content}${SYSTEM_UPDATE_CLOSE}`;
}

const PLAN_FILE_REFERENCE_LEAD = "A plan file exists from plan mode at: ";

/** Whether rendered attachments carry a plan; a plan is always the first block. */
export function rendersPlanFileReference(content: string): boolean {
return content.startsWith(`${SYSTEM_UPDATE_OPEN}${PLAN_FILE_REFERENCE_LEAD}`);
}

/**
* Render a todo list attachment to a content string.
*/
Expand Down Expand Up @@ -136,24 +143,24 @@
attachment: PlanFileReferenceAttachment,
maxChars: number
): string | null {
if (maxChars <= 0) {

Check warning on line 146 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: <= -> <

No unit test failed when renderPlanFileReferenceWithBudget had <= -> <. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 146 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when renderPlanFileReferenceWithBudget had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
return null;
}

const prefix = `A plan file exists from plan mode at: ${attachment.planFilePath}\n\nPlan contents:\n`;
const prefix = `${PLAN_FILE_REFERENCE_LEAD}${attachment.planFilePath}\n\nPlan contents:\n`;
const suffix =
"\n\nIf this plan is relevant to the current work and not already complete, continue working on it.";

const availableForContent = maxChars - prefix.length - suffix.length;
if (availableForContent <= 0) {

Check warning on line 155 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: <= -> <

No unit test failed when renderPlanFileReferenceWithBudget had <= -> <. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 155 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when renderPlanFileReferenceWithBudget had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
const minimal = `A plan file exists from plan mode at: ${attachment.planFilePath}`;
const minimal = `${PLAN_FILE_REFERENCE_LEAD}${attachment.planFilePath}`;
return minimal.length <= maxChars ? minimal : null;
}

let planContent = attachment.planContent;
if (planContent.length > availableForContent) {

Check warning on line 161 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: > -> >=

No unit test failed when renderPlanFileReferenceWithBudget had > -> >=. Add a test that tells them apart, or say why they are equivalent.
const sliceLength = Math.max(0, availableForContent - PLAN_TRUNCATION_NOTE.length);

Check warning on line 162 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when renderPlanFileReferenceWithBudget had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
planContent = `${planContent.slice(0, sliceLength)}${PLAN_TRUNCATION_NOTE}`;

Check warning on line 163 in src/browser/utils/messages/attachmentRenderer.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when renderPlanFileReferenceWithBudget had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
}

return `${prefix}${planContent}${suffix}`;
Expand Down
164 changes: 22 additions & 142 deletions src/browser/utils/messages/modelMessageTransform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
addInterruptedSentinel,
injectAgentTransition,
filterEmptyAssistantMessages,
injectPostCompactionAttachments,
stripOrphanedToolCalls,
} from "./modelMessageTransform";
import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments";
import type { MuxMessage } from "@/common/types/message";
import {
createMuxMessage,
postCompactionContextMessageId,
type MuxMessage,
} from "@/common/types/message";

describe("modelMessageTransform", () => {
describe("transformModelMessages", () => {
Expand Down Expand Up @@ -1431,6 +1433,23 @@ describe("injectAgentTransition", () => {
expect(result[1].metadata?.synthetic).toBe(true);
});

// The header is context for the whole window, not a user request: a transition in front of
// it would change the bytes right after the boundary.
it("does not inject the transition before a post-compaction context row", () => {
const messages: MuxMessage[] = [
createMuxMessage("summary", "assistant", "Compacted summary", {
compactionBoundary: true,
compactionEpoch: 1,
}),
createMuxMessage(postCompactionContextMessageId("summary"), "user", "Edited files", {
synthetic: true,
}),
createMuxMessage("assistant-1", "assistant", "Here's the plan...", { agentId: "plan" }),
];

expect(injectAgentTransition(messages, "exec")).toEqual(messages);
});

it("should not inject transition when agent is the same", () => {
const messages: MuxMessage[] = [
{
Expand Down Expand Up @@ -1996,142 +2015,3 @@ describe("filterEmptyAssistantMessages", () => {
expect(result2[1].metadata?.partial).toBe(true);
});
});

describe("injectPostCompactionAttachments", () => {
it("inserts before the request after a compaction summary and enforces a size budget", () => {
const messages: MuxMessage[] = [
{
id: "compaction-summary",
role: "assistant",
parts: [{ type: "text", text: "Compacted summary" }],
metadata: {
timestamp: 1000,
compacted: "user",
compactionBoundary: true,
compactionEpoch: 1,
},
},
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Continue" }],
metadata: { timestamp: 1100 },
},
];

const attachments = [
{
type: "edited_files_reference" as const,
files: [
{
path: "src/huge.ts",
diff: "x".repeat(MAX_POST_COMPACTION_INJECTION_CHARS + 10_000),
truncated: false,
},
],
},
];

const result = injectPostCompactionAttachments(messages, attachments);

expect(result).toHaveLength(3);
expect(result[0].id).toBe("compaction-summary");
expect(result[2].id).toBe("user-1");

const injected = result[1];
expect(injected.role).toBe("user");
expect(injected.metadata?.synthetic).toBe(true);

const text = (injected.parts[0] as { type: "text"; text: string }).text;
expect(text.length).toBeLessThanOrEqual(MAX_POST_COMPACTION_INJECTION_CHARS);
expect(text).toContain("post-compaction context truncated");
});

it("goes before the newest user message, past Token Budget warnings, or last without one", () => {
const row = (id: string, role: "user" | "assistant", extra = {}): MuxMessage => ({
id,
role,
parts: [{ type: "text", text: id }],
metadata: { timestamp: 1000, ...extra },
});
const warning = row("warning", "user", { muxMetadata: { type: "context-budget-warning" } });
const attachments = [
{
type: "plan_file_reference" as const,
planFilePath: "PLAN.md",
planContent: "Plan",
},
];
const ids = (messages: MuxMessage[]) =>
injectPostCompactionAttachments(messages, attachments).map((message) =>
message.metadata?.synthetic === true ? "attachments" : message.id
);

// Request-only and re-injected every few turns: the rows before the current request stay.
expect(
ids([row("user-1", "user"), row("assistant-1", "assistant"), row("user-2", "user"), warning])
).toEqual(["user-1", "assistant-1", "attachments", "user-2", "warning"]);
expect(ids([row("assistant-1", "assistant")])).toEqual(["assistant-1", "attachments"]);
});

it("inserts before the newest request when multiple summaries exist", () => {
const messages: MuxMessage[] = [
{
id: "summary-1",
role: "assistant",
parts: [{ type: "text", text: "Older summary" }],
metadata: {
timestamp: 1000,
compacted: "user",
compactionBoundary: true,
compactionEpoch: 1,
},
},
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Between summaries" }],
metadata: { timestamp: 1100 },
},
{
id: "summary-2",
role: "assistant",
parts: [{ type: "text", text: "Latest summary" }],
metadata: {
timestamp: 2000,
compacted: "user",
compactionBoundary: true,
compactionEpoch: 2,
},
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Continue" }],
metadata: { timestamp: 2100 },
},
];

const attachments = [
{
type: "plan_file_reference" as const,
planFilePath: "PLAN.md",
planContent: "Latest plan",
},
];

const result = injectPostCompactionAttachments(messages, attachments);

expect(result).toHaveLength(5);
expect(result[0].id).toBe("summary-1");
expect(result[1].id).toBe("user-1");
expect(result[2].id).toBe("summary-2");

const injected = result[3];
expect(injected.metadata?.synthetic).toBe(true);
const text = (injected.parts[0] as { type: "text"; text: string }).text;
expect(text).toContain("<system-update>");

expect(result[4].id).toBe("user-2");
});
});
75 changes: 14 additions & 61 deletions src/browser/utils/messages/modelMessageTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@
import type { ModelMessage, AssistantModelMessage, ToolModelMessage, UserModelMessage } from "ai";
import {
isContextBudgetWarningMessage,
isPostCompactionContextMessage,
type MuxMessage,
type MuxReasoningPart,
} from "@/common/types/message";
import type { PostCompactionAttachment } from "@/common/types/attachment";
import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments";
import { hasProviderReplayableContent } from "@/common/utils/messages/providerEligibility";
import { renderAttachmentsToContentWithBudget } from "./attachmentRenderer";

/**
* Filter out assistant messages that are empty or only contain reasoning parts.
Expand Down Expand Up @@ -93,6 +91,18 @@
return result;
}

/**
* The user request that a transition goes before. Token Budget warnings and the post-compaction
* context row are not requests.
*/
function isTransitionAnchor(message: MuxMessage): boolean {
return (
message.role === "user" &&
!isContextBudgetWarningMessage(message) &&
!isPostCompactionContextMessage(message)
);
}

/**
* Inject agent transition context when the active agent changes mid-conversation.
* Inserts a synthetic user message before the final user message to signal the agent switch.
Expand Down Expand Up @@ -121,13 +131,13 @@
}

// Need at least one message to have a conversation
if (messages.length === 0) {

Check warning on line 134 in src/browser/utils/messages/modelMessageTransform.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when injectAgentTransition had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
return messages;
}

// Find the last assistant message to check its agent
const lastAssistantMessage = [...messages].reverse().find((m) => m.role === "assistant");
const lastAgentId = lastAssistantMessage?.metadata?.agentId;

Check warning on line 140 in src/browser/utils/messages/modelMessageTransform.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: ?. -> .

No unit test failed when injectAgentTransition had ?. -> .. Add a test that tells them apart, or say why they are equivalent.

// No agent transition if no previous agent or same agent
if (!lastAgentId || lastAgentId === currentAgentId) {
Expand All @@ -137,14 +147,7 @@
// Agent transition detected! Inject a synthetic user message before the last user message
// This provides temporal context: user says "switch agents" before their actual request

// Find the index of the last user message
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user" && !isContextBudgetWarningMessage(messages[i])) {
lastUserIndex = i;
break;
}
}
const lastUserIndex = messages.findLastIndex(isTransitionAnchor);

// If there's no user message, can't inject transition (nothing to inject before)
if (lastUserIndex === -1) {
Expand All @@ -162,7 +165,7 @@
let transitionText = `[Agent switched from ${lastAgentId} to ${currentAgentId}. Follow ${currentAgentId} agent instructions.`;

// Append tool availability if provided
if (toolNames && toolNames.length > 0) {

Check warning on line 168 in src/browser/utils/messages/modelMessageTransform.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when injectAgentTransition had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
transitionText += ` Available tools: ${toolNames.join(", ")}.]`;
} else {
transitionText += "]";
Expand Down Expand Up @@ -213,56 +216,6 @@
// turn start (see createFileChangeNotificationMessage in fileChangeTracker.ts),
// keeping the provider request a pure function of the session log.

/**
* Inject post-compaction attachments as a synthetic user message.
* When compaction occurs, this injects a message containing plan file content
* and/or edited files to preserve context that would otherwise be lost.
*
* The message goes right before the newest user message (Token Budget warning rows
* excepted, as in injectAgentTransition), or last without one. It is request-only and comes
* back every TURNS_BETWEEN_ATTACHMENTS turns for the whole segment, so it must not sit
* early in the history: placed after the compaction summary, it rewrote the cached history
* after the summary on the turn it appeared and again on the next turn, when it was gone.
* Here every row before the current turn's request stays byte-identical, and every step of
* the turn rebuilds the same position (the tool loop appends after the request). The model
* sees the same shape as before: the attachments, then the user's request (provider
* transforms merge the two user messages, attachments first).
*
* @param messages The conversation history
* @param attachments Post-compaction attachments (plan file, loaded skills, edited files)
* @returns Messages with the attachments injected before the newest user message
*/
export function injectPostCompactionAttachments(
messages: MuxMessage[],
attachments?: PostCompactionAttachment[] | null
): MuxMessage[] {
if (!attachments || attachments.length === 0) {
return messages;
}

const newestUserIndex = messages.findLastIndex(
(message) => message.role === "user" && !isContextBudgetWarningMessage(message)
);
const index = newestUserIndex === -1 ? messages.length : newestUserIndex;
const syntheticMessage: MuxMessage = {
id: `post-compaction-${Date.now()}`,
role: "user",
parts: [
{
type: "text",
text: renderAttachmentsToContentWithBudget(attachments, {
maxChars: MAX_POST_COMPACTION_INJECTION_CHARS,
}),
},
],
metadata: {
timestamp: messages[index]?.metadata?.timestamp ?? Date.now(),
synthetic: true,
},
};
return [...messages.slice(0, index), syntheticMessage, ...messages.slice(index)];
}

/**
* Filter out assistant messages that only contain reasoning parts (no text or tool parts).
* Anthropic API rejects messages that have reasoning but no actual content.
Expand Down
8 changes: 6 additions & 2 deletions src/cli/debug/replay-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export function printStepPrefixAudit(audit: StepPrefixAudit): void {
);
}

function withReason(line: string, reason: string | undefined): string {
return reason ? `${line}: ${reason}` : line;
}

/**
* Debug command: rebuild every turn's provider request from durable session
* logs (chat.jsonl + turn-envelope rows + blob store) and byte-compare it
Expand Down Expand Up @@ -104,11 +108,11 @@ export async function replayVerifyCommand(workspaceId: string): Promise<void> {
for (const turn of result.turns) {
const label = `turn ${turn.turnIndex} (envelope seq ${turn.envelopeSeq}, run ${turn.runId || "n/a"})`;
if (turn.status === "SKIPPED") {
console.log(`SKIP ${label}: ${turn.reason ?? ""}`);
console.log(withReason(`SKIP ${label}`, turn.reason));
continue;
}
if (turn.status === "PASS") {
console.log(`PASS ${label}`);
console.log(withReason(`PASS ${label}`, turn.reason));
continue;
}
failures++;
Expand Down
3 changes: 0 additions & 3 deletions src/common/constants/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
* Constants for the post-compaction attachment system.
*/

/** Number of turns between post-compaction attachment injections after the first immediate injection */
export const TURNS_BETWEEN_ATTACHMENTS = 5;

/** Maximum size of file content before truncation (50KB) */
export const MAX_FILE_CONTENT_SIZE = 50_000;

Expand Down
Loading
Loading