diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts
index 67b1e4e7b..dbf441abd 100644
--- a/packages/coding-agent/src/core/compaction/branch-summarization.ts
+++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts
@@ -20,6 +20,7 @@ import { completeSummarization, estimateTokens } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
+ excludePlanItems,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
@@ -220,7 +221,8 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
- const message = getMessageFromEntry(entry);
+ const rawMessage = getMessageFromEntry(entry);
+ const message = rawMessage ? excludePlanItems([rawMessage])[0] : undefined;
if (!message) continue;
// Extract file ops from assistant messages (tool calls)
diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts
index 7b2c3f9a4..189ee7970 100644
--- a/packages/coding-agent/src/core/compaction/compaction.ts
+++ b/packages/coding-agent/src/core/compaction/compaction.ts
@@ -18,6 +18,7 @@ import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall,
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@diffexai/diffex-ai/compat";
import { completeSimple } from "@diffexai/diffex-ai/compat";
import { convertToLlm } from "../messages.ts";
+import { stripCurrentPlanSnapshot } from "../plan-snapshot.ts";
import {
buildSessionContext,
type CompactionEntry,
@@ -27,6 +28,7 @@ import {
import {
computeFileLists,
createFileOps,
+ excludePlanItems,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
@@ -692,6 +694,7 @@ export async function generateSummaryWithUsage(
);
// Use update prompt if we have a previous summary, otherwise initial prompt
+ previousSummary = previousSummary === undefined ? undefined : stripCurrentPlanSnapshot(previousSummary);
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
@@ -699,7 +702,7 @@ export async function generateSummaryWithUsage(
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
- const llmMessages = convertToLlm(currentMessages);
+ const llmMessages = convertToLlm(excludePlanItems(currentMessages));
const conversationText = serializeConversation(llmMessages);
// Build the prompt with conversation wrapped in tags
@@ -788,7 +791,7 @@ export function prepareCompaction(
let boundaryStart = 0;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
- previousSummary = prevCompaction.summary;
+ previousSummary = stripCurrentPlanSnapshot(prevCompaction.summary);
const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId);
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
}
@@ -839,8 +842,8 @@ export function prepareCompaction(
return {
firstKeptEntryId,
- messagesToSummarize,
- turnPrefixMessages,
+ messagesToSummarize: excludePlanItems(messagesToSummarize),
+ turnPrefixMessages: excludePlanItems(turnPrefixMessages),
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
@@ -905,7 +908,7 @@ export async function compact(
let summaryUsage: Usage;
if (isSplitTurn && turnPrefixMessages.length > 0) {
- let historyText = "No prior history.";
+ let historyText = previousSummary ?? "No prior history.";
let historyUsage: Usage | undefined;
if (messagesToSummarize.length > 0) {
const historyResult = await generateSummaryWithUsage(
@@ -1004,7 +1007,7 @@ async function generateTurnPrefixSummary(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
); // Smaller budget for turn prefix
- const llmMessages = convertToLlm(messages);
+ const llmMessages = convertToLlm(excludePlanItems(messages));
const conversationText = serializeConversation(llmMessages);
const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts
index bde21cb81..5b52ece70 100644
--- a/packages/coding-agent/src/core/compaction/utils.ts
+++ b/packages/coding-agent/src/core/compaction/utils.ts
@@ -4,6 +4,7 @@
import type { AgentMessage } from "@diffexai/diffex-agent-core";
import { contentText, type Message } from "@diffexai/diffex-ai";
+import { stripCurrentPlanSnapshot } from "../plan-snapshot.ts";
// ============================================================================
// File Operation Tracking
@@ -85,6 +86,21 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[
// Message Serialization
// ============================================================================
+/** Exclude plan data from a context projection without changing persisted messages. */
+export function excludePlanItems(messages: AgentMessage[]): AgentMessage[] {
+ return messages.flatMap((message): AgentMessage[] => {
+ if (message.role === "toolResult" && message.toolName === "update_plan") return [];
+ if (message.role === "assistant") {
+ const content = message.content.filter((block) => block.type !== "toolCall" || block.name !== "update_plan");
+ return content.length > 0 ? [{ ...message, content }] : [];
+ }
+ if (message.role === "compactionSummary" || message.role === "branchSummary") {
+ return [{ ...message, summary: stripCurrentPlanSnapshot(message.summary) }];
+ }
+ return [message];
+ });
+}
+
/** Maximum characters for a tool result in serialized summaries. */
const TOOL_RESULT_MAX_CHARS = 2000;
@@ -105,6 +121,7 @@ function truncateForSummary(text: string, maxChars: number): string {
*
* Tool results are truncated to keep the summarization request within
* reasonable token budgets. Full content is not needed for summarization.
+ * Plan calls and results are omitted because the latest plan is attached separately.
*/
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
@@ -121,6 +138,7 @@ export function serializeConversation(messages: Message[]): string {
if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
+ if (block.name === "update_plan") continue;
const args = block.arguments as Record;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
@@ -139,6 +157,7 @@ export function serializeConversation(messages: Message[]): string {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
+ if (msg.toolName === "update_plan") continue;
const content = contentText(msg.content, "");
if (content) {
parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
diff --git a/packages/coding-agent/src/core/current-plan-state.ts b/packages/coding-agent/src/core/current-plan-state.ts
index 76cab2300..adc5b0590 100644
--- a/packages/coding-agent/src/core/current-plan-state.ts
+++ b/packages/coding-agent/src/core/current-plan-state.ts
@@ -1,3 +1,4 @@
+import { CURRENT_PLAN_SNAPSHOT_PREFIX } from "./plan-snapshot.ts";
import type { SessionManager } from "./session-manager.ts";
import {
createUpdatePlanToolDefinition,
@@ -57,7 +58,7 @@ export function formatCurrentPlanSnapshot(
: state.status === "inactive" && "plan" in state.operation
? state.operation.plan
: [];
- return `Current task context (latest plan):\n${JSON.stringify({ plan }, null, 2)}`;
+ return `${CURRENT_PLAN_SNAPSHOT_PREFIX}${JSON.stringify({ plan }, null, 2)}`;
}
export function createSessionUpdatePlanToolDefinition(
diff --git a/packages/coding-agent/src/core/plan-snapshot.ts b/packages/coding-agent/src/core/plan-snapshot.ts
new file mode 100644
index 000000000..2bd3a3f2c
--- /dev/null
+++ b/packages/coding-agent/src/core/plan-snapshot.ts
@@ -0,0 +1,25 @@
+export const CURRENT_PLAN_SNAPSHOT_PREFIX = "Current task context (latest plan):\n";
+
+// Branch navigation attaches this authority paragraph with the snapshot.
+const BRANCH_PLAN_AUTHORITY =
+ "The plan below is authoritative for the selected conversation branch. Plan references in the exploration summary above are historical.\n";
+
+/** Remove only the structured plan snapshot appended to a summary. */
+export function stripCurrentPlanSnapshot(summary: string): string {
+ const index = summary.lastIndexOf(CURRENT_PLAN_SNAPSHOT_PREFIX);
+ const authorityStart = index - BRANCH_PLAN_AUTHORITY.length;
+ const hasBranchAuthority = authorityStart >= 0 && summary.slice(authorityStart, index) === BRANCH_PLAN_AUTHORITY;
+ const attachmentStart = hasBranchAuthority ? authorityStart : index;
+ if (index < 0 || (attachmentStart > 0 && summary.slice(attachmentStart - 2, attachmentStart) !== "\n\n")) {
+ return summary;
+ }
+ try {
+ const snapshot: unknown = JSON.parse(summary.slice(index + CURRENT_PLAN_SNAPSHOT_PREFIX.length));
+ if (typeof snapshot !== "object" || snapshot === null || !("plan" in snapshot) || !Array.isArray(snapshot.plan)) {
+ return summary;
+ }
+ } catch {
+ return summary;
+ }
+ return summary.slice(0, attachmentStart === 0 ? 0 : attachmentStart - 2);
+}
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index ea3ed7278..07e5ddc52 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -20,6 +20,7 @@ import { createInterface } from "readline";
import { StringDecoder } from "string_decoder";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
+import { excludePlanItems } from "./compaction/utils.ts";
import {
type BashExecutionMessage,
type CustomMessage,
@@ -27,6 +28,7 @@ import {
createCompactionSummaryMessage,
createCustomMessage,
} from "./messages.ts";
+import { stripCurrentPlanSnapshot } from "./plan-snapshot.ts";
export const CURRENT_SESSION_VERSION = 3;
@@ -497,7 +499,27 @@ export function buildSessionContext(
): SessionContext {
const path = buildSessionPath(entries, leafId, byId);
const { thinkingLevel, model } = getSessionContextSettings(path);
- const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
+ const contextEntries = buildContextEntries(entries, leafId, byId);
+ const contextEntryIds = new Set(contextEntries.map((entry) => entry.id));
+ // Context order puts the latest compaction before its retained tail. Use path
+ // order to find the snapshot that supersedes earlier plan calls and summaries.
+ let snapshotIndex = -1;
+ for (let i = path.length - 1; i >= 0; i--) {
+ const entry = path[i];
+ if (
+ contextEntryIds.has(entry.id) &&
+ (entry.type === "compaction" || entry.type === "branch_summary") &&
+ stripCurrentPlanSnapshot(entry.summary) !== entry.summary
+ ) {
+ snapshotIndex = i;
+ break;
+ }
+ }
+ const supersededEntryIds = new Set(path.slice(0, Math.max(0, snapshotIndex)).map((entry) => entry.id));
+ const messages = contextEntries.flatMap((entry) => {
+ const projected = sessionEntryToContextMessages(entry);
+ return supersededEntryIds.has(entry.id) ? excludePlanItems(projected) : projected;
+ });
return { messages, thinkingLevel, model };
}
diff --git a/packages/coding-agent/test/compaction-serialization.test.ts b/packages/coding-agent/test/compaction-serialization.test.ts
index a12edaa94..de894c1cd 100644
--- a/packages/coding-agent/test/compaction-serialization.test.ts
+++ b/packages/coding-agent/test/compaction-serialization.test.ts
@@ -1,8 +1,69 @@
-import type { Message } from "@diffexai/diffex-ai";
+import type { AgentMessage } from "@diffexai/diffex-agent-core";
+import { fauxAssistantMessage, fauxToolCall, type Message } from "@diffexai/diffex-ai";
import { describe, expect, it } from "vitest";
-import { serializeConversation } from "../src/core/compaction/utils.ts";
+import { excludePlanItems, serializeConversation } from "../src/core/compaction/utils.ts";
+
+describe("excludePlanItems", () => {
+ it("strips snapshots from summary messages and preserves user content without mutation", () => {
+ const snapshot = 'Current task context (latest plan):\n{"plan":[]}';
+ const user: AgentMessage = { role: "user", content: snapshot, timestamp: 1 };
+ const messages: AgentMessage[] = [
+ { role: "compactionSummary", summary: `Compaction evidence\n\n${snapshot}`, tokensBefore: 100, timestamp: 1 },
+ { role: "branchSummary", summary: `Branch evidence\n\n${snapshot}`, fromId: "branch", timestamp: 1 },
+ user,
+ fauxAssistantMessage(fauxToolCall("update_plan", { clear: true })),
+ ];
+
+ const result = excludePlanItems(messages);
+
+ expect(result).toEqual([
+ { ...messages[0], summary: "Compaction evidence" },
+ { ...messages[1], summary: "Branch evidence" },
+ user,
+ ]);
+ expect(messages[0]).toHaveProperty("summary", `Compaction evidence\n\n${snapshot}`);
+ expect(messages[1]).toHaveProperty("summary", `Branch evidence\n\n${snapshot}`);
+ });
+});
describe("serializeConversation", () => {
+ it("omits plan calls and results while preserving other content in the same message", () => {
+ const messages: Message[] = [
+ fauxAssistantMessage([
+ { type: "text", text: "Implementation findings" },
+ { type: "thinking", thinking: "Reasoning about the implementation" },
+ fauxToolCall("update_plan", { clear: true, explanation: "Plan-only explanation" }),
+ fauxToolCall("read", { path: "src/example.ts" }),
+ ]),
+ {
+ role: "toolResult",
+ toolCallId: "plan-result",
+ toolName: "update_plan",
+ content: [{ type: "text", text: "Plan cleared." }],
+ isError: false,
+ timestamp: Date.now(),
+ },
+ {
+ role: "toolResult",
+ toolCallId: "read-result",
+ toolName: "read",
+ content: [{ type: "text", text: "File contents" }],
+ isError: false,
+ timestamp: Date.now(),
+ },
+ ];
+
+ const result = serializeConversation(messages);
+
+ expect(result).toContain("Implementation findings");
+ expect(result).toContain("Reasoning about the implementation");
+ expect(result).toContain('read(path="src/example.ts")');
+ expect(result).toContain("[Tool result]: File contents");
+ expect(result).not.toContain("update_plan");
+ expect(result).not.toContain("Plan-only explanation");
+ expect(result).not.toContain("Plan cleared.");
+ });
+
it("should truncate long tool results", () => {
const longContent = "x".repeat(5000);
const messages: Message[] = [
diff --git a/packages/coding-agent/test/plan-snapshot.test.ts b/packages/coding-agent/test/plan-snapshot.test.ts
new file mode 100644
index 000000000..60e0fab5a
--- /dev/null
+++ b/packages/coding-agent/test/plan-snapshot.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { CURRENT_PLAN_SNAPSHOT_PREFIX, stripCurrentPlanSnapshot } from "../src/core/plan-snapshot.ts";
+
+describe("stripCurrentPlanSnapshot", () => {
+ it.each([
+ { plan: [] },
+ { plan: [{ id: "active", step: "Active", status: "in_progress", success_criteria: ["Retain criteria"] }] },
+ { plan: [{ id: "finished", step: "Finished", status: "completed" }] },
+ ])("removes the attached snapshot while preserving narrative and file metadata", ({ plan }) => {
+ const narrative = "## Goal\nContinue work\n\n\nsrc/example.ts\n";
+ const snapshot = `${CURRENT_PLAN_SNAPSHOT_PREFIX}${JSON.stringify({ plan }, null, 2)}`;
+
+ expect(stripCurrentPlanSnapshot(`${narrative}\n\n${snapshot}`)).toBe(narrative);
+ expect(stripCurrentPlanSnapshot(snapshot)).toBe("");
+ });
+
+ it("removes the authority paragraph and snapshot attached by branch navigation", () => {
+ const narrative = "Branch narrative\n\n\nsrc/example.ts\n";
+ const attachment =
+ 'The plan below is authoritative for the selected conversation branch. Plan references in the exploration summary above are historical.\nCurrent task context (latest plan):\n{"plan":[]}';
+
+ expect(stripCurrentPlanSnapshot(`${narrative}\n\n${attachment}`)).toBe(narrative);
+ expect(stripCurrentPlanSnapshot(attachment)).toBe("");
+ expect(stripCurrentPlanSnapshot(`${narrative}\n\n${attachment}\nMore narrative`)).toBe(
+ `${narrative}\n\n${attachment}\nMore narrative`,
+ );
+ });
+
+ it.each([
+ "Narrative without a plan",
+ `Mention of ${CURRENT_PLAN_SNAPSHOT_PREFIX}{"plan": []}`,
+ `Narrative\n\n${CURRENT_PLAN_SNAPSHOT_PREFIX}not JSON`,
+ `Narrative\n\n${CURRENT_PLAN_SNAPSHOT_PREFIX}{"other": []}`,
+ `Narrative\n\n${CURRENT_PLAN_SNAPSHOT_PREFIX}{"plan": []}\nMore narrative`,
+ ])("preserves text that is not an appended plan snapshot", (summary) => {
+ expect(stripCurrentPlanSnapshot(summary)).toBe(summary);
+ });
+});
diff --git a/packages/coding-agent/test/suite/compaction-plan-exclusion.test.ts b/packages/coding-agent/test/suite/compaction-plan-exclusion.test.ts
new file mode 100644
index 000000000..7c7db43ef
--- /dev/null
+++ b/packages/coding-agent/test/suite/compaction-plan-exclusion.test.ts
@@ -0,0 +1,166 @@
+import { fauxAssistantMessage, fauxToolCall } from "@diffexai/diffex-ai";
+import { afterEach, describe, expect, it } from "vitest";
+import type { CompactionPreparation } from "../../src/core/compaction/compaction.ts";
+import type { UpdatePlanItem } from "../../src/core/tools/update-plan.ts";
+import { createHarness, getMessageText, type Harness } from "./harness.ts";
+
+const originalPlan: UpdatePlanItem[] = [{ id: "old-step", step: "PLAN_ONLY_ORIGINAL_STEP", status: "in_progress" }];
+const latestPlan: UpdatePlanItem[] = [
+ {
+ id: "latest-step",
+ step: "PLAN_ONLY_LATEST_STEP",
+ status: "in_progress",
+ success_criteria: ["PLAN_ONLY_SUCCESS_CRITERION"],
+ },
+];
+
+describe("Compaction plan exclusion", () => {
+ const harnesses: Harness[] = [];
+
+ afterEach(async () => {
+ while (harnesses.length > 0) await harnesses.pop()?.cleanup();
+ });
+
+ it.each([1, 100])("excludes plan tool history with keepRecentTokens=%i", async (keepRecentTokens) => {
+ const preparations: CompactionPreparation[] = [];
+ const harness = await createHarness({
+ settings: { compaction: { enabled: false, keepRecentTokens, reserveTokens: 1024 } },
+ extensionFactories: [
+ (diffex) =>
+ diffex.on("session_before_compact", (event) => {
+ preparations.push(event.preparation);
+ }),
+ ],
+ });
+ harnesses.push(harness);
+ harness.setResponses([
+ fauxAssistantMessage(
+ [
+ { type: "text", text: "Implementation evidence preserved." },
+ fauxToolCall("update_plan", { plan: originalPlan }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage(fauxToolCall("update_plan", { plan: latestPlan }), { stopReason: "toolUse" }),
+ fauxAssistantMessage("Implementation evidence preserved. ".repeat(100)),
+ fauxAssistantMessage("Ready."),
+ ]);
+ await harness.session.prompt("Inspect the implementation.");
+ if (keepRecentTokens === 100) await harness.session.prompt("Continue with the implementation. ".repeat(100));
+ const requests: string[] = [];
+ harness.setResponses(
+ Array.from({ length: 2 }, () => (context) => {
+ requests.push(context.messages.map(getMessageText).join("\n"));
+ return fauxAssistantMessage("Compacted implementation evidence.");
+ }),
+ );
+
+ const result = await harness.session.compact();
+
+ expect(requests.length).toBeGreaterThan(0);
+ const summarizedContext = requests.join("\n");
+ expect(summarizedContext).toContain("Implementation evidence preserved.");
+ expect(summarizedContext).not.toContain("PLAN_ONLY_");
+ expect(summarizedContext).not.toContain("update_plan");
+ expect(preparations).toHaveLength(1);
+ expect(JSON.stringify(preparations)).not.toContain("PLAN_ONLY_");
+ expect(JSON.stringify(preparations)).not.toContain("update_plan");
+ expect(result.summary).toContain(JSON.stringify({ plan: latestPlan }, null, 2));
+ expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "message")).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ message: expect.objectContaining({ role: "toolResult", toolName: "update_plan" }),
+ }),
+ ]),
+ );
+ });
+
+ it.each([
+ { mode: "manual", state: "active" },
+ { mode: "auto", state: "active" },
+ { mode: "manual", state: "completed" },
+ { mode: "auto", state: "completed" },
+ { mode: "manual", state: "cleared" },
+ { mode: "auto", state: "cleared" },
+ ])(
+ "excludes the attached snapshot on the next $mode compaction with a $state plan after resume",
+ async ({ mode, state }) => {
+ const settings = { compaction: { enabled: false, keepRecentTokens: 100, reserveTokens: 1024 } };
+ const harness = await createHarness({ settings });
+ harnesses.push(harness);
+ const compactSession = async (activeHarness: Harness) => {
+ if (mode === "manual") await activeHarness.session.compact();
+ else {
+ const session = activeHarness.session as unknown as {
+ _runAutoCompaction: (reason: "threshold", willRetry: boolean) => Promise;
+ };
+ await session._runAutoCompaction("threshold", false);
+ expect(activeHarness.eventsOfType("compaction_end").at(-1)?.result).toBeDefined();
+ }
+ };
+ harness.setResponses([
+ fauxAssistantMessage(fauxToolCall("update_plan", { plan: originalPlan }), { stopReason: "toolUse" }),
+ fauxAssistantMessage("Initial findings."),
+ fauxAssistantMessage("Ready."),
+ fauxAssistantMessage("Narrative checkpoint."),
+ ]);
+ await harness.session.prompt("Inspect the implementation.");
+ await harness.session.prompt("Continue with the implementation. ".repeat(100));
+ await compactSession(harness);
+ const firstCompaction = harness.sessionManager.getLeafEntry();
+ expect(firstCompaction?.type).toBe("compaction");
+ if (firstCompaction?.type !== "compaction") throw new Error("Missing first compaction");
+ expect(firstCompaction.summary).toContain("PLAN_ONLY_ORIGINAL_STEP");
+
+ const resumed = await createHarness({ settings, sessionManager: harness.sessionManager });
+ harnesses.push(resumed);
+ const finalPlan =
+ state === "cleared"
+ ? []
+ : latestPlan.map((item) => ({
+ ...item,
+ status: state === "completed" ? "completed" : item.status,
+ }));
+ resumed.setResponses([
+ fauxAssistantMessage(
+ fauxToolCall("update_plan", state === "cleared" ? { clear: true } : { plan: finalPlan }),
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage("New implementation evidence."),
+ fauxAssistantMessage("Ready again."),
+ ]);
+ await resumed.session.prompt("Implement the change.");
+ await resumed.session.prompt("Continue with verification. ".repeat(100));
+ const requests: string[] = [];
+ resumed.setResponses([
+ (context) => {
+ requests.push(context.messages.map(getMessageText).join("\n"));
+ return fauxAssistantMessage("Updated narrative checkpoint.");
+ },
+ ]);
+ await compactSession(resumed);
+
+ expect(requests).toHaveLength(1);
+ expect(requests[0]).toContain("\nNarrative checkpoint.\n");
+ expect(requests[0]).toContain("New implementation evidence.");
+ expect(requests[0]).not.toContain("PLAN_ONLY_");
+ expect(requests[0]).not.toContain("Current task context (latest plan)");
+ for (const heading of [
+ "Goal",
+ "Constraints & Preferences",
+ "Progress",
+ "Key Decisions",
+ "Next Steps",
+ "Critical Context",
+ ]) {
+ expect(requests[0]).toContain(`## ${heading}`);
+ }
+ const lastCompaction = resumed.sessionManager.getLeafEntry();
+ expect(lastCompaction?.type).toBe("compaction");
+ if (lastCompaction?.type !== "compaction") throw new Error("Missing second compaction");
+ expect(lastCompaction.summary).toContain(JSON.stringify({ plan: finalPlan }, null, 2));
+ expect(lastCompaction.summary).not.toContain("PLAN_ONLY_ORIGINAL_STEP");
+ expect(firstCompaction.summary).toContain("PLAN_ONLY_ORIGINAL_STEP");
+ },
+ );
+});
diff --git a/packages/coding-agent/test/suite/regressions/251-plan-context-duplication.test.ts b/packages/coding-agent/test/suite/regressions/251-plan-context-duplication.test.ts
new file mode 100644
index 000000000..2713b97be
--- /dev/null
+++ b/packages/coding-agent/test/suite/regressions/251-plan-context-duplication.test.ts
@@ -0,0 +1,174 @@
+import { writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { fauxAssistantMessage, fauxToolCall, type Message } from "@diffexai/diffex-ai";
+import { afterEach, describe, expect, it } from "vitest";
+import type { UpdatePlanItem } from "../../../src/core/tools/update-plan.ts";
+import { createHarness, getMessageText, type Harness } from "../harness.ts";
+
+const oldPlan: UpdatePlanItem[] = [{ id: "old", step: "PLAN_ONLY_OLD", status: "in_progress" }];
+const latestPlan: UpdatePlanItem[] = [
+ { id: "latest", step: "PLAN_ONLY_LATEST", status: "in_progress", success_criteria: ["PLAN_ONLY_CRITERIA"] },
+];
+
+describe("PR 251 plan context duplication", () => {
+ const harnesses: Harness[] = [];
+
+ afterEach(async () => {
+ while (harnesses.length > 0) await harnesses.pop()?.cleanup();
+ });
+
+ it.each(["manual", "auto"] as const)("removes superseded plan messages from the retained %s tail", async (mode) => {
+ const settings = { compaction: { enabled: false, keepRecentTokens: 1000, reserveTokens: 1024 } };
+ const harness = await createHarness({ settings });
+ harnesses.push(harness);
+ const path = join(harness.tempDir, "evidence.txt");
+ writeFileSync(path, "Retained file evidence.");
+ harness.setResponses([
+ fauxAssistantMessage("Older implementation evidence. ".repeat(1000)),
+ fauxAssistantMessage(fauxToolCall("update_plan", { plan: oldPlan }), { stopReason: "toolUse" }),
+ fauxAssistantMessage(
+ [
+ { type: "text", text: "Retained assistant evidence." },
+ fauxToolCall("update_plan", { plan: latestPlan }),
+ fauxToolCall("read", { path }),
+ ],
+ { stopReason: "toolUse" },
+ ),
+ fauxAssistantMessage("Ready."),
+ ]);
+ await harness.session.prompt("Inspect the implementation.");
+ await harness.session.prompt("Update the plan and read the evidence.");
+ harness.setResponses([fauxAssistantMessage("Compacted narrative."), fauxAssistantMessage("Turn narrative.")]);
+ if (mode === "manual") await harness.session.compact();
+ else {
+ const session = harness.session as unknown as {
+ _runAutoCompaction: (reason: "threshold", willRetry: boolean) => Promise;
+ };
+ await session._runAutoCompaction("threshold", false);
+ expect(harness.eventsOfType("compaction_end").at(-1)?.result).toBeDefined();
+ }
+ // Verify the raw retained tail still contains plan history, without deleting persisted entries.
+ expect(JSON.stringify(harness.sessionManager.buildContextEntries())).toContain('"toolName":"update_plan"');
+ const persisted = structuredClone(harness.sessionManager.getEntries());
+ const resumed = await createHarness({ settings, sessionManager: harness.sessionManager });
+ harnesses.push(resumed);
+ resumed.session.agent.state.messages = resumed.sessionManager.buildSessionContext().messages;
+ for (const activeHarness of [harness, resumed]) {
+ const requests: Message[][] = [];
+ activeHarness.setResponses([
+ (context) => {
+ requests.push(context.messages);
+ return fauxAssistantMessage("Continued.");
+ },
+ ]);
+ await activeHarness.session.prompt("Continue after compaction.");
+ const context = JSON.stringify(requests[0]);
+ expect(context).not.toContain("update_plan");
+ expect(context).not.toContain("PLAN_ONLY_OLD");
+ expect(context.match(/PLAN_ONLY_LATEST/g)).toHaveLength(1);
+ expect(context).toContain("Retained assistant evidence.");
+ expect(context).toContain("Retained file evidence.");
+ expect(requests[0]).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "toolResult", toolName: "read" }),
+ expect.objectContaining({
+ role: "assistant",
+ content: expect.arrayContaining([expect.objectContaining({ name: "read" })]),
+ }),
+ ]),
+ );
+ }
+ expect(harness.sessionManager.getEntries().slice(0, persisted.length)).toEqual(persisted);
+ // Updates made after the snapshot must still reach the next agent request, including after replay.
+ resumed.setResponses([
+ fauxAssistantMessage(fauxToolCall("update_plan", { plan: oldPlan }), { stopReason: "toolUse" }),
+ (context) => {
+ expect(JSON.stringify(context.messages)).toContain("update_plan");
+ expect(JSON.stringify(context.messages)).toContain("PLAN_ONLY_OLD");
+ return fauxAssistantMessage("Plan updated.");
+ },
+ ]);
+ await resumed.session.prompt("Revise the plan.");
+ expect(JSON.stringify(resumed.sessionManager.buildSessionContext().messages)).toContain(
+ '"toolName":"update_plan"',
+ );
+ });
+
+ it("excludes prior compaction and branch snapshots from repeated tree summarization", async () => {
+ const harness = await createHarness({
+ settings: { compaction: { enabled: false, keepRecentTokens: 100, reserveTokens: 1024 } },
+ });
+ harnesses.push(harness);
+ harness.setResponses([
+ fauxAssistantMessage("Root implementation evidence."),
+ fauxAssistantMessage(fauxToolCall("update_plan", { plan: oldPlan }), { stopReason: "toolUse" }),
+ fauxAssistantMessage("Later implementation evidence. ".repeat(1000)),
+ fauxAssistantMessage("Ready."),
+ fauxAssistantMessage("Compaction narrative."),
+ ]);
+ await harness.session.prompt("Start work.");
+ const targetId = harness.sessionManager.getLeafId();
+ if (!targetId) throw new Error("Missing root target");
+ await harness.session.prompt("Plan the later work.");
+ await harness.session.prompt("Keep this recent context. ".repeat(100));
+ await harness.session.compact();
+ const compaction = harness.sessionManager.getLeafEntry();
+ if (compaction?.type !== "compaction") throw new Error("Missing compaction entry");
+ const persistedCompaction = structuredClone(compaction);
+ const requests: string[] = [];
+ for (const narrative of ["First exploration narrative.", "Second exploration narrative."]) {
+ harness.setResponses([
+ (context) => {
+ requests.push(context.messages.map(getMessageText).join("\n"));
+ return fauxAssistantMessage(narrative);
+ },
+ ]);
+ const destination = requests.length === 0 ? targetId : compaction.id;
+ const result = await harness.session.navigateTree(destination, { summarize: true });
+ expect(result.summaryEntry?.summary).toContain(
+ JSON.stringify({ plan: destination === targetId ? [] : oldPlan }, null, 2),
+ );
+ }
+ expect(requests).toHaveLength(2);
+ expect(requests[0]).toContain("Compaction narrative.");
+ expect(requests[1]).toContain("First exploration narrative.");
+ for (const request of requests) {
+ expect(request).not.toContain("PLAN_ONLY_");
+ expect(request).not.toContain("update_plan");
+ expect(request).not.toContain("Current task context (latest plan)");
+ expect(request).not.toContain("The plan below is authoritative");
+ }
+ // The new branch snapshot supersedes the selected compaction's snapshot too.
+ harness.setResponses([
+ (context) => {
+ const text = JSON.stringify(context.messages);
+ expect(text.match(/Current task context \(latest plan\)/g)).toHaveLength(1);
+ expect(text.match(/PLAN_ONLY_OLD/g)).toHaveLength(1);
+ expect(text).not.toContain("update_plan");
+ expect(text).toContain("Compaction narrative.");
+ expect(text).toContain("Second exploration narrative.");
+ return fauxAssistantMessage("Continued selected branch.");
+ },
+ ]);
+ await harness.session.prompt("Continue the selected branch.");
+ expect(harness.sessionManager.getEntry(compaction.id)).toEqual(persistedCompaction);
+ // The branch attachment must also be excluded from the next compaction request.
+ harness.setResponses([fauxAssistantMessage("New recent context.")]);
+ await harness.session.prompt("Keep the new recent context. ".repeat(200));
+ const compactionRequests: string[] = [];
+ harness.setResponses(
+ Array.from({ length: 2 }, () => (context) => {
+ compactionRequests.push(context.messages.map(getMessageText).join("\n"));
+ return fauxAssistantMessage("Updated compaction narrative.");
+ }),
+ );
+ await harness.session.compact();
+ expect(compactionRequests.length).toBeGreaterThan(0);
+ const summarized = compactionRequests.join("\n");
+ expect(summarized).toContain("Second exploration narrative.");
+ expect(summarized).not.toContain("PLAN_ONLY_");
+ expect(summarized).not.toContain("Current task context (latest plan)");
+ expect(summarized).not.toContain("The plan below is authoritative");
+ expect(JSON.stringify(harness.session.messages).match(/Current task context \(latest plan\)/g)).toHaveLength(1);
+ });
+});