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); + }); +});