From a51fdcfed3e2ccbfba192f491204ca52cc57c928 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 18:46:27 +1100 Subject: [PATCH 01/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20persist=20post-comp?= =?UTF-8?q?action=20attachments=20as=20a=20durable=20history=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-compaction attachments (plan file, edited-file diffs, todo list, path skills) were injected only into the provider request. The next turn rebuilt the request without them, so the model saw its context change between turns. This caused many thinking-block drops. Now the first non-compaction turn after a compaction appends the attachments as a hidden synthetic row in chat.jsonl, after the user message, like the file-change notice rows. Later turns read the same row from history, so the request prefix stays stable. - Skip the row for compaction turns, prepared requests and when attachments are disabled. Reuse an existing row in the current window. - Delete the row on a context-exceeded retry (new HistoryService.deleteMessageUnderAdmission). - Never copy the row into a compaction's preserved tail. - Hide the row in the transcript. - Treat the row like the budget warning row for resume, task goal continuation, goal follower and revival scans. - Remove the periodic refresh every 5 turns. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh` • Cost: `$94.02`_ --- .../StreamingMessageAggregator.test.ts | 27 +- .../messages/StreamingMessageAggregator.ts | 18 +- .../utils/messages/modelMessageTransform.ts | 79 +++--- src/common/constants/attachments.ts | 7 +- src/common/types/message.ts | 17 ++ .../utils/tools/tools.pathSkills.test.ts | 12 - .../agentSession.pendingOwnership.test.ts | 82 ++---- ...tSession.postCompactionAttachments.test.ts | 198 +-------------- .../agentSession.postCompactionRetry.test.ts | 91 ++++++- src/node/services/agentSession.ts | 236 +++++++++--------- .../agentSkills/pathSkillActivation.ts | 13 - src/node/services/compactionHandler.test.ts | 30 ++- src/node/services/compactionHandler.ts | 6 +- src/node/services/historyService.ts | 26 ++ .../services/taskService.childGoals.test.ts | 41 +-- src/node/services/taskService.ts | 4 +- src/node/services/turnRequestBuilder.ts | 13 +- .../services/workspaceGoalService.test.ts | 9 + src/node/services/workspaceGoalService.ts | 4 +- ...workspaceTurnManager.historyRepair.test.ts | 13 + src/node/services/workspaceTurnManager.ts | 7 +- 21 files changed, 435 insertions(+), 498 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index 4655316cbc2..a046a0c783f 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -5465,40 +5465,49 @@ describe("StreamingMessageAggregator in-turn hidden notice rows", () => { "\nNote: a.ts was modified\n", { historySequence: 2, timestamp: Date.now(), synthetic: true } ); + const postCompactionAttachments = createMuxMessage( + "post-compaction-1700000000000", + "user", + "\nPlan file:\n...\n", + { historySequence: 3, timestamp: Date.now(), synthetic: true } + ); const continueSentinel = createMuxMessage("user-continue", "user", "[CONTINUE]", { - historySequence: 3, + historySequence: 4, timestamp: Date.now(), synthetic: true, }); test("a notice row during PREPARING keeps the pending turn's compaction, model and status", () => { // Rows as the client receives them: oRPC strips keys the wire schema does not know. - const [request, notice, sentinel] = [compactionRequest, fileChangeNotice, continueSentinel].map( - (row) => MuxMessageSchema.parse(row) as typeof row - ); + const [request, notice, attachments, sentinel] = [ + compactionRequest, + fileChangeNotice, + postCompactionAttachments, + continueSentinel, + ].map((row) => MuxMessageSchema.parse(row) as typeof row); const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); aggregator.handleMessage({ ...request, type: "message" }); const pendingStart = aggregator.getPendingStreamStartTime(); expect(pendingStart).not.toBeNull(); aggregator.handleMessage({ ...notice, type: "message" }); + aggregator.handleMessage({ ...attachments, type: "message" }); aggregator.handleMessage({ ...sentinel, type: "message" }); expect(aggregator.getPendingStreamModel()).toBe(model); expect(aggregator.getAgentStatus()?.message).toBe("Compacting"); expect(aggregator.getPendingStreamStartTime()).toBe(pendingStart); // Hidden in the transcript, but kept as edit evidence that matches the persisted rows. - expect(aggregator.getDisplayedMessages().some((row) => row.id === fileChangeNotice.id)).toBe( - false - ); + const hiddenIds = [fileChangeNotice.id, postCompactionAttachments.id]; + expect(aggregator.getDisplayedMessages().some((row) => hiddenIds.includes(row.id))).toBe(false); expect( buildHistoryEditPrecondition(aggregator.getHistoryEvidenceMessages(), request.id) - ).toEqual(buildHistoryEditPrecondition([request, notice, sentinel], request.id)); + ).toEqual(buildHistoryEditPrecondition([request, notice, attachments, sentinel], request.id)); aggregator.handleStreamStart({ type: "stream-start", workspaceId: "test-workspace", messageId: "compact-stream", - historySequence: 4, + historySequence: 5, model, startTime: Date.now(), mode: "compact", diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 385ad0088c3..c68d6298265 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -1,4 +1,5 @@ import { restoreContextBudgetRejectedMessageForDisplay } from "@/common/utils/messages/contextBudgetRejection"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import type { MuxMessage, MuxMetadata, @@ -118,13 +119,19 @@ function isTranscriptVisibleUserRow(message: MuxMessage): boolean { /** * Hidden notices AgentSession.streamWithHistory persists INSIDE a turn that already started: the - * `` row (createFileChangeNotificationMessage, `file-change-` id) and the - * `[CONTINUE]` sentinel for a trailing completed assistant. The backend publishes them so the + * `` row (createFileChangeNotificationMessage, `file-change-` id), the + * post-compaction attachments row (createPostCompactionAttachmentsMessage, `post-compaction-` id) + * and the `[CONTINUE]` sentinel for a trailing completed assistant. The backend publishes them so the * client holds the same rows as history (an edit fences its range with them), but they arrive * after the turn's real user row and must not restart that turn's pending state. Matched by the * exact markers the backend writes, not by `synthetic` alone: hidden synthetic rows such as * context-budget continuations do start turns. */ +const IN_TURN_NOTICE_PREFIXES = [ + { id: "file-change-", text: "" }, + { id: POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX, text: "" }, +] as const; + function isInTurnHiddenNoticeRow(message: MuxMessage): boolean { const metadata = message.metadata; if ( @@ -138,9 +145,12 @@ function isInTurnHiddenNoticeRow(message: MuxMessage): boolean { } const part = message.parts[0]; if (part.type !== "text") return false; + const text = part.text; return ( - part.text === "[CONTINUE]" || - (message.id.startsWith("file-change-") && part.text.startsWith("")) + text === "[CONTINUE]" || + IN_TURN_NOTICE_PREFIXES.some( + (prefix) => message.id.startsWith(prefix.id) && text.startsWith(prefix.text) + ) ); } diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index 3afbaa3b4f2..d67b89c9ba9 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -10,7 +10,10 @@ import { type MuxReasoningPart, } from "@/common/types/message"; import type { PostCompactionAttachment } from "@/common/types/attachment"; -import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; +import { + MAX_POST_COMPACTION_INJECTION_CHARS, + POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX, +} from "@/common/constants/attachments"; import { hasProviderReplayableContent } from "@/common/utils/messages/providerEligibility"; import { renderAttachmentsToContentWithBudget } from "./attachmentRenderer"; @@ -208,25 +211,46 @@ ${planContent} return result; } -// NOTE: File-change notifications are no longer injected at request time. -// AgentSession appends the row durably to chat.jsonl at -// turn start (see createFileChangeNotificationMessage in fileChangeTracker.ts), -// keeping the provider request a pure function of the session log. +// NOTE: File-change notifications and post-compaction attachments are no longer +// injected at request time for live turns. AgentSession appends both rows durably +// to chat.jsonl at turn start (see createFileChangeNotificationMessage in +// fileChangeTracker.ts and createPostCompactionAttachmentsMessage below), keeping +// the provider request a pure function of the session log. A request-only row +// changed the cached prefix on the turn it appeared and again on the next turn, +// when it was gone, and that change dropped the replayed thinking blocks. /** - * 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. + * Build the synthetic user row that carries post-compaction attachments (plan file, + * loaded skills, edited files, report index) to preserve context lost to compaction. + * Returns null when nothing renders. + */ +export function createPostCompactionAttachmentsMessage( + attachments: PostCompactionAttachment[], + timestamp: number +): MuxMessage | null { + const text = renderAttachmentsToContentWithBudget(attachments, { + maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, + }); + if (text.trim().length === 0) { + return null; + } + return { + id: `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}${Date.now()}`, + role: "user", + parts: [{ type: "text", text }], + metadata: { timestamp, synthetic: true }, + }; +} + +/** + * Inject post-compaction attachments as a synthetic user message. Live turns persist the + * row instead (see the NOTE above); this path rebuilds older recorded request envelopes and + * swaps the prefix of an in-flight stream after mid-stream compaction. * * 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). + * excepted, as in injectAgentTransition), or last without one, so every row before the + * current turn's request stays byte-identical. The model sees 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) @@ -244,22 +268,13 @@ export function injectPostCompactionAttachments( (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, - }, - }; + const syntheticMessage = createPostCompactionAttachmentsMessage( + attachments, + messages[index]?.metadata?.timestamp ?? Date.now() + ); + if (syntheticMessage == null) { + return messages; + } return [...messages.slice(0, index), syntheticMessage, ...messages.slice(index)]; } diff --git a/src/common/constants/attachments.ts b/src/common/constants/attachments.ts index 3714956e37f..9bde6dc0a76 100644 --- a/src/common/constants/attachments.ts +++ b/src/common/constants/attachments.ts @@ -2,8 +2,11 @@ * 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; +/** + * ID prefix of the durable post-compaction attachments row in chat.jsonl. + * Tail copies drop these rows, and the transcript hides them. + */ +export const POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX = "post-compaction-"; /** Maximum size of file content before truncation (50KB) */ export const MAX_FILE_CONTENT_SIZE = 50_000; diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 31627743044..58bc4a3b298 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -21,6 +21,7 @@ import type { ThinkingLevel } from "./thinking"; import type { AutoModelRoutingRecord } from "./autoModelRouting"; import { type ReviewNoteData, formatReviewForModel } from "./review"; import { isMcpPromptCommandKey } from "@/common/utils/tools/mcpPromptCommandKey"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import type { PlanReviewRecordKind } from "@/common/utils/planReview/planReviewRecord"; import { isPlanReviewRecordMessage } from "@/common/utils/planReview/planReviewEnvelope"; @@ -903,6 +904,22 @@ export function isContextBudgetWarningMessage(message: MuxMessage): boolean { return message.metadata?.muxMetadata?.type === "context-budget-warning"; } +/** The post-compaction attachments row is context sent with a turn, never its request. */ +export function isPostCompactionAttachmentsMessage(message: MuxMessage): boolean { + return ( + message.metadata?.synthetic === true && + message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) + ); +} + +/** + * A synthetic user row appended after a turn's prompt: a Token Budget warning or the + * post-compaction attachments row. It is a notice or context, never a newer prompt. + */ +export function isAppendedTurnNoticeMessage(message: MuxMessage): boolean { + return isContextBudgetWarningMessage(message) || isPostCompactionAttachmentsMessage(message); +} + export function isRolloverBoundary(message: MuxMessage): boolean { return ( message.metadata?.contextBoundaryKind === "reset" && diff --git a/src/common/utils/tools/tools.pathSkills.test.ts b/src/common/utils/tools/tools.pathSkills.test.ts index 6fd5c8a7f2b..28fd53b232c 100644 --- a/src/common/utils/tools/tools.pathSkills.test.ts +++ b/src/common/utils/tools/tools.pathSkills.test.ts @@ -216,18 +216,6 @@ describe("getToolsForModel - path-scoped skill activation", () => { expect(surfacedNames(await call(tools.file_read, { path: "src/foo.ts" }))).toBeUndefined(); }); - test("a skill carried in the post-compaction snapshot is not pointed at again", () => { - const runtime = new LocalRuntime(workspace); - const activation = createPathSkillActivation({ - skills: SKILLS, - messages: [], - loadedSkillNames: ["ts-rules"], - cwd: workspace, - runtime, - }); - expect(activation?.takeMatches("src/foo.ts")).toEqual([]); - }); - test("only the first MAX_PATH_SCOPED_SKILLS path-scoped skills can activate", () => { const runtime = new LocalRuntime(workspace); const many = Array.from({ length: MAX_PATH_SCOPED_SKILLS + 1 }, (_, i) => ({ diff --git a/src/node/services/agentSession.pendingOwnership.test.ts b/src/node/services/agentSession.pendingOwnership.test.ts index 9026ce9efd5..6a940a05af5 100644 --- a/src/node/services/agentSession.pendingOwnership.test.ts +++ b/src/node/services/agentSession.pendingOwnership.test.ts @@ -2,7 +2,6 @@ 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 { Ok } from "@/common/types/result"; @@ -18,7 +17,6 @@ const model = "openai:gpt-4o"; interface SessionAccess { contextController: { compactionHandler: CompactionHandler }; coordinator: TurnCoordinator; - turnsSinceLastAttachment: number; getPostCompactionAttachmentsIfNeeded( includeReadFiles: boolean ): 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([{ outcome: "success" }, { outcome: "context-exceeded" }] as const)( + "$outcome retains its request snapshot", + async ({ outcome }) => { const completion = Promise.withResolvers(); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -115,24 +107,6 @@ 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); - } const consume = spyOn( handler, outcome === "success" ? "ackPendingStateConsumed" : "discardPendingState" @@ -180,39 +154,29 @@ describe("pending snapshot consumers", () => { assert(result?.type === "return", "Expected an active completion consumer"); policy = result.value; 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"); - } + 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); + 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..48a2fb4616b 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -7,16 +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 } from "@/common/types/message"; import { Err } from "@/common/types/result"; -import { POST_COMPACTION_STATE_FILENAME } from "@/constants/compaction"; import type { Config } from "@/node/config"; import type { AgentSession } from "./agentSession"; import { createAgentSessionHarness, createTestStreamManager } from "./agentSession.testHarness"; -import type { CompactionHandler } from "./compactionHandler"; -import { CompactionPendingState } from "./compactionPendingState"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; import { createLoadedSkillSnapshot } from "@/node/services/agentSkills/loadedSkillSnapshots"; @@ -27,26 +23,6 @@ import { makeTestEffectRunner } from "./di/testEffectRunner"; import type { EffectRunner } from "./di/effectRunner"; import { nodeSessionLocks } from "@/node/services/sessionLocks/nodeSessionLocks"; -function createSuccessfulFileEditMessage(id: string, filePath: string, diff: string): MuxMessage { - return { - id, - role: "assistant", - parts: [ - { - type: "dynamic-tool", - toolCallId: `tool-${id}`, - toolName: "file_edit_replace_string", - state: "output-available", - input: { path: filePath }, - output: { success: true, diff }, - }, - ], - metadata: { - timestamp: Date.now(), - }, - }; -} - function createLoadedSkillFixture(args: { name: string; body: string; @@ -127,9 +103,6 @@ async function createSessionForHistory( } interface PrivateSessionAccess { - compactionOccurred: boolean; - turnsSinceLastAttachment: number; - contextController: { compactionHandler: CompactionHandler }; getPostCompactionAttachmentsIfNeeded: () => Promise; } @@ -142,20 +115,6 @@ async function getImmediatePostCompactionAttachments( return attachments ?? []; } -async function generatePeriodicPostCompactionAttachments( - session: AgentSession -): Promise { - 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 ?? []; -} - async function writePendingPostCompactionState(args: { sessionDir: string; diffs: Array<{ path: string; diff: string; truncated: boolean }>; @@ -231,11 +190,8 @@ describe("AgentSession post-compaction attachments", () => { 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(); - } + // ...no later turn may re-inject pre-boundary paths. + 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, @@ -257,86 +213,6 @@ describe("AgentSession post-compaction attachments", () => { } }); - test("extracts edited file diffs from the latest durable compaction boundary slice", async () => { - const { historyService, config, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const history: MuxMessage[] = [ - createSuccessfulFileEditMessage( - "stale-before-boundary", - "/tmp/stale-before-boundary.ts", - "@@ -1 +1 @@\n-old\n+older\n" - ), - createMuxMessage("boundary-1", "assistant", "epoch 1 summary", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, - }), - createSuccessfulFileEditMessage( - "stale-epoch-1", - "/tmp/stale-epoch-1.ts", - "@@ -1 +1 @@\n-old\n+stale\n" - ), - createMuxMessage("boundary-2", "assistant", "epoch 2 summary", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 2, - }), - createSuccessfulFileEditMessage( - "recent-epoch-2", - "/tmp/recent-epoch-2.ts", - "@@ -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); - expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent-epoch-2.ts"]); - } finally { - await session.dispose(); - } - }); - - test("falls back safely when boundary markers are malformed", async () => { - const { historyService, config, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const history: MuxMessage[] = [ - createSuccessfulFileEditMessage("stale-edit", "/tmp/stale.ts", "@@ -1 +1 @@\n-old\n+stale\n"), - createMuxMessage("malformed-boundary", "assistant", "malformed summary", { - compacted: "user", - compactionBoundary: true, - // Missing compactionEpoch: marker should be ignored without crashing. - }), - createSuccessfulFileEditMessage( - "recent-edit", - "/tmp/recent.ts", - "@@ -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); - expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent.ts", "/tmp/stale.ts"]); - } finally { - await session.dispose(); - } - }); - test("indexes a report completed before the compaction while the session runs on a TestClock", async () => { // TaskService stamps report completion with Date.now(), so the cutoff must read the wall // clock too (#7282). On the TestClock (months behind wall time) the report would look newer @@ -413,74 +289,6 @@ describe("AgentSession post-compaction attachments", () => { } }); - test("reinjects cached loaded skills on later turns even after pending state is acknowledged", 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.", - }); - - 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) - ); - expect( - ( - await pending.publishBoundary({ - summaryMessage: history[0], - tailCopies: history.slice(1), - updateExisting: false, - attachments: { diffs: [], loadedSkills: [loadedSkill], readFiles: [] }, - publication: { - generation: await historyService - .getContinuousCompactionJournal(workspaceId) - .captureGeneration(), - }, - isCurrent: () => true, - shouldPersist: () => true, - onCommitted: () => undefined, - }) - ).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"]); - expect(getLoadedSkillAttachment(attachments)?.skills[0]?.body).toContain( - "Persist this guardrail across follow-up turns." - ); - expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent-periodic.ts"]); - } finally { - await session.dispose(); - } - }); - test("suppresses only loaded skills when the skills exclusion is enabled", async () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index fc939896fd3..dbb6cf20200 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "events"; import * as fsPromises from "fs/promises"; import * as path from "path"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { Err } from "@/common/types/result"; import type { MuxMessage } from "@/common/types/message"; @@ -153,18 +154,18 @@ describe("AgentSession post-compaction context retry", () => { "retry-started" ); - // 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); - - const secondOpts = (streamMessage as ReturnType).mock.calls[1][0] as Record< - string, - unknown - >; - expect(secondOpts.postCompactionAttachments).toBeNull(); + // The first request carried the durable attachments row; the retry runs without it. + const hasAttachmentsRow = (messages: MuxMessage[]) => + messages.some((message) => + message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) + ); + const requestMessages = (call: number) => + ((streamMessage as ReturnType).mock.calls[call][0] as { messages: MuxMessage[] }) + .messages; + expect(hasAttachmentsRow(requestMessages(0))).toBe(true); + expect(hasAttachmentsRow(requestMessages(1))).toBe(false); + const historyAfterRetry = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(historyAfterRetry.success && hasAttachmentsRow(historyAfterRetry.data)).toBe(false); expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( "assistant-ctx-exceeded" @@ -383,4 +384,70 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); + + // The row is durable: a turn that fails before the ack must not append a second copy. + test("a failed turn keeps its attachments row and the next turn reuses it", async () => { + const workspaceId = "ws-reuse"; + const { historyService, config, sessionDir } = await createSessionsFixture(workspaceId); + await createPersistedPostCompactionState({ + filePath: path.join(sessionDir, "post-compaction.json"), + diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], + }); + await historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }); + + let callCount = 0; + const streamMessage = mock((..._args: unknown[]) => { + callCount += 1; + if (callCount === 1) { + return Promise.resolve({ + success: false as const, + error: { type: "unknown" as const, raw: "startup failed before stream" }, + }); + } + return Promise.resolve({ + success: true as const, + data: createStartedTurnHandle(session.closingSignal, "assistant-next"), + }); + }); + const { session } = await createAgentSessionHarness({ + workspaceId, + config, + historyService, + aiServiceOverrides: { + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); + const options = { model: "openai:gpt-4o", agentId: "exec" } as unknown as SendMessageOptions; + + await session.resumeStream(options); + // The manual Retry resumes the request past the failed turn's attachments row. + expect(await session.resumeStream(options)).toEqual({ success: true, data: { started: true } }); + + expect(callCount).toBe(2); + const attachmentRowIds = (messages: MuxMessage[]) => + messages + .filter((message) => message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX)) + .map((message) => message.id); + const requestRowIds = (call: number) => + attachmentRowIds( + ( + (streamMessage as ReturnType).mock.calls[call][0] as { + messages: MuxMessage[]; + } + ).messages + ); + const firstRowIds = requestRowIds(0); + expect(firstRowIds).toHaveLength(1); + expect(requestRowIds(1)).toEqual(firstRowIds); + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(history.success && attachmentRowIds(history.data)).toEqual(firstRowIds); + + await session.dispose(); + }); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b2803a015b7..0de854fd02c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -211,7 +211,9 @@ import { pickStartupRetrySendOptions, prepareUserMessageForSend, type AgentSkillReference, + isAppendedTurnNoticeMessage, isContextBudgetWarningMessage, + isPostCompactionAttachmentsMessage, isSyntheticSnapshotUserMessage, type CompactionFollowUpRequest, type MuxMessageMetadata, @@ -223,7 +225,6 @@ import { 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, @@ -294,12 +295,9 @@ import type { PostCompactionAttachment, PostCompactionExclusions, } from "@/common/types/attachment"; -import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; -import { - extractEditedFileDiffs, - type FileEditDiff, -} from "@/common/utils/messages/extractEditedFiles"; +import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; +import { createPostCompactionAttachmentsMessage } from "@/browser/utils/messages/modelMessageTransform"; import { buildCompactionMessageText } from "@/common/utils/compaction/compactionPrompt"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { getModelCapabilitiesResolved } from "@/common/utils/ai/modelCapabilities"; @@ -333,8 +331,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"; @@ -1728,18 +1724,6 @@ export class AgentSession { tracking: ReturnType; }; - /** - * 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. * @@ -1841,8 +1825,8 @@ 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; + /** The post-compaction attachments row the current stream sent, if any. */ + private activeStreamPostCompactionRow: MuxMessage | undefined; /** * muxMetadata of the queued entry currently being dispatched, held from @@ -7350,7 +7334,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, @@ -8842,7 +8825,7 @@ export class AgentSession { this.clearLiveUsageState(); this.pendingPostCompactionStateToAcknowledge = null; this.activeStreamHadAnyDelta = false; - this.activeStreamHadPostCompactionInjection = false; + this.activeStreamPostCompactionRow = undefined; 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). @@ -8919,9 +8902,10 @@ export class AgentSession { return await refuseRejectedResume(retryRequest); // Model-hidden records (plan-review snapshot/resolve/reopen rows) are UI state that can // land after the resumable row without changing provider-visible history; resume from - // the newest visible row so such a record cannot silently cancel the request. + // the newest visible row so such a record cannot silently cancel the request. The + // attachments row of a failed post-compaction turn follows its request the same way. const target = tail.data.findLast( - (message) => !isModelHiddenMessage(message) && !isContextBudgetWarningMessage(message) + (message) => !isModelHiddenMessage(message) && !isAppendedTurnNoticeMessage(message) ); if (!target) return await fail(createUnknownSendMessageError(EMPTY_RESUME_HISTORY_ERROR)); if (target.metadata?.contextBudgetRejected) return await refuseRejectedResume(target); @@ -9097,21 +9081,22 @@ 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); - } - // #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; + const withAttachments = await this.addPostCompactionAttachmentsRow( + { messages: requestMessages, historyReadSequence }, + disablePostCompactionAttachments === true || preparedRequest != null + ); + if (isStreamStartAborted()) { + return Ok(undefined); + } + if (!withAttachments.success) { + return await fail(createUnknownSendMessageError(withAttachments.error)); + } + requestMessages = withAttachments.data.messages; + historyReadSequence = withAttachments.data.historyReadSequence; // Apply per-model thinking floors once so desktop, mobile, and ACP requests match. // Gateway-preserving key first (an explicit coder:/ @@ -9227,7 +9212,6 @@ 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 @@ -9442,6 +9426,90 @@ export class AgentSession { } } + /** + * Persist the pending post-compaction attachments as one synthetic row after the request, and + * record the row this turn sends. A turn that failed before the ack already wrote the row in + * this window: reuse that row. A compaction turn skips the attachments: its new publication + * merges the old pending state. + * @param request The request history of the current window and the newest sequence it read + * @param skip True when the turn disables the attachments or sends a prepared request + */ + private async addPostCompactionAttachmentsRow( + request: { messages: MuxMessage[]; historyReadSequence: number }, + skip: boolean + ): Promise> { + if (skip || this.activeCompactionRequest != null) return Ok(request); + const attachments = await this.getPostCompactionAttachmentsIfNeeded( + this.isRlmCompactionEnabled() + ); + if (attachments == null || attachments.length === 0) return Ok(request); + this.activeStreamPostCompactionRow = request.messages.find(isPostCompactionAttachmentsMessage); + if (this.activeStreamPostCompactionRow) return Ok(request); + // Record time: wall clock until #7282 moves its consumers to HostClock. + const row = createPostCompactionAttachmentsMessage(attachments, nodeClock.nowMs()); + if (row == null) return Ok(request); + const appendResult = await this.historyService.appendToHistory(this.workspaceId, row); + if (!appendResult.success) return Err(appendResult.error); + // Publish the hidden row like the file-change row, so an edit fence counts the same rows. + this.emitChatEvent({ ...row, type: "message" }); + this.activeStreamPostCompactionRow = row; + const messages = [...request.messages, row]; + return Ok({ messages, historyReadSequence: newestHistorySequence(messages) }); + } + + /** + * The context-exceeded retry runs without the attachments, so their row leaves history too. + * The delete changes the provider context, so the retry adopts the frontier after it. + * @returns false when the row is still in history + */ + private async deletePostCompactionRow( + row: MuxMessage, + admissionCapture: CompactionReplacementCapture | undefined + ): Promise { + const fail = (error: string) => { + log.warn("Failed to delete post-compaction attachments row", { + workspaceId: this.workspaceId, + error, + }); + return false; + }; + if (!admissionCapture) { + const deleted = await this.historyService.deleteMessage(this.workspaceId, row.id); + if (!deleted.success) return fail(deleted.error); + const sequence = row.metadata?.historySequence; + if (sequence != null) this.emitChatEvent({ type: "delete", historySequences: [sequence] }); + return true; + } + const deleted = await this.historyService.deleteMessageUnderAdmission( + this.workspaceId, + admissionCapture, + row.id + ); + if (!deleted.success) return fail(deleted.error); + // Copy the predecessor: the advance rewrites admissionCapture in place. + this.advanceOwnedCompactionAdmission( + { ...admissionCapture }, + deleted.data.capture, + admissionCapture + ); + this.emitChatEvent({ type: "delete", historySequences: deleted.data.sequences }); + return true; + } + + private async discardFailedPostCompactionState( + pendingState: Awaited> + ): Promise { + try { + await this.contextController.discardFailedCarryover(pendingState); + this.onPostCompactionStateChange?.(); + } catch (error) { + log.warn("Failed to discard pending post-compaction state", { + workspaceId: this.workspaceId, + error: getErrorMessage(error), + }); + } + } + private async finalizeCompactionRetry(messageId: string): Promise { this.activeCompactionRequest = undefined; this.resetActiveStreamState(); @@ -9690,8 +9758,9 @@ export class AgentSession { return false; } - // Only retry if we actually injected post-compaction context. - if (!this.activeStreamHadPostCompactionInjection) { + // Only retry if this turn sent the post-compaction row. + const postCompactionRow = this.activeStreamPostCompactionRow; + if (postCompactionRow == null) { return false; } @@ -9719,15 +9788,7 @@ export class AgentSession { }); // The post-compaction context is likely the culprit; discard it so we don't loop. - try { - await this.contextController.discardFailedCarryover(pendingState); - this.onPostCompactionStateChange?.(); - } catch (error) { - log.warn("Failed to discard pending post-compaction state", { - workspaceId: this.workspaceId, - error: getErrorMessage(error), - }); - } + await this.discardFailedPostCompactionState(pendingState); // Abort the failed assistant placeholder and clean up persisted partial/history state. this.resetActiveStreamState(); @@ -9737,6 +9798,9 @@ export class AgentSession { messageId: data.messageId, }); await this.clearFailedAssistantMessage(data.messageId, "post-compaction-retry"); + if (!(await this.deletePostCompactionRow(postCompactionRow, context.admissionCapture))) { + return false; + } // r40: same admission gate as the compaction retry above — this path also // crosses a transient idle gap before re-entering PREPARING. @@ -9928,7 +9992,7 @@ export class AgentSession { this.activeStreamContext = undefined; this.activeStreamUserMessageId = undefined; this.activeStreamStartedAtMs = undefined; - this.activeStreamHadPostCompactionInjection = false; + this.activeStreamPostCompactionRow = undefined; this.activeStreamHadAnyDelta = false; this.pendingPostCompactionStateToAcknowledge = null; } @@ -13376,15 +13440,12 @@ export class AgentSession { * 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. + * Covers the pending-state path (on-disk post-compaction.json). */ 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 stale state 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. @@ -13479,23 +13540,17 @@ export class AgentSession { } /** - * Get post-compaction attachments if they should be injected this turn. + * Get post-compaction attachments when a compaction left pending state, and clear the file + * state cache. The caller persists them as one row, so they appear once per compaction. * - * 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 + * @returns Attachments to persist, or null if none needed */ 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(); @@ -13514,60 +13569,9 @@ export class AgentSession { }); } - // 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; } - /** - * Generate post-compaction attachments by extracting diffs and loaded skills from message history. - */ - 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 []; - } - - 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(), - }); - } - /** * Shared logic for assembling post-compaction attachments from cached context. * Loads exclusions, TODO state, workspace metadata, and plan references, diff --git a/src/node/services/agentSkills/pathSkillActivation.ts b/src/node/services/agentSkills/pathSkillActivation.ts index 1b0d0839fd4..264b7e65bd9 100644 --- a/src/node/services/agentSkills/pathSkillActivation.ts +++ b/src/node/services/agentSkills/pathSkillActivation.ts @@ -5,7 +5,6 @@ import { type PathSkillsResultField, } from "@/common/types/tools"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { MuxMessage } from "@/common/types/message"; import { deriveProjectRelativePath } from "@/common/utils/review/assistedReview"; import { createSkillPathsMatcher } from "@/common/utils/skills/skillPaths"; @@ -171,15 +170,6 @@ function createTurnMatcher(): (skill: AgentSkillDescriptor, relativePath: string }; } -/** Names of the skills a post-compaction `loaded_skills_snapshot` re-injects. */ -export function loadedSnapshotSkillNames( - attachments: readonly PostCompactionAttachment[] | null | undefined -): string[] { - return (attachments ?? []).flatMap((attachment) => - attachment.type === "loaded_skills_snapshot" ? attachment.skills.map((skill) => skill.name) : [] - ); -} - /** * Build the turn's activation, or undefined when no skill can activate (no model- * invocable skill has `paths`), so the file tools stay unwrapped. @@ -190,8 +180,6 @@ export function loadedSnapshotSkillNames( export function createPathSkillActivation(params: { skills: readonly AgentSkillDescriptor[] | undefined; messages: readonly MuxMessage[]; - /** Skills already in the request outside history (post-compaction loaded-skill snapshot). */ - loadedSkillNames?: readonly string[]; cwd: string; runtime: Runtime; }): PathSkillActivation | undefined { @@ -201,7 +189,6 @@ export function createPathSkillActivation(params: { } const surfaced = collectSurfacedSkillNames(params.messages); - for (const name of params.loadedSkillNames ?? []) surfaced.add(name); const matches = createTurnMatcher(); return { diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index 1a6183801f9..fc1d0bdbf1a 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -1993,9 +1993,24 @@ describe("CompactionHandler", () => { }); }); - it("never copies model-hidden plan-review record rows into the preserved tail", async () => { - // Record rows are UI state that no provider request ever contains; a copy behind the - // boundary would only duplicate hidden state (and the projection ignores copies anyway). + // Record rows are UI state that no provider request ever contains; a copy behind the + // boundary would only duplicate hidden state (and the projection ignores copies anyway). + // The next turn writes a fresh attachments row, so a copy would send that context twice. + it.each([ + [ + "model-hidden plan-review record rows", + createMuxMessage("plan-review-1", "user", "…", { + synthetic: true, + muxMetadata: { type: "plan-review", kind: "resolve", recordId: "rec", threadId: "thr" }, + }), + ], + [ + "the old window's post-compaction attachments row", + createMuxMessage("post-compaction-1", "user", "\nPlan\n", { + synthetic: true, + }), + ], + ])("never copies %s into the preserved tail", async (_name, record) => { handler = new CompactionHandler({ workspaceId, historyService, @@ -2004,10 +2019,6 @@ describe("CompactionHandler", () => { emitter: mockEmitter, }); - const record = createMuxMessage("plan-review-1", "user", "…", { - synthetic: true, - muxMetadata: { type: "plan-review", kind: "resolve", recordId: "rec", threadId: "thr" }, - }); await seedHistory( createMuxMessage("u0", "user", "old head question"), createMuxMessage("a0", "assistant", "old head answer"), @@ -2024,7 +2035,10 @@ describe("CompactionHandler", () => { if (!epochResult.success) throw new Error(epochResult.error); const copies = epochResult.data.filter((row) => row.metadata?.rlmPreservedTailCopy === true); expect(copies.map((copy) => copy.role)).toEqual(["user", "assistant"]); - expect(copies.some((copy) => copy.metadata?.muxMetadata?.type === "plan-review")).toBe(false); + expect(copies.map((copy) => copy.parts)).toMatchObject([ + [{ type: "text", text: "tail question" }], + [{ type: "text", text: "tail answer" }], + ]); }); it("keeps a tail user row's prompt-hook context on its copy", async () => { diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 1253b4203a2..f32e0b378a3 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -30,6 +30,7 @@ import { getCompactionFollowUpContent, type CompactionFollowUpRequest, type CompactionSummaryMetadata, + isPostCompactionAttachmentsMessage, type MuxMessage, } from "@/common/types/message"; import { createCompactionSummaryMessageId } from "@/node/services/utils/messageIds"; @@ -1284,7 +1285,10 @@ export class CompactionHandler { } /** Copies for regular RLM keep-tail and continuous compaction tails. */ - private buildTailCopies(tailRows: MuxMessage[]): MuxMessage[] { + private buildTailCopies(allTailRows: MuxMessage[]): MuxMessage[] { + // Drop the old window's post-compaction attachments row: the next turn writes a fresh one + // from the new pending state, and a copy would send the same context twice. + const tailRows = allTailRows.filter((row) => !isPostCompactionAttachmentsMessage(row)); // Preassign copy IDs for ALL tail rows before building any copy: MCP // snapshot rows precede the user row they expand, so a build-time map // would not yet contain the invoking row's copy ID when the snapshot row diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 9db9c176b8e..94228332096 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -5780,6 +5780,32 @@ export class HistoryService { ); } + /** + * Delete one row of the caller's own turn under its admission. Removing a provider-visible + * row advances the context generation, so this returns the frontier after the delete: the + * caller adopts it, because its own delete is not a foreign reset. + */ + deleteMessageUnderAdmission( + workspaceId: string, + captured: CompactionReplacementCapture, + messageId: string + ): Promise> { + const expected = { ...captured }; + return this.withRecoveredHistoryWriteResultLock( + workspaceId, + "Failed to delete message", + async () => { + const current = await this.captureCompactionReplacementUnderHistoryLock(workspaceId); + if (current.nonce !== expected.nonce || current.generation !== expected.generation) + return Err("Compaction admission was superseded"); + const deleted = await this.deleteMessageUnderWriteLock(workspaceId, messageId); + if (!deleted.success) return deleted; + const capture = await this.captureCompactionReplacementUnderHistoryLock(workspaceId); + return Ok({ sequences: deleted.data, capture }); + } + ); + } + private async fenceDeletedMessagesUnderHistoryLock( workspaceId: string, rows: HistoryRewriteRow[], diff --git a/src/node/services/taskService.childGoals.test.ts b/src/node/services/taskService.childGoals.test.ts index 8f91ef2f527..fe59b92b7f7 100644 --- a/src/node/services/taskService.childGoals.test.ts +++ b/src/node/services/taskService.childGoals.test.ts @@ -461,27 +461,36 @@ describe("TaskService child goals", () => { }); test.each([ - { tail: "goal_continuation row", appendWarning: false }, - // A Token Budget warning row is a synthetic user row appended after the turn's trigger; it is - // a notice, not a newer trigger, so the ended turn still counts as the goal continuation. - { tail: "goal_continuation row then a Token Budget warning", appendWarning: true }, + { tail: "goal_continuation row", trailingRow: null }, + // Token Budget warning and post-compaction attachments rows are synthetic user rows appended + // after the turn's trigger; they are not a newer trigger, so the ended turn still counts as the + // goal continuation. + { + tail: "goal_continuation row then a Token Budget warning", + trailingRow: () => + createContextBudgetWarning({ + contextTokens: 90_000, + maxTokens: 128_000, + budgetTokens: 119_808, + handoff: true, + sessionHistoryAvailable: true, + }), + }, + { + tail: "goal_continuation row then a post-compaction attachments row", + trailingRow: () => + createMuxMessage("post-compaction-1", "user", "\nPlan\n", { + synthetic: true, + }), + }, ])( "a tool-free goal continuation completes the goal and publishes exactly one report ($tail)", - async ({ appendWarning }) => { + async ({ trailingRow }) => { const t = await setup(); const goal = await t.setChildGoal(); await t.appendGoalContinuationRow(goal.goalId); - if (appendWarning) { - const appended = await t.historyService.appendToHistory( - childId, - createContextBudgetWarning({ - contextTokens: 90_000, - maxTokens: 128_000, - budgetTokens: 119_808, - handoff: true, - sessionHistoryAvailable: true, - }) - ); + if (trailingRow != null) { + const appended = await t.historyService.appendToHistory(childId, trailingRow()); expect(appended.success).toBe(true); } diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index e0662fc04a7..e787750eae7 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -170,7 +170,7 @@ import { } from "@/common/types/backgroundWorkAttention"; import { createMuxMessage, - isContextBudgetWarningMessage, + isAppendedTurnNoticeMessage, type MuxMessage, type MuxMessageMetadata, } from "@/common/types/message"; @@ -17094,7 +17094,7 @@ export class TaskService implements AgentTaskIntegration { const tail = await this.historyService.getLastMessages(workspaceId, 20); if (!tail.success) return false; const userRow = tail.data.findLast( - (message) => message.role === "user" && !isContextBudgetWarningMessage(message) + (message) => message.role === "user" && !isAppendedTurnNoticeMessage(message) ); return userRow?.metadata?.kind === GOAL_CONTINUATION_KIND && userRow.metadata.goalId === goalId; } diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 0f59e62af13..1e1f0000fa1 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -211,10 +211,7 @@ 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 { 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,7 +309,6 @@ 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"; @@ -367,7 +363,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 +1676,6 @@ export class TurnRequestBuilder { delegatedToolNames, recordFileState, recordProposedPlan, - postCompactionAttachments, resolveMemoryContext, workspaceGoalService, goalTurnKind, @@ -3153,9 +3147,6 @@ 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), cwd: workspacePath, runtime, }), @@ -3576,7 +3567,6 @@ export class TurnRequestBuilder { toolNamesForSentinel: loadedToolNames, planContentForTransition, planFilePath, - postCompactionAttachments, providersConfig: seed.providersConfig, anthropicCacheTtl: effectiveAnthropicCacheTtl, workspaceId, @@ -3631,7 +3621,6 @@ export class TurnRequestBuilder { anthropicCacheTtl: effectiveAnthropicCacheTtl, planContentForTransition, planFilePath, - postCompactionAttachments, partialContinuationMessage: options.partialContinuationMessage, mcpAppModelContextMessage: mcpAppContextMessage, }); diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index cb99aa13984..61d060b91d3 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -566,6 +566,15 @@ describe("WorkspaceGoalService", () => { sessionHistoryAvailable: true, }), }, + { + row: "a post-compaction attachments row", + // The first turn after compaction appends this row right after its user row. + build: () => + createMuxMessage("post-compaction-1", "user", "\nPlan\n", { + timestamp: Date.now(), + synthetic: true, + }), + }, ])( "getGoal keeps a never-driven goal active when $row follows the processed prompt", async ({ build }) => { diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index d29bf3d5e53..374e3549a8b 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -27,7 +27,7 @@ import { import type { GoalBoardEntry, GoalBoardSnapshot, GoalBoardV1 } from "@/common/types/goal"; import { createMuxMessage, - isContextBudgetWarningMessage, + isAppendedTurnNoticeMessage, isSyntheticSnapshotUserMessage, pickStartupRetrySendOptions, } from "@/common/types/message"; @@ -1172,7 +1172,7 @@ export class WorkspaceGoalService { // look past them for the settled response. const followerRow = historyResult.data .slice(index + 1) - .find((row) => !isPlanReviewRecordMessage(row) && !isContextBudgetWarningMessage(row)); + .find((row) => !isPlanReviewRecordMessage(row) && !isAppendedTurnNoticeMessage(row)); const manualRowProcessed = followerRow?.role === "assistant" && followerRow.metadata?.partial !== true && diff --git a/src/node/services/workspaceTurnManager.historyRepair.test.ts b/src/node/services/workspaceTurnManager.historyRepair.test.ts index 4506fac350a..4edf6e7cc7c 100644 --- a/src/node/services/workspaceTurnManager.historyRepair.test.ts +++ b/src/node/services/workspaceTurnManager.historyRepair.test.ts @@ -15,6 +15,7 @@ import { import type { ErrorEvent } from "@/common/types/stream"; import { createMuxMessage } from "@/common/types/message"; import { createContextBudgetWarning } from "@/node/services/contextWindowRollover"; +import { createPostCompactionAttachmentsMessage } from "@/browser/utils/messages/modelMessageTransform"; import { buildPlanReviewMetadata, formatPlanReviewEnvelope, @@ -807,6 +808,18 @@ describe("WorkspaceTurnManager", () => { sessionHistoryAvailable: true, }), }, + { + row: "post-compaction attachments row", + // The first turn after a compaction persists its attachments right after the prompt. + build: () => { + const row = createPostCompactionAttachmentsMessage( + [{ type: "todo_list", todos: [{ content: "Keep going", status: "in_progress" }] }], + 1 + ); + assert(row); + return row; + }, + }, ])("a trailing $row does not block reviving a retrying workspace turn", async ({ build }) => { // Reconciliation must treat these rows like the absence of a row: the correlated prompt is // still the newest PROMPT, so the retrying child revives the handle. diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index 581706e2acf..c64cd75de75 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -75,7 +75,7 @@ import { } from "@/common/types/backgroundWorkAttention"; import { createMuxMessage, - isContextBudgetWarningMessage, + isAppendedTurnNoticeMessage, isSameWorkspaceTurnTaskCorrelation, parseWorkspaceTurnTaskCorrelation, type MuxMessage, @@ -3525,11 +3525,12 @@ export class WorkspaceTurnManager { }); } // Model-hidden user rows (plan-review snapshot/resolve/reopen records) are UI state, not - // prompts: they must not read as a newer, superseding prompt that disables revival. + // prompts: they must not read as a newer, superseding prompt that disables revival. The + // post-compaction attachments row is context sent with the prompt, not a prompt either. if ( message.role !== "user" || isModelHiddenMessage(message) || - isContextBudgetWarningMessage(message) + isAppendedTurnNoticeMessage(message) ) { continue; } From dc29e58b6f5ddf4906a69b3ef89ca9414aadea67 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 19:01:07 +1100 Subject: [PATCH 02/27] fix: mock AI and agent transition skip rows appended after the prompt --- .../messages/modelMessageTransform.test.ts | 70 ++++++++++++------- .../utils/messages/modelMessageTransform.ts | 3 +- .../services/mock/mockAiStreamPlayer.test.ts | 43 ++++++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 33 ++++----- 4 files changed, 107 insertions(+), 42 deletions(-) diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index 32acfa65dfa..a1da9008b68 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -9,7 +9,10 @@ import { injectPostCompactionAttachments, stripOrphanedToolCalls, } from "./modelMessageTransform"; -import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; +import { + MAX_POST_COMPACTION_INJECTION_CHARS, + POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX, +} from "@/common/constants/attachments"; import type { MuxMessage } from "@/common/types/message"; describe("modelMessageTransform", () => { @@ -1388,20 +1391,9 @@ describe("injectAgentTransition", () => { expect(result[3]).toEqual(messages[2]); // Last user message shifted }); - it("injects the transition before the user prompt, not before a trailing Token Budget warning", () => { - const messages: MuxMessage[] = [ - { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text: "Here's the plan..." }], - metadata: { timestamp: 1000, agentId: "plan" }, - }, - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Now execute it" }], - metadata: { timestamp: 2000 }, - }, + it.each<[string, MuxMessage]>([ + [ + "Token Budget warning", { id: "warning-1", role: "user", @@ -1418,18 +1410,46 @@ describe("injectAgentTransition", () => { }, }, }, - ]; + ], + [ + "post-compaction attachments row", + { + id: `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + role: "user", + parts: [{ type: "text", text: "plan" }], + metadata: { timestamp: 3000, synthetic: true }, + }, + ], + ])( + "injects the transition before the user prompt, not before a trailing %s", + (_label, notice) => { + const messages: MuxMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Here's the plan..." }], + metadata: { timestamp: 1000, agentId: "plan" }, + }, + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Now execute it" }], + metadata: { timestamp: 2000 }, + }, + notice, + ]; - const result = injectAgentTransition(messages, "exec"); + const result = injectAgentTransition(messages, "exec"); - expect(result.map((message) => message.id)).toEqual([ - "assistant-1", - result[1].id, - "user-1", - "warning-1", - ]); - expect(result[1].metadata?.synthetic).toBe(true); - }); + expect(result.map((message) => message.id)).toEqual([ + "assistant-1", + result[1].id, + "user-1", + notice.id, + ]); + expect(result[1].metadata?.synthetic).toBe(true); + } + ); it("should not inject transition when agent is the same", () => { const messages: MuxMessage[] = [ diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index d67b89c9ba9..5e444dc3164 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -5,6 +5,7 @@ import type { ModelMessage, AssistantModelMessage, ToolModelMessage, UserModelMessage } from "ai"; import { + isAppendedTurnNoticeMessage, isContextBudgetWarningMessage, type MuxMessage, type MuxReasoningPart, @@ -143,7 +144,7 @@ export function injectAgentTransition( // 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])) { + if (messages[i].role === "user" && !isAppendedTurnNoticeMessage(messages[i])) { lastUserIndex = i; break; } diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index 9395b6cd2ba..ab5b00801ec 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { EventEmitter } from "events"; import { MockAiStreamPlayer } from "./mockAiStreamPlayer"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { Ok } from "@/common/types/result"; import type { HistoryService } from "@/node/services/historyService"; import type { AIService } from "@/node/services/aiService"; @@ -759,6 +760,48 @@ describe("MockAiStreamPlayer", () => { }); }); + test.each([ + [ + "post-compaction attachments", + { synthetic: true }, + `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + ], + [ + "Token Budget warning", + { + synthetic: true, + muxMetadata: { + type: "context-budget-warning" as const, + contextTokens: 1, + maxTokens: 2, + budgetTokens: 1, + }, + }, + "budget-warning-1", + ], + ])("answers the prompt, not a %s row appended after it", async (_label, metadata, id) => { + const player = new MockAiStreamPlayer({ + historyService, + aiService: new EventEmitter() as unknown as AIService, + }); + const workspaceId = `workspace-appended-${id}`; + const prompt = createMuxMessage("user-prompt", "user", "water the herbs", { + timestamp: Date.now(), + }); + const notice = createMuxMessage(id, "user", "notice", { + timestamp: Date.now(), + ...metadata, + }); + + const playResult = await player.play([prompt, notice], workspaceId); + if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); + expect(await playResult.data.completion).toMatchObject({ status: "completed" }); + + const history = await historyService.getLastMessages(workspaceId, 10); + const reply = (history.success ? history.data : []).find((m) => m.role === "assistant"); + expect(extractText(reply)).toBe("Mock response: water the herbs"); + }); + test("passes the mock rate limit's Retry-After to the turn completion", async () => { const aiServiceStub = new EventEmitter(); aiServiceStub.on("error", () => undefined); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 16f49987830..458618c191e 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import assert from "@/common/utils/assert"; import type { MuxMessage, MuxMessageMetadata } from "@/common/types/message"; -import { createMuxMessage } from "@/common/types/message"; +import { createMuxMessage, isAppendedTurnNoticeMessage } from "@/common/types/message"; import type { HistoryService } from "@/node/services/historyService"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -213,6 +213,20 @@ interface ActiveStream { toolCallRelease?: ReturnType>; } +// A request that ends with a partial assistant continues an interrupted or errored turn: +// stream-error now keeps that partial, as StreamManager does (#5700). Real requests get a +// [CONTINUE] row at transform time (addInterruptedSentinel), which this mock never sees, so +// answer the user message that started the turn, as a provider would. commitPartial keeps +// `partial: true` on errored partials, so repeated retries leave several in a row. +// Rows appended after the prompt (attachments, budget warnings) are context, not the prompt. +function findPromptMessage(messages: MuxMessage[]): MuxMessage | undefined { + return messages.findLast( + (message) => + !(message.role === "assistant" && message.metadata?.partial === true) && + !isAppendedTurnNoticeMessage(message) + ); +} + export class MockAiStreamPlayer { private readonly streamStartGates = new Map(); private readonly releasedStreamStartGates = new Set(); @@ -425,21 +439,8 @@ export class MockAiStreamPlayer { return Ok(undefined); } - // A request that ends with a partial assistant continues an interrupted or errored turn: - // stream-error now keeps that partial, as StreamManager does (#5700). Real requests get a - // [CONTINUE] row at transform time (addInterruptedSentinel), which this mock never sees, so - // answer the user message that started the turn, as a provider would. commitPartial keeps - // `partial: true` on errored partials, so repeated retries leave several in a row. - let latestIndex = messages.length - 1; - while ( - latestIndex >= 0 && - messages[latestIndex].role === "assistant" && - messages[latestIndex].metadata?.partial === true - ) { - latestIndex -= 1; - } - const latest = latestIndex >= 0 ? messages[latestIndex] : undefined; - if (!latest || latest.role !== "user") { + const latest = findPromptMessage(messages); + if (latest?.role !== "user") { return Err({ type: "unknown", raw: "Mock AI expected a user message" }); } From b6d680ef2a549b2e82d5cfb206d583d33388c8ac Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 19:25:12 +1100 Subject: [PATCH 03/27] fix: address review of the durable post-compaction attachments row - Place the attachments row before the turn's prompt in every provider request. - Append the row only while the history tail still matches the turn's read, so a compaction publication that lands meanwhile cannot leave a row from the older snapshot in the new window. - Count skills that the row carries as surfaced, so path-scoped skills get no second pointer. --- .../utils/messages/modelMessageTransform.ts | 27 +++++ .../utils/tools/tools.pathSkills.test.ts | 21 ++++ .../agentSession.postCompactionRetry.test.ts | 103 +++++++++++++++++- src/node/services/agentSession.ts | 49 ++++++--- .../agentSkills/pathSkillActivation.ts | 33 +++++- src/node/services/messagePipeline.test.ts | 49 +++++++++ src/node/services/messagePipeline.ts | 32 +++--- 7 files changed, 280 insertions(+), 34 deletions(-) diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index 5e444dc3164..beb0b30acec 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -7,6 +7,7 @@ import type { ModelMessage, AssistantModelMessage, ToolModelMessage, UserModelMe import { isAppendedTurnNoticeMessage, isContextBudgetWarningMessage, + isPostCompactionAttachmentsMessage, type MuxMessage, type MuxReasoningPart, } from "@/common/types/message"; @@ -243,6 +244,32 @@ export function createPostCompactionAttachmentsMessage( }; } +/** + * Move each durable post-compaction attachments row before the run of user rows it follows. + * AgentSession appends the row after the turn's prompt, but the attachments carry repository + * bytes (plan, skills, diffs) that must not be the last text the model reads: the operator's + * request comes last, as it did when the row was injected per request. The new position + * depends only on earlier rows, so later turns replay the same prefix. + */ +export function placePostCompactionAttachmentsBeforePrompt(messages: MuxMessage[]): MuxMessage[] { + if (!messages.some(isPostCompactionAttachmentsMessage)) { + return messages; + } + const result: MuxMessage[] = []; + for (const message of messages) { + if (!isPostCompactionAttachmentsMessage(message)) { + result.push(message); + continue; + } + let index = result.length; + while (index > 0 && result[index - 1].role === "user") { + index--; + } + result.splice(index, 0, message); + } + return result; +} + /** * Inject post-compaction attachments as a synthetic user message. Live turns persist the * row instead (see the NOTE above); this path rebuilds older recorded request envelopes and diff --git a/src/common/utils/tools/tools.pathSkills.test.ts b/src/common/utils/tools/tools.pathSkills.test.ts index 28fd53b232c..ff5cf97b16b 100644 --- a/src/common/utils/tools/tools.pathSkills.test.ts +++ b/src/common/utils/tools/tools.pathSkills.test.ts @@ -4,6 +4,7 @@ import * as os from "os"; import * as path from "path"; import { z } from "zod"; +import { createPostCompactionAttachmentsMessage } from "@/browser/utils/messages/modelMessageTransform"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { PATH_SKILLS_RESULT_FIELD } from "@/common/types/tools"; @@ -216,6 +217,26 @@ describe("getToolsForModel - path-scoped skill activation", () => { expect(surfacedNames(await call(tools.file_read, { path: "src/foo.ts" }))).toBeUndefined(); }); + test("a skill that the post-compaction attachments row carries is not pointed at again", () => { + const row = createPostCompactionAttachmentsMessage( + [ + { + type: "loaded_skills_snapshot", + skills: [{ name: "ts-rules", scope: "project", sha256: "x", body: "Use strict types." }], + }, + ], + 1 + ); + expect(row).not.toBeNull(); + const activation = createPathSkillActivation({ + skills: SKILLS, + messages: [row!], + cwd: workspace, + runtime: new LocalRuntime(workspace), + }); + expect(activation?.takeMatches("src/foo.ts")).toEqual([]); + }); + test("only the first MAX_PATH_SCOPED_SKILLS path-scoped skills can activate", () => { const runtime = new LocalRuntime(workspace); const many = Array.from({ length: MAX_PATH_SCOPED_SKILLS + 1 }, (_, i) => ({ diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index dbb6cf20200..e8d89c0244a 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -6,9 +6,12 @@ import * as path from "path"; import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { Err } from "@/common/types/result"; -import type { MuxMessage } from "@/common/types/message"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; import { createTestHistoryService } from "./testHistoryService"; +import { AttachmentService } from "./attachmentService"; +import type { CompactionHandler } from "./compactionHandler"; +import type { TurnStreamHandle } from "./turnEngine/types"; import { createAgentSessionHarness, createFailedTurnHandle, @@ -450,4 +453,102 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); + + // A publication that lands while a turn builds its row must not leave that row in the new + // window: the next turn would reuse it and send the older snapshot's attachments. + test("a publication while a turn builds its row skips the row, so the next turn sends the new snapshot", async () => { + const workspaceId = "ws-publication-race"; + const model = "openai:gpt-4o"; + const firstCompletion = Promise.withResolvers>(); + const settled = Promise.withResolvers(); + const streamMessage = mock((..._args: unknown[]) => + Promise.resolve({ + success: true as const, + data: + streamMessage.mock.calls.length === 1 + ? { messageId: "assistant-first", completion: firstCompletion.promise } + : createStartedTurnHandle(h.session.closingSignal, "assistant-next"), + }) + ); + const h = await createAgentSessionHarness({ + workspaceId, + onTurnSettled: () => settled.resolve(), + aiServiceOverrides: { streamMessage }, + }); + const handler = ( + h.session as unknown as { + contextController: { compactionHandler: CompactionHandler }; + } + ).contextController.compactionHandler; + async function publish(id: string) { + const edit = createMuxMessage(id, "assistant", ""); + edit.parts = [ + { + type: "dynamic-tool", + toolCallId: id, + toolName: "file_edit_replace_string", + state: "output-available", + input: { path: `/${id}.ts` }, + output: { success: true, diff: `change ${id}` }, + }, + ]; + const published = await handler.persistContinuousCompaction({ + preparation: handler.beginPreparation(() => true), + publication: { + generation: await h.historyService + .getContinuousCompactionJournal(workspaceId) + .captureGeneration(), + }, + attachmentMessages: [edit], + messages: [edit], + boundaryMessageId: id, + text: `${id} summary`, + model, + tail: [], + systemMessageTokens: 0, + attachmentTokens: 0, + shouldPersist: () => true, + }); + expect(published).toBe(true); + } + // Publication B lands after the first turn read snapshot A and before it appends A's row. + let publishedB = false; + const reports = spyOn(AttachmentService, "generateCompletedReportsAttachment"); + reports.mockImplementation(async () => { + if (!publishedB) { + publishedB = true; + await publish("b"); + } + return null; + }); + const attachmentRows = (messages: MuxMessage[]) => + messages.filter((message) => + message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) + ); + const sentRows = (call: number) => + attachmentRows((streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }).messages); + const started = { success: true as const, data: { started: true } }; + + try { + await publish("a"); + await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-1", "user", "go")); + + expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); + expect(publishedB).toBe(true); + expect(sentRows(0)).toHaveLength(0); + const history = await h.historyService.getHistoryFromLatestBoundary(workspaceId); + expect(history.success && attachmentRows(history.data)).toEqual([]); + + firstCompletion.resolve({ status: "aborted", abortReason: "user" }); + await settled.promise; + expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); + const sent = sentRows(1); + expect(sent).toHaveLength(1); + expect(JSON.stringify(sent[0].parts)).toContain("/b.ts"); + } finally { + reports.mockRestore(); + firstCompletion.resolve({ status: "aborted", abortReason: "user" }); + await h.cleanup(); + } + }); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 0de854fd02c..15da75f7110 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1441,6 +1441,18 @@ function newestHistorySequence(rows: readonly MuxMessage[]): number { return rows.reduce((newest, row) => Math.max(newest, row.metadata?.historySequence ?? -1), -1); } +/** What a turn's history read saw. */ +interface HistoryReadPosition { + /** The newest sequence: a stage warning is refused after a row above it (#5286). */ + historyReadSequence: number; + /** The newest raw row's ID: a tail-guarded append refuses when a row landed after the read. */ + historyTailId: string; +} + +function readHistoryPosition(rows: readonly MuxMessage[]): HistoryReadPosition { + return { historyReadSequence: newestHistorySequence(rows), historyTailId: rows.at(-1)?.id ?? "" }; +} + export class AgentSession { private readonly replayPublication = new AsyncLocalStorage<{ listener: (event: AgentSessionReplayEvent) => void; @@ -8998,10 +9010,10 @@ export class AgentSession { // A crash between snapshot and user-row appends can leave orphaned prompt // expansions on disk; exclude them from every provider request. let requestMessages = filterOrphanedMcpPromptSnapshots(historyResult.data); - // The newest sequence this turn's history read saw, before any filter: a stage warning is - // refused after a row above it (#5286). Rows the filters drop are never delivered, so + // What this turn's history read saw, before any filter: a stage warning is refused after a + // row above its newest sequence (#5286). Rows the filters drop are never delivered, so // counting them as seen is right. - let historyReadSequence = newestHistorySequence(historyResult.data); + let historyRead = readHistoryPosition(historyResult.data); if (requestMessages.length === 0) { return await fail(createUnknownSendMessageError(EMPTY_RESUME_HISTORY_ERROR)); @@ -9048,7 +9060,7 @@ export class AgentSession { ); if (refreshed.success) { requestMessages = filterOrphanedMcpPromptSnapshots(refreshed.data); - historyReadSequence = newestHistorySequence(refreshed.data); + historyRead = readHistoryPosition(refreshed.data); } } } @@ -9086,7 +9098,7 @@ export class AgentSession { if (leaseRefusal != null) return fail(createUnknownSendMessageError(leaseRefusal)); const withAttachments = await this.addPostCompactionAttachmentsRow( - { messages: requestMessages, historyReadSequence }, + { messages: requestMessages, ...historyRead }, disablePostCompactionAttachments === true || preparedRequest != null ); if (isStreamStartAborted()) { @@ -9096,7 +9108,7 @@ export class AgentSession { return await fail(createUnknownSendMessageError(withAttachments.error)); } requestMessages = withAttachments.data.messages; - historyReadSequence = withAttachments.data.historyReadSequence; + historyRead = withAttachments.data; // Apply per-model thinking floors once so desktop, mobile, and ACP requests match. // Gateway-preserving key first (an explicit coder:/ @@ -9242,7 +9254,7 @@ export class AgentSession { onTurnStartStage: this.contextController.turnStartStage(options, (row) => this.publishTurnStartStage( row, - historyReadSequence, + historyRead.historyReadSequence, admissionCapture, isStreamStartAborted, stopFence @@ -9431,30 +9443,41 @@ export class AgentSession { * record the row this turn sends. A turn that failed before the ack already wrote the row in * this window: reuse that row. A compaction turn skips the attachments: its new publication * merges the old pending state. - * @param request The request history of the current window and the newest sequence it read + * @param request The request history of the current window and what its history read saw * @param skip True when the turn disables the attachments or sends a prepared request */ private async addPostCompactionAttachmentsRow( - request: { messages: MuxMessage[]; historyReadSequence: number }, + request: { messages: MuxMessage[] } & HistoryReadPosition, skip: boolean - ): Promise> { + ): Promise> { if (skip || this.activeCompactionRequest != null) return Ok(request); const attachments = await this.getPostCompactionAttachmentsIfNeeded( this.isRlmCompactionEnabled() ); - if (attachments == null || attachments.length === 0) return Ok(request); + if (attachments == null) return Ok(request); this.activeStreamPostCompactionRow = request.messages.find(isPostCompactionAttachmentsMessage); if (this.activeStreamPostCompactionRow) return Ok(request); // Record time: wall clock until #7282 moves its consumers to HostClock. const row = createPostCompactionAttachmentsMessage(attachments, nodeClock.nowMs()); if (row == null) return Ok(request); - const appendResult = await this.historyService.appendToHistory(this.workspaceId, row); + const appendResult = await this.historyService.appendToHistoryIfTailMatches( + this.workspaceId, + row, + request.historyTailId + ); if (!appendResult.success) return Err(appendResult.error); + if (appendResult.data === "tail-mismatch") { + // A row landed after this turn read history, such as a newer compaction boundary. This + // row could then sit in a window that it was not built for, and a retry would reuse it. + // Send this turn without it; the next turn writes the row from the current pending state. + this.pendingPostCompactionStateToAcknowledge = null; + return Ok(request); + } // Publish the hidden row like the file-change row, so an edit fence counts the same rows. this.emitChatEvent({ ...row, type: "message" }); this.activeStreamPostCompactionRow = row; const messages = [...request.messages, row]; - return Ok({ messages, historyReadSequence: newestHistorySequence(messages) }); + return Ok({ messages, ...readHistoryPosition(messages) }); } /** diff --git a/src/node/services/agentSkills/pathSkillActivation.ts b/src/node/services/agentSkills/pathSkillActivation.ts index 264b7e65bd9..b71ab4d01a0 100644 --- a/src/node/services/agentSkills/pathSkillActivation.ts +++ b/src/node/services/agentSkills/pathSkillActivation.ts @@ -5,7 +5,7 @@ import { type PathSkillsResultField, } from "@/common/types/tools"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; -import type { MuxMessage } from "@/common/types/message"; +import { isPostCompactionAttachmentsMessage, type MuxMessage } from "@/common/types/message"; import { deriveProjectRelativePath } from "@/common/utils/review/assistedReview"; import { createSkillPathsMatcher } from "@/common/utils/skills/skillPaths"; import { log } from "@/node/services/log"; @@ -107,17 +107,40 @@ function collectFromParts(parts: unknown, names: Set): void { } } +/** The opening tag of a carried skill, at a line start, as renderAgentSkillSnapshotText writes it. */ +const CARRIED_SKILL_TAG = /^$/gm; + +/** + * Skills that the post-compaction attachments row carries into the window. The row stores + * only rendered text, so read the names from its skill tags. A skill that the row budget + * dropped has no tag, so it can still get a pointer. + */ +function collectCarriedSkillNames(parts: unknown, names: Set): void { + if (!Array.isArray(parts)) return; + for (const part of parts) { + const text = asRecord(part)?.text; + if (typeof text !== "string") continue; + for (const match of text.matchAll(CARRIED_SKILL_TAG)) names.add(match[1]); + } +} + /** * Names of skills already in the context window: prior pointers, successful - * agent_skill_read calls, and /skill-name snapshot rows. History is untrusted on disk, - * so malformed rows are skipped, never thrown on (self-healing). + * agent_skill_read calls, /skill-name snapshot rows and skills that the post-compaction + * attachments row carries. History is untrusted on disk, so malformed rows are skipped, + * never thrown on (self-healing). */ export function collectSurfacedSkillNames(messages: readonly MuxMessage[]): Set { const names = new Set(); for (const message of messages) { const record = asRecord(message); - addName(names, asRecord(asRecord(record?.metadata)?.agentSkillSnapshot)?.skillName); - collectFromParts(record?.parts, names); + if (record == null) continue; + addName(names, asRecord(asRecord(record.metadata)?.agentSkillSnapshot)?.skillName); + collectFromParts(record.parts, names); + // The shared check reads the ID as a string: skip a malformed row without one first. + if (typeof record.id === "string" && isPostCompactionAttachmentsMessage(message)) { + collectCarriedSkillNames(record.parts, names); + } } return names; } diff --git a/src/node/services/messagePipeline.test.ts b/src/node/services/messagePipeline.test.ts index 407eef55860..c8e80a67a8e 100644 --- a/src/node/services/messagePipeline.test.ts +++ b/src/node/services/messagePipeline.test.ts @@ -8,6 +8,7 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import { createTestHistoryService } from "./testHistoryService"; import { prepareMessagesForProvider, sanitizeAssistantModelMessages } from "./messagePipeline"; import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { extractToolMediaAsUserMessagesFromModelMessages } from "@/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages"; import { createToolAttachmentSummaryText } from "@/node/utils/messages/toolResultAttachments"; @@ -1184,3 +1185,51 @@ describe("prepareMessagesForProvider: MCP tool results", () => { expect(outputs["mcp-missing"]).toEqual({ type: "json", value: null }); }); }); + +describe("prepareMessagesForProvider: durable post-compaction attachments row", () => { + const options = { + effectiveAgentId: "exec", + toolNamesForSentinel: [] as string[], + providerForMessages: "anthropic", + effectiveThinkingLevel: "off" as const, + modelString: "anthropic:claude-sonnet-4-5", + workspaceId: "test-ws", + }; + const firstTurn = [ + createMuxMessage("summary-1", "assistant", "Summary of earlier work.", { + timestamp: 1000, + compacted: "user", + compactionBoundary: true, + }), + createMuxMessage("user-1", "user", "OPERATOR_PROMPT", { timestamp: 1001 }), + createMuxMessage("file-change-1", "user", "x", { + timestamp: 1002, + synthetic: true, + }), + createMuxMessage(`${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, "user", "REPO_BYTES", { + timestamp: 1003, + synthetic: true, + }), + ]; + + function textOrder(messages: ModelMessage[]): string[] { + return JSON.stringify(messages).match(/OPERATOR_PROMPT|REPO_BYTES|SECOND_PROMPT/g) ?? []; + } + + it("sends the attachments before the operator's prompt on every turn", async () => { + // AgentSession appends the row after the prompt; repository bytes must never be the + // last text of the merged user message, on the first turn or when later turns replay it. + const first = await prepareMessagesForProvider({ ...options, messagesWithSentinel: firstTurn }); + expect(textOrder(first)).toEqual(["REPO_BYTES", "OPERATOR_PROMPT"]); + + const second = await prepareMessagesForProvider({ + ...options, + messagesWithSentinel: [ + ...firstTurn, + createMuxMessage("assistant-1", "assistant", "Done.", { timestamp: 1004 }), + createMuxMessage("user-2", "user", "SECOND_PROMPT", { timestamp: 1005 }), + ], + }); + expect(textOrder(second)).toEqual(["REPO_BYTES", "OPERATOR_PROMPT", "SECOND_PROMPT"]); + }); +}); diff --git a/src/node/services/messagePipeline.ts b/src/node/services/messagePipeline.ts index 4c17091aa68..ce916ec1d94 100644 --- a/src/node/services/messagePipeline.ts +++ b/src/node/services/messagePipeline.ts @@ -31,6 +31,7 @@ import { validateAnthropicCompliance, injectAgentTransition, injectPostCompactionAttachments, + placePostCompactionAttachmentsBeforePrompt, } from "@/browser/utils/messages/modelMessageTransform"; import { applyNativeToolSearchReplay, @@ -89,20 +90,21 @@ export interface PrepareMessagesOptions { * Run the full message preparation pipeline. * * 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) + * 1. Placing durable post-compaction attachments rows before the turn's prompt + * 2. Injecting agent-transition context (plan→exec handoff) + * 3. Injecting post-compaction attachments + * 4. Redacting heavy tool outputs + * 5. Sanitizing tool inputs + * 6. Inlining SVG attachments as text + * 7. Neutralizing user-typed lookalikes (peer-envelope provenance) + * 8. Sanitizing PDF filenames for Anthropic + * 9. Extracting tool-result media as user message attachments + * 10. Rewriting data-URI file parts to SDK-safe inline base64 + * 11. Converting to Vercel AI SDK ModelMessage format, split at persisted step boundaries + * 12. Self-healing: filtering empty/whitespace assistant messages + * 13. Applying provider-specific message transforms + * 14. Applying cache control headers + * 15. 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 @@ -135,7 +137,7 @@ export async function prepareMessagesForProvider( // Inject agent transition context with plan content (for plan→exec handoff) const messagesWithAgentContext = injectAgentTransition( - messagesWithSentinel, + placePostCompactionAttachmentsBeforePrompt(messagesWithSentinel), effectiveAgentId, toolNamesForSentinel, planContentForTransition, From 36832f51e78c5fb249ccc4d40bb72bc98fe11b5d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 19:34:21 +1100 Subject: [PATCH 04/27] fix: reuse the attachments row only while history ends where the turn read it --- .../agentSession.postCompactionRetry.test.ts | 163 ++++++++++++++---- src/node/services/agentSession.ts | 20 ++- 2 files changed, 145 insertions(+), 38 deletions(-) diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index e8d89c0244a..bd4288968f4 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -5,6 +5,7 @@ import * as path from "path"; import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { Err } from "@/common/types/result"; +import assert from "@/common/utils/assert"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; @@ -41,6 +42,47 @@ function createPersistedPostCompactionState(options: { return fsPromises.writeFile(options.filePath, JSON.stringify(payload)); } +/** Publish a continuous compaction boundary for a window that edited `/.ts`. */ +async function publishEditWindow( + h: Awaited>, + workspaceId: string, + id: string, + model: string +): Promise { + const handler = ( + h.session as unknown as { contextController: { compactionHandler: CompactionHandler } } + ).contextController.compactionHandler; + const edit = createMuxMessage(id, "assistant", ""); + edit.parts = [ + { + type: "dynamic-tool", + toolCallId: id, + toolName: "file_edit_replace_string", + state: "output-available", + input: { path: `/${id}.ts` }, + output: { success: true, diff: `change ${id}` }, + }, + ]; + const published = await handler.persistContinuousCompaction({ + preparation: handler.beginPreparation(() => true), + publication: { + generation: await h.historyService + .getContinuousCompactionJournal(workspaceId) + .captureGeneration(), + }, + attachmentMessages: [edit], + messages: [edit], + boundaryMessageId: id, + text: `${id} summary`, + model, + tail: [], + systemMessageTokens: 0, + attachmentTokens: 0, + shouldPersist: () => true, + }); + expect(published).toBe(true); +} + describe("AgentSession post-compaction context retry", () => { let historyCleanup: (() => Promise) | undefined; afterEach(async () => { @@ -475,42 +517,7 @@ describe("AgentSession post-compaction context retry", () => { onTurnSettled: () => settled.resolve(), aiServiceOverrides: { streamMessage }, }); - const handler = ( - h.session as unknown as { - contextController: { compactionHandler: CompactionHandler }; - } - ).contextController.compactionHandler; - async function publish(id: string) { - const edit = createMuxMessage(id, "assistant", ""); - edit.parts = [ - { - type: "dynamic-tool", - toolCallId: id, - toolName: "file_edit_replace_string", - state: "output-available", - input: { path: `/${id}.ts` }, - output: { success: true, diff: `change ${id}` }, - }, - ]; - const published = await handler.persistContinuousCompaction({ - preparation: handler.beginPreparation(() => true), - publication: { - generation: await h.historyService - .getContinuousCompactionJournal(workspaceId) - .captureGeneration(), - }, - attachmentMessages: [edit], - messages: [edit], - boundaryMessageId: id, - text: `${id} summary`, - model, - tail: [], - systemMessageTokens: 0, - attachmentTokens: 0, - shouldPersist: () => true, - }); - expect(published).toBe(true); - } + const publish = (id: string) => publishEditWindow(h, workspaceId, id, model); // Publication B lands after the first turn read snapshot A and before it appends A's row. let publishedB = false; const reports = spyOn(AttachmentService, "generateCompletedReportsAttachment"); @@ -551,4 +558,88 @@ describe("AgentSession post-compaction context retry", () => { await h.cleanup(); } }); + + // A turn that reuses a failed turn's row must not acknowledge a snapshot published after its + // history read: that row carries the older snapshot, so the newer diffs would never be sent. + test("a publication before a turn reuses its row keeps the new snapshot pending", async () => { + const workspaceId = "ws-publication-reuse-race"; + const model = "openai:gpt-4o"; + const secondCompletion = Promise.withResolvers>(); + const streamMessage = mock((..._args: unknown[]) => { + const call = streamMessage.mock.calls.length; + if (call === 1) { + return Promise.resolve({ + success: false as const, + error: { type: "unknown" as const, raw: "startup failed before stream" }, + }); + } + return Promise.resolve({ + success: true as const, + data: + call === 2 + ? { messageId: "assistant-second", completion: secondCompletion.promise } + : createStartedTurnHandle(h.session.closingSignal, "assistant-next"), + }); + }); + const h = await createAgentSessionHarness({ + workspaceId, + aiServiceOverrides: { streamMessage }, + }); + const session = h.session as unknown as { + coordinator: { consumeCompletion: (...args: never[]) => Promise }; + getPostCompactionAttachmentsIfNeeded: (includeReadFiles: boolean) => Promise; + }; + const publish = (id: string) => publishEditWindow(h, workspaceId, id, model); + // Publication B lands after the second turn read window A and before it captures the + // pending state, so that turn captures B while its request still holds A's row. + let publishB = false; + const captureAttachments = session.getPostCompactionAttachmentsIfNeeded.bind(session); + const capture = spyOn(session, "getPostCompactionAttachmentsIfNeeded"); + const consumer = spyOn(session.coordinator, "consumeCompletion"); + capture.mockImplementation(async (includeReadFiles) => { + if (publishB) { + publishB = false; + await publish("b"); + } + return captureAttachments(includeReadFiles); + }); + const sentRows = (call: number) => + (streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }).messages.filter((message) => + message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) + ); + const started = { success: true as const, data: { started: true } }; + + try { + await publish("a"); + await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-1", "user", "go")); + await h.session.resumeStream({ model, agentId: "exec" }); + expect(sentRows(0)).toHaveLength(1); + + publishB = true; + expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); + expect(publishB).toBe(false); + const policy = consumer.mock.results.at(-1); + assert(policy?.type === "return", "Expected the second turn's completion consumer"); + secondCompletion.resolve({ + status: "completed", + streamEnd: { + type: "stream-end", + workspaceId, + metadata: { model }, + parts: [{ type: "text", text: "done" }], + }, + }); + await policy.value; + + expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); + const sent = sentRows(2); + expect(sent).toHaveLength(1); + expect(JSON.stringify(sent[0].parts)).toContain("/b.ts"); + } finally { + capture.mockRestore(); + consumer.mockRestore(); + secondCompletion.resolve({ status: "aborted", abortReason: "user" }); + await h.cleanup(); + } + }); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 15da75f7110..395c8c1f2ef 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9455,8 +9455,8 @@ export class AgentSession { this.isRlmCompactionEnabled() ); if (attachments == null) return Ok(request); - this.activeStreamPostCompactionRow = request.messages.find(isPostCompactionAttachmentsMessage); - if (this.activeStreamPostCompactionRow) return Ok(request); + const reusableRow = request.messages.find(isPostCompactionAttachmentsMessage); + if (reusableRow) return this.reusePostCompactionRow(request, reusableRow); // Record time: wall clock until #7282 moves its consumers to HostClock. const row = createPostCompactionAttachmentsMessage(attachments, nodeClock.nowMs()); if (row == null) return Ok(request); @@ -9480,6 +9480,22 @@ export class AgentSession { return Ok({ messages, ...readHistoryPosition(messages) }); } + /** + * Reuse a failed turn's row only while history still ends where this turn read it. A + * publication after the read moves the tail, and the pending state captured after it is newer + * than this row: acknowledging it would drop the newer attachments. + */ + private async reusePostCompactionRow( + request: { messages: MuxMessage[] } & HistoryReadPosition, + row: MuxMessage + ): Promise> { + const tail = await this.historyService.getLastMessages(this.workspaceId, 1); + if (!tail.success) return Err(tail.error); + if (tail.data.at(-1)?.id === request.historyTailId) this.activeStreamPostCompactionRow = row; + else this.pendingPostCompactionStateToAcknowledge = null; + return Ok(request); + } + /** * The context-exceeded retry runs without the attachments, so their row leaves history too. * The delete changes the provider context, so the retry adopts the frontier after it. From b8a5950bbc95107724f5100f31b59296b3c0ba61 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 19:55:44 +1100 Subject: [PATCH 05/27] fix: keep the attachments row across edits and failed deletes - Restore the post-compaction attachments row when an edit cuts it, unless the edit also cut the row's context boundary. The snapshot was acknowledged, so no later turn writes it again. - Consume the pending snapshot and mark the context retry only after the row delete succeeds, so a failed delete leaves a row that the next turn can still reuse or delete. - Stop reading carried skills from the row text: plan text or a skill body can contain a skill tag and hide a real path-skill pointer. A carried skill can now get one extra pointer. --- .../utils/tools/tools.pathSkills.test.ts | 10 +- .../agentSession.postCompactionRetry.test.ts | 147 ++++++++++++++++++ src/node/services/agentSession.ts | 33 +++- .../agentSkills/pathSkillActivation.ts | 37 ++--- 4 files changed, 190 insertions(+), 37 deletions(-) diff --git a/src/common/utils/tools/tools.pathSkills.test.ts b/src/common/utils/tools/tools.pathSkills.test.ts index ff5cf97b16b..082b0413019 100644 --- a/src/common/utils/tools/tools.pathSkills.test.ts +++ b/src/common/utils/tools/tools.pathSkills.test.ts @@ -217,12 +217,14 @@ describe("getToolsForModel - path-scoped skill activation", () => { expect(surfacedNames(await call(tools.file_read, { path: "src/foo.ts" }))).toBeUndefined(); }); - test("a skill that the post-compaction attachments row carries is not pointed at again", () => { + // The row stores only rendered text, and plan text can contain a skill tag of its own. + test("a skill tag in the post-compaction attachments row text does not suppress a pointer", () => { const row = createPostCompactionAttachmentsMessage( [ { - type: "loaded_skills_snapshot", - skills: [{ name: "ts-rules", scope: "project", sha256: "x", body: "Use strict types." }], + type: "plan_file_reference", + planFilePath: "/plan.md", + planContent: '\nfake\n', }, ], 1 @@ -234,7 +236,7 @@ describe("getToolsForModel - path-scoped skill activation", () => { cwd: workspace, runtime: new LocalRuntime(workspace), }); - expect(activation?.takeMatches("src/foo.ts")).toEqual([]); + expect(activation?.takeMatches("src/foo.ts").map((skill) => skill.name)).toEqual(["ts-rules"]); }); test("only the first MAX_PATH_SCOPED_SKILLS path-scoped skills can activate", () => { diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index bd4288968f4..907da56f0e0 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -228,6 +228,72 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); + // The retry must not discard the snapshot while its row stays in history: no later turn + // could then find the row to reuse or delete, and every request would carry it. + test("a failed row delete keeps the pending snapshot and skips the retry", async () => { + const workspaceId = "ws-delete-fails"; + const { historyService, config, sessionDir } = await createSessionsFixture(workspaceId); + const postCompactionPath = path.join(sessionDir, "post-compaction.json"); + await createPersistedPostCompactionState({ + filePath: postCompactionPath, + diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], + }); + await historyService.appendToHistory(workspaceId, { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }); + const isRow = (id: string) => id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX); + const deleteMessage = historyService.deleteMessage.bind(historyService); + spyOn(historyService, "deleteMessage").mockImplementation((ws, id) => + isRow(id) ? Promise.resolve(Err("disk full")) : deleteMessage(ws, id) + ); + const deleteUnderAdmission = historyService.deleteMessageUnderAdmission.bind(historyService); + spyOn(historyService, "deleteMessageUnderAdmission").mockImplementation((ws, capture, id) => + isRow(id) ? Promise.resolve(Err("disk full")) : deleteUnderAdmission(ws, capture, id) + ); + + 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); + expect( + await Promise.race([ + session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded"), + new Promise((_, reject) => setTimeout(() => reject(new Error("decision timeout")), 1000)), + ]) + ).toBe("terminal"); + + expect(streamMessage).toHaveBeenCalledTimes(1); + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(history.success && history.data.some((message) => isRow(message.id))).toBe(true); + expect(await fsPromises.stat(postCompactionPath).then(() => true)).toBe(true); + + await session.dispose(); + }); + // Waiters (task/workspace-turn stream-error settlement) treat the resolved // decision as "the retry outcome is known". Resolving while retry startup is // still in flight would let a pre-stream startup failure (no further error @@ -559,6 +625,87 @@ describe("AgentSession post-compaction context retry", () => { } }); + /** + * Run a first turn after a compaction (it sends and acknowledges the attachments row), then + * edit `editMessageId`. History: user-0, compaction boundary, user-1, row, reply. + * @returns the attachments row IDs that the first turn and the edit turn sent + */ + async function editAfterAttachmentsTurn(workspaceId: string, editMessageId: string) { + const model = "openai:gpt-4o"; + const firstCompletion = Promise.withResolvers>(); + const streamMessage = mock((..._args: unknown[]) => + Promise.resolve({ + success: true as const, + data: + streamMessage.mock.calls.length === 1 + ? { messageId: "assistant-first", completion: firstCompletion.promise } + : createStartedTurnHandle(h.session.closingSignal, "assistant-edit"), + }) + ); + const h = await createAgentSessionHarness({ + workspaceId, + aiServiceOverrides: { streamMessage }, + }); + const consumer = spyOn( + (h.session as unknown as { coordinator: { consumeCompletion: () => Promise } }) + .coordinator, + "consumeCompletion" + ); + const sentRowIds = (call: number) => + (streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }).messages + .filter((message) => message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX)) + .map((message) => message.id); + + try { + await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-0", "user", "hi")); + await publishEditWindow(h, workspaceId, "a", model); + await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-1", "user", "go")); + await h.session.resumeStream({ model, agentId: "exec" }); + const policy = consumer.mock.results.at(-1); + assert(policy?.type === "return", "Expected the first turn's completion consumer"); + firstCompletion.resolve({ + status: "completed", + streamEnd: { + type: "stream-end", + workspaceId, + metadata: { model }, + parts: [{ type: "text", text: "done" }], + }, + }); + await policy.value; + + const edited = await h.session.sendMessage("edited", { + model, + agentId: "exec", + editMessageId, + }); + expect(edited.success).toBe(true); + // An edit starts its stream in the background. + for (let i = 0; i < 100 && streamMessage.mock.calls.length < 2; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return { first: sentRowIds(0), edit: sentRowIds(1) }; + } finally { + consumer.mockRestore(); + firstCompletion.resolve({ status: "aborted", abortReason: "user" }); + await h.cleanup(); + } + } + + // An edit cuts from its prompt, so it also cuts the row stored after that prompt. The first + // turn acknowledged the snapshot, so no later turn writes the row again. + test("an edit of the prompt before the attachments row keeps the row", async () => { + const sent = await editAfterAttachmentsTurn("ws-edit-keeps-row", "user-1"); + expect(sent.first).toHaveLength(1); + expect(sent.edit).toEqual(sent.first); + }); + + test("an edit before the compaction boundary drops the row with its window", async () => { + const sent = await editAfterAttachmentsTurn("ws-edit-drops-row", "user-0"); + expect(sent.first).toHaveLength(1); + expect(sent.edit).toEqual([]); + }); + // A turn that reuses a failed turn's row must not acknowledge a snapshot published after its // history read: that row carries the older snapshot, so the newer diffs would never be sent. test("a publication before a turn reuses its row keeps the new snapshot pending", async () => { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 395c8c1f2ef..27839105eda 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, @@ -5635,6 +5636,7 @@ export class AgentSession { if (truncateResult.success) { editTailTruncated = true; + await this.restorePostCompactionRowAfterEdit(truncateResult.data.removedMessages); // RLM mode: summarize the truncated tail into a durable labeled row // BEFORE the edited user message is appended and this turn's request is // built (log purity by construction). Best-effort with a hard deadline — @@ -9535,6 +9537,28 @@ export class AgentSession { return true; } + /** + * An edit cuts from its prompt, so it also cuts an attachments row stored after that prompt. + * The snapshot was acknowledged and no later turn writes the row again: put the row back + * before the edited prompt. An edit that cut a context boundary also cut the row's window. + */ + private async restorePostCompactionRowAfterEdit(removed: readonly MuxMessage[]): Promise { + if (removed.some(isDurableContextBoundaryMarker)) return; + const row = removed.findLast(isPostCompactionAttachmentsMessage); + if (row == null) return; + const { historySequence: _sequence, ...metadata } = row.metadata ?? {}; + const restored: MuxMessage = { ...row, metadata }; + const appended = await this.historyService.appendToHistory(this.workspaceId, restored); + if (!appended.success) { + log.warn("Failed to restore post-compaction attachments row after edit", { + workspaceId: this.workspaceId, + error: appended.error, + }); + return; + } + this.emitChatEvent({ ...restored, type: "message" }); + } + private async discardFailedPostCompactionState( pendingState: Awaited> ): Promise { @@ -9818,17 +9842,12 @@ export class AgentSession { return false; } - 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. - await this.discardFailedPostCompactionState(pendingState); - // Abort the failed assistant placeholder and clean up persisted partial/history state. this.resetActiveStreamState(); this.emitChatEvent({ @@ -9837,9 +9856,13 @@ export class AgentSession { messageId: data.messageId, }); await this.clearFailedAssistantMessage(data.messageId, "post-compaction-retry"); + // Keep the pending state while the row stays in history: the next turn reuses the row. if (!(await this.deletePostCompactionRow(postCompactionRow, context.admissionCapture))) { return false; } + this.postCompactionRetryAttempts.add(requestId); + // The post-compaction context is likely the culprit; discard it so we don't loop. + await this.discardFailedPostCompactionState(pendingState); // r40: same admission gate as the compaction retry above — this path also // crosses a transient idle gap before re-entering PREPARING. diff --git a/src/node/services/agentSkills/pathSkillActivation.ts b/src/node/services/agentSkills/pathSkillActivation.ts index b71ab4d01a0..145af225f7f 100644 --- a/src/node/services/agentSkills/pathSkillActivation.ts +++ b/src/node/services/agentSkills/pathSkillActivation.ts @@ -5,7 +5,7 @@ import { type PathSkillsResultField, } from "@/common/types/tools"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; -import { isPostCompactionAttachmentsMessage, type MuxMessage } from "@/common/types/message"; +import type { MuxMessage } from "@/common/types/message"; import { deriveProjectRelativePath } from "@/common/utils/review/assistedReview"; import { createSkillPathsMatcher } from "@/common/utils/skills/skillPaths"; import { log } from "@/node/services/log"; @@ -107,40 +107,21 @@ function collectFromParts(parts: unknown, names: Set): void { } } -/** The opening tag of a carried skill, at a line start, as renderAgentSkillSnapshotText writes it. */ -const CARRIED_SKILL_TAG = /^$/gm; - -/** - * Skills that the post-compaction attachments row carries into the window. The row stores - * only rendered text, so read the names from its skill tags. A skill that the row budget - * dropped has no tag, so it can still get a pointer. - */ -function collectCarriedSkillNames(parts: unknown, names: Set): void { - if (!Array.isArray(parts)) return; - for (const part of parts) { - const text = asRecord(part)?.text; - if (typeof text !== "string") continue; - for (const match of text.matchAll(CARRIED_SKILL_TAG)) names.add(match[1]); - } -} - /** * Names of skills already in the context window: prior pointers, successful - * agent_skill_read calls, /skill-name snapshot rows and skills that the post-compaction - * attachments row carries. History is untrusted on disk, so malformed rows are skipped, - * never thrown on (self-healing). + * agent_skill_read calls, and /skill-name snapshot rows. History is untrusted on disk, + * so malformed rows are skipped, never thrown on (self-healing). + * + * The post-compaction attachments row does not count: it stores only rendered text, and + * plan text or a skill body can contain a skill tag. A skill that the row carries can get + * one extra pointer, but a spoofed tag cannot hide a pointer. */ export function collectSurfacedSkillNames(messages: readonly MuxMessage[]): Set { const names = new Set(); for (const message of messages) { const record = asRecord(message); - if (record == null) continue; - addName(names, asRecord(asRecord(record.metadata)?.agentSkillSnapshot)?.skillName); - collectFromParts(record.parts, names); - // The shared check reads the ID as a string: skip a malformed row without one first. - if (typeof record.id === "string" && isPostCompactionAttachmentsMessage(message)) { - collectCarriedSkillNames(record.parts, names); - } + addName(names, asRecord(asRecord(record?.metadata)?.agentSkillSnapshot)?.skillName); + collectFromParts(record?.parts, names); } return names; } From cee66e832d4e5af4a0bd2ff902b8d797aa66486c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 20:19:50 +1100 Subject: [PATCH 06/27] fix: treat the attachments row as context in every history consumer The post-compaction attachments row is a user-role row stored after its turn's prompt. Edits that cut a later boundary keep it, branch summaries and /refine skip it, rolling cuts and title context do not start a turn on it, request context goes before the prompt instead of before the row, and the default Continue resume stays muted when the row follows it. --- .../StreamingMessageAggregator.test.ts | 15 ++++++++- .../messages/StreamingMessageAggregator.ts | 10 ++++-- .../utils/compaction/rollingCut.test.ts | 13 ++++++++ src/common/utils/compaction/rollingCut.ts | 18 +++++++++-- .../agentSession.postCompactionRetry.test.ts | 18 +++++++++-- src/node/services/agentSession.ts | 8 +++-- src/node/services/branchSummary.test.ts | 30 ++++++++++++++++++ src/node/services/branchSummary.ts | 16 ++++++++-- .../services/turnContextAssembler.test.ts | 27 ++++++++++++++++ src/node/services/turnContextAssembler.ts | 25 +++++++++++++-- .../services/workspaceService.titles.test.ts | 31 +++++++++++++++++++ src/node/services/workspaceService.ts | 3 ++ 12 files changed, 198 insertions(+), 16 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index a046a0c783f..25740216d26 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -1,5 +1,6 @@ import type { MCPToolCallDisplay } from "@/common/types/mcp"; import { describe, test, expect } from "bun:test"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { MuxMessageSchema } from "@/common/orpc/schemas/message"; import { createMuxMessage, type DisplayedMessage } from "@/common/types/message"; @@ -3445,7 +3446,8 @@ describe("StreamingMessageAggregator", () => { expect(aggregator.isCompacting()).toBe(false); }); - test("suppresses response notifications for default post-compaction Continue resumes", () => { + // The backend stores the post-compaction attachments row after the follow-up row. + test.each([false, true])("mutes default Continue resumes (attachments row: %p)", (withRow) => { const workspaceId = "test-workspace-default-continue-notify"; const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT, workspaceId); let completion: Parameters[0]; @@ -3478,6 +3480,17 @@ describe("StreamingMessageAggregator", () => { }), type: "message", }); + if (withRow) { + aggregator.handleMessage({ + ...createMuxMessage( + `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + "user", + "plan", + { historySequence: 3, timestamp: Date.now(), synthetic: true } + ), + type: "message", + }); + } aggregator.handleStreamStart({ type: "stream-start", diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index c68d6298265..b94caf541ea 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -12,6 +12,7 @@ import { getMcpPromptReferenceKey, isCompactionSummaryMetadata, isContextBudgetWarningMessage, + isPostCompactionAttachmentsMessage, sanitizeAgentSkillRefs, sanitizeMcpPromptRefs, } from "@/common/types/message"; @@ -179,6 +180,12 @@ function startsUserTurn(message: MuxMessage): boolean { ); } +// A hidden plan-review record can sit between the compaction summary and its follow-up row, and +// the post-compaction attachments row lands after the follow-up row. +function isPostCompactionHiddenRow(message: MuxMessage): boolean { + return isPlanReviewRecordMessage(message) || isPostCompactionAttachmentsMessage(message); +} + // Maximum number of messages to display in the DOM for performance // Full history is still maintained internally for token counting and stats const AgentStatusSchema = z.object({ @@ -1863,8 +1870,7 @@ export class StreamingMessageAggregator { } private isDefaultPostCompactionContinueTurn(): boolean { - // A hidden plan-review record can sit between the summary and its follow-up row. - const messages = this.getAllMessages().filter((message) => !isPlanReviewRecordMessage(message)); + const messages = this.getAllMessages().filter((message) => !isPostCompactionHiddenRow(message)); const latestMessage = messages.at(-1); const previousMessage = messages.at(-2); if (latestMessage?.role !== "user" || previousMessage?.role !== "assistant") { diff --git a/src/common/utils/compaction/rollingCut.test.ts b/src/common/utils/compaction/rollingCut.test.ts index 3012141b033..f5504bc17dc 100644 --- a/src/common/utils/compaction/rollingCut.test.ts +++ b/src/common/utils/compaction/rollingCut.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { selectRollingCut, type RollingCutBudget } from "./rollingCut"; @@ -99,6 +100,18 @@ describe("selectRollingCut", () => { expect(cut?.tail).toEqual(rows.slice(4)); }); + test("duplicates the prompt, not the post-compaction attachments row after it", () => { + const attachments: MuxMessage = { + ...message(`${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, "user", 100), + metadata: { synthetic: true }, + }; + const rows = [...oldHead(), message("user", "user", 100), attachments, steppedAnswer(18_000)]; + const cut = selectRollingCut(rows, null, budget); + expect(cut?.stepCut?.partIndex).toBe(1); + expect(cut?.tail.slice(0, 1)).toEqual(rows.slice(2, 3)); + expect(cut?.tail).not.toContain(attachments); + }); + test("splits consecutive tool-only steps only at recorded boundaries and duplicates the user cluster", () => { const answer = steppedAnswer(18_000); const rows = [...oldHead(), snapshot("file"), message("user", "user", 100), answer]; diff --git a/src/common/utils/compaction/rollingCut.ts b/src/common/utils/compaction/rollingCut.ts index ec2bbdb2f67..9547b9b1a15 100644 --- a/src/common/utils/compaction/rollingCut.ts +++ b/src/common/utils/compaction/rollingCut.ts @@ -1,4 +1,8 @@ -import { isSyntheticSnapshotUserMessage, type MuxMessage } from "@/common/types/message"; +import { + isPostCompactionAttachmentsMessage, + isSyntheticSnapshotUserMessage, + type MuxMessage, +} from "@/common/types/message"; import assert from "@/common/utils/assert"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { @@ -29,6 +33,16 @@ export interface RollingCut { stepCut?: { messageId: string; partIndex: number; firstTailToolCallId?: string }; } +// A synthetic snapshot joins the prompt after it, and the post-compaction attachments row follows +// its turn's prompt: neither starts a turn. +function startsTurn(row: MuxMessage): boolean { + return ( + row.role === "user" && + !isSyntheticSnapshotUserMessage(row) && + !isPostCompactionAttachmentsMessage(row) + ); +} + function exactStepStarts(indices: readonly number[] | undefined, partCount: number): number[] { // Old or malformed metadata is not permission to guess boundaries from tool shapes. const isArray: boolean = Array.isArray(indices); @@ -118,7 +132,7 @@ export function selectRollingCut( for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { const row = rows[rowIndex]; - if (row.role === "user" && !isSyntheticSnapshotUserMessage(row)) { + if (startsTurn(row)) { userIndex = rowIndex; clusterStart = rowIndex; while (clusterStart > 0 && isSyntheticSnapshotUserMessage(rows[clusterStart - 1])) { diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 907da56f0e0..a05c5457eb0 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -627,10 +627,15 @@ describe("AgentSession post-compaction context retry", () => { /** * Run a first turn after a compaction (it sends and acknowledges the attachments row), then - * edit `editMessageId`. History: user-0, compaction boundary, user-1, row, reply. + * edit `editMessageId`. History: user-0, compaction boundary, user-1, row, reply, and with + * `laterWindow` one more compaction boundary. * @returns the attachments row IDs that the first turn and the edit turn sent */ - async function editAfterAttachmentsTurn(workspaceId: string, editMessageId: string) { + async function editAfterAttachmentsTurn( + workspaceId: string, + editMessageId: string, + laterWindow = false + ) { const model = "openai:gpt-4o"; const firstCompletion = Promise.withResolvers>(); const streamMessage = mock((..._args: unknown[]) => @@ -673,6 +678,9 @@ describe("AgentSession post-compaction context retry", () => { }, }); await policy.value; + if (laterWindow) { + await publishEditWindow(h, workspaceId, "b", model); + } const edited = await h.session.sendMessage("edited", { model, @@ -700,6 +708,12 @@ describe("AgentSession post-compaction context retry", () => { expect(sent.edit).toEqual(sent.first); }); + test("an edit that also cuts a later compaction boundary keeps the row", async () => { + const sent = await editAfterAttachmentsTurn("ws-edit-later-boundary", "user-1", true); + expect(sent.first).toHaveLength(1); + expect(sent.edit).toContain(sent.first[0]); + }); + test("an edit before the compaction boundary drops the row with its window", async () => { const sent = await editAfterAttachmentsTurn("ws-edit-drops-row", "user-0"); expect(sent.first).toHaveLength(1); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 27839105eda..ff9c44b68c9 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9540,11 +9540,13 @@ export class AgentSession { /** * An edit cuts from its prompt, so it also cuts an attachments row stored after that prompt. * The snapshot was acknowledged and no later turn writes the row again: put the row back - * before the edited prompt. An edit that cut a context boundary also cut the row's window. + * before the edited prompt. Only rows before the first cut context boundary belong to the + * window the edit keeps; later rows belong to windows the edit cut. */ private async restorePostCompactionRowAfterEdit(removed: readonly MuxMessage[]): Promise { - if (removed.some(isDurableContextBoundaryMarker)) return; - const row = removed.findLast(isPostCompactionAttachmentsMessage); + const boundaryIndex = removed.findIndex(isDurableContextBoundaryMarker); + const keptWindowRows = boundaryIndex === -1 ? removed : removed.slice(0, boundaryIndex); + const row = keptWindowRows.findLast(isPostCompactionAttachmentsMessage); if (row == null) return; const { historySequence: _sequence, ...metadata } = row.metadata ?? {}; const restored: MuxMessage = { ...row, metadata }; diff --git a/src/node/services/branchSummary.test.ts b/src/node/services/branchSummary.test.ts index 895f9fd63d6..aca5d00ab22 100644 --- a/src/node/services/branchSummary.test.ts +++ b/src/node/services/branchSummary.test.ts @@ -9,6 +9,7 @@ import * as path from "node:path"; import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; import type { ProviderName } from "@/common/constants/providers"; import { WORDS_TO_TOKENS_RATIO } from "@/common/constants/ui"; @@ -476,6 +477,35 @@ describe("maybeAppendAbandonedBranchSummary", () => { } }); + test("a post-compaction attachments row is not abandoned work", async () => { + const attachments = createMuxMessage( + `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + "user", + "carried plan ".repeat(BRANCH_SUMMARY_MIN_SEGMENT_TOKENS * 3), + { synthetic: true } + ); + expect(buildAbandonedBranchTranscript([attachments])).toBe(""); + const { historyService, cleanup } = await createTestHistoryService(); + let calls = 0; + try { + const result = await maybeAppendAbandonedBranchSummary({ + historyService, + workspaceId: "attachments-row-budget", + isExperimentEnabled: RLM_ON, + aiService: fakeAiService( + summaryModel("summary", () => { + calls++; + }) + ), + abandonedMessages: [createMuxMessage("visible", "user", "tiny visible turn"), attachments], + }); + expect(result).toBeNull(); + expect(calls).toBe(0); + } finally { + await cleanup(); + } + }); + test("tiny abandoned segments skip the model call", async () => { const { historyService, cleanup } = await createTestHistoryService(); try { diff --git a/src/node/services/branchSummary.ts b/src/node/services/branchSummary.ts index eb8bc425305..ad1f0b7e034 100644 --- a/src/node/services/branchSummary.ts +++ b/src/node/services/branchSummary.ts @@ -21,7 +21,11 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; import { buildCompactionPrompt } from "@/common/constants/ui"; -import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { + createMuxMessage, + isPostCompactionAttachmentsMessage, + type MuxMessage, +} from "@/common/types/message"; import { anthropicRejectsDisabledThinking, anthropicThinksUnlessDisabled, @@ -130,6 +134,12 @@ function formatMessageForBranchTranscript(message: MuxMessage): string { return `${role}: ${segments.join("\n")}`; } +// The post-compaction attachments row is carried context (an edit may even restore it), not +// conversation or work. +function isTranscriptMessage(message: MuxMessage): boolean { + return !isModelHiddenMessage(message) && !isPostCompactionAttachmentsMessage(message); +} + /** * Build the thinking-stripped transcript of the abandoned segment, trimming * oldest messages first when over the input cap (the newest abandoned work @@ -139,7 +149,7 @@ export function buildAbandonedBranchTranscript(messages: MuxMessage[]): string { assert(Array.isArray(messages), "buildAbandonedBranchTranscript requires a message array"); // Both /refine and abandoned-branch summaries must exclude hidden state before the char cap. const formatted = messages - .filter((message) => !isModelHiddenMessage(message)) + .filter(isTranscriptMessage) .map(formatMessageForBranchTranscript) .filter((s) => s.length > 0); @@ -727,7 +737,7 @@ export async function maybeAppendAbandonedBranchSummary( // Hidden records also cannot make a tiny visible segment eligible for a model call. const abandonedMessages = input.abandonedMessages.filter( (message) => - !isModelHiddenMessage(message) && + isTranscriptMessage(message) && message.metadata?.rlmPreservedTailCopy !== true && (message.metadata?.compacted === undefined || message.metadata.compacted === false) ); diff --git a/src/node/services/turnContextAssembler.test.ts b/src/node/services/turnContextAssembler.test.ts index fd727cd2bdc..f3e045e937b 100644 --- a/src/node/services/turnContextAssembler.test.ts +++ b/src/node/services/turnContextAssembler.test.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import { describe, expect, test } from "bun:test"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { sliceMessagesFromLatestCompactionBoundary } from "@/common/utils/messages/compactionBoundary"; @@ -550,6 +551,32 @@ describe("assemblePromptPayload", () => { expect(afterPartial).toContain("[CONTINUE]"); }); + test("puts request context right before the prompt, not before its attachments row", async () => { + const context = createMuxMessage("ctx", "user", "VIEW-CONTEXT"); + const attachments = createMuxMessage( + `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + "user", + "ATTACHED", + { synthetic: true } + ); + const prompt = createMuxMessage("u1", "user", "NEWEST"); + const partial = createMuxMessage("a1", "assistant", "PARTIAL", { partial: true }); + const order = async (history: MuxMessage[]) => { + const payload = await assemble({ history, requestContextMessage: context }); + const wire = JSON.stringify(payload.messages); + return ["ATTACHED", "VIEW-CONTEXT", "NEWEST", "PARTIAL"] + .filter((text) => wire.includes(text)) + .sort((a, b) => wire.indexOf(a) - wire.indexOf(b)); + }; + expect(await order([prompt, attachments])).toEqual(["ATTACHED", "VIEW-CONTEXT", "NEWEST"]); + expect(await order([prompt, attachments, partial])).toEqual([ + "ATTACHED", + "VIEW-CONTEXT", + "NEWEST", + "PARTIAL", + ]); + }); + test("replays tool_catalog_search results as tool references only for deferred tools", async () => { const history = [ createMuxMessage("user", "user", "find it"), diff --git a/src/node/services/turnContextAssembler.ts b/src/node/services/turnContextAssembler.ts index b53a076f004..4873d56af8f 100644 --- a/src/node/services/turnContextAssembler.ts +++ b/src/node/services/turnContextAssembler.ts @@ -10,6 +10,7 @@ import * as path from "node:path"; import assert from "@/common/utils/assert"; import { + isPostCompactionAttachmentsMessage, isTokenBudgetInternalMessage, type MuxMessage, type SystemSectionRecords, @@ -376,7 +377,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 attachments row stored after the + * request is not the request: the provider transform moves it before the request's user rows. */ export function insertRequestContextMessage( history: MuxMessage[], @@ -387,14 +389,31 @@ export function insertRequestContextMessage( const last = history.at(-1); let index = history.length; if (last?.role === "user") { - index = history.length - 1; + index = requestEnd(history); } else if (last?.role === "assistant" && last.metadata?.partial === true) { - const request = history.findLastIndex((message) => message.role === "user"); + const request = history.findLastIndex(isRequestRow); index = request === -1 ? history.length - 1 : request; } return [...history.slice(0, index), context, ...history.slice(index)]; } +function isRequestRow(message: MuxMessage): boolean { + return message.role === "user" && !isPostCompactionAttachmentsMessage(message); +} + +/** The index of the last request row when history ends in user rows. */ +function requestEnd(history: MuxMessage[]): number { + let index = history.length - 1; + while ( + index > 0 && + isPostCompactionAttachmentsMessage(history[index]) && + history[index - 1].role === "user" + ) { + index--; + } + return index; +} + /** * Codex parity: the model records the item ID of each relevant user request in its checkpoint and * later passes it to session_history read_item. The ID is the persisted history sequence, so the diff --git a/src/node/services/workspaceService.titles.test.ts b/src/node/services/workspaceService.titles.test.ts index a4b59fb6bfa..1907e86756b 100644 --- a/src/node/services/workspaceService.titles.test.ts +++ b/src/node/services/workspaceService.titles.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, mock, beforeEach, afterEach, spyOn } from "bun:test"; import type { WorkspaceService } from "./workspaceService"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { NAME_GEN_PREFERRED_MODELS } from "@/common/constants/nameGeneration"; import { DEFAULT_MODEL } from "@/common/constants/knownModels"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -569,6 +570,7 @@ describe("WorkspaceService regenerateTitle", () => { "ws-regenerate-title-first-plus-last-three", "title-hidden-review", "title-budget-warning", + "title-attachments-row", ].map((id) => ({ id, name: id, path: `/tmp/proj/${id}` })), { agentAiDefaults: { @@ -665,6 +667,35 @@ describe("WorkspaceService regenerateTitle", () => { } }); + test("the post-compaction attachments row is neither title context nor the latest request", async () => { + const workspaceId = "title-attachments-row"; + const rows = [ + createMuxMessage("objective", "user", "visible objective"), + createMuxMessage("answer", "assistant", "visible progress"), + createMuxMessage("follow-up", "user", "visible follow-up request"), + createMuxMessage( + `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + "user", + "ATTACHMENTS_SENTINEL", + { synthetic: true } + ), + ]; + for (const row of rows) + expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); + const generate = spyOn(workspaceTitleGenerator, "generateWorkspaceIdentity").mockResolvedValue( + Ok({ name: "visible-title", title: "Visible title", modelUsed: "test:model" }) + ); + try { + expect((await workspaceService.regenerateTitle(workspaceId)).success).toBe(true); + const call = generate.mock.calls[0]; + expect(call?.[3]).toContain("visible follow-up request"); + expect(call?.[3]).not.toContain("ATTACHMENTS_SENTINEL"); + expect(call?.[4]).toBe("visible follow-up request"); + } finally { + generate.mockRestore(); + } + }); + test("returns updateTitle error when persisting generated title fails", async () => { const workspaceId = "ws-regenerate-title"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2b72473945d..1723695176a 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -314,6 +314,7 @@ import { createMuxMessage, getCompactionFollowUpContent, isContextBudgetWarningMessage, + isPostCompactionAttachmentsMessage, isSameWorkspaceTurnTaskCorrelation, parseWorkspaceTurnTaskCorrelation, pickPreservedSendOptions, @@ -1251,9 +1252,11 @@ function collectWorkspaceTitleContextTurns( const turns: WorkspaceTitleContextTurn[] = []; // Hidden records must not select the naming objective or consume the title's turn budget. + // The post-compaction attachments row is carried repository context, not a conversation turn. for (const message of messages) { if ( isModelHiddenMessage(message) || + isPostCompactionAttachmentsMessage(message) || (message.role !== "user" && message.role !== "assistant") ) { continue; From 7484a7586a6a287353c034dc0eb0be07f650aae6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 28 Oct 2026 20:30:56 +1100 Subject: [PATCH 07/27] fix: do not count the dropped attachments row against the rolling tail The boundary drops the old window's post-compaction attachments row, and the attachment budget already counts the fresh row that replaces it. Cut selection and the staged-summary fit check now give that row zero weight, so a large row no longer forces a smaller or empty retained tail. --- src/common/utils/compaction/rollingCut.ts | 10 +++++++- src/node/services/continuousCompactor.test.ts | 25 +++++++++++++++++++ src/node/services/continuousCompactor.ts | 8 ++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/common/utils/compaction/rollingCut.ts b/src/common/utils/compaction/rollingCut.ts index 9547b9b1a15..95072cdd601 100644 --- a/src/common/utils/compaction/rollingCut.ts +++ b/src/common/utils/compaction/rollingCut.ts @@ -43,6 +43,14 @@ function startsTurn(row: MuxMessage): boolean { ); } +/** + * The boundary drops the old window's post-compaction attachments row (the next turn writes a + * fresh one), and the attachment budget already counts that fresh row. + */ +export function estimateRetainedRowTokens(row: MuxMessage): number { + return isPostCompactionAttachmentsMessage(row) ? 0 : estimateMuxMessageTokens(row); +} + function exactStepStarts(indices: readonly number[] | undefined, partCount: number): number[] { // Old or malformed metadata is not permission to guess boundaries from tool shapes. const isArray: boolean = Array.isArray(indices); @@ -112,7 +120,7 @@ export function selectRollingCut( const minHead = Math.min(MIN_HEAD_TOKENS, 0.1 * budget.contextWindowTokens); const prefixTokens = [0]; for (const row of rows) { - prefixTokens.push(prefixTokens[prefixTokens.length - 1] + estimateMuxMessageTokens(row)); + prefixTokens.push(prefixTokens[prefixTokens.length - 1] + estimateRetainedRowTokens(row)); } const totalTokens = prefixTokens[rows.length]; let best: RollingCut | null = null; diff --git a/src/node/services/continuousCompactor.test.ts b/src/node/services/continuousCompactor.test.ts index 7ed1d5365b0..43a1c9a7305 100644 --- a/src/node/services/continuousCompactor.test.ts +++ b/src/node/services/continuousCompactor.test.ts @@ -12,6 +12,7 @@ import { type CompactionFollowUpRequest, type MuxMessage, } from "@/common/types/message"; +import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { FORCE_COMPACTION_BUFFER_PERCENT } from "@/common/constants/ui"; import { EAGER_LEAD_PERCENT } from "@/constants/continuousCompaction"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; @@ -1575,6 +1576,30 @@ describe("ContinuousCompactor", () => { expect(completed.mock.calls[0][0]).toMatchObject({ preservedTailMessageCount: 0 }); }); + it("keeps the recent turn when its post-compaction attachments row alone outgrows the budgets", async () => { + // The boundary drops the old attachments row (the next turn writes a fresh one), so its tokens + // must neither shrink the retained tail nor block staging. + await seed( + createMuxMessage("old-user", "user", "Investigate the regression"), + createMuxMessage("old-answer", "assistant", "earlier investigation ".repeat(4_000)), + createMuxMessage("recent-user", "user", "Implement the fix"), + createMuxMessage( + `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, + "user", + "x".repeat(context.contextWindowTokens * 4), + { synthetic: true } + ), + createMuxMessage("recent-answer", "assistant", "The fix is ready for review.") + ); + await stage(); + expect(await compactor.observe(context.thresholdPercent, context)).toBe("applied"); + expect(completed.mock.calls[0][0]).toMatchObject({ preservedTailMessageCount: 2 }); + const texts = (await rows()).map((row) => + row.parts.map((part) => (part.type === "text" ? part.text : "")).join("") + ); + expect(texts).toEqual([summary.text, "Implement the fix", "The fix is ready for review."]); + }); + it("requests full-compaction fallback at the force threshold when no summary is ready", async () => { await seedConversation(); const release = deferred(); diff --git a/src/node/services/continuousCompactor.ts b/src/node/services/continuousCompactor.ts index 6556133d32c..ca719e5e0a9 100644 --- a/src/node/services/continuousCompactor.ts +++ b/src/node/services/continuousCompactor.ts @@ -18,7 +18,11 @@ import { type MuxMessage, type MuxMetadata, } from "@/common/types/message"; -import { selectRollingCut, type RollingCut } from "@/common/utils/compaction/rollingCut"; +import { + estimateRetainedRowTokens, + selectRollingCut, + type RollingCut, +} from "@/common/utils/compaction/rollingCut"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { isModelHiddenMessage } from "@/common/utils/messages/modelHiddenMessages"; import { @@ -504,7 +508,7 @@ export class ContinuousCompactor { summaryTokens + (context.systemMessageTokens ?? 0) + Math.max(context.attachmentTokens ?? 0, staged.attachmentTokens) + - tail.reduce((sum, row) => sum + estimateMuxMessageTokens(row), 0); + tail.reduce((sum, row) => sum + estimateRetainedRowTokens(row), 0); return ( tokens < (context.contextWindowTokens * (context.thresholdPercent + FORCE_COMPACTION_BUFFER_PERCENT)) / From 8b087f60bc0758d542637efb72a4d7160e1bd994 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 12:12:27 +1100 Subject: [PATCH 08/27] revert: drop the durable attachments row before the boundary-header redesign --- .../StreamingMessageAggregator.test.ts | 42 +- .../messages/StreamingMessageAggregator.ts | 28 +- .../messages/modelMessageTransform.test.ts | 70 +-- .../utils/messages/modelMessageTransform.ts | 109 ++--- src/common/constants/attachments.ts | 7 +- src/common/types/message.ts | 17 - .../utils/compaction/rollingCut.test.ts | 13 - src/common/utils/compaction/rollingCut.ts | 28 +- .../utils/tools/tools.pathSkills.test.ts | 23 +- .../agentSession.pendingOwnership.test.ts | 82 +++- ...tSession.postCompactionAttachments.test.ts | 198 +++++++- .../agentSession.postCompactionRetry.test.ts | 446 +----------------- src/node/services/agentSession.ts | 318 +++++-------- .../agentSkills/pathSkillActivation.ts | 17 +- src/node/services/branchSummary.test.ts | 30 -- src/node/services/branchSummary.ts | 16 +- src/node/services/compactionHandler.test.ts | 30 +- src/node/services/compactionHandler.ts | 6 +- src/node/services/continuousCompactor.test.ts | 25 - src/node/services/continuousCompactor.ts | 8 +- src/node/services/historyService.ts | 26 - src/node/services/messagePipeline.test.ts | 49 -- src/node/services/messagePipeline.ts | 32 +- .../services/mock/mockAiStreamPlayer.test.ts | 43 -- src/node/services/mock/mockAiStreamPlayer.ts | 33 +- .../services/taskService.childGoals.test.ts | 41 +- src/node/services/taskService.ts | 4 +- .../services/turnContextAssembler.test.ts | 27 -- src/node/services/turnContextAssembler.ts | 25 +- src/node/services/turnRequestBuilder.ts | 13 +- .../services/workspaceGoalService.test.ts | 9 - src/node/services/workspaceGoalService.ts | 4 +- .../services/workspaceService.titles.test.ts | 31 -- src/node/services/workspaceService.ts | 3 - ...workspaceTurnManager.historyRepair.test.ts | 13 - src/node/services/workspaceTurnManager.ts | 7 +- 36 files changed, 573 insertions(+), 1300 deletions(-) diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index 25740216d26..4655316cbc2 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -1,6 +1,5 @@ import type { MCPToolCallDisplay } from "@/common/types/mcp"; import { describe, test, expect } from "bun:test"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { MuxMessageSchema } from "@/common/orpc/schemas/message"; import { createMuxMessage, type DisplayedMessage } from "@/common/types/message"; @@ -3446,8 +3445,7 @@ describe("StreamingMessageAggregator", () => { expect(aggregator.isCompacting()).toBe(false); }); - // The backend stores the post-compaction attachments row after the follow-up row. - test.each([false, true])("mutes default Continue resumes (attachments row: %p)", (withRow) => { + test("suppresses response notifications for default post-compaction Continue resumes", () => { const workspaceId = "test-workspace-default-continue-notify"; const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT, workspaceId); let completion: Parameters[0]; @@ -3480,17 +3478,6 @@ describe("StreamingMessageAggregator", () => { }), type: "message", }); - if (withRow) { - aggregator.handleMessage({ - ...createMuxMessage( - `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - "user", - "plan", - { historySequence: 3, timestamp: Date.now(), synthetic: true } - ), - type: "message", - }); - } aggregator.handleStreamStart({ type: "stream-start", @@ -5478,49 +5465,40 @@ describe("StreamingMessageAggregator in-turn hidden notice rows", () => { "\nNote: a.ts was modified\n", { historySequence: 2, timestamp: Date.now(), synthetic: true } ); - const postCompactionAttachments = createMuxMessage( - "post-compaction-1700000000000", - "user", - "\nPlan file:\n...\n", - { historySequence: 3, timestamp: Date.now(), synthetic: true } - ); const continueSentinel = createMuxMessage("user-continue", "user", "[CONTINUE]", { - historySequence: 4, + historySequence: 3, timestamp: Date.now(), synthetic: true, }); test("a notice row during PREPARING keeps the pending turn's compaction, model and status", () => { // Rows as the client receives them: oRPC strips keys the wire schema does not know. - const [request, notice, attachments, sentinel] = [ - compactionRequest, - fileChangeNotice, - postCompactionAttachments, - continueSentinel, - ].map((row) => MuxMessageSchema.parse(row) as typeof row); + const [request, notice, sentinel] = [compactionRequest, fileChangeNotice, continueSentinel].map( + (row) => MuxMessageSchema.parse(row) as typeof row + ); const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); aggregator.handleMessage({ ...request, type: "message" }); const pendingStart = aggregator.getPendingStreamStartTime(); expect(pendingStart).not.toBeNull(); aggregator.handleMessage({ ...notice, type: "message" }); - aggregator.handleMessage({ ...attachments, type: "message" }); aggregator.handleMessage({ ...sentinel, type: "message" }); expect(aggregator.getPendingStreamModel()).toBe(model); expect(aggregator.getAgentStatus()?.message).toBe("Compacting"); expect(aggregator.getPendingStreamStartTime()).toBe(pendingStart); // Hidden in the transcript, but kept as edit evidence that matches the persisted rows. - const hiddenIds = [fileChangeNotice.id, postCompactionAttachments.id]; - expect(aggregator.getDisplayedMessages().some((row) => hiddenIds.includes(row.id))).toBe(false); + expect(aggregator.getDisplayedMessages().some((row) => row.id === fileChangeNotice.id)).toBe( + false + ); expect( buildHistoryEditPrecondition(aggregator.getHistoryEvidenceMessages(), request.id) - ).toEqual(buildHistoryEditPrecondition([request, notice, attachments, sentinel], request.id)); + ).toEqual(buildHistoryEditPrecondition([request, notice, sentinel], request.id)); aggregator.handleStreamStart({ type: "stream-start", workspaceId: "test-workspace", messageId: "compact-stream", - historySequence: 5, + historySequence: 4, model, startTime: Date.now(), mode: "compact", diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index b94caf541ea..385ad0088c3 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -1,5 +1,4 @@ import { restoreContextBudgetRejectedMessageForDisplay } from "@/common/utils/messages/contextBudgetRejection"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import type { MuxMessage, MuxMetadata, @@ -12,7 +11,6 @@ import { getMcpPromptReferenceKey, isCompactionSummaryMetadata, isContextBudgetWarningMessage, - isPostCompactionAttachmentsMessage, sanitizeAgentSkillRefs, sanitizeMcpPromptRefs, } from "@/common/types/message"; @@ -120,19 +118,13 @@ function isTranscriptVisibleUserRow(message: MuxMessage): boolean { /** * Hidden notices AgentSession.streamWithHistory persists INSIDE a turn that already started: the - * `` row (createFileChangeNotificationMessage, `file-change-` id), the - * post-compaction attachments row (createPostCompactionAttachmentsMessage, `post-compaction-` id) - * and the `[CONTINUE]` sentinel for a trailing completed assistant. The backend publishes them so the + * `` row (createFileChangeNotificationMessage, `file-change-` id) and the + * `[CONTINUE]` sentinel for a trailing completed assistant. The backend publishes them so the * client holds the same rows as history (an edit fences its range with them), but they arrive * after the turn's real user row and must not restart that turn's pending state. Matched by the * exact markers the backend writes, not by `synthetic` alone: hidden synthetic rows such as * context-budget continuations do start turns. */ -const IN_TURN_NOTICE_PREFIXES = [ - { id: "file-change-", text: "" }, - { id: POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX, text: "" }, -] as const; - function isInTurnHiddenNoticeRow(message: MuxMessage): boolean { const metadata = message.metadata; if ( @@ -146,12 +138,9 @@ function isInTurnHiddenNoticeRow(message: MuxMessage): boolean { } const part = message.parts[0]; if (part.type !== "text") return false; - const text = part.text; return ( - text === "[CONTINUE]" || - IN_TURN_NOTICE_PREFIXES.some( - (prefix) => message.id.startsWith(prefix.id) && text.startsWith(prefix.text) - ) + part.text === "[CONTINUE]" || + (message.id.startsWith("file-change-") && part.text.startsWith("")) ); } @@ -180,12 +169,6 @@ function startsUserTurn(message: MuxMessage): boolean { ); } -// A hidden plan-review record can sit between the compaction summary and its follow-up row, and -// the post-compaction attachments row lands after the follow-up row. -function isPostCompactionHiddenRow(message: MuxMessage): boolean { - return isPlanReviewRecordMessage(message) || isPostCompactionAttachmentsMessage(message); -} - // Maximum number of messages to display in the DOM for performance // Full history is still maintained internally for token counting and stats const AgentStatusSchema = z.object({ @@ -1870,7 +1853,8 @@ export class StreamingMessageAggregator { } private isDefaultPostCompactionContinueTurn(): boolean { - const messages = this.getAllMessages().filter((message) => !isPostCompactionHiddenRow(message)); + // A hidden plan-review record can sit between the summary and its follow-up row. + const messages = this.getAllMessages().filter((message) => !isPlanReviewRecordMessage(message)); const latestMessage = messages.at(-1); const previousMessage = messages.at(-2); if (latestMessage?.role !== "user" || previousMessage?.role !== "assistant") { diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index a1da9008b68..32acfa65dfa 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -9,10 +9,7 @@ import { injectPostCompactionAttachments, stripOrphanedToolCalls, } from "./modelMessageTransform"; -import { - MAX_POST_COMPACTION_INJECTION_CHARS, - POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX, -} from "@/common/constants/attachments"; +import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; import type { MuxMessage } from "@/common/types/message"; describe("modelMessageTransform", () => { @@ -1391,9 +1388,20 @@ describe("injectAgentTransition", () => { expect(result[3]).toEqual(messages[2]); // Last user message shifted }); - it.each<[string, MuxMessage]>([ - [ - "Token Budget warning", + it("injects the transition before the user prompt, not before a trailing Token Budget warning", () => { + const messages: MuxMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Here's the plan..." }], + metadata: { timestamp: 1000, agentId: "plan" }, + }, + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Now execute it" }], + metadata: { timestamp: 2000 }, + }, { id: "warning-1", role: "user", @@ -1410,46 +1418,18 @@ describe("injectAgentTransition", () => { }, }, }, - ], - [ - "post-compaction attachments row", - { - id: `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - role: "user", - parts: [{ type: "text", text: "plan" }], - metadata: { timestamp: 3000, synthetic: true }, - }, - ], - ])( - "injects the transition before the user prompt, not before a trailing %s", - (_label, notice) => { - const messages: MuxMessage[] = [ - { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text: "Here's the plan..." }], - metadata: { timestamp: 1000, agentId: "plan" }, - }, - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Now execute it" }], - metadata: { timestamp: 2000 }, - }, - notice, - ]; + ]; - const result = injectAgentTransition(messages, "exec"); + const result = injectAgentTransition(messages, "exec"); - expect(result.map((message) => message.id)).toEqual([ - "assistant-1", - result[1].id, - "user-1", - notice.id, - ]); - expect(result[1].metadata?.synthetic).toBe(true); - } - ); + expect(result.map((message) => message.id)).toEqual([ + "assistant-1", + result[1].id, + "user-1", + "warning-1", + ]); + expect(result[1].metadata?.synthetic).toBe(true); + }); it("should not inject transition when agent is the same", () => { const messages: MuxMessage[] = [ diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index beb0b30acec..3afbaa3b4f2 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -5,17 +5,12 @@ import type { ModelMessage, AssistantModelMessage, ToolModelMessage, UserModelMessage } from "ai"; import { - isAppendedTurnNoticeMessage, isContextBudgetWarningMessage, - isPostCompactionAttachmentsMessage, type MuxMessage, type MuxReasoningPart, } from "@/common/types/message"; import type { PostCompactionAttachment } from "@/common/types/attachment"; -import { - MAX_POST_COMPACTION_INJECTION_CHARS, - POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX, -} from "@/common/constants/attachments"; +import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; import { hasProviderReplayableContent } from "@/common/utils/messages/providerEligibility"; import { renderAttachmentsToContentWithBudget } from "./attachmentRenderer"; @@ -145,7 +140,7 @@ export function injectAgentTransition( // 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" && !isAppendedTurnNoticeMessage(messages[i])) { + if (messages[i].role === "user" && !isContextBudgetWarningMessage(messages[i])) { lastUserIndex = i; break; } @@ -213,72 +208,25 @@ ${planContent} return result; } -// NOTE: File-change notifications and post-compaction attachments are no longer -// injected at request time for live turns. AgentSession appends both rows durably -// to chat.jsonl at turn start (see createFileChangeNotificationMessage in -// fileChangeTracker.ts and createPostCompactionAttachmentsMessage below), keeping -// the provider request a pure function of the session log. A request-only row -// changed the cached prefix on the turn it appeared and again on the next turn, -// when it was gone, and that change dropped the replayed thinking blocks. - -/** - * Build the synthetic user row that carries post-compaction attachments (plan file, - * loaded skills, edited files, report index) to preserve context lost to compaction. - * Returns null when nothing renders. - */ -export function createPostCompactionAttachmentsMessage( - attachments: PostCompactionAttachment[], - timestamp: number -): MuxMessage | null { - const text = renderAttachmentsToContentWithBudget(attachments, { - maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, - }); - if (text.trim().length === 0) { - return null; - } - return { - id: `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}${Date.now()}`, - role: "user", - parts: [{ type: "text", text }], - metadata: { timestamp, synthetic: true }, - }; -} - -/** - * Move each durable post-compaction attachments row before the run of user rows it follows. - * AgentSession appends the row after the turn's prompt, but the attachments carry repository - * bytes (plan, skills, diffs) that must not be the last text the model reads: the operator's - * request comes last, as it did when the row was injected per request. The new position - * depends only on earlier rows, so later turns replay the same prefix. - */ -export function placePostCompactionAttachmentsBeforePrompt(messages: MuxMessage[]): MuxMessage[] { - if (!messages.some(isPostCompactionAttachmentsMessage)) { - return messages; - } - const result: MuxMessage[] = []; - for (const message of messages) { - if (!isPostCompactionAttachmentsMessage(message)) { - result.push(message); - continue; - } - let index = result.length; - while (index > 0 && result[index - 1].role === "user") { - index--; - } - result.splice(index, 0, message); - } - return result; -} +// NOTE: File-change notifications are no longer injected at request time. +// AgentSession appends the row durably to chat.jsonl at +// 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. Live turns persist the - * row instead (see the NOTE above); this path rebuilds older recorded request envelopes and - * swaps the prefix of an in-flight stream after mid-stream compaction. + * 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, so every row before the - * current turn's request stays byte-identical. The model sees the attachments, then the - * user's request (provider transforms merge the two user messages, attachments first). + * 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) @@ -296,13 +244,22 @@ export function injectPostCompactionAttachments( (message) => message.role === "user" && !isContextBudgetWarningMessage(message) ); const index = newestUserIndex === -1 ? messages.length : newestUserIndex; - const syntheticMessage = createPostCompactionAttachmentsMessage( - attachments, - messages[index]?.metadata?.timestamp ?? Date.now() - ); - if (syntheticMessage == null) { - return messages; - } + 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)]; } diff --git a/src/common/constants/attachments.ts b/src/common/constants/attachments.ts index 9bde6dc0a76..3714956e37f 100644 --- a/src/common/constants/attachments.ts +++ b/src/common/constants/attachments.ts @@ -2,11 +2,8 @@ * Constants for the post-compaction attachment system. */ -/** - * ID prefix of the durable post-compaction attachments row in chat.jsonl. - * Tail copies drop these rows, and the transcript hides them. - */ -export const POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX = "post-compaction-"; +/** 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/types/message.ts b/src/common/types/message.ts index 58bc4a3b298..31627743044 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -21,7 +21,6 @@ import type { ThinkingLevel } from "./thinking"; import type { AutoModelRoutingRecord } from "./autoModelRouting"; import { type ReviewNoteData, formatReviewForModel } from "./review"; import { isMcpPromptCommandKey } from "@/common/utils/tools/mcpPromptCommandKey"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import type { PlanReviewRecordKind } from "@/common/utils/planReview/planReviewRecord"; import { isPlanReviewRecordMessage } from "@/common/utils/planReview/planReviewEnvelope"; @@ -904,22 +903,6 @@ export function isContextBudgetWarningMessage(message: MuxMessage): boolean { return message.metadata?.muxMetadata?.type === "context-budget-warning"; } -/** The post-compaction attachments row is context sent with a turn, never its request. */ -export function isPostCompactionAttachmentsMessage(message: MuxMessage): boolean { - return ( - message.metadata?.synthetic === true && - message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) - ); -} - -/** - * A synthetic user row appended after a turn's prompt: a Token Budget warning or the - * post-compaction attachments row. It is a notice or context, never a newer prompt. - */ -export function isAppendedTurnNoticeMessage(message: MuxMessage): boolean { - return isContextBudgetWarningMessage(message) || isPostCompactionAttachmentsMessage(message); -} - export function isRolloverBoundary(message: MuxMessage): boolean { return ( message.metadata?.contextBoundaryKind === "reset" && diff --git a/src/common/utils/compaction/rollingCut.test.ts b/src/common/utils/compaction/rollingCut.test.ts index f5504bc17dc..3012141b033 100644 --- a/src/common/utils/compaction/rollingCut.test.ts +++ b/src/common/utils/compaction/rollingCut.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { selectRollingCut, type RollingCutBudget } from "./rollingCut"; @@ -100,18 +99,6 @@ describe("selectRollingCut", () => { expect(cut?.tail).toEqual(rows.slice(4)); }); - test("duplicates the prompt, not the post-compaction attachments row after it", () => { - const attachments: MuxMessage = { - ...message(`${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, "user", 100), - metadata: { synthetic: true }, - }; - const rows = [...oldHead(), message("user", "user", 100), attachments, steppedAnswer(18_000)]; - const cut = selectRollingCut(rows, null, budget); - expect(cut?.stepCut?.partIndex).toBe(1); - expect(cut?.tail.slice(0, 1)).toEqual(rows.slice(2, 3)); - expect(cut?.tail).not.toContain(attachments); - }); - test("splits consecutive tool-only steps only at recorded boundaries and duplicates the user cluster", () => { const answer = steppedAnswer(18_000); const rows = [...oldHead(), snapshot("file"), message("user", "user", 100), answer]; diff --git a/src/common/utils/compaction/rollingCut.ts b/src/common/utils/compaction/rollingCut.ts index 95072cdd601..ec2bbdb2f67 100644 --- a/src/common/utils/compaction/rollingCut.ts +++ b/src/common/utils/compaction/rollingCut.ts @@ -1,8 +1,4 @@ -import { - isPostCompactionAttachmentsMessage, - isSyntheticSnapshotUserMessage, - type MuxMessage, -} from "@/common/types/message"; +import { isSyntheticSnapshotUserMessage, type MuxMessage } from "@/common/types/message"; import assert from "@/common/utils/assert"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { @@ -33,24 +29,6 @@ export interface RollingCut { stepCut?: { messageId: string; partIndex: number; firstTailToolCallId?: string }; } -// A synthetic snapshot joins the prompt after it, and the post-compaction attachments row follows -// its turn's prompt: neither starts a turn. -function startsTurn(row: MuxMessage): boolean { - return ( - row.role === "user" && - !isSyntheticSnapshotUserMessage(row) && - !isPostCompactionAttachmentsMessage(row) - ); -} - -/** - * The boundary drops the old window's post-compaction attachments row (the next turn writes a - * fresh one), and the attachment budget already counts that fresh row. - */ -export function estimateRetainedRowTokens(row: MuxMessage): number { - return isPostCompactionAttachmentsMessage(row) ? 0 : estimateMuxMessageTokens(row); -} - function exactStepStarts(indices: readonly number[] | undefined, partCount: number): number[] { // Old or malformed metadata is not permission to guess boundaries from tool shapes. const isArray: boolean = Array.isArray(indices); @@ -120,7 +98,7 @@ export function selectRollingCut( const minHead = Math.min(MIN_HEAD_TOKENS, 0.1 * budget.contextWindowTokens); const prefixTokens = [0]; for (const row of rows) { - prefixTokens.push(prefixTokens[prefixTokens.length - 1] + estimateRetainedRowTokens(row)); + prefixTokens.push(prefixTokens[prefixTokens.length - 1] + estimateMuxMessageTokens(row)); } const totalTokens = prefixTokens[rows.length]; let best: RollingCut | null = null; @@ -140,7 +118,7 @@ export function selectRollingCut( for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { const row = rows[rowIndex]; - if (startsTurn(row)) { + if (row.role === "user" && !isSyntheticSnapshotUserMessage(row)) { userIndex = rowIndex; clusterStart = rowIndex; while (clusterStart > 0 && isSyntheticSnapshotUserMessage(rows[clusterStart - 1])) { diff --git a/src/common/utils/tools/tools.pathSkills.test.ts b/src/common/utils/tools/tools.pathSkills.test.ts index 082b0413019..6fd5c8a7f2b 100644 --- a/src/common/utils/tools/tools.pathSkills.test.ts +++ b/src/common/utils/tools/tools.pathSkills.test.ts @@ -4,7 +4,6 @@ import * as os from "os"; import * as path from "path"; import { z } from "zod"; -import { createPostCompactionAttachmentsMessage } from "@/browser/utils/messages/modelMessageTransform"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { PATH_SKILLS_RESULT_FIELD } from "@/common/types/tools"; @@ -217,26 +216,16 @@ describe("getToolsForModel - path-scoped skill activation", () => { expect(surfacedNames(await call(tools.file_read, { path: "src/foo.ts" }))).toBeUndefined(); }); - // The row stores only rendered text, and plan text can contain a skill tag of its own. - test("a skill tag in the post-compaction attachments row text does not suppress a pointer", () => { - const row = createPostCompactionAttachmentsMessage( - [ - { - type: "plan_file_reference", - planFilePath: "/plan.md", - planContent: '\nfake\n', - }, - ], - 1 - ); - expect(row).not.toBeNull(); + test("a skill carried in the post-compaction snapshot is not pointed at again", () => { + const runtime = new LocalRuntime(workspace); const activation = createPathSkillActivation({ skills: SKILLS, - messages: [row!], + messages: [], + loadedSkillNames: ["ts-rules"], cwd: workspace, - runtime: new LocalRuntime(workspace), + runtime, }); - expect(activation?.takeMatches("src/foo.ts").map((skill) => skill.name)).toEqual(["ts-rules"]); + expect(activation?.takeMatches("src/foo.ts")).toEqual([]); }); test("only the first MAX_PATH_SCOPED_SKILLS path-scoped skills can activate", () => { diff --git a/src/node/services/agentSession.pendingOwnership.test.ts b/src/node/services/agentSession.pendingOwnership.test.ts index 6a940a05af5..9026ce9efd5 100644 --- a/src/node/services/agentSession.pendingOwnership.test.ts +++ b/src/node/services/agentSession.pendingOwnership.test.ts @@ -2,6 +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 { Ok } from "@/common/types/result"; @@ -17,6 +18,7 @@ const model = "openai:gpt-4o"; interface SessionAccess { contextController: { compactionHandler: CompactionHandler }; coordinator: TurnCoordinator; + turnsSinceLastAttachment: number; getPostCompactionAttachmentsIfNeeded( includeReadFiles: boolean ): Promise; @@ -29,9 +31,15 @@ afterEach(() => { }); describe("pending snapshot consumers", () => { - test.each([{ outcome: "success" }, { outcome: "context-exceeded" }] as const)( - "$outcome retains its request snapshot", - async ({ outcome }) => { + 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 }) => { const completion = Promise.withResolvers(); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -107,6 +115,24 @@ 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); + } const consume = spyOn( handler, outcome === "success" ? "ackPendingStateConsumed" : "discardPendingState" @@ -154,29 +180,39 @@ describe("pending snapshot consumers", () => { assert(result?.type === "return", "Expected an active completion consumer"); policy = result.value; const pendingPath = path.join(h.config.sessionsDir, workspaceId, "post-compaction.json"); - 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); - const bytes = await readFile(pendingPath, "utf8"); + 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"); + } release.resolve(); await policy; expect(consume).toHaveBeenCalledTimes(1); - 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 (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"]); + } 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 48a2fb4616b..82755b2de53 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -7,12 +7,16 @@ import type { PostCompactionAttachment, TodoListAttachment, } from "@/common/types/attachment"; -import { createMuxMessage } from "@/common/types/message"; +import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { Err } from "@/common/types/result"; +import { POST_COMPACTION_STATE_FILENAME } from "@/constants/compaction"; import type { Config } from "@/node/config"; import type { AgentSession } from "./agentSession"; import { createAgentSessionHarness, createTestStreamManager } from "./agentSession.testHarness"; +import type { CompactionHandler } from "./compactionHandler"; +import { CompactionPendingState } from "./compactionPendingState"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; import { createLoadedSkillSnapshot } from "@/node/services/agentSkills/loadedSkillSnapshots"; @@ -23,6 +27,26 @@ import { makeTestEffectRunner } from "./di/testEffectRunner"; import type { EffectRunner } from "./di/effectRunner"; import { nodeSessionLocks } from "@/node/services/sessionLocks/nodeSessionLocks"; +function createSuccessfulFileEditMessage(id: string, filePath: string, diff: string): MuxMessage { + return { + id, + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: `tool-${id}`, + toolName: "file_edit_replace_string", + state: "output-available", + input: { path: filePath }, + output: { success: true, diff }, + }, + ], + metadata: { + timestamp: Date.now(), + }, + }; +} + function createLoadedSkillFixture(args: { name: string; body: string; @@ -103,6 +127,9 @@ async function createSessionForHistory( } interface PrivateSessionAccess { + compactionOccurred: boolean; + turnsSinceLastAttachment: number; + contextController: { compactionHandler: CompactionHandler }; getPostCompactionAttachmentsIfNeeded: () => Promise; } @@ -115,6 +142,20 @@ async function getImmediatePostCompactionAttachments( return attachments ?? []; } +async function generatePeriodicPostCompactionAttachments( + session: AgentSession +): Promise { + 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 ?? []; +} + async function writePendingPostCompactionState(args: { sessionDir: string; diffs: Array<{ path: string; diff: string; truncated: boolean }>; @@ -190,8 +231,11 @@ describe("AgentSession post-compaction attachments", () => { expect((await historyService.clearHistory(WORKSPACE_ID)).success).toBe(true); await session.clearPostCompactionState(); - // ...no later turn may re-inject pre-boundary paths. - expect(await privateSession.getPostCompactionAttachmentsIfNeeded(true)).toBeNull(); + // ...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, @@ -213,6 +257,86 @@ describe("AgentSession post-compaction attachments", () => { } }); + test("extracts edited file diffs from the latest durable compaction boundary slice", async () => { + const { historyService, config, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const history: MuxMessage[] = [ + createSuccessfulFileEditMessage( + "stale-before-boundary", + "/tmp/stale-before-boundary.ts", + "@@ -1 +1 @@\n-old\n+older\n" + ), + createMuxMessage("boundary-1", "assistant", "epoch 1 summary", { + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, + }), + createSuccessfulFileEditMessage( + "stale-epoch-1", + "/tmp/stale-epoch-1.ts", + "@@ -1 +1 @@\n-old\n+stale\n" + ), + createMuxMessage("boundary-2", "assistant", "epoch 2 summary", { + compacted: "user", + compactionBoundary: true, + compactionEpoch: 2, + }), + createSuccessfulFileEditMessage( + "recent-epoch-2", + "/tmp/recent-epoch-2.ts", + "@@ -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); + expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent-epoch-2.ts"]); + } finally { + await session.dispose(); + } + }); + + test("falls back safely when boundary markers are malformed", async () => { + const { historyService, config, cleanup } = await createTestHistoryService(); + historyCleanup = cleanup; + + const history: MuxMessage[] = [ + createSuccessfulFileEditMessage("stale-edit", "/tmp/stale.ts", "@@ -1 +1 @@\n-old\n+stale\n"), + createMuxMessage("malformed-boundary", "assistant", "malformed summary", { + compacted: "user", + compactionBoundary: true, + // Missing compactionEpoch: marker should be ignored without crashing. + }), + createSuccessfulFileEditMessage( + "recent-edit", + "/tmp/recent.ts", + "@@ -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); + expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent.ts", "/tmp/stale.ts"]); + } finally { + await session.dispose(); + } + }); + test("indexes a report completed before the compaction while the session runs on a TestClock", async () => { // TaskService stamps report completion with Date.now(), so the cutoff must read the wall // clock too (#7282). On the TestClock (months behind wall time) the report would look newer @@ -289,6 +413,74 @@ describe("AgentSession post-compaction attachments", () => { } }); + test("reinjects cached loaded skills on later turns even after pending state is acknowledged", 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.", + }); + + 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) + ); + expect( + ( + await pending.publishBoundary({ + summaryMessage: history[0], + tailCopies: history.slice(1), + updateExisting: false, + attachments: { diffs: [], loadedSkills: [loadedSkill], readFiles: [] }, + publication: { + generation: await historyService + .getContinuousCompactionJournal(workspaceId) + .captureGeneration(), + }, + isCurrent: () => true, + shouldPersist: () => true, + onCommitted: () => undefined, + }) + ).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"]); + expect(getLoadedSkillAttachment(attachments)?.skills[0]?.body).toContain( + "Persist this guardrail across follow-up turns." + ); + expect(getEditedFilePaths(attachments)).toEqual(["/tmp/recent-periodic.ts"]); + } finally { + await session.dispose(); + } + }); + test("suppresses only loaded skills when the skills exclusion is enabled", async () => { const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index a05c5457eb0..fc939896fd3 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -3,16 +3,11 @@ import { EventEmitter } from "events"; import * as fsPromises from "fs/promises"; import * as path from "path"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { Err } from "@/common/types/result"; -import assert from "@/common/utils/assert"; -import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import type { MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; import { createTestHistoryService } from "./testHistoryService"; -import { AttachmentService } from "./attachmentService"; -import type { CompactionHandler } from "./compactionHandler"; -import type { TurnStreamHandle } from "./turnEngine/types"; import { createAgentSessionHarness, createFailedTurnHandle, @@ -42,47 +37,6 @@ function createPersistedPostCompactionState(options: { return fsPromises.writeFile(options.filePath, JSON.stringify(payload)); } -/** Publish a continuous compaction boundary for a window that edited `/.ts`. */ -async function publishEditWindow( - h: Awaited>, - workspaceId: string, - id: string, - model: string -): Promise { - const handler = ( - h.session as unknown as { contextController: { compactionHandler: CompactionHandler } } - ).contextController.compactionHandler; - const edit = createMuxMessage(id, "assistant", ""); - edit.parts = [ - { - type: "dynamic-tool", - toolCallId: id, - toolName: "file_edit_replace_string", - state: "output-available", - input: { path: `/${id}.ts` }, - output: { success: true, diff: `change ${id}` }, - }, - ]; - const published = await handler.persistContinuousCompaction({ - preparation: handler.beginPreparation(() => true), - publication: { - generation: await h.historyService - .getContinuousCompactionJournal(workspaceId) - .captureGeneration(), - }, - attachmentMessages: [edit], - messages: [edit], - boundaryMessageId: id, - text: `${id} summary`, - model, - tail: [], - systemMessageTokens: 0, - attachmentTokens: 0, - shouldPersist: () => true, - }); - expect(published).toBe(true); -} - describe("AgentSession post-compaction context retry", () => { let historyCleanup: (() => Promise) | undefined; afterEach(async () => { @@ -199,18 +153,18 @@ describe("AgentSession post-compaction context retry", () => { "retry-started" ); - // The first request carried the durable attachments row; the retry runs without it. - const hasAttachmentsRow = (messages: MuxMessage[]) => - messages.some((message) => - message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) - ); - const requestMessages = (call: number) => - ((streamMessage as ReturnType).mock.calls[call][0] as { messages: MuxMessage[] }) - .messages; - expect(hasAttachmentsRow(requestMessages(0))).toBe(true); - expect(hasAttachmentsRow(requestMessages(1))).toBe(false); - const historyAfterRetry = await historyService.getHistoryFromLatestBoundary(workspaceId); - expect(historyAfterRetry.success && hasAttachmentsRow(historyAfterRetry.data)).toBe(false); + // 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); + + const secondOpts = (streamMessage as ReturnType).mock.calls[1][0] as Record< + string, + unknown + >; + expect(secondOpts.postCompactionAttachments).toBeNull(); expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( "assistant-ctx-exceeded" @@ -228,72 +182,6 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); - // The retry must not discard the snapshot while its row stays in history: no later turn - // could then find the row to reuse or delete, and every request would carry it. - test("a failed row delete keeps the pending snapshot and skips the retry", async () => { - const workspaceId = "ws-delete-fails"; - const { historyService, config, sessionDir } = await createSessionsFixture(workspaceId); - const postCompactionPath = path.join(sessionDir, "post-compaction.json"); - await createPersistedPostCompactionState({ - filePath: postCompactionPath, - diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], - }); - await historyService.appendToHistory(workspaceId, { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Continue" }], - metadata: { timestamp: 1100 }, - }); - const isRow = (id: string) => id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX); - const deleteMessage = historyService.deleteMessage.bind(historyService); - spyOn(historyService, "deleteMessage").mockImplementation((ws, id) => - isRow(id) ? Promise.resolve(Err("disk full")) : deleteMessage(ws, id) - ); - const deleteUnderAdmission = historyService.deleteMessageUnderAdmission.bind(historyService); - spyOn(historyService, "deleteMessageUnderAdmission").mockImplementation((ws, capture, id) => - isRow(id) ? Promise.resolve(Err("disk full")) : deleteUnderAdmission(ws, capture, id) - ); - - 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); - expect( - await Promise.race([ - session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded"), - new Promise((_, reject) => setTimeout(() => reject(new Error("decision timeout")), 1000)), - ]) - ).toBe("terminal"); - - expect(streamMessage).toHaveBeenCalledTimes(1); - const history = await historyService.getHistoryFromLatestBoundary(workspaceId); - expect(history.success && history.data.some((message) => isRow(message.id))).toBe(true); - expect(await fsPromises.stat(postCompactionPath).then(() => true)).toBe(true); - - await session.dispose(); - }); - // Waiters (task/workspace-turn stream-error settlement) treat the resolved // decision as "the retry outcome is known". Resolving while retry startup is // still in flight would let a pre-stream startup failure (no further error @@ -495,312 +383,4 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); - - // The row is durable: a turn that fails before the ack must not append a second copy. - test("a failed turn keeps its attachments row and the next turn reuses it", async () => { - const workspaceId = "ws-reuse"; - const { historyService, config, sessionDir } = await createSessionsFixture(workspaceId); - await createPersistedPostCompactionState({ - filePath: path.join(sessionDir, "post-compaction.json"), - diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], - }); - await historyService.appendToHistory(workspaceId, { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Continue" }], - metadata: { timestamp: 1100 }, - }); - - let callCount = 0; - const streamMessage = mock((..._args: unknown[]) => { - callCount += 1; - if (callCount === 1) { - return Promise.resolve({ - success: false as const, - error: { type: "unknown" as const, raw: "startup failed before stream" }, - }); - } - return Promise.resolve({ - success: true as const, - data: createStartedTurnHandle(session.closingSignal, "assistant-next"), - }); - }); - const { session } = await createAgentSessionHarness({ - workspaceId, - config, - historyService, - aiServiceOverrides: { - streamMessage, - getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), - }, - }); - const options = { model: "openai:gpt-4o", agentId: "exec" } as unknown as SendMessageOptions; - - await session.resumeStream(options); - // The manual Retry resumes the request past the failed turn's attachments row. - expect(await session.resumeStream(options)).toEqual({ success: true, data: { started: true } }); - - expect(callCount).toBe(2); - const attachmentRowIds = (messages: MuxMessage[]) => - messages - .filter((message) => message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX)) - .map((message) => message.id); - const requestRowIds = (call: number) => - attachmentRowIds( - ( - (streamMessage as ReturnType).mock.calls[call][0] as { - messages: MuxMessage[]; - } - ).messages - ); - const firstRowIds = requestRowIds(0); - expect(firstRowIds).toHaveLength(1); - expect(requestRowIds(1)).toEqual(firstRowIds); - const history = await historyService.getHistoryFromLatestBoundary(workspaceId); - expect(history.success && attachmentRowIds(history.data)).toEqual(firstRowIds); - - await session.dispose(); - }); - - // A publication that lands while a turn builds its row must not leave that row in the new - // window: the next turn would reuse it and send the older snapshot's attachments. - test("a publication while a turn builds its row skips the row, so the next turn sends the new snapshot", async () => { - const workspaceId = "ws-publication-race"; - const model = "openai:gpt-4o"; - const firstCompletion = Promise.withResolvers>(); - const settled = Promise.withResolvers(); - const streamMessage = mock((..._args: unknown[]) => - Promise.resolve({ - success: true as const, - data: - streamMessage.mock.calls.length === 1 - ? { messageId: "assistant-first", completion: firstCompletion.promise } - : createStartedTurnHandle(h.session.closingSignal, "assistant-next"), - }) - ); - const h = await createAgentSessionHarness({ - workspaceId, - onTurnSettled: () => settled.resolve(), - aiServiceOverrides: { streamMessage }, - }); - const publish = (id: string) => publishEditWindow(h, workspaceId, id, model); - // Publication B lands after the first turn read snapshot A and before it appends A's row. - let publishedB = false; - const reports = spyOn(AttachmentService, "generateCompletedReportsAttachment"); - reports.mockImplementation(async () => { - if (!publishedB) { - publishedB = true; - await publish("b"); - } - return null; - }); - const attachmentRows = (messages: MuxMessage[]) => - messages.filter((message) => - message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) - ); - const sentRows = (call: number) => - attachmentRows((streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }).messages); - const started = { success: true as const, data: { started: true } }; - - try { - await publish("a"); - await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-1", "user", "go")); - - expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); - expect(publishedB).toBe(true); - expect(sentRows(0)).toHaveLength(0); - const history = await h.historyService.getHistoryFromLatestBoundary(workspaceId); - expect(history.success && attachmentRows(history.data)).toEqual([]); - - firstCompletion.resolve({ status: "aborted", abortReason: "user" }); - await settled.promise; - expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); - const sent = sentRows(1); - expect(sent).toHaveLength(1); - expect(JSON.stringify(sent[0].parts)).toContain("/b.ts"); - } finally { - reports.mockRestore(); - firstCompletion.resolve({ status: "aborted", abortReason: "user" }); - await h.cleanup(); - } - }); - - /** - * Run a first turn after a compaction (it sends and acknowledges the attachments row), then - * edit `editMessageId`. History: user-0, compaction boundary, user-1, row, reply, and with - * `laterWindow` one more compaction boundary. - * @returns the attachments row IDs that the first turn and the edit turn sent - */ - async function editAfterAttachmentsTurn( - workspaceId: string, - editMessageId: string, - laterWindow = false - ) { - const model = "openai:gpt-4o"; - const firstCompletion = Promise.withResolvers>(); - const streamMessage = mock((..._args: unknown[]) => - Promise.resolve({ - success: true as const, - data: - streamMessage.mock.calls.length === 1 - ? { messageId: "assistant-first", completion: firstCompletion.promise } - : createStartedTurnHandle(h.session.closingSignal, "assistant-edit"), - }) - ); - const h = await createAgentSessionHarness({ - workspaceId, - aiServiceOverrides: { streamMessage }, - }); - const consumer = spyOn( - (h.session as unknown as { coordinator: { consumeCompletion: () => Promise } }) - .coordinator, - "consumeCompletion" - ); - const sentRowIds = (call: number) => - (streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }).messages - .filter((message) => message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX)) - .map((message) => message.id); - - try { - await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-0", "user", "hi")); - await publishEditWindow(h, workspaceId, "a", model); - await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-1", "user", "go")); - await h.session.resumeStream({ model, agentId: "exec" }); - const policy = consumer.mock.results.at(-1); - assert(policy?.type === "return", "Expected the first turn's completion consumer"); - firstCompletion.resolve({ - status: "completed", - streamEnd: { - type: "stream-end", - workspaceId, - metadata: { model }, - parts: [{ type: "text", text: "done" }], - }, - }); - await policy.value; - if (laterWindow) { - await publishEditWindow(h, workspaceId, "b", model); - } - - const edited = await h.session.sendMessage("edited", { - model, - agentId: "exec", - editMessageId, - }); - expect(edited.success).toBe(true); - // An edit starts its stream in the background. - for (let i = 0; i < 100 && streamMessage.mock.calls.length < 2; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - return { first: sentRowIds(0), edit: sentRowIds(1) }; - } finally { - consumer.mockRestore(); - firstCompletion.resolve({ status: "aborted", abortReason: "user" }); - await h.cleanup(); - } - } - - // An edit cuts from its prompt, so it also cuts the row stored after that prompt. The first - // turn acknowledged the snapshot, so no later turn writes the row again. - test("an edit of the prompt before the attachments row keeps the row", async () => { - const sent = await editAfterAttachmentsTurn("ws-edit-keeps-row", "user-1"); - expect(sent.first).toHaveLength(1); - expect(sent.edit).toEqual(sent.first); - }); - - test("an edit that also cuts a later compaction boundary keeps the row", async () => { - const sent = await editAfterAttachmentsTurn("ws-edit-later-boundary", "user-1", true); - expect(sent.first).toHaveLength(1); - expect(sent.edit).toContain(sent.first[0]); - }); - - test("an edit before the compaction boundary drops the row with its window", async () => { - const sent = await editAfterAttachmentsTurn("ws-edit-drops-row", "user-0"); - expect(sent.first).toHaveLength(1); - expect(sent.edit).toEqual([]); - }); - - // A turn that reuses a failed turn's row must not acknowledge a snapshot published after its - // history read: that row carries the older snapshot, so the newer diffs would never be sent. - test("a publication before a turn reuses its row keeps the new snapshot pending", async () => { - const workspaceId = "ws-publication-reuse-race"; - const model = "openai:gpt-4o"; - const secondCompletion = Promise.withResolvers>(); - const streamMessage = mock((..._args: unknown[]) => { - const call = streamMessage.mock.calls.length; - if (call === 1) { - return Promise.resolve({ - success: false as const, - error: { type: "unknown" as const, raw: "startup failed before stream" }, - }); - } - return Promise.resolve({ - success: true as const, - data: - call === 2 - ? { messageId: "assistant-second", completion: secondCompletion.promise } - : createStartedTurnHandle(h.session.closingSignal, "assistant-next"), - }); - }); - const h = await createAgentSessionHarness({ - workspaceId, - aiServiceOverrides: { streamMessage }, - }); - const session = h.session as unknown as { - coordinator: { consumeCompletion: (...args: never[]) => Promise }; - getPostCompactionAttachmentsIfNeeded: (includeReadFiles: boolean) => Promise; - }; - const publish = (id: string) => publishEditWindow(h, workspaceId, id, model); - // Publication B lands after the second turn read window A and before it captures the - // pending state, so that turn captures B while its request still holds A's row. - let publishB = false; - const captureAttachments = session.getPostCompactionAttachmentsIfNeeded.bind(session); - const capture = spyOn(session, "getPostCompactionAttachmentsIfNeeded"); - const consumer = spyOn(session.coordinator, "consumeCompletion"); - capture.mockImplementation(async (includeReadFiles) => { - if (publishB) { - publishB = false; - await publish("b"); - } - return captureAttachments(includeReadFiles); - }); - const sentRows = (call: number) => - (streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }).messages.filter((message) => - message.id.startsWith(POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX) - ); - const started = { success: true as const, data: { started: true } }; - - try { - await publish("a"); - await h.historyService.appendToHistory(workspaceId, createMuxMessage("user-1", "user", "go")); - await h.session.resumeStream({ model, agentId: "exec" }); - expect(sentRows(0)).toHaveLength(1); - - publishB = true; - expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); - expect(publishB).toBe(false); - const policy = consumer.mock.results.at(-1); - assert(policy?.type === "return", "Expected the second turn's completion consumer"); - secondCompletion.resolve({ - status: "completed", - streamEnd: { - type: "stream-end", - workspaceId, - metadata: { model }, - parts: [{ type: "text", text: "done" }], - }, - }); - await policy.value; - - expect(await h.session.resumeStream({ model, agentId: "exec" })).toEqual(started); - const sent = sentRows(2); - expect(sent).toHaveLength(1); - expect(JSON.stringify(sent[0].parts)).toContain("/b.ts"); - } finally { - capture.mockRestore(); - consumer.mockRestore(); - secondCompletion.resolve({ status: "aborted", abortReason: "user" }); - await h.cleanup(); - } - }); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index ff9c44b68c9..b2803a015b7 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -36,7 +36,6 @@ import { } from "./agentPlugins/promptHooks"; import { createContextBudgetRejectedMessage } from "@/common/utils/messages/contextBudgetRejection"; import { - isDurableContextBoundaryMarker, isProviderEligibleMessage, leavesCompactionFollowUpPending, sliceMessagesForProviderFromLatestContextBoundary, @@ -212,9 +211,7 @@ import { pickStartupRetrySendOptions, prepareUserMessageForSend, type AgentSkillReference, - isAppendedTurnNoticeMessage, isContextBudgetWarningMessage, - isPostCompactionAttachmentsMessage, isSyntheticSnapshotUserMessage, type CompactionFollowUpRequest, type MuxMessageMetadata, @@ -226,6 +223,7 @@ import { 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, @@ -296,9 +294,12 @@ 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 { createPostCompactionAttachmentsMessage } from "@/browser/utils/messages/modelMessageTransform"; +import { + extractEditedFileDiffs, + type FileEditDiff, +} from "@/common/utils/messages/extractEditedFiles"; import { buildCompactionMessageText } from "@/common/utils/compaction/compactionPrompt"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { getModelCapabilitiesResolved } from "@/common/utils/ai/modelCapabilities"; @@ -332,6 +333,8 @@ 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"; @@ -1442,18 +1445,6 @@ function newestHistorySequence(rows: readonly MuxMessage[]): number { return rows.reduce((newest, row) => Math.max(newest, row.metadata?.historySequence ?? -1), -1); } -/** What a turn's history read saw. */ -interface HistoryReadPosition { - /** The newest sequence: a stage warning is refused after a row above it (#5286). */ - historyReadSequence: number; - /** The newest raw row's ID: a tail-guarded append refuses when a row landed after the read. */ - historyTailId: string; -} - -function readHistoryPosition(rows: readonly MuxMessage[]): HistoryReadPosition { - return { historyReadSequence: newestHistorySequence(rows), historyTailId: rows.at(-1)?.id ?? "" }; -} - export class AgentSession { private readonly replayPublication = new AsyncLocalStorage<{ listener: (event: AgentSessionReplayEvent) => void; @@ -1737,6 +1728,18 @@ export class AgentSession { tracking: ReturnType; }; + /** + * 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. * @@ -1838,8 +1841,8 @@ export class AgentSession { /** Last lifecycle snapshot emitted to live subscribers (used for change detection only). */ private lastEmittedStreamLifecycle: StreamLifecycleSnapshot | null = null; - /** The post-compaction attachments row the current stream sent, if any. */ - private activeStreamPostCompactionRow: MuxMessage | undefined; + /** Tracks whether the current stream included post-compaction attachments. */ + private activeStreamHadPostCompactionInjection = false; /** * muxMetadata of the queued entry currently being dispatched, held from @@ -5636,7 +5639,6 @@ export class AgentSession { if (truncateResult.success) { editTailTruncated = true; - await this.restorePostCompactionRowAfterEdit(truncateResult.data.removedMessages); // RLM mode: summarize the truncated tail into a durable labeled row // BEFORE the edited user message is appended and this turn's request is // built (log purity by construction). Best-effort with a hard deadline — @@ -7348,6 +7350,7 @@ 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, @@ -8839,7 +8842,7 @@ export class AgentSession { this.clearLiveUsageState(); this.pendingPostCompactionStateToAcknowledge = null; this.activeStreamHadAnyDelta = false; - this.activeStreamPostCompactionRow = undefined; + 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). @@ -8916,10 +8919,9 @@ export class AgentSession { return await refuseRejectedResume(retryRequest); // Model-hidden records (plan-review snapshot/resolve/reopen rows) are UI state that can // land after the resumable row without changing provider-visible history; resume from - // the newest visible row so such a record cannot silently cancel the request. The - // attachments row of a failed post-compaction turn follows its request the same way. + // the newest visible row so such a record cannot silently cancel the request. const target = tail.data.findLast( - (message) => !isModelHiddenMessage(message) && !isAppendedTurnNoticeMessage(message) + (message) => !isModelHiddenMessage(message) && !isContextBudgetWarningMessage(message) ); if (!target) return await fail(createUnknownSendMessageError(EMPTY_RESUME_HISTORY_ERROR)); if (target.metadata?.contextBudgetRejected) return await refuseRejectedResume(target); @@ -9012,10 +9014,10 @@ export class AgentSession { // A crash between snapshot and user-row appends can leave orphaned prompt // expansions on disk; exclude them from every provider request. let requestMessages = filterOrphanedMcpPromptSnapshots(historyResult.data); - // What this turn's history read saw, before any filter: a stage warning is refused after a - // row above its newest sequence (#5286). Rows the filters drop are never delivered, so + // The newest sequence this turn's history read saw, before any filter: a stage warning is + // refused after a row above it (#5286). Rows the filters drop are never delivered, so // counting them as seen is right. - let historyRead = readHistoryPosition(historyResult.data); + let historyReadSequence = newestHistorySequence(historyResult.data); if (requestMessages.length === 0) { return await fail(createUnknownSendMessageError(EMPTY_RESUME_HISTORY_ERROR)); @@ -9062,7 +9064,7 @@ export class AgentSession { ); if (refreshed.success) { requestMessages = filterOrphanedMcpPromptSnapshots(refreshed.data); - historyRead = readHistoryPosition(refreshed.data); + historyReadSequence = newestHistorySequence(refreshed.data); } } } @@ -9095,22 +9097,21 @@ 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); + } + // #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)); - const withAttachments = await this.addPostCompactionAttachmentsRow( - { messages: requestMessages, ...historyRead }, - disablePostCompactionAttachments === true || preparedRequest != null - ); - if (isStreamStartAborted()) { - return Ok(undefined); - } - if (!withAttachments.success) { - return await fail(createUnknownSendMessageError(withAttachments.error)); - } - requestMessages = withAttachments.data.messages; - historyRead = withAttachments.data; + 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:/ @@ -9226,6 +9227,7 @@ 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 @@ -9256,7 +9258,7 @@ export class AgentSession { onTurnStartStage: this.contextController.turnStartStage(options, (row) => this.publishTurnStartStage( row, - historyRead.historyReadSequence, + historyReadSequence, admissionCapture, isStreamStartAborted, stopFence @@ -9440,141 +9442,6 @@ export class AgentSession { } } - /** - * Persist the pending post-compaction attachments as one synthetic row after the request, and - * record the row this turn sends. A turn that failed before the ack already wrote the row in - * this window: reuse that row. A compaction turn skips the attachments: its new publication - * merges the old pending state. - * @param request The request history of the current window and what its history read saw - * @param skip True when the turn disables the attachments or sends a prepared request - */ - private async addPostCompactionAttachmentsRow( - request: { messages: MuxMessage[] } & HistoryReadPosition, - skip: boolean - ): Promise> { - if (skip || this.activeCompactionRequest != null) return Ok(request); - const attachments = await this.getPostCompactionAttachmentsIfNeeded( - this.isRlmCompactionEnabled() - ); - if (attachments == null) return Ok(request); - const reusableRow = request.messages.find(isPostCompactionAttachmentsMessage); - if (reusableRow) return this.reusePostCompactionRow(request, reusableRow); - // Record time: wall clock until #7282 moves its consumers to HostClock. - const row = createPostCompactionAttachmentsMessage(attachments, nodeClock.nowMs()); - if (row == null) return Ok(request); - const appendResult = await this.historyService.appendToHistoryIfTailMatches( - this.workspaceId, - row, - request.historyTailId - ); - if (!appendResult.success) return Err(appendResult.error); - if (appendResult.data === "tail-mismatch") { - // A row landed after this turn read history, such as a newer compaction boundary. This - // row could then sit in a window that it was not built for, and a retry would reuse it. - // Send this turn without it; the next turn writes the row from the current pending state. - this.pendingPostCompactionStateToAcknowledge = null; - return Ok(request); - } - // Publish the hidden row like the file-change row, so an edit fence counts the same rows. - this.emitChatEvent({ ...row, type: "message" }); - this.activeStreamPostCompactionRow = row; - const messages = [...request.messages, row]; - return Ok({ messages, ...readHistoryPosition(messages) }); - } - - /** - * Reuse a failed turn's row only while history still ends where this turn read it. A - * publication after the read moves the tail, and the pending state captured after it is newer - * than this row: acknowledging it would drop the newer attachments. - */ - private async reusePostCompactionRow( - request: { messages: MuxMessage[] } & HistoryReadPosition, - row: MuxMessage - ): Promise> { - const tail = await this.historyService.getLastMessages(this.workspaceId, 1); - if (!tail.success) return Err(tail.error); - if (tail.data.at(-1)?.id === request.historyTailId) this.activeStreamPostCompactionRow = row; - else this.pendingPostCompactionStateToAcknowledge = null; - return Ok(request); - } - - /** - * The context-exceeded retry runs without the attachments, so their row leaves history too. - * The delete changes the provider context, so the retry adopts the frontier after it. - * @returns false when the row is still in history - */ - private async deletePostCompactionRow( - row: MuxMessage, - admissionCapture: CompactionReplacementCapture | undefined - ): Promise { - const fail = (error: string) => { - log.warn("Failed to delete post-compaction attachments row", { - workspaceId: this.workspaceId, - error, - }); - return false; - }; - if (!admissionCapture) { - const deleted = await this.historyService.deleteMessage(this.workspaceId, row.id); - if (!deleted.success) return fail(deleted.error); - const sequence = row.metadata?.historySequence; - if (sequence != null) this.emitChatEvent({ type: "delete", historySequences: [sequence] }); - return true; - } - const deleted = await this.historyService.deleteMessageUnderAdmission( - this.workspaceId, - admissionCapture, - row.id - ); - if (!deleted.success) return fail(deleted.error); - // Copy the predecessor: the advance rewrites admissionCapture in place. - this.advanceOwnedCompactionAdmission( - { ...admissionCapture }, - deleted.data.capture, - admissionCapture - ); - this.emitChatEvent({ type: "delete", historySequences: deleted.data.sequences }); - return true; - } - - /** - * An edit cuts from its prompt, so it also cuts an attachments row stored after that prompt. - * The snapshot was acknowledged and no later turn writes the row again: put the row back - * before the edited prompt. Only rows before the first cut context boundary belong to the - * window the edit keeps; later rows belong to windows the edit cut. - */ - private async restorePostCompactionRowAfterEdit(removed: readonly MuxMessage[]): Promise { - const boundaryIndex = removed.findIndex(isDurableContextBoundaryMarker); - const keptWindowRows = boundaryIndex === -1 ? removed : removed.slice(0, boundaryIndex); - const row = keptWindowRows.findLast(isPostCompactionAttachmentsMessage); - if (row == null) return; - const { historySequence: _sequence, ...metadata } = row.metadata ?? {}; - const restored: MuxMessage = { ...row, metadata }; - const appended = await this.historyService.appendToHistory(this.workspaceId, restored); - if (!appended.success) { - log.warn("Failed to restore post-compaction attachments row after edit", { - workspaceId: this.workspaceId, - error: appended.error, - }); - return; - } - this.emitChatEvent({ ...restored, type: "message" }); - } - - private async discardFailedPostCompactionState( - pendingState: Awaited> - ): Promise { - try { - await this.contextController.discardFailedCarryover(pendingState); - this.onPostCompactionStateChange?.(); - } catch (error) { - log.warn("Failed to discard pending post-compaction state", { - workspaceId: this.workspaceId, - error: getErrorMessage(error), - }); - } - } - private async finalizeCompactionRetry(messageId: string): Promise { this.activeCompactionRequest = undefined; this.resetActiveStreamState(); @@ -9823,9 +9690,8 @@ export class AgentSession { return false; } - // Only retry if this turn sent the post-compaction row. - const postCompactionRow = this.activeStreamPostCompactionRow; - if (postCompactionRow == null) { + // Only retry if we actually injected post-compaction context. + if (!this.activeStreamHadPostCompactionInjection) { return false; } @@ -9844,12 +9710,25 @@ export class AgentSession { return false; } + 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. + try { + await this.contextController.discardFailedCarryover(pendingState); + this.onPostCompactionStateChange?.(); + } catch (error) { + log.warn("Failed to discard pending post-compaction state", { + workspaceId: this.workspaceId, + error: getErrorMessage(error), + }); + } + // Abort the failed assistant placeholder and clean up persisted partial/history state. this.resetActiveStreamState(); this.emitChatEvent({ @@ -9858,13 +9737,6 @@ export class AgentSession { messageId: data.messageId, }); await this.clearFailedAssistantMessage(data.messageId, "post-compaction-retry"); - // Keep the pending state while the row stays in history: the next turn reuses the row. - if (!(await this.deletePostCompactionRow(postCompactionRow, context.admissionCapture))) { - return false; - } - this.postCompactionRetryAttempts.add(requestId); - // The post-compaction context is likely the culprit; discard it so we don't loop. - await this.discardFailedPostCompactionState(pendingState); // r40: same admission gate as the compaction retry above — this path also // crosses a transient idle gap before re-entering PREPARING. @@ -10056,7 +9928,7 @@ export class AgentSession { this.activeStreamContext = undefined; this.activeStreamUserMessageId = undefined; this.activeStreamStartedAtMs = undefined; - this.activeStreamPostCompactionRow = undefined; + this.activeStreamHadPostCompactionInjection = false; this.activeStreamHadAnyDelta = false; this.pendingPostCompactionStateToAcknowledge = null; } @@ -13504,12 +13376,15 @@ export class AgentSession { * 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 the pending-state path (on-disk post-compaction.json). + * Covers both injection routes: the immediate pending-state path (on-disk + * post-compaction.json) and the periodic capture of qualified carryover. */ async clearPostCompactionState(): Promise { await this.discardMemoryContext(); - // The in-memory clear stays unconditional: it stops THIS session from - // acknowledging stale state even when the durable discard below fails. + // 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; 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. @@ -13604,17 +13479,23 @@ export class AgentSession { } /** - * Get post-compaction attachments when a compaction left pending state, and clear the file - * state cache. The caller persists them as one row, so they appear once per compaction. + * 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 persist, or null if none needed + * @returns Attachments to inject, or null if none needed */ 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(); @@ -13633,9 +13514,60 @@ export class AgentSession { }); } + // 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; } + /** + * Generate post-compaction attachments by extracting diffs and loaded skills from message history. + */ + 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 []; + } + + 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(), + }); + } + /** * Shared logic for assembling post-compaction attachments from cached context. * Loads exclusions, TODO state, workspace metadata, and plan references, diff --git a/src/node/services/agentSkills/pathSkillActivation.ts b/src/node/services/agentSkills/pathSkillActivation.ts index 145af225f7f..1b0d0839fd4 100644 --- a/src/node/services/agentSkills/pathSkillActivation.ts +++ b/src/node/services/agentSkills/pathSkillActivation.ts @@ -5,6 +5,7 @@ import { type PathSkillsResultField, } from "@/common/types/tools"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; +import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { MuxMessage } from "@/common/types/message"; import { deriveProjectRelativePath } from "@/common/utils/review/assistedReview"; import { createSkillPathsMatcher } from "@/common/utils/skills/skillPaths"; @@ -111,10 +112,6 @@ function collectFromParts(parts: unknown, names: Set): void { * Names of skills already in the context window: prior pointers, successful * agent_skill_read calls, and /skill-name snapshot rows. History is untrusted on disk, * so malformed rows are skipped, never thrown on (self-healing). - * - * The post-compaction attachments row does not count: it stores only rendered text, and - * plan text or a skill body can contain a skill tag. A skill that the row carries can get - * one extra pointer, but a spoofed tag cannot hide a pointer. */ export function collectSurfacedSkillNames(messages: readonly MuxMessage[]): Set { const names = new Set(); @@ -174,6 +171,15 @@ function createTurnMatcher(): (skill: AgentSkillDescriptor, relativePath: string }; } +/** Names of the skills a post-compaction `loaded_skills_snapshot` re-injects. */ +export function loadedSnapshotSkillNames( + attachments: readonly PostCompactionAttachment[] | null | undefined +): string[] { + return (attachments ?? []).flatMap((attachment) => + attachment.type === "loaded_skills_snapshot" ? attachment.skills.map((skill) => skill.name) : [] + ); +} + /** * Build the turn's activation, or undefined when no skill can activate (no model- * invocable skill has `paths`), so the file tools stay unwrapped. @@ -184,6 +190,8 @@ function createTurnMatcher(): (skill: AgentSkillDescriptor, relativePath: string export function createPathSkillActivation(params: { skills: readonly AgentSkillDescriptor[] | undefined; messages: readonly MuxMessage[]; + /** Skills already in the request outside history (post-compaction loaded-skill snapshot). */ + loadedSkillNames?: readonly string[]; cwd: string; runtime: Runtime; }): PathSkillActivation | undefined { @@ -193,6 +201,7 @@ export function createPathSkillActivation(params: { } const surfaced = collectSurfacedSkillNames(params.messages); + for (const name of params.loadedSkillNames ?? []) surfaced.add(name); const matches = createTurnMatcher(); return { diff --git a/src/node/services/branchSummary.test.ts b/src/node/services/branchSummary.test.ts index aca5d00ab22..895f9fd63d6 100644 --- a/src/node/services/branchSummary.test.ts +++ b/src/node/services/branchSummary.test.ts @@ -9,7 +9,6 @@ import * as path from "node:path"; import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; import type { LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; import type { ProviderName } from "@/common/constants/providers"; import { WORDS_TO_TOKENS_RATIO } from "@/common/constants/ui"; @@ -477,35 +476,6 @@ describe("maybeAppendAbandonedBranchSummary", () => { } }); - test("a post-compaction attachments row is not abandoned work", async () => { - const attachments = createMuxMessage( - `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - "user", - "carried plan ".repeat(BRANCH_SUMMARY_MIN_SEGMENT_TOKENS * 3), - { synthetic: true } - ); - expect(buildAbandonedBranchTranscript([attachments])).toBe(""); - const { historyService, cleanup } = await createTestHistoryService(); - let calls = 0; - try { - const result = await maybeAppendAbandonedBranchSummary({ - historyService, - workspaceId: "attachments-row-budget", - isExperimentEnabled: RLM_ON, - aiService: fakeAiService( - summaryModel("summary", () => { - calls++; - }) - ), - abandonedMessages: [createMuxMessage("visible", "user", "tiny visible turn"), attachments], - }); - expect(result).toBeNull(); - expect(calls).toBe(0); - } finally { - await cleanup(); - } - }); - test("tiny abandoned segments skip the model call", async () => { const { historyService, cleanup } = await createTestHistoryService(); try { diff --git a/src/node/services/branchSummary.ts b/src/node/services/branchSummary.ts index ad1f0b7e034..eb8bc425305 100644 --- a/src/node/services/branchSummary.ts +++ b/src/node/services/branchSummary.ts @@ -21,11 +21,7 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; import { buildCompactionPrompt } from "@/common/constants/ui"; -import { - createMuxMessage, - isPostCompactionAttachmentsMessage, - type MuxMessage, -} from "@/common/types/message"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { anthropicRejectsDisabledThinking, anthropicThinksUnlessDisabled, @@ -134,12 +130,6 @@ function formatMessageForBranchTranscript(message: MuxMessage): string { return `${role}: ${segments.join("\n")}`; } -// The post-compaction attachments row is carried context (an edit may even restore it), not -// conversation or work. -function isTranscriptMessage(message: MuxMessage): boolean { - return !isModelHiddenMessage(message) && !isPostCompactionAttachmentsMessage(message); -} - /** * Build the thinking-stripped transcript of the abandoned segment, trimming * oldest messages first when over the input cap (the newest abandoned work @@ -149,7 +139,7 @@ export function buildAbandonedBranchTranscript(messages: MuxMessage[]): string { assert(Array.isArray(messages), "buildAbandonedBranchTranscript requires a message array"); // Both /refine and abandoned-branch summaries must exclude hidden state before the char cap. const formatted = messages - .filter(isTranscriptMessage) + .filter((message) => !isModelHiddenMessage(message)) .map(formatMessageForBranchTranscript) .filter((s) => s.length > 0); @@ -737,7 +727,7 @@ export async function maybeAppendAbandonedBranchSummary( // Hidden records also cannot make a tiny visible segment eligible for a model call. const abandonedMessages = input.abandonedMessages.filter( (message) => - isTranscriptMessage(message) && + !isModelHiddenMessage(message) && message.metadata?.rlmPreservedTailCopy !== true && (message.metadata?.compacted === undefined || message.metadata.compacted === false) ); diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index fc1d0bdbf1a..1a6183801f9 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -1993,24 +1993,9 @@ describe("CompactionHandler", () => { }); }); - // Record rows are UI state that no provider request ever contains; a copy behind the - // boundary would only duplicate hidden state (and the projection ignores copies anyway). - // The next turn writes a fresh attachments row, so a copy would send that context twice. - it.each([ - [ - "model-hidden plan-review record rows", - createMuxMessage("plan-review-1", "user", "…", { - synthetic: true, - muxMetadata: { type: "plan-review", kind: "resolve", recordId: "rec", threadId: "thr" }, - }), - ], - [ - "the old window's post-compaction attachments row", - createMuxMessage("post-compaction-1", "user", "\nPlan\n", { - synthetic: true, - }), - ], - ])("never copies %s into the preserved tail", async (_name, record) => { + it("never copies model-hidden plan-review record rows into the preserved tail", async () => { + // Record rows are UI state that no provider request ever contains; a copy behind the + // boundary would only duplicate hidden state (and the projection ignores copies anyway). handler = new CompactionHandler({ workspaceId, historyService, @@ -2019,6 +2004,10 @@ describe("CompactionHandler", () => { emitter: mockEmitter, }); + const record = createMuxMessage("plan-review-1", "user", "…", { + synthetic: true, + muxMetadata: { type: "plan-review", kind: "resolve", recordId: "rec", threadId: "thr" }, + }); await seedHistory( createMuxMessage("u0", "user", "old head question"), createMuxMessage("a0", "assistant", "old head answer"), @@ -2035,10 +2024,7 @@ describe("CompactionHandler", () => { if (!epochResult.success) throw new Error(epochResult.error); const copies = epochResult.data.filter((row) => row.metadata?.rlmPreservedTailCopy === true); expect(copies.map((copy) => copy.role)).toEqual(["user", "assistant"]); - expect(copies.map((copy) => copy.parts)).toMatchObject([ - [{ type: "text", text: "tail question" }], - [{ type: "text", text: "tail answer" }], - ]); + expect(copies.some((copy) => copy.metadata?.muxMetadata?.type === "plan-review")).toBe(false); }); it("keeps a tail user row's prompt-hook context on its copy", async () => { diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index f32e0b378a3..1253b4203a2 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -30,7 +30,6 @@ import { getCompactionFollowUpContent, type CompactionFollowUpRequest, type CompactionSummaryMetadata, - isPostCompactionAttachmentsMessage, type MuxMessage, } from "@/common/types/message"; import { createCompactionSummaryMessageId } from "@/node/services/utils/messageIds"; @@ -1285,10 +1284,7 @@ export class CompactionHandler { } /** Copies for regular RLM keep-tail and continuous compaction tails. */ - private buildTailCopies(allTailRows: MuxMessage[]): MuxMessage[] { - // Drop the old window's post-compaction attachments row: the next turn writes a fresh one - // from the new pending state, and a copy would send the same context twice. - const tailRows = allTailRows.filter((row) => !isPostCompactionAttachmentsMessage(row)); + private buildTailCopies(tailRows: MuxMessage[]): MuxMessage[] { // Preassign copy IDs for ALL tail rows before building any copy: MCP // snapshot rows precede the user row they expand, so a build-time map // would not yet contain the invoking row's copy ID when the snapshot row diff --git a/src/node/services/continuousCompactor.test.ts b/src/node/services/continuousCompactor.test.ts index 43a1c9a7305..7ed1d5365b0 100644 --- a/src/node/services/continuousCompactor.test.ts +++ b/src/node/services/continuousCompactor.test.ts @@ -12,7 +12,6 @@ import { type CompactionFollowUpRequest, type MuxMessage, } from "@/common/types/message"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { FORCE_COMPACTION_BUFFER_PERCENT } from "@/common/constants/ui"; import { EAGER_LEAD_PERCENT } from "@/constants/continuousCompaction"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; @@ -1576,30 +1575,6 @@ describe("ContinuousCompactor", () => { expect(completed.mock.calls[0][0]).toMatchObject({ preservedTailMessageCount: 0 }); }); - it("keeps the recent turn when its post-compaction attachments row alone outgrows the budgets", async () => { - // The boundary drops the old attachments row (the next turn writes a fresh one), so its tokens - // must neither shrink the retained tail nor block staging. - await seed( - createMuxMessage("old-user", "user", "Investigate the regression"), - createMuxMessage("old-answer", "assistant", "earlier investigation ".repeat(4_000)), - createMuxMessage("recent-user", "user", "Implement the fix"), - createMuxMessage( - `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - "user", - "x".repeat(context.contextWindowTokens * 4), - { synthetic: true } - ), - createMuxMessage("recent-answer", "assistant", "The fix is ready for review.") - ); - await stage(); - expect(await compactor.observe(context.thresholdPercent, context)).toBe("applied"); - expect(completed.mock.calls[0][0]).toMatchObject({ preservedTailMessageCount: 2 }); - const texts = (await rows()).map((row) => - row.parts.map((part) => (part.type === "text" ? part.text : "")).join("") - ); - expect(texts).toEqual([summary.text, "Implement the fix", "The fix is ready for review."]); - }); - it("requests full-compaction fallback at the force threshold when no summary is ready", async () => { await seedConversation(); const release = deferred(); diff --git a/src/node/services/continuousCompactor.ts b/src/node/services/continuousCompactor.ts index ca719e5e0a9..6556133d32c 100644 --- a/src/node/services/continuousCompactor.ts +++ b/src/node/services/continuousCompactor.ts @@ -18,11 +18,7 @@ import { type MuxMessage, type MuxMetadata, } from "@/common/types/message"; -import { - estimateRetainedRowTokens, - selectRollingCut, - type RollingCut, -} from "@/common/utils/compaction/rollingCut"; +import { selectRollingCut, type RollingCut } from "@/common/utils/compaction/rollingCut"; import { estimateMuxMessageTokens } from "@/common/utils/messages/keepRecentTail"; import { isModelHiddenMessage } from "@/common/utils/messages/modelHiddenMessages"; import { @@ -508,7 +504,7 @@ export class ContinuousCompactor { summaryTokens + (context.systemMessageTokens ?? 0) + Math.max(context.attachmentTokens ?? 0, staged.attachmentTokens) + - tail.reduce((sum, row) => sum + estimateRetainedRowTokens(row), 0); + tail.reduce((sum, row) => sum + estimateMuxMessageTokens(row), 0); return ( tokens < (context.contextWindowTokens * (context.thresholdPercent + FORCE_COMPACTION_BUFFER_PERCENT)) / diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 94228332096..9db9c176b8e 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -5780,32 +5780,6 @@ export class HistoryService { ); } - /** - * Delete one row of the caller's own turn under its admission. Removing a provider-visible - * row advances the context generation, so this returns the frontier after the delete: the - * caller adopts it, because its own delete is not a foreign reset. - */ - deleteMessageUnderAdmission( - workspaceId: string, - captured: CompactionReplacementCapture, - messageId: string - ): Promise> { - const expected = { ...captured }; - return this.withRecoveredHistoryWriteResultLock( - workspaceId, - "Failed to delete message", - async () => { - const current = await this.captureCompactionReplacementUnderHistoryLock(workspaceId); - if (current.nonce !== expected.nonce || current.generation !== expected.generation) - return Err("Compaction admission was superseded"); - const deleted = await this.deleteMessageUnderWriteLock(workspaceId, messageId); - if (!deleted.success) return deleted; - const capture = await this.captureCompactionReplacementUnderHistoryLock(workspaceId); - return Ok({ sequences: deleted.data, capture }); - } - ); - } - private async fenceDeletedMessagesUnderHistoryLock( workspaceId: string, rows: HistoryRewriteRow[], diff --git a/src/node/services/messagePipeline.test.ts b/src/node/services/messagePipeline.test.ts index c8e80a67a8e..407eef55860 100644 --- a/src/node/services/messagePipeline.test.ts +++ b/src/node/services/messagePipeline.test.ts @@ -8,7 +8,6 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import { createTestHistoryService } from "./testHistoryService"; import { prepareMessagesForProvider, sanitizeAssistantModelMessages } from "./messagePipeline"; import { SUPPORTED_PROVIDERS } from "@/common/constants/providers"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { extractToolMediaAsUserMessagesFromModelMessages } from "@/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages"; import { createToolAttachmentSummaryText } from "@/node/utils/messages/toolResultAttachments"; @@ -1185,51 +1184,3 @@ describe("prepareMessagesForProvider: MCP tool results", () => { expect(outputs["mcp-missing"]).toEqual({ type: "json", value: null }); }); }); - -describe("prepareMessagesForProvider: durable post-compaction attachments row", () => { - const options = { - effectiveAgentId: "exec", - toolNamesForSentinel: [] as string[], - providerForMessages: "anthropic", - effectiveThinkingLevel: "off" as const, - modelString: "anthropic:claude-sonnet-4-5", - workspaceId: "test-ws", - }; - const firstTurn = [ - createMuxMessage("summary-1", "assistant", "Summary of earlier work.", { - timestamp: 1000, - compacted: "user", - compactionBoundary: true, - }), - createMuxMessage("user-1", "user", "OPERATOR_PROMPT", { timestamp: 1001 }), - createMuxMessage("file-change-1", "user", "x", { - timestamp: 1002, - synthetic: true, - }), - createMuxMessage(`${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, "user", "REPO_BYTES", { - timestamp: 1003, - synthetic: true, - }), - ]; - - function textOrder(messages: ModelMessage[]): string[] { - return JSON.stringify(messages).match(/OPERATOR_PROMPT|REPO_BYTES|SECOND_PROMPT/g) ?? []; - } - - it("sends the attachments before the operator's prompt on every turn", async () => { - // AgentSession appends the row after the prompt; repository bytes must never be the - // last text of the merged user message, on the first turn or when later turns replay it. - const first = await prepareMessagesForProvider({ ...options, messagesWithSentinel: firstTurn }); - expect(textOrder(first)).toEqual(["REPO_BYTES", "OPERATOR_PROMPT"]); - - const second = await prepareMessagesForProvider({ - ...options, - messagesWithSentinel: [ - ...firstTurn, - createMuxMessage("assistant-1", "assistant", "Done.", { timestamp: 1004 }), - createMuxMessage("user-2", "user", "SECOND_PROMPT", { timestamp: 1005 }), - ], - }); - expect(textOrder(second)).toEqual(["REPO_BYTES", "OPERATOR_PROMPT", "SECOND_PROMPT"]); - }); -}); diff --git a/src/node/services/messagePipeline.ts b/src/node/services/messagePipeline.ts index ce916ec1d94..4c17091aa68 100644 --- a/src/node/services/messagePipeline.ts +++ b/src/node/services/messagePipeline.ts @@ -31,7 +31,6 @@ import { validateAnthropicCompliance, injectAgentTransition, injectPostCompactionAttachments, - placePostCompactionAttachmentsBeforePrompt, } from "@/browser/utils/messages/modelMessageTransform"; import { applyNativeToolSearchReplay, @@ -90,21 +89,20 @@ export interface PrepareMessagesOptions { * Run the full message preparation pipeline. * * Transforms pre-filtered `XumMessage[]` into provider-ready `ModelMessage[]` by: - * 1. Placing durable post-compaction attachments rows before the turn's prompt - * 2. Injecting agent-transition context (plan→exec handoff) - * 3. Injecting post-compaction attachments - * 4. Redacting heavy tool outputs - * 5. Sanitizing tool inputs - * 6. Inlining SVG attachments as text - * 7. Neutralizing user-typed lookalikes (peer-envelope provenance) - * 8. Sanitizing PDF filenames for Anthropic - * 9. Extracting tool-result media as user message attachments - * 10. Rewriting data-URI file parts to SDK-safe inline base64 - * 11. Converting to Vercel AI SDK ModelMessage format, split at persisted step boundaries - * 12. Self-healing: filtering empty/whitespace assistant messages - * 13. Applying provider-specific message transforms - * 14. Applying cache control headers - * 15. Validating Anthropic compliance (logs warnings only) + * 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) * * Log purity: this pipeline never reads live workspace state (disk, file * trackers). File-change notifications and @file mention snapshots are @@ -137,7 +135,7 @@ export async function prepareMessagesForProvider( // Inject agent transition context with plan content (for plan→exec handoff) const messagesWithAgentContext = injectAgentTransition( - placePostCompactionAttachmentsBeforePrompt(messagesWithSentinel), + messagesWithSentinel, effectiveAgentId, toolNamesForSentinel, planContentForTransition, diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index ab5b00801ec..9395b6cd2ba 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { EventEmitter } from "events"; import { MockAiStreamPlayer } from "./mockAiStreamPlayer"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { Ok } from "@/common/types/result"; import type { HistoryService } from "@/node/services/historyService"; import type { AIService } from "@/node/services/aiService"; @@ -760,48 +759,6 @@ describe("MockAiStreamPlayer", () => { }); }); - test.each([ - [ - "post-compaction attachments", - { synthetic: true }, - `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - ], - [ - "Token Budget warning", - { - synthetic: true, - muxMetadata: { - type: "context-budget-warning" as const, - contextTokens: 1, - maxTokens: 2, - budgetTokens: 1, - }, - }, - "budget-warning-1", - ], - ])("answers the prompt, not a %s row appended after it", async (_label, metadata, id) => { - const player = new MockAiStreamPlayer({ - historyService, - aiService: new EventEmitter() as unknown as AIService, - }); - const workspaceId = `workspace-appended-${id}`; - const prompt = createMuxMessage("user-prompt", "user", "water the herbs", { - timestamp: Date.now(), - }); - const notice = createMuxMessage(id, "user", "notice", { - timestamp: Date.now(), - ...metadata, - }); - - const playResult = await player.play([prompt, notice], workspaceId); - if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); - expect(await playResult.data.completion).toMatchObject({ status: "completed" }); - - const history = await historyService.getLastMessages(workspaceId, 10); - const reply = (history.success ? history.data : []).find((m) => m.role === "assistant"); - expect(extractText(reply)).toBe("Mock response: water the herbs"); - }); - test("passes the mock rate limit's Retry-After to the turn completion", async () => { const aiServiceStub = new EventEmitter(); aiServiceStub.on("error", () => undefined); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 458618c191e..16f49987830 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import assert from "@/common/utils/assert"; import type { MuxMessage, MuxMessageMetadata } from "@/common/types/message"; -import { createMuxMessage, isAppendedTurnNoticeMessage } from "@/common/types/message"; +import { createMuxMessage } from "@/common/types/message"; import type { HistoryService } from "@/node/services/historyService"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -213,20 +213,6 @@ interface ActiveStream { toolCallRelease?: ReturnType>; } -// A request that ends with a partial assistant continues an interrupted or errored turn: -// stream-error now keeps that partial, as StreamManager does (#5700). Real requests get a -// [CONTINUE] row at transform time (addInterruptedSentinel), which this mock never sees, so -// answer the user message that started the turn, as a provider would. commitPartial keeps -// `partial: true` on errored partials, so repeated retries leave several in a row. -// Rows appended after the prompt (attachments, budget warnings) are context, not the prompt. -function findPromptMessage(messages: MuxMessage[]): MuxMessage | undefined { - return messages.findLast( - (message) => - !(message.role === "assistant" && message.metadata?.partial === true) && - !isAppendedTurnNoticeMessage(message) - ); -} - export class MockAiStreamPlayer { private readonly streamStartGates = new Map(); private readonly releasedStreamStartGates = new Set(); @@ -439,8 +425,21 @@ export class MockAiStreamPlayer { return Ok(undefined); } - const latest = findPromptMessage(messages); - if (latest?.role !== "user") { + // A request that ends with a partial assistant continues an interrupted or errored turn: + // stream-error now keeps that partial, as StreamManager does (#5700). Real requests get a + // [CONTINUE] row at transform time (addInterruptedSentinel), which this mock never sees, so + // answer the user message that started the turn, as a provider would. commitPartial keeps + // `partial: true` on errored partials, so repeated retries leave several in a row. + let latestIndex = messages.length - 1; + while ( + latestIndex >= 0 && + messages[latestIndex].role === "assistant" && + messages[latestIndex].metadata?.partial === true + ) { + latestIndex -= 1; + } + const latest = latestIndex >= 0 ? messages[latestIndex] : undefined; + if (!latest || latest.role !== "user") { return Err({ type: "unknown", raw: "Mock AI expected a user message" }); } diff --git a/src/node/services/taskService.childGoals.test.ts b/src/node/services/taskService.childGoals.test.ts index fe59b92b7f7..8f91ef2f527 100644 --- a/src/node/services/taskService.childGoals.test.ts +++ b/src/node/services/taskService.childGoals.test.ts @@ -461,36 +461,27 @@ describe("TaskService child goals", () => { }); test.each([ - { tail: "goal_continuation row", trailingRow: null }, - // Token Budget warning and post-compaction attachments rows are synthetic user rows appended - // after the turn's trigger; they are not a newer trigger, so the ended turn still counts as the - // goal continuation. - { - tail: "goal_continuation row then a Token Budget warning", - trailingRow: () => - createContextBudgetWarning({ - contextTokens: 90_000, - maxTokens: 128_000, - budgetTokens: 119_808, - handoff: true, - sessionHistoryAvailable: true, - }), - }, - { - tail: "goal_continuation row then a post-compaction attachments row", - trailingRow: () => - createMuxMessage("post-compaction-1", "user", "\nPlan\n", { - synthetic: true, - }), - }, + { tail: "goal_continuation row", appendWarning: false }, + // A Token Budget warning row is a synthetic user row appended after the turn's trigger; it is + // a notice, not a newer trigger, so the ended turn still counts as the goal continuation. + { tail: "goal_continuation row then a Token Budget warning", appendWarning: true }, ])( "a tool-free goal continuation completes the goal and publishes exactly one report ($tail)", - async ({ trailingRow }) => { + async ({ appendWarning }) => { const t = await setup(); const goal = await t.setChildGoal(); await t.appendGoalContinuationRow(goal.goalId); - if (trailingRow != null) { - const appended = await t.historyService.appendToHistory(childId, trailingRow()); + if (appendWarning) { + const appended = await t.historyService.appendToHistory( + childId, + createContextBudgetWarning({ + contextTokens: 90_000, + maxTokens: 128_000, + budgetTokens: 119_808, + handoff: true, + sessionHistoryAvailable: true, + }) + ); expect(appended.success).toBe(true); } diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index a7d7f10ea3e..72f61990cf8 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -170,7 +170,7 @@ import { } from "@/common/types/backgroundWorkAttention"; import { createMuxMessage, - isAppendedTurnNoticeMessage, + isContextBudgetWarningMessage, type MuxMessage, type MuxMessageMetadata, } from "@/common/types/message"; @@ -17875,7 +17875,7 @@ export class TaskService implements AgentTaskIntegration { const tail = await this.historyService.getLastMessages(workspaceId, 20); if (!tail.success) return false; const userRow = tail.data.findLast( - (message) => message.role === "user" && !isAppendedTurnNoticeMessage(message) + (message) => message.role === "user" && !isContextBudgetWarningMessage(message) ); return userRow?.metadata?.kind === GOAL_CONTINUATION_KIND && userRow.metadata.goalId === goalId; } diff --git a/src/node/services/turnContextAssembler.test.ts b/src/node/services/turnContextAssembler.test.ts index f3e045e937b..fd727cd2bdc 100644 --- a/src/node/services/turnContextAssembler.test.ts +++ b/src/node/services/turnContextAssembler.test.ts @@ -3,7 +3,6 @@ import * as path from "node:path"; import { describe, expect, test } from "bun:test"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { sliceMessagesFromLatestCompactionBoundary } from "@/common/utils/messages/compactionBoundary"; @@ -551,32 +550,6 @@ describe("assemblePromptPayload", () => { expect(afterPartial).toContain("[CONTINUE]"); }); - test("puts request context right before the prompt, not before its attachments row", async () => { - const context = createMuxMessage("ctx", "user", "VIEW-CONTEXT"); - const attachments = createMuxMessage( - `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - "user", - "ATTACHED", - { synthetic: true } - ); - const prompt = createMuxMessage("u1", "user", "NEWEST"); - const partial = createMuxMessage("a1", "assistant", "PARTIAL", { partial: true }); - const order = async (history: MuxMessage[]) => { - const payload = await assemble({ history, requestContextMessage: context }); - const wire = JSON.stringify(payload.messages); - return ["ATTACHED", "VIEW-CONTEXT", "NEWEST", "PARTIAL"] - .filter((text) => wire.includes(text)) - .sort((a, b) => wire.indexOf(a) - wire.indexOf(b)); - }; - expect(await order([prompt, attachments])).toEqual(["ATTACHED", "VIEW-CONTEXT", "NEWEST"]); - expect(await order([prompt, attachments, partial])).toEqual([ - "ATTACHED", - "VIEW-CONTEXT", - "NEWEST", - "PARTIAL", - ]); - }); - test("replays tool_catalog_search results as tool references only for deferred tools", async () => { const history = [ createMuxMessage("user", "user", "find it"), diff --git a/src/node/services/turnContextAssembler.ts b/src/node/services/turnContextAssembler.ts index 4873d56af8f..b53a076f004 100644 --- a/src/node/services/turnContextAssembler.ts +++ b/src/node/services/turnContextAssembler.ts @@ -10,7 +10,6 @@ import * as path from "node:path"; import assert from "@/common/utils/assert"; import { - isPostCompactionAttachmentsMessage, isTokenBudgetInternalMessage, type MuxMessage, type SystemSectionRecords, @@ -377,8 +376,7 @@ 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. The post-compaction attachments row stored after the - * request is not the request: the provider transform moves it before the request's user rows. + * still gets its continuation sentinel. */ export function insertRequestContextMessage( history: MuxMessage[], @@ -389,31 +387,14 @@ export function insertRequestContextMessage( const last = history.at(-1); let index = history.length; if (last?.role === "user") { - index = requestEnd(history); + index = history.length - 1; } else if (last?.role === "assistant" && last.metadata?.partial === true) { - const request = history.findLastIndex(isRequestRow); + const request = history.findLastIndex((message) => message.role === "user"); index = request === -1 ? history.length - 1 : request; } return [...history.slice(0, index), context, ...history.slice(index)]; } -function isRequestRow(message: MuxMessage): boolean { - return message.role === "user" && !isPostCompactionAttachmentsMessage(message); -} - -/** The index of the last request row when history ends in user rows. */ -function requestEnd(history: MuxMessage[]): number { - let index = history.length - 1; - while ( - index > 0 && - isPostCompactionAttachmentsMessage(history[index]) && - history[index - 1].role === "user" - ) { - index--; - } - return index; -} - /** * Codex parity: the model records the item ID of each relevant user request in its checkpoint and * later passes it to session_history read_item. The ID is the persisted history sequence, so the diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 1e1f0000fa1..0f59e62af13 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -211,7 +211,10 @@ import { WORKFLOW_RESULT_METADATA_TYPE, } from "@/common/utils/workflowRunMessages"; import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; -import { createPathSkillActivation } from "@/node/services/agentSkills/pathSkillActivation"; +import { + createPathSkillActivation, + loadedSnapshotSkillNames, +} from "@/node/services/agentSkills/pathSkillActivation"; 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"; @@ -309,6 +312,7 @@ 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"; @@ -363,6 +367,7 @@ 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. @@ -1676,6 +1681,7 @@ export class TurnRequestBuilder { delegatedToolNames, recordFileState, recordProposedPlan, + postCompactionAttachments, resolveMemoryContext, workspaceGoalService, goalTurnKind, @@ -3147,6 +3153,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), cwd: workspacePath, runtime, }), @@ -3567,6 +3576,7 @@ export class TurnRequestBuilder { toolNamesForSentinel: loadedToolNames, planContentForTransition, planFilePath, + postCompactionAttachments, providersConfig: seed.providersConfig, anthropicCacheTtl: effectiveAnthropicCacheTtl, workspaceId, @@ -3621,6 +3631,7 @@ export class TurnRequestBuilder { anthropicCacheTtl: effectiveAnthropicCacheTtl, planContentForTransition, planFilePath, + postCompactionAttachments, partialContinuationMessage: options.partialContinuationMessage, mcpAppModelContextMessage: mcpAppContextMessage, }); diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 61d060b91d3..cb99aa13984 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -566,15 +566,6 @@ describe("WorkspaceGoalService", () => { sessionHistoryAvailable: true, }), }, - { - row: "a post-compaction attachments row", - // The first turn after compaction appends this row right after its user row. - build: () => - createMuxMessage("post-compaction-1", "user", "\nPlan\n", { - timestamp: Date.now(), - synthetic: true, - }), - }, ])( "getGoal keeps a never-driven goal active when $row follows the processed prompt", async ({ build }) => { diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 374e3549a8b..d29bf3d5e53 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -27,7 +27,7 @@ import { import type { GoalBoardEntry, GoalBoardSnapshot, GoalBoardV1 } from "@/common/types/goal"; import { createMuxMessage, - isAppendedTurnNoticeMessage, + isContextBudgetWarningMessage, isSyntheticSnapshotUserMessage, pickStartupRetrySendOptions, } from "@/common/types/message"; @@ -1172,7 +1172,7 @@ export class WorkspaceGoalService { // look past them for the settled response. const followerRow = historyResult.data .slice(index + 1) - .find((row) => !isPlanReviewRecordMessage(row) && !isAppendedTurnNoticeMessage(row)); + .find((row) => !isPlanReviewRecordMessage(row) && !isContextBudgetWarningMessage(row)); const manualRowProcessed = followerRow?.role === "assistant" && followerRow.metadata?.partial !== true && diff --git a/src/node/services/workspaceService.titles.test.ts b/src/node/services/workspaceService.titles.test.ts index 1907e86756b..a4b59fb6bfa 100644 --- a/src/node/services/workspaceService.titles.test.ts +++ b/src/node/services/workspaceService.titles.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test, mock, beforeEach, afterEach, spyOn } from "bun:test"; import type { WorkspaceService } from "./workspaceService"; -import { POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX } from "@/common/constants/attachments"; import { NAME_GEN_PREFERRED_MODELS } from "@/common/constants/nameGeneration"; import { DEFAULT_MODEL } from "@/common/constants/knownModels"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -570,7 +569,6 @@ describe("WorkspaceService regenerateTitle", () => { "ws-regenerate-title-first-plus-last-three", "title-hidden-review", "title-budget-warning", - "title-attachments-row", ].map((id) => ({ id, name: id, path: `/tmp/proj/${id}` })), { agentAiDefaults: { @@ -667,35 +665,6 @@ describe("WorkspaceService regenerateTitle", () => { } }); - test("the post-compaction attachments row is neither title context nor the latest request", async () => { - const workspaceId = "title-attachments-row"; - const rows = [ - createMuxMessage("objective", "user", "visible objective"), - createMuxMessage("answer", "assistant", "visible progress"), - createMuxMessage("follow-up", "user", "visible follow-up request"), - createMuxMessage( - `${POST_COMPACTION_ATTACHMENTS_MESSAGE_ID_PREFIX}1`, - "user", - "ATTACHMENTS_SENTINEL", - { synthetic: true } - ), - ]; - for (const row of rows) - expect((await historyService.appendToHistory(workspaceId, row)).success).toBe(true); - const generate = spyOn(workspaceTitleGenerator, "generateWorkspaceIdentity").mockResolvedValue( - Ok({ name: "visible-title", title: "Visible title", modelUsed: "test:model" }) - ); - try { - expect((await workspaceService.regenerateTitle(workspaceId)).success).toBe(true); - const call = generate.mock.calls[0]; - expect(call?.[3]).toContain("visible follow-up request"); - expect(call?.[3]).not.toContain("ATTACHMENTS_SENTINEL"); - expect(call?.[4]).toBe("visible follow-up request"); - } finally { - generate.mockRestore(); - } - }); - test("returns updateTitle error when persisting generated title fails", async () => { const workspaceId = "ws-regenerate-title"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index e673eed03ae..0bff9848e3d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -318,7 +318,6 @@ import { createMuxMessage, getCompactionFollowUpContent, isContextBudgetWarningMessage, - isPostCompactionAttachmentsMessage, isSameWorkspaceTurnTaskCorrelation, parseWorkspaceTurnTaskCorrelation, pickPreservedSendOptions, @@ -1330,11 +1329,9 @@ function collectWorkspaceTitleContextTurns( const turns: WorkspaceTitleContextTurn[] = []; // Hidden records must not select the naming objective or consume the title's turn budget. - // The post-compaction attachments row is carried repository context, not a conversation turn. for (const message of messages) { if ( isModelHiddenMessage(message) || - isPostCompactionAttachmentsMessage(message) || (message.role !== "user" && message.role !== "assistant") ) { continue; diff --git a/src/node/services/workspaceTurnManager.historyRepair.test.ts b/src/node/services/workspaceTurnManager.historyRepair.test.ts index 4edf6e7cc7c..4506fac350a 100644 --- a/src/node/services/workspaceTurnManager.historyRepair.test.ts +++ b/src/node/services/workspaceTurnManager.historyRepair.test.ts @@ -15,7 +15,6 @@ import { import type { ErrorEvent } from "@/common/types/stream"; import { createMuxMessage } from "@/common/types/message"; import { createContextBudgetWarning } from "@/node/services/contextWindowRollover"; -import { createPostCompactionAttachmentsMessage } from "@/browser/utils/messages/modelMessageTransform"; import { buildPlanReviewMetadata, formatPlanReviewEnvelope, @@ -808,18 +807,6 @@ describe("WorkspaceTurnManager", () => { sessionHistoryAvailable: true, }), }, - { - row: "post-compaction attachments row", - // The first turn after a compaction persists its attachments right after the prompt. - build: () => { - const row = createPostCompactionAttachmentsMessage( - [{ type: "todo_list", todos: [{ content: "Keep going", status: "in_progress" }] }], - 1 - ); - assert(row); - return row; - }, - }, ])("a trailing $row does not block reviving a retrying workspace turn", async ({ build }) => { // Reconciliation must treat these rows like the absence of a row: the correlated prompt is // still the newest PROMPT, so the retrying child revives the handle. diff --git a/src/node/services/workspaceTurnManager.ts b/src/node/services/workspaceTurnManager.ts index fe279121edc..959dbee9133 100644 --- a/src/node/services/workspaceTurnManager.ts +++ b/src/node/services/workspaceTurnManager.ts @@ -75,7 +75,7 @@ import { } from "@/common/types/backgroundWorkAttention"; import { createMuxMessage, - isAppendedTurnNoticeMessage, + isContextBudgetWarningMessage, isSameWorkspaceTurnTaskCorrelation, parseWorkspaceTurnTaskCorrelation, type MuxMessage, @@ -3545,12 +3545,11 @@ export class WorkspaceTurnManager { }); } // Model-hidden user rows (plan-review snapshot/resolve/reopen records) are UI state, not - // prompts: they must not read as a newer, superseding prompt that disables revival. The - // post-compaction attachments row is context sent with the prompt, not a prompt either. + // prompts: they must not read as a newer, superseding prompt that disables revival. if ( message.role !== "user" || isModelHiddenMessage(message) || - isAppendedTurnNoticeMessage(message) + isContextBudgetWarningMessage(message) ) { continue; } From a4ab88fe97e2bbad6f734fdd6be64517923a00c7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 15:56:36 +1100 Subject: [PATCH 09/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20store=20the=20post-?= =?UTF-8?q?compaction=20context=20as=20a=20boundary-row=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compaction renders the post-compaction context once and stores it on the boundary row. Every request of the window inserts the same synthetic row after the boundary, so the prefix stays stable and Anthropic keeps the replayed thinking. Keeps the context-exceeded retry without the header, the report cutoff at wall-clock time, a 30s header build limit, the header fit for continuous compaction, and plan-file re-tracking after the post-compaction clear. --- .../RightSidebar/PostCompactionSection.tsx | 8 +- .../messages/modelMessageTransform.test.ts | 164 ++-------- .../utils/messages/modelMessageTransform.ts | 75 +---- src/common/constants/attachments.ts | 3 - .../orpc/schemas/continuousCompaction.ts | 55 +--- src/common/types/durableEvent.ts | 5 +- src/common/types/message.ts | 33 ++ src/constants/compaction.ts | 6 + .../agentSession.continuousCompaction.test.ts | 7 +- ...gentSession.fileChangeNotification.test.ts | 55 +++- .../agentSession.memoryContext.test.ts | 6 +- .../agentSession.pendingOwnership.test.ts | 111 ++----- ...tSession.postCompactionAttachments.test.ts | 304 +++++++++--------- .../agentSession.postCompactionRetry.test.ts | 99 ++++-- .../agentSession.startupAutoRetry.test.ts | 1 - src/node/services/agentSession.ts | 241 ++++++-------- src/node/services/compactionHandler.test.ts | 196 ++++++++++- src/node/services/compactionHandler.ts | 104 +++++- .../sessionContextController.ts | 1 + .../contextManagement/sessionContextHost.ts | 19 +- .../strategies/continuous.ts | 20 +- .../services/continuousCompactionJournal.ts | 7 +- .../services/continuousCompactionSummary.ts | 1 - src/node/services/continuousCompactor.test.ts | 26 +- src/node/services/continuousCompactor.ts | 26 +- src/node/services/messagePipeline.ts | 38 +-- .../services/postCompactionContext.test.ts | 123 +++++++ src/node/services/postCompactionContext.ts | 172 ++++++++++ src/node/services/replay/replayFixture.ts | 10 +- .../services/replay/replayRequestBuilder.ts | 4 - src/node/services/replay/replayVerify.test.ts | 49 ++- src/node/services/replay/replayVerify.ts | 24 +- ...streamManager.continuousCompaction.test.ts | 10 +- .../services/turnContextAssembler.test.ts | 187 +++++------ src/node/services/turnContextAssembler.ts | 23 +- .../turnEngine/ProviderRequest.test.ts | 1 - .../turnEngine/RefusalFallback.test.ts | 1 - src/node/services/turnEnvelope.ts | 19 +- src/node/services/turnRequestBuilder.ts | 17 +- 39 files changed, 1317 insertions(+), 934 deletions(-) create mode 100644 src/node/services/postCompactionContext.test.ts create mode 100644 src/node/services/postCompactionContext.ts 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/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/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..04480c5e077 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,13 @@ 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[]; +} + /** * Record of a model-fallback chain application. `requestedModel` is the model * originally asked for; `refusedModels` lists every model that refused, in @@ -1222,6 +1248,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/constants/compaction.ts b/src/constants/compaction.ts index 31c183b8891..324ad63ae82 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 the post-compaction header build at stream end. 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.continuousCompaction.test.ts b/src/node/services/agentSession.continuousCompaction.test.ts index a8c0974484e..67b396d4d5a 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 () => { diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 2286c032554..07254c087a5 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import { mkdtemp, rm, stat, utimes, writeFile } from "fs/promises"; +import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from "fs/promises"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import { createMuxMessage } from "@/common/types/message"; import type { MuxMessage } from "@/common/types/message"; @@ -39,7 +39,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 +50,7 @@ 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 }; } /** Track a file via the session, then edit it externally (content + future mtime). */ @@ -116,6 +116,53 @@ describe("AgentSession file-change notification (turn start)", () => { expect(notificationRows).toHaveLength(1); }); + test("an external plan edit after compaction appends a notification", async () => { + tmpDir = await mkdtemp(join(tmpdir(), "mux-file-change-notification-")); + const previousRoot = process.env.XUM_ROOT; + process.env.XUM_ROOT = tmpDir; + try { + const { historyService, session, config } = await createSessionFixture(); + // The harness metadata is local workspace "ws" in project "project". + const planFile = join(tmpDir, "plans", "project", "ws.md"); + await mkdir(dirname(planFile), { recursive: true }); + await writeFile(planFile, "plan v1"); + const sessionDir = join(config.sessionsDir, "ws"); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + join(sessionDir, "post-compaction.json"), + JSON.stringify({ version: 1, createdAt: Date.now(), diffs: [], loadedSkills: [] }) + ); + + // The first turn after compaction consumes the pending state and clears the tracker. + const firstResult = await session.resumeStream({ + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + }); + expect(firstResult.success).toBe(true); + + await writeFile(planFile, "plan v2"); + const futureMtime = Date.now() + 1000; + await utimes(planFile, futureMtime / 1000, futureMtime / 1000); + + const secondResult = await session.resumeStream({ + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + }); + expect(secondResult.success).toBe(true); + + 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"); + } finally { + if (previousRoot === undefined) delete process.env.XUM_ROOT; + else process.env.XUM_ROOT = previousRoot; + } + }); + 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..de37f917981 100644 --- a/src/node/services/agentSession.memoryContext.test.ts +++ b/src/node/services/agentSession.memoryContext.test.ts @@ -61,7 +61,7 @@ interface PrivateSessionAccess { cache?: Map, persistence?: { windowKey: string | undefined } ) => Promise; - getPostCompactionAttachmentsIfNeeded: () => Promise; + consumePendingPostCompactionState: () => Promise; clearPostCompactionState: () => Promise; applyContextResetSideEffects: () => Promise; } @@ -346,7 +346,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 +474,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..bfc926d5d88 100644 --- a/src/node/services/agentSession.pendingOwnership.test.ts +++ b/src/node/services/agentSession.pendingOwnership.test.ts @@ -2,8 +2,6 @@ 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 { Ok } from "@/common/types/result"; import type { StreamEndEvent } from "@/common/types/stream"; @@ -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(): 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( @@ -115,24 +99,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 +109,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 +146,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..3c8d95775ce 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,81 @@ 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; } -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, + attachmentTokens: 0, + shouldPersist: () => true, + }) + ).toBe(true); + expect(build).toHaveBeenCalledTimes(1); + const attachments = await (build.mock.results[0].value as Promise); + 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 +209,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 +233,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 +320,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 +332,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 +345,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 +361,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 +372,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 +383,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 +406,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 +465,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 +493,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 +517,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 +534,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..087397f5ba5 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -24,6 +24,30 @@ 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, + }, + }; +} + +/** The header on the boundary row of one streamMessage call's request messages. */ +function requestHeader(streamMessage: ReturnType, call: number): unknown { + const { messages } = streamMessage.mock.calls[call][0] as { messages: MuxMessage[] }; + return messages.find((message) => message.id === "compaction-summary")?.metadata + ?.postCompactionContext; +} + function createPersistedPostCompactionState(options: { filePath: string; diffs: Array<{ path: string; diff: string; truncated: boolean }>; @@ -31,6 +55,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, }; @@ -69,12 +95,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", @@ -153,18 +174,8 @@ describe("AgentSession post-compaction context retry", () => { "retry-started" ); - // 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); - - const secondOpts = (streamMessage as ReturnType).mock.calls[1][0] as Record< - string, - unknown - >; - expect(secondOpts.postCompactionAttachments).toBeNull(); + expect(requestHeader(streamMessage, 0)).toEqual(HEADER); + expect(requestHeader(streamMessage, 1)).toBeUndefined(); expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( "assistant-ctx-exceeded" @@ -182,6 +193,51 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); + 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); + + expect(await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded")).toBe( + "terminal" + ); + expect(streamMessage).toHaveBeenCalledTimes(1); + + await session.dispose(); + }); + // Waiters (task/workspace-turn stream-error settlement) treat the resolved // decision as "the retry outcome is known". Resolving while retry startup is // still in flight would let a pre-stream startup failure (no further error @@ -194,6 +250,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 +358,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 +439,11 @@ describe("AgentSession post-compaction context retry", () => { ).toBe("terminal"); expect(callCount).toBe(2); + // The header stays in chat.jsonl, but later requests of this session leave it out. + await session.resumeStream(options); + expect(callCount).toBe(3); + expect(requestHeader(streamMessage, 2)).toBeUndefined(); + 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.ts b/src/node/services/agentSession.ts index b2803a015b7..7a44291abe5 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -219,11 +219,11 @@ import { type MuxMessage, type MuxMetadata, type PluginHookContextEntry, + type PostCompactionContext, 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, @@ -294,12 +294,14 @@ 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"; + findPostCompactionContextBoundaryId, + omitPostCompactionContext, + 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 +335,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"; @@ -361,7 +361,7 @@ import { } from "@/node/services/branchSummary"; import { isRuntimeReadFailure, type Runtime } from "@/node/runtime/Runtime"; import type { XumToolScope } from "@/common/types/toolScope"; -import { execBuffered } from "@/node/utils/runtime/helpers"; +import { execBuffered, readFileString } from "@/node/utils/runtime/helpers"; import { isErrnoWithCode } from "@/node/utils/fs"; import type { SessionFiles } from "@/node/services/sessionFiles/sessionFiles"; import { nodeSessionFiles } from "@/node/services/sessionFiles/nodeSessionFiles"; @@ -1729,19 +1729,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 +1786,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; + + /** + * A boundary whose header overflowed the context window once. Later requests of this session + * leave the header out, so the next turn cannot hit the same limit again (#1932). + */ + private omittedPostCompactionBoundaryId: string | undefined; + /** Backend start time for the current stream, used to avoid charging goals created mid-stream. */ private activeStreamStartedAtMs?: number; @@ -1841,9 +1838,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 +2072,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 +6553,6 @@ export class AgentSession { modelForStream, optionsForStream, undefined, - undefined, agentInitiated, preparedTurnAbortController.signal, goalKind, @@ -6763,7 +6756,6 @@ export class AgentSession { modelForStream, optionsForStream, undefined, - undefined, internal?.agentInitiated, startupController?.signal, internal?.goalKind, @@ -7350,7 +7342,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 +8761,6 @@ export class AgentSession { modelString: string, options?: ResolvedSendMessageOptions, openaiTruncationModeOverride?: "auto" | "disabled", - disablePostCompactionAttachments?: boolean, agentInitiated?: boolean, abortSignal?: AbortSignal, goalKind?: GoalSyntheticMessageKind, @@ -8841,8 +8831,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). @@ -9069,6 +9059,13 @@ export class AgentSession { } } + requestMessages = omitPostCompactionContext( + requestMessages, + this.omittedPostCompactionBoundaryId + ); + this.activeStreamPostCompactionBoundaryId = + findPostCompactionContextBoundaryId(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 +9094,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(); + 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 +9219,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 +9303,6 @@ export class AgentSession { recovery.model, rolled.data.options ?? options, openaiTruncationModeOverride, - true, agentInitiated, abortSignal, goalKind, @@ -9642,7 +9632,6 @@ export class AgentSession { context.modelString, retryOptions, openaiTruncation, - undefined, retryAgentInitiated, undefined, retryGoalKind, @@ -9680,9 +9669,14 @@ export class AgentSession { return true; } + /** + * #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 +9684,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; } @@ -9711,6 +9705,7 @@ export class AgentSession { } this.postCompactionRetryAttempts.add(requestId); + this.omittedPostCompactionBoundaryId = boundaryId; log.info("Post-compaction context hit context limit; retrying once without it", { workspaceId: this.workspaceId, @@ -9718,7 +9713,7 @@ export class AgentSession { 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. try { await this.contextController.discardFailedCarryover(pendingState); this.onPostCompactionStateChange?.(); @@ -9748,7 +9743,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 +9774,6 @@ export class AgentSession { context.modelString, context.options, context.openaiTruncationModeOverride, - true, context.agentInitiated, undefined, context.goalKind, @@ -9928,7 +9921,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 +9989,6 @@ export class AgentSession { model, rolled.data.options ?? context.options, context.openaiTruncationModeOverride, - true, context.agentInitiated, undefined, context.goalKind, @@ -13373,18 +13365,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. @@ -13479,93 +13468,63 @@ export class AgentSession { } /** - * 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 + * 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 getPostCompactionAttachmentsIfNeeded( - includeReadFiles: boolean - ): Promise { - // Check if compaction just occurred (immediate injection with cached post-compaction state) + private async consumePendingPostCompactionState(): Promise { 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(), - }); - } - - // 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; + if (pendingState === null) return; + this.pendingPostCompactionStateToAcknowledge = pendingState; + // The next stream recomputes the memory index from current files. + await this.discardMemoryContext(); + // Clear file state cache since history context is gone + this.clearFileState(); + await this.trackPlanFileAfterCompaction(); } /** - * Generate post-compaction attachments by extracting diffs and loaded skills from message history. + * The boundary header holds the plan as it was at compaction and never refreshes. Track the + * plan again, so a later external edit reaches the model as a file-change notification. + * The tracker reads the host filesystem, so only local runtimes qualify. */ - 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 trackPlanFileAfterCompaction(): Promise { + try { + const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success) return; + const runtime = this.aiService.runtimeFactory.createRuntimeForWorkspace(metadataResult.data); + if (runtime.location !== "local") return; + const { planPath } = await resolvePlanFileLocation( + this.config, + runtime, + { ...metadataResult.data, id: this.workspaceId }, + this.historyService.locks + ); + const content = await readFileString(runtime, planPath, undefined, { + requireRegularFile: true, + }); + const fileStat = await runtime.stat(planPath); + await this.recordFileState(runtime.normalizePath(planPath, "/"), { + content, + timestamp: fileStat.modifiedTime.getTime(), + }); + } catch (error) { + log.debug("Skipping plan file tracking after compaction", { error: getErrorMessage(error) }); } + } - 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(), - }); + /** Render the boundary header from the compaction's carryover; null when nothing is kept. */ + private async buildPostCompactionContext( + input: PostCompactionContextInput + ): Promise { + return renderPostCompactionContext( + await this.buildAttachmentsFromContext({ + ...input, + // 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 : [], + }) + ); } /** diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index 1a6183801f9..b2f80fa768c 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, jest, mock, spyOn } from "bun:test"; import { CompactionHandler } from "./compactionHandler"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; @@ -14,10 +14,13 @@ import { createMuxMessage, type CompactionFollowUpRequest, type MuxMessage, + type PostCompactionContext, } from "@/common/types/message"; +import type { PostCompactionContextInput } from "./postCompactionContext"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import { SESSION_HISTORY_MAX_LINE_BYTES } from "@/common/constants/contextBudget"; import { CHAT_ARCHIVE_FILE_NAME, CHAT_FILE_NAME } from "@/common/constants/paths"; +import { POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS } from "@/constants/compaction"; import assert from "@/common/utils/assert"; import { formatCompactionSummaryTruncationMarker } from "./historyRowBudget"; import { countTokens } from "@/node/utils/main/tokenizer"; @@ -141,6 +144,8 @@ const getEmittedStreamEndEvent = (events: EmittedEvent[]): StreamEndEvent | unde }); }; +const fakeTimers = jest as typeof jest & { advanceTimersByTime: (ms: number) => void }; + describe("CompactionHandler", () => { let handler: CompactionHandler; let historyService: HistoryService; @@ -2258,6 +2263,175 @@ 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(); + }); + + it("publishes the boundary without a header when the builder does not settle in time", async () => { + let markStarted!: () => void; + const started = new Promise((resolve) => (markStarted = resolve)); + installBuilder(() => { + markStarted(); + return new Promise(() => undefined); + }); + await seedHistory(createMuxMessage("u0", "user", "hello"), createCompactionRequest()); + + fakeTimers.useFakeTimers(); + let completion: Promise; + try { + completion = handler.handleCompletion(createStreamEndEvent("Summary")); + await started; + fakeTimers.advanceTimersByTime(POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS); + } finally { + fakeTimers.useRealTimers(); + } + expect(await completion).toBe(true); + + const boundary = await latestBoundary(); + expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); + }); + + 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, + attachmentTokens: 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); + }); + + it("drops a journaled header that does not fit, not summary text", async () => { + const summary = "s".repeat(SESSION_HISTORY_MAX_LINE_BYTES - 64 * 1024); + + const boundary = await persistJournaledBoundary(summary, { + text: "h".repeat(64 * 1024), + skillNames: [], + }); + + expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); + 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 +2530,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..601bb2c10ef 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -9,11 +9,21 @@ import type { CompactionFollowUpCleanupOutcome, HistoryService } from "./history import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import type { ContinuousCompactionPublication } from "./continuousCompactionJournal"; import { exactJson } from "./continuousCompactionJournal"; -import { POST_COMPACTION_STATE_FILENAME } from "@/constants/compaction"; +import { + POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS, + POST_COMPACTION_STATE_FILENAME, +} from "@/constants/compaction"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { CompactionPendingState, + type CompactionPendingAttachments, type CompactionPendingBoundaryWrite, } from "./compactionPendingState"; +import { + splitPostCompactionContext, + withPostCompactionContext, + type PostCompactionContextInput, +} from "./postCompactionContext"; import { CompactionPreparationLifecycle, type CompactionPreparation, @@ -31,12 +41,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 +222,10 @@ 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. */ + buildPostCompactionContext?: ( + input: PostCompactionContextInput + ) => Promise; } /** @@ -268,6 +284,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 +305,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 +349,8 @@ export class CompactionHandler { private async publishPreparedBoundary( preparation: CompactionPreparation, messages: MuxMessage[], - boundary: CompactionPendingBoundaryWrite + boundary: CompactionPendingBoundaryWrite, + options: { buildHeader: boolean } = { buildHeader: true } ) { messages = structuredClone(messages); boundary = { @@ -351,23 +370,65 @@ 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.buildHeader + ), + attachments, }); } + private async addPostCompactionContext( + summary: MuxMessage, + attachments: CompactionPendingAttachments, + buildHeader: boolean + ): Promise { + if (!buildHeader || !this.buildPostCompactionContext) return summary; + try { + const built = await raceWithAbortAndTimeout( + 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(), + }), + { timeoutMs: POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS } + ); + if (built.kind !== "ok") { + log.warn("Post-compaction context build timed out; publishing the boundary without it", { + workspaceId: this.workspaceId, + }); + return summary; + } + return withPostCompactionContext(summary, built.value, this.workspaceId); + } 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 @@ -934,9 +995,15 @@ export class CompactionHandler { const built = params.prepared ? structuredClone(params.prepared) : this.buildContinuousCompactionRows(params); + const unfitted = splitPostCompactionContext(built.boundary); // 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; + // after it was built, and the follow-up counts toward the row size. Fit the summary + // without the header, so a row that is too big drops the header, not summary text. + const boundary = withPostCompactionContext( + this.fitBoundarySummary(unfitted.boundary).message, + unfitted.context, + this.workspaceId + ); const copies = built.copies; const inputTokens = params.systemMessageTokens + @@ -960,7 +1027,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/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..08596dafb8e 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,10 @@ 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). */ + 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..79ce399d49f 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,6 +13,7 @@ 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 { CompactionMonitor } from "../../compactionMonitor"; import type { CompactionToken } from "../../turnCoordinator"; @@ -79,20 +78,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,10 +112,10 @@ export class ContinuousStrategy { }); } - private async buildContinuousCompactionAttachments(head: MuxMessage[]) { + private async buildContinuousPostCompactionContext(head: MuxMessage[]) { const pending = await this.handler.peekPendingState(); const warm = await this.handler.peekCarryoverState(); - return this.host.buildAttachments({ + return this.host.buildPostCompactionContext({ diffs: [...(pending?.diffs ?? []), ...extractEditedFileDiffs(head)], loadedSkills: mergeLoadedSkillSnapshots([ ...(warm?.loadedSkills ?? []), 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..4bc8d0d25d0 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, }); diff --git a/src/node/services/continuousCompactor.ts b/src/node/services/continuousCompactor.ts index 6556133d32c..419b8ca2534 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,11 @@ import { import type { HistoryService } from "./historyService"; import type { CompactionHandler } from "./compactionHandler"; import { log } from "./log"; +import { + estimatePostCompactionContextTokens, + findPostCompactionContext, + withPostCompactionContext, +} from "./postCompactionContext"; export interface ContinuousCompactionContext { enabled: boolean; @@ -69,7 +73,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. @@ -549,7 +553,7 @@ export class ContinuousCompactor { return false; const prepared = await this.deps.prepareSwap(staged.cut.head); if (!prepared || !this.isValid(staged, rows)) return false; - const { boundary, copies } = this.deps.compactionHandler.buildContinuousCompactionRows({ + const built = this.deps.compactionHandler.buildContinuousCompactionRows({ messages: rows, text: staged.text, model: staged.model, @@ -557,6 +561,13 @@ export class ContinuousCompactor { systemMessageTokens: context.systemMessageTokens ?? 0, attachmentTokens: staged.attachmentTokens, }); + // The swapped prefix and every later request render the header from this boundary. + const boundary = withPostCompactionContext( + built.boundary, + prepared.postCompactionContext, + this.deps.workspaceId + ); + const copies = built.copies; 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 +588,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 +763,7 @@ export class ContinuousCompactor { model: journal.summaryModel, tail: [], systemMessageTokens: boundary.metadata?.systemMessageTokens ?? 0, - attachmentTokens: injectPostCompactionAttachments( - [], - journal.postCompactionAttachments - ).reduce((sum, row) => sum + estimateMuxMessageTokens(row), 0), + attachmentTokens: estimatePostCompactionContextTokens(findPostCompactionContext([boundary])), // Sequence assignment must not mutate the exact journal receipt used by cleanup. prepared: { boundary, copies: [...structuredClone(journal.staticCopies), liveCopy] }, publication: { generation: journal.publicationGeneration, journal }, 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..105d02e5991 --- /dev/null +++ b/src/node/services/postCompactionContext.test.ts @@ -0,0 +1,123 @@ +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, + renderPostCompactionContext, + 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: [] }); + +describe("renderPostCompactionContext", () => { + test("returns no header without attachments", () => { + expect(renderPostCompactionContext([])).toBeNull(); + }); + + 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, + }, + ], + }, + ]); + 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 mixedNames = JSON.parse('{"text":"CTX","skillNames":["a",1]}') as PostCompactionContext; + expect(findPostCompactionContext([compactionBoundary("summary", mixedNames)])).toEqual({ + text: "CTX", + skillNames: ["a"], + }); + }); +}); + +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); + }); +}); diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts new file mode 100644 index 00000000000..8bc52d4a24e --- /dev/null +++ b/src/node/services/postCompactionContext.ts @@ -0,0 +1,172 @@ +import type { LoadedSkillSnapshot, PostCompactionAttachment } from "@/common/types/attachment"; +import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; +import { + postCompactionContextMessageId, + type MuxMessage, + type PostCompactionContext, +} from "@/common/types/message"; +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 } 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[] +): PostCompactionContext | null { + const text = renderAttachmentsToContentWithBudget(attachments, { + maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, + }); + return text.length > 0 ? { text, skillNames: loadedSnapshotSkillNames(attachments) } : null; +} + +/** The header of a compaction boundary row; a malformed persisted value counts as none. */ +function readPostCompactionContext(message: MuxMessage): PostCompactionContext | null { + if (!isDurableCompactionBoundaryMarker(message)) return null; + const context: unknown = message.metadata?.postCompactionContext; + if (typeof context !== "object" || context === null) return null; + const { text, skillNames } = context as Partial>; + if (typeof text !== "string" || text.length === 0) return null; + return { + text, + skillNames: Array.isArray(skillNames) + ? skillNames.filter((name): name is string => typeof name === "string") + : [], + }; +} + +/** 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 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; +} + +/** + * Remove the header from one boundary row of a request copy. chat.jsonl keeps the header; only + * the requests of this session leave it out (see the context-exceeded retry in AgentSession). + */ +export function omitPostCompactionContext( + messages: MuxMessage[], + boundaryId: string | undefined +): MuxMessage[] { + if (boundaryId == null) return messages; + return messages.map((message) => + message.id === boundaryId ? splitPostCompactionContext(message).boundary : message + ); +} + +/** Split a boundary row into the row without its header and the header (null without one). */ +export function splitPostCompactionContext(boundary: MuxMessage): { + boundary: MuxMessage; + context: PostCompactionContext | null; +} { + if (boundary.metadata?.postCompactionContext == null) return { boundary, context: null }; + const { postCompactionContext: context, ...metadata } = boundary.metadata; + return { boundary: { ...boundary, metadata }, context }; +} + +/** + * 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. + */ +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) { + log.warn("Post-compaction context does not fit the boundary row; dropping it", { + workspaceId, + messageId: boundary.id, + rowBytes, + }); + return boundary; + } + return withContext; +} + +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..686ee49f713 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"; @@ -90,7 +90,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). */ @@ -212,7 +211,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, @@ -316,7 +314,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 +361,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 +402,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 +415,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,39 @@ 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("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..ce79e4dc760 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"; @@ -781,18 +780,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; @@ -855,7 +854,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..409b0c17aad 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,7 +310,6 @@ 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"; @@ -367,7 +364,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 +1677,6 @@ export class TurnRequestBuilder { delegatedToolNames, recordFileState, recordProposedPlan, - postCompactionAttachments, resolveMemoryContext, workspaceGoalService, goalTurnKind, @@ -3153,9 +3148,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 +3571,6 @@ export class TurnRequestBuilder { toolNamesForSentinel: loadedToolNames, planContentForTransition, planFilePath, - postCompactionAttachments, providersConfig: seed.providersConfig, anthropicCacheTtl: effectiveAnthropicCacheTtl, workspaceId, @@ -3631,7 +3625,6 @@ export class TurnRequestBuilder { anthropicCacheTtl: effectiveAnthropicCacheTtl, planContentForTransition, planFilePath, - postCompactionAttachments, partialContinuationMessage: options.partialContinuationMessage, mcpAppModelContextMessage: mcpAppContextMessage, }); From c213bc1c28f9efa424267679478dbed0995c5cb3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 15:59:03 +1100 Subject: [PATCH 10/27] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20default=20miss?= =?UTF-8?q?ing=20compaction=20state=20once=20in=20the=20continuous=20heade?= =?UTF-8?q?r=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategies/continuous.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/node/services/contextManagement/strategies/continuous.ts b/src/node/services/contextManagement/strategies/continuous.ts index 79ce399d49f..74a3591f498 100644 --- a/src/node/services/contextManagement/strategies/continuous.ts +++ b/src/node/services/contextManagement/strategies/continuous.ts @@ -15,6 +15,7 @@ import { ContinuousCompactor, type ContinuousCompactionContext } from "../../con 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"; @@ -27,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, @@ -113,17 +120,17 @@ export class ContinuousStrategy { } private async buildContinuousPostCompactionContext(head: MuxMessage[]) { - const pending = await this.handler.peekPendingState(); - const warm = await this.handler.peekCarryoverState(); + 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)], + 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 From 030ebe41cf7799ca98615c841028b250116974d4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 16:22:41 +1100 Subject: [PATCH 11/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20baseline=20the=20pl?= =?UTF-8?q?an=20before=20the=20header=20reads=20it,=20and=20let=20replay?= =?UTF-8?q?=20match=20header-less=20retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first turn after a compaction detected file edits before it tracked the plan again, so a plan edit made between compaction and that turn never reached the model. The header build now baselines an untracked plan first. Plan tracking skips remote runtimes and a plan the user excluded. A context-exceeded retry leaves the boundary's header out of later requests, and no log records that. Replay-verify now rebuilds a mismatched turn once more without the header, and a match passes with a reason. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/cli/debug/replay-verify.ts | 8 +- .../agentSession.changeDetection.test.ts | 16 +++ ...gentSession.fileChangeNotification.test.ts | 88 +++++++++++--- src/node/services/agentSession.ts | 34 ++++-- src/node/services/replay/replayFixture.ts | 11 +- src/node/services/replay/replayVerify.test.ts | 28 +++++ src/node/services/replay/replayVerify.ts | 114 ++++++++++++------ src/node/services/utils/fileChangeTracker.ts | 6 + 8 files changed, 235 insertions(+), 70 deletions(-) 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/node/services/agentSession.changeDetection.test.ts b/src/node/services/agentSession.changeDetection.test.ts index 29a3e6b6683..0f3b83d2829 100644 --- a/src/node/services/agentSession.changeDetection.test.ts +++ b/src/node/services/agentSession.changeDetection.test.ts @@ -78,6 +78,22 @@ describe("AgentSession change detection", () => { expect(attachments[0].snippet).toContain("New step"); }); + it("recordIfUntracked keeps an existing baseline, so its unreported edit is still detected", async () => { + const tracker = new FileChangeTracker(); + const testFile = join(tmpDir, "plan.md"); + await writeFile(testFile, "v1"); + await tracker.record(testFile, { content: "v1", timestamp: await getMtime(testFile) }); + + await writeFile(testFile, "v2"); + const newMtime = Date.now() + 1000; + await utimes(testFile, newMtime / 1000, newMtime / 1000); + await tracker.recordIfUntracked(testFile, { content: "v2", timestamp: newMtime }); + + const { attachments } = await tracker.getChangedAttachments(); + expect(attachments).toHaveLength(1); + expect(attachments[0].snippet).toContain("+v2"); + }); + 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.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 07254c087a5..40b408769fa 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -8,6 +8,8 @@ import type { MuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { AgentSession } from "./agentSession"; import type { StreamMessageOptions } from "./aiService"; +import type { CompactionHandler } from "./compactionHandler"; +import type { HistoryService } from "./historyService"; import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; /** @@ -116,16 +118,79 @@ describe("AgentSession file-change notification (turn start)", () => { expect(notificationRows).toHaveLength(1); }); - test("an external plan edit after compaction appends a notification", async () => { + /** Run `body` with XUM_ROOT in a temp dir that holds the plan "plan v1" of workspace "ws". */ + async function withPlanFile(body: (planFile: string) => Promise): Promise { tmpDir = await mkdtemp(join(tmpdir(), "mux-file-change-notification-")); const previousRoot = process.env.XUM_ROOT; process.env.XUM_ROOT = tmpDir; try { - const { historyService, session, config } = await createSessionFixture(); // The harness metadata is local workspace "ws" in project "project". const planFile = join(tmpDir, "plans", "project", "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): Promise { + await writeFile(planFile, "plan v2"); + 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"); + } + + test("a plan edit between compaction and the next turn appends a notification", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + // Nothing tracks the plan before this compaction, whose boundary header holds "plan v1". + const handler = ( + session as unknown as { contextController: { compaction: CompactionHandler } } + ).contextController.compaction; + expect( + await handler.persistContinuousCompaction({ + preparation: handler.beginPreparation(() => true), + publication: { + generation: await historyService + .getContinuousCompactionJournal("ws") + .captureGeneration(), + }, + attachmentMessages: [], + messages: [], + boundaryMessageId: "boundary", + text: "summary", + model: "anthropic:claude-sonnet-4-5", + tail: [], + systemMessageTokens: 0, + attachmentTokens: 0, + shouldPersist: () => true, + }) + ).toBe(true); + + await editPlanExternally(planFile); + const result = await session.resumeStream({ + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + }); + expect(result.success).toBe(true); + await expectOnePlanNotification(historyService); + }); + }); + + test("an external plan edit after compaction appends a notification", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session, config } = await createSessionFixture(); const sessionDir = join(config.sessionsDir, "ws"); await mkdir(sessionDir, { recursive: true }); await writeFile( @@ -140,27 +205,14 @@ describe("AgentSession file-change notification (turn start)", () => { }); expect(firstResult.success).toBe(true); - await writeFile(planFile, "plan v2"); - const futureMtime = Date.now() + 1000; - await utimes(planFile, futureMtime / 1000, futureMtime / 1000); - + await editPlanExternally(planFile); const secondResult = await session.resumeStream({ model: "anthropic:claude-sonnet-4-5", agentId: "exec", }); expect(secondResult.success).toBe(true); - - 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"); - } finally { - if (previousRoot === undefined) delete process.env.XUM_ROOT; - else process.env.XUM_ROOT = previousRoot; - } + await expectOnePlanNotification(historyService); + }); }); test("a failed notification append is not dropped: retry re-detects and persists it", async () => { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 7a44291abe5..856cd388c52 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -13480,20 +13480,24 @@ export class AgentSession { await this.discardMemoryContext(); // Clear file state cache since history context is gone this.clearFileState(); - await this.trackPlanFileAfterCompaction(); + // The boundary header holds the plan as it was at compaction and never refreshes. Track the + // plan again, so a later external edit reaches the model as a file-change notification. + const plan = await this.readLocalPlanFileState(); + if (plan) await this.recordFileState(plan.path, plan.state); } /** - * The boundary header holds the plan as it was at compaction and never refreshes. Track the - * plan again, so a later external edit reaches the model as a file-change notification. - * The tracker reads the host filesystem, so only local runtimes qualify. + * The plan file's path and current state for the file-change tracker. The tracker reads the + * host filesystem, so only local runtimes qualify; null also when the user excluded the plan + * from post-compaction context or the plan cannot be read. */ - private async trackPlanFileAfterCompaction(): Promise { + private async readLocalPlanFileState(): Promise<{ path: string; state: FileState } | null> { try { + if ((await this.loadExcludedItems()).has("plan")) return null; const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); - if (!metadataResult.success) return; + if (!metadataResult.success) return null; const runtime = this.aiService.runtimeFactory.createRuntimeForWorkspace(metadataResult.data); - if (runtime.location !== "local") return; + if (runtime.location !== "local") return null; const { planPath } = await resolvePlanFileLocation( this.config, runtime, @@ -13504,12 +13508,13 @@ export class AgentSession { requireRegularFile: true, }); const fileStat = await runtime.stat(planPath); - await this.recordFileState(runtime.normalizePath(planPath, "/"), { - content, - timestamp: fileStat.modifiedTime.getTime(), - }); + return { + path: runtime.normalizePath(planPath, "/"), + state: { content, timestamp: fileStat.modifiedTime.getTime() }, + }; } catch (error) { - log.debug("Skipping plan file tracking after compaction", { error: getErrorMessage(error) }); + log.debug("Skipping plan file tracking", { error: getErrorMessage(error) }); + return null; } } @@ -13517,6 +13522,11 @@ export class AgentSession { private async buildPostCompactionContext( input: PostCompactionContextInput ): Promise { + // The first turn after the boundary detects file edits before it tracks the plan again, so + // baseline an untracked plan before the header reads it: that turn then reports an edit + // made after this snapshot. Estimates and unpublished builds only start tracking it sooner. + const plan = await this.readLocalPlanFileState(); + if (plan) await this.fileChangeTracker.recordIfUntracked(plan.path, plan.state); return renderPostCompactionContext( await this.buildAttachmentsFromContext({ ...input, diff --git a/src/node/services/replay/replayFixture.ts b/src/node/services/replay/replayFixture.ts index 686ee49f713..4687aac75c5 100644 --- a/src/node/services/replay/replayFixture.ts +++ b/src/node/services/replay/replayFixture.ts @@ -31,6 +31,10 @@ 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 { + findPostCompactionContextBoundaryId, + omitPostCompactionContext, +} from "@/node/services/postCompactionContext"; import { assemblePromptPayload } from "@/node/services/turnContextAssembler"; import { emitTurnEnvelope } from "@/node/services/turnEnvelope"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; @@ -101,6 +105,8 @@ export interface ReplayFixtureTurnSpec { systemVolatileSuffixLength?: number; /** Set false to simulate devtools logging being off for this turn. */ recordDevtools?: boolean; + /** Send the request without the boundary's header, as AgentSession does after a context-exceeded retry. */ + omitPostCompactionContext?: boolean; /** * Emit this many extra turn-envelope rows (same requestHistorySequence) * before the final one — simulates retried attempts whose streams failed. @@ -287,9 +293,12 @@ function fixtureModel(spec: ReplayFixtureTurnSpec): string { function buildFixtureRequest( ctx: ReplayFixtureSessionContext, spec: ReplayFixtureTurnSpec, - historyMessages: MuxMessage[], + history: MuxMessage[], agentId: string ) { + const historyMessages = spec.omitPostCompactionContext + ? omitPostCompactionContext(history, findPostCompactionContextBoundaryId(history)) + : history; return spec.liveToolInstructions == null ? buildReplayedFixtureRequest(ctx, spec, historyMessages, agentId) : buildLiveFixtureRequest(ctx, spec, historyMessages, agentId); diff --git a/src/node/services/replay/replayVerify.test.ts b/src/node/services/replay/replayVerify.test.ts index c52c6e0cb09..49f3eeb3470 100644 --- a/src/node/services/replay/replayVerify.test.ts +++ b/src/node/services/replay/replayVerify.test.ts @@ -92,6 +92,34 @@ describe("replayVerifySession pairing", () => { expect(devtoolsRaw).toContain("1. do the thing"); }); + test("a request sent without the boundary's header passes and says so", 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) => ({ status: turn.status, omitted: turn.reason != null })) + ).toEqual([ + { status: "PASS", omitted: false }, + { status: "PASS", omitted: true }, + ]); + // 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); diff --git a/src/node/services/replay/replayVerify.ts b/src/node/services/replay/replayVerify.ts index ce79e4dc760..2d1a268035c 100644 --- a/src/node/services/replay/replayVerify.ts +++ b/src/node/services/replay/replayVerify.ts @@ -45,6 +45,10 @@ import { } from "@/common/utils/messages/compactionBoundary"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; import type { HistoryService } from "@/node/services/historyService"; +import { + findPostCompactionContextBoundaryId, + omitPostCompactionContext, +} from "@/node/services/postCompactionContext"; import { hashToolSchema, isProviderDefinedToolRecord, @@ -353,7 +357,7 @@ export interface ReplayVerifyTurnResult { assistantMessageId: string; runId: string; status: "PASS" | "FAIL" | "SKIPPED"; - /** Present for SKIPPED (why verification was impossible). */ + /** Why verification was impossible (SKIPPED), or how the messages matched without the full log. */ reason?: string; systemPromptMatch?: boolean; toolsetMatch?: boolean; @@ -702,6 +706,34 @@ function parseAnthropicCacheTtl(value: string | undefined): AnthropicCacheTtl | return value === "5m" || value === "1h" ? value : undefined; } +const HEADER_OMITTED_REASON = + "matched without the boundary's post-compaction header (a context-exceeded retry omits it)"; + +/** + * Rebuild a turn's prompt and compare it with the recorded one. After a context-exceeded retry, + * AgentSession leaves the boundary's post-compaction header out of the rest of the window's + * requests, and no log records that. So a mismatch is rebuilt once more without the header. + */ +async function rebuildRecordedMessages( + historySlice: MuxMessage[], + recordedPrompt: unknown, + rebuild: (history: MuxMessage[]) => Promise +): Promise<{ rebuiltJson: unknown; messagesMatch: boolean; headerOmitted: boolean }> { + const recordedJson = JSON.stringify(recordedPrompt); + const rebuiltJson = await rebuild(historySlice); + if (JSON.stringify(rebuiltJson) === recordedJson) { + return { rebuiltJson, messagesMatch: true, headerOmitted: false }; + } + const boundaryId = findPostCompactionContextBoundaryId(historySlice); + if (boundaryId != null) { + const withoutHeader = await rebuild(omitPostCompactionContext(historySlice, boundaryId)); + if (JSON.stringify(withoutHeader) === recordedJson) { + return { rebuiltJson: withoutHeader, messagesMatch: true, headerOmitted: true }; + } + } + return { rebuiltJson, messagesMatch: false, headerOmitted: false }; +} + /** * Verify every turn of a session: rebuild the provider request from durable * logs and byte-compare against the recorded devtools request. @@ -830,41 +862,46 @@ export async function replayVerifySession(params: { // byte-compare against the recorded request. const historySlice = sliceEpochForTurn(params.historyMessages, turn.requestHistorySequence); - const rebuilt = await buildReplayRequest({ - historyMessages: historySlice, - systemPrompt, - modelString: envelope.data.modelString, - thinkingLevel: envelope.data.thinkingLevel as ThinkingLevel, - effectiveAgentId: turn.message.metadata?.agentId ?? "exec", - // Prefer the independently-recorded sentinel names: forced first-step - // scoping (e.g. xAI search) narrows the wire manifest while the live - // sentinel listed the full active set. Manifest names remain the - // legacy fallback for rows written before sentinelToolNames existed. - toolNamesForSentinel: - envelope.data.sentinelToolNames ?? - envelope.data.toolsetManifest.map((entry) => entry.name), - routeProvider: turn.message.metadata?.routeProvider, - providersConfig: params.providersConfig, - wireProviderName: envelope.data.wireProviderName, - anthropicCacheTtl: parseAnthropicCacheTtl(envelope.data.anthropicCacheTtl), - systemPromptVolatileSuffixLength: envelope.data.systemPromptVolatileSuffixLength, - volatileSystemTail: readVolatileSystemTailRecord(turn.message), - customInstructions: readCustomInstructionsRecord(turn.message), - skillIndex: readSkillIndexRecord(turn.message), - toolInstructions: readToolInstructionsRecord(turn.message), - planContentForTransition, - planFilePath: envelope.data.planTransitionFilePath, - partialContinuation, - mcpAppModelContext, - deferLoadingToolNames: deferLoadingNamesFromRecordedTools(record.tools), - workspaceId: params.workspaceId, - compactionInputBudgetTokens: record.compactionInputBudgetTokens, - }); - - // JSON round-trip the rebuilt prompt so both sides are plain JSON values - // (the recorded prompt already survived a devtools.jsonl round-trip). - const rebuiltJson: unknown = JSON.parse(JSON.stringify(rebuilt.lmPrompt)); - const messagesMatch = JSON.stringify(rebuiltJson) === JSON.stringify(record.prompt); + const messages = await rebuildRecordedMessages( + historySlice, + record.prompt, + async (history) => { + const rebuilt = await buildReplayRequest({ + historyMessages: history, + systemPrompt, + modelString: envelope.data.modelString, + thinkingLevel: envelope.data.thinkingLevel as ThinkingLevel, + effectiveAgentId: turn.message.metadata?.agentId ?? "exec", + // Prefer the independently-recorded sentinel names: forced first-step + // scoping (e.g. xAI search) narrows the wire manifest while the live + // sentinel listed the full active set. Manifest names remain the + // legacy fallback for rows written before sentinelToolNames existed. + toolNamesForSentinel: + envelope.data.sentinelToolNames ?? + envelope.data.toolsetManifest.map((entry) => entry.name), + routeProvider: turn.message.metadata?.routeProvider, + providersConfig: params.providersConfig, + wireProviderName: envelope.data.wireProviderName, + anthropicCacheTtl: parseAnthropicCacheTtl(envelope.data.anthropicCacheTtl), + systemPromptVolatileSuffixLength: envelope.data.systemPromptVolatileSuffixLength, + volatileSystemTail: readVolatileSystemTailRecord(turn.message), + customInstructions: readCustomInstructionsRecord(turn.message), + skillIndex: readSkillIndexRecord(turn.message), + toolInstructions: readToolInstructionsRecord(turn.message), + planContentForTransition, + planFilePath: envelope.data.planTransitionFilePath, + partialContinuation, + mcpAppModelContext, + deferLoadingToolNames: deferLoadingNamesFromRecordedTools(record.tools), + workspaceId: params.workspaceId, + compactionInputBudgetTokens: record.compactionInputBudgetTokens, + }); + // JSON round-trip the rebuilt prompt so both sides are plain JSON values + // (the recorded prompt already survived a devtools.jsonl round-trip). + return JSON.parse(JSON.stringify(rebuilt.lmPrompt)) as unknown; + } + ); + const { messagesMatch } = messages; const pass = systemPromptMatch && toolsetMatch && messagesMatch; results.push({ @@ -873,12 +910,15 @@ export async function replayVerifySession(params: { assistantMessageId: turn.message.id, runId: record.runId, status: pass ? "PASS" : "FAIL", + ...(messages.headerOmitted ? { reason: HEADER_OMITTED_REASON } : {}), systemPromptMatch, toolsetMatch, messagesMatch, ...(messagesMatch ? {} - : { divergence: findFirstJsonDivergence(rebuiltJson, record.prompt) ?? undefined }), + : { + divergence: findFirstJsonDivergence(messages.rebuiltJson, record.prompt) ?? undefined, + }), ...(toolsetMatch ? {} : { toolsetDiff: describeManifestDiff(envelope.data.toolsetManifest, recordedManifest) }), diff --git a/src/node/services/utils/fileChangeTracker.ts b/src/node/services/utils/fileChangeTracker.ts index c9b4b4ad5c0..1444faa0675 100644 --- a/src/node/services/utils/fileChangeTracker.ts +++ b/src/node/services/utils/fileChangeTracker.ts @@ -117,6 +117,12 @@ export class FileChangeTracker { this.fileState.set(canonicalPath, state); } + /** Record a file only when it has no baseline; an existing baseline may hold an unreported edit. */ + async recordIfUntracked(filePath: string, state: FileState): Promise { + const canonicalPath = await this.canonicalize(filePath); + if (!this.fileState.has(canonicalPath)) 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); From 0144d40418c1a2000d46acf559952c34e5f7aa10 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 16:42:27 +1100 Subject: [PATCH 12/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20track=20the=20plan?= =?UTF-8?q?=20after=20compaction=20only=20when=20the=20boundary=20header?= =?UTF-8?q?=20carries=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first turn after a compaction tracked the plan again based on the current exclusions. A user who changed the plan exclusion after the compaction got notifications that did not match the header: an edit to a plan the header left out, or no notification for a plan the header holds. The header decides now. Like the header, an exclusion change applies from the next compaction. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .../utils/messages/attachmentRenderer.ts | 11 +- ...gentSession.fileChangeNotification.test.ts | 126 +++++++++++------- src/node/services/agentSession.ts | 23 ++-- src/node/services/postCompactionContext.ts | 11 +- 4 files changed, 114 insertions(+), 57 deletions(-) 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/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 40b408769fa..e28d569c1f9 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -151,70 +151,106 @@ describe("AgentSession file-change notification (turn start)", () => { 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 { + const handler = (session as unknown as { contextController: { compaction: CompactionHandler } }) + .contextController.compaction; + expect( + await handler.persistContinuousCompaction({ + preparation: handler.beginPreparation(() => true), + publication: { + generation: await historyService.getContinuousCompactionJournal("ws").captureGeneration(), + }, + attachmentMessages: [], + messages: [], + boundaryMessageId: "boundary", + text: "summary", + model: "anthropic:claude-sonnet-4-5", + tail: [], + systemMessageTokens: 0, + attachmentTokens: 0, + shouldPersist: () => true, + }) + ).toBe(true); + // 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(); // Nothing tracks the plan before this compaction, whose boundary header holds "plan v1". - const handler = ( - session as unknown as { contextController: { compaction: CompactionHandler } } - ).contextController.compaction; - expect( - await handler.persistContinuousCompaction({ - preparation: handler.beginPreparation(() => true), - publication: { - generation: await historyService - .getContinuousCompactionJournal("ws") - .captureGeneration(), - }, - attachmentMessages: [], - messages: [], - boundaryMessageId: "boundary", - text: "summary", - model: "anthropic:claude-sonnet-4-5", - tail: [], - systemMessageTokens: 0, - attachmentTokens: 0, - shouldPersist: () => true, - }) - ).toBe(true); + await compact(session, historyService); await editPlanExternally(planFile); - const result = await session.resumeStream({ - model: "anthropic:claude-sonnet-4-5", - agentId: "exec", - }); - expect(result.success).toBe(true); + await resume(session); await expectOnePlanNotification(historyService); }); }); test("an external plan edit after compaction appends a notification", async () => { await withPlanFile(async (planFile) => { - const { historyService, session, config } = await createSessionFixture(); - const sessionDir = join(config.sessionsDir, "ws"); - await mkdir(sessionDir, { recursive: true }); - await writeFile( - join(sessionDir, "post-compaction.json"), - JSON.stringify({ version: 1, createdAt: Date.now(), diffs: [], loadedSkills: [] }) - ); - + const { historyService, session } = await createSessionFixture(); + await compact(session, historyService); // The first turn after compaction consumes the pending state and clears the tracker. - const firstResult = await session.resumeStream({ - model: "anthropic:claude-sonnet-4-5", - agentId: "exec", - }); - expect(firstResult.success).toBe(true); + await resume(session); await editPlanExternally(planFile); - const secondResult = await session.resumeStream({ - model: "anthropic:claude-sonnet-4-5", - agentId: "exec", - }); - expect(secondResult.success).toBe(true); + await resume(session); 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 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.ts b/src/node/services/agentSession.ts index 856cd388c52..2d7c2a8dbbe 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -299,6 +299,7 @@ import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; import { findPostCompactionContextBoundaryId, omitPostCompactionContext, + postCompactionContextCarriesPlan, renderPostCompactionContext, type PostCompactionContextInput, } from "./postCompactionContext"; @@ -9095,7 +9096,7 @@ export class AgentSession { } if (preparedRequest == null && leaseRefusal == null) { - await this.consumePendingPostCompactionState(); + await this.consumePendingPostCompactionState(historyResult.data); if (isStreamStartAborted()) { return Ok(undefined); } @@ -13472,7 +13473,7 @@ export class AgentSession { * 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(): Promise { + private async consumePendingPostCompactionState(history: readonly MuxMessage[]): Promise { const pendingState = await this.contextController.compaction.peekPendingState(); if (pendingState === null) return; this.pendingPostCompactionStateToAcknowledge = pendingState; @@ -13480,20 +13481,21 @@ export class AgentSession { await this.discardMemoryContext(); // Clear file state cache since history context is gone this.clearFileState(); - // The boundary header holds the plan as it was at compaction and never refreshes. Track the - // plan again, so a later external edit reaches the model as a file-change notification. + // The boundary header holds the plan as it was at compaction and never refreshes. When it + // carries the plan, track the plan again, so a later external edit reaches the model as a + // file-change notification. The header decides, not the current exclusions: like the + // header, a change to them applies from the next compaction. + if (!postCompactionContextCarriesPlan(history)) return; const plan = await this.readLocalPlanFileState(); if (plan) await this.recordFileState(plan.path, plan.state); } /** * The plan file's path and current state for the file-change tracker. The tracker reads the - * host filesystem, so only local runtimes qualify; null also when the user excluded the plan - * from post-compaction context or the plan cannot be read. + * host filesystem, so only local runtimes qualify; null also when the plan cannot be read. */ private async readLocalPlanFileState(): Promise<{ path: string; state: FileState } | null> { try { - if ((await this.loadExcludedItems()).has("plan")) return null; const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); if (!metadataResult.success) return null; const runtime = this.aiService.runtimeFactory.createRuntimeForWorkspace(metadataResult.data); @@ -13525,11 +13527,13 @@ export class AgentSession { // The first turn after the boundary detects file edits before it tracks the plan again, so // baseline an untracked plan before the header reads it: that turn then reports an edit // made after this snapshot. Estimates and unpublished builds only start tracking it sooner. - const plan = await this.readLocalPlanFileState(); + const excludedItems = await this.loadExcludedItems(); + const plan = excludedItems.has("plan") ? null : await this.readLocalPlanFileState(); if (plan) await this.fileChangeTracker.recordIfUntracked(plan.path, plan.state); return renderPostCompactionContext( 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 : [], @@ -13549,8 +13553,9 @@ export class AgentSession { readFilePaths: string[]; /** Cutoff for the completed-reports index: reports completed before this were summarized away. */ reportsCompletedBeforeMs: number; + excludedItems: Set; }): Promise { - const excludedItems = await this.loadExcludedItems(); + const { excludedItems } = context; const todoAttachment = await this.loadTodoListAttachment(excludedItems); // Host-side disk read (session dir), independent of workspace metadata/runtime. diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts index 8bc52d4a24e..a331f8515e2 100644 --- a/src/node/services/postCompactionContext.ts +++ b/src/node/services/postCompactionContext.ts @@ -10,7 +10,10 @@ import { COMPACTION_BOUNDARY_ROW_HEADROOM_BYTES, SESSION_HISTORY_MAX_LINE_BYTES, } from "@/common/constants/contextBudget"; -import { renderAttachmentsToContentWithBudget } from "@/browser/utils/messages/attachmentRenderer"; +import { + renderAttachmentsToContentWithBudget, + rendersPlanFileReference, +} from "@/browser/utils/messages/attachmentRenderer"; import { isDurableCompactionBoundaryMarker, isDurableContextBoundaryMarker, @@ -72,6 +75,12 @@ export function findPostCompactionContextSkillNames(messages: readonly MuxMessag return findPostCompactionContext(messages)?.skillNames ?? []; } +/** Whether the latest boundary's header carries the plan file. */ +export function postCompactionContextCarriesPlan(messages: readonly MuxMessage[]): boolean { + const context = findPostCompactionContext(messages); + return context != null && rendersPlanFileReference(context.text); +} + /** The id of the latest context boundary when its header renders into the request. */ export function findPostCompactionContextBoundaryId( messages: readonly MuxMessage[] From 847758344bf3518e2d198203dd1c07779cd40ba1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 16:55:18 +1100 Subject: [PATCH 13/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20discard=20the=20ack?= =?UTF-8?q?nowledged=20carryover=20when=20the=20retry=20leaves=20the=20hea?= =?UTF-8?q?der=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every request of a window carries the boundary header, so the context-exceeded retry can run after the window's first successful turn. That turn already acknowledged the pending snapshot, and the retry passed a null snapshot, so it discarded nothing. The next compaction then carried the same skills and reads into its header again. The retry now discards the acknowledged carryover when no pending snapshot is left. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- ...gentSession.fileChangeNotification.test.ts | 27 +--- .../agentSession.postCompactionRetry.test.ts | 140 +++++++++++------- src/node/services/agentSession.testHarness.ts | 34 +++++ .../strategies/tokenBudget.ts | 8 +- 4 files changed, 132 insertions(+), 77 deletions(-) diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index e28d569c1f9..5cb10a25ee8 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -8,9 +8,12 @@ import type { MuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { AgentSession } from "./agentSession"; import type { StreamMessageOptions } from "./aiService"; -import type { CompactionHandler } from "./compactionHandler"; import type { HistoryService } from "./historyService"; -import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; +import { + compactSessionForTest, + createAgentSessionHarness, + createStartedTurnHandle, +} from "./agentSession.testHarness"; /** * Log purity: externally-edited files must produce a durable @@ -153,25 +156,7 @@ describe("AgentSession file-change notification (turn start)", () => { /** Compact for real; the boundary header holds the plan unless exclusions.json drops it. */ async function compact(session: AgentSession, historyService: HistoryService): Promise { - const handler = (session as unknown as { contextController: { compaction: CompactionHandler } }) - .contextController.compaction; - expect( - await handler.persistContinuousCompaction({ - preparation: handler.beginPreparation(() => true), - publication: { - generation: await historyService.getContinuousCompactionJournal("ws").captureGeneration(), - }, - attachmentMessages: [], - messages: [], - boundaryMessageId: "boundary", - text: "summary", - model: "anthropic:claude-sonnet-4-5", - tail: [], - systemMessageTokens: 0, - attachmentTokens: 0, - shouldPersist: () => true, - }) - ).toBe(true); + await compactSessionForTest(session, historyService, "ws"); // A user row after the boundary lets every later resume start a turn. await historyService.appendToHistory( "ws", diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 087397f5ba5..5cb0978a6a0 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -7,11 +7,14 @@ import { Err } from "@/common/types/result"; import type { MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; +import type { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; import { + compactSessionForTest, createAgentSessionHarness, createFailedTurnHandle, createStartedTurnHandle, + sessionCompactionHandler, } from "./agentSession.testHarness"; function contextExceededResult(messageId: string) { @@ -78,49 +81,22 @@ describe("AgentSession post-compaction context retry", () => { return { historyService, config, sessionDir }; } - 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"); - - await createPersistedPostCompactionState({ - filePath: postCompactionPath, - diffs: [ - { - path: "/tmp/foo.ts", - diff: "@@ -1 +1 @@\n-foo\n+bar\n", - truncated: false, - }, - ], - }); - - const history: MuxMessage[] = [ - createHeaderBoundary(), - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Continue" }], - metadata: { timestamp: 1100 }, - }, - ]; - - for (const msg of history) { - await historyService.appendToHistory(workspaceId, msg); - } - spyOn(historyService, "deleteMessage"); - + /** + * 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 resolveSecondCall: (() => void) | undefined; - const secondCall = new Promise((resolve) => { - resolveSecondCall = resolve; + let resolveRetry: (() => void) | undefined; + const retried = new Promise((resolve) => { + resolveRetry = resolve; }); - - let callCount = 0; const streamMessage = mock((..._args: unknown[]) => { - callCount += 1; - - if (callCount === 1) { + if (streamMessage.mock.calls.length === 1) { // Simulate a provider context limit error before any deltas. aiEmitter.emit("error", { workspaceId, @@ -128,21 +104,18 @@ describe("AgentSession post-compaction context retry", () => { error: "Context length exceeded", errorType: "context_exceeded", }); - return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); } - - resolveSecondCall?.(); + resolveRetry?.(); return Promise.resolve({ success: true as const, data: createStartedTurnHandle(session.closingSignal, "assistant-retry"), }); }); - const { session } = await createAgentSessionHarness({ workspaceId, - config, - historyService, + config: fixture.config, + historyService: fixture.historyService, aiEmitter, aiServiceOverrides: { streamMessage, @@ -150,21 +123,53 @@ describe("AgentSession post-compaction context retry", () => { getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), }, }); + await beforeResume?.(session); - const options: SendMessageOptions = { + // 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; - - // Exercise the public resume facade so retry preparation has a real admitted owner. - await session.resumeStream(options); - - // Wait for the retry call to happen. + } as unknown as SendMessageOptions); await Promise.race([ - secondCall, + 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 fixture = await createSessionsFixture(workspaceId); + const { historyService } = fixture; + const postCompactionPath = path.join(fixture.sessionDir, "post-compaction.json"); + await createPersistedPostCompactionState({ + filePath: postCompactionPath, + diffs: [ + { + path: "/tmp/foo.ts", + diff: "@@ -1 +1 @@\n-foo\n+bar\n", + truncated: false, + }, + ], + }); + + const history: MuxMessage[] = [ + createHeaderBoundary(), + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }, + ]; + + for (const msg of history) { + await historyService.appendToHistory(workspaceId, msg); + } + spyOn(historyService, "deleteMessage"); + + const { session, streamMessage } = await resumeIntoContextRetry(fixture, workspaceId); expect(streamMessage).toHaveBeenCalledTimes(2); // Codex fast-retry scenario: a waiter that arrives only after the retry @@ -193,6 +198,33 @@ describe("AgentSession post-compaction context retry", () => { await session.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("does not retry when the request had no post-compaction header", async () => { const workspaceId = "ws-no-header"; const { historyService, config } = await createSessionsFixture(workspaceId); diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index a83b6faf671..5e33e91bf4a 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,36 @@ 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, + attachmentTokens: 0, + shouldPersist: () => true, + }); + assert(persisted, "Expected the compaction to persist"); +} 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 { From 7d172ac7861a7a0baffdb3a406c517ee5f058ea0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 17:06:48 +1100 Subject: [PATCH 14/27] =?UTF-8?q?=F0=9F=A4=96=20tests:=20pass=20the=20hist?= =?UTF-8?q?ory=20to=20consumePendingPostCompactionState=20in=20the=20priva?= =?UTF-8?q?te-access=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentSession.memoryContext.test.ts | 7 ++++--- src/node/services/agentSession.pendingOwnership.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/node/services/agentSession.memoryContext.test.ts b/src/node/services/agentSession.memoryContext.test.ts index de37f917981..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; - consumePendingPostCompactionState: () => 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.consumePendingPostCompactionState(); + 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.consumePendingPostCompactionState(); + 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 bfc926d5d88..26a3c694631 100644 --- a/src/node/services/agentSession.pendingOwnership.test.ts +++ b/src/node/services/agentSession.pendingOwnership.test.ts @@ -2,7 +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 { 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"; @@ -19,7 +19,7 @@ interface SessionAccess { pendingPostCompactionStateToAcknowledge: Awaited< ReturnType >; - consumePendingPostCompactionState(): Promise; + consumePendingPostCompactionState(history: readonly MuxMessage[]): Promise; clearStartupAutoRetryAbandon(): Promise; maybeRetryCompactionOnContextExceeded(): Promise; } @@ -148,7 +148,7 @@ describe("pending snapshot consumers", () => { 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(); + await session.consumePendingPostCompactionState([]); expect( session.pendingPostCompactionStateToAcknowledge?.diffs.some( (diff) => diff.path === "/b.ts" From 5827707547f6b95ad487a2786c6a9e2e2e0245f4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 17:37:34 +1100 Subject: [PATCH 15/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20time-box=20the=20he?= =?UTF-8?q?ader=20build=20inside=20the=20builder=20and=20baseline=20the=20?= =?UTF-8?q?plan=20only=20after=20it=20wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compaction handler raced the header build against a timeout, but the build kept running after it lost. A late build could then record the plan baseline in the tracker after the next window had started, so a later turn reported an edit the model never saw. The builder now owns the time box: the build reads the plan state but does not touch the tracker, and the builder records the baseline only when the build settles in time. The continuous estimate and swap builds get the same bound. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/constants/compaction.ts | 4 +- ...gentSession.fileChangeNotification.test.ts | 51 ++++++++++++++++++- src/node/services/agentSession.ts | 35 +++++++++++-- src/node/services/compactionHandler.test.ts | 29 +---------- src/node/services/compactionHandler.ts | 40 ++++++--------- .../contextManagement/sessionContextHost.ts | 5 +- 6 files changed, 101 insertions(+), 63 deletions(-) diff --git a/src/constants/compaction.ts b/src/constants/compaction.ts index 324ad63ae82..3c0f61007b4 100644 --- a/src/constants/compaction.ts +++ b/src/constants/compaction.ts @@ -2,7 +2,7 @@ export const POST_COMPACTION_STATE_FILENAME = "post-compaction.json"; /** - * Bounds the post-compaction header build at stream end. A slow remote read (plan file over SSH) - * must not hold the paid summary unpublished, where a Stop would drop it. + * 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.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 5cb10a25ee8..58680981cf6 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -1,20 +1,24 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +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 { 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 type { StreamMessageOptions } from "./aiService"; 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 * row in chat.jsonl BEFORE the provider request is built. The request itself is a @@ -236,6 +240,49 @@ describe("AgentSession file-change notification (turn start)", () => { }); }); + 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.ts b/src/node/services/agentSession.ts index 2d7c2a8dbbe..ccf4953fbbb 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -120,6 +120,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, @@ -13520,17 +13521,40 @@ export class AgentSession { } } - /** Render the boundary header from the compaction's carryover; null when nothing is kept. */ + /** + * Render the boundary header from the compaction's carryover; null when nothing is kept or + * the build does not settle in time. + */ 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; + } + const { context, plan } = built.value; // The first turn after the boundary detects file edits before it tracks the plan again, so - // baseline an untracked plan before the header reads it: that turn then reports an edit - // made after this snapshot. Estimates and unpublished builds only start tracking it sooner. + // baseline an untracked plan with the state read before the header read it: that turn then + // reports an edit made after this snapshot. Estimates and unpublished builds only start + // tracking it sooner. Only a build that wins the time box writes the baseline: a late build + // must not touch the tracker after the next window started. + if (plan) await this.fileChangeTracker.recordIfUntracked(plan.path, plan.state); + return context; + } + + /** The unbounded header build. It reads the plan state but leaves the tracker to the caller. */ + private async renderPostCompactionContextHeader(input: PostCompactionContextInput): Promise<{ + context: PostCompactionContext | null; + plan: { path: string; state: FileState } | null; + }> { const excludedItems = await this.loadExcludedItems(); const plan = excludedItems.has("plan") ? null : await this.readLocalPlanFileState(); - if (plan) await this.fileChangeTracker.recordIfUntracked(plan.path, plan.state); - return renderPostCompactionContext( + const context = renderPostCompactionContext( await this.buildAttachmentsFromContext({ ...input, excludedItems, @@ -13539,6 +13563,7 @@ export class AgentSession { readFilePaths: this.isRlmCompactionEnabled() ? input.readFilePaths : [], }) ); + return { context, plan }; } /** diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index b2f80fa768c..06d4b68e965 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, jest, mock, spyOn } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import { CompactionHandler } from "./compactionHandler"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; @@ -20,7 +20,6 @@ import type { PostCompactionContextInput } from "./postCompactionContext"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import { SESSION_HISTORY_MAX_LINE_BYTES } from "@/common/constants/contextBudget"; import { CHAT_ARCHIVE_FILE_NAME, CHAT_FILE_NAME } from "@/common/constants/paths"; -import { POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS } from "@/constants/compaction"; import assert from "@/common/utils/assert"; import { formatCompactionSummaryTruncationMarker } from "./historyRowBudget"; import { countTokens } from "@/node/utils/main/tokenizer"; @@ -144,8 +143,6 @@ const getEmittedStreamEndEvent = (events: EmittedEvent[]): StreamEndEvent | unde }); }; -const fakeTimers = jest as typeof jest & { advanceTimersByTime: (ms: number) => void }; - describe("CompactionHandler", () => { let handler: CompactionHandler; let historyService: HistoryService; @@ -2340,30 +2337,6 @@ describe("CompactionHandler", () => { expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); }); - it("publishes the boundary without a header when the builder does not settle in time", async () => { - let markStarted!: () => void; - const started = new Promise((resolve) => (markStarted = resolve)); - installBuilder(() => { - markStarted(); - return new Promise(() => undefined); - }); - await seedHistory(createMuxMessage("u0", "user", "hello"), createCompactionRequest()); - - fakeTimers.useFakeTimers(); - let completion: Promise; - try { - completion = handler.handleCompletion(createStreamEndEvent("Summary")); - await started; - fakeTimers.advanceTimersByTime(POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS); - } finally { - fakeTimers.useRealTimers(); - } - expect(await completion).toBe(true); - - const boundary = await latestBoundary(); - expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); - }); - async function persistJournaledBoundary( text: string, journaledHeader: PostCompactionContext | null diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 601bb2c10ef..8a38b23bb80 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -9,11 +9,7 @@ import type { CompactionFollowUpCleanupOutcome, HistoryService } from "./history import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import type { ContinuousCompactionPublication } from "./continuousCompactionJournal"; import { exactJson } from "./continuousCompactionJournal"; -import { - POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS, - POST_COMPACTION_STATE_FILENAME, -} from "@/constants/compaction"; -import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; +import { POST_COMPACTION_STATE_FILENAME } from "@/constants/compaction"; import { CompactionPendingState, type CompactionPendingAttachments, @@ -222,7 +218,10 @@ 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. */ + /** + * 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; @@ -400,25 +399,16 @@ export class CompactionHandler { ): Promise { if (!buildHeader || !this.buildPostCompactionContext) return summary; try { - const built = await raceWithAbortAndTimeout( - 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(), - }), - { timeoutMs: POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS } - ); - if (built.kind !== "ok") { - log.warn("Post-compaction context build timed out; publishing the boundary without it", { - workspaceId: this.workspaceId, - }); - return summary; - } - return withPostCompactionContext(summary, built.value, this.workspaceId); + 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(), + }); + return withPostCompactionContext(summary, built, this.workspaceId); } 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", { diff --git a/src/node/services/contextManagement/sessionContextHost.ts b/src/node/services/contextManagement/sessionContextHost.ts index 08596dafb8e..7f7647d5358 100644 --- a/src/node/services/contextManagement/sessionContextHost.ts +++ b/src/node/services/contextManagement/sessionContextHost.ts @@ -94,7 +94,10 @@ export interface SessionContextHost { continuation: CompactionContinuation ): Promise; dispatchPendingFollowUp(summaryId: string | null, admissionStale: () => boolean): Promise; - /** Builds the header that a new compaction boundary row stores (null: nothing to restore). */ + /** + * 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; From 4c01302a3a6a0a81f62cc52f90921b5a3560ae5c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 18:08:28 +1100 Subject: [PATCH 16/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20baseline=20the=20pl?= =?UTF-8?q?an=20the=20header=20rendered,=20at=20its=20real=20path,=20insid?= =?UTF-8?q?e=20the=20time=20box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header build read the plan twice: once for the tracker baseline and once for the header. An edit between the two reads gave a baseline that differed from the header. The build now reads the plan once with AttachmentService.readPlan, which stats before it reads, falls back to the legacy path without moving the file, and returns the path it read. The header and the baseline both use that read, and the first-turn re-track uses the same reader, so a plan at the legacy path is tracked too. The real-path lookup for the baseline ran after the build won the time box, so a slow filesystem could delay the boundary. The build now resolves the path inside the box and returns a synchronous write that the builder calls only after a win. Manual, idle and heartbeat boundaries now add the header's token estimate to their contextUsage, and the compaction stream-end event carries the persisted estimate, so the context meter and the next compaction check count the header. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .../agentSession.changeDetection.test.ts | 11 +- ...gentSession.fileChangeNotification.test.ts | 117 +++++++++++++++++- ...tSession.postCompactionAttachments.test.ts | 8 +- src/node/services/agentSession.ts | 104 +++++++++------- src/node/services/attachmentService.ts | 100 ++++++++------- src/node/services/compactionHandler.test.ts | 21 +++- src/node/services/compactionHandler.ts | 62 +++++++--- src/node/services/utils/fileChangeTracker.ts | 12 +- ...rkspaceService.remotePlanNamespace.test.ts | 4 +- tests/ipc/planFileNonRegular.test.ts | 11 +- 10 files changed, 326 insertions(+), 124 deletions(-) diff --git a/src/node/services/agentSession.changeDetection.test.ts b/src/node/services/agentSession.changeDetection.test.ts index 0f3b83d2829..952b9657b51 100644 --- a/src/node/services/agentSession.changeDetection.test.ts +++ b/src/node/services/agentSession.changeDetection.test.ts @@ -78,16 +78,21 @@ describe("AgentSession change detection", () => { expect(attachments[0].snippet).toContain("New step"); }); - it("recordIfUntracked keeps an existing baseline, so its unreported edit is still detected", async () => { + it("prepareRecordIfUntracked keeps a baseline that exists when it writes, so its unreported edit is still detected", async () => { const tracker = new FileChangeTracker(); const testFile = join(tmpDir, "plan.md"); await writeFile(testFile, "v1"); - await tracker.record(testFile, { content: "v1", timestamp: await getMtime(testFile) }); + const v1 = { content: "v1", timestamp: await getMtime(testFile) }; await writeFile(testFile, "v2"); const newMtime = Date.now() + 1000; await utimes(testFile, newMtime / 1000, newMtime / 1000); - await tracker.recordIfUntracked(testFile, { content: "v2", timestamp: newMtime }); + const recordV2 = await tracker.prepareRecordIfUntracked(testFile, { + content: "v2", + timestamp: newMtime, + }); + await tracker.record(testFile, v1); + recordV2(); const { attachments } = await tracker.getChangedAttachments(); expect(attachments).toHaveLength(1); diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 58680981cf6..30a20ad7667 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -8,9 +8,11 @@ import { createMuxMessage } 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 type { HistoryService } from "./historyService"; import type { PostCompactionContextInput } from "./postCompactionContext"; +import type { FileChangeTracker } from "./utils/fileChangeTracker"; import { compactSessionForTest, createAgentSessionHarness, @@ -125,14 +127,23 @@ 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". */ - async function withPlanFile(body: (planFile: string) => Promise): Promise { + /** + * 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 = join(tmpDir, "plans", "project", "ws.md"); + 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); @@ -142,8 +153,8 @@ describe("AgentSession file-change notification (turn start)", () => { } } - async function editPlanExternally(planFile: string): Promise { - await writeFile(planFile, "plan v2"); + 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); } @@ -240,6 +251,102 @@ describe("AgentSession file-change notification (turn start)", () => { }); }); + 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 baseline resolved after the time box is not written", async () => { + await withPlanFile(async (planFile) => { + const { historyService, session } = await createSessionFixture(); + const internals = session as unknown as { + buildPostCompactionContext( + input: PostCompactionContextInput + ): Promise; + fileChangeTracker: FileChangeTracker; + }; + const tracker = internals.fileChangeTracker; + const prepare = tracker.prepareRecordIfUntracked.bind(tracker); + let reachPrepare!: () => void; + const prepareReached = new Promise((resolve) => (reachPrepare = resolve)); + let releasePrepare!: () => void; + const prepareReleased = new Promise((resolve) => (releasePrepare = resolve)); + let latePrepare: Promise<() => void> | undefined; + spyOn(tracker, "prepareRecordIfUntracked").mockImplementationOnce((path, state) => { + reachPrepare(); + latePrepare = prepareReleased.then(() => prepare(path, state)); + return latePrepare; + }); + const input = { + diffs: [], + loadedSkills: [], + readFilePaths: [], + reportsCompletedBeforeMs: Date.now(), + }; + + fakeTimers.useFakeTimers(); + let built: Promise; + try { + built = internals.buildPostCompactionContext(input); + // The header read the plan; resolving its path outlives the time box. + await prepareReached; + fakeTimers.advanceTimersByTime(POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS); + } finally { + fakeTimers.useRealTimers(); + } + releasePrepare(); + expect(await built).toBeNull(); + + await latePrepare; + await editPlanExternally(planFile); + 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(); diff --git a/src/node/services/agentSession.postCompactionAttachments.test.ts b/src/node/services/agentSession.postCompactionAttachments.test.ts index 3c8d95775ce..dc02b84c09d 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -127,7 +127,9 @@ async function createSessionForHistory( interface PrivateSessionAccess { contextController: { compactionHandler: CompactionHandler }; - buildAttachmentsFromContext: (context: unknown) => Promise; + buildAttachmentsFromContext: ( + context: unknown + ) => Promise<{ attachments: PostCompactionAttachment[] }>; } /** @@ -164,7 +166,9 @@ async function compact( }) ).toBe(true); expect(build).toHaveBeenCalledTimes(1); - const attachments = await (build.mock.results[0].value as Promise); + 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); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index ccf4953fbbb..e67db9a8934 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -287,7 +287,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 { @@ -363,7 +363,7 @@ import { } from "@/node/services/branchSummary"; import { isRuntimeReadFailure, type Runtime } from "@/node/runtime/Runtime"; import type { XumToolScope } from "@/common/types/toolScope"; -import { execBuffered, readFileString } from "@/node/utils/runtime/helpers"; +import { execBuffered } from "@/node/utils/runtime/helpers"; import { isErrnoWithCode } from "@/node/utils/fs"; import type { SessionFiles } from "@/node/services/sessionFiles/sessionFiles"; import { nodeSessionFiles } from "@/node/services/sessionFiles/nodeSessionFiles"; @@ -555,6 +555,21 @@ function overflowedModel(request: { modelString: string }, failedModel: string | return failedModel ?? request.modelString; } +/** A plan's path and state for the file-change tracker. */ +interface TrackedPlanState { + path: string; + state: FileState; +} + +/** The tracker state of a plan read with a stat first; null without one. */ +function trackedPlanState(runtime: Runtime, plan: PlanFileRead | null): TrackedPlanState | null { + if (plan?.modifiedTimeMs == null) return null; + return { + path: runtime.normalizePath(plan.planFilePath, "/"), + state: { content: plan.planContent, timestamp: plan.modifiedTimeMs }, + }; +} + function isProviderVisibleUserRow(message: MuxMessage): boolean { return ( message.role === "user" && isProviderEligibleMessage(message) && !isModelHiddenMessage(message) @@ -13495,26 +13510,22 @@ export class AgentSession { * The plan file's path and current state for the file-change tracker. The tracker reads the * host filesystem, so only local runtimes qualify; null also when the plan cannot be read. */ - private async readLocalPlanFileState(): Promise<{ path: string; state: FileState } | null> { + private async readLocalPlanFileState(): Promise { try { const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); if (!metadataResult.success) return null; const runtime = this.aiService.runtimeFactory.createRuntimeForWorkspace(metadataResult.data); if (runtime.location !== "local") return null; - const { planPath } = await resolvePlanFileLocation( + const location = await resolvePlanFileLocation( this.config, runtime, { ...metadataResult.data, id: this.workspaceId }, this.historyService.locks ); - const content = await readFileString(runtime, planPath, undefined, { - requireRegularFile: true, - }); - const fileStat = await runtime.stat(planPath); - return { - path: runtime.normalizePath(planPath, "/"), - state: { content, timestamp: fileStat.modifiedTime.getTime() }, - }; + return trackedPlanState( + runtime, + await AttachmentService.readPlan(location, runtime, { statFirst: true }) + ); } catch (error) { log.debug("Skipping plan file tracking", { error: getErrorMessage(error) }); return null; @@ -13537,33 +13548,38 @@ export class AgentSession { }); return null; } - const { context, plan } = built.value; + const { context, recordPlan } = built.value; // The first turn after the boundary detects file edits before it tracks the plan again, so - // baseline an untracked plan with the state read before the header read it: that turn then - // reports an edit made after this snapshot. Estimates and unpublished builds only start - // tracking it sooner. Only a build that wins the time box writes the baseline: a late build - // must not touch the tracker after the next window started. - if (plan) await this.fileChangeTracker.recordIfUntracked(plan.path, plan.state); + // baseline an untracked plan with the state the header rendered: that turn then reports an + // edit made after this snapshot. Estimates and unpublished builds only start tracking it + // sooner. Only a build that wins the time box writes the baseline, and the write does not + // await: a late build must not touch the tracker after the next window started. + recordPlan?.(); return context; } - /** The unbounded header build. It reads the plan state but leaves the tracker to the caller. */ + /** + * The unbounded header build. It resolves the plan baseline but leaves the write to the caller, + * as recordPlan. + */ private async renderPostCompactionContextHeader(input: PostCompactionContextInput): Promise<{ context: PostCompactionContext | null; - plan: { path: string; state: FileState } | null; + recordPlan: (() => void) | null; }> { const excludedItems = await this.loadExcludedItems(); - const plan = excludedItems.has("plan") ? null : await this.readLocalPlanFileState(); - const context = renderPostCompactionContext( - 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 { context, plan }; + 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 { + context: renderPostCompactionContext(attachments), + recordPlan: plan + ? await this.fileChangeTracker.prepareRecordIfUntracked(plan.path, plan.state) + : null, + }; } /** @@ -13579,7 +13595,11 @@ export class AgentSession { /** Cutoff for the completed-reports index: reports completed before this were summarized away. */ reportsCompletedBeforeMs: number; excludedItems: Set; - }): Promise { + }): Promise<{ + attachments: PostCompactionAttachment[]; + /** The plan the attachments carry, for the file-change tracker; local runtimes only. */ + plan: TrackedPlanState | null; + }> { const { excludedItems } = context; const todoAttachment = await this.loadTodoListAttachment(excludedItems); @@ -13638,17 +13658,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, @@ -13675,7 +13695,7 @@ export class AgentSession { attachments.push(artifactsIndexAttachment); } - return attachments; + return { attachments, plan: trackedPlanState(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.test.ts b/src/node/services/compactionHandler.test.ts index 06d4b68e965..697e92aa4e0 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -16,7 +16,10 @@ import { type MuxMessage, type PostCompactionContext, } from "@/common/types/message"; -import type { PostCompactionContextInput } from "./postCompactionContext"; +import { + estimatePostCompactionContextTokens, + type PostCompactionContextInput, +} from "./postCompactionContext"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import { SESSION_HISTORY_MAX_LINE_BYTES } from "@/common/constants/contextBudget"; import { CHAT_ARCHIVE_FILE_NAME, CHAT_FILE_NAME } from "@/common/constants/paths"; @@ -2337,6 +2340,22 @@ describe("CompactionHandler", () => { 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); + }); + async function persistJournaledBoundary( text: string, journaledHeader: PostCompactionContext | null diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 8a38b23bb80..091ed7081da 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -16,6 +16,7 @@ import { type CompactionPendingBoundaryWrite, } from "./compactionPendingState"; import { + estimatePostCompactionContextTokens, splitPostCompactionContext, withPostCompactionContext, type PostCompactionContextInput, @@ -273,6 +274,35 @@ function pickTailCopyMetadata(source: RowMetadata | undefined): Partial; } +/** Whether a boundary publication builds the header, and adds it to the boundary's contextUsage. */ +interface HeaderOptions { + buildHeader: boolean; + countHeader: boolean; +} + +/** + * Add the header's tokens to the boundary's estimate: the context meter and the compaction check + * of the next send read it, and every request of the window carries the header. + */ +function withHeaderInContextUsage(boundary: MuxMessage): MuxMessage { + const usage = boundary.metadata?.contextUsage; + if (!usage) return boundary; + const headerTokens = estimatePostCompactionContextTokens( + boundary.metadata?.postCompactionContext + ); + return { + ...boundary, + metadata: { + ...boundary.metadata, + contextUsage: { + ...usage, + inputTokens: (usage.inputTokens ?? 0) + headerTokens, + totalTokens: (usage.totalTokens ?? 0) + headerTokens, + }, + }, + }; +} + export class CompactionHandler { private readonly workspaceId: string; private readonly historyService: HistoryService; @@ -349,7 +379,7 @@ export class CompactionHandler { preparation: CompactionPreparation, messages: MuxMessage[], boundary: CompactionPendingBoundaryWrite, - options: { buildHeader: boolean } = { buildHeader: true } + options: HeaderOptions = { buildHeader: true, countHeader: true } ) { messages = structuredClone(messages); boundary = { @@ -386,7 +416,7 @@ export class CompactionHandler { summaryMessage: await this.addPostCompactionContext( boundary.summaryMessage, attachments, - options.buildHeader + options ), attachments, }); @@ -395,9 +425,9 @@ export class CompactionHandler { private async addPostCompactionContext( summary: MuxMessage, attachments: CompactionPendingAttachments, - buildHeader: boolean + options: HeaderOptions ): Promise { - if (!buildHeader || !this.buildPostCompactionContext) return summary; + if (!options.buildHeader || !this.buildPostCompactionContext) return summary; try { const built = await this.buildPostCompactionContext({ diffs: attachments.diffs, @@ -408,7 +438,10 @@ export class CompactionHandler { // times come from Date.now() (#7282). reportsCompletedBeforeMs: nodeClock.nowMs(), }); - return withPostCompactionContext(summary, built, this.workspaceId); + const withContext = withPostCompactionContext(summary, built, this.workspaceId); + return options.countHeader && withContext !== summary + ? withHeaderInContextUsage(withContext) + : withContext; } 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", { @@ -766,13 +799,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; } @@ -1019,8 +1052,9 @@ export class CompactionHandler { 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 } + // would change that prefix after the stream sent it. inputTokens already counts + // attachmentTokens, so the header is not counted again. + { buildHeader: !params.prepared, countHeader: false } ); if (!result.success) { log.warn("[continuous-compaction] persist failed", result.error); diff --git a/src/node/services/utils/fileChangeTracker.ts b/src/node/services/utils/fileChangeTracker.ts index 1444faa0675..4c6bbeb68a8 100644 --- a/src/node/services/utils/fileChangeTracker.ts +++ b/src/node/services/utils/fileChangeTracker.ts @@ -117,10 +117,16 @@ export class FileChangeTracker { this.fileState.set(canonicalPath, state); } - /** Record a file only when it has no baseline; an existing baseline may hold an unreported edit. */ - async recordIfUntracked(filePath: string, state: FileState): Promise { + /** + * Resolve the path now and return a write that records the file only when it has no baseline + * then; an existing baseline may hold an unreported edit. The write does not await, so a caller + * can resolve inside a time box and write only if the box was met. + */ + async prepareRecordIfUntracked(filePath: string, state: FileState): Promise<() => void> { const canonicalPath = await this.canonicalize(filePath); - if (!this.fileState.has(canonicalPath)) this.fileState.set(canonicalPath, state); + return () => { + if (!this.fileState.has(canonicalPath)) this.fileState.set(canonicalPath, state); + }; } /** Capture only these accepted states, before later tool reads can replace their baseline. */ 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..0b8c132608f 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 From 83c6ace4c8ba1c7792c607eec3196183c9e83b56 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 18:21:47 +1100 Subject: [PATCH 17/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20do=20not=20retry=20?= =?UTF-8?q?without=20the=20header=20when=20the=20carryover=20discard=20fai?= =?UTF-8?q?ls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If discarding the acknowledged carryover throws, the context retry no longer omits the header and retries. Once the header is omitted, no later request reaches the retry path to discard the carryover, so the next compaction would merge it again. The turn now fails with the original context error, and the next request still carries the header and can try the discard again. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .../agentSession.postCompactionRetry.test.ts | 54 +++++++++++++++++++ src/node/services/agentSession.ts | 19 ++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 5cb0978a6a0..1f6da6f2585 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -225,6 +225,60 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); + test("does not retry without the header when the carryover discard fails", 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[]) => { + 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: 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")); + + await session.resumeStream({ + model: "openai:gpt-4o", + agentId: "exec", + } as unknown as SendMessageOptions); + + // A headerless retry would leave the carryover for the next compaction to merge again. + expect(await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded")).toBe( + "terminal" + ); + expect(streamMessage).toHaveBeenCalledTimes(1); + expect(await compaction.peekCarryoverState()).not.toBeNull(); + + await session.dispose(); + }); + test("does not retry when the request had no post-compaction header", async () => { const workspaceId = "ws-no-header"; const { historyService, config } = await createSessionsFixture(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e67db9a8934..e26fb5bb2e7 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9722,24 +9722,27 @@ export class AgentSession { } this.postCompactionRetryAttempts.add(requestId); - this.omittedPostCompactionBoundaryId = boundaryId; - - log.info("Post-compaction context hit context limit; retrying once without it", { - workspaceId: this.workspaceId, - requestId, - model: context.modelString, - }); // The next compaction must not carry the same context forward again. + // Fail closed if the discard throws: once the header is omitted, 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?.(); } catch (error) { - log.warn("Failed to discard pending post-compaction state", { + log.warn("Failed to discard pending post-compaction state; not retrying without it", { workspaceId: this.workspaceId, error: getErrorMessage(error), }); + return false; } + this.omittedPostCompactionBoundaryId = boundaryId; + + 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(); From 6dadf7d10306122739492cf85f6536e3fcb00b24 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 18:32:00 +1100 Subject: [PATCH 18/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20let=20a=20turn's=20?= =?UTF-8?q?retry=20recover=20after=20a=20failed=20carryover=20discard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the carryover discard fails, the context retry now forgets its retry marker for the request. A later retry of the same user turn can then discard the carryover and retry without the header, instead of failing with the context error every time. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .../agentSession.postCompactionRetry.test.ts | 33 +++++++++++++------ src/node/services/agentSession.ts | 2 ++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 1f6da6f2585..a51a4055b4d 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -225,7 +225,7 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); - test("does not retry without the header when the carryover discard fails", async () => { + 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( @@ -235,13 +235,20 @@ describe("AgentSession post-compaction context retry", () => { 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", + messageId: `assistant-ctx-exceeded-${call}`, error: "Context length exceeded", errorType: "context_exceeded", }); - return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); + return Promise.resolve(contextExceededResult(`assistant-ctx-exceeded-${call}`)); }); const { session } = await createAgentSessionHarness({ workspaceId, @@ -263,19 +270,25 @@ describe("AgentSession post-compaction context retry", () => { 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({ - 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")).toBe( - "terminal" - ); + 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(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e26fb5bb2e7..e9be7e39a03 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9734,6 +9734,8 @@ export class AgentSession { 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; } this.omittedPostCompactionBoundaryId = boundaryId; From b33b1334bb9f91bf29e49a4e44d370334eebd1f8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 18:42:11 +1100 Subject: [PATCH 19/27] =?UTF-8?q?=F0=9F=A4=96=20tests:=20expect=20readPlan?= =?UTF-8?q?'s=20plan=20state=20in=20the=20FIFO=20plan=20integration=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AttachmentService.readPlan returns the plan path, content and modified time, not a plan_file_reference attachment. The integration test called the new reader but still expected the old attachment shape. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- tests/ipc/planFileNonRegular.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ipc/planFileNonRegular.test.ts b/tests/ipc/planFileNonRegular.test.ts index 0b8c132608f..698f29634e5 100644 --- a/tests/ipc/planFileNonRegular.test.ts +++ b/tests/ipc/planFileNonRegular.test.ts @@ -147,17 +147,17 @@ describeIntegration("plan file readers on a non-regular plan path", () => { await fs.writeFile(planPath, "# canonical\n"); const canonical = await generate(); expect(canonical).toEqual({ - type: "plan_file_reference", planFilePath: getPlanFilePath(workspaceName, projectName, runtime.getXumHome()), planContent: "# canonical\n", + modifiedTimeMs: expect.any(Number), }); await fs.rm(planPath); await fs.writeFile(legacyPlanPath, "# legacy\n"); const legacy = await generate(); expect(legacy).toEqual({ - type: "plan_file_reference", planFilePath: getLegacyPlanFilePath(workspaceId, runtime.getXumHome()), planContent: "# legacy\n", + modifiedTimeMs: expect.any(Number), }); await fs.rm(legacyPlanPath); }, 60_000); From 9d8597d6e9f99302b0b2d9ce8ea7e0d255525cc2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 19:20:17 +1100 Subject: [PATCH 20/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20persist=20the=20hea?= =?UTF-8?q?der=20omission=20and=20the=20plan=20baseline=20on=20the=20bound?= =?UTF-8?q?ary=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context-exceeded retry now marks the boundary row with the first request sequence that leaves the header out. Later requests leave it out too, also after a restart, and replay-verify rebuilds the earlier requests with it. The header also stores the plan as it read. Turn start tracks the plan from it, so plan edits after the snapshot get a change notice, even before the next turn or after a restart. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/common/types/message.ts | 14 ++ src/common/utils/readStringArray.ts | 6 + src/common/utils/tools/tools.ts | 7 +- .../agentSession.changeDetection.test.ts | 32 ++-- ...gentSession.fileChangeNotification.test.ts | 85 ++++----- .../agentSession.postCompactionRetry.test.ts | 116 +++++++++++- src/node/services/agentSession.ts | 172 +++++++++--------- .../services/postCompactionContext.test.ts | 88 +++++++-- src/node/services/postCompactionContext.ts | 130 ++++++++++--- src/node/services/replay/replayFixture.ts | 27 ++- src/node/services/replay/replayVerify.test.ts | 9 +- src/node/services/replay/replayVerify.ts | 121 +++++------- src/node/services/utils/fileChangeTracker.ts | 20 +- 13 files changed, 516 insertions(+), 311 deletions(-) create mode 100644 src/common/utils/readStringArray.ts diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 04480c5e077..9cdc7b37500 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -1058,6 +1058,20 @@ 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; } /** 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/node/services/agentSession.changeDetection.test.ts b/src/node/services/agentSession.changeDetection.test.ts index 952b9657b51..77b05d7fbae 100644 --- a/src/node/services/agentSession.changeDetection.test.ts +++ b/src/node/services/agentSession.changeDetection.test.ts @@ -78,25 +78,27 @@ describe("AgentSession change detection", () => { expect(attachments[0].snippet).toContain("New step"); }); - it("prepareRecordIfUntracked keeps a baseline that exists when it writes, so its unreported edit is still detected", async () => { + it("clearExcept keeps only the named file's baseline, so only its edit is still detected", async () => { const tracker = new FileChangeTracker(); - const testFile = join(tmpDir, "plan.md"); - await writeFile(testFile, "v1"); - const v1 = { content: "v1", timestamp: await getMtime(testFile) }; - - await writeFile(testFile, "v2"); + 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; - await utimes(testFile, newMtime / 1000, newMtime / 1000); - const recordV2 = await tracker.prepareRecordIfUntracked(testFile, { - content: "v2", - timestamp: newMtime, - }); - await tracker.record(testFile, v1); - recordV2(); + 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).toHaveLength(1); - expect(attachments[0].snippet).toContain("+v2"); + expect(attachments.map((attachment) => attachment.filename)).toEqual([planFile]); }); it("should update stored state only after commit()", async () => { diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 30a20ad7667..6b976b24ef8 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -12,7 +12,6 @@ import { AttachmentService } from "./attachmentService"; import type { StreamMessageOptions } from "./aiService"; import type { HistoryService } from "./historyService"; import type { PostCompactionContextInput } from "./postCompactionContext"; -import type { FileChangeTracker } from "./utils/fileChangeTracker"; import { compactSessionForTest, createAgentSessionHarness, @@ -64,6 +63,25 @@ describe("AgentSession file-change notification (turn start)", () => { 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). */ async function trackAndEditExternally(session: AgentSession, dir: string): Promise { const trackedFile = join(dir, "plan.md"); @@ -202,6 +220,8 @@ describe("AgentSession file-change notification (turn start)", () => { 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); @@ -224,6 +244,21 @@ describe("AgentSession file-change notification (turn start)", () => { }); }); + 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(); @@ -299,54 +334,6 @@ describe("AgentSession file-change notification (turn start)", () => { }); }); - test("a plan baseline resolved after the time box is not written", async () => { - await withPlanFile(async (planFile) => { - const { historyService, session } = await createSessionFixture(); - const internals = session as unknown as { - buildPostCompactionContext( - input: PostCompactionContextInput - ): Promise; - fileChangeTracker: FileChangeTracker; - }; - const tracker = internals.fileChangeTracker; - const prepare = tracker.prepareRecordIfUntracked.bind(tracker); - let reachPrepare!: () => void; - const prepareReached = new Promise((resolve) => (reachPrepare = resolve)); - let releasePrepare!: () => void; - const prepareReleased = new Promise((resolve) => (releasePrepare = resolve)); - let latePrepare: Promise<() => void> | undefined; - spyOn(tracker, "prepareRecordIfUntracked").mockImplementationOnce((path, state) => { - reachPrepare(); - latePrepare = prepareReleased.then(() => prepare(path, state)); - return latePrepare; - }); - const input = { - diffs: [], - loadedSkills: [], - readFilePaths: [], - reportsCompletedBeforeMs: Date.now(), - }; - - fakeTimers.useFakeTimers(); - let built: Promise; - try { - built = internals.buildPostCompactionContext(input); - // The header read the plan; resolving its path outlives the time box. - await prepareReached; - fakeTimers.advanceTimersByTime(POST_COMPACTION_CONTEXT_BUILD_TIMEOUT_MS); - } finally { - fakeTimers.useRealTimers(); - } - releasePrepare(); - expect(await built).toBeNull(); - - await latePrepare; - await editPlanExternally(planFile); - 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(); diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index a51a4055b4d..93247ac1fb7 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -5,9 +5,10 @@ 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, @@ -44,11 +45,12 @@ function createHeaderBoundary(): MuxMessage { }; } -/** The header on the boundary row of one streamMessage call's request messages. */ -function requestHeader(streamMessage: ReturnType, call: number): unknown { +/** 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 messages.find((message) => message.id === "compaction-summary")?.metadata - ?.postCompactionContext; + return insertPostCompactionContext(messages).some( + (message) => message.id === postCompactionContextMessageId("compaction-summary") + ); } function createPersistedPostCompactionState(options: { @@ -179,8 +181,8 @@ describe("AgentSession post-compaction context retry", () => { "retry-started" ); - expect(requestHeader(streamMessage, 0)).toEqual(HEADER); - expect(requestHeader(streamMessage, 1)).toBeUndefined(); + expect(requestRendersHeader(streamMessage, 0)).toBe(true); + expect(requestRendersHeader(streamMessage, 1)).toBe(false); expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( "assistant-ctx-exceeded" @@ -198,6 +200,57 @@ describe("AgentSession post-compaction context retry", () => { 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(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); @@ -292,6 +345,51 @@ describe("AgentSession post-compaction context retry", () => { 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, + historyService, + aiEmitter, + aiServiceOverrides: { + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve(Err("nope"))), + }, + }); + spyOn(historyService, "updateHistory").mockResolvedValueOnce(Err("disk")); + + await session.resumeStream({ + model: "openai:gpt-4o", + agentId: "exec", + } as unknown as SendMessageOptions); + + expect(await session.waitForPendingStreamErrorRecoveryDecision("assistant-ctx-exceeded")).toBe( + "terminal" + ); + expect(streamMessage).toHaveBeenCalledTimes(1); + + await session.dispose(); + }); + test("does not retry when the request had no post-compaction header", async () => { const workspaceId = "ws-no-header"; const { historyService, config } = await createSessionsFixture(workspaceId); @@ -538,10 +636,10 @@ describe("AgentSession post-compaction context retry", () => { ).toBe("terminal"); expect(callCount).toBe(2); - // The header stays in chat.jsonl, but later requests of this session leave it out. + // Later requests leave the header out too. await session.resumeStream(options); expect(callCount).toBe(3); - expect(requestHeader(streamMessage, 2)).toBeUndefined(); + expect(requestRendersHeader(streamMessage, 2)).toBe(false); await session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e9be7e39a03..4603b1c8454 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, @@ -175,6 +176,7 @@ import { import { FileChangeTracker, createFileChangeNotificationMessage, + type FileChangeDetection, type FileState, } from "@/node/services/utils/fileChangeTracker"; import type { Result } from "@/common/types/result"; @@ -221,6 +223,7 @@ import { type MuxMetadata, type PluginHookContextEntry, type PostCompactionContext, + type PostCompactionPlan, type StartupRetrySendOptions, type WorkspaceTurnTaskCorrelation, } from "@/common/types/message"; @@ -298,9 +301,9 @@ import type { import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; import { + findPostCompactionPlan, findPostCompactionContextBoundaryId, - omitPostCompactionContext, - postCompactionContextCarriesPlan, + omitPostCompactionContextFrom, renderPostCompactionContext, type PostCompactionContextInput, } from "./postCompactionContext"; @@ -555,18 +558,16 @@ function overflowedModel(request: { modelString: string }, failedModel: string | return failedModel ?? request.modelString; } -/** A plan's path and state for the file-change tracker. */ -interface TrackedPlanState { - path: string; - state: FileState; -} - -/** The tracker state of a plan read with a stat first; null without one. */ -function trackedPlanState(runtime: Runtime, plan: PlanFileRead | null): TrackedPlanState | null { +/** 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, "/"), - state: { content: plan.planContent, timestamp: plan.modifiedTimeMs }, + content: plan.planContent, + modifiedTimeMs: plan.modifiedTimeMs, }; } @@ -1810,10 +1811,10 @@ export class AgentSession { private activeStreamPostCompactionBoundaryId: string | undefined; /** - * A boundary whose header overflowed the context window once. Later requests of this session - * leave the header out, so the next turn cannot hit the same limit again (#1932). + * 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 omittedPostCompactionBoundaryId: string | undefined; + private planBaselineBoundaryId: string | null | undefined; /** Backend start time for the current stream, used to avoid charging goals created mid-stream. */ private activeStreamStartedAtMs?: number; @@ -8978,7 +8979,7 @@ export class AgentSession { const fileChangeDetection = preparedRequest || leaseRefusal != null ? { attachments: [], commit: () => undefined } - : await this.fileChangeTracker.getChangedAttachments(); + : await this.detectFileChanges(); if (isStreamStartAborted()) { return Ok(undefined); } @@ -9076,10 +9077,6 @@ export class AgentSession { } } - requestMessages = omitPostCompactionContext( - requestMessages, - this.omittedPostCompactionBoundaryId - ); this.activeStreamPostCompactionBoundaryId = findPostCompactionContextBoundaryId(requestMessages); @@ -9686,6 +9683,29 @@ 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); + } + /** * #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. @@ -9723,14 +9743,15 @@ export class AgentSession { this.postCompactionRetryAttempts.add(requestId); - // The next compaction must not carry the same context forward again. - // Fail closed if the discard throws: once the header is omitted, no later - // request hits this path to discard it, so the next compaction would merge it again. + // 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; not retrying without it", { + log.warn("Failed to discard the post-compaction context; not retrying without it", { workspaceId: this.workspaceId, error: getErrorMessage(error), }); @@ -9738,7 +9759,6 @@ export class AgentSession { this.postCompactionRetryAttempts.delete(requestId); return false; } - this.omittedPostCompactionBoundaryId = boundaryId; log.info("Post-compaction context hit context limit; retrying once without it", { workspaceId: this.workspaceId, @@ -13489,6 +13509,39 @@ 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(); + } + + /** + * 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 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) { + await this.recordFileState(plan.path, { + content: plan.content, + timestamp: plan.modifiedTimeMs, + }); + } + } + /** * 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 @@ -13500,41 +13553,10 @@ export class AgentSession { this.pendingPostCompactionStateToAcknowledge = pendingState; // The next stream recomputes the memory index from current files. await this.discardMemoryContext(); - // Clear file state cache since history context is gone - this.clearFileState(); - // The boundary header holds the plan as it was at compaction and never refreshes. When it - // carries the plan, track the plan again, so a later external edit reaches the model as a - // file-change notification. The header decides, not the current exclusions: like the - // header, a change to them applies from the next compaction. - if (!postCompactionContextCarriesPlan(history)) return; - const plan = await this.readLocalPlanFileState(); - if (plan) await this.recordFileState(plan.path, plan.state); - } - - /** - * The plan file's path and current state for the file-change tracker. The tracker reads the - * host filesystem, so only local runtimes qualify; null also when the plan cannot be read. - */ - private async readLocalPlanFileState(): Promise { - try { - const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); - if (!metadataResult.success) return null; - const runtime = this.aiService.runtimeFactory.createRuntimeForWorkspace(metadataResult.data); - if (runtime.location !== "local") return null; - const location = await resolvePlanFileLocation( - this.config, - runtime, - { ...metadataResult.data, id: this.workspaceId }, - this.historyService.locks - ); - return trackedPlanState( - runtime, - await AttachmentService.readPlan(location, runtime, { statFirst: true }) - ); - } catch (error) { - log.debug("Skipping plan file tracking", { error: getErrorMessage(error) }); - return null; - } + // 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; } /** @@ -13553,24 +13575,13 @@ export class AgentSession { }); return null; } - const { context, recordPlan } = built.value; - // The first turn after the boundary detects file edits before it tracks the plan again, so - // baseline an untracked plan with the state the header rendered: that turn then reports an - // edit made after this snapshot. Estimates and unpublished builds only start tracking it - // sooner. Only a build that wins the time box writes the baseline, and the write does not - // await: a late build must not touch the tracker after the next window started. - recordPlan?.(); - return context; + return built.value; } - /** - * The unbounded header build. It resolves the plan baseline but leaves the write to the caller, - * as recordPlan. - */ - private async renderPostCompactionContextHeader(input: PostCompactionContextInput): Promise<{ - context: PostCompactionContext | null; - recordPlan: (() => void) | null; - }> { + /** The unbounded header build. */ + private async renderPostCompactionContextHeader( + input: PostCompactionContextInput + ): Promise { const excludedItems = await this.loadExcludedItems(); const { attachments, plan } = await this.buildAttachmentsFromContext({ ...input, @@ -13579,12 +13590,7 @@ export class AgentSession { // model-visible in RLM mode, keeping RLM-off prompts byte-identical. readFilePaths: this.isRlmCompactionEnabled() ? input.readFilePaths : [], }); - return { - context: renderPostCompactionContext(attachments), - recordPlan: plan - ? await this.fileChangeTracker.prepareRecordIfUntracked(plan.path, plan.state) - : null, - }; + return renderPostCompactionContext(attachments, plan); } /** @@ -13603,7 +13609,7 @@ export class AgentSession { }): Promise<{ attachments: PostCompactionAttachment[]; /** The plan the attachments carry, for the file-change tracker; local runtimes only. */ - plan: TrackedPlanState | null; + plan: PostCompactionPlan | null; }> { const { excludedItems } = context; const todoAttachment = await this.loadTodoListAttachment(excludedItems); @@ -13700,7 +13706,7 @@ export class AgentSession { attachments.push(artifactsIndexAttachment); } - return { attachments, plan: trackedPlanState(runtime, plan) }; + return { attachments, plan: postCompactionPlan(runtime, plan) }; } /** diff --git a/src/node/services/postCompactionContext.test.ts b/src/node/services/postCompactionContext.test.ts index 105d02e5991..538cde53c80 100644 --- a/src/node/services/postCompactionContext.test.ts +++ b/src/node/services/postCompactionContext.test.ts @@ -10,7 +10,9 @@ import { import { findPostCompactionContext, insertPostCompactionContext, + omitPostCompactionContextFrom, renderPostCompactionContext, + restorePostCompactionContextBefore, withPostCompactionContext, } from "./postCompactionContext"; @@ -25,24 +27,45 @@ const compactionBoundary = (id: string, postCompactionContext?: PostCompactionCo 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([])).toBeNull(); + 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, - }, - ], - }, - ]); + 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); }); @@ -93,14 +116,38 @@ describe("insertPostCompactionContext", () => { ]; expect(insertPostCompactionContext(messages)).toBe(messages); } - const mixedNames = JSON.parse('{"text":"CTX","skillNames":["a",1]}') as PostCompactionContext; - expect(findPostCompactionContext([compactionBoundary("summary", mixedNames)])).toEqual({ + 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(); + }); +}); + describe("withPostCompactionContext", () => { const boundaryHeader = (message: MuxMessage) => message.metadata?.postCompactionContext; @@ -120,4 +167,15 @@ describe("withPostCompactionContext", () => { const oversized = header("x".repeat(SESSION_HISTORY_MAX_LINE_BYTES)); expect(withPostCompactionContext(bare, oversized, "ws")).toBe(bare); }); + + test("drops the plan baseline first when the row is too big with it", () => { + const text = "x".repeat(SESSION_HISTORY_MAX_LINE_BYTES / 2); + const bigPlan = { ...plan, content: "y".repeat(SESSION_HISTORY_MAX_LINE_BYTES / 2) }; + const fitted = withPostCompactionContext( + compactionBoundary("s"), + { ...header(text), plan: bigPlan }, + "ws" + ); + expect(boundaryHeader(fitted)).toEqual(header(text)); + }); }); diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts index a331f8515e2..84cea077342 100644 --- a/src/node/services/postCompactionContext.ts +++ b/src/node/services/postCompactionContext.ts @@ -4,7 +4,11 @@ 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, @@ -39,29 +43,54 @@ export interface PostCompactionContextInput { * renderer change cannot alter the bytes that earlier requests sent. */ export function renderPostCompactionContext( - attachments: PostCompactionAttachment[] + attachments: PostCompactionAttachment[], + plan: PostCompactionPlan | null ): PostCompactionContext | null { const text = renderAttachmentsToContentWithBudget(attachments, { maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, }); - return text.length > 0 ? { text, skillNames: loadedSnapshotSkillNames(attachments) } : null; + 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; } -/** The header of a compaction boundary row; a malformed persisted value counts as none. */ -function readPostCompactionContext(message: MuxMessage): PostCompactionContext | null { +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 (typeof context !== "object" || context === null) return null; - const { text, skillNames } = context as Partial>; + 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: Array.isArray(skillNames) - ? skillNames.filter((name): name is string => typeof name === "string") - : [], + 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[] @@ -75,10 +104,11 @@ export function findPostCompactionContextSkillNames(messages: readonly MuxMessag return findPostCompactionContext(messages)?.skillNames ?? []; } -/** Whether the latest boundary's header carries the plan file. */ -export function postCompactionContextCarriesPlan(messages: readonly MuxMessage[]): boolean { - const context = findPostCompactionContext(messages); - return context != null && rendersPlanFileReference(context.text); +/** 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. */ @@ -90,16 +120,46 @@ export function findPostCompactionContextBoundaryId( } /** - * Remove the header from one boundary row of a request copy. chat.jsonl keeps the header; only - * the requests of this session leave it out (see the context-exceeded retry in AgentSession). + * 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. Null when requests do not render a header for the row. */ -export function omitPostCompactionContext( +export function omitPostCompactionContextFrom( + boundary: MuxMessage, + requestHistorySequence: number +): MuxMessage | null { + const context = readPostCompactionContext(boundary); + if (!context) return null; + return { + ...boundary, + metadata: { + ...boundary.metadata, + postCompactionContext: { ...context, omittedFromRequestSequence: requestHistorySequence }, + }, + }; +} + +/** + * 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[], - boundaryId: string | undefined + requestHistorySequence: number ): MuxMessage[] { - if (boundaryId == null) return messages; - return messages.map((message) => - message.id === boundaryId ? splitPostCompactionContext(message).boundary : message + 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 ); } @@ -116,6 +176,8 @@ export function splitPostCompactionContext(boundary: MuxMessage): { /** * 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 plan baseline goes first: without it, only a session that restarts in the window misses + * plan edits. */ export function withPostCompactionContext( boundary: MuxMessage, @@ -123,20 +185,32 @@ export function withPostCompactionContext( workspaceId: string ): MuxMessage { if (!context || boundary.metadata?.postCompactionContext) return boundary; + const withContext = fitPostCompactionContext(boundary, context, workspaceId); + if (withContext || !context.plan) return withContext ?? boundary; + const { plan: _plan, ...withoutPlan } = context; + return fitPostCompactionContext(boundary, withoutPlan, workspaceId) ?? boundary; +} + +function fitPostCompactionContext( + boundary: MuxMessage, + context: PostCompactionContext, + workspaceId: string +): MuxMessage | null { 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) { - log.warn("Post-compaction context does not fit the boundary row; dropping it", { - workspaceId, - messageId: boundary.id, - rowBytes, - }); - return boundary; + if (rowBytes <= SESSION_HISTORY_MAX_LINE_BYTES - COMPACTION_BOUNDARY_ROW_HEADROOM_BYTES) { + return withContext; } - 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 null; } function createPostCompactionContextMessage( diff --git a/src/node/services/replay/replayFixture.ts b/src/node/services/replay/replayFixture.ts index 4687aac75c5..579cc9ec8d5 100644 --- a/src/node/services/replay/replayFixture.ts +++ b/src/node/services/replay/replayFixture.ts @@ -31,10 +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 { - findPostCompactionContextBoundaryId, - omitPostCompactionContext, -} from "@/node/services/postCompactionContext"; +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"; @@ -105,7 +102,7 @@ export interface ReplayFixtureTurnSpec { systemVolatileSuffixLength?: number; /** Set false to simulate devtools logging being off for this turn. */ recordDevtools?: boolean; - /** Send the request without the boundary's header, as AgentSession does after a context-exceeded retry. */ + /** 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) @@ -189,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. @@ -285,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; } @@ -293,12 +305,9 @@ function fixtureModel(spec: ReplayFixtureTurnSpec): string { function buildFixtureRequest( ctx: ReplayFixtureSessionContext, spec: ReplayFixtureTurnSpec, - history: MuxMessage[], + historyMessages: MuxMessage[], agentId: string ) { - const historyMessages = spec.omitPostCompactionContext - ? omitPostCompactionContext(history, findPostCompactionContextBoundaryId(history)) - : history; return spec.liveToolInstructions == null ? buildReplayedFixtureRequest(ctx, spec, historyMessages, agentId) : buildLiveFixtureRequest(ctx, spec, historyMessages, agentId); diff --git a/src/node/services/replay/replayVerify.test.ts b/src/node/services/replay/replayVerify.test.ts index 49f3eeb3470..5caea9a284c 100644 --- a/src/node/services/replay/replayVerify.test.ts +++ b/src/node/services/replay/replayVerify.test.ts @@ -92,7 +92,7 @@ describe("replayVerifySession pairing", () => { expect(devtoolsRaw).toContain("1. do the thing"); }); - test("a request sent without the boundary's header passes and says so", async () => { + 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, { @@ -110,12 +110,7 @@ describe("replayVerifySession pairing", () => { } const result = await verify(ctx); - expect( - result.turns.map((turn) => ({ status: turn.status, omitted: turn.reason != null })) - ).toEqual([ - { status: "PASS", omitted: false }, - { status: "PASS", omitted: true }, - ]); + 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); }); diff --git a/src/node/services/replay/replayVerify.ts b/src/node/services/replay/replayVerify.ts index 2d1a268035c..40472d0d4f2 100644 --- a/src/node/services/replay/replayVerify.ts +++ b/src/node/services/replay/replayVerify.ts @@ -45,10 +45,7 @@ import { } from "@/common/utils/messages/compactionBoundary"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; import type { HistoryService } from "@/node/services/historyService"; -import { - findPostCompactionContextBoundaryId, - omitPostCompactionContext, -} from "@/node/services/postCompactionContext"; +import { restorePostCompactionContextBefore } from "@/node/services/postCompactionContext"; import { hashToolSchema, isProviderDefinedToolRecord, @@ -357,7 +354,7 @@ export interface ReplayVerifyTurnResult { assistantMessageId: string; runId: string; status: "PASS" | "FAIL" | "SKIPPED"; - /** Why verification was impossible (SKIPPED), or how the messages matched without the full log. */ + /** Present for SKIPPED (why verification was impossible). */ reason?: string; systemPromptMatch?: boolean; toolsetMatch?: boolean; @@ -706,34 +703,6 @@ function parseAnthropicCacheTtl(value: string | undefined): AnthropicCacheTtl | return value === "5m" || value === "1h" ? value : undefined; } -const HEADER_OMITTED_REASON = - "matched without the boundary's post-compaction header (a context-exceeded retry omits it)"; - -/** - * Rebuild a turn's prompt and compare it with the recorded one. After a context-exceeded retry, - * AgentSession leaves the boundary's post-compaction header out of the rest of the window's - * requests, and no log records that. So a mismatch is rebuilt once more without the header. - */ -async function rebuildRecordedMessages( - historySlice: MuxMessage[], - recordedPrompt: unknown, - rebuild: (history: MuxMessage[]) => Promise -): Promise<{ rebuiltJson: unknown; messagesMatch: boolean; headerOmitted: boolean }> { - const recordedJson = JSON.stringify(recordedPrompt); - const rebuiltJson = await rebuild(historySlice); - if (JSON.stringify(rebuiltJson) === recordedJson) { - return { rebuiltJson, messagesMatch: true, headerOmitted: false }; - } - const boundaryId = findPostCompactionContextBoundaryId(historySlice); - if (boundaryId != null) { - const withoutHeader = await rebuild(omitPostCompactionContext(historySlice, boundaryId)); - if (JSON.stringify(withoutHeader) === recordedJson) { - return { rebuiltJson: withoutHeader, messagesMatch: true, headerOmitted: true }; - } - } - return { rebuiltJson, messagesMatch: false, headerOmitted: false }; -} - /** * Verify every turn of a session: rebuild the provider request from durable * logs and byte-compare against the recorded devtools request. @@ -860,48 +829,47 @@ 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); - - const messages = await rebuildRecordedMessages( - historySlice, - record.prompt, - async (history) => { - const rebuilt = await buildReplayRequest({ - historyMessages: history, - systemPrompt, - modelString: envelope.data.modelString, - thinkingLevel: envelope.data.thinkingLevel as ThinkingLevel, - effectiveAgentId: turn.message.metadata?.agentId ?? "exec", - // Prefer the independently-recorded sentinel names: forced first-step - // scoping (e.g. xAI search) narrows the wire manifest while the live - // sentinel listed the full active set. Manifest names remain the - // legacy fallback for rows written before sentinelToolNames existed. - toolNamesForSentinel: - envelope.data.sentinelToolNames ?? - envelope.data.toolsetManifest.map((entry) => entry.name), - routeProvider: turn.message.metadata?.routeProvider, - providersConfig: params.providersConfig, - wireProviderName: envelope.data.wireProviderName, - anthropicCacheTtl: parseAnthropicCacheTtl(envelope.data.anthropicCacheTtl), - systemPromptVolatileSuffixLength: envelope.data.systemPromptVolatileSuffixLength, - volatileSystemTail: readVolatileSystemTailRecord(turn.message), - customInstructions: readCustomInstructionsRecord(turn.message), - skillIndex: readSkillIndexRecord(turn.message), - toolInstructions: readToolInstructionsRecord(turn.message), - planContentForTransition, - planFilePath: envelope.data.planTransitionFilePath, - partialContinuation, - mcpAppModelContext, - deferLoadingToolNames: deferLoadingNamesFromRecordedTools(record.tools), - workspaceId: params.workspaceId, - compactionInputBudgetTokens: record.compactionInputBudgetTokens, - }); - // JSON round-trip the rebuilt prompt so both sides are plain JSON values - // (the recorded prompt already survived a devtools.jsonl round-trip). - return JSON.parse(JSON.stringify(rebuilt.lmPrompt)) as unknown; - } + // 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 { messagesMatch } = messages; + + const rebuilt = await buildReplayRequest({ + historyMessages: historySlice, + systemPrompt, + modelString: envelope.data.modelString, + thinkingLevel: envelope.data.thinkingLevel as ThinkingLevel, + effectiveAgentId: turn.message.metadata?.agentId ?? "exec", + // Prefer the independently-recorded sentinel names: forced first-step + // scoping (e.g. xAI search) narrows the wire manifest while the live + // sentinel listed the full active set. Manifest names remain the + // legacy fallback for rows written before sentinelToolNames existed. + toolNamesForSentinel: + envelope.data.sentinelToolNames ?? + envelope.data.toolsetManifest.map((entry) => entry.name), + routeProvider: turn.message.metadata?.routeProvider, + providersConfig: params.providersConfig, + wireProviderName: envelope.data.wireProviderName, + anthropicCacheTtl: parseAnthropicCacheTtl(envelope.data.anthropicCacheTtl), + systemPromptVolatileSuffixLength: envelope.data.systemPromptVolatileSuffixLength, + volatileSystemTail: readVolatileSystemTailRecord(turn.message), + customInstructions: readCustomInstructionsRecord(turn.message), + skillIndex: readSkillIndexRecord(turn.message), + toolInstructions: readToolInstructionsRecord(turn.message), + planContentForTransition, + planFilePath: envelope.data.planTransitionFilePath, + partialContinuation, + mcpAppModelContext, + deferLoadingToolNames: deferLoadingNamesFromRecordedTools(record.tools), + workspaceId: params.workspaceId, + compactionInputBudgetTokens: record.compactionInputBudgetTokens, + }); + + // JSON round-trip the rebuilt prompt so both sides are plain JSON values + // (the recorded prompt already survived a devtools.jsonl round-trip). + const rebuiltJson: unknown = JSON.parse(JSON.stringify(rebuilt.lmPrompt)); + const messagesMatch = JSON.stringify(rebuiltJson) === JSON.stringify(record.prompt); const pass = systemPromptMatch && toolsetMatch && messagesMatch; results.push({ @@ -910,15 +878,12 @@ export async function replayVerifySession(params: { assistantMessageId: turn.message.id, runId: record.runId, status: pass ? "PASS" : "FAIL", - ...(messages.headerOmitted ? { reason: HEADER_OMITTED_REASON } : {}), systemPromptMatch, toolsetMatch, messagesMatch, ...(messagesMatch ? {} - : { - divergence: findFirstJsonDivergence(messages.rebuiltJson, record.prompt) ?? undefined, - }), + : { divergence: findFirstJsonDivergence(rebuiltJson, record.prompt) ?? undefined }), ...(toolsetMatch ? {} : { toolsetDiff: describeManifestDiff(envelope.data.toolsetManifest, recordedManifest) }), diff --git a/src/node/services/utils/fileChangeTracker.ts b/src/node/services/utils/fileChangeTracker.ts index 4c6bbeb68a8..94f30baa0ba 100644 --- a/src/node/services/utils/fileChangeTracker.ts +++ b/src/node/services/utils/fileChangeTracker.ts @@ -117,18 +117,6 @@ export class FileChangeTracker { this.fileState.set(canonicalPath, state); } - /** - * Resolve the path now and return a write that records the file only when it has no baseline - * then; an existing baseline may hold an unreported edit. The write does not await, so a caller - * can resolve inside a time box and write only if the box was met. - */ - async prepareRecordIfUntracked(filePath: string, state: FileState): Promise<() => void> { - const canonicalPath = await this.canonicalize(filePath); - return () => { - if (!this.fileState.has(canonicalPath)) 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); @@ -165,6 +153,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()` — From 2c3e21ee2fad28312bf0f06a99dcee74b6f2f6c6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 29 Oct 2026 19:24:35 +1100 Subject: [PATCH 21/27] =?UTF-8?q?=F0=9F=A4=96=20tests:=20avoid=20unsafe=20?= =?UTF-8?q?any=20in=20the=20FIFO=20plan=20integration=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expect.any(Number) inside toEqual is an unsafe any assignment for the type-aware ESLint rule. Check the plan fields with toMatchObject and the modified time with typeof. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- tests/ipc/planFileNonRegular.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ipc/planFileNonRegular.test.ts b/tests/ipc/planFileNonRegular.test.ts index 698f29634e5..1006f5e7fbf 100644 --- a/tests/ipc/planFileNonRegular.test.ts +++ b/tests/ipc/planFileNonRegular.test.ts @@ -146,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({ + expect(canonical).toMatchObject({ planFilePath: getPlanFilePath(workspaceName, projectName, runtime.getXumHome()), planContent: "# canonical\n", - modifiedTimeMs: expect.any(Number), }); + expect(typeof canonical?.modifiedTimeMs).toBe("number"); await fs.rm(planPath); await fs.writeFile(legacyPlanPath, "# legacy\n"); const legacy = await generate(); - expect(legacy).toEqual({ + expect(legacy).toMatchObject({ planFilePath: getLegacyPlanFilePath(workspaceId, runtime.getXumHome()), planContent: "# legacy\n", - modifiedTimeMs: expect.any(Number), }); + expect(typeof legacy?.modifiedTimeMs).toBe("number"); await fs.rm(legacyPlanPath); }, 60_000); From 0f577a887cd593bba53eb4b56f82c9449e778c6b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 30 Oct 2026 12:46:13 +1100 Subject: [PATCH 22/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20the=20swappe?= =?UTF-8?q?d=20boundary=20row=20and=20gate=20the=20header=20retry=20to=20n?= =?UTF-8?q?ormal=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuous compaction builder now fits the summary and adds the header once. The swap sends that row, and finalize persists it as built. A pending follow-up that makes the row too big leaves it oversized; the history scanner recovers oversized compaction boundaries. 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. The header-omission retry arms only for requests that are not compaction requests. A compaction request trims its input to its budget, and the trim can drop the header row, so its overflow does not show that the header caused it. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .../agentSession.postCompactionRetry.test.ts | 63 +++++++++++++++++++ src/node/services/agentSession.ts | 21 ++++++- src/node/services/compactionHandler.test.ts | 12 ++-- src/node/services/compactionHandler.ts | 31 ++++----- .../contextManagement/compactionRequests.ts | 17 +++++ src/node/services/continuousCompactor.ts | 12 +--- .../services/postCompactionContext.test.ts | 10 +-- src/node/services/postCompactionContext.ts | 27 +------- src/node/services/turnRequestBuilder.ts | 12 ++-- 9 files changed, 136 insertions(+), 69 deletions(-) diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 93247ac1fb7..d147377ed50 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -435,6 +435,69 @@ describe("AgentSession post-compaction context retry", () => { await session.dispose(); }); + // 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", + }, + }, + }); + + 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"))), + }, + }); + + 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(); + }); + // Waiters (task/workspace-turn stream-error settlement) treat the resolved // decision as "the retry outcome is known". Resolving while retry startup is // still in flight would let a pre-stream startup failure (no further error diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 4603b1c8454..f3be7338b96 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -276,6 +276,7 @@ import { inheritOpenWorkspaceTurnMetadata, computeKeepRecentTailStamp, isCompactionRequestMetadata, + isCompactionRequestTurn, type CompactionRequestMetadata, } from "./contextManagement/compactionRequests"; import { @@ -508,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; }; @@ -9077,8 +9092,10 @@ export class AgentSession { } } - this.activeStreamPostCompactionBoundaryId = - findPostCompactionContextBoundaryId(requestMessages); + 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 diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index 697e92aa4e0..6e2320fb89e 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -2411,15 +2411,15 @@ describe("CompactionHandler", () => { expect(boundary?.metadata?.postCompactionContext).toEqual(journaledHeader ?? undefined); }); - it("drops a journaled header that does not fit, not summary text", async () => { + // 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, { - text: "h".repeat(64 * 1024), - skillNames: [], - }); + const boundary = await persistJournaledBoundary(summary, journaledHeader); - expect(boundary?.metadata?.postCompactionContext).toBeUndefined(); + expect(boundary?.metadata?.postCompactionContext).toEqual(journaledHeader); expect(boundary?.parts[0]).toMatchObject({ type: "text", text: summary }); }); }); diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 091ed7081da..575c10c1419 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -17,7 +17,6 @@ import { } from "./compactionPendingState"; import { estimatePostCompactionContextTokens, - splitPostCompactionContext, withPostCompactionContext, type PostCompactionContextInput, } from "./postCompactionContext"; @@ -962,7 +961,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[]; @@ -972,9 +975,10 @@ export class CompactionHandler { 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, @@ -992,6 +996,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. @@ -1015,19 +1024,13 @@ 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); - const unfitted = splitPostCompactionContext(built.boundary); - // 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. Fit the summary - // without the header, so a row that is too big drops the header, not summary text. - const boundary = withPostCompactionContext( - this.fitBoundarySummary(unfitted.boundary).message, - unfitted.context, - this.workspaceId - ); - const copies = built.copies; const inputTokens = params.systemMessageTokens + params.attachmentTokens + 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/continuousCompactor.ts b/src/node/services/continuousCompactor.ts index 419b8ca2534..5d6353f81c1 100644 --- a/src/node/services/continuousCompactor.ts +++ b/src/node/services/continuousCompactor.ts @@ -30,7 +30,6 @@ import { log } from "./log"; import { estimatePostCompactionContextTokens, findPostCompactionContext, - withPostCompactionContext, } from "./postCompactionContext"; export interface ContinuousCompactionContext { @@ -553,21 +552,16 @@ export class ContinuousCompactor { return false; const prepared = await this.deps.prepareSwap(staged.cut.head); if (!prepared || !this.isValid(staged, rows)) return false; - const built = this.deps.compactionHandler.buildContinuousCompactionRows({ + // 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 swapped prefix and every later request render the header from this boundary. - const boundary = withPostCompactionContext( - built.boundary, - prepared.postCompactionContext, - this.deps.workspaceId - ); - const copies = built.copies; const staticCopies = copies.slice(0, -1); const liveCopy = copies.at(-1); assert(liveCopy && tail.at(-1)?.id === live.messageId, "Live tail must be final"); diff --git a/src/node/services/postCompactionContext.test.ts b/src/node/services/postCompactionContext.test.ts index 538cde53c80..d5ae862bbb3 100644 --- a/src/node/services/postCompactionContext.test.ts +++ b/src/node/services/postCompactionContext.test.ts @@ -168,14 +168,10 @@ describe("withPostCompactionContext", () => { expect(withPostCompactionContext(bare, oversized, "ws")).toBe(bare); }); - test("drops the plan baseline first when the row is too big with it", () => { + 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) }; - const fitted = withPostCompactionContext( - compactionBoundary("s"), - { ...header(text), plan: bigPlan }, - "ws" - ); - expect(boundaryHeader(fitted)).toEqual(header(text)); + expect(withPostCompactionContext(bare, { ...header(text), plan: bigPlan }, "ws")).toBe(bare); }); }); diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts index 84cea077342..1559c5d0710 100644 --- a/src/node/services/postCompactionContext.ts +++ b/src/node/services/postCompactionContext.ts @@ -163,21 +163,11 @@ export function restorePostCompactionContextBefore( ); } -/** Split a boundary row into the row without its header and the header (null without one). */ -export function splitPostCompactionContext(boundary: MuxMessage): { - boundary: MuxMessage; - context: PostCompactionContext | null; -} { - if (boundary.metadata?.postCompactionContext == null) return { boundary, context: null }; - const { postCompactionContext: context, ...metadata } = boundary.metadata; - return { boundary: { ...boundary, metadata }, context }; -} - /** * 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 plan baseline goes first: without it, only a session that restarts in the window misses - * plan edits. + * 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, @@ -185,17 +175,6 @@ export function withPostCompactionContext( workspaceId: string ): MuxMessage { if (!context || boundary.metadata?.postCompactionContext) return boundary; - const withContext = fitPostCompactionContext(boundary, context, workspaceId); - if (withContext || !context.plan) return withContext ?? boundary; - const { plan: _plan, ...withoutPlan } = context; - return fitPostCompactionContext(boundary, withoutPlan, workspaceId) ?? boundary; -} - -function fitPostCompactionContext( - boundary: MuxMessage, - context: PostCompactionContext, - workspaceId: string -): MuxMessage | null { const withContext: MuxMessage = { ...boundary, metadata: { ...boundary.metadata, postCompactionContext: context }, @@ -210,7 +189,7 @@ function fitPostCompactionContext( rowBytes, withPlan: context.plan != null, }); - return null; + return boundary; } function createPostCompactionContextMessage( diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 409b0c17aad..8f239344606 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -313,6 +313,7 @@ export function resolveXumToolScope( 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"; @@ -2286,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(); From a6a78d7e9d9de3765fc5e9660b05b31f9ae96448 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 30 Oct 2026 14:44:35 +1100 Subject: [PATCH 23/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20drop=20an=20omitted?= =?UTF-8?q?=20header's=20tokens=20from=20the=20boundary's=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A context-exceeded retry leaves the post-compaction header out of later requests. The boundary row's contextUsage now drops the header's tokens at the same time, so the context meter and the next compaction check stop counting a header that requests no longer render. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/node/services/agentSession.ts | 2 ++ src/node/services/compactionHandler.ts | 27 ++--------------- .../services/postCompactionContext.test.ts | 13 +++++++++ src/node/services/postCompactionContext.ts | 29 +++++++++++++++++-- 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index f3be7338b96..20bdfabb153 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9721,6 +9721,8 @@ export class AgentSession { 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; } /** diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 575c10c1419..ebc4815bb50 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -16,7 +16,7 @@ import { type CompactionPendingBoundaryWrite, } from "./compactionPendingState"; import { - estimatePostCompactionContextTokens, + shiftContextUsageByHeader, withPostCompactionContext, type PostCompactionContextInput, } from "./postCompactionContext"; @@ -279,29 +279,6 @@ interface HeaderOptions { countHeader: boolean; } -/** - * Add the header's tokens to the boundary's estimate: the context meter and the compaction check - * of the next send read it, and every request of the window carries the header. - */ -function withHeaderInContextUsage(boundary: MuxMessage): MuxMessage { - const usage = boundary.metadata?.contextUsage; - if (!usage) return boundary; - const headerTokens = estimatePostCompactionContextTokens( - boundary.metadata?.postCompactionContext - ); - return { - ...boundary, - metadata: { - ...boundary.metadata, - contextUsage: { - ...usage, - inputTokens: (usage.inputTokens ?? 0) + headerTokens, - totalTokens: (usage.totalTokens ?? 0) + headerTokens, - }, - }, - }; -} - export class CompactionHandler { private readonly workspaceId: string; private readonly historyService: HistoryService; @@ -439,7 +416,7 @@ export class CompactionHandler { }); const withContext = withPostCompactionContext(summary, built, this.workspaceId); return options.countHeader && withContext !== summary - ? withHeaderInContextUsage(withContext) + ? shiftContextUsageByHeader(withContext, 1) : withContext; } catch (error) { // The header only restores context; it must never block the compaction itself. diff --git a/src/node/services/postCompactionContext.test.ts b/src/node/services/postCompactionContext.test.ts index d5ae862bbb3..5ee300b5dfc 100644 --- a/src/node/services/postCompactionContext.test.ts +++ b/src/node/services/postCompactionContext.test.ts @@ -13,6 +13,7 @@ import { omitPostCompactionContextFrom, renderPostCompactionContext, restorePostCompactionContextBefore, + shiftContextUsageByHeader, withPostCompactionContext, } from "./postCompactionContext"; @@ -146,6 +147,18 @@ describe("omitPostCompactionContextFrom", () => { // 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", () => { diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts index 1559c5d0710..be8732a3dc7 100644 --- a/src/node/services/postCompactionContext.ts +++ b/src/node/services/postCompactionContext.ts @@ -122,7 +122,8 @@ export function findPostCompactionContextBoundaryId( /** * 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. Null when requests do not render a header for the row. + * 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, @@ -130,13 +131,37 @@ export function omitPostCompactionContextFrom( ): MuxMessage | null { const context = readPostCompactionContext(boundary); if (!context) return null; - return { + 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), + }, + }, + }; } /** From 5675315150bd8bd37c0d44574c918c6fe5634638 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 30 Oct 2026 15:28:40 +1100 Subject: [PATCH 24/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20render=20a=20left-o?= =?UTF-8?q?ut=20header=20again=20after=20a=20cut=20before=20its=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An edit or fork that removes every row a request without the header saw also removes the reason to leave the header out: the retained turns ran with it, and the request that overflowed is gone. Truncation now clears the omission mark on the latest boundary in that case and counts the header in the boundary's usage again. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/node/services/historyService.test.ts | 39 ++++++++++++++++++++++ src/node/services/historyService.ts | 15 +++++++-- src/node/services/postCompactionContext.ts | 39 +++++++++++++++++++--- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 5718c88e581..7e7b7c3be3f 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -7,6 +7,7 @@ import { HistoryService } from "./historyService"; import type { Config } from "@/node/config"; import { createTestHistoryService } from "./testHistoryService"; import { prepareProviderRequestMessages } from "./turnContextAssembler"; +import { findPostCompactionContext, omitPostCompactionContextFrom } from "./postCompactionContext"; import type { ContinuousCompactionJournal } from "@/common/orpc/schemas/continuousCompaction"; import { createContextBudgetRejectedMessage } from "@/common/utils/messages/contextBudgetRejection"; import { @@ -1086,6 +1087,44 @@ describe("HistoryService", () => { expect(messages[0].id).toBe("msg1"); expect(messages[1].id).toBe("msg2"); }); + + it("renders a left-out header again once the cut removes every request without it", async () => { + const workspaceId = "workspace-header-cut"; + const usage = { inputTokens: 100, outputTokens: 0, totalTokens: 100 }; + const boundary = createMuxMessage("summary", "assistant", "summary", { + compacted: "user", + compactionBoundary: true, + compactionEpoch: 1, + contextUsage: usage, + postCompactionContext: { text: "CTX", skillNames: [] }, + }); + await service.appendToHistory(workspaceId, boundary); + for (const [id, role] of [ + ["u1", "user"], + ["a1", "assistant"], + ["u2", "user"], + ["a2", "assistant"], + ] as const) { + await service.appendToHistory(workspaceId, createMuxMessage(id, role, id)); + } + // The u2 request overflowed, and its retry left the header out from u2's sequence on. + const omitted = omitPostCompactionContextFrom(boundary, 3); + assert(omitted, "expected the header to be left out"); + expect((await service.updateHistory(workspaceId, omitted)).success).toBe(true); + const latestBoundary = async () => { + const history = await service.getHistoryFromLatestBoundary(workspaceId); + assert(history.success); + return history.data; + }; + + await service.truncateAfterMessage(workspaceId, "u2", { keepTargetMessage: true }); + expect(findPostCompactionContext(await latestBoundary())).toBeNull(); + + await service.truncateAfterMessage(workspaceId, "a1", { keepTargetMessage: true }); + const restored = await latestBoundary(); + expect(findPostCompactionContext(restored)?.text).toBe("CTX"); + expect(restored[0].metadata?.contextUsage).toEqual(usage); + }); }); describe("destructive context publication fencing", () => { diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 9db9c176b8e..1418294525c 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -98,6 +98,7 @@ import { type SendIdentity, } from "./sendIds"; import { log } from "./log"; +import { restorePostCompactionContextAfterCut } from "./postCompactionContext"; import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { safeStringifyForCounting } from "@/common/utils/tokens/safeStringifyForCounting"; @@ -260,6 +261,16 @@ function retireReexposedCompactionFollowUp( return (message) => (message === exposed ? retired : message); } +/** The retained-row sanitizer of an edit or fork cut. */ +function sanitizeRetainedAfterCut( + retained: readonly MuxMessage[], + removed: readonly MuxMessage[] +): (message: MuxMessage) => MuxMessage { + const retire = retireReexposedCompactionFollowUp(retained, removed); + const restoreHeader = restorePostCompactionContextAfterCut(retained); + return (message) => restoreHeader(retire(message)); +} + function deletionCreatesRawReset( rows: readonly HistoryRewriteRow[], deletedIds: ReadonlySet, @@ -6064,7 +6075,7 @@ export class HistoryService { rows, workspaceId, truncatedMessages, - retireReexposedCompactionFollowUp(truncatedMessages, removedMessages) + sanitizeRetainedAfterCut(truncatedMessages, removedMessages) ); const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); @@ -6193,7 +6204,7 @@ export class HistoryService { [...archiveRows, ...activeEpochRows], workspaceId, truncatedMessages, - retireReexposedCompactionFollowUp(truncatedMessages, removedMessages) + sanitizeRetainedAfterCut(truncatedMessages, removedMessages) ), publication ); diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts index be8732a3dc7..084dd41756f 100644 --- a/src/node/services/postCompactionContext.ts +++ b/src/node/services/postCompactionContext.ts @@ -180,14 +180,45 @@ export function restorePostCompactionContextBefore( requestHistorySequence >= context.omittedFromRequestSequence ) return messages; - const { omittedFromRequestSequence: _omitted, ...restored } = context; return messages.map((message, messageIndex) => - messageIndex === index - ? { ...message, metadata: { ...message.metadata, postCompactionContext: restored } } - : message + messageIndex === index ? withHeaderRendered(message, context) : message ); } +/** + * Edit and fork truncation: the latest boundary renders its header again when the cut removed + * every row that a request without the header saw. The retained turns ran with the header, and + * the request that overflowed is gone. + */ +export function restorePostCompactionContextAfterCut( + retained: readonly MuxMessage[] +): (message: MuxMessage) => MuxMessage { + const boundary = retained.findLast(isDurableContextBoundaryMarker); + const context = boundary ? readPersistedPostCompactionContext(boundary) : null; + if (!boundary || !context || !retainsOnlyRowsBeforeOmission(retained, context)) + return (message) => message; + return (message) => + message.id === boundary.id + ? shiftContextUsageByHeader(withHeaderRendered(message, context), 1) + : message; +} + +function retainsOnlyRowsBeforeOmission( + retained: readonly MuxMessage[], + context: PostCompactionContext +): boolean { + const omittedFrom = context.omittedFromRequestSequence; + return ( + omittedFrom != null && + !retained.some((message) => (message.metadata?.historySequence ?? -1) >= omittedFrom) + ); +} + +function withHeaderRendered(boundary: MuxMessage, context: PostCompactionContext): MuxMessage { + const { omittedFromRequestSequence: _omitted, ...rendered } = context; + return { ...boundary, metadata: { ...boundary.metadata, postCompactionContext: rendered } }; +} + /** * 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. From 42718e9a50f6b7e8965dac81a8558c62d7747403 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 30 Oct 2026 15:57:11 +1100 Subject: [PATCH 25/27] =?UTF-8?q?=F0=9F=A4=96=20revert:=20keep=20a=20left-?= =?UTF-8?q?out=20header=20out=20after=20a=20cut=20before=20its=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 567531515. Rendering the header again after an edit or fork cut did not restore the state that the headerless retry had already changed: the plan-baseline cache keyed by the boundary ID and the discarded carryover. An edit or fork back before a header omission now keeps the header out, and the boundary state stays consistent. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- src/node/services/historyService.test.ts | 39 ---------------------- src/node/services/historyService.ts | 15 ++------- src/node/services/postCompactionContext.ts | 39 +++------------------- 3 files changed, 6 insertions(+), 87 deletions(-) diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 7e7b7c3be3f..5718c88e581 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -7,7 +7,6 @@ import { HistoryService } from "./historyService"; import type { Config } from "@/node/config"; import { createTestHistoryService } from "./testHistoryService"; import { prepareProviderRequestMessages } from "./turnContextAssembler"; -import { findPostCompactionContext, omitPostCompactionContextFrom } from "./postCompactionContext"; import type { ContinuousCompactionJournal } from "@/common/orpc/schemas/continuousCompaction"; import { createContextBudgetRejectedMessage } from "@/common/utils/messages/contextBudgetRejection"; import { @@ -1087,44 +1086,6 @@ describe("HistoryService", () => { expect(messages[0].id).toBe("msg1"); expect(messages[1].id).toBe("msg2"); }); - - it("renders a left-out header again once the cut removes every request without it", async () => { - const workspaceId = "workspace-header-cut"; - const usage = { inputTokens: 100, outputTokens: 0, totalTokens: 100 }; - const boundary = createMuxMessage("summary", "assistant", "summary", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, - contextUsage: usage, - postCompactionContext: { text: "CTX", skillNames: [] }, - }); - await service.appendToHistory(workspaceId, boundary); - for (const [id, role] of [ - ["u1", "user"], - ["a1", "assistant"], - ["u2", "user"], - ["a2", "assistant"], - ] as const) { - await service.appendToHistory(workspaceId, createMuxMessage(id, role, id)); - } - // The u2 request overflowed, and its retry left the header out from u2's sequence on. - const omitted = omitPostCompactionContextFrom(boundary, 3); - assert(omitted, "expected the header to be left out"); - expect((await service.updateHistory(workspaceId, omitted)).success).toBe(true); - const latestBoundary = async () => { - const history = await service.getHistoryFromLatestBoundary(workspaceId); - assert(history.success); - return history.data; - }; - - await service.truncateAfterMessage(workspaceId, "u2", { keepTargetMessage: true }); - expect(findPostCompactionContext(await latestBoundary())).toBeNull(); - - await service.truncateAfterMessage(workspaceId, "a1", { keepTargetMessage: true }); - const restored = await latestBoundary(); - expect(findPostCompactionContext(restored)?.text).toBe("CTX"); - expect(restored[0].metadata?.contextUsage).toEqual(usage); - }); }); describe("destructive context publication fencing", () => { diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 1418294525c..9db9c176b8e 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -98,7 +98,6 @@ import { type SendIdentity, } from "./sendIds"; import { log } from "./log"; -import { restorePostCompactionContextAfterCut } from "./postCompactionContext"; import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { safeStringifyForCounting } from "@/common/utils/tokens/safeStringifyForCounting"; @@ -261,16 +260,6 @@ function retireReexposedCompactionFollowUp( return (message) => (message === exposed ? retired : message); } -/** The retained-row sanitizer of an edit or fork cut. */ -function sanitizeRetainedAfterCut( - retained: readonly MuxMessage[], - removed: readonly MuxMessage[] -): (message: MuxMessage) => MuxMessage { - const retire = retireReexposedCompactionFollowUp(retained, removed); - const restoreHeader = restorePostCompactionContextAfterCut(retained); - return (message) => restoreHeader(retire(message)); -} - function deletionCreatesRawReset( rows: readonly HistoryRewriteRow[], deletedIds: ReadonlySet, @@ -6075,7 +6064,7 @@ export class HistoryService { rows, workspaceId, truncatedMessages, - sanitizeRetainedAfterCut(truncatedMessages, removedMessages) + retireReexposedCompactionFollowUp(truncatedMessages, removedMessages) ); const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId); @@ -6204,7 +6193,7 @@ export class HistoryService { [...archiveRows, ...activeEpochRows], workspaceId, truncatedMessages, - sanitizeRetainedAfterCut(truncatedMessages, removedMessages) + retireReexposedCompactionFollowUp(truncatedMessages, removedMessages) ), publication ); diff --git a/src/node/services/postCompactionContext.ts b/src/node/services/postCompactionContext.ts index 084dd41756f..be8732a3dc7 100644 --- a/src/node/services/postCompactionContext.ts +++ b/src/node/services/postCompactionContext.ts @@ -180,45 +180,14 @@ export function restorePostCompactionContextBefore( requestHistorySequence >= context.omittedFromRequestSequence ) return messages; + const { omittedFromRequestSequence: _omitted, ...restored } = context; return messages.map((message, messageIndex) => - messageIndex === index ? withHeaderRendered(message, context) : message + messageIndex === index + ? { ...message, metadata: { ...message.metadata, postCompactionContext: restored } } + : message ); } -/** - * Edit and fork truncation: the latest boundary renders its header again when the cut removed - * every row that a request without the header saw. The retained turns ran with the header, and - * the request that overflowed is gone. - */ -export function restorePostCompactionContextAfterCut( - retained: readonly MuxMessage[] -): (message: MuxMessage) => MuxMessage { - const boundary = retained.findLast(isDurableContextBoundaryMarker); - const context = boundary ? readPersistedPostCompactionContext(boundary) : null; - if (!boundary || !context || !retainsOnlyRowsBeforeOmission(retained, context)) - return (message) => message; - return (message) => - message.id === boundary.id - ? shiftContextUsageByHeader(withHeaderRendered(message, context), 1) - : message; -} - -function retainsOnlyRowsBeforeOmission( - retained: readonly MuxMessage[], - context: PostCompactionContext -): boolean { - const omittedFrom = context.omittedFromRequestSequence; - return ( - omittedFrom != null && - !retained.some((message) => (message.metadata?.historySequence ?? -1) >= omittedFrom) - ); -} - -function withHeaderRendered(boundary: MuxMessage, context: PostCompactionContext): MuxMessage { - const { omittedFromRequestSequence: _omitted, ...rendered } = context; - return { ...boundary, metadata: { ...boundary.metadata, postCompactionContext: rendered } }; -} - /** * 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. From e53d3bfec60d8c707f2d6e3493e289a65b128fa8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 30 Oct 2026 16:04:14 +1100 Subject: [PATCH 26/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20count=20only=20the?= =?UTF-8?q?=20header=20a=20continuous=20boundary=20keeps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A continuous boundary drops a header that does not fit its row, but its contextUsage still added the header estimate from before the build. The boundary now counts the header on the row: the journaled header for a prepared boundary, and the header that publication adds for the others. The unused attachmentTokens argument of buildContinuousCompactionRows is removed. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- .../agentSession.continuousCompaction.test.ts | 2 - .../agentSession.pendingOwnership.test.ts | 1 - ...tSession.postCompactionAttachments.test.ts | 1 - src/node/services/agentSession.testHarness.ts | 1 - .../compactionHandler.continuous.test.ts | 5 --- .../compactionHandler.partialRecovery.test.ts | 1 - ...compactionHandler.pendingOwnership.test.ts | 1 - src/node/services/compactionHandler.test.ts | 45 ++++++++++++++++++- src/node/services/compactionHandler.ts | 19 ++++---- .../sessionContextController.test.ts | 1 - src/node/services/continuousCompactor.test.ts | 4 +- src/node/services/continuousCompactor.ts | 7 --- 12 files changed, 54 insertions(+), 34 deletions(-) diff --git a/src/node/services/agentSession.continuousCompaction.test.ts b/src/node/services/agentSession.continuousCompaction.test.ts index 67b396d4d5a..efe9825d4ef 100644 --- a/src/node/services/agentSession.continuousCompaction.test.ts +++ b/src/node/services/agentSession.continuousCompaction.test.ts @@ -699,7 +699,6 @@ describe("AgentSession continuous compaction wiring", () => { model, tail: [retained], systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }) ).toBe(true); @@ -1109,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.pendingOwnership.test.ts b/src/node/services/agentSession.pendingOwnership.test.ts index 26a3c694631..d75bb4b38f7 100644 --- a/src/node/services/agentSession.pendingOwnership.test.ts +++ b/src/node/services/agentSession.pendingOwnership.test.ts @@ -91,7 +91,6 @@ describe("pending snapshot consumers", () => { model, tail: [], systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }) ).toBe(true); diff --git a/src/node/services/agentSession.postCompactionAttachments.test.ts b/src/node/services/agentSession.postCompactionAttachments.test.ts index dc02b84c09d..3bb0c341139 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -161,7 +161,6 @@ async function compact( model: MODEL, tail: [], systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }) ).toBe(true); diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 5e33e91bf4a..d17c7a5ba4d 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -429,7 +429,6 @@ export async function compactSessionForTest( model: "anthropic:claude-sonnet-4-5", tail: [], systemMessageTokens: 0, - attachmentTokens: 0, shouldPersist: () => true, }); assert(persisted, "Expected the compaction to persist"); 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 6e2320fb89e..4a070dfeabf 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -22,6 +22,7 @@ import { } 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"; @@ -2356,6 +2357,46 @@ describe("CompactionHandler", () => { 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 @@ -2372,7 +2413,6 @@ describe("CompactionHandler", () => { model: "anthropic:test", tail: [], systemMessageTokens: 0, - attachmentTokens: 0, }; const prepared = handler.buildContinuousCompactionRows(rows); if (journaledHeader) { @@ -2409,6 +2449,9 @@ describe("CompactionHandler", () => { 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 diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index ebc4815bb50..cebaa002e0b 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -16,6 +16,7 @@ import { type CompactionPendingBoundaryWrite, } from "./compactionPendingState"; import { + estimatePostCompactionContextTokens, shiftContextUsageByHeader, withPostCompactionContext, type PostCompactionContextInput, @@ -273,10 +274,9 @@ function pickTailCopyMetadata(source: RowMetadata | undefined): Partial; } -/** Whether a boundary publication builds the header, and adds it to the boundary's contextUsage. */ +/** Whether a boundary publication builds the header. A header it builds counts in contextUsage. */ interface HeaderOptions { buildHeader: boolean; - countHeader: boolean; } export class CompactionHandler { @@ -355,7 +355,7 @@ export class CompactionHandler { preparation: CompactionPreparation, messages: MuxMessage[], boundary: CompactionPendingBoundaryWrite, - options: HeaderOptions = { buildHeader: true, countHeader: true } + options: HeaderOptions = { buildHeader: true } ) { messages = structuredClone(messages); boundary = { @@ -415,9 +415,7 @@ export class CompactionHandler { reportsCompletedBeforeMs: nodeClock.nowMs(), }); const withContext = withPostCompactionContext(summary, built, this.workspaceId); - return options.countHeader && withContext !== summary - ? shiftContextUsageByHeader(withContext, 1) - : withContext; + 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", { @@ -950,7 +948,6 @@ export class CompactionHandler { model: string; tail: MuxMessage[]; systemMessageTokens: number; - attachmentTokens: number; pendingFollowUp?: CompactionFollowUpRequest; postCompactionContext?: PostCompactionContext | null; }): { boundary: MuxMessage; copies: MuxMessage[] } { @@ -1008,9 +1005,10 @@ export class CompactionHandler { const { boundary, copies } = params.prepared ? structuredClone(params.prepared) : this.buildContinuousCompactionRows(params); + // 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"); @@ -1032,9 +1030,8 @@ export class CompactionHandler { 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. inputTokens already counts - // attachmentTokens, so the header is not counted again. - { buildHeader: !params.prepared, countHeader: false } + // 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/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/continuousCompactor.test.ts b/src/node/services/continuousCompactor.test.ts index 4bc8d0d25d0..9c1f48b0aa0 100644 --- a/src/node/services/continuousCompactor.test.ts +++ b/src/node/services/continuousCompactor.test.ts @@ -1423,13 +1423,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 5d6353f81c1..c31a9988c64 100644 --- a/src/node/services/continuousCompactor.ts +++ b/src/node/services/continuousCompactor.ts @@ -27,10 +27,6 @@ import { import type { HistoryService } from "./historyService"; import type { CompactionHandler } from "./compactionHandler"; import { log } from "./log"; -import { - estimatePostCompactionContextTokens, - findPostCompactionContext, -} from "./postCompactionContext"; export interface ContinuousCompactionContext { enabled: boolean; @@ -559,7 +555,6 @@ export class ContinuousCompactor { model: staged.model, tail, systemMessageTokens: context.systemMessageTokens ?? 0, - attachmentTokens: staged.attachmentTokens, postCompactionContext: prepared.postCompactionContext, }); const staticCopies = copies.slice(0, -1); @@ -757,7 +752,6 @@ export class ContinuousCompactor { model: journal.summaryModel, tail: [], systemMessageTokens: boundary.metadata?.systemMessageTokens ?? 0, - attachmentTokens: estimatePostCompactionContextTokens(findPostCompactionContext([boundary])), // Sequence assignment must not mutate the exact journal receipt used by cleanup. prepared: { boundary, copies: [...structuredClone(journal.staticCopies), liveCopy] }, publication: { generation: journal.publicationGeneration, journal }, @@ -829,7 +823,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"); From 942e080f017fe56ebce9c303df919218302b2ba1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 30 Oct 2026 16:34:31 +1100 Subject: [PATCH 27/27] =?UTF-8?q?=F0=9F=A4=96=20fix:=20size-check=20the=20?= =?UTF-8?q?swap=20header=20and=20keep=20newer=20plan=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A mid-stream prefix swap now checks the window again with the header it sends. The estimate that admitted the summary can lack that header when its build timed out. A swap that no longer fits falls back to the durable apply. - Turn start seeds the plan baseline from the boundary header only when the tracker has no newer state for the plan. A tool in the retained tail of a swap can record a newer state that the model already saw. _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_ --- ...gentSession.fileChangeNotification.test.ts | 16 ++++++++ src/node/services/agentSession.ts | 4 +- src/node/services/continuousCompactor.test.ts | 40 +++++++++++++++++++ src/node/services/continuousCompactor.ts | 19 ++++++--- src/node/services/utils/fileChangeTracker.ts | 9 +++++ 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index 6b976b24ef8..a2f7ffac8bb 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -334,6 +334,22 @@ describe("AgentSession file-change notification (turn start)", () => { }); }); + 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(); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 20bdfabb153..7aeed23ec83 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -13554,7 +13554,9 @@ export class AgentSession { this.planBaselineBoundaryId = boundaryId; const plan = findPostCompactionPlan(history.data); if (plan) { - await this.recordFileState(plan.path, { + // 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, }); diff --git a/src/node/services/continuousCompactor.test.ts b/src/node/services/continuousCompactor.test.ts index 9c1f48b0aa0..35588a5a741 100644 --- a/src/node/services/continuousCompactor.test.ts +++ b/src/node/services/continuousCompactor.test.ts @@ -1135,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"); diff --git a/src/node/services/continuousCompactor.ts b/src/node/services/continuousCompactor.ts index c31a9988c64..5a01b9896bd 100644 --- a/src/node/services/continuousCompactor.ts +++ b/src/node/services/continuousCompactor.ts @@ -27,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; @@ -153,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; @@ -534,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" || @@ -557,6 +560,12 @@ export class ContinuousCompactor { systemMessageTokens: context.systemMessageTokens ?? 0, 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"); diff --git a/src/node/services/utils/fileChangeTracker.ts b/src/node/services/utils/fileChangeTracker.ts index 94f30baa0ba..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);