diff --git a/src/browser/features/RightSidebar/PostCompactionSection.tsx b/src/browser/features/RightSidebar/PostCompactionSection.tsx index 8b86a32927e..f31f02b8792 100644 --- a/src/browser/features/RightSidebar/PostCompactionSection.tsx +++ b/src/browser/features/RightSidebar/PostCompactionSection.tsx @@ -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 = (props) => { @@ -70,7 +70,7 @@ export const PostCompactionSection: React.FC = (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; } @@ -91,8 +91,8 @@ export const PostCompactionSection: React.FC = (prop - 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. diff --git a/src/browser/utils/messages/attachmentRenderer.ts b/src/browser/utils/messages/attachmentRenderer.ts index 15214ed1cd0..77be14d0641 100644 --- a/src/browser/utils/messages/attachmentRenderer.ts +++ b/src/browser/utils/messages/attachmentRenderer.ts @@ -22,6 +22,13 @@ function wrapSystemUpdate(content: string): string { 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. */ @@ -140,13 +147,13 @@ function renderPlanFileReferenceWithBudget( 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) { - 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; } diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index 32acfa65dfa..dad68d2a500 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -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", () => { @@ -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[] = [ { @@ -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(""); - - expect(result[4].id).toBe("user-2"); - }); -}); diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index 3afbaa3b4f2..4a6fecd86ae 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -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. @@ -93,6 +91,18 @@ export function addInterruptedSentinel(messages: MuxMessage[]): MuxMessage[] { 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. @@ -137,14 +147,7 @@ export function injectAgentTransition( // 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) { @@ -213,56 +216,6 @@ ${planContent} // 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. diff --git a/src/cli/debug/replay-verify.ts b/src/cli/debug/replay-verify.ts index 186d3287602..a962bb6d388 100644 --- a/src/cli/debug/replay-verify.ts +++ b/src/cli/debug/replay-verify.ts @@ -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 @@ -104,11 +108,11 @@ export async function replayVerifyCommand(workspaceId: string): Promise { 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++; diff --git a/src/common/constants/attachments.ts b/src/common/constants/attachments.ts index 3714956e37f..fa2bfac3420 100644 --- a/src/common/constants/attachments.ts +++ b/src/common/constants/attachments.ts @@ -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; diff --git a/src/common/orpc/schemas/continuousCompaction.ts b/src/common/orpc/schemas/continuousCompaction.ts index a87822d45fc..8e3a2e94c62 100644 --- a/src/common/orpc/schemas/continuousCompaction.ts +++ b/src/common/orpc/schemas/continuousCompaction.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import type { MuxMessage } from "@/common/types/message"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import { MuxMessageSchema, MuxTextPartSchema, @@ -27,56 +26,6 @@ const row: z.ZodType = MuxMessageSchema.extend({ ]) ), }).passthrough(); -const attachment: z.ZodType = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("plan_file_reference"), - planFilePath: z.string(), - planContent: z.string(), - }), - z.object({ - type: z.literal("todo_list"), - todos: z.array( - z.object({ content: z.string(), status: z.enum(["pending", "in_progress", "completed"]) }) - ), - }), - z.object({ - type: z.literal("edited_files_reference"), - files: z.array(z.object({ path: z.string(), diff: z.string(), truncated: z.boolean() })), - }), - z.object({ - type: z.literal("loaded_skills_snapshot"), - skills: z.array( - z.object({ - name: z.string(), - scope: z.enum(["project", "global", "built-in"]), - sha256: z.string(), - body: z.string(), - frontmatterYaml: z.string().optional(), - truncated: z.boolean().optional(), - }) - ), - }), - z.object({ - type: z.literal("completed_reports_index"), - reports: z.array( - z.object({ - id: z.string(), - kind: z.enum(["task", "workflow"]), - title: z.string().optional(), - completedAtMs: z.number(), - reportTokenEstimate: z.number().optional(), - }) - ), - }), - z.object({ type: z.literal("read_files_reference"), paths: z.array(z.string()) }), - z.object({ - type: z.literal("artifacts_index"), - artifacts: z.array( - z.object({ path: z.string(), latestVersion: z.number(), label: z.string().nullable() }) - ), - }), -]); - /** The journal is the write-ahead record for a request-only prefix swap, not a history boundary. */ export const ContinuousCompactionJournalSchema = z .object({ @@ -92,7 +41,9 @@ export const ContinuousCompactionJournalSchema = z partIndex: z.number().int().nonnegative(), metadataTemplate: metadata, }), - postCompactionAttachments: z.array(attachment), + // Unused since the boundary row carries the header. Older builds require the key, so new + // journals write an empty list and stay readable after a downgrade. + postCompactionAttachments: z.array(z.json()).optional(), // JSON wire prefix when representable; otherwise the pinned pipeline inputs below rebuild it. prefix: z.array(z.json()).optional(), // Preserve the initial request too: a fallback can rebuild system/tool context. diff --git a/src/common/types/durableEvent.ts b/src/common/types/durableEvent.ts index adbf943c831..57454860de2 100644 --- a/src/common/types/durableEvent.ts +++ b/src/common/types/durableEvent.ts @@ -70,7 +70,10 @@ export const TurnEnvelopeDataSchema = z.object({ planTransitionContentHash: BlobRefSchema.optional(), /** Plan file path referenced by the plan→exec handoff injection. */ planTransitionFilePath: z.string().optional(), - /** JSON-serialized PostCompactionAttachment[] injected this turn (blob-stored). */ + /** + * JSON-serialized PostCompactionAttachment[] that older builds injected per turn (blob-stored). + * Not written now: the boundary row in chat.jsonl carries the post-compaction context. + */ postCompactionAttachmentsHash: BlobRefSchema.optional(), /** * JSON-serialized MuxMessage: the partial-output continuation a refusal diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 31627743044..9cdc7b37500 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -903,6 +903,25 @@ export function isContextBudgetWarningMessage(message: MuxMessage): boolean { return message.metadata?.muxMetadata?.type === "context-budget-warning"; } +const POST_COMPACTION_CONTEXT_ID_PREFIX = "post-compaction-context-"; + +/** The request-only user row that renders a boundary's postCompactionContext. */ +export function postCompactionContextMessageId(boundaryId: string): string { + return `${POST_COMPACTION_CONTEXT_ID_PREFIX}${boundaryId}`; +} + +/** + * The post-compaction context row is context for the whole window, never a turn's request. Request + * rows placed before it would change the bytes right after the boundary. + */ +export function isPostCompactionContextMessage(message: MuxMessage): boolean { + return ( + message.role === "user" && + message.metadata?.synthetic === true && + message.id.startsWith(POST_COMPACTION_CONTEXT_ID_PREFIX) + ); +} + export function isRolloverBoundary(message: MuxMessage): boolean { return ( message.metadata?.contextBoundaryKind === "reset" && @@ -1034,6 +1053,27 @@ export interface PluginHookContextEntry { text: string; } +/** MuxMetadata.postCompactionContext: the rendered attachments of one compaction boundary. */ +export interface PostCompactionContext { + text: string; + /** Skills that `text` already loads, so path-based activation does not load them again. */ + skillNames: string[]; + /** + * Set when the header pushed a request over the context window (#1932). Requests whose + * requestHistorySequence is at least this value leave the header out. + */ + omittedFromRequestSequence?: number; + /** The plan file that `text` carries, as the file-change tracker baseline for its window. */ + plan?: PostCompactionPlan; +} + +/** PostCompactionContext.plan: a local plan file as compaction read it. */ +export interface PostCompactionPlan { + path: string; + content: string; + modifiedTimeMs: number; +} + /** * Record of a model-fallback chain application. `requestedModel` is the model * originally asked for; `refusedModels` lists every model that refused, in @@ -1222,6 +1262,13 @@ export interface MuxMetadata { compactionPublicationGeneration?: string | null; /** Durable provider-context boundary kind. Existing compaction rows are also boundaries via compactionBoundary. */ contextBoundaryKind?: PersistedContextBoundaryKind; + /** + * Post-compaction context (plan, todo, edited files, loaded skills) that compaction rendered + * for this boundary row. Requests insert it right after the boundary + * (insertPostCompactionContext); it never changes, so the request prefix stays byte-stable. + * Backend only: MuxMessageSchema strips it on the wire. + */ + postCompactionContext?: PostCompactionContext; toolPolicy?: ToolPolicy; // Tool policy active when this message was sent (user messages only) disableWorkspaceAgents?: boolean; // Whether workspace-local agent files were disabled for this user turn /** Snapshot of send options used for this user turn (for startup retry recovery). */ diff --git a/src/common/utils/readStringArray.ts b/src/common/utils/readStringArray.ts new file mode 100644 index 00000000000..e315ef2617c --- /dev/null +++ b/src/common/utils/readStringArray.ts @@ -0,0 +1,6 @@ +/** The string entries of an untrusted value; anything that is not an array reads as empty. */ +export function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 2f74ba10ba8..37f2b85d894 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -126,6 +126,7 @@ import { } from "@/node/services/agentSkills/pathSkillActivation"; import { isPtcBridgedExecution } from "@/node/services/ptc/toolBridge"; import type { GoalDefaults } from "@/constants/goals"; +import { readStringArray } from "@/common/utils/readStringArray"; import type { ProjectRef, WorkspaceMetadata } from "@/common/types/workspace"; export type WorkspaceHeartbeatSettings = NonNullable; @@ -809,12 +810,6 @@ function getXaiSearchSources( }); } -function readStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string") - : []; -} - function readIsoDate(value: unknown): string | undefined { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { return undefined; diff --git a/src/constants/compaction.ts b/src/constants/compaction.ts index 31c183b8891..3c0f61007b4 100644 --- a/src/constants/compaction.ts +++ b/src/constants/compaction.ts @@ -1,2 +1,8 @@ /** Shared by the qualified pending store's runtime producers and metadata readers. */ export const POST_COMPACTION_STATE_FILENAME = "post-compaction.json"; + +/** + * Bounds each post-compaction header build. A slow remote read (plan file over SSH) must not hold + * the paid summary unpublished, where a Stop would drop it. + */ +export const POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS = 30 * 1000; diff --git a/src/node/services/agentSession.changeDetection.test.ts b/src/node/services/agentSession.changeDetection.test.ts index 29a3e6b6683..77b05d7fbae 100644 --- a/src/node/services/agentSession.changeDetection.test.ts +++ b/src/node/services/agentSession.changeDetection.test.ts @@ -78,6 +78,29 @@ describe("AgentSession change detection", () => { expect(attachments[0].snippet).toContain("New step"); }); + it("clearExcept keeps only the named file's baseline, so only its edit is still detected", async () => { + const tracker = new FileChangeTracker(); + const planFile = join(tmpDir, "plan.md"); + const otherFile = join(tmpDir, "other.md"); + await writeFile(planFile, "v1"); + await writeFile(otherFile, "o1"); + await tracker.record(planFile, { content: "v1", timestamp: await getMtime(planFile) }); + await tracker.record(otherFile, { content: "o1", timestamp: await getMtime(otherFile) }); + + await tracker.clearExcept(planFile); + const newMtime = Date.now() + 1000; + for (const [file, content] of [ + [planFile, "v2"], + [otherFile, "o2"], + ]) { + await writeFile(file, content); + await utimes(file, newMtime / 1000, newMtime / 1000); + } + + const { attachments } = await tracker.getChangedAttachments(); + expect(attachments.map((attachment) => attachment.filename)).toEqual([planFile]); + }); + it("should update stored state only after commit()", async () => { const tracker = new FileChangeTracker(); const testFile = join(tmpDir, "plan.md"); diff --git a/src/node/services/agentSession.continuousCompaction.test.ts b/src/node/services/agentSession.continuousCompaction.test.ts index a8c0974484e..efe9825d4ef 100644 --- a/src/node/services/agentSession.continuousCompaction.test.ts +++ b/src/node/services/agentSession.continuousCompaction.test.ts @@ -415,7 +415,7 @@ describe("AgentSession continuous compaction wiring", () => { effectiveThinkingLevel: "off", toolNamesForSentinel: ["bash"], }, - attachments: [], + postCompactionContext: null, systemPrefix: [], cacheEnabled: false, }); @@ -672,7 +672,10 @@ describe("AgentSession continuous compaction wiring", () => { expect(await deps.prepareSwap([])).toBeNull(); // A real send captures them for its live turn. expect((await h.session.sendMessage("Working", sendOptions)).success).toBe(true); - expect(await deps.prepareSwap([])).toMatchObject({ attachments: [], cacheEnabled: false }); + expect(await deps.prepareSwap([])).toMatchObject({ + postCompactionContext: null, + cacheEnabled: false, + }); }); test("resumeless continuous fold cannot divert a later legacy compaction's saved follow-up", async () => { @@ -696,7 +699,6 @@ describe("AgentSession continuous compaction wiring", () => { model, tail: [retained], systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }) ).toBe(true); @@ -1106,7 +1108,6 @@ describe("AgentSession continuous compaction wiring", () => { tail: history.slice(-1), pendingFollowUp: followUp, systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }); failRead = true; diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 2286c032554..a2f7ffac8bb 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -1,14 +1,24 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import { mkdtemp, rm, stat, utimes, writeFile } from "fs/promises"; +import { afterEach, describe, expect, jest, mock, spyOn, test } from "bun:test"; +import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from "fs/promises"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; +import { POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS } from "@/constants/compaction"; import { createMuxMessage } from "@/common/types/message"; -import type { MuxMessage } from "@/common/types/message"; +import type { MuxMessage, PostCompactionContext } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { AgentSession } from "./agentSession"; +import { AttachmentService } from "./attachmentService"; import type { StreamMessageOptions } from "./aiService"; -import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; +import type { HistoryService } from "./historyService"; +import type { PostCompactionContextInput } from "./postCompactionContext"; +import { + compactSessionForTest, + createAgentSessionHarness, + createStartedTurnHandle, +} from "./agentSession.testHarness"; + +const fakeTimers = jest as typeof jest & { advanceTimersByTime: (ms: number) => void }; /** * Log purity: externally-edited files must produce a durable @@ -39,7 +49,7 @@ describe("AgentSession file-change notification (turn start)", () => { capturedRequests.push(opts.messages); return Promise.resolve(Ok(createStartedTurnHandle(session.closingSignal))); }); - const { historyService, session, cleanup } = await createAgentSessionHarness({ + const { historyService, session, config, cleanup } = await createAgentSessionHarness({ workspaceId: "ws", aiServiceOverrides: { streamMessage }, }); @@ -50,7 +60,26 @@ describe("AgentSession file-change notification (turn start)", () => { createMuxMessage("user-1", "user", "hello", { timestamp: Date.now() }) ); - return { historyService, session, capturedRequests, streamMessage }; + return { historyService, session, config, capturedRequests, streamMessage }; + } + + /** A new session on existing history, as after an app restart. */ + async function createSessionOn( + historyService: HistoryService, + config: Awaited>["config"] + ): Promise { + const { session } = await createAgentSessionHarness({ + workspaceId: "ws", + historyService, + config, + aiServiceOverrides: { + streamMessage: mock(() => + Promise.resolve(Ok(createStartedTurnHandle(session.closingSignal))) + ), + }, + }); + sessions.push(session); + return session; } /** Track a file via the session, then edit it externally (content + future mtime). */ @@ -116,6 +145,254 @@ describe("AgentSession file-change notification (turn start)", () => { expect(notificationRows).toHaveLength(1); }); + /** + * Run `body` with XUM_ROOT in a temp dir that holds the plan "plan v1" of workspace "ws", at + * its plan path or at its legacy path. + */ + async function withPlanFile( + body: (planFile: string) => Promise, + location: "plan" | "legacy" = "plan" + ): Promise { + tmpDir = await mkdtemp(join(tmpdir(), "mux-file-change-notification-")); + const previousRoot = process.env.XUM_ROOT; + process.env.XUM_ROOT = tmpDir; + try { + // The harness metadata is local workspace "ws" in project "project". + const planFile = + location === "plan" + ? join(tmpDir, "plans", "project", "ws.md") + : join(tmpDir, "plans", "ws.md"); + await mkdir(dirname(planFile), { recursive: true }); + await writeFile(planFile, "plan v1"); + await body(planFile); + } finally { + if (previousRoot === undefined) delete process.env.XUM_ROOT; + else process.env.XUM_ROOT = previousRoot; + } + } + + async function editPlanExternally(planFile: string, content = "plan v2"): Promise { + await writeFile(planFile, content); + const futureMtime = Date.now() + 1000; + await utimes(planFile, futureMtime / 1000, futureMtime / 1000); + } + + async function expectOnePlanNotification(historyService: HistoryService): Promise { + const historyResult = await historyService.getHistoryFromLatestBoundary("ws"); + expect(historyResult.success).toBe(true); + if (!historyResult.success) return; + const notificationRows = historyResult.data.filter((m) => m.id.startsWith("file-change-")); + expect(notificationRows).toHaveLength(1); + const notificationText = notificationRows[0].parts.find((p) => p.type === "text")?.text ?? ""; + expect(notificationText).toContain("+plan v2"); + } + + /** Compact for real; the boundary header holds the plan unless exclusions.json drops it. */ + async function compact(session: AgentSession, historyService: HistoryService): Promise { + await compactSessionForTest(session, historyService, "ws"); + // A user row after the boundary lets every later resume start a turn. + await historyService.appendToHistory( + "ws", + createMuxMessage("user-2", "user", "continue", { timestamp: Date.now() }) + ); + } + + async function writeExclusions(config: { sessionsDir: string }, excludedItems: string[]) { + const sessionDir = join(config.sessionsDir, "ws"); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, "exclusions.json"), JSON.stringify({ excludedItems })); + } + + async function resume(session: AgentSession): Promise { + const result = await session.resumeStream({ + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + }); + expect(result.success).toBe(true); + } + + async function countNotifications(historyService: HistoryService): Promise { + const historyResult = await historyService.getHistoryFromLatestBoundary("ws"); + if (!historyResult.success) throw new Error(historyResult.error); + return historyResult.data.filter((m) => m.id.startsWith("file-change-")).length; + } + + test("a plan edit between compaction and the next turn appends a notification", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + // A turn of this session before the boundary existed. + await resume(session); + // Nothing tracks the plan before this compaction, whose boundary header holds "plan v1". + await compact(session, historyService); + + await editPlanExternally(planFile); + await resume(session); + await expectOnePlanNotification(historyService); + }); + }); + + test("an external plan edit after compaction appends a notification", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + await compact(session, historyService); + // The first turn after compaction consumes the pending state and clears the tracker. + await resume(session); + + await editPlanExternally(planFile); + await resume(session); + await expectOnePlanNotification(historyService); + }); + }); + + test("a plan edit after a restart in the window appends a notification", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session, config } = await createSessionFixture(); + await compact(session, historyService); + await resume(session); + await session.dispose(); + + // The restarted session has no tracker state; the boundary row gives the plan baseline. + const restarted = await createSessionOn(historyService, config); + await editPlanExternally(planFile); + await resume(restarted); + await expectOnePlanNotification(historyService); + }); + }); + + test("plan tracking follows the boundary header, not exclusions changed after it", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session, config } = await createSessionFixture(); + await compact(session, historyService); + await writeExclusions(config, ["plan"]); + await resume(session); + + await editPlanExternally(planFile); + await resume(session); + await expectOnePlanNotification(historyService); + }); + }); + + test("a plan excluded at compaction stays untracked when the exclusion is removed", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session, config } = await createSessionFixture(); + await writeExclusions(config, ["plan"]); + await compact(session, historyService); + await writeExclusions(config, []); + await resume(session); + + await editPlanExternally(planFile); + await resume(session); + expect(await countNotifications(historyService)).toBe(0); + }); + }); + + test("a plan at the legacy path gets a baseline and is tracked again after the boundary", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + await compact(session, historyService); + + await editPlanExternally(planFile); + await resume(session); + await expectOnePlanNotification(historyService); + + // That turn consumed the pending state and tracked the legacy plan again. + // A resume after a notification row starts a turn only after a new user row. + await historyService.appendToHistory( + "ws", + createMuxMessage("user-3", "user", "go on", { timestamp: Date.now() }) + ); + await editPlanExternally(planFile, "plan v3"); + await resume(session); + expect(await countNotifications(historyService)).toBe(2); + }, "legacy"); + }); + + test("the plan baseline is the content the boundary header holds", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + const readPlan = AttachmentService.readPlan.bind(AttachmentService); + // An edit right after the header build reads the plan: a second read would see "plan v2". + const readSpy = spyOn(AttachmentService, "readPlan").mockImplementationOnce( + async (...args) => { + const plan = await readPlan(...args); + await editPlanExternally(planFile); + return plan; + } + ); + try { + await compact(session, historyService); + } finally { + readSpy.mockRestore(); + } + const history = await historyService.getHistoryFromLatestBoundary("ws"); + if (!history.success) throw new Error(history.error); + const boundary = history.data.find((message) => message.id === "boundary"); + expect(boundary?.metadata?.postCompactionContext?.text).toContain("plan v1"); + + await resume(session); + await expectOnePlanNotification(historyService); + }); + }); + + test("a plan state recorded after the boundary header stays the baseline", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + await compact(session, historyService); + // A tool after a mid-stream swap edits the plan and records the state the model saw. + await editPlanExternally(planFile); + await session.recordFileState(planFile, { + content: "plan v2", + timestamp: (await stat(planFile)).mtimeMs, + }); + + await resume(session); + expect(await countNotifications(historyService)).toBe(0); + }); + }); + + test("a header build that misses its time box leaves the plan untracked", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + const internals = session as unknown as { + buildPostCompactionContext( + input: PostCompactionContextInput + ): Promise; + renderPostCompactionContextHeader(input: PostCompactionContextInput): Promise; + }; + const render = internals.renderPostCompactionContextHeader.bind(session); + let releaseBuild!: () => void; + const buildReleased = new Promise((resolve) => (releaseBuild = resolve)); + let lateBuild: Promise | undefined; + spyOn(internals, "renderPostCompactionContextHeader").mockImplementationOnce((input) => { + lateBuild = buildReleased.then(() => render(input)); + return lateBuild; + }); + const input = { + diffs: [], + loadedSkills: [], + readFilePaths: [], + reportsCompletedBeforeMs: Date.now(), + }; + + fakeTimers.useFakeTimers(); + let built: Promise; + try { + built = internals.buildPostCompactionContext(input); + fakeTimers.advanceTimersByTime(POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS); + } finally { + fakeTimers.useRealTimers(); + } + expect(await built).toBeNull(); + + // The build reads the plan only after its time box ended, when the next window may run. + releaseBuild(); + await lateBuild; + await editPlanExternally(planFile); + await resume(session); + expect(await countNotifications(historyService)).toBe(0); + }); + }); + test("a failed notification append is not dropped: retry re-detects and persists it", async () => { tmpDir = await mkdtemp(join(tmpdir(), "mux-file-change-notification-")); diff --git a/src/node/services/agentSession.memoryContext.test.ts b/src/node/services/agentSession.memoryContext.test.ts index 70836d8c5e5..7bf29f2b9bf 100644 --- a/src/node/services/agentSession.memoryContext.test.ts +++ b/src/node/services/agentSession.memoryContext.test.ts @@ -11,6 +11,7 @@ import type { AgentSession } from "./agentSession"; import { createAgentSessionHarness } from "./agentSession.testHarness"; import { MEMORY_CONTEXT_FILE_NAME } from "./memoryContextPersistence"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import type { MuxMessage } from "@/common/types/message"; import { Err } from "@/common/types/result"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; @@ -61,7 +62,7 @@ interface PrivateSessionAccess { cache?: Map, persistence?: { windowKey: string | undefined } ) => Promise; - getPostCompactionAttachmentsIfNeeded: () => Promise; + consumePendingPostCompactionState: (history: readonly MuxMessage[]) => Promise; clearPostCompactionState: () => Promise; applyContextResetSideEffects: () => Promise; } @@ -346,7 +347,7 @@ describe("AgentSession memory context", () => { else if (boundary === "new segment") await priv.clearPostCompactionState(); else { await writePendingPostCompactionState(path.join(config.sessionsDir, WORKSPACE_ID)); - await priv.getPostCompactionAttachmentsIfNeeded(); + await priv.consumePendingPostCompactionState([]); } } finally { await before.dispose(); @@ -474,7 +475,7 @@ describe("AgentSession memory context", () => { // Consume a pending compaction boundary (first stream after compaction). version = 2; await writePendingPostCompactionState(path.join(config.sessionsDir, WORKSPACE_ID)); - await priv.getPostCompactionAttachmentsIfNeeded(); + await priv.consumePendingPostCompactionState([]); expect(nameOf(await priv.resolveMemoryContext("test-model"))).toBe("/memories/global/v2.md"); expect(buildMemorySessionContext).toHaveBeenCalledTimes(2); diff --git a/src/node/services/agentSession.pendingOwnership.test.ts b/src/node/services/agentSession.pendingOwnership.test.ts index 9026ce9efd5..d75bb4b38f7 100644 --- a/src/node/services/agentSession.pendingOwnership.test.ts +++ b/src/node/services/agentSession.pendingOwnership.test.ts @@ -2,9 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import { readFile } from "fs/promises"; import * as path from "path"; -import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; -import { createMuxMessage } from "@/common/types/message"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import type { StreamEndEvent } from "@/common/types/stream"; import assert from "@/common/utils/assert"; @@ -18,10 +16,10 @@ const model = "openai:gpt-4o"; interface SessionAccess { contextController: { compactionHandler: CompactionHandler }; coordinator: TurnCoordinator; - turnsSinceLastAttachment: number; - getPostCompactionAttachmentsIfNeeded( - includeReadFiles: boolean - ): Promise; + pendingPostCompactionStateToAcknowledge: Awaited< + ReturnType + >; + consumePendingPostCompactionState(history: readonly MuxMessage[]): Promise; clearStartupAutoRetryAbandon(): Promise; maybeRetryCompactionOnContextExceeded(): Promise; } @@ -31,15 +29,9 @@ afterEach(() => { }); describe("pending snapshot consumers", () => { - test.each([ - { outcome: "success", periodic: false, replacement: true }, - { outcome: "context-exceeded", periodic: false, replacement: true }, - { outcome: "success", periodic: true, replacement: true }, - { outcome: "context-exceeded", periodic: true, replacement: true }, - { outcome: "context-exceeded", periodic: true, replacement: false }, - ] as const)( - "$outcome retains its request snapshot (periodic=$periodic, replacement=$replacement)", - async ({ outcome, periodic, replacement }) => { + test.each(["success", "context-exceeded"] as const)( + "%s settles its own request snapshot, not a successor's", + async (outcome) => { const completion = Promise.withResolvers(); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -82,14 +74,6 @@ describe("pending snapshot consumers", () => { input: { path: `/${id}.ts` }, output: { success: true, diff: `change ${id}` }, }, - { - type: "dynamic-tool", - toolCallId: `${id}-read`, - toolName: "file_read", - state: "output-available", - input: { path: `/${id}.ts` }, - output: { success: true }, - }, ]; const preparation = handler.beginPreparation(() => true); expect( @@ -107,7 +91,6 @@ describe("pending snapshot consumers", () => { model, tail: [], systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }) ).toBe(true); @@ -115,24 +98,7 @@ describe("pending snapshot consumers", () => { let policy: Promise | undefined; try { await publish("a"); - if (periodic) { - await session.getPostCompactionAttachmentsIfNeeded(true); - await handler.ackPendingStateConsumed(); - session.turnsSinceLastAttachment = TURNS_BETWEEN_ATTACHMENTS; - // Periodic injection reads current-epoch edits even when RLM read-path injection is off. - const recent = createMuxMessage("recent", "assistant", ""); - recent.parts = [ - { - type: "dynamic-tool", - toolCallId: "recent-edit", - toolName: "file_edit_replace_string", - state: "output-available", - input: { path: "/recent.ts" }, - output: { success: true, diff: "recent change" }, - }, - ]; - await h.historyService.appendToHistory(workspaceId, recent); - } + // A context-exceeded request with a header retries without it and discards its carryover. const consume = spyOn( handler, outcome === "success" ? "ackPendingStateConsumed" : "discardPendingState" @@ -142,16 +108,16 @@ describe("pending snapshot consumers", () => { createMuxMessage("user", "user", "continue") ); expect((await h.session.resumeStream({ model, agentId: "exec" })).success).toBe(true); + const hold = async () => { + entered.resolve(); + await release.promise; + }; if (outcome === "success") { - spyOn(session, "clearStartupAutoRetryAbandon").mockImplementationOnce(async () => { - entered.resolve(); - await release.promise; - }); + spyOn(session, "clearStartupAutoRetryAbandon").mockImplementationOnce(hold); } else { spyOn(session, "maybeRetryCompactionOnContextExceeded").mockImplementationOnce( async () => { - entered.resolve(); - await release.promise; + await hold(); return false; } ); @@ -179,40 +145,30 @@ describe("pending snapshot consumers", () => { const result = consumer.mock.results.at(-1); assert(result?.type === "return", "Expected an active completion consumer"); policy = result.value; + await publish("b"); + // A successor request captures B while A's policy is suspended. A retains its entry value. + await session.consumePendingPostCompactionState([]); + expect( + session.pendingPostCompactionStateToAcknowledge?.diffs.some( + (diff) => diff.path === "/b.ts" + ) + ).toBe(true); const pendingPath = path.join(h.config.sessionsDir, workspaceId, "post-compaction.json"); - let bytes: string | undefined; - if (replacement) { - await publish("b"); - // A successor request captures B while A's policy is suspended. A retains its entry value. - const attachments = await session.getPostCompactionAttachmentsIfNeeded(false); - expect( - attachments - ?.find((attachment) => attachment.type === "edited_files_reference") - ?.files.some((file) => file.path === "/b.ts") - ).toBe(true); - bytes = await readFile(pendingPath, "utf8"); - } + const bytes = await readFile(pendingPath, "utf8"); release.resolve(); await policy; expect(consume).toHaveBeenCalledTimes(1); - if (replacement) { - expect( - (await handler.peekPendingState())?.diffs.some((diff) => diff.path === "/b.ts") - ).toBe(true); - assert(bytes !== undefined, "Expected replacement file"); - const { - previousState: _previous, - previousStateGeneration: _generation, - previousStateBoundary: _boundary, - ...successor - } = JSON.parse(bytes) as Record; - // Retiring A also retires its crash fallback, while B's exact owner survives. - expect(JSON.parse(await readFile(pendingPath, "utf8"))).toEqual(successor); - } else { - // The failed periodic injection still owns A's retained read-path carryover after ack. - await publish("c"); - expect((await handler.peekPendingState())?.readFiles).toEqual(["/c.ts"]); - } + expect( + (await handler.peekPendingState())?.diffs.some((diff) => diff.path === "/b.ts") + ).toBe(true); + const { + previousState: _previous, + previousStateGeneration: _generation, + previousStateBoundary: _boundary, + ...successor + } = JSON.parse(bytes) as Record; + // Retiring A also retires its crash fallback, while B's exact owner survives. + expect(JSON.parse(await readFile(pendingPath, "utf8"))).toEqual(successor); if (outcome === "context-exceeded") { expect(calls).toBe(2); expect(h.session.isBusy()).toBe(true); diff --git a/src/node/services/agentSession.postCompactionAttachments.test.ts b/src/node/services/agentSession.postCompactionAttachments.test.ts index 82755b2de53..3bb0c341139 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, mock, afterEach } from "bun:test"; +import { describe, expect, test, mock, afterEach, spyOn } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -7,8 +7,12 @@ import type { PostCompactionAttachment, TodoListAttachment, } from "@/common/types/attachment"; -import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; -import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { + createMuxMessage, + type MuxMessage, + type PostCompactionContext, +} from "@/common/types/message"; +import assert from "@/common/utils/assert"; import { Err } from "@/common/types/result"; import { POST_COMPACTION_STATE_FILENAME } from "@/constants/compaction"; import type { Config } from "@/node/config"; @@ -71,17 +75,6 @@ function getEditedFilePaths(attachments: PostCompactionAttachment[]): string[] { return editedFilesAttachment?.files.map((file) => file.path) ?? []; } -function getLoadedSkillNames(attachments: PostCompactionAttachment[]): string[] { - const loadedSkillsAttachment = attachments.find( - ( - attachment - ): attachment is Extract => - attachment.type === "loaded_skills_snapshot" - ); - - return loadedSkillsAttachment?.skills.map((skill) => skill.name) ?? []; -} - function getLoadedSkillAttachment( attachments: PostCompactionAttachment[] ): Extract | undefined { @@ -108,52 +101,84 @@ function getAttachmentTypes( } const WORKSPACE_ID = "workspace-post-compaction-test"; +const MODEL = "openai:gpt-4o"; async function createSessionForHistory( historyService: HistoryService, config: Config, - runner?: EffectRunner + options: { runner?: EffectRunner; experiments?: ExperimentId[] } = {} ): Promise { const { session } = await createAgentSessionHarness({ workspaceId: WORKSPACE_ID, config, historyService, - ...(runner ? { streamManager: createTestStreamManager(undefined, runner) } : {}), + ...(options.runner + ? { streamManager: createTestStreamManager(undefined, options.runner) } + : {}), aiServiceOverrides: { getWorkspaceMetadata: mock(() => Promise.resolve(Err("metadata unavailable"))), + isExperimentEnabled: mock((experimentId: ExperimentId) => + (options.experiments ?? []).includes(experimentId) + ), }, }); return session; } interface PrivateSessionAccess { - compactionOccurred: boolean; - turnsSinceLastAttachment: number; contextController: { compactionHandler: CompactionHandler }; - getPostCompactionAttachmentsIfNeeded: () => Promise; -} - -async function getImmediatePostCompactionAttachments( - session: AgentSession -): Promise { - const privateSession = session as unknown as PrivateSessionAccess; - const attachments = await privateSession.getPostCompactionAttachmentsIfNeeded(); - expect(attachments).not.toBeNull(); - return attachments ?? []; + buildAttachmentsFromContext: ( + context: unknown + ) => Promise<{ attachments: PostCompactionAttachment[] }>; } -async function generatePeriodicPostCompactionAttachments( - session: AgentSession -): Promise { +/** + * Publish a compaction boundary over `epoch` and return the attachments the session built for + * it, plus the header that the published boundary row stores. + */ +async function compact( + session: AgentSession, + historyService: HistoryService, + epoch: MuxMessage[] = [], + boundaryMessageId = "boundary" +): Promise<{ attachments: PostCompactionAttachment[]; header: PostCompactionContext | undefined }> { const privateSession = session as unknown as PrivateSessionAccess; - - privateSession.compactionOccurred = true; - privateSession.turnsSinceLastAttachment = TURNS_BETWEEN_ATTACHMENTS - 1; - - const attachments = await privateSession.getPostCompactionAttachmentsIfNeeded(); - expect(attachments).not.toBeNull(); - - return attachments ?? []; + const build = spyOn(privateSession, "buildAttachmentsFromContext"); + try { + const handler = privateSession.contextController.compactionHandler; + expect( + await handler.persistContinuousCompaction({ + preparation: handler.beginPreparation(() => true), + publication: { + generation: await historyService + .getContinuousCompactionJournal(WORKSPACE_ID) + .captureGeneration(), + }, + attachmentMessages: epoch, + messages: epoch, + boundaryMessageId, + text: `${boundaryMessageId} summary`, + model: MODEL, + tail: [], + systemMessageTokens: 0, + shouldPersist: () => true, + }) + ).toBe(true); + expect(build).toHaveBeenCalledTimes(1); + const { attachments } = await (build.mock.results[0].value as Promise<{ + attachments: PostCompactionAttachment[]; + }>); + const history = await historyService.getHistoryFromLatestBoundary(WORKSPACE_ID); + assert(history.success, "Expected readable history"); + const boundary = history.data.find((message) => message.id === boundaryMessageId); + assert(boundary, "Expected the published boundary row"); + const header = boundary.metadata?.postCompactionContext; + // The boundary stores a header exactly when there is something to restore. + expect(header !== undefined).toBe(attachments.length > 0); + return { attachments, header }; + } finally { + build.mockRestore(); + } } async function writePendingPostCompactionState(args: { @@ -187,17 +212,22 @@ function getReadFilePaths(attachments: PostCompactionAttachment[]): string[] { return readFilesAttachment?.paths ?? []; } -describe("AgentSession post-compaction attachments", () => { +const RLM_EXPERIMENTS: ExperimentId[] = [ + EXPERIMENT_IDS.RLM, + EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING, +]; + +describe("AgentSession post-compaction context", () => { let historyCleanup: (() => Promise) | undefined; afterEach(async () => { + mock.restore(); await historyCleanup?.(); }); - test("a context boundary discards read carryover so later turns inject no pre-boundary paths", async () => { + test("a context boundary discards read carryover so the next compaction stores no pre-boundary paths", async () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const sessionDir = path.join(config.sessionsDir, WORKSPACE_ID); - expect( ( await historyService.appendToHistory( @@ -206,62 +236,67 @@ describe("AgentSession post-compaction attachments", () => { ) ).success ).toBe(true); - // A compaction persisted cumulative pre-boundary read paths... + // A compaction persisted cumulative pre-boundary read paths. await writePendingPostCompactionState({ - sessionDir: sessionDir, + sessionDir, diffs: [], loadedSkills: [], readFiles: ["/tmp/pre-boundary-read.ts"], }); - - const session = await createSessionForHistory(historyService, config); - const privateSession = session as unknown as { - getPostCompactionAttachmentsIfNeeded: ( - includeReadFiles: boolean - ) => Promise; - }; + const session = await createSessionForHistory(historyService, config, { + experiments: RLM_EXPERIMENTS, + }); try { - // ...which a turn injects (guards the fixture against silent rot). - const injected = await privateSession.getPostCompactionAttachmentsIfNeeded(true); - expect(injected).not.toBeNull(); - expect(getReadFilePaths(injected ?? [])).toEqual(["/tmp/pre-boundary-read.ts"]); - - // A new context segment starts (context reset / full history clear): - // the reset was meant to discard that context, so... + // Guards the fixture against silent rot: the reads are pending before the reset. + expect( + ( + await ( + session as unknown as PrivateSessionAccess + ).contextController.compactionHandler.peekPendingState() + )?.readFiles + ).toEqual(["/tmp/pre-boundary-read.ts"]); + // A new context segment starts (context reset / full history clear): the reset was meant + // to discard that context. expect((await historyService.clearHistory(WORKSPACE_ID)).success).toBe(true); await session.clearPostCompactionState(); - - // ...no later turn may re-inject pre-boundary paths — neither - // immediately from pending state nor via the periodic re-merge. - for (let turn = 0; turn <= TURNS_BETWEEN_ATTACHMENTS; turn++) { - expect(await privateSession.getPostCompactionAttachmentsIfNeeded(true)).toBeNull(); - } // Explicit destruction removes compatible legacy bytes so a downgrade cannot reload them. const stateExists = await fs.access(path.join(sessionDir, "post-compaction.json")).then( () => true, () => false ); expect(stateExists).toBe(false); - const restarted = await createSessionForHistory(historyService, config); - try { - expect( - await ( - restarted as unknown as PrivateSessionAccess - ).getPostCompactionAttachmentsIfNeeded() - ).toBeNull(); - } finally { - await restarted.dispose(); - } + const { attachments } = await compact(session, historyService); + expect(getReadFilePaths(attachments)).toEqual([]); } finally { await session.dispose(); } }); - test("extracts edited file diffs from the latest durable compaction boundary slice", async () => { + test.each<{ rlm: boolean; experiments: ExperimentId[] }>([ + { rlm: true, experiments: RLM_EXPERIMENTS }, + { rlm: false, experiments: [] }, + ])("stores pending reads only in RLM mode (rlm=$rlm)", async ({ rlm, experiments }) => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; + await writePendingPostCompactionState({ + sessionDir: path.join(config.sessionsDir, WORKSPACE_ID), + diffs: [], + loadedSkills: [], + readFiles: ["/tmp/read.ts"], + }); + const session = await createSessionForHistory(historyService, config, { experiments }); + try { + const { attachments } = await compact(session, historyService); + expect(getReadFilePaths(attachments)).toEqual(rlm ? ["/tmp/read.ts"] : []); + } finally { + await session.dispose(); + } + }); - const history: MuxMessage[] = [ + test("extracts edited file diffs from the latest durable compaction boundary slice", async () => { + const { historyService, config, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + const epoch: MuxMessage[] = [ createSuccessfulFileEditMessage( "stale-before-boundary", "/tmp/stale-before-boundary.ts", @@ -288,16 +323,9 @@ describe("AgentSession post-compaction attachments", () => { "@@ -1 +1 @@\n-before\n+after\n" ), ]; - - const workspaceId = "workspace-post-compaction-test"; - for (const msg of history) { - await historyService.appendToHistory(workspaceId, msg); - } - const session = await createSessionForHistory(historyService, config); - try { - const attachments = await generatePeriodicPostCompactionAttachments(session); + const { attachments } = await compact(session, historyService, epoch); expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent-epoch-2.ts"]); } finally { await session.dispose(); @@ -307,8 +335,7 @@ describe("AgentSession post-compaction attachments", () => { test("falls back safely when boundary markers are malformed", async () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; - - const history: MuxMessage[] = [ + const epoch: MuxMessage[] = [ createSuccessfulFileEditMessage("stale-edit", "/tmp/stale.ts", "@@ -1 +1 @@\n-old\n+stale\n"), createMuxMessage("malformed-boundary", "assistant", "malformed summary", { compacted: "user", @@ -321,16 +348,9 @@ describe("AgentSession post-compaction attachments", () => { "@@ -1 +1 @@\n-before\n+after\n" ), ]; - - const workspaceId = "workspace-post-compaction-test"; - for (const msg of history) { - await historyService.appendToHistory(workspaceId, msg); - } - const session = await createSessionForHistory(historyService, config); - try { - const attachments = await generatePeriodicPostCompactionAttachments(session); + const { attachments } = await compact(session, historyService, epoch); expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent.ts", "/tmp/stale.ts"]); } finally { await session.dispose(); @@ -344,7 +364,6 @@ describe("AgentSession post-compaction attachments", () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const sessionDir = path.join(config.sessionsDir, WORKSPACE_ID); - await writePendingPostCompactionState({ sessionDir, diffs: [], loadedSkills: [] }); await upsertSubagentReportArtifact({ locks: nodeSessionLocks, workspaceId: WORKSPACE_ID, @@ -356,10 +375,9 @@ describe("AgentSession post-compaction attachments", () => { nowMs: Date.now() - 60_000, }); const clock = makeTestEffectRunner(); - const session = await createSessionForHistory(historyService, config, clock.runner); - + const session = await createSessionForHistory(historyService, config, { runner: clock.runner }); try { - const attachments = await getImmediatePostCompactionAttachments(session); + const { attachments } = await compact(session, historyService); const index = attachments.find((attachment) => attachment.type === "completed_reports_index"); expect(index?.reports.map((report) => report.id)).toEqual(["task-before-compaction"]); } finally { @@ -368,17 +386,16 @@ describe("AgentSession post-compaction attachments", () => { } }); - test("immediately injects persisted loaded skills alongside todo and file attachments", async () => { + test("stores pending loaded skills alongside todo and file attachments", async () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const sessionDir = path.join(config.sessionsDir, WORKSPACE_ID); - const loadedSkill = createLoadedSkillFixture({ name: "react-effects", body: "Avoid unnecessary useEffect calls.", }); await writePendingPostCompactionState({ - sessionDir: sessionDir, + sessionDir, diffs: [ { path: "/tmp/post-compaction.ts", @@ -392,70 +409,57 @@ describe("AgentSession post-compaction attachments", () => { path.join(sessionDir, "todos.json"), JSON.stringify([{ content: "Verify loaded skills", status: "in_progress" }]) ); - const session = await createSessionForHistory(historyService, config); - try { - const attachments = await getImmediatePostCompactionAttachments(session); + const { attachments, header } = await compact(session, historyService); expect(getAttachmentTypes(attachments)).toEqual([ "todo_list", "loaded_skills_snapshot", "edited_files_reference", ]); expect(getTodoAttachment(attachments)?.todos[0]?.content).toBe("Verify loaded skills"); - expect(getLoadedSkillNames(attachments)).toEqual(["react-effects"]); expect(getLoadedSkillAttachment(attachments)?.skills[0]?.body).toContain( "Avoid unnecessary useEffect calls." ); expect(getEditedFilePaths(attachments)).toEqual(["/tmp/post-compaction.ts"]); + // Path-based activation reads the stored names, so it does not load the skill again. + expect(header?.skillNames).toEqual(["react-effects"]); } finally { await session.dispose(); } }); - test("reinjects cached loaded skills on later turns even after pending state is acknowledged", async () => { + test("a chained compaction keeps loaded skills from acknowledged carryover", async () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const sessionDir = path.join(config.sessionsDir, WORKSPACE_ID); - - const history: MuxMessage[] = [ - createMuxMessage("boundary-1", "assistant", "epoch 1 summary", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, - }), - createSuccessfulFileEditMessage( - "recent-edit", - "/tmp/recent-periodic.ts", - "@@ -1 +1 @@\n-before\n+after\n" - ), - ]; - - const workspaceId = "workspace-post-compaction-test"; - const session = await createSessionForHistory(historyService, config); const loadedSkill = createLoadedSkillFixture({ name: "react-effects", - body: "Persist this guardrail across follow-up turns.", + body: "Persist this guardrail across compactions.", + }); + const firstBoundary = createMuxMessage("boundary-1", "assistant", "epoch 1 summary", { + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, }); - try { // Acknowledged warmth belongs to the exact durable publication; seed its row and // pending file together so the test exercises the same identity as real compaction. const pending = new CompactionPendingState( path.join(sessionDir, POST_COMPACTION_STATE_FILENAME), - historyService.getCompactionPendingHistory(workspaceId) + historyService.getCompactionPendingHistory(WORKSPACE_ID) ); expect( ( await pending.publishBoundary({ - summaryMessage: history[0], - tailCopies: history.slice(1), + summaryMessage: firstBoundary, + tailCopies: [], updateExisting: false, attachments: { diffs: [], loadedSkills: [loadedSkill], readFiles: [] }, publication: { generation: await historyService - .getContinuousCompactionJournal(workspaceId) + .getContinuousCompactionJournal(WORKSPACE_ID) .captureGeneration(), }, isCurrent: () => true, @@ -464,18 +468,25 @@ describe("AgentSession post-compaction attachments", () => { }) ).success ).toBe(true); - expect(getLoadedSkillNames(await getImmediatePostCompactionAttachments(session))).toEqual([ - "react-effects", - ]); await ( session as unknown as PrivateSessionAccess ).contextController.compactionHandler.ackPendingStateConsumed(); - const attachments = await generatePeriodicPostCompactionAttachments(session); - expect(getLoadedSkillNames(attachments)).toEqual(["react-effects"]); + const recentEdit = createSuccessfulFileEditMessage( + "recent-edit", + "/tmp/recent.ts", + "@@ -1 +1 @@\n-before\n+after\n" + ); + const { attachments, header } = await compact( + session, + historyService, + [firstBoundary, recentEdit], + "boundary-2" + ); expect(getLoadedSkillAttachment(attachments)?.skills[0]?.body).toContain( - "Persist this guardrail across follow-up turns." + "Persist this guardrail across compactions." ); - expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent-periodic.ts"]); + expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent.ts"]); + expect(header?.skillNames).toEqual(["react-effects"]); } finally { await session.dispose(); } @@ -485,9 +496,8 @@ describe("AgentSession post-compaction attachments", () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const sessionDir = path.join(config.sessionsDir, WORKSPACE_ID); - await writePendingPostCompactionState({ - sessionDir: sessionDir, + sessionDir, diffs: [ { path: "/tmp/excluded-skills.ts", @@ -510,15 +520,14 @@ describe("AgentSession post-compaction attachments", () => { path.join(sessionDir, "exclusions.json"), JSON.stringify({ excludedItems: ["skills"] }) ); - const session = await createSessionForHistory(historyService, config); - try { - const attachments = await getImmediatePostCompactionAttachments(session); + const { attachments, header } = await compact(session, historyService); expect(getAttachmentTypes(attachments)).toEqual(["todo_list", "edited_files_reference"]); expect(getTodoAttachment(attachments)?.todos[0]?.content).toBe("Keep todo attached"); - expect(getLoadedSkillNames(attachments)).toEqual([]); expect(getEditedFilePaths(attachments)).toEqual(["/tmp/excluded-skills.ts"]); + // An excluded skill is not in the header, so path-based activation may load it. + expect(header?.skillNames).toEqual([]); } finally { await session.dispose(); } @@ -528,28 +537,18 @@ describe("AgentSession post-compaction attachments", () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const sessionDir = path.join(config.sessionsDir, WORKSPACE_ID); - await writePendingPostCompactionState({ sessionDir, diffs: [], loadedSkills: [] }); await recordArtifactVersion({ sessionDir, relPath: "chart.html", bytes: Buffer.from("

v1

"), label: "chart", }); - for (const enabled of [true, false]) { - const { session } = await createAgentSessionHarness({ - workspaceId: WORKSPACE_ID, - config, - historyService, - aiServiceOverrides: { - getWorkspaceMetadata: mock(() => Promise.resolve(Err("metadata unavailable"))), - isExperimentEnabled: mock( - (experimentId: ExperimentId) => enabled && experimentId === EXPERIMENT_IDS.ARTIFACTS - ), - }, + const session = await createSessionForHistory(historyService, config, { + experiments: enabled ? [EXPERIMENT_IDS.ARTIFACTS] : [], }); try { - const attachments = await getImmediatePostCompactionAttachments(session); + const { attachments } = await compact(session, historyService, [], `boundary-${enabled}`); expect(getAttachmentTypes(attachments).includes("artifacts_index")).toBe(enabled); } finally { await session.dispose(); diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index fc939896fd3..d147377ed50 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -5,13 +5,17 @@ import * as path from "path"; import { Err } from "@/common/types/result"; -import type { MuxMessage } from "@/common/types/message"; +import { postCompactionContextMessageId, type MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; +import type { AgentSession } from "./agentSession"; +import { insertPostCompactionContext } from "./postCompactionContext"; import { createTestHistoryService } from "./testHistoryService"; import { + compactSessionForTest, createAgentSessionHarness, createFailedTurnHandle, createStartedTurnHandle, + sessionCompactionHandler, } from "./agentSession.testHarness"; function contextExceededResult(messageId: string) { @@ -24,6 +28,31 @@ function contextExceededResult(messageId: string) { }; } +const HEADER = { text: "", skillNames: [] }; + +function createHeaderBoundary(): MuxMessage { + return { + id: "compaction-summary", + role: "assistant", + parts: [{ type: "text", text: "Summary" }], + metadata: { + timestamp: 1000, + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, + postCompactionContext: HEADER, + }, + }; +} + +/** Whether one streamMessage call's request renders the header of the boundary row. */ +function requestRendersHeader(streamMessage: ReturnType, call: number): boolean { + const { messages } = streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }; + return insertPostCompactionContext(messages).some( + (message) => message.id === postCompactionContextMessageId("compaction-summary") + ); +} + function createPersistedPostCompactionState(options: { filePath: string; diffs: Array<{ path: string; diff: string; truncated: boolean }>; @@ -31,6 +60,8 @@ function createPersistedPostCompactionState(options: { const payload = { version: 1 as const, createdAt: Date.now(), + // Pending state belongs to the boundary that createHeaderBoundary writes. + boundaryMessageId: "compaction-summary", diffs: options.diffs, }; @@ -52,10 +83,67 @@ describe("AgentSession post-compaction context retry", () => { return { historyService, config, sessionDir }; } + /** + * Resume a session whose first stream fails with context_exceeded, and wait until the retry + * streams. `beforeResume` runs on the new session before the resume. + */ + async function resumeIntoContextRetry( + fixture: Awaited>, + workspaceId: string, + beforeResume?: (session: AgentSession) => Promise + ) { + const aiEmitter = new EventEmitter(); + let resolveRetry: (() => void) | undefined; + const retried = new Promise((resolve) => { + resolveRetry = resolve; + }); + const streamMessage = mock((..._args: unknown[]) => { + if (streamMessage.mock.calls.length === 1) { + // Simulate a provider context limit error before any deltas. + aiEmitter.emit("error", { + workspaceId, + messageId: "assistant-ctx-exceeded", + error: "Context length exceeded", + errorType: "context_exceeded", + }); + return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); + } + resolveRetry?.(); + return Promise.resolve({ + success: true as const, + data: createStartedTurnHandle(session.closingSignal, "assistant-retry"), + }); + }); + const { session } = await createAgentSessionHarness({ + workspaceId, + config: fixture.config, + historyService: fixture.historyService, + aiEmitter, + aiServiceOverrides: { + streamMessage, + // No workspace metadata: the retry path must not depend on a runtime. + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); + await beforeResume?.(session); + + // Exercise the public resume facade so retry preparation has a real admitted owner. + await session.resumeStream({ + model: "openai:gpt-4o", + agentId: "exec", + } as unknown as SendMessageOptions); + await Promise.race([ + retried, + new Promise((_, reject) => setTimeout(() => reject(new Error("retry timeout")), 1000)), + ]); + return { session, streamMessage }; + } + test("retries once without post-compaction injection on context_exceeded", async () => { const workspaceId = "ws"; - const { historyService, config, sessionDir } = await createSessionsFixture(workspaceId); - const postCompactionPath = path.join(sessionDir, "post-compaction.json"); + const fixture = await createSessionsFixture(workspaceId); + const { historyService } = fixture; + const postCompactionPath = path.join(fixture.sessionDir, "post-compaction.json"); await createPersistedPostCompactionState({ filePath: postCompactionPath, @@ -69,12 +157,7 @@ describe("AgentSession post-compaction context retry", () => { }); const history: MuxMessage[] = [ - { - id: "compaction-summary", - role: "assistant", - parts: [{ type: "text", text: "Summary" }], - metadata: { timestamp: 1000, compacted: "user" }, - }, + createHeaderBoundary(), { id: "user-1", role: "user", @@ -88,36 +171,200 @@ describe("AgentSession post-compaction context retry", () => { } spyOn(historyService, "deleteMessage"); - const aiEmitter = new EventEmitter(); + const { session, streamMessage } = await resumeIntoContextRetry(fixture, workspaceId); + expect(streamMessage).toHaveBeenCalledTimes(2); - let resolveSecondCall: (() => void) | undefined; - const secondCall = new Promise((resolve) => { - resolveSecondCall = resolve; - }); + // Codex fast-retry scenario: a waiter that arrives only after the retry + // already started (and possibly finished) must still see the recorded + // "retry-started" outcome for this attempt instead of sampling live flags. + expect(await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded")).toBe( + "retry-started" + ); - let callCount = 0; - const streamMessage = mock((..._args: unknown[]) => { - callCount += 1; + expect(requestRendersHeader(streamMessage, 0)).toBe(true); + expect(requestRendersHeader(streamMessage, 1)).toBe(false); - if (callCount === 1) { - // Simulate a provider context limit error before any deltas. - aiEmitter.emit("error", { - workspaceId, - messageId: "assistant-ctx-exceeded", - error: "Context length exceeded", - errorType: "context_exceeded", - }); + expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( + "assistant-ctx-exceeded" + ); - return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); - } + // Pending post-compaction state should be discarded. + let exists = true; + try { + await fsPromises.stat(postCompactionPath); + } catch { + exists = false; + } + expect(exists).toBe(false); - resolveSecondCall?.(); - return Promise.resolve({ + await session.dispose(); + }); + + test("a header that a retry left out stays out after a restart", async () => { + const workspaceId = "ws-restart"; + const fixture = await createSessionsFixture(workspaceId); + const { historyService } = fixture; + await historyService.appendToHistory(workspaceId, createHeaderBoundary()); + await historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }); + const { session } = await resumeIntoContextRetry(fixture, workspaceId); + await session.dispose(); + + const streamMessage = mock((..._args: unknown[]) => + Promise.resolve({ success: true as const, - data: createStartedTurnHandle(session.closingSignal, "assistant-retry"), + data: createStartedTurnHandle(restarted.closingSignal, "assistant-after-restart"), + }) + ); + const { session: restarted } = await createAgentSessionHarness({ + workspaceId, + config: fixture.config, + historyService, + aiServiceOverrides: { + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); + await historyService.appendToHistory(workspaceId, { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Go on" }], + metadata: { timestamp: 1200 }, + }); + await restarted.resumeStream({ + model: "openai:gpt-4o", + agentId: "exec", + } as unknown as SendMessageOptions); + + expect(streamMessage).toHaveBeenCalledTimes(1); + expect(requestRendersHeader(streamMessage, 0)).toBe(false); + // chat.jsonl keeps the header text, so replay can rebuild the requests that sent it. + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + if (!history.success) throw new Error(history.error); + const boundary = history.data.find((message) => message.id === "compaction-summary"); + expect(boundary?.metadata?.postCompactionContext?.text).toBe(HEADER.text); + + await restarted.dispose(); + }); + + test("a retry after the window's first turn discards the acknowledged carryover", async () => { + const workspaceId = "ws-carryover"; + const fixture = await createSessionsFixture(workspaceId); + // Carryover needs a published boundary; a todo list gives its header content. + await fsPromises.writeFile( + path.join(fixture.sessionDir, "todos.json"), + JSON.stringify([{ content: "Keep going", status: "pending" }]) + ); + + const { session } = await resumeIntoContextRetry(fixture, workspaceId, async (session) => { + await compactSessionForTest(session, fixture.historyService, workspaceId); + await fixture.historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: Date.now() }, }); + // The window's first turn succeeded, so its snapshot is carryover now, not pending. + const compaction = sessionCompactionHandler(session); + await compaction.ackPendingStateConsumed(); + expect(await compaction.peekCarryoverState()).not.toBeNull(); }); + expect(await sessionCompactionHandler(session).peekCarryoverState()).toBeNull(); + await session.dispose(); + }); + + test("a failed carryover discard fails the turn, and the turn's retry can still recover", async () => { + const workspaceId = "ws-discard-fails"; + const fixture = await createSessionsFixture(workspaceId); + await fsPromises.writeFile( + path.join(fixture.sessionDir, "todos.json"), + JSON.stringify([{ content: "Keep going", status: "pending" }]) + ); + + const aiEmitter = new EventEmitter(); + const streamMessage = mock((..._args: unknown[]) => { + const call = streamMessage.mock.calls.length; + if (call === 3) { + return Promise.resolve({ + success: true as const, + data: createStartedTurnHandle(session.closingSignal, "assistant-retry"), + }); + } + aiEmitter.emit("error", { + workspaceId, + messageId: `assistant-ctx-exceeded-${call}`, + error: "Context length exceeded", + errorType: "context_exceeded", + }); + return Promise.resolve(contextExceededResult(`assistant-ctx-exceeded-${call}`)); + }); + const { session } = await createAgentSessionHarness({ + workspaceId, + config: fixture.config, + historyService: fixture.historyService, + aiEmitter, + aiServiceOverrides: { + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); + await compactSessionForTest(session, fixture.historyService, workspaceId); + await fixture.historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: Date.now() }, + }); + const compaction = sessionCompactionHandler(session); + await compaction.ackPendingStateConsumed(); + spyOn(compaction, "discardPendingState").mockRejectedValueOnce(new Error("disk")); + const options = { model: "openai:gpt-4o", agentId: "exec" } as unknown as SendMessageOptions; + + await session.resumeStream(options); + + // A headerless retry would leave the carryover for the next compaction to merge again. + expect( + await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded-1") + ).toBe("terminal"); + expect(streamMessage).toHaveBeenCalledTimes(1); + expect(await compaction.peekCarryoverState()).not.toBeNull(); + + // Retrying the same user turn once the disk recovers discards the carryover and retries. + await session.resumeStream(options); + expect( + await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded-2") + ).toBe("retry-started"); + expect(streamMessage).toHaveBeenCalledTimes(3); + expect(await compaction.peekCarryoverState()).toBeNull(); + + await session.dispose(); + }); + + test("does not retry without the header when leaving it out of chat.jsonl fails", async () => { + const workspaceId = "ws-omit-fails"; + const { historyService, config } = await createSessionsFixture(workspaceId); + await historyService.appendToHistory(workspaceId, createHeaderBoundary()); + await historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }); + const aiEmitter = new EventEmitter(); + const streamMessage = mock((..._args: unknown[]) => { + aiEmitter.emit("error", { + workspaceId, + messageId: "assistant-ctx-exceeded", + error: "Context length exceeded", + errorType: "context_exceeded", + }); + return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); + }); const { session } = await createAgentSessionHarness({ workspaceId, config, @@ -125,59 +372,128 @@ describe("AgentSession post-compaction context retry", () => { aiEmitter, aiServiceOverrides: { streamMessage, - // No workspace metadata: the retry path must not depend on a runtime. getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), }, }); + spyOn(historyService, "updateHistory").mockResolvedValueOnce(Err("disk")); - const options: SendMessageOptions = { + await session.resumeStream({ model: "openai:gpt-4o", agentId: "exec", - } as unknown as SendMessageOptions; + } as unknown as SendMessageOptions); - // Exercise the public resume facade so retry preparation has a real admitted owner. - await session.resumeStream(options); + expect(await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded")).toBe( + "terminal" + ); + expect(streamMessage).toHaveBeenCalledTimes(1); - // Wait for the retry call to happen. - await Promise.race([ - secondCall, - new Promise((_, reject) => setTimeout(() => reject(new Error("retry timeout")), 1000)), - ]); + await session.dispose(); + }); - expect(streamMessage).toHaveBeenCalledTimes(2); + test("does not retry when the request had no post-compaction header", async () => { + const workspaceId = "ws-no-header"; + const { historyService, config } = await createSessionsFixture(workspaceId); + await historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }); + + const aiEmitter = new EventEmitter(); + const streamMessage = mock((..._args: unknown[]) => { + aiEmitter.emit("error", { + workspaceId, + messageId: "assistant-ctx-exceeded", + error: "Context length exceeded", + errorType: "context_exceeded", + }); + return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); + }); + + const { session } = await createAgentSessionHarness({ + workspaceId, + config, + historyService, + aiEmitter, + aiServiceOverrides: { + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); + + await session.resumeStream({ + model: "openai:gpt-4o", + agentId: "exec", + } as unknown as SendMessageOptions); - // Codex fast-retry scenario: a waiter that arrives only after the retry - // already started (and possibly finished) must still see the recorded - // "retry-started" outcome for this attempt instead of sampling live flags. expect(await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded")).toBe( - "retry-started" + "terminal" ); + expect(streamMessage).toHaveBeenCalledTimes(1); - // With the options bag, arg[0] is the StreamMessageOptions object. - const firstOpts = (streamMessage as ReturnType).mock.calls[0][0] as Record< - string, - unknown - >; - expect(Array.isArray(firstOpts.postCompactionAttachments)).toBe(true); + await session.dispose(); + }); - const secondOpts = (streamMessage as ReturnType).mock.calls[1][0] as Record< - string, - unknown - >; - expect(secondOpts.postCompactionAttachments).toBeNull(); + // A compaction request trims its input to its budget, and the trim can drop the header row. + // An overflow after its own retry therefore does not show that the header caused it. + test("a compaction request that overflows after its own retry keeps the header", async () => { + const workspaceId = "ws-compaction-request"; + const { historyService, config } = await createSessionsFixture(workspaceId); + await historyService.appendToHistory(workspaceId, createHeaderBoundary()); + await historyService.appendToHistory(workspaceId, { + id: "compaction-request-1", + role: "user", + parts: [{ type: "text", text: "Summarize the conversation." }], + metadata: { + timestamp: 1100, + muxMetadata: { + type: "compaction-request", + rawCommand: "/compact", + parsed: {}, + source: "auto-compaction", + }, + }, + }); - expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( - "assistant-ctx-exceeded" - ); + const aiEmitter = new EventEmitter(); + const streamMessage = mock((..._args: unknown[]) => { + const messageId = `assistant-ctx-exceeded-${streamMessage.mock.calls.length}`; + aiEmitter.emit("error", { + workspaceId, + messageId, + error: "Context length exceeded", + errorType: "context_exceeded", + }); + return Promise.resolve(contextExceededResult(messageId)); + }); + const { session } = await createAgentSessionHarness({ + workspaceId, + config, + historyService, + aiEmitter, + aiServiceOverrides: { + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); - // Pending post-compaction state should be discarded. - let exists = true; - try { - await fsPromises.stat(postCompactionPath); - } catch { - exists = false; - } - expect(exists).toBe(false); + await session.resumeStream({ + model: "anthropic:claude-haiku-4-5", + agentId: "compact", + } as unknown as SendMessageOptions); + + expect( + await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded-1") + ).toBe("retry-started"); + expect( + await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded-2") + ).toBe("terminal"); + expect(streamMessage).toHaveBeenCalledTimes(2); + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + if (!history.success) throw new Error(history.error); + const boundary = history.data.find((message) => message.id === "compaction-summary"); + expect(boundary?.metadata?.postCompactionContext).toEqual(HEADER); await session.dispose(); }); @@ -194,6 +510,7 @@ describe("AgentSession post-compaction context retry", () => { diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], }); + await historyService.appendToHistory(workspaceId, createHeaderBoundary()); await historyService.appendToHistory(workspaceId, { id: "user-1", role: "user", @@ -301,6 +618,7 @@ describe("AgentSession post-compaction context retry", () => { diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], }); + await historyService.appendToHistory(workspaceId, createHeaderBoundary()); await historyService.appendToHistory(workspaceId, { id: "user-1", role: "user", @@ -381,6 +699,11 @@ describe("AgentSession post-compaction context retry", () => { ).toBe("terminal"); expect(callCount).toBe(2); + // Later requests leave the header out too. + await session.resumeStream(options); + expect(callCount).toBe(3); + expect(requestRendersHeader(streamMessage, 2)).toBe(false); + await session.dispose(); }); }); diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index bf1e0d0be58..c8e4fe73db1 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -2925,7 +2925,6 @@ describe("AgentSession startup auto-retry recovery", () => { modelString: string, options?: SendMessageOptions, openaiTruncationModeOverride?: "auto" | "disabled", - disablePostCompactionAttachments?: boolean, agentInitiated?: boolean ) => Promise< | { success: true; data: undefined } diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index a83b6faf671..d17c7a5ba4d 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -16,6 +16,7 @@ import { type AgentSessionStreamManager, } from "@/node/services/agentSession"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; +import type { CompactionHandler } from "@/node/services/compactionHandler"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; import type { HistoryService } from "@/node/services/historyService"; @@ -400,3 +401,35 @@ export async function createAgentSessionHarness( events, }; } + +/** The session's compaction handler, for tests that drive compaction directly. */ +export function sessionCompactionHandler(session: AgentSession): CompactionHandler { + return (session as unknown as { contextController: { compaction: CompactionHandler } }) + .contextController.compaction; +} + +/** Publish a real compaction boundary "boundary" with no tail; it builds its header as usual. */ +export async function compactSessionForTest( + session: AgentSession, + historyService: HistoryService, + workspaceId: string +): Promise { + const handler = sessionCompactionHandler(session); + const persisted = await handler.persistContinuousCompaction({ + preparation: handler.beginPreparation(() => true), + publication: { + generation: await historyService + .getContinuousCompactionJournal(workspaceId) + .captureGeneration(), + }, + attachmentMessages: [], + messages: [], + boundaryMessageId: "boundary", + text: "summary", + model: "anthropic:claude-sonnet-4-5", + tail: [], + systemMessageTokens: 0, + shouldPersist: () => true, + }); + assert(persisted, "Expected the compaction to persist"); +} diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b2803a015b7..7aeed23ec83 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -36,6 +36,7 @@ import { } from "./agentPlugins/promptHooks"; import { createContextBudgetRejectedMessage } from "@/common/utils/messages/contextBudgetRejection"; import { + isDurableContextBoundaryMarker, isProviderEligibleMessage, leavesCompactionFollowUpPending, sliceMessagesForProviderFromLatestContextBoundary, @@ -120,6 +121,7 @@ import type { } from "@/common/orpc/types"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { COMPACTION_RETRY_INPUT_BUDGET_SCALE } from "@/constants/continuousCompaction"; +import { POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS } from "@/constants/compaction"; import { readTodosForSessionDir } from "@/node/services/todos/todoStorage"; import { mergeTurnStopContinuation, @@ -174,6 +176,7 @@ import { import { FileChangeTracker, createFileChangeNotificationMessage, + type FileChangeDetection, type FileState, } from "@/node/services/utils/fileChangeTracker"; import type { Result } from "@/common/types/result"; @@ -219,11 +222,12 @@ import { type MuxMessage, type MuxMetadata, type PluginHookContextEntry, + type PostCompactionContext, + type PostCompactionPlan, type StartupRetrySendOptions, type WorkspaceTurnTaskCorrelation, } from "@/common/types/message"; import { toValidGoalId } from "@/common/types/goal"; -import { extractReadFilePaths, mergeReadFilePaths } from "@/common/utils/messages/extractReadFiles"; import type { RuntimeFactory } from "@/node/services/runtimeFactory/runtimeFactoryService"; import { carriedPlanReviewFeedback, @@ -272,6 +276,7 @@ import { inheritOpenWorkspaceTurnMetadata, computeKeepRecentTailStamp, isCompactionRequestMetadata, + isCompactionRequestTurn, type CompactionRequestMetadata, } from "./contextManagement/compactionRequests"; import { @@ -286,7 +291,7 @@ import type { Scope } from "effect"; import type { TelemetryService } from "./telemetryService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; -import { AttachmentService } from "./attachmentService"; +import { AttachmentService, type PlanFileRead } from "./attachmentService"; import { generateArtifactsIndexAttachment } from "./artifactVersionsOperations"; import type { TodoItem } from "@/common/types/tools"; import type { @@ -294,12 +299,15 @@ import type { PostCompactionAttachment, PostCompactionExclusions, } from "@/common/types/attachment"; -import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; +import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; import { - extractEditedFileDiffs, - type FileEditDiff, -} from "@/common/utils/messages/extractEditedFiles"; + findPostCompactionPlan, + findPostCompactionContextBoundaryId, + omitPostCompactionContextFrom, + renderPostCompactionContext, + type PostCompactionContextInput, +} from "./postCompactionContext"; import { buildCompactionMessageText } from "@/common/utils/compaction/compactionPrompt"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { getModelCapabilitiesResolved } from "@/common/utils/ai/modelCapabilities"; @@ -333,8 +341,6 @@ import { import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; import { createLoadedSkillSnapshot, - extractLoadedSkillSnapshotsFromMessages, - mergeLoadedSkillSnapshots, stringifyAgentSkillFrontmatter, } from "@/node/services/agentSkills/loadedSkillSnapshots"; import { createSkillArgumentSubstituter } from "@/node/services/agentSkills/skillArguments"; @@ -503,6 +509,20 @@ function goalAdvancementSendOptions( return isHeartbeat ? undefined : options; } +/** + * The boundary whose header a context_exceeded retry may leave out. A compaction request + * answers that error with its trim and its own retry. The trim can drop the header row, so a + * later overflow does not show that the header caused it. + */ +function findOmittableHeaderBoundaryId( + options: SendMessageOptions | undefined, + requestMessages: readonly MuxMessage[] +): string | undefined { + const muxMetadata = options?.muxMetadata as MuxMessageMetadata | undefined; + if (isCompactionRequestTurn(options?.agentId, muxMetadata, requestMessages)) return undefined; + return findPostCompactionContextBoundaryId(requestMessages); +} + type ResolvedSendMessageOptions = SendMessageOptions & { autoModelRoutingRecord?: AutoModelRoutingRecord; }; @@ -553,6 +573,19 @@ function overflowedModel(request: { modelString: string }, failedModel: string | return failedModel ?? request.modelString; } +/** The header's plan baseline for a plan read with a stat first; null without one. */ +function postCompactionPlan( + runtime: Runtime, + plan: PlanFileRead | null +): PostCompactionPlan | null { + if (plan?.modifiedTimeMs == null) return null; + return { + path: runtime.normalizePath(plan.planFilePath, "/"), + content: plan.planContent, + modifiedTimeMs: plan.modifiedTimeMs, + }; +} + function isProviderVisibleUserRow(message: MuxMessage): boolean { return ( message.role === "user" && isProviderEligibleMessage(message) && !isModelHiddenMessage(message) @@ -1729,19 +1762,7 @@ export class AgentSession { }; /** - * Track turns since last post-compaction attachment injection. - * Start at max to trigger immediate injection on first turn after compaction. - */ - private turnsSinceLastAttachment = TURNS_BETWEEN_ATTACHMENTS; - - /** - * Flag indicating compaction has occurred in this session. - * Used to enable the cooldown-based attachment injection. - */ - private compactionOccurred = false; - - /** - * Retain the exact injected snapshot so a late completion cannot consume a replacement. + * Retain the exact consumed snapshot so a late completion cannot consume a replacement. * * This is intentionally delayed until stream-end so a crash mid-stream doesn't lose the diffs. */ @@ -1798,9 +1819,18 @@ export class AgentSession { */ private turnStartedByUserMessage = false; - /** Track user message ids that already retried without post-compaction injection. */ + /** Track user message ids that already retried without the post-compaction header. */ private readonly postCompactionRetryAttempts = new Set(); + /** The boundary whose post-compaction header the active stream's request rendered. */ + private activeStreamPostCompactionBoundaryId: string | undefined; + + /** + * The latest boundary when turn start last took the plan baseline from it (null without a + * boundary); undefined until the first turn of this session (seedPlanBaselineFromBoundary). + */ + private planBaselineBoundaryId: string | null | undefined; + /** Backend start time for the current stream, used to avoid charging goals created mid-stream. */ private activeStreamStartedAtMs?: number; @@ -1841,9 +1871,6 @@ export class AgentSession { /** Last lifecycle snapshot emitted to live subscribers (used for change detection only). */ private lastEmittedStreamLifecycle: StreamLifecycleSnapshot | null = null; - /** Tracks whether the current stream included post-compaction attachments. */ - private activeStreamHadPostCompactionInjection = false; - /** * muxMetadata of the queued entry currently being dispatched, held from * dequeue until its sendMessage settles (the stream has started or failed). @@ -2078,7 +2105,7 @@ export class AgentSession { dispatchPendingFollowUp: async (summaryId, admissionStale) => { await this.dispatchPendingFollowUp(summaryId ?? undefined, admissionStale); }, - buildAttachments: (input) => this.buildAttachmentsFromContext(input), + buildPostCompactionContext: (input) => this.buildPostCompactionContext(input), emitChatEvent: (event) => this.emitChatEvent(event), onCompactionComplete: (metadata) => onCompactionComplete?.(metadata), onIdleCompactionOutcome: (success) => onIdleCompactionOutcome?.(success), @@ -6559,7 +6586,6 @@ export class AgentSession { modelForStream, optionsForStream, undefined, - undefined, agentInitiated, preparedTurnAbortController.signal, goalKind, @@ -6763,7 +6789,6 @@ export class AgentSession { modelForStream, optionsForStream, undefined, - undefined, internal?.agentInitiated, startupController?.signal, internal?.goalKind, @@ -7350,7 +7375,6 @@ export class AgentSession { ), recordFileState: this.fileChangeTracker.record.bind(this.fileChangeTracker), recordProposedPlan: this.recordProposedPlan, - postCompactionAttachments: null, resolveMemoryContext: (model) => this.resolveMemoryContext(model, cache, { windowKey }), workspaceGoalService: this.workspaceGoalService, goalTurnKind: goalKind, @@ -8770,7 +8794,6 @@ export class AgentSession { modelString: string, options?: ResolvedSendMessageOptions, openaiTruncationModeOverride?: "auto" | "disabled", - disablePostCompactionAttachments?: boolean, agentInitiated?: boolean, abortSignal?: AbortSignal, goalKind?: GoalSyntheticMessageKind, @@ -8841,8 +8864,8 @@ export class AgentSession { this.contextController.onStreamStarting(); this.clearLiveUsageState(); this.pendingPostCompactionStateToAcknowledge = null; + this.activeStreamPostCompactionBoundaryId = undefined; this.activeStreamHadAnyDelta = false; - this.activeStreamHadPostCompactionInjection = false; const providersConfig = this.getProvidersConfigSafe(); // Provider-start fence closes over the epoch captured at this turn's admission (never // refreshed here, so a prepared candidate cannot launder a stop that happened meanwhile). @@ -8971,7 +8994,7 @@ export class AgentSession { const fileChangeDetection = preparedRequest || leaseRefusal != null ? { attachments: [], commit: () => undefined } - : await this.fileChangeTracker.getChangedAttachments(); + : await this.detectFileChanges(); if (isStreamStartAborted()) { return Ok(undefined); } @@ -9069,6 +9092,11 @@ export class AgentSession { } } + this.activeStreamPostCompactionBoundaryId = findOmittableHeaderBoundaryId( + options, + requestMessages + ); + // Capture the current user message id so retries are stable across assistant message ids. // Retry-eligible rows only: startup recovery matches this persisted ID // against shouldUseUserMessageForRetry candidates, so selecting an @@ -9097,22 +9125,17 @@ export class AgentSession { return Ok(undefined); } - // Check if post-compaction attachments should be injected. - const postCompactionAttachments = - disablePostCompactionAttachments === true || preparedRequest != null || leaseRefusal != null - ? null - : await this.getPostCompactionAttachmentsIfNeeded(this.isRlmCompactionEnabled()); - if (isStreamStartAborted()) { - return Ok(undefined); + if (preparedRequest == null && leaseRefusal == null) { + await this.consumePendingPostCompactionState(historyResult.data); + if (isStreamStartAborted()) { + return Ok(undefined); + } } // #4476: from here the provider can run tools in the checkout, so another backend's rename // or removal must have seen this turn's lease (confirmed above; a running one refused it). if (leaseRefusal != null) return fail(createUnknownSendMessageError(leaseRefusal)); - this.activeStreamHadPostCompactionInjection = - postCompactionAttachments !== null && postCompactionAttachments.length > 0; - // Apply per-model thinking floors once so desktop, mobile, and ACP requests match. // Gateway-preserving key first (an explicit coder:/ // floor stays distinct from a direct model with the same ID), with a @@ -9227,10 +9250,9 @@ export class AgentSession { muxMetadata: streamMuxMetadata, recordFileState, recordProposedPlan: this.recordProposedPlan, - postCompactionAttachments, // Invoked by AIService after runtime.ensureReady() (project-scope // listing needs a running runtime). Still ordered after the - // post-compaction check above: a just-consumed compaction boundary has + // post-compaction step above: a just-consumed compaction boundary has // already reset the segment cache, so this stream recomputes the context. // The window's first row keys the saved context (see resolveMemoryContext). resolveMemoryContext: (forModelString) => @@ -9312,7 +9334,6 @@ export class AgentSession { recovery.model, rolled.data.options ?? options, openaiTruncationModeOverride, - true, agentInitiated, abortSignal, goalKind, @@ -9642,7 +9663,6 @@ export class AgentSession { context.modelString, retryOptions, openaiTruncation, - undefined, retryAgentInitiated, undefined, retryGoalKind, @@ -9680,9 +9700,39 @@ export class AgentSession { return true; } + /** + * Mark the boundary row's header as left out of the failed request and every later one. The + * mark is in chat.jsonl, so the header stays out after a restart, and replay still rebuilds + * the earlier requests of the window with it. + */ + private async omitPostCompactionContextFromFailedRequest( + boundaryId: string, + failedMessageId: string + ): Promise { + const history = await this.historyService.getHistoryFromLatestBoundary(this.workspaceId); + if (!history.success) throw new Error(history.error); + const boundary = history.data.find((message) => message.id === boundaryId); + if (!boundary) return; + const failed = history.data.find((message) => message.id === failedMessageId); + const omitted = omitPostCompactionContextFrom( + boundary, + failed?.metadata?.requestHistorySequence ?? newestHistorySequence(history.data) + ); + if (!omitted) return; + const updated = await this.historyService.updateHistory(this.workspaceId, omitted); + if (!updated.success) throw new Error(updated.error); + // The next send reads the boundary's estimate again, now without the header. + this.lastUsageState = undefined; + } + + /** + * #1932: a post-compaction header can push the first request of a window over the context + * limit. Only token-budget mode rolls over on that error, so other modes would fail every turn. + * Retry once without the header, and keep it out of later requests for this boundary. + */ private async maybeRetryWithoutPostCompactionOnContextExceeded( data: { messageId: string; errorType?: string }, - pendingState = this.pendingPostCompactionStateToAcknowledge + pendingState: typeof this.pendingPostCompactionStateToAcknowledge ): Promise { const expectedTurnId = this.coordinator.turnId; const expectedOperationId = this.coordinator.operationId; @@ -9690,8 +9740,8 @@ export class AgentSession { return false; } - // Only retry if we actually injected post-compaction context. - if (!this.activeStreamHadPostCompactionInjection) { + const boundaryId = this.activeStreamPostCompactionBoundaryId; + if (boundaryId == null) { return false; } @@ -9712,23 +9762,29 @@ export class AgentSession { this.postCompactionRetryAttempts.add(requestId); - log.info("Post-compaction context hit context limit; retrying once without it", { - workspaceId: this.workspaceId, - requestId, - model: context.modelString, - }); - - // The post-compaction context is likely the culprit; discard it so we don't loop. + // The next compaction must not carry the same context forward again. Discard before the + // header is left out, and fail closed if either step throws: once the header is out, no + // later request hits this path to discard it, so the next compaction would merge it again. try { await this.contextController.discardFailedCarryover(pendingState); this.onPostCompactionStateChange?.(); + await this.omitPostCompactionContextFromFailedRequest(boundaryId, data.messageId); } catch (error) { - log.warn("Failed to discard pending post-compaction state", { + log.warn("Failed to discard the post-compaction context; not retrying without it", { workspaceId: this.workspaceId, error: getErrorMessage(error), }); + // A later retry of this request must still be able to discard and retry. + this.postCompactionRetryAttempts.delete(requestId); + return false; } + log.info("Post-compaction context hit context limit; retrying once without it", { + workspaceId: this.workspaceId, + requestId, + model: context.modelString, + }); + // Abort the failed assistant placeholder and clean up persisted partial/history state. this.resetActiveStreamState(); this.emitChatEvent({ @@ -9748,7 +9804,6 @@ export class AgentSession { return false; } - // Retry the same request, but without post-compaction injection. if (!this.coordinator.isCurrentOperation(expectedOperationId)) return false; let claimedTurn: TurnId | undefined; using _preparation = { @@ -9780,7 +9835,6 @@ export class AgentSession { context.modelString, context.options, context.openaiTruncationModeOverride, - true, context.agentInitiated, undefined, context.goalKind, @@ -9928,7 +9982,7 @@ export class AgentSession { this.activeStreamContext = undefined; this.activeStreamUserMessageId = undefined; this.activeStreamStartedAtMs = undefined; - this.activeStreamHadPostCompactionInjection = false; + this.activeStreamPostCompactionBoundaryId = undefined; this.activeStreamHadAnyDelta = false; this.pendingPostCompactionStateToAcknowledge = null; } @@ -9996,7 +10050,6 @@ export class AgentSession { model, rolled.data.options ?? context.options, context.openaiTruncationModeOverride, - true, context.agentInitiated, undefined, context.goalKind, @@ -13373,18 +13426,15 @@ export class AgentSession { * Discard cumulative post-compaction carryover when a NEW context segment * starts (context reset, full history clear, destructive replace). The * cached read-file paths, loaded skills, and pending diff snapshot - * summarize PRE-boundary epochs; injecting them into a later turn would - * resurrect context the user explicitly discarded and tell the model files - * were "previously read" when their contents are gone from active context. - * Covers both injection routes: the immediate pending-state path (on-disk - * post-compaction.json) and the periodic capture of qualified carryover. + * summarize PRE-boundary epochs; a later compaction header built from them + * would resurrect context the user explicitly discarded and tell the model + * files were "previously read" when their contents are gone from active + * context. */ async clearPostCompactionState(): Promise { await this.discardMemoryContext(); - // In-memory clears stay unconditional: they stop THIS session from - // injecting carryover even when the durable discard below fails. - this.compactionOccurred = false; - this.turnsSinceLastAttachment = TURNS_BETWEEN_ATTACHMENTS; + // The in-memory clear stays unconditional: it stops THIS session from + // acknowledging carryover even when the durable discard below fails. this.pendingPostCompactionStateToAcknowledge = null; // The destructive history operation already fenced its captured context under the locks. // Retire compatible old bytes for downgrades; preserve newer publications and unknown schemas. @@ -13478,94 +13528,90 @@ export class AgentSession { await this.memoryContextStore.discard(); } + /** External file edits since the tracked baselines, which include the boundary header's plan. */ + private async detectFileChanges(): Promise { + await this.seedPlanBaselineFromBoundary(); + return this.fileChangeTracker.getChangedAttachments(); + } + /** - * Get post-compaction attachments if they should be injected this turn. - * - * Logic: - * - On first turn after compaction: inject immediately, clear file state cache - * - Subsequent turns: inject every TURNS_BETWEEN_ATTACHMENTS turns - * - * @returns Attachments to inject, or null if none needed + * Turn start, before file-change detection: track the plan file from the state that the latest + * boundary's header carries. The model sees that header, so detection reports any edit made + * after it. The state is on the boundary row, so this also works after a restart. Each + * boundary seeds once per session, and in a session only a compaction (with its pending + * state) adds a boundary, so most turns skip the history read. */ - private async getPostCompactionAttachmentsIfNeeded( - includeReadFiles: boolean - ): Promise { - // Check if compaction just occurred (immediate injection with cached post-compaction state) - const pendingState = await this.contextController.compaction.peekPendingState(); - if (pendingState !== null) { - this.pendingPostCompactionStateToAcknowledge = pendingState; - this.compactionOccurred = true; - this.turnsSinceLastAttachment = 0; - // Compaction boundary: invalidate the session-cached memory context so - // the next stream recomputes the index from current files. - await this.discardMemoryContext(); - // Clear file state cache since history context is gone - this.clearFileState(); - - return this.buildAttachmentsFromContext({ - diffs: pendingState.diffs, - loadedSkills: pendingState.loadedSkills, - // Read tracking is internal bookkeeping in both modes but only ever - // model-visible in RLM mode, keeping RLM-off prompts byte-identical. - readFilePaths: includeReadFiles ? pendingState.readFiles : [], - // Compaction just completed, so every already-completed report predates the boundary. - // Wall clock: TaskService and WorkflowService stamp report completion with Date.now() (#7282). - reportsCompletedBeforeMs: nodeClock.nowMs(), + private async seedPlanBaselineFromBoundary(): Promise { + if ( + this.planBaselineBoundaryId !== undefined && + (await this.contextController.compaction.peekPendingState()) === null + ) + return; + const history = await this.historyService.getHistoryFromLatestBoundary(this.workspaceId); + if (!history.success) return; + const boundaryId = history.data.findLast(isDurableContextBoundaryMarker)?.id ?? null; + if (boundaryId === this.planBaselineBoundaryId) return; + this.planBaselineBoundaryId = boundaryId; + const plan = findPostCompactionPlan(history.data); + if (plan) { + // A tool in the retained tail of a mid-stream swap can record a newer plan state that + // the model already saw; the older header snapshot must not replace it. + await this.fileChangeTracker.recordIfNewer(plan.path, { + content: plan.content, + timestamp: plan.modifiedTimeMs, }); } + } - // Increment turn counter - this.turnsSinceLastAttachment++; - - // Check cooldown for subsequent injections (re-read from current history) - if (this.compactionOccurred && this.turnsSinceLastAttachment >= TURNS_BETWEEN_ATTACHMENTS) { - const warm = await this.contextController.compaction.peekCarryoverState(); - this.pendingPostCompactionStateToAcknowledge = warm; - this.turnsSinceLastAttachment = 0; - return this.generatePostCompactionAttachments(includeReadFiles, warm); - } - - return null; + /** + * The first turn after a compaction consumes its pending state. The boundary row already + * carries the post-compaction context, so this only starts the new window: it keeps the + * snapshot to acknowledge when the turn succeeds and clears per-window caches. + */ + private async consumePendingPostCompactionState(history: readonly MuxMessage[]): Promise { + const pendingState = await this.contextController.compaction.peekPendingState(); + if (pendingState === null) return; + this.pendingPostCompactionStateToAcknowledge = pendingState; + // The next stream recomputes the memory index from current files. + await this.discardMemoryContext(); + // History context is gone, so drop the file baselines. Keep the plan baseline that turn + // start took from the boundary header (seedPlanBaselineFromBoundary). + await this.fileChangeTracker.clearExcept(findPostCompactionPlan(history)?.path); + this.acceptedFileSnapshotBaseline = undefined; } /** - * Generate post-compaction attachments by extracting diffs and loaded skills from message history. + * Render the boundary header from the compaction's carryover; null when nothing is kept or + * the build does not settle in time. */ - private async generatePostCompactionAttachments( - includeReadFiles: boolean, - warm: Awaited> - ): Promise { - // getHistoryFromLatestBoundary already returns only the active compaction epoch, - // so no further boundary slicing is needed. - const historyResult = await this.historyService.getHistoryFromLatestBoundary(this.workspaceId); - if (!historyResult.success) { - return []; + private async buildPostCompactionContext( + input: PostCompactionContextInput + ): Promise { + const built = await raceWithAbortAndTimeout(this.renderPostCompactionContextHeader(input), { + timeoutMs: POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS, + }); + if (built.kind !== "ok") { + log.warn("Post-compaction context build timed out; continuing without it", { + workspaceId: this.workspaceId, + }); + return null; } + return built.value; + } - const fileDiffs = extractEditedFileDiffs(historyResult.data); - const loadedSkills = mergeLoadedSkillSnapshots([ - ...(warm?.loadedSkills ?? []), - ...extractLoadedSkillSnapshotsFromMessages(historyResult.data), - ]); - // Mirror loadedSkills: cumulative pre-boundary reads carried in memory, - // merged with reads from the current epoch (newest-first, capped). - const readFilePaths = includeReadFiles - ? mergeReadFilePaths(warm?.readFiles ?? [], extractReadFilePaths(historyResult.data)) - : []; - - // Reports completed before the latest boundary had their tool results summarized away; - // anything newer is still visible in the active epoch and would be redundant. - const boundaryTimestampMs = historyResult.data.find( - (message) => message.metadata?.compactionBoundary === true - )?.metadata?.timestamp; - - return this.buildAttachmentsFromContext({ - diffs: fileDiffs, - loadedSkills, - readFilePaths, - // Wall clock, like the boundary timestamp: report times come from Date.now() (#7282). - reportsCompletedBeforeMs: boundaryTimestampMs ?? nodeClock.nowMs(), + /** The unbounded header build. */ + private async renderPostCompactionContextHeader( + input: PostCompactionContextInput + ): Promise { + const excludedItems = await this.loadExcludedItems(); + const { attachments, plan } = await this.buildAttachmentsFromContext({ + ...input, + excludedItems, + // Read tracking is internal bookkeeping in both modes but only ever + // model-visible in RLM mode, keeping RLM-off prompts byte-identical. + readFilePaths: this.isRlmCompactionEnabled() ? input.readFilePaths : [], }); + return renderPostCompactionContext(attachments, plan); } /** @@ -13580,8 +13626,13 @@ export class AgentSession { readFilePaths: string[]; /** Cutoff for the completed-reports index: reports completed before this were summarized away. */ reportsCompletedBeforeMs: number; - }): Promise { - const excludedItems = await this.loadExcludedItems(); + excludedItems: Set; + }): Promise<{ + attachments: PostCompactionAttachment[]; + /** The plan the attachments carry, for the file-change tracker; local runtimes only. */ + plan: PostCompactionPlan | null; + }> { + const { excludedItems } = context; const todoAttachment = await this.loadTodoListAttachment(excludedItems); // Host-side disk read (session dir), independent of workspace metadata/runtime. @@ -13639,17 +13690,17 @@ export class AgentSession { attachments.push(editedFilesRef); } - return attachments; + return { attachments, plan: null }; } const runtime = this.aiService.runtimeFactory.createRuntimeForWorkspace(metadataResult.data); - - const attachments = await AttachmentService.generatePostCompactionAttachments( - await resolvePlanFileLocation( - this.config, - runtime, - { ...metadataResult.data, id: this.workspaceId }, - this.historyService.locks - ), + const planLocation = await resolvePlanFileLocation( + this.config, + runtime, + { ...metadataResult.data, id: this.workspaceId }, + this.historyService.locks + ); + const { attachments, plan } = await AttachmentService.generatePostCompactionAttachments( + planLocation, context.diffs, context.loadedSkills, runtime, @@ -13676,7 +13727,7 @@ export class AgentSession { attachments.push(artifactsIndexAttachment); } - return attachments; + return { attachments, plan: postCompactionPlan(runtime, plan) }; } /** diff --git a/src/node/services/attachmentService.ts b/src/node/services/attachmentService.ts index 3a46078e108..b4c224ad9cb 100644 --- a/src/node/services/attachmentService.ts +++ b/src/node/services/attachmentService.ts @@ -25,6 +25,16 @@ import { } from "@/node/services/subagentReportArtifacts"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; +/** One read of a workspace's plan (AttachmentService.readPlan). */ +export interface PlanFileRead { + /** The path the plan was read from: planPath, or the legacy path. */ + planFilePath: string; + /** The full content, not truncated. */ + planContent: string; + /** The mtime from a stat taken before the read; null when the caller did not ask for it. */ + modifiedTimeMs: number | null; +} + const TRUNCATED_PLAN_NOTE = "\n\n...(truncated)\n"; function truncatePlanContent(planContent: string): string { @@ -42,50 +52,49 @@ function truncatePlanContent(planContent: string): string { */ export class AttachmentService { /** - * Generate a plan file reference attachment if the plan file exists. - * Mode-agnostic: plan context is valuable in both plan and exec modes. - * Falls back to legacy plan path if new path doesn't exist. + * Read a workspace's plan once: planPath, else the legacy path (local and container rows; an SSH + * row has migrated, planLocation.ts). Unlike readPlanFile, it never moves the legacy file. + * Callers that render the plan and baseline it for change detection share this one read, so the + * baseline is the content that was rendered. */ - static async generatePlanFileReference( + static async readPlan( location: PlanFileLocation, - runtime: Runtime - ): Promise { - const { planPath: planFilePath, legacyIdPath: legacyPlanPath } = location; + runtime: Runtime, + options: { statFirst: boolean } = { statFirst: false } + ): Promise { // The plan path is user/agent-writable and this reader runs on every post-compaction turn: // a FIFO (or other special file) there must fail like a missing plan instead of blocking the // read — and, on Node, a libuv threadpool worker — indefinitely. Same guard as readPlanFile. const planReadOptions = { requireRegularFile: true } as const; - - // Try new path first - try { - const planContent = await readFileString(runtime, planFilePath, undefined, planReadOptions); - if (planContent) { - return { - type: "plan_file_reference", - planFilePath, - planContent: truncatePlanContent(planContent), - }; + for (const planFilePath of planReadPaths(location)) { + try { + // Stat before the read: an edit between the two then pairs an older mtime with newer + // content, which change detection rereads, never a newer mtime with older content, + // which it skips. + const modifiedTimeMs = options.statFirst + ? (await runtime.stat(planFilePath)).modifiedTime.getTime() + : null; + const planContent = await readFileString(runtime, planFilePath, undefined, planReadOptions); + if (planContent) return { planFilePath, planContent, modifiedTimeMs }; + } catch { + // No readable plan at this path; try the next one. } - } catch { - // Plan file doesn't exist at new path, try legacy } + return null; + } - // Fall back to legacy path (local and container rows; an SSH row has migrated, planLocation.ts) - if (legacyPlanPath === undefined) return null; - try { - const planContent = await readFileString(runtime, legacyPlanPath, undefined, planReadOptions); - if (planContent) { - return { + /** + * The plan file reference for a plan read with readPlan; null without a plan. + * Mode-agnostic: plan context is valuable in both plan and exec modes. + */ + static planFileReference(plan: PlanFileRead | null): PlanFileReferenceAttachment | null { + return plan + ? { type: "plan_file_reference", - planFilePath: legacyPlanPath, - planContent: truncatePlanContent(planContent), - }; - } - } catch { - // Plan file doesn't exist at legacy path either - } - - return null; + planFilePath: plan.planFilePath, + planContent: truncatePlanContent(plan.planContent), + } + : null; } /** @@ -260,8 +269,8 @@ export class AttachmentService { } /** - * Generate all post-compaction attachments. - * Returns empty array if no attachments are needed. + * Generate all post-compaction attachments, and return the plan read they render. + * Returns an empty attachment list if no attachments are needed. * @param excludedItems - Set of item IDs to exclude ("plan", "skills", or "file:") */ static async generatePostCompactionAttachments( @@ -270,16 +279,17 @@ export class AttachmentService { loadedSkills: LoadedSkillSnapshot[], runtime: Runtime, excludedItems: Set = new Set() - ): Promise { + ): Promise<{ attachments: PostCompactionAttachment[]; plan: PlanFileRead | null }> { const attachments: PostCompactionAttachment[] = []; - // Plan file reference (skip if excluded) - let planRef: PlanFileReferenceAttachment | null = null; - if (!excludedItems.has("plan")) { - planRef = await this.generatePlanFileReference(planLocation, runtime); - if (planRef) { - attachments.push(planRef); - } + // Plan file reference (skip if excluded). The file-change tracker reads the host filesystem, + // so only a local plan gets the mtime its baseline needs. + const plan = excludedItems.has("plan") + ? null + : await this.readPlan(planLocation, runtime, { statFirst: runtime.location === "local" }); + const planRef = this.planFileReference(plan); + if (planRef) { + attachments.push(planRef); } const loadedSkillsAttachment = this.generateLoadedSkillsAttachment(loadedSkills, excludedItems); @@ -299,6 +309,6 @@ export class AttachmentService { attachments.push(editedFilesRef); } - return attachments; + return { attachments, plan }; } } diff --git a/src/node/services/compactionHandler.continuous.test.ts b/src/node/services/compactionHandler.continuous.test.ts index 349a1b356ce..a7e80221fb3 100644 --- a/src/node/services/compactionHandler.continuous.test.ts +++ b/src/node/services/compactionHandler.continuous.test.ts @@ -62,7 +62,6 @@ describe("continuous compaction provider replay", () => { text: "Unpublished summary", model: "anthropic:test", systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => false, }) ).toBe(false); @@ -109,7 +108,6 @@ describe("continuous compaction provider replay", () => { text: "Fix completed", model: "anthropic:test", systemMessageTokens: 0, - attachmentTokens: 0, }) .catch((error: unknown) => error) ).toEqual(new Error("observer failed")); @@ -166,7 +164,6 @@ describe("continuous compaction provider replay", () => { model: "anthropic:test-model", tail: [prompt, answer], systemMessageTokens: 0, - attachmentTokens: 0, }); const answerCopy = copies[1]; expect(answerCopy.metadata?.uiVisible).toBe(true); @@ -294,7 +291,6 @@ describe("continuous compaction provider replay", () => { model: `${provider}:test-model`, tail, systemMessageTokens: 200, - attachmentTokens: 50, }) ).toBe(true); const after = await store.historyService.getHistoryFromLatestBoundary(workspaceId); @@ -379,7 +375,6 @@ describe("continuous compaction provider replay", () => { model: "anthropic:test-model", tail: [prompt, answer], systemMessageTokens: 0, - attachmentTokens: 0, pendingFollowUp: followUp, }) ).toBe(true); diff --git a/src/node/services/compactionHandler.partialRecovery.test.ts b/src/node/services/compactionHandler.partialRecovery.test.ts index ec80ebc3148..0b733d9602a 100644 --- a/src/node/services/compactionHandler.partialRecovery.test.ts +++ b/src/node/services/compactionHandler.partialRecovery.test.ts @@ -75,7 +75,6 @@ describe("compaction partial recovery", () => { text: "Summary", model: "openai:gpt-4o", systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }); } diff --git a/src/node/services/compactionHandler.pendingOwnership.test.ts b/src/node/services/compactionHandler.pendingOwnership.test.ts index db6261b2ba2..4e864b76a78 100644 --- a/src/node/services/compactionHandler.pendingOwnership.test.ts +++ b/src/node/services/compactionHandler.pendingOwnership.test.ts @@ -252,7 +252,6 @@ describe("exact pending snapshot consumption", () => { text: "B", model: followUp.model, systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => false, }) ).toBe(false); diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index 1a6183801f9..4a070dfeabf 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -14,9 +14,15 @@ import { createMuxMessage, type CompactionFollowUpRequest, type MuxMessage, + type PostCompactionContext, } from "@/common/types/message"; +import { + estimatePostCompactionContextTokens, + type PostCompactionContextInput, +} from "./postCompactionContext"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import { SESSION_HISTORY_MAX_LINE_BYTES } from "@/common/constants/contextBudget"; +import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { CHAT_ARCHIVE_FILE_NAME, CHAT_FILE_NAME } from "@/common/constants/paths"; import assert from "@/common/utils/assert"; import { formatCompactionSummaryTruncationMarker } from "./historyRowBudget"; @@ -2258,6 +2264,209 @@ describe("CompactionHandler", () => { }); }); + describe("post-compaction context header", () => { + const header: PostCompactionContext = { text: "Edited files: /src/app.ts", skillNames: ["ui"] }; + + function installBuilder( + build: (input: PostCompactionContextInput) => Promise + ) { + handler = new CompactionHandler({ + workspaceId, + historyService, + sessionDir, + telemetryService, + emitter: mockEmitter, + buildPostCompactionContext: build, + }); + } + + async function latestBoundary() { + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + if (!history.success) throw new Error(history.error); + const boundary = history.data[0]; + expect(boundary?.metadata?.compactionBoundary).toBe(true); + return boundary; + } + + it("stores the builder's header once on the new boundary", async () => { + const build = mock((_input: PostCompactionContextInput) => Promise.resolve(header)); + installBuilder(build); + await seedHistory( + createMuxMessage("u0", "user", "fix the app"), + createSuccessfulFileEditMessage("edit-1", "/src/app.ts", "@@ -1 +1 @@\n-a\n+b"), + createCompactionRequest("compact-req") + ); + + expect(await handler.handleCompletion(createStreamEndEvent("Summary"))).toBe(true); + + const boundary = await latestBoundary(); + expect(boundary?.metadata?.postCompactionContext).toEqual(header); + expect(build).toHaveBeenCalledTimes(1); + const input = build.mock.calls[0]?.[0]; + expect(input?.diffs.map((diff) => diff.path)).toEqual(["/src/app.ts"]); + }); + + // #3517: idle compaction dates the summary at the last activity, but reports that finished + // after that activity are in the summary too, so the report index must list them. + it("lists reports up to the compaction time, not the idle summary timestamp", async () => { + const build = mock((_input: PostCompactionContextInput) => Promise.resolve(header)); + installBuilder(build); + const lastActivity = Date.now() - 3600 * 1000; + await seedHistory( + createMuxMessage("u0", "user", "hello", { timestamp: lastActivity }), + createMuxMessage("idle-req", "user", "Summarize", { + muxMetadata: { + type: "compaction-request", + source: "idle-compaction", + rawCommand: "/compact", + parsed: {}, + }, + }) + ); + const compactedAt = Date.now(); + + expect(await handler.handleCompletion(createStreamEndEvent("Summary"))).toBe(true); + + expect((await latestBoundary())?.metadata?.timestamp).toBe(lastActivity); + expect(build.mock.calls[0]?.[0].reportsCompletedBeforeMs).toBeGreaterThanOrEqual(compactedAt); + }); + + it("publishes the boundary without a header when the builder fails", async () => { + installBuilder(() => Promise.reject(new Error("artifact index unavailable"))); + await seedHistory(createMuxMessage("u0", "user", "hello"), createCompactionRequest()); + + expect(await handler.handleCompletion(createStreamEndEvent("Summary"))).toBe(true); + + const boundary = await latestBoundary(); + expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); + }); + + // The context meter and the compaction check of the next send read the boundary's estimate. + it("counts the header in the boundary's context estimate", async () => { + installBuilder(() => Promise.resolve(header)); + await seedHistory(createMuxMessage("u0", "user", "hello"), createCompactionRequest()); + + expect( + await handler.handleCompletion(createStreamEndEvent("Summary", { systemMessageTokens: 10 })) + ).toBe(true); + + // System prompt (10) plus the summary's output tokens (50), plus the header. + const inputTokens = 60 + estimatePostCompactionContextTokens(header); + const expected = { inputTokens, outputTokens: 0, totalTokens: inputTokens }; + expect((await latestBoundary())?.metadata?.contextUsage).toEqual(expected); + expect(getEmittedStreamEndEvent(emittedEvents)?.metadata.contextUsage).toEqual(expected); + }); + + // A header too big for the continuous boundary row is dropped, so the row must not count it. + it.each([ + ["counts the header that a continuous boundary keeps", "Rolling summary", true], + [ + "does not count a header that the continuous boundary row drops", + "s".repeat(SESSION_HISTORY_MAX_LINE_BYTES - 64 * 1024), + false, + ], + ])("%s", async (_name, text, kept) => { + const built: PostCompactionContext = { text: "h".repeat(64 * 1024), skillNames: [] }; + installBuilder(() => Promise.resolve(built)); + const source = createMuxMessage("source", "assistant", "Fixed the bug"); + await seedHistory(source); + + expect( + await handler.persistContinuousCompaction({ + messages: [source], + text, + model: "anthropic:test", + tail: [], + systemMessageTokens: 10, + preparation: handler.beginPreparation(() => true), + attachmentMessages: [source], + publication: { + generation: await historyService + .getContinuousCompactionJournal(workspaceId) + .captureGeneration(), + }, + shouldPersist: () => true, + }) + ).toBe(true); + + const boundary = await latestBoundary(); + expect(boundary.metadata?.postCompactionContext).toEqual(kept ? built : undefined); + const headerTokens = kept ? estimatePostCompactionContextTokens(built) : 0; + expect(boundary.metadata?.contextUsage?.inputTokens).toBe( + 10 + estimateMuxMessageTokens(boundary) + headerTokens + ); + }); + + async function persistJournaledBoundary( + text: string, + journaledHeader: PostCompactionContext | null + ) { + const build = mock((_input: PostCompactionContextInput) => + Promise.resolve({ text: "rebuilt", skillNames: [] }) + ); + installBuilder(build); + const source = createMuxMessage("source", "assistant", "Fixed the bug"); + await seedHistory(source); + const rows = { + messages: [source], + text, + model: "anthropic:test", + tail: [], + systemMessageTokens: 0, + }; + const prepared = handler.buildContinuousCompactionRows(rows); + if (journaledHeader) { + prepared.boundary.metadata = { + ...prepared.boundary.metadata, + postCompactionContext: journaledHeader, + }; + } + + expect( + await handler.persistContinuousCompaction({ + ...rows, + prepared, + preparation: handler.beginPreparation(() => true), + attachmentMessages: [source], + publication: { + generation: await historyService + .getContinuousCompactionJournal(workspaceId) + .captureGeneration(), + }, + shouldPersist: () => true, + }) + ).toBe(true); + + // The live stream already sent the journaled prefix; a rebuilt header would change it. + expect(build).not.toHaveBeenCalled(); + return latestBoundary(); + } + + it.each([ + ["keeps the header that a journaled continuous boundary brings", header], + ["builds no header for a journaled continuous boundary without one", null], + ])("%s", async (_name, journaledHeader) => { + const boundary = await persistJournaledBoundary("Rolling summary", journaledHeader); + + expect(boundary?.metadata?.postCompactionContext).toEqual(journaledHeader ?? undefined); + expect(boundary?.metadata?.contextUsage?.inputTokens).toBe( + estimateMuxMessageTokens(boundary) + estimatePostCompactionContextTokens(journaledHeader) + ); + }); + + // The swapped prefix already sent this summary and header, so the row keeps both even + // when it is too big for the history line limit. + it("persists a journaled boundary as the swap built it, even when oversized", async () => { + const summary = "s".repeat(SESSION_HISTORY_MAX_LINE_BYTES - 64 * 1024); + const journaledHeader = { text: "h".repeat(64 * 1024), skillNames: [] }; + + const boundary = await persistJournaledBoundary(summary, journaledHeader); + + expect(boundary?.metadata?.postCompactionContext).toEqual(journaledHeader); + expect(boundary?.parts[0]).toMatchObject({ type: "text", text: summary }); + }); + }); + // #4551: history scanners skip rows over SESSION_HISTORY_MAX_LINE_BYTES, so an oversized // boundary stops being a boundary and provider history falls back to the previous epoch. describe("boundary row size", () => { @@ -2356,5 +2565,25 @@ describe("CompactionHandler", () => { expect(text.length).toBeLessThan(summary.length); expect(summary.startsWith(text.slice(0, text.lastIndexOf("\n\n[")))).toBe(true); }); + + it("drops a header that does not fit and keeps the fitted summary", async () => { + handler = new CompactionHandler({ + workspaceId, + historyService, + sessionDir, + telemetryService, + emitter: mockEmitter, + buildPostCompactionContext: () => + Promise.resolve({ text: "h".repeat(64 * 1024), skillNames: [] }), + }); + const summary = "s".repeat(SESSION_HISTORY_MAX_LINE_BYTES + 64 * 1024); + const followUp = { text: "Continue", model: "openai:gpt-4o", agentId: "exec" }; + + const { boundary, text } = await compactWith(summary, followUp); + + expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); + const kept = text.lastIndexOf("\n\n["); + expect(text.slice(kept)).toBe(formatCompactionSummaryTruncationMarker(kept, summary.length)); + }); }); }); diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 1253b4203a2..cebaa002e0b 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -12,8 +12,15 @@ import { exactJson } from "./continuousCompactionJournal"; import { POST_COMPACTION_STATE_FILENAME } from "@/constants/compaction"; import { CompactionPendingState, + type CompactionPendingAttachments, type CompactionPendingBoundaryWrite, } from "./compactionPendingState"; +import { + estimatePostCompactionContextTokens, + shiftContextUsageByHeader, + withPostCompactionContext, + type PostCompactionContextInput, +} from "./postCompactionContext"; import { CompactionPreparationLifecycle, type CompactionPreparation, @@ -31,12 +38,14 @@ import { type CompactionFollowUpRequest, type CompactionSummaryMetadata, type MuxMessage, + type PostCompactionContext, } from "@/common/types/message"; import { createCompactionSummaryMessageId } from "@/node/services/utils/messageIds"; import type { TelemetryService } from "@/node/services/telemetryService"; import { MAX_EDITED_FILES } from "@/common/constants/attachments"; import { roundToBase2 } from "@/common/telemetry/utils"; import { log } from "@/node/services/log"; +import { nodeClock } from "@/node/utils/hostClock"; import { fitCompactionSummaryToHistoryRow, type FittedCompactionSummary } from "./historyRowBudget"; import { countTokens } from "@/node/utils/main/tokenizer"; import { getErrorMessage } from "@/common/utils/errors"; @@ -210,6 +219,13 @@ interface CompactionHandlerOptions { * workspace whose compaction keeps failing even though the provider stream ended cleanly. */ onIdleCompactionOutcome?: (success: boolean) => void; + /** + * Builds the post-compaction context header that each new boundary row stores. It settles in + * bounded time, so a slow read cannot hold the paid summary unpublished. + */ + buildPostCompactionContext?: ( + input: PostCompactionContextInput + ) => Promise; } /** @@ -258,6 +274,11 @@ function pickTailCopyMetadata(source: RowMetadata | undefined): Partial; } +/** Whether a boundary publication builds the header. A header it builds counts in contextUsage. */ +interface HeaderOptions { + buildHeader: boolean; +} + export class CompactionHandler { private readonly workspaceId: string; private readonly historyService: HistoryService; @@ -268,6 +289,7 @@ export class CompactionHandler { private readonly onCompactionComplete?: (metadata: CompactionCompletionMetadata) => void; private readonly onIdleCompactionOutcome?: (success: boolean) => void; + private readonly buildPostCompactionContext?: CompactionHandlerOptions["buildPostCompactionContext"]; constructor(options: CompactionHandlerOptions) { assert(options, "CompactionHandler requires options"); @@ -288,6 +310,7 @@ export class CompactionHandler { this.emitter = options.emitter; this.onCompactionComplete = options.onCompactionComplete; this.onIdleCompactionOutcome = options.onIdleCompactionOutcome; + this.buildPostCompactionContext = options.buildPostCompactionContext; } beginPreparation(isCurrent: () => boolean): CompactionPreparation { @@ -331,7 +354,8 @@ export class CompactionHandler { private async publishPreparedBoundary( preparation: CompactionPreparation, messages: MuxMessage[], - boundary: CompactionPendingBoundaryWrite + boundary: CompactionPendingBoundaryWrite, + options: HeaderOptions = { buildHeader: true } ) { messages = structuredClone(messages); boundary = { @@ -351,23 +375,57 @@ export class CompactionHandler { const pending = await this.peekPendingState(); const warm = await this.peekCarryoverState(); const epoch = sliceMessagesFromLatestCompactionBoundary(messages); + const attachments = { + diffs: mergeFileEditDiffs(pending?.diffs ?? [], extractEditedFileDiffs(epoch)), + loadedSkills: mergeLoadedSkillSnapshots([ + ...(warm?.loadedSkills ?? []), + ...(pending?.loadedSkills ?? []), + ...extractLoadedSkillSnapshotsFromMessages(epoch), + ]), + readFiles: mergeReadFilePaths( + mergeReadFilePaths(warm?.readFiles ?? [], pending?.readFiles ?? []), + extractReadFilePaths(epoch) + ), + }; return this.pendingLifecycle.publish(preparation, { ...boundary, - attachments: { - diffs: mergeFileEditDiffs(pending?.diffs ?? [], extractEditedFileDiffs(epoch)), - loadedSkills: mergeLoadedSkillSnapshots([ - ...(warm?.loadedSkills ?? []), - ...(pending?.loadedSkills ?? []), - ...extractLoadedSkillSnapshotsFromMessages(epoch), - ]), - readFiles: mergeReadFilePaths( - mergeReadFilePaths(warm?.readFiles ?? [], pending?.readFiles ?? []), - extractReadFilePaths(epoch) - ), - }, + summaryMessage: await this.addPostCompactionContext( + boundary.summaryMessage, + attachments, + options + ), + attachments, }); } + private async addPostCompactionContext( + summary: MuxMessage, + attachments: CompactionPendingAttachments, + options: HeaderOptions + ): Promise { + if (!options.buildHeader || !this.buildPostCompactionContext) return summary; + try { + const built = await this.buildPostCompactionContext({ + diffs: attachments.diffs, + loadedSkills: attachments.loadedSkills, + readFilePaths: attachments.readFiles, + // Not the summary timestamp: idle compaction dates the summary at the last activity, + // but reports that finished after it are summarized too (#3517). Wall clock: report + // times come from Date.now() (#7282). + reportsCompletedBeforeMs: nodeClock.nowMs(), + }); + const withContext = withPostCompactionContext(summary, built, this.workspaceId); + return withContext === summary ? summary : shiftContextUsageByHeader(withContext, 1); + } catch (error) { + // The header only restores context; it must never block the compaction itself. + log.warn("Failed to build post-compaction context; publishing the boundary without it", { + workspaceId: this.workspaceId, + error: getErrorMessage(error), + }); + return summary; + } + } + private async retirePartial( preparation: CompactionPreparation, messageId?: string @@ -715,13 +773,13 @@ export class CompactionHandler { } : event ); - if (truncated) { - const { contextUsage: _fullSummaryEstimate, ...rest } = streamEnd.metadata; - streamEnd.metadata = { - ...rest, - ...(result.data.summaryContextUsage && { contextUsage: result.data.summaryContextUsage }), - }; - } + // The renderer merges this metadata over the persisted row's, so carry the row's estimate: it + // counts only the kept summary text, plus the header. + const { contextUsage: _summaryEstimate, ...rest } = streamEnd.metadata; + streamEnd.metadata = { + ...rest, + ...(result.data.summaryContextUsage && { contextUsage: result.data.summaryContextUsage }), + }; this.emitChatEvent(streamEnd); return true; } @@ -878,7 +936,11 @@ export class CompactionHandler { return fitted; } - /** The rolling summarizer already paid for this text; applying it must not start another turn. */ + /** + * The rolling summarizer already paid for this text; applying it must not start another turn. + * The boundary comes back fitted to the history row with its header, so its model-visible + * bytes are final: a prefix swap sends them before the row is persisted. + */ buildContinuousCompactionRows(params: { boundaryMessageId?: string; messages: MuxMessage[]; @@ -886,11 +948,11 @@ export class CompactionHandler { model: string; tail: MuxMessage[]; systemMessageTokens: number; - attachmentTokens: number; pendingFollowUp?: CompactionFollowUpRequest; + postCompactionContext?: PostCompactionContext | null; }): { boundary: MuxMessage; copies: MuxMessage[] } { assert(params.text.trim().length > 0, "Continuous compaction requires a summary"); - const boundary = createMuxMessage( + const summary = createMuxMessage( params.boundaryMessageId ?? createCompactionSummaryMessageId(), "assistant", params.text, @@ -908,6 +970,11 @@ export class CompactionHandler { }, } ); + const boundary = withPostCompactionContext( + this.fitBoundarySummary(summary).message, + params.postCompactionContext ?? null, + this.workspaceId + ); const copies = this.buildTailCopies(params.tail).map((copy) => { // Continuous compaction prunes the just-finished answer too. Keep recent pages // visible below the boundary while retaining RLM's usage/snapshot sanitizer. @@ -931,16 +998,17 @@ export class CompactionHandler { ): Promise { const shouldPersist = params.shouldPersist; const previousBoundaryHistorySequence = getLatestBoundaryHistorySequence(params.messages); - const built = params.prepared + // A journaled boundary is persisted as the swap built it, because the swapped prefix sent + // its summary and header. Its pending follow-up came later and the model never sees it, + // so a follow-up too big for the row leaves the row oversized; the history scanner + // recovers oversized compaction boundaries. + const { boundary, copies } = params.prepared ? structuredClone(params.prepared) : this.buildContinuousCompactionRows(params); - // Bound here, not in the builder: a journaled boundary gets its pending follow-up only - // after it was built, and the follow-up counts toward the row size. - const boundary = this.fitBoundarySummary(built.boundary).message; - const copies = built.copies; + // A header too big for the row is dropped, so count only the header the row keeps. const inputTokens = params.systemMessageTokens + - params.attachmentTokens + + estimatePostCompactionContextTokens(boundary.metadata?.postCompactionContext) + estimateMuxMessageTokens(boundary) + copies.reduce((sum, row) => sum + estimateMuxMessageTokens(row), 0); assert(boundary.metadata !== undefined, "Continuous boundary requires metadata"); @@ -960,7 +1028,10 @@ export class CompactionHandler { updateExisting: false, publication: params.publication, shouldPersist: (messages, partial) => !partial && shouldPersist(messages), - } + }, + // The swapped prefix already rendered the journaled header, or none. A new header here + // would change that prefix after the stream sent it. + { buildHeader: !params.prepared } ); if (!result.success) { log.warn("[continuous-compaction] persist failed", result.error); diff --git a/src/node/services/contextManagement/compactionRequests.ts b/src/node/services/contextManagement/compactionRequests.ts index df0b0031bea..c87a1e28205 100644 --- a/src/node/services/contextManagement/compactionRequests.ts +++ b/src/node/services/contextManagement/compactionRequests.ts @@ -183,3 +183,20 @@ export function isCompactionRequestMetadata(meta: unknown): meta is CompactionRe if (typeof obj.parsed !== "object" || obj.parsed === null) return false; return true; } + +/** + * Whether TurnRequestBuilder builds a compaction request, which it trims to the compaction + * input budget. Only the newest user row of the request counts, never an older compact command. + */ +export function isCompactionRequestTurn( + agentId: string | undefined, + muxMetadata: MuxMessageMetadata | undefined, + messages: readonly MuxMessage[] +): boolean { + const latestUserMessage = messages.findLast((message) => message.role === "user"); + return ( + agentId === "compact" || + muxMetadata?.type === "compaction-request" || + latestUserMessage?.metadata?.muxMetadata?.type === "compaction-request" + ); +} diff --git a/src/node/services/contextManagement/sessionContextController.test.ts b/src/node/services/contextManagement/sessionContextController.test.ts index e7a4dbc273f..f068c9e6f1f 100644 --- a/src/node/services/contextManagement/sessionContextController.test.ts +++ b/src/node/services/contextManagement/sessionContextController.test.ts @@ -139,7 +139,6 @@ test("durable completion records the tail summary before notifying the external text: "The investigation and fix are complete", model: "openai:gpt-4o", systemMessageTokens: 0, - attachmentTokens: 0, attachmentMessages: [], preparation: handler.beginPreparation(() => true), publication: { diff --git a/src/node/services/contextManagement/sessionContextController.ts b/src/node/services/contextManagement/sessionContextController.ts index 5ef11959257..ac7d717ca5e 100644 --- a/src/node/services/contextManagement/sessionContextController.ts +++ b/src/node/services/contextManagement/sessionContextController.ts @@ -71,6 +71,7 @@ export class SessionContextController { host.onCompactionComplete?.(metadata); }, onIdleCompactionOutcome: (success) => host.onIdleCompactionOutcome?.(success), + buildPostCompactionContext: (input) => host.buildPostCompactionContext(input), }); // Keep the handler receiver intact while limiting the session's permanent API surface. this.compaction = this.compactionHandler; diff --git a/src/node/services/contextManagement/sessionContextHost.ts b/src/node/services/contextManagement/sessionContextHost.ts index 243ac3371f2..7f7647d5358 100644 --- a/src/node/services/contextManagement/sessionContextHost.ts +++ b/src/node/services/contextManagement/sessionContextHost.ts @@ -5,13 +5,16 @@ import type { ProvidersConfigMap, } from "@/common/orpc/types"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; -import type { CompactionFollowUpRequest, MuxMessageMetadata } from "@/common/types/message"; -import type { LoadedSkillSnapshot, PostCompactionAttachment } from "@/common/types/attachment"; +import type { + CompactionFollowUpRequest, + MuxMessageMetadata, + PostCompactionContext, +} from "@/common/types/message"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; -import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; import type { AgentSessionStreamManager } from "../agentSession"; import type { TurnCoordinator } from "../turnCoordinator"; import type { TurnAcceptanceOrigin } from "../taskWorkspaceSeam"; +import type { PostCompactionContextInput } from "../postCompactionContext"; import type { ContextDispatchRequest, CompactionContinuation, @@ -91,12 +94,13 @@ export interface SessionContextHost { continuation: CompactionContinuation ): Promise; dispatchPendingFollowUp(summaryId: string | null, admissionStale: () => boolean): Promise; - buildAttachments(input: { - diffs: FileEditDiff[]; - loadedSkills: LoadedSkillSnapshot[]; - readFilePaths: string[]; - reportsCompletedBeforeMs: number; - }): Promise; + /** + * Builds the header that a new compaction boundary row stores (null: nothing to restore, or the + * build did not settle in time). It settles in bounded time. + */ + buildPostCompactionContext( + input: PostCompactionContextInput + ): Promise; emitChatEvent(event: WorkspaceChatMessage): void; onCompactionComplete?(metadata: CompactionCompletionMetadata): void; onIdleCompactionOutcome?(success: boolean): void; diff --git a/src/node/services/contextManagement/strategies/continuous.ts b/src/node/services/contextManagement/strategies/continuous.ts index 009d46986ed..74a3591f498 100644 --- a/src/node/services/contextManagement/strategies/continuous.ts +++ b/src/node/services/contextManagement/strategies/continuous.ts @@ -4,8 +4,6 @@ import type { MuxMessage, CompactionFollowUpRequest } from "@/common/types/messa import { FORCE_COMPACTION_BUFFER_PERCENT } from "@/common/constants/ui"; import { getEffectiveContextLimit } from "@/common/utils/compaction/contextLimit"; import { isAnthropic1MEffectivelyEnabled } from "@/common/utils/ai/providerOptions"; -import { injectPostCompactionAttachments } from "@/browser/utils/messages/modelMessageTransform"; -import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { extractEditedFileDiffs } from "@/common/utils/messages/extractEditedFiles"; import { extractReadFilePaths, mergeReadFilePaths } from "@/common/utils/messages/extractReadFiles"; import { nodeClock } from "@/node/utils/hostClock"; @@ -15,7 +13,9 @@ import { } from "../../agentSkills/loadedSkillSnapshots"; import { ContinuousCompactor, type ContinuousCompactionContext } from "../../continuousCompactor"; import { summarizeContinuousCompaction } from "../../continuousCompactionSummary"; +import { estimatePostCompactionContextTokens } from "../../postCompactionContext"; import type { CompactionHandler } from "../../compactionHandler"; +import type { CompactionPreparationSnapshot } from "../../compactionPreparationLifecycle"; import type { CompactionMonitor } from "../../compactionMonitor"; import type { CompactionToken } from "../../turnCoordinator"; import { eventSpine } from "../../events/eventSpine"; @@ -28,6 +28,12 @@ import type { StreamContextSnapshot } from "../types"; type SessionCompactionContext = ContinuousCompactionContext & { sendOptions?: SendMessageOptions }; +const NO_ATTACHMENTS: CompactionPreparationSnapshot = { + diffs: [], + loadedSkills: [], + readFiles: [], +}; + function is1MContextEnabledForModel( model: string, options?: SendMessageOptions, @@ -79,20 +85,15 @@ export class ContinuousStrategy { workspaceId: this.host.workspaceId, reason: "continuous-eager", }), - estimateAttachmentTokens: async (head) => { - const attachments = await this.buildContinuousCompactionAttachments(head); - return injectPostCompactionAttachments([], attachments).reduce( - (sum, row) => sum + estimateMuxMessageTokens(row), - 0 - ); - }, + estimateAttachmentTokens: async (head) => + estimatePostCompactionContextTokens(await this.buildContinuousPostCompactionContext(head)), prepareSwap: async (head) => { // A consumed swap may need the fast-stop fallback on a provider-family hop. if (!this.host.state.stream?.options) return null; const prepared = this.host.streams.getPrefixSwapPreparation?.(this.host.workspaceId); if (!prepared) return null; - const attachments = await this.buildContinuousCompactionAttachments(head); - return { ...prepared, attachments }; + const postCompactionContext = await this.buildContinuousPostCompactionContext(head); + return { ...prepared, postCompactionContext }; }, summarize: (head, signal, context: SessionCompactionContext, receiptRows) => { const baseOptions = context.sendOptions ?? { model: context.model, agentId: "exec" }; @@ -118,18 +119,18 @@ export class ContinuousStrategy { }); } - private async buildContinuousCompactionAttachments(head: MuxMessage[]) { - const pending = await this.handler.peekPendingState(); - const warm = await this.handler.peekCarryoverState(); - return this.host.buildAttachments({ - diffs: [...(pending?.diffs ?? []), ...extractEditedFileDiffs(head)], + private async buildContinuousPostCompactionContext(head: MuxMessage[]) { + const pending = (await this.handler.peekPendingState()) ?? NO_ATTACHMENTS; + const warm = (await this.handler.peekCarryoverState()) ?? NO_ATTACHMENTS; + return this.host.buildPostCompactionContext({ + diffs: [...pending.diffs, ...extractEditedFileDiffs(head)], loadedSkills: mergeLoadedSkillSnapshots([ - ...(warm?.loadedSkills ?? []), - ...(pending?.loadedSkills ?? []), + ...warm.loadedSkills, + ...pending.loadedSkills, ...extractLoadedSkillSnapshotsFromMessages(head), ]), - readFilePaths: mergeReadFilePaths(warm?.readFiles ?? [], [ - ...(pending?.readFiles ?? []), + readFilePaths: mergeReadFilePaths(warm.readFiles, [ + ...pending.readFiles, ...extractReadFilePaths(head), ]), // Wall clock on purpose: TaskService and WorkflowService stamp report diff --git a/src/node/services/contextManagement/strategies/tokenBudget.ts b/src/node/services/contextManagement/strategies/tokenBudget.ts index 52645c0d9d3..52ff240d6e4 100644 --- a/src/node/services/contextManagement/strategies/tokenBudget.ts +++ b/src/node/services/contextManagement/strategies/tokenBudget.ts @@ -147,10 +147,14 @@ export class TokenBudgetStrategy { private readonly isActive: (options?: SendMessageOptions) => boolean ) {} - discardFailedCarryover( + async discardFailedCarryover( pendingState: Parameters[1] ): Promise { - return this.compaction.discardPendingState("context_exceeded", pendingState); + // Every request of the window carries the header. After its first successful turn, the + // header's snapshot is acknowledged carryover, not pending: the next compaction must not + // carry it forward either. + const snapshot = pendingState ?? (await this.compaction.peekCarryoverState()); + await this.compaction.discardPendingState("context_exceeded", snapshot); } capturePreparation(): PreparationReceipt { diff --git a/src/node/services/continuousCompactionJournal.ts b/src/node/services/continuousCompactionJournal.ts index b06884a31a3..85c5a8bb531 100644 --- a/src/node/services/continuousCompactionJournal.ts +++ b/src/node/services/continuousCompactionJournal.ts @@ -16,6 +16,7 @@ import { } from "@/common/orpc/schemas/continuousCompaction"; import { extractPreActivatedToolNames } from "@/common/utils/tools/toolCatalog"; import { prepareMessagesForProvider } from "./messagePipeline"; +import { insertPostCompactionContext } from "./postCompactionContext"; import { unlockedHistoryScans } from "./unlockedHistoryScans"; import { log } from "./log"; @@ -80,9 +81,11 @@ export async function rebuildContinuousPrefix( (name) => !deferred.has(name) || referenced.has(name) ), workspaceId, - messagesWithSentinel: addInterruptedSentinel(prepared.providerRequestMessages), + // The same header row, at the same place, as assemblePromptPayload builds for later turns. + messagesWithSentinel: addInterruptedSentinel( + insertPostCompactionContext(prepared.providerRequestMessages) + ), replayReceiptMessages: prepared.activeContextMessages, - postCompactionAttachments: journal.postCompactionAttachments, deferLoadingToolNames: deferred, }); const prefix = stripMessageCacheControl(messages); diff --git a/src/node/services/continuousCompactionSummary.ts b/src/node/services/continuousCompactionSummary.ts index 729433c7385..d27c19677ed 100644 --- a/src/node/services/continuousCompactionSummary.ts +++ b/src/node/services/continuousCompactionSummary.ts @@ -166,7 +166,6 @@ export async function summarizeContinuousCompaction(args: { replayReceiptMessages: args.receiptRows, effectiveAgentId: "compact", toolNamesForSentinel: [], - postCompactionAttachments: null, providerForMessages: created.data.wireProviderName, effectiveThinkingLevel: thinkingLevel, modelString: created.data.optionsModelString, diff --git a/src/node/services/continuousCompactor.test.ts b/src/node/services/continuousCompactor.test.ts index 7ed1d5365b0..35588a5a741 100644 --- a/src/node/services/continuousCompactor.test.ts +++ b/src/node/services/continuousCompactor.test.ts @@ -775,7 +775,7 @@ describe("ContinuousCompactor", () => { toolNamesForSentinel: ["bash"], effectiveThinkingLevel: "off", }, - attachments: [{ type: "read_files_reference", paths: ["kept.txt"] }], + postCompactionContext: { text: "Read kept.txt", skillNames: [] }, systemPrefix: [], cacheEnabled: true, }), @@ -889,6 +889,28 @@ describe("ContinuousCompactor", () => { expect(full.filter((row) => row.id === journal.boundary.id)).toHaveLength(1); }); + it("keeps the post-compaction header from the journal boundary through recovery", async () => { + const { journal, journalStore, swap } = await activateJournaledSwap(); + const header = { text: "Read kept.txt", skillNames: [] }; + expect(journal.boundary.metadata?.postCompactionContext).toEqual(header); + expect(JSON.stringify(swap.prefix)).toContain(header.text); + // Older builds also wrote the attachments into the journal; such a journal still folds. + await writeFile( + journalStore.path, + JSON.stringify({ + ...journal, + postCompactionAttachments: [{ type: "read_files_reference", paths: ["kept.txt"] }], + }) + ); + streaming = false; + live = undefined; + expect(await compactor.recover()).toBe(true); + const boundary = (await rows())[0]; + expect(boundary.id).toBe(journal.boundary.id); + expect(boundary.metadata?.postCompactionContext).toEqual(header); + expect(await journalStore.read()).toBeNull(); + }); + it("recovers a journal whose source fingerprint an earlier build took over raw rows", async () => { // Earlier builds fingerprinted the raw rows, including a hidden record inside the head. // Such a persisted journal must still fold when nothing was appended after its source. @@ -1095,7 +1117,7 @@ describe("ContinuousCompactor", () => { effectiveThinkingLevel: "off", toolNamesForSentinel: ["bash"], }, - attachments: [], + postCompactionContext: null, systemPrefix: [], cacheEnabled: true, }); @@ -1113,6 +1135,46 @@ describe("ContinuousCompactor", () => { expect(current[0].metadata?.compactionBoundary).toBe(true); }); + it("uses the durable fallback when the swap header no longer fits the window", async () => { + const answer = await seedLiveTurn(); + assert(live, "Live fixture missing"); + const toolPart: MuxMessage["parts"][number] = { + type: "dynamic-tool", + toolCallId: "tail-tool", + toolName: "bash", + state: "output-available", + input: { script: "pwd" }, + output: { success: true }, + }; + live.parts[1] = toolPart; + answer.parts[1] = toolPart; + const deps = Reflect.get(compactor, "deps") as Dependencies; + const setSwap = mock(() => true); + deps.streamManager.setPrefixSwap = setSwap; + // The eager estimate counted a small header; the header built at swap time is huge. + deps.prepareSwap = () => + Promise.resolve({ + preparation: { + modelString: context.model, + providerForMessages: "anthropic", + effectiveAgentId: "exec", + effectiveThinkingLevel: "off", + toolNamesForSentinel: ["bash"], + }, + postCompactionContext: { text: "kept header line ".repeat(40_000), skillNames: [] }, + systemPrefix: [], + cacheEnabled: true, + }); + await ( + await start(eagerPercent, { ...context, phase: "mid-stream" }) + ).job; + expect( + await compactor.observe(context.thresholdPercent, { ...context, phase: "mid-stream" }) + ).toBe("applied"); + expect(setSwap).not.toHaveBeenCalled(); + expect(fastApply).toHaveBeenCalledTimes(1); + }); + it("seamlessly rebuilds an internal committed cut as static prefix context and anchors the live step", async () => { const { answer, journal, journalStore } = await activateJournaledSwap(true, true, true); expect(journal.headEnd.id).toBe("committed-tail"); @@ -1401,13 +1463,13 @@ describe("ContinuousCompactor", () => { expect(copy.metadata?.usage).toBeUndefined(); expect(copy.metadata?.contextUsage).toBeUndefined(); expect(copy.metadata?.compactionBoundary).toBeUndefined(); + // The handler builds no header, so the attachment estimate is not counted. const expectedInput = context.systemMessageTokens! + - context.attachmentTokens! + persisted.reduce((sum, row) => sum + estimateMuxMessageTokens(row), 0); expect(boundary.metadata?.contextUsage?.inputTokens).toBe(expectedInput); expect(boundary.metadata?.contextUsage?.inputTokens).toBeGreaterThan( - estimateMuxMessageTokens(boundary) + context.systemMessageTokens! + context.attachmentTokens! + estimateMuxMessageTokens(boundary) + context.systemMessageTokens! ); expect(boundary.metadata?.usage).toBeUndefined(); expect(completed).toHaveBeenCalledTimes(1); diff --git a/src/node/services/continuousCompactor.ts b/src/node/services/continuousCompactor.ts index 6556133d32c..5a01b9896bd 100644 --- a/src/node/services/continuousCompactor.ts +++ b/src/node/services/continuousCompactor.ts @@ -1,6 +1,4 @@ -import { injectPostCompactionAttachments } from "@/browser/utils/messages/modelMessageTransform"; import type { ModelMessage } from "ai"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { ContinuousCompactionJournal } from "@/common/orpc/schemas/continuousCompaction"; import { exactJson, @@ -17,6 +15,7 @@ import { type CompactionFollowUpRequest, type MuxMessage, type MuxMetadata, + type PostCompactionContext, } from "@/common/types/message"; import { selectRollingCut, type RollingCut } from "@/common/utils/compaction/rollingCut"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; @@ -28,6 +27,7 @@ import { import type { HistoryService } from "./historyService"; import type { CompactionHandler } from "./compactionHandler"; import { log } from "./log"; +import { estimatePostCompactionContextTokens } from "./postCompactionContext"; export interface ContinuousCompactionContext { enabled: boolean; @@ -69,7 +69,7 @@ interface Dependencies { preparation: ContinuousCompactionJournal["preparation"]; systemPrefix: ModelMessage[]; requestProviderOptions?: Record; - attachments: PostCompactionAttachment[]; + postCompactionContext: PostCompactionContext | null; cacheEnabled: boolean; } | null>; // Includes usage recording: the generation fence must be AFTER the last await. @@ -154,6 +154,12 @@ function journalSourceRows( return [...rows.slice(0, -1), { ...source, parts: source.parts.slice(0, partIndex) }]; } +/** The first tool part in a row's first step: the wire anchor of a prefix swap. */ +function firstStepTool(row: MuxMessage | undefined) { + const firstStepEnd = row?.metadata?.stepStartPartIndices?.[1] ?? row?.parts.length; + return row?.parts.slice(0, firstStepEnd).find((part) => part.type === "dynamic-tool"); +} + /** Background summary + recent pages. Only the short apply is serialized with the turn. */ export class ContinuousCompactor { private generation = 0; @@ -535,11 +541,7 @@ export class ContinuousCompactor { const retainedLive = tail.at(-1); // Rebuild all committed context in the prefix: persisted rows can combine // consecutive tool-only steps. Only the current live step supplies a wire anchor. - const firstStepEnd = - retainedLive?.metadata?.stepStartPartIndices?.[1] ?? retainedLive?.parts.length; - const tool = retainedLive?.parts - .slice(0, firstStepEnd) - .find((part) => part.type === "dynamic-tool"); + const tool = firstStepTool(retainedLive); if ( retainedLive?.id !== live.messageId || retainedLive.role !== "assistant" || @@ -549,14 +551,21 @@ export class ContinuousCompactor { return false; const prepared = await this.deps.prepareSwap(staged.cut.head); if (!prepared || !this.isValid(staged, rows)) return false; + // The swapped prefix and every later request render the header from this boundary. const { boundary, copies } = this.deps.compactionHandler.buildContinuousCompactionRows({ messages: rows, text: staged.text, model: staged.model, tail, systemMessageTokens: context.systemMessageTokens ?? 0, - attachmentTokens: staged.attachmentTokens, + postCompactionContext: prepared.postCompactionContext, }); + // The estimate that admitted the summary can lack this header (a timed-out build has + // none), and the swapped prefix sends it with no later size check. + const headerTokens = estimatePostCompactionContextTokens( + boundary.metadata?.postCompactionContext + ); + if (!this.wouldFit({ ...staged, attachmentTokens: headerTokens }, rows, context)) return false; const staticCopies = copies.slice(0, -1); const liveCopy = copies.at(-1); assert(liveCopy && tail.at(-1)?.id === live.messageId, "Live tail must be final"); @@ -577,11 +586,11 @@ export class ContinuousCompactor { partIndex, metadataTemplate: liveCopy.metadata, }, + postCompactionAttachments: [], prefixSourceRows: [boundary, ...staticCopies], systemPrefix: z.array(z.json()).parse(exactJson(prepared.systemPrefix)), cacheEnabled: prepared.cacheEnabled, sourceFingerprint: fingerprint(journalSourceRows(visibleRows, source, partIndex)), - postCompactionAttachments: prepared.attachments, preparation: prepared.preparation, requestProviderOptions: prepared.requestProviderOptions == null @@ -752,10 +761,6 @@ export class ContinuousCompactor { model: journal.summaryModel, tail: [], systemMessageTokens: boundary.metadata?.systemMessageTokens ?? 0, - attachmentTokens: injectPostCompactionAttachments( - [], - journal.postCompactionAttachments - ).reduce((sum, row) => sum + estimateMuxMessageTokens(row), 0), // Sequence assignment must not mutate the exact journal receipt used by cleanup. prepared: { boundary, copies: [...structuredClone(journal.staticCopies), liveCopy] }, publication: { generation: journal.publicationGeneration, journal }, @@ -827,7 +832,6 @@ export class ContinuousCompactor { model: staged.model, tail, systemMessageTokens: context.systemMessageTokens ?? 0, - attachmentTokens: context.attachmentTokens ?? 0, pendingFollowUp, }); if (applied && staged.generation === this.generation) this.reset("applied"); diff --git a/src/node/services/messagePipeline.ts b/src/node/services/messagePipeline.ts index 4c17091aa68..1639a642783 100644 --- a/src/node/services/messagePipeline.ts +++ b/src/node/services/messagePipeline.ts @@ -22,7 +22,6 @@ import { prepareMcpReplay } from "@/node/services/mcpModelOutput"; import { planAnthropicThinkingStrip } from "@/node/utils/messages/anthropicThinkingReplay"; import { attachReasoningReplayMetadata } from "@/node/utils/messages/reasoningProviderOptions"; import type { MuxMessage } from "@/common/types/message"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import { anthropicBindsThinkingToPrefix, type ThinkingLevel } from "@/common/types/thinking"; import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries"; @@ -30,7 +29,6 @@ import { transformModelMessages, validateAnthropicCompliance, injectAgentTransition, - injectPostCompactionAttachments, } from "@/browser/utils/messages/modelMessageTransform"; import { applyNativeToolSearchReplay, @@ -52,8 +50,6 @@ export interface PrepareMessagesOptions { planContentForTransition?: string; /** Plan file path for transition context. */ planFilePath?: string; - /** Post-compaction attachments (plan file, loaded skills, edited files). */ - postCompactionAttachments?: PostCompactionAttachment[] | null; /** Canonical provider name for provider-specific transforms. */ providerForMessages: string; /** Thinking level for provider-specific behavior. */ @@ -90,19 +86,18 @@ export interface PrepareMessagesOptions { * * Transforms pre-filtered `XumMessage[]` into provider-ready `ModelMessage[]` by: * 1. Injecting agent-transition context (plan→exec handoff) - * 2. Injecting post-compaction attachments - * 3. Redacting heavy tool outputs - * 4. Sanitizing tool inputs - * 5. Inlining SVG attachments as text - * 6. Neutralizing user-typed lookalikes (peer-envelope provenance) - * 7. Sanitizing PDF filenames for Anthropic - * 8. Extracting tool-result media as user message attachments - * 9. Rewriting data-URI file parts to SDK-safe inline base64 - * 10. Converting to Vercel AI SDK ModelMessage format, split at persisted step boundaries - * 11. Self-healing: filtering empty/whitespace assistant messages - * 12. Applying provider-specific message transforms - * 13. Applying cache control headers - * 14. Validating Anthropic compliance (logs warnings only) + * 2. Redacting heavy tool outputs + * 3. Sanitizing tool inputs + * 4. Inlining SVG attachments as text + * 5. Neutralizing user-typed lookalikes (peer-envelope provenance) + * 6. Sanitizing PDF filenames for Anthropic + * 7. Extracting tool-result media as user message attachments + * 8. Rewriting data-URI file parts to SDK-safe inline base64 + * 9. Converting to Vercel AI SDK ModelMessage format, split at persisted step boundaries + * 10. Self-healing: filtering empty/whitespace assistant messages + * 11. Applying provider-specific message transforms + * 12. Applying cache control headers + * 13. Validating Anthropic compliance (logs warnings only) * * Log purity: this pipeline never reads live workspace state (disk, file * trackers). File-change notifications and @file mention snapshots are @@ -120,7 +115,6 @@ export async function prepareMessagesForProvider( toolNamesForSentinel, planContentForTransition, planFilePath, - postCompactionAttachments, providerForMessages, effectiveThinkingLevel, modelString, @@ -142,15 +136,9 @@ export async function prepareMessagesForProvider( planContentForTransition ? planFilePath : undefined ); - // Inject post-compaction attachments (plan file, loaded skills, edited files) after compaction summary - const messagesWithPostCompaction = injectPostCompactionAttachments( - messagesWithAgentContext, - postCompactionAttachments - ); - // Apply centralized tool-output redaction BEFORE converting to provider ModelMessages. // Keeps the persisted/UI history intact while trimming heavy fields for the request. - const redactedForProvider = applyToolOutputRedaction(messagesWithPostCompaction); + const redactedForProvider = applyToolOutputRedaction(messagesWithAgentContext); log.debug_obj(`${workspaceId}/2a_redacted_messages.json`, redactedForProvider); // Sanitize tool inputs to ensure they are valid objects (not strings or arrays). diff --git a/src/node/services/postCompactionContext.test.ts b/src/node/services/postCompactionContext.test.ts new file mode 100644 index 00000000000..5ee300b5dfc --- /dev/null +++ b/src/node/services/postCompactionContext.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; +import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; +import { SESSION_HISTORY_MAX_LINE_BYTES } from "@/common/constants/contextBudget"; +import { CONTEXT_BOUNDARY_KINDS } from "@/common/utils/messages/compactionBoundary"; +import { + createMuxMessage, + type MuxMessage, + type PostCompactionContext, +} from "@/common/types/message"; +import { + findPostCompactionContext, + insertPostCompactionContext, + omitPostCompactionContextFrom, + renderPostCompactionContext, + restorePostCompactionContextBefore, + shiftContextUsageByHeader, + withPostCompactionContext, +} from "./postCompactionContext"; + +const compactionBoundary = (id: string, postCompactionContext?: PostCompactionContext) => + createMuxMessage(id, "assistant", `summary ${id}`, { + timestamp: 1000, + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, + postCompactionContext, + }); + +const header = (text: string): PostCompactionContext => ({ text, skillNames: [] }); + +const plan = { path: "/plans/ws.md", content: "plan v1", modifiedTimeMs: 5 }; + +describe("renderPostCompactionContext", () => { + test("returns no header without attachments", () => { + expect(renderPostCompactionContext([], plan)).toBeNull(); + }); + + test("carries the plan baseline only when the text renders the plan", () => { + const planAttachment = { + type: "plan_file_reference" as const, + planFilePath: plan.path, + planContent: plan.content, + }; + const todos = { + type: "todo_list" as const, + todos: [{ content: "a", status: "pending" as const }], + }; + expect(renderPostCompactionContext([planAttachment, todos], plan)?.plan).toEqual(plan); + const withoutPlan = renderPostCompactionContext([todos], plan); + expect(withoutPlan?.text.length).toBeGreaterThan(0); + expect(withoutPlan?.plan).toBeUndefined(); + }); + + test("keeps the text within the injection budget", () => { + const context = renderPostCompactionContext( + [ + { + type: "edited_files_reference", + files: [ + { + path: "src/huge.ts", + diff: "x".repeat(MAX_POST_COMPACTION_INJECTION_CHARS + 10_000), + truncated: false, + }, + ], + }, + ], + null + ); + expect(context?.text.length).toBeGreaterThan(0); + expect(context?.text.length).toBeLessThanOrEqual(MAX_POST_COMPACTION_INJECTION_CHARS); + }); +}); + +describe("insertPostCompactionContext", () => { + test("inserts the latest boundary's header right after that boundary", () => { + const older = compactionBoundary("older", header("OLD")); + const latest = compactionBoundary("latest", header("NEW")); + const u1 = createMuxMessage("u1", "user", "first"); + const u2 = createMuxMessage("u2", "user", "second"); + + const result = insertPostCompactionContext([older, u1, latest, u2]); + + expect(result.map((message) => message.id)).toEqual([ + "older", + "u1", + "latest", + "post-compaction-context-latest", + "u2", + ]); + expect(result[3]).toMatchObject({ + role: "user", + parts: [{ type: "text", text: "NEW" }], + metadata: { timestamp: 1000, synthetic: true }, + }); + expect(result[3].metadata?.historySequence).toBeUndefined(); + }); + + test("inserts nothing when a reset boundary follows the compaction", () => { + const reset = createMuxMessage("reset", "assistant", "", { + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }); + const messages = [ + compactionBoundary("summary", header("STALE")), + createMuxMessage("u1", "user", "first"), + reset, + ]; + expect(insertPostCompactionContext(messages)).toBe(messages); + expect(findPostCompactionContext(messages)).toBeNull(); + }); + + test("ignores a malformed persisted header", () => { + for (const persisted of ['{"text":42}', '{"text":""}', "null"]) { + const messages = [ + compactionBoundary("summary", JSON.parse(persisted) as PostCompactionContext), + createMuxMessage("u1", "user", "first"), + ]; + expect(insertPostCompactionContext(messages)).toBe(messages); + } + const mixedFields = JSON.parse( + '{"text":"CTX","skillNames":["a",1],"omittedFromRequestSequence":"3","plan":{"path":"p"}}' + ) as PostCompactionContext; + expect(findPostCompactionContext([compactionBoundary("summary", mixedFields)])).toEqual({ + text: "CTX", + skillNames: ["a"], + }); + }); +}); + +describe("omitPostCompactionContextFrom", () => { + const renderedHeaders = (messages: MuxMessage[]) => + insertPostCompactionContext(messages) + .filter((message) => message.id.startsWith("post-compaction-context-")) + .map((message) => message.parts); + + test("leaves the header out of requests from the sequence on, and replay restores it before", () => { + const omitted = omitPostCompactionContextFrom(compactionBoundary("s", header("CTX")), 7); + if (!omitted) throw new Error("expected the header to be left out"); + const messages = [omitted, createMuxMessage("u1", "user", "first")]; + + expect(renderedHeaders(messages)).toEqual([]); + expect(findPostCompactionContext(messages)).toBeNull(); + expect(renderedHeaders(restorePostCompactionContextBefore(messages, 6))).toEqual([ + [{ type: "text", text: "CTX" }], + ]); + expect(restorePostCompactionContextBefore(messages, 7)).toBe(messages); + // A second retry cannot leave out a header that requests no longer render. + expect(omitPostCompactionContextFrom(omitted, 9)).toBeNull(); + }); + + test("removes the header's tokens from the boundary's context usage", () => { + const usage = { inputTokens: 100, outputTokens: 0, totalTokens: 100 }; + const boundary = compactionBoundary("s", header("CTX ".repeat(500))); + const counted = shiftContextUsageByHeader( + { ...boundary, metadata: { ...boundary.metadata, contextUsage: usage } }, + 1 + ); + expect(counted.metadata?.contextUsage?.inputTokens).toBeGreaterThan(100); + + expect(omitPostCompactionContextFrom(counted, 7)?.metadata?.contextUsage).toEqual(usage); + }); +}); + +describe("withPostCompactionContext", () => { + const boundaryHeader = (message: MuxMessage) => message.metadata?.postCompactionContext; + + test("sets the header only on a boundary without one", () => { + const fresh = withPostCompactionContext(compactionBoundary("s"), header("NEW"), "ws"); + expect(boundaryHeader(fresh)).toEqual(header("NEW")); + + const existing = compactionBoundary("s", header("KEPT")); + expect(withPostCompactionContext(existing, header("NEW"), "ws")).toBe(existing); + + const bare = compactionBoundary("s"); + expect(withPostCompactionContext(bare, null, "ws")).toBe(bare); + }); + + test("drops a header that would push the row over the history line limit", () => { + const bare = compactionBoundary("s"); + const oversized = header("x".repeat(SESSION_HISTORY_MAX_LINE_BYTES)); + expect(withPostCompactionContext(bare, oversized, "ws")).toBe(bare); + }); + + test("drops the header with its plan baseline when the baseline does not fit", () => { + const bare = compactionBoundary("s"); + const text = "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES / 2); + const bigPlan = { ...plan, content: "y".repeat(SESSION_HISTORY_MAX_LINE_BYTES / 2) }; + expect(withPostCompactionContext(bare, { ...header(text), plan: bigPlan }, "ws")).toBe(bare); + }); +}); diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts new file mode 100644 index 00000000000..be8732a3dc7 --- /dev/null +++ b/src/node/services/postCompactionContext.ts @@ -0,0 +1,259 @@ +import type { LoadedSkillSnapshot, PostCompactionAttachment } from "@/common/types/attachment"; +import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; +import { + postCompactionContextMessageId, + type MuxMessage, + type PostCompactionContext, + type PostCompactionPlan, +} from "@/common/types/message"; +import { isNonNegativeInteger } from "@/common/utils/numbers"; +import { isPlainObject } from "@/common/utils/isPlainObject"; +import { readStringArray } from "@/common/utils/readStringArray"; +import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; +import { + COMPACTION_BOUNDARY_ROW_HEADROOM_BYTES, + SESSION_HISTORY_MAX_LINE_BYTES, +} from "@/common/constants/contextBudget"; +import { + renderAttachmentsToContentWithBudget, + rendersPlanFileReference, +} from "@/browser/utils/messages/attachmentRenderer"; +import { + isDurableCompactionBoundaryMarker, + isDurableContextBoundaryMarker, +} from "@/common/utils/messages/compactionBoundary"; +import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; +import { loadedSnapshotSkillNames } from "./agentSkills/pathSkillActivation"; +import { measurePersistedHistoryRowBytes } from "./historyRowBudget"; +import { log } from "./log"; + +/** What compaction knows when it builds a boundary's header. */ +export interface PostCompactionContextInput { + diffs: FileEditDiff[]; + loadedSkills: LoadedSkillSnapshot[]; + /** All tracked reads; the builder decides whether the model sees them. */ + readFilePaths: string[]; + /** Reports completed before the boundary: their tool results are in the summary now. */ + reportsCompletedBeforeMs: number; +} + +/** + * Render post-compaction attachments once, when compaction writes the boundary. The boundary row + * keeps the rendered text, not the attachments: the text has a fixed size limit, and a later + * renderer change cannot alter the bytes that earlier requests sent. + */ +export function renderPostCompactionContext( + attachments: PostCompactionAttachment[], + plan: PostCompactionPlan | null +): PostCompactionContext | null { + const text = renderAttachmentsToContentWithBudget(attachments, { + maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, + }); + if (text.length === 0) return null; + const context = { text, skillNames: loadedSnapshotSkillNames(attachments) }; + // The budget can leave the plan out of the text; then the window has no plan to track. + return plan && rendersPlanFileReference(text) ? { ...context, plan } : context; +} + +function readPostCompactionPlan(plan: unknown): PostCompactionPlan | undefined { + if (!isPlainObject(plan)) return undefined; + const { path, content, modifiedTimeMs } = plan; + return typeof path === "string" && + typeof content === "string" && + typeof modifiedTimeMs === "number" && + Number.isFinite(modifiedTimeMs) + ? { path, content, modifiedTimeMs } + : undefined; +} + +/** + * The header of a compaction boundary row, also when requests leave it out; a malformed + * persisted value counts as none. + */ +function readPersistedPostCompactionContext(message: MuxMessage): PostCompactionContext | null { + if (!isDurableCompactionBoundaryMarker(message)) return null; + const context: unknown = message.metadata?.postCompactionContext; + if (!isPlainObject(context)) return null; + const { text, skillNames, omittedFromRequestSequence, plan } = context; + if (typeof text !== "string" || text.length === 0) return null; + const parsedPlan = readPostCompactionPlan(plan); + return { + text, + skillNames: readStringArray(skillNames), + ...(isNonNegativeInteger(omittedFromRequestSequence) ? { omittedFromRequestSequence } : {}), + ...(parsedPlan ? { plan: parsedPlan } : {}), + }; +} + +/** The header that new requests render after a boundary row; null once a retry left it out. */ +function readPostCompactionContext(message: MuxMessage): PostCompactionContext | null { + const context = readPersistedPostCompactionContext(message); + return context?.omittedFromRequestSequence == null ? context : null; +} + +/** The header of the latest context boundary, if that boundary is a compaction with one. */ +export function findPostCompactionContext( + messages: readonly MuxMessage[] +): PostCompactionContext | null { + const boundary = messages.findLast(isDurableContextBoundaryMarker); + return boundary ? readPostCompactionContext(boundary) : null; +} + +/** Skills that the latest boundary's header loads into every request of its window. */ +export function findPostCompactionContextSkillNames(messages: readonly MuxMessage[]): string[] { + return findPostCompactionContext(messages)?.skillNames ?? []; +} + +/** The plan that the latest boundary's header shows the model, if any. */ +export function findPostCompactionPlan( + messages: readonly MuxMessage[] +): PostCompactionPlan | undefined { + return findPostCompactionContext(messages)?.plan; +} + +/** The id of the latest context boundary when its header renders into the request. */ +export function findPostCompactionContextBoundaryId( + messages: readonly MuxMessage[] +): string | undefined { + const boundary = messages.findLast(isDurableContextBoundaryMarker); + return boundary && readPostCompactionContext(boundary) ? boundary.id : undefined; +} + +/** + * The boundary row with its header left out of requests from this sequence on, for the + * context-exceeded retry in AgentSession. The row keeps the header, so replay can rebuild the + * earlier requests, but its contextUsage no longer counts it. Null when requests do not render + * a header for the row. + */ +export function omitPostCompactionContextFrom( + boundary: MuxMessage, + requestHistorySequence: number +): MuxMessage | null { + const context = readPostCompactionContext(boundary); + if (!context) return null; + const omitted: MuxMessage = { + ...boundary, + metadata: { + ...boundary.metadata, + postCompactionContext: { ...context, omittedFromRequestSequence: requestHistorySequence }, + }, + }; + return shiftContextUsageByHeader(omitted, -1); +} + +/** + * The boundary with its header's tokens added to (1) or removed from (-1) its contextUsage. The + * context meter and the compaction check of the next send read that estimate, so it counts the + * header exactly while requests render it. + */ +export function shiftContextUsageByHeader(boundary: MuxMessage, sign: 1 | -1): MuxMessage { + const usage = boundary.metadata?.contextUsage; + if (!usage) return boundary; + const tokens = + sign * estimatePostCompactionContextTokens(boundary.metadata?.postCompactionContext); + return { + ...boundary, + metadata: { + ...boundary.metadata, + contextUsage: { + ...usage, + inputTokens: Math.max(0, (usage.inputTokens ?? 0) + tokens), + totalTokens: Math.max(0, (usage.totalTokens ?? 0) + tokens), + }, + }, + }; +} + +/** + * Replay: the history as a request at this sequence saw it. A header that a retry left out + * still rendered into the requests before that retry. + */ +export function restorePostCompactionContextBefore( + messages: MuxMessage[], + requestHistorySequence: number +): MuxMessage[] { + const index = messages.findLastIndex(isDurableContextBoundaryMarker); + if (index === -1) return messages; + const context = readPersistedPostCompactionContext(messages[index]); + if ( + context?.omittedFromRequestSequence == null || + requestHistorySequence >= context.omittedFromRequestSequence + ) + return messages; + const { omittedFromRequestSequence: _omitted, ...restored } = context; + return messages.map((message, messageIndex) => + messageIndex === index + ? { ...message, metadata: { ...message.metadata, postCompactionContext: restored } } + : message + ); +} + +/** + * Set the header on a boundary that has none. The summary is worth more than the header, so a + * header that would push the row over the history line limit is dropped, not the summary cut. + * The header keeps its plan baseline or is dropped with it: a plan in the header that nothing + * tracks would replay stale after the user edits the file. + */ +export function withPostCompactionContext( + boundary: MuxMessage, + context: PostCompactionContext | null, + workspaceId: string +): MuxMessage { + if (!context || boundary.metadata?.postCompactionContext) return boundary; + const withContext: MuxMessage = { + ...boundary, + metadata: { ...boundary.metadata, postCompactionContext: context }, + }; + const rowBytes = measurePersistedHistoryRowBytes(withContext, workspaceId); + if (rowBytes <= SESSION_HISTORY_MAX_LINE_BYTES - COMPACTION_BOUNDARY_ROW_HEADROOM_BYTES) { + return withContext; + } + log.warn("Post-compaction context does not fit the boundary row; dropping it", { + workspaceId, + messageId: boundary.id, + rowBytes, + withPlan: context.plan != null, + }); + return boundary; +} + +function createPostCompactionContextMessage( + boundary: Pick, + context: PostCompactionContext +): MuxMessage { + return { + id: postCompactionContextMessageId(boundary.id), + role: "user", + parts: [{ type: "text", text: context.text }], + // No historySequence: the row is not in chat.jsonl, so it gets no [id: ...] tag. + metadata: { timestamp: boundary.metadata?.timestamp, synthetic: true }, + }; +} + +/** Tokens the header adds to a request (0 without one). */ +export function estimatePostCompactionContextTokens( + context: PostCompactionContext | null | undefined +): number { + return context + ? estimateMuxMessageTokens(createPostCompactionContextMessage({ id: "estimate" }, context)) + : 0; +} + +/** + * Insert the latest context boundary's header as a synthetic user row right after the + * boundary. The header is fixed for the life of the boundary, so every turn, step and + * compaction request of the window builds the same bytes at the same place, and the cached + * prefix after the summary stays valid. + */ +export function insertPostCompactionContext(messages: MuxMessage[]): MuxMessage[] { + const index = messages.findLastIndex(isDurableContextBoundaryMarker); + if (index === -1) return messages; + const boundary = messages[index]; + const context = readPostCompactionContext(boundary); + if (!context) return messages; + return [ + ...messages.slice(0, index + 1), + createPostCompactionContextMessage(boundary, context), + ...messages.slice(index + 1), + ]; +} diff --git a/src/node/services/replay/replayFixture.ts b/src/node/services/replay/replayFixture.ts index 754d5566a36..579cc9ec8d5 100644 --- a/src/node/services/replay/replayFixture.ts +++ b/src/node/services/replay/replayFixture.ts @@ -17,13 +17,13 @@ import * as path from "node:path"; import { tool, type Tool } from "ai"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { z } from "zod"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { DevToolsLogEntry } from "@/common/types/devtools"; import { createMuxMessage, filterOrphanedMcpPromptSnapshots, type MuxMessage, type PluginHookContextEntry, + type PostCompactionContext, type SystemSectionRecords, } from "@/common/types/message"; import { applyCacheControlToTools, type AnthropicCacheTtl } from "@/common/utils/ai/cacheStrategy"; @@ -31,6 +31,7 @@ import assert from "@/common/utils/assert"; import { collectDeferLoadingToolNames } from "@/common/utils/tools/toolCatalog"; import { replaceOrAppendMessageById } from "@/node/services/aiService"; import { HistoryService } from "@/node/services/historyService"; +import { omitPostCompactionContextFrom } from "@/node/services/postCompactionContext"; import { assemblePromptPayload } from "@/node/services/turnContextAssembler"; import { emitTurnEnvelope } from "@/node/services/turnEnvelope"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; @@ -90,7 +91,6 @@ export interface ReplayFixtureTurnSpec { /** Request-time inputs beyond chat.jsonl — logged in the turn envelope. */ planContentForTransition?: string; planFilePath?: string; - postCompactionAttachments?: PostCompactionAttachment[]; /** Refusal-fallback partial continuation (envelope-only, never in chat.jsonl). */ partialContinuation?: MuxMessage; /** MCP Apps model context message (request-only, never in chat.jsonl). */ @@ -102,6 +102,8 @@ export interface ReplayFixtureTurnSpec { systemVolatileSuffixLength?: number; /** Set false to simulate devtools logging being off for this turn. */ recordDevtools?: boolean; + /** Leave the boundary's header out from this turn on, as AgentSession's context-exceeded retry does. */ + omitPostCompactionContext?: boolean; /** * Emit this many extra turn-envelope rows (same requestHistorySequence) * before the final one — simulates retried attempts whose streams failed. @@ -184,6 +186,7 @@ export async function appendReplayFixtureTurn( ...(spec.pluginHookContext != null ? { pluginHookContext: spec.pluginHookContext } : {}), }); const requestHistorySequence = await appendOrThrow(ctx, userMessage); + await omitFixturePostCompactionContext(ctx, spec, requestHistorySequence); // The request is built from history as read BEFORE the assistant row is // appended — same ordering as AgentSession.streamWithHistory. @@ -212,7 +215,6 @@ export async function appendReplayFixtureTurn( anthropicCacheTtl: spec.anthropicCacheTtl, planContentForTransition: spec.planContentForTransition, planFilePath: spec.planFilePath, - postCompactionAttachments: spec.postCompactionAttachments, partialContinuationMessage: spec.partialContinuation, mcpAppModelContextMessage: spec.envelopeOmitsMcpAppModelContext === true ? undefined : spec.mcpAppModelContext, @@ -281,6 +283,20 @@ export async function appendReplayFixtureTurn( } } +async function omitFixturePostCompactionContext( + ctx: ReplayFixtureSessionContext, + spec: ReplayFixtureTurnSpec, + requestHistorySequence: number +): Promise { + if (!spec.omitPostCompactionContext) return; + const history = await ctx.historyService.getHistoryFromLatestBoundary(ctx.workspaceId); + assert(history.success, "fixture history read failed"); + const omitted = omitPostCompactionContextFrom(history.data[0], requestHistorySequence); + assert(omitted != null, "fixture boundary has no post-compaction header to omit"); + const result = await ctx.historyService.updateHistory(ctx.workspaceId, omitted); + assert(result.success, "fixture boundary update failed"); +} + function fixtureModel(spec: ReplayFixtureTurnSpec): string { return spec.liveToolInstructions == null ? REPLAY_FIXTURE_MODEL : REPLAY_FIXTURE_ROWS_MODEL; } @@ -316,7 +332,6 @@ async function buildReplayedFixtureRequest( anthropicCacheTtl: spec.anthropicCacheTtl, planContentForTransition: spec.planContentForTransition, planFilePath: spec.planFilePath, - postCompactionAttachments: spec.postCompactionAttachments, partialContinuation: spec.partialContinuation, mcpAppModelContext: spec.mcpAppModelContext, // Production derives these from the request tools (assemblePromptPayload). @@ -364,7 +379,6 @@ async function buildLiveFixtureRequest( anthropicCacheTtl: spec.anthropicCacheTtl, planContentForTransition: spec.planContentForTransition, planFilePath: spec.planFilePath, - postCompactionAttachments: spec.postCompactionAttachments, requestContextMessage: spec.mcpAppModelContext, compactionInputBudgetTokens: spec.compactionInputBudgetTokens, modelString: REPLAY_FIXTURE_ROWS_MODEL, @@ -406,7 +420,8 @@ async function buildLiveFixtureRequest( export async function appendReplayFixtureCompactionBoundary( ctx: ReplayFixtureSessionContext, summaryText: string, - compactionEpoch: number + compactionEpoch: number, + postCompactionContext?: PostCompactionContext ): Promise { ctx.turnCounter += 1; const boundaryMessage = createMuxMessage( @@ -418,6 +433,7 @@ export async function appendReplayFixtureCompactionBoundary( compactionBoundary: true, compacted: true, compactionEpoch, + postCompactionContext, } ); await appendOrThrow(ctx, boundaryMessage); diff --git a/src/node/services/replay/replayRequestBuilder.ts b/src/node/services/replay/replayRequestBuilder.ts index 5ffb6856a1b..d8f6d18db66 100644 --- a/src/node/services/replay/replayRequestBuilder.ts +++ b/src/node/services/replay/replayRequestBuilder.ts @@ -27,7 +27,6 @@ import type { LanguageModelV4StreamPart, } from "@ai-sdk/provider"; import type { ProvidersConfigMap } from "@/common/orpc/types"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import { filterOrphanedMcpPromptSnapshots, type MuxMessage, @@ -86,8 +85,6 @@ export interface ReplayRequestInputs { /** Plan→exec handoff content from the turn-envelope blob (model-visible). */ planContentForTransition?: string; planFilePath?: string; - /** Post-compaction attachments from the turn-envelope blob (model-visible). */ - postCompactionAttachments?: PostCompactionAttachment[] | null; /** * Refusal-fallback partial continuation from the turn-envelope blob. The * eventual assistant row lands after requestHistorySequence, so this @@ -252,7 +249,6 @@ export async function buildReplayRequest(inputs: ReplayRequestInputs): Promise { systemPrompt: SYSTEM_PROMPT, tools: tools(), }); - await appendReplayFixtureCompactionBoundary(ctx, "Summary of the session so far.", 1); + // The boundary's post-compaction header renders into the request from chat.jsonl alone. + await appendReplayFixtureCompactionBoundary(ctx, "Summary of the session so far.", 1, { + text: "1. do the thing", + skillNames: [], + }); await appendReplayFixtureTurn(ctx, { userText: "After compaction.", assistantText: "Answer two.", systemPrompt: SYSTEM_PROMPT, tools: tools(), - // Post-compaction attachments are injected into the request and must be - // reconstructible from the envelope blob alone. - postCompactionAttachments: [ - { - type: "plan_file_reference", - planFilePath: "/tmp/plan.md", - planContent: "1. do the thing", - }, - ], }); const result = await verify(ctx); @@ -90,12 +86,62 @@ describe("replayVerifySession pairing", () => { { status: "PASS", reason: undefined }, ]); - // Guard against a vacuous pass: the injected attachment content must - // actually be model-visible in the recorded request. + // Guard against a vacuous pass: the header must actually be model-visible in the + // recorded request. const devtoolsRaw = ctx.devtoolsLines.join("\n"); expect(devtoolsRaw).toContain("1. do the thing"); }); + test("a request sent after a retry left the boundary's header out passes", async () => { + using tmp = new DisposableTempDir("replay-verify-header-omitted"); + const ctx = createReplayFixtureSessionContext(tmp.path); + await appendReplayFixtureCompactionBoundary(ctx, "Summary of the session so far.", 1, { + text: "1. do the thing", + skillNames: [], + }); + for (const omitPostCompactionContext of [false, true]) { + await appendReplayFixtureTurn(ctx, { + userText: "After compaction.", + assistantText: "Done.", + systemPrompt: SYSTEM_PROMPT, + tools: tools(), + omitPostCompactionContext, + }); + } + + const result = await verify(ctx); + expect(result.turns.map((turn) => turn.status)).toEqual(["PASS", "PASS"]); + // Guard against a vacuous pass: only the first recorded request holds the header. + expect(ctx.devtoolsLines.filter((line) => line.includes("1. do the thing"))).toHaveLength(1); + }); + + test("skips a turn whose envelope recorded request-time post-compaction attachments", async () => { + using tmp = new DisposableTempDir("replay-verify-legacy-attachments"); + const ctx = createReplayFixtureSessionContext(tmp.path); + for (const userText of ["First.", "Second."]) { + await appendReplayFixtureTurn(ctx, { + userText, + assistantText: "Done.", + systemPrompt: SYSTEM_PROMPT, + tools: tools(), + }); + } + // Older builds added attachments to the request and logged them in the envelope; + // this build cannot rebuild those bytes from chat.jsonl. + const eventsPath = `${tmp.path}/${DURABLE_EVENTS_FILE_NAME}`; + const lines = (await fs.readFile(eventsPath, "utf-8")).trim().split("\n"); + const lastEnvelopeIndex = lines.findLastIndex((line) => line.includes('"turn-envelope"')); + const envelope = JSON.parse(lines[lastEnvelopeIndex]) as { + data: Record & { systemPromptHash: unknown }; + }; + envelope.data.postCompactionAttachmentsHash = envelope.data.systemPromptHash; + lines[lastEnvelopeIndex] = JSON.stringify(envelope); + await fs.writeFile(eventsPath, `${lines.join("\n")}\n`, "utf-8"); + + const result = await verify(ctx); + expect(result.turns.map((turn) => turn.status)).toEqual(["PASS", "SKIPPED"]); + }); + test("the prefix rule checks every recorded step, not only each turn's first", async () => { using tmp = new DisposableTempDir("replay-verify-step-prefix"); const ctx = createReplayFixtureSessionContext(tmp.path); diff --git a/src/node/services/replay/replayVerify.ts b/src/node/services/replay/replayVerify.ts index d0f57776638..40472d0d4f2 100644 --- a/src/node/services/replay/replayVerify.ts +++ b/src/node/services/replay/replayVerify.ts @@ -29,7 +29,6 @@ import type { DevToolsStep, DevToolsUsage, } from "@/common/types/devtools"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { DurableEvent } from "@/common/types/durableEvent"; import type { MuxMessage } from "@/common/types/message"; import type { Result } from "@/common/types/result"; @@ -46,6 +45,7 @@ import { } from "@/common/utils/messages/compactionBoundary"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; import type { HistoryService } from "@/node/services/historyService"; +import { restorePostCompactionContextBefore } from "@/node/services/postCompactionContext"; import { hashToolSchema, isProviderDefinedToolRecord, @@ -781,18 +781,18 @@ export async function replayVerifySession(params: { planContentForTransition = planContent; } - let postCompactionAttachments: PostCompactionAttachment[] | undefined; if (envelope.data.postCompactionAttachmentsHash != null) { - const attachmentsJson = await journal.blobs.getText( - envelope.data.postCompactionAttachmentsHash - ); - if (attachmentsJson == null) { - fail( - `post-compaction attachments blob ${envelope.data.postCompactionAttachmentsHash} missing from blob store` - ); - continue; - } - postCompactionAttachments = JSON.parse(attachmentsJson) as PostCompactionAttachment[]; + // Older builds added post-compaction attachments at request time. This build renders + // the boundary's header from chat.jsonl instead, so it cannot rebuild those requests. + results.push({ + turnIndex: i, + envelopeSeq: envelope.seq, + assistantMessageId: turn.message.id, + runId: record.runId, + status: "SKIPPED", + reason: "request-time post-compaction attachments from an older build", + }); + continue; } let partialContinuation: MuxMessage | undefined; @@ -829,7 +829,11 @@ export async function replayVerifySession(params: { // 3) Messages: rebuild the full LanguageModel prompt from log rows and // byte-compare against the recorded request. - const historySlice = sliceEpochForTurn(params.historyMessages, turn.requestHistorySequence); + // A header that a later context-exceeded retry left out still rendered into this request. + const historySlice = restorePostCompactionContextBefore( + sliceEpochForTurn(params.historyMessages, turn.requestHistorySequence), + turn.requestHistorySequence + ); const rebuilt = await buildReplayRequest({ historyMessages: historySlice, @@ -855,7 +859,6 @@ export async function replayVerifySession(params: { toolInstructions: readToolInstructionsRecord(turn.message), planContentForTransition, planFilePath: envelope.data.planTransitionFilePath, - postCompactionAttachments, partialContinuation, mcpAppModelContext, deferLoadingToolNames: deferLoadingNamesFromRecordedTools(record.tools), diff --git a/src/node/services/streamManager.continuousCompaction.test.ts b/src/node/services/streamManager.continuousCompaction.test.ts index 1cabeaef1b1..8b5d303d9cb 100644 --- a/src/node/services/streamManager.continuousCompaction.test.ts +++ b/src/node/services/streamManager.continuousCompaction.test.ts @@ -82,7 +82,6 @@ function journalFixture(): ContinuousCompactionJournal { partIndex: 1, metadataTemplate: { synthetic: true, rlmPreservedTailCopy: true }, }, - postCompactionAttachments: [], prefixSourceRows: [boundary, createMuxMessage("user-copy", "user", "current prompt")], systemPrefix: [{ role: "system", content: "system", providerOptions: cache }], cacheEnabled: true, @@ -978,7 +977,6 @@ describe("continuous prefix prepareStep and journal", () => { workspaceId, history: journal.prefixSourceRows, systemMessage: "", - postCompactionAttachments: journal.postCompactionAttachments, }); const actual = (await rebuildContinuousPrefix(journal, workspaceId)).filter( (message) => message.role !== "system" @@ -1000,6 +998,11 @@ describe("continuous prefix prepareStep and journal", () => { }, { type: "text", text: "kept answer" }, ]); + // The boundary's header renders in both builds, right after the boundary. + journal.boundary.metadata = { + ...journal.boundary.metadata, + postCompactionContext: { text: "post-compaction header", skillNames: [] }, + }; // A repaired turn that failed before output: commitPartial kept only its receipt. const receiptOnly: MuxMessage = { id: "repaired-no-output", @@ -1021,7 +1024,6 @@ describe("continuous prefix prepareStep and journal", () => { workspaceId, history: journal.prefixSourceRows, systemMessage: "", - postCompactionAttachments: journal.postCompactionAttachments, }); const actual = (await rebuildContinuousPrefix(journal, workspaceId)).filter( (message) => message.role !== "system" @@ -1030,6 +1032,7 @@ describe("continuous prefix prepareStep and journal", () => { const serialized = JSON.stringify(actual); expect(serialized).toContain("kept answer"); expect(serialized).not.toContain("removed thinking"); + expect(serialized).toContain("post-compaction header"); }); it("prefix replay keeps the thinking after a server tool out, tool pairs intact (#5887)", async () => { @@ -1092,7 +1095,6 @@ describe("continuous prefix prepareStep and journal", () => { workspaceId, history: journal.prefixSourceRows, systemMessage: "", - postCompactionAttachments: journal.postCompactionAttachments, }); const actual = (await rebuildContinuousPrefix(journal, workspaceId)).filter( (message) => message.role !== "system" diff --git a/src/node/services/turnContextAssembler.test.ts b/src/node/services/turnContextAssembler.test.ts index fd727cd2bdc..dce17318068 100644 --- a/src/node/services/turnContextAssembler.test.ts +++ b/src/node/services/turnContextAssembler.test.ts @@ -28,7 +28,6 @@ import type { PlanFileLocation } from "@/node/utils/runtime/planLocation"; import { createRemoteProjectId } from "@/node/runtime/remoteProjectLayout"; import { DisposableTempDir } from "@/node/services/tempDir"; import { createTestHistoryService } from "./testHistoryService"; -import { createFileChangeNotificationMessage } from "./utils/fileChangeTracker"; import { createContextBudgetWarning } from "./contextWindowRollover"; import { extractToolInstructionsFromSources } from "./systemMessage"; @@ -1533,133 +1532,95 @@ describe("assemblePromptPayload", () => { expect(JSON.stringify(payload.messages[1])).toContain("approved plan body"); }); - test("post-compaction attachments leave every row before the newest request unchanged", async () => { - // F34: the attachments are request-only and come back every few turns for the segment. - // After the compaction summary they rewrote the cached history on the turn they appeared - // and again on the next turn, when they were gone. + describe("post-compaction header", () => { const opus = { modelString: "anthropic:claude-opus-5-5", providerForMessages: "anthropic", routeProvider: "anthropic", + systemMessage: "stable instructions", }; - const summary = createMuxMessage("summary", "assistant", "Compacted summary", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, - }); - const [u1, a1, u2, a2, u3] = [ - createMuxMessage("u1", "user", "first"), - createMuxMessage("a1", "assistant", "answer 1"), - createMuxMessage("u2", "user", "second"), - createMuxMessage("a2", "assistant", "answer 2"), - createMuxMessage("u3", "user", "third"), - ]; - const attachments = [ - { - type: "plan_file_reference" as const, - planFilePath: "/tmp/plan.md", - planContent: "ATTACHED PLAN", - }, - ]; - const send = (history: MuxMessage[], withAttachments: boolean) => - assemble({ - ...opus, - history, - systemMessage: "stable instructions", - postCompactionAttachments: withAttachments ? attachments : null, + const HEADER_ID = "post-compaction-context-summary"; + const boundary = (text?: string) => + createMuxMessage("summary", "assistant", "Compacted summary", { + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, + ...(text ? { postCompactionContext: { text, skillNames: [] } } : {}), }); - const rows = (payload: Awaited>, count: number) => + const ids = (payload: Awaited>) => + payload.providerRequestMessages.map((message) => message.id); + const rows = (payload: Awaited>, count: number) => JSON.stringify(payload.messages.slice(0, count), (key, value: unknown) => key === "providerOptions" ? undefined : value ); - const plain = await send([summary, u1, a1, u2], false); - const attached = await send([summary, u1, a1, u2], true); - const next = await send([summary, u1, a1, u2, a2, u3], false); - // The cached system row, the summary, u1 and a1 come before the newest request (u2). - const before = plain.messages.length - 1; - expect(JSON.stringify(plain.messages.at(-1))).toContain("second"); - expect(rows(attached, before)).toBe(rows(plain, before)); - expect(rows(next, before)).toBe(rows(attached, before)); - // The newest request carries the attachments, first. - expect(attached.messages).toHaveLength(plain.messages.length); - const request = JSON.stringify(attached.messages.at(-1)); - expect(request.indexOf("ATTACHED PLAN")).toBeLessThan(request.indexOf("second")); - }); + test("sits right after the boundary and keeps earlier rows byte-stable across turns", async () => { + const summary = boundary("HEADER CONTEXT"); + const [u1, a1, u2, a2, u3] = [ + createMuxMessage("u1", "user", "first"), + createMuxMessage("a1", "assistant", "answer 1"), + createMuxMessage("u2", "user", "second"), + createMuxMessage("a2", "assistant", "answer 2"), + createMuxMessage("u3", "user", "third"), + ]; + const first = await assemble({ ...opus, history: [summary, u1, a1, u2] }); + const next = await assemble({ ...opus, history: [summary, u1, a1, u2, a2, u3] }); - test("post-compaction attachments keep their place when a file-change row is the newest", async () => { - // F37: AgentSession appends the durable row before it reads the - // history, so it is the newest user row. Every rebuild of the turn (a retry after the - // committed assistant row, a budget warning appended mid-turn) and the plain request - // keep the rows before the turn's request, and the retry extends the first request. - const opus = { - modelString: "anthropic:claude-opus-5-5", - providerForMessages: "anthropic", - routeProvider: "anthropic", - }; - const summary = createMuxMessage("summary", "assistant", "Compacted summary", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, + expect(ids(first)).toEqual(["summary", HEADER_ID, "u1", "a1", "u2"]); + const wire = JSON.stringify(first.messages); + expect(wire.indexOf("Compacted summary")).toBeLessThan(wire.indexOf("HEADER CONTEXT")); + expect(wire.indexOf("HEADER CONTEXT")).toBeLessThan(wire.indexOf("first")); + const before = first.messages.length - 1; + expect(rows(next, before)).toBe(rows(first, before)); }); - const notification = createFileChangeNotificationMessage([ - { type: "edited_text_file", filename: "src/a.ts", snippet: "@@ -1 +1 @@" }, - ]); - const turn = [ - summary, - createMuxMessage("u1", "user", "first"), - createMuxMessage("a1", "assistant", "answer 1"), - createMuxMessage("u2", "user", "second"), - notification, - ]; - const attachments = [ - { type: "plan_file_reference" as const, planFilePath: "/p.md", planContent: "ATTACHED PLAN" }, - ]; - const send = (history: MuxMessage[], withAttachments = true) => - assemble({ + + test("is absent when the boundary has no header", async () => { + const payload = await assemble({ ...opus, - history, - systemMessage: "stable instructions", - postCompactionAttachments: withAttachments ? attachments : null, + history: [boundary(), createMuxMessage("u1", "user", "first")], }); - const rows = (payload: Awaited>, count: number) => - JSON.stringify(payload.messages.slice(0, count), (key, value: unknown) => - key === "providerOptions" ? undefined : value - ); + expect(ids(payload)).toEqual(["summary", "u1"]); + }); - const first = await send(turn); - const before = first.messages.length - 1; - const request = JSON.stringify(first.messages.at(-1)); - expect(request.indexOf("second")).toBeLessThan(request.indexOf("ATTACHED PLAN")); - expect(request.indexOf("ATTACHED PLAN")).toBeLessThan(request.indexOf("system-file-update")); - - const retry = await send([...turn, createMuxMessage("a2", "assistant", "partial answer")]); - expect(rows(retry, first.messages.length)).toBe(rows(first, first.messages.length)); - const warned = await send([ - ...turn, - createContextBudgetWarning({ - contextTokens: 80_000, - maxTokens: 128_000, - budgetTokens: 96_000, - sessionHistoryAvailable: true, - handoff: true, - }), - ]); - expect(rows(warned, before)).toBe(rows(first, before)); - expect(JSON.stringify(warned.messages.at(-1))).toContain("ATTACHED PLAN"); - for (const plain of [ - await send(turn, false), - await send( - [ - ...turn, - createMuxMessage("a2", "assistant", "answer 2"), - createMuxMessage("u3", "user", "third"), - ], - false - ), - ]) { - expect(rows(plain, before)).toBe(rows(first, before)); - } + test("counts toward the compaction trim", async () => { + const history = (text?: string) => [ + boundary(text), + createMuxMessage("u1", "user", "first"), + createMuxMessage("a1", "assistant", "answer 1"), + createMuxMessage("u2", "user", "second"), + createMuxMessage("a2", "assistant", "answer 2"), + createMuxMessage("request", "user", "summarize"), + ]; + const header = "HEADER ".repeat(8_000); + const send = (text: string | undefined, compactionInputBudgetTokens: number) => + assemble({ ...opus, history: history(text), compactionInputBudgetTokens }); + + expect(ids(await send(header, 100_000))).toContain(HEADER_ID); + expect(ids(await send(undefined, 2_000))).toContain("u1"); + // Only the header puts the request over the budget, so the trim drops the oldest turn, + // which the header starts. + expect(ids(await send(header, 2_000))).toEqual(["summary", "u2", "a2", "request"]); + }); + + // A continuous cut can leave no user row after the boundary. The header is still not the + // request, so request context must not go between the boundary and the header. + test.each([ + [ + "a trailing partial reply", + [createMuxMessage("a1", "assistant", "PARTIAL", { partial: true })], + ], + ["no later row", []], + ])("keeps request context after the header with %s", async (_name, later) => { + const payload = await assemble({ + ...opus, + history: [boundary("HEADER CONTEXT"), ...later], + requestContextMessage: createMuxMessage("ctx", "user", "VIEW-CONTEXT"), + }); + + const wire = JSON.stringify(payload.messages); + expect(wire.indexOf("HEADER CONTEXT")).toBeGreaterThan(-1); + expect(wire.indexOf("HEADER CONTEXT")).toBeLessThan(wire.indexOf("VIEW-CONTEXT")); + }); }); test("token budget tags persisted user rows with their session_history item ID", async () => { diff --git a/src/node/services/turnContextAssembler.ts b/src/node/services/turnContextAssembler.ts index b53a076f004..a580391b396 100644 --- a/src/node/services/turnContextAssembler.ts +++ b/src/node/services/turnContextAssembler.ts @@ -1,6 +1,7 @@ import { SESSION_MEMORY_VIRTUAL_DIR } from "@/common/constants/memory"; import { isWorkspaceProjectTrustedIn } from "@/node/utils/projectTrust"; import type { ContextWindowIds } from "./contextWindowRollover"; +import { insertPostCompactionContext } from "./postCompactionContext"; /** * Owns provider prompt synthesis plus the plan and system context it consumes. * All functions are independent of mutable service state. @@ -10,6 +11,7 @@ import * as path from "node:path"; import assert from "@/common/utils/assert"; import { + isPostCompactionContextMessage, isTokenBudgetInternalMessage, type MuxMessage, type SystemSectionRecords, @@ -20,7 +22,6 @@ import { isHiddenFromSessionHistory, } from "@/common/utils/messages/contextWindows"; import type { ThinkingLevel } from "@/common/types/thinking"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import { addInterruptedSentinel, filterEmptyAssistantMessages, @@ -337,7 +338,6 @@ export interface AssemblePromptPayloadOptions { toolNamesForSentinel: string[]; planContentForTransition?: string; planFilePath?: string; - postCompactionAttachments?: PostCompactionAttachment[] | null; providersConfig?: ProvidersConfigMap | null; anthropicCacheTtl?: AnthropicCacheTtl | null; workspaceId: string; @@ -376,7 +376,8 @@ export interface AssemblePromptPayloadOptions { * the refused output), `context` goes before the user request that partial answers: placed * after the partial, it would read as a new context-only user turn, and the model would answer * it instead of continuing the partial. Runs before addInterruptedSentinel, so the partial - * still gets its continuation sentinel. + * still gets its continuation sentinel. The post-compaction context row is never the request: + * `context` must not go between it and its boundary. */ export function insertRequestContextMessage( history: MuxMessage[], @@ -384,12 +385,14 @@ export function insertRequestContextMessage( ): MuxMessage[] { if (context === undefined) return history; assert(context.role === "user", "request context must be a user-role message"); + const isRequest = (message: MuxMessage) => + message.role === "user" && !isPostCompactionContextMessage(message); const last = history.at(-1); let index = history.length; - if (last?.role === "user") { + if (last && isRequest(last)) { index = history.length - 1; } else if (last?.role === "assistant" && last.metadata?.partial === true) { - const request = history.findLastIndex((message) => message.role === "user"); + const request = history.findLastIndex(isRequest); index = request === -1 ? history.length - 1 : request; } return [...history.slice(0, index), context, ...history.slice(index)]; @@ -762,6 +765,11 @@ export async function assemblePromptPayload( options.providerForMessages, options.effectiveThinkingLevel ); + // The boundary's header is fixed for the life of the boundary, so it can sit early in the + // prefix: every turn, step and compaction request of this window sends the same bytes there, + // which keeps the prompt cache and Anthropic thinking replay valid. It goes in before the + // compaction trim, so the trim counts it. + const historyWithHeader = insertPostCompactionContext(prepared.providerRequestMessages); // Prompt-hook context renders BEFORE the compaction trim: the trim counts each row's parts, not // its metadata, so unrendered context (up to PLUGIN_HOOK_CONTEXT_MAX_CHARS per user row) would // pass the budget and then grow the request past it in messagePipeline. Rendered rows drop @@ -771,8 +779,8 @@ export async function assemblePromptPayload( options.compactionInputBudgetTokens != null || options.tagHistoryItemIds === true; const providerRequestMessages = await trimToCompactionInputBudget( renderHookContextFirst - ? renderPluginHookContextForProvider(prepared.providerRequestMessages) - : prepared.providerRequestMessages, + ? renderPluginHookContextForProvider(historyWithHeader) + : historyWithHeader, options.compactionInputBudgetTokens, options.modelString, options.requestContextMessage @@ -794,7 +802,6 @@ export async function assemblePromptPayload( toolNamesForSentinel: options.toolNamesForSentinel, planContentForTransition: options.planContentForTransition, planFilePath: options.planFilePath, - postCompactionAttachments: options.postCompactionAttachments, providerForMessages: options.providerForMessages, effectiveThinkingLevel: options.effectiveThinkingLevel, modelString: options.modelString, diff --git a/src/node/services/turnEngine/ProviderRequest.test.ts b/src/node/services/turnEngine/ProviderRequest.test.ts index c16d313c552..3737a2909bf 100644 --- a/src/node/services/turnEngine/ProviderRequest.test.ts +++ b/src/node/services/turnEngine/ProviderRequest.test.ts @@ -99,7 +99,6 @@ const journal: ContinuousCompactionJournal = { partIndex: 1, metadataTemplate: {}, }, - postCompactionAttachments: [], prefixSourceRows: [boundary], systemPrefix: [], cacheEnabled: true, diff --git a/src/node/services/turnEngine/RefusalFallback.test.ts b/src/node/services/turnEngine/RefusalFallback.test.ts index 7d5f56b8fff..b45dc0458b4 100644 --- a/src/node/services/turnEngine/RefusalFallback.test.ts +++ b/src/node/services/turnEngine/RefusalFallback.test.ts @@ -149,7 +149,6 @@ function journalFixture( partIndex: overrides.partIndex ?? 0, metadataTemplate: {}, }, - postCompactionAttachments: [], prefixSourceRows: [boundary], systemPrefix: [], cacheEnabled: true, diff --git a/src/node/services/turnEnvelope.ts b/src/node/services/turnEnvelope.ts index be7eddb8784..32d928b3efe 100644 --- a/src/node/services/turnEnvelope.ts +++ b/src/node/services/turnEnvelope.ts @@ -10,7 +10,6 @@ import crypto from "node:crypto"; import type { Tool } from "ai"; import { extractToolJsonSchema } from "@/common/utils/tools/extractToolJsonSchema"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { BlobRef } from "@/common/types/durableEvent"; import type { MuxMessage } from "@/common/types/message"; import { stableStringify } from "@/common/utils/stableStringify"; @@ -114,8 +113,6 @@ export async function emitTurnEnvelope(params: { /** Plan content injected on a plan→exec handoff this turn (model-visible). */ planContentForTransition?: string; planFilePath?: string; - /** Post-compaction attachments injected this turn (model-visible). */ - postCompactionAttachments?: PostCompactionAttachment[] | null; /** * Partial-output continuation a refusal fallback appended to its request * (model-visible but never persisted to chat.jsonl at this sequence). @@ -152,21 +149,12 @@ export async function emitTurnEnvelope(params: { const ref = await putTracked(params.systemMessage); // Request-time inputs that reach the provider request must be logged too - // ("model-visible ⟹ logged"): blob-store the injected plan content and - // post-compaction attachments so replay can rebuild those turns. + // ("model-visible ⟹ logged"): blob-store the injected plan content so replay can + // rebuild those turns. The post-compaction header is in chat.jsonl already. let planTransitionContentHash: BlobRef | undefined; if (params.planContentForTransition != null && params.planContentForTransition.length > 0) { planTransitionContentHash = await putTracked(params.planContentForTransition); } - let postCompactionAttachmentsHash: BlobRef | undefined; - if ( - params.postCompactionAttachments != null && - params.postCompactionAttachments.length > 0 - ) { - postCompactionAttachmentsHash = await putTracked( - JSON.stringify(params.postCompactionAttachments) - ); - } let partialContinuationHash: BlobRef | undefined; if (params.partialContinuationMessage != null) { partialContinuationHash = await putTracked( @@ -219,9 +207,6 @@ export async function emitTurnEnvelope(params: { : {}), } : {}), - ...(postCompactionAttachmentsHash !== undefined - ? { postCompactionAttachmentsHash } - : {}), ...(partialContinuationHash !== undefined ? { partialContinuationHash } : {}), ...(mcpAppModelContextHash !== undefined ? { mcpAppModelContextHash } : {}), }, diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 0f59e62af13..8f239344606 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -211,10 +211,8 @@ import { WORKFLOW_RESULT_METADATA_TYPE, } from "@/common/utils/workflowRunMessages"; import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; -import { - createPathSkillActivation, - loadedSnapshotSkillNames, -} from "@/node/services/agentSkills/pathSkillActivation"; +import { createPathSkillActivation } from "@/node/services/agentSkills/pathSkillActivation"; +import { findPostCompactionContextSkillNames } from "@/node/services/postCompactionContext"; import { eventSpine, type RequestAssembleContext } from "@/node/services/events/eventSpine"; import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; import type { PTCEventWithParent } from "@/node/services/tools/code_execution"; @@ -312,10 +310,10 @@ export function resolveXumToolScope( }; } -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { ErrorEvent } from "@/common/types/stream"; import { applyToolPolicyToNames, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { FileState } from "@/node/services/agentSession"; +import { isCompactionRequestTurn } from "@/node/services/contextManagement/compactionRequests"; import type { ActiveTurnThinkingOverride } from "@/node/services/thinkingOverride"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; @@ -367,7 +365,6 @@ export interface StreamMessageOptions { recordFileState?: (filePath: string, state: FileState) => Promise; /** See ToolConfiguration.recordProposedPlan. */ recordProposedPlan?: (toolCallId: string, content: string) => void; - postCompactionAttachments?: PostCompactionAttachment[] | null; /** * Resolver for the session-segment memory context (memory experiment): * index snapshot for the memory tool description. @@ -1681,7 +1678,6 @@ export class TurnRequestBuilder { delegatedToolNames, recordFileState, recordProposedPlan, - postCompactionAttachments, resolveMemoryContext, workspaceGoalService, goalTurnKind, @@ -2291,14 +2287,11 @@ export class TurnRequestBuilder { })) : resolvedSwitchableAgents; // Explicit summaries remain recovery operations, not token-budget turns. - // Inspect this request's last effective user row, never an older compact command. - const latestUserMessage = providerRequestMessages.findLast( - (message) => message.role === "user" + const isCompactionRequest = isCompactionRequestTurn( + effectiveAgentId, + muxMetadata, + providerRequestMessages ); - const isCompactionRequest = - effectiveAgentId === "compact" || - muxMetadata?.type === "compaction-request" || - latestUserMessage?.metadata?.muxMetadata?.type === "compaction-request"; // Before any request is sent: compaction prompts never extend the chat's prefix, and // the baseline must clear even when the compaction model is not on an Anthropic route. if (isCompactionRequest) promptCacheScope.markCompaction(); @@ -3153,9 +3146,9 @@ export class TurnRequestBuilder { pathSkillActivation: createPathSkillActivation({ skills: availableSkills, messages: activeContextMessages, - // The first request after compaction re-injects loaded skills from this - // attachment, outside history: those skills need no pointer either. - loadedSkillNames: loadedSnapshotSkillNames(postCompactionAttachments), + // The boundary's post-compaction header already loads these skills in every request + // of this window: they need no pointer either. + loadedSkillNames: findPostCompactionContextSkillNames(activeContextMessages), cwd: workspacePath, runtime, }), @@ -3576,7 +3569,6 @@ export class TurnRequestBuilder { toolNamesForSentinel: loadedToolNames, planContentForTransition, planFilePath, - postCompactionAttachments, providersConfig: seed.providersConfig, anthropicCacheTtl: effectiveAnthropicCacheTtl, workspaceId, @@ -3631,7 +3623,6 @@ export class TurnRequestBuilder { anthropicCacheTtl: effectiveAnthropicCacheTtl, planContentForTransition, planFilePath, - postCompactionAttachments, partialContinuationMessage: options.partialContinuationMessage, mcpAppModelContextMessage: mcpAppContextMessage, }); diff --git a/src/node/services/utils/fileChangeTracker.ts b/src/node/services/utils/fileChangeTracker.ts index c9b4b4ad5c0..6682ba53748 100644 --- a/src/node/services/utils/fileChangeTracker.ts +++ b/src/node/services/utils/fileChangeTracker.ts @@ -117,6 +117,15 @@ export class FileChangeTracker { this.fileState.set(canonicalPath, state); } + /** Record a file's state unless the tracker already holds a state at least as new. */ + async recordIfNewer(filePath: string, state: FileState): Promise { + const canonicalPath = await this.canonicalize(filePath); + const tracked = this.fileState.get(canonicalPath); + if (tracked == null || tracked.timestamp < state.timestamp) { + this.fileState.set(canonicalPath, state); + } + } + /** Capture only these accepted states, before later tool reads can replace their baseline. */ captureSnapshotBaseline(states: readonly FileState[]): { restore(): void; forget(): void } { const accepted = new Set(states); @@ -153,6 +162,14 @@ export class FileChangeTracker { this.fileState.clear(); } + /** Clear all tracked state except the baseline of this file. */ + async clearExcept(filePath: string | undefined): Promise { + const keptPath = filePath == null ? undefined : await this.canonicalize(filePath); + const kept = keptPath == null ? undefined : this.fileState.get(keptPath); + this.fileState.clear(); + if (keptPath != null && kept) this.fileState.set(keptPath, kept); + } + /** * Check all tracked files for external modifications WITHOUT mutating * tracked state. Stored state advances only via the returned `commit()` — diff --git a/src/node/services/workspaceService.remotePlanNamespace.test.ts b/src/node/services/workspaceService.remotePlanNamespace.test.ts index c53fab3fa1a..65dbb8f1848 100644 --- a/src/node/services/workspaceService.remotePlanNamespace.test.ts +++ b/src/node/services/workspaceService.remotePlanNamespace.test.ts @@ -410,7 +410,7 @@ describe("SSH plans are installation-scoped (#5174)", () => { owner(), harness.historyService.locks ); - expect(await AttachmentService.generatePlanFileReference(location, host)).toBeNull(); + expect(await AttachmentService.readPlan(location, host)).toBeNull(); expect(await readIfExists(await scopedPlanPath())).toBeUndefined(); }); }); @@ -525,7 +525,7 @@ describe("SSH plans are installation-scoped (#5174)", () => { expect(location).toEqual({ planPath: await resolvePlanFilePath(harness.config, host, owner()), }); - const attachments = await AttachmentService.generatePostCompactionAttachments( + const { attachments } = await AttachmentService.generatePostCompactionAttachments( location, [{ path: location.planPath, diff: "@@ -1 +1 @@", truncated: false }], [], diff --git a/tests/ipc/planFileNonRegular.test.ts b/tests/ipc/planFileNonRegular.test.ts index b75f9edd057..1006f5e7fbf 100644 --- a/tests/ipc/planFileNonRegular.test.ts +++ b/tests/ipc/planFileNonRegular.test.ts @@ -112,12 +112,13 @@ describeIntegration("plan file readers on a non-regular plan path", () => { const runtime = new LocalRuntime(repoPath); const legacyPlanPath = expandTilde(getLegacyPlanFilePath(workspaceId, runtime.getXumHome())); const generate = () => - AttachmentService.generatePlanFileReference( + AttachmentService.readPlan( { planPath: getPlanFilePath(workspaceName, projectName, runtime.getXumHome()), legacyIdPath: getLegacyPlanFilePath(workspaceId, runtime.getXumHome()), }, - runtime + runtime, + { statFirst: true } ); await fs.rm(planPath, { force: true }); await fs.rm(legacyPlanPath, { force: true }); @@ -128,11 +129,7 @@ describeIntegration("plan file readers on a non-regular plan path", () => { let drains: Awaited>[] | undefined; try { await settleWithin(fs.stat(env.config.rootDir), 2000, "unrelated fs.stat"); - const results = await settleWithin( - Promise.all(attempts), - 5000, - "6 x generatePlanFileReference" - ); + const results = await settleWithin(Promise.all(attempts), 5000, "6 x readPlan"); for (const r of results) expect(r).toBeNull(); } finally { // A reader released from the canonical FIFO falls through to the legacy FIFO, so both are @@ -149,19 +146,19 @@ describeIntegration("plan file readers on a non-regular plan path", () => { // Controls: canonical regular file wins; missing canonical falls back to a regular legacy file. await fs.writeFile(planPath, "# canonical\n"); const canonical = await generate(); - expect(canonical).toEqual({ - type: "plan_file_reference", + expect(canonical).toMatchObject({ planFilePath: getPlanFilePath(workspaceName, projectName, runtime.getXumHome()), planContent: "# canonical\n", }); + expect(typeof canonical?.modifiedTimeMs).toBe("number"); await fs.rm(planPath); await fs.writeFile(legacyPlanPath, "# legacy\n"); const legacy = await generate(); - expect(legacy).toEqual({ - type: "plan_file_reference", + expect(legacy).toMatchObject({ planFilePath: getLegacyPlanFilePath(workspaceId, runtime.getXumHome()), planContent: "# legacy\n", }); + expect(typeof legacy?.modifiedTimeMs).toBe("number"); await fs.rm(legacyPlanPath); }, 60_000);