diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index 3b4cecf1e9..b2429cd2ae 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -22,8 +22,6 @@ import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSett import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import { PostCompactionSection } from "./PostCompactionSection"; import { usePostCompactionState } from "@/browser/hooks/usePostCompactionState"; -import { useExperimentValue } from "@/browser/contexts/ExperimentsContext"; -import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; /** @@ -59,8 +57,7 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; - // Post-compaction context state for UI display (gated by experiment) - const postCompactionEnabled = useExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + // Post-compaction context state for UI display const postCompactionState = usePostCompactionState(workspaceId); // Get runtimeConfig for SSH-aware editor opening @@ -136,16 +133,14 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { ); })()} - {postCompactionEnabled && ( - - )} + )} diff --git a/src/browser/components/Settings/sections/ExperimentsSection.tsx b/src/browser/components/Settings/sections/ExperimentsSection.tsx index e20f12b671..c1de89d45e 100644 --- a/src/browser/components/Settings/sections/ExperimentsSection.tsx +++ b/src/browser/components/Settings/sections/ExperimentsSection.tsx @@ -23,7 +23,6 @@ import type { ApiServerStatus } from "@/common/orpc/types"; import { Input } from "@/browser/components/ui/input"; import { useAPI } from "@/browser/contexts/API"; import { useFeatureFlags } from "@/browser/contexts/FeatureFlagsContext"; -import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useTelemetry } from "@/browser/hooks/useTelemetry"; interface ExperimentRowProps { @@ -485,7 +484,6 @@ function StatsTabRow() { export function ExperimentsSection() { const allExperiments = getExperimentList(); - const { refreshWorkspaceMetadata } = useWorkspaceContext(); const { api } = useAPI(); // Only show user-overridable experiments (non-overridable ones are hidden since users can't change them) @@ -495,13 +493,6 @@ export function ExperimentsSection() { [allExperiments] ); - // When post-compaction experiment is toggled, refresh metadata to fetch/clear bundled state - const handlePostCompactionToggle = useCallback(() => { - refreshWorkspaceMetadata().catch(() => { - // ignore - }); - }, [refreshWorkspaceMetadata]); - const handleConfigurableBindUrlToggle = useCallback( (enabled: boolean) => { if (enabled) { @@ -531,11 +522,9 @@ export function ExperimentsSection() { name={exp.name} description={exp.description} onToggle={ - exp.id === EXPERIMENT_IDS.POST_COMPACTION_CONTEXT - ? handlePostCompactionToggle - : exp.id === EXPERIMENT_IDS.CONFIGURABLE_BIND_URL - ? handleConfigurableBindUrlToggle - : undefined + exp.id === EXPERIMENT_IDS.CONFIGURABLE_BIND_URL + ? handleConfigurableBindUrlToggle + : undefined } /> {exp.id === EXPERIMENT_IDS.CONFIGURABLE_BIND_URL && } diff --git a/src/browser/contexts/ExperimentsContext.test.tsx b/src/browser/contexts/ExperimentsContext.test.tsx index 4fd8fdb945..1034b0b925 100644 --- a/src/browser/contexts/ExperimentsContext.test.tsx +++ b/src/browser/contexts/ExperimentsContext.test.tsx @@ -39,12 +39,12 @@ describe("ExperimentsProvider", () => { if (callCount === 1) { return Promise.resolve({ - [EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: { value: null, source: "cache" }, + [EXPERIMENT_IDS.SYSTEM_1]: { value: null, source: "cache" }, } satisfies Record); } return Promise.resolve({ - [EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: { value: "test", source: "posthog" }, + [EXPERIMENT_IDS.SYSTEM_1]: { value: "test", source: "posthog" }, } satisfies Record); }); @@ -56,7 +56,7 @@ describe("ExperimentsProvider", () => { }; function Observer() { - const enabled = useExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + const enabled = useExperimentValue(EXPERIMENT_IDS.SYSTEM_1); return
{String(enabled)}
; } diff --git a/src/browser/hooks/useExperiments.test.ts b/src/browser/hooks/useExperiments.test.ts index 74157fde66..327d843e98 100644 --- a/src/browser/hooks/useExperiments.test.ts +++ b/src/browser/hooks/useExperiments.test.ts @@ -24,30 +24,30 @@ describe("isExperimentEnabled", () => { }); test("returns undefined when no local override exists for a user-overridable experiment", () => { - expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBeUndefined(); + expect(isExperimentEnabled(EXPERIMENT_IDS.SYSTEM_1)).toBeUndefined(); }); test("returns boolean when local override exists", () => { - const key = getExperimentKey(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + const key = getExperimentKey(EXPERIMENT_IDS.SYSTEM_1); globalThis.window.localStorage.setItem(key, JSON.stringify(true)); - expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBe(true); + expect(isExperimentEnabled(EXPERIMENT_IDS.SYSTEM_1)).toBe(true); globalThis.window.localStorage.setItem(key, JSON.stringify(false)); - expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBe(false); + expect(isExperimentEnabled(EXPERIMENT_IDS.SYSTEM_1)).toBe(false); }); test('treats literal "undefined" as no override', () => { - const key = getExperimentKey(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + const key = getExperimentKey(EXPERIMENT_IDS.SYSTEM_1); globalThis.window.localStorage.setItem(key, "undefined"); - expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBeUndefined(); + expect(isExperimentEnabled(EXPERIMENT_IDS.SYSTEM_1)).toBeUndefined(); }); test("treats non-boolean stored value as no override", () => { - const key = getExperimentKey(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + const key = getExperimentKey(EXPERIMENT_IDS.SYSTEM_1); globalThis.window.localStorage.setItem(key, JSON.stringify("test")); - expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBeUndefined(); + expect(isExperimentEnabled(EXPERIMENT_IDS.SYSTEM_1)).toBeUndefined(); }); }); diff --git a/src/browser/hooks/usePostCompactionState.ts b/src/browser/hooks/usePostCompactionState.ts index 778cba06cb..a99c9f6b4c 100644 --- a/src/browser/hooks/usePostCompactionState.ts +++ b/src/browser/hooks/usePostCompactionState.ts @@ -2,8 +2,6 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useAPI } from "@/browser/contexts/API"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getPostCompactionStateKey } from "@/common/constants/storage"; -import { useExperimentValue } from "@/browser/hooks/useExperiments"; -import { EXPERIMENT_IDS } from "@/common/constants/experiments"; interface PostCompactionState { planPath: string | null; @@ -35,12 +33,11 @@ function loadFromCache(wsId: string) { * Hook to get post-compaction context state for a workspace. * Fetches lazily from the backend API and caches in localStorage. * This avoids the expensive runtime.stat calls during workspace.list(). - * Only fetches when the POST_COMPACTION_CONTEXT experiment is enabled. + * + * Always enabled: post-compaction context is a stable feature (not an experiment). */ export function usePostCompactionState(workspaceId: string): PostCompactionState { const { api } = useAPI(); - const experimentEnabled = useExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); - const [state, setState] = useState(() => loadFromCache(workspaceId)); // Track which workspaceId the current state belongs to. @@ -51,9 +48,9 @@ export function usePostCompactionState(workspaceId: string): PostCompactionState setState(loadFromCache(workspaceId)); } - // Fetch fresh data when workspaceId changes (only if experiment enabled) + // Fetch fresh data when workspaceId changes useEffect(() => { - if (!api || !experimentEnabled) return; + if (!api) return; let cancelled = false; const fetchState = async () => { @@ -84,11 +81,11 @@ export function usePostCompactionState(workspaceId: string): PostCompactionState return () => { cancelled = true; }; - }, [api, workspaceId, experimentEnabled]); + }, [api, workspaceId]); const toggleExclusion = useCallback( async (itemId: string) => { - if (!api || !experimentEnabled) return; + if (!api) return; const isCurrentlyExcluded = state.excludedItems.has(itemId); const result = await api.workspace.setPostCompactionExclusion({ workspaceId, @@ -117,7 +114,7 @@ export function usePostCompactionState(workspaceId: string): PostCompactionState }); } }, - [api, workspaceId, state.excludedItems, experimentEnabled] + [api, workspaceId, state.excludedItems] ); return { ...state, toggleExclusion }; diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 8e94637c55..6482e0464a 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -36,7 +36,6 @@ function applyGatewayTransform(modelId: string, gateway: GatewayState): string { } interface ExperimentValues { - postCompactionContext: boolean | undefined; programmaticToolCalling: boolean | undefined; programmaticToolCallingExclusive: boolean | undefined; system1: boolean | undefined; @@ -93,7 +92,6 @@ function constructSendMessageOptions( // toolPolicy is computed by backend from agent definitions (resolveToolPolicyForAgent) providerOptions, experiments: { - postCompactionContext: experimentValues.postCompactionContext, programmaticToolCalling: experimentValues.programmaticToolCalling, programmaticToolCallingExclusive: experimentValues.programmaticToolCallingExclusive, system1: experimentValues.system1, @@ -139,7 +137,6 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi // Subscribe to local override state so toggles apply immediately. // If undefined, the backend will apply the PostHog assignment. - const postCompactionContext = useExperimentOverrideValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); const programmaticToolCalling = useExperimentOverrideValue( EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING ); @@ -177,7 +174,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi providerOptions, defaultModel, gateway, - { postCompactionContext, programmaticToolCalling, programmaticToolCallingExclusive, system1 }, + { programmaticToolCalling, programmaticToolCallingExclusive, system1 }, system1Model, system1ThinkingLevel ); diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index f1595dd4cf..972bc526ba 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -20,7 +20,11 @@ import { createWorkspace, groupWorkspacesByProject } from "./mockFactory"; import { selectWorkspace } from "./storyHelpers"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { within, userEvent, waitFor } from "@storybook/test"; -import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { + getExperimentKey, + EXPERIMENT_IDS, + type ExperimentId, +} from "@/common/constants/experiments"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import type { TaskSettings } from "@/common/types/tasks"; import type { LayoutPresetsConfig } from "@/common/types/uiLayouts"; @@ -54,7 +58,7 @@ function setupSettingsStory(options: { // Pre-set experiment states if provided if (options.experiments) { for (const [experimentId, enabled] of Object.entries(options.experiments)) { - const key = getExperimentKey(experimentId as typeof EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + const key = getExperimentKey(experimentId as ExperimentId); window.localStorage.setItem(key, JSON.stringify(enabled)); } } @@ -483,7 +487,7 @@ export const ExperimentsToggleOn: AppStory = { setupSettingsStory({ - experiments: { [EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: true }, + experiments: { [EXPERIMENT_IDS.SYSTEM_1]: true }, }) } /> diff --git a/src/browser/utils/messages/attachmentRenderer.test.ts b/src/browser/utils/messages/attachmentRenderer.test.ts index 1b0f878f19..521648ced8 100644 --- a/src/browser/utils/messages/attachmentRenderer.test.ts +++ b/src/browser/utils/messages/attachmentRenderer.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "@jest/globals"; -import { renderAttachmentToContent } from "./attachmentRenderer"; -import type { TodoListAttachment } from "@/common/types/attachment"; +import { + renderAttachmentToContent, + renderAttachmentsToContentWithBudget, +} from "./attachmentRenderer"; +import type { + TodoListAttachment, + PlanFileReferenceAttachment, + EditedFilesReferenceAttachment, +} from "@/common/types/attachment"; describe("attachmentRenderer", () => { it("renders todo list inline and mentions todo_read", () => { @@ -27,4 +34,35 @@ describe("attachmentRenderer", () => { expect(content).not.toContain("todos.json"); expect(content).not.toContain("~/.mux"); }); + + it("respects a maxChars budget and truncates oversized plan content", () => { + const attachment: PlanFileReferenceAttachment = { + type: "plan_file_reference", + planFilePath: "~/.mux/plans/cmux/ws.md", + planContent: "a".repeat(10_000), + }; + + const content = renderAttachmentsToContentWithBudget([attachment], { maxChars: 400 }); + + expect(content.length).toBeLessThanOrEqual(400); + expect(content).toContain("Plan contents"); + expect(content).toContain("...(truncated)"); + expect(content).toContain(""); + }); + + it("emits an omitted-file-diffs note when edited file diffs do not fit", () => { + const attachment: EditedFilesReferenceAttachment = { + type: "edited_files_reference", + files: [ + { path: "src/a.ts", diff: "a".repeat(2000), truncated: false }, + { path: "src/b.ts", diff: "b".repeat(2000), truncated: false }, + ], + }; + + const content = renderAttachmentsToContentWithBudget([attachment], { maxChars: 120 }); + + expect(content.length).toBeLessThanOrEqual(120); + expect(content).toContain("omitted 2 file diffs"); + expect(content).toContain(""); + }); }); diff --git a/src/browser/utils/messages/attachmentRenderer.ts b/src/browser/utils/messages/attachmentRenderer.ts index b0ba775b5a..067f5d0688 100644 --- a/src/browser/utils/messages/attachmentRenderer.ts +++ b/src/browser/utils/messages/attachmentRenderer.ts @@ -6,6 +6,13 @@ import type { } from "@/common/types/attachment"; import { renderTodoItemsAsMarkdownList } from "@/common/utils/todoList"; +const SYSTEM_UPDATE_OPEN = "\n"; +const SYSTEM_UPDATE_CLOSE = "\n"; + +function wrapSystemUpdate(content: string): string { + return `${SYSTEM_UPDATE_OPEN}${content}${SYSTEM_UPDATE_CLOSE}`; +} + /** * Render a plan file reference attachment to content string. */ @@ -64,7 +71,175 @@ export function renderAttachmentToContent(attachment: PostCompactionAttachment): * Each attachment is wrapped in a tag. */ export function renderAttachmentsToContent(attachments: PostCompactionAttachment[]): string { + return attachments.map((att) => wrapSystemUpdate(renderAttachmentToContent(att))).join("\n"); +} + +const PLAN_TRUNCATION_NOTE = "\n\n...(truncated)\n"; + +function renderPlanFileReferenceWithBudget( + attachment: PlanFileReferenceAttachment, + maxChars: number +): string | null { + if (maxChars <= 0) { + return null; + } + + const prefix = `A plan file exists from plan mode at: ${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}`; + return minimal.length <= maxChars ? minimal : null; + } + + let planContent = attachment.planContent; + if (planContent.length > availableForContent) { + const sliceLength = Math.max(0, availableForContent - PLAN_TRUNCATION_NOTE.length); + planContent = `${planContent.slice(0, sliceLength)}${PLAN_TRUNCATION_NOTE}`; + } + + return `${prefix}${planContent}${suffix}`; +} + +function renderEditedFilesReferenceWithBudget( + attachment: EditedFilesReferenceAttachment, + maxChars: number +): { content: string | null; omittedFiles: number } { + const header = "The following files were edited in this session:\n\n"; + + if (maxChars <= header.length) { + return { content: null, omittedFiles: attachment.files.length }; + } + + const entries: string[] = []; + let used = header.length; + + for (const file of attachment.files) { + const truncationNote = file.truncated ? " (truncated)" : ""; + const entry = `File: ${file.path}${truncationNote}\n\`\`\`diff\n${file.diff}\n\`\`\``; + const separator = entries.length > 0 ? "\n\n" : ""; + const nextLen = used + separator.length + entry.length; + + if (nextLen > maxChars) { + break; + } + + entries.push(entry); + used = nextLen; + } + + const included = entries.length; + const omittedFiles = attachment.files.length - included; + + if (included === 0) { + return { content: null, omittedFiles: attachment.files.length }; + } + + return { + content: `${header}${entries.join("\n\n")}`, + omittedFiles, + }; +} + +function sortAttachmentsForInjection( + attachments: PostCompactionAttachment[] +): PostCompactionAttachment[] { + const priority: Record = { + plan_file_reference: 0, + todo_list: 1, + edited_files_reference: 2, + }; + return attachments - .map((att) => `\n${renderAttachmentToContent(att)}\n`) - .join("\n"); + .map((att, index) => ({ att, index })) + .sort((a, b) => { + const diff = priority[a.att.type] - priority[b.att.type]; + return diff !== 0 ? diff : a.index - b.index; + }) + .map((item) => item.att); +} + +export function renderAttachmentsToContentWithBudget( + attachments: PostCompactionAttachment[], + options: { maxChars: number } +): string { + const maxChars = Math.max(0, Math.floor(options.maxChars)); + if (attachments.length === 0 || maxChars === 0) { + return ""; + } + + const ordered = sortAttachmentsForInjection(attachments); + + const blocks: string[] = []; + let currentLength = 0; + let omittedFileDiffs = 0; + + const addBlock = (block: string): boolean => { + const separatorLen = blocks.length > 0 ? "\n".length : 0; + const nextLength = currentLength + separatorLen + block.length; + if (nextLength > maxChars) { + return false; + } + + blocks.push(block); + currentLength = nextLength; + return true; + }; + + for (const attachment of ordered) { + const separatorLen = blocks.length > 0 ? "\n".length : 0; + const remainingForBlock = maxChars - currentLength - separatorLen; + const remainingForContent = + remainingForBlock - SYSTEM_UPDATE_OPEN.length - SYSTEM_UPDATE_CLOSE.length; + + if (remainingForContent <= 0) { + break; + } + + if (attachment.type === "plan_file_reference") { + const content = renderPlanFileReferenceWithBudget(attachment, remainingForContent); + if (content) { + addBlock(wrapSystemUpdate(content)); + } + continue; + } + + if (attachment.type === "todo_list") { + const content = renderTodoListAttachment(attachment); + if (content.length <= remainingForContent) { + addBlock(wrapSystemUpdate(content)); + } + continue; + } + + if (attachment.type === "edited_files_reference") { + const { content, omittedFiles } = renderEditedFilesReferenceWithBudget( + attachment, + remainingForContent + ); + omittedFileDiffs += omittedFiles; + + if (content) { + addBlock(wrapSystemUpdate(content)); + } + continue; + } + } + + if (omittedFileDiffs > 0) { + const plural = omittedFileDiffs === 1 ? "" : "s"; + const note = `(post-compaction context truncated; omitted ${omittedFileDiffs} file diff${plural})`; + addBlock(wrapSystemUpdate(note)); + } + + if (blocks.length === 0) { + const note = "(post-compaction context omitted due to size)"; + if (note.length + SYSTEM_UPDATE_OPEN.length + SYSTEM_UPDATE_CLOSE.length <= maxChars) { + blocks.push(wrapSystemUpdate(note)); + } + } + + return blocks.join("\n"); } diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index dfe580d8bd..fe674428f7 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -8,8 +8,10 @@ import { injectAgentTransition, filterEmptyAssistantMessages, injectFileChangeNotifications, + injectPostCompactionAttachments, stripOrphanedToolCalls, } from "./modelMessageTransform"; +import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; import type { MuxMessage } from "@/common/types/message"; describe("modelMessageTransform", () => { @@ -1556,3 +1558,49 @@ describe("injectFileChangeNotifications", () => { expect(text).toContain("diff2"); }); }); + +describe("injectPostCompactionAttachments", () => { + it("inserts after the 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" }, + }, + { + 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"); + }); +}); diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index 8623fb9ec6..ee9a10668a 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -7,7 +7,8 @@ import type { ModelMessage, AssistantModelMessage, ToolModelMessage } from "ai"; import type { MuxMessage } from "@/common/types/message"; import type { EditedFileAttachment } from "@/node/services/agentSession"; import type { PostCompactionAttachment } from "@/common/types/attachment"; -import { renderAttachmentsToContent } from "./attachmentRenderer"; +import { MAX_POST_COMPACTION_INJECTION_CHARS } from "@/common/constants/attachments"; +import { renderAttachmentsToContentWithBudget } from "./attachmentRenderer"; /** * Filter out assistant messages that are empty or only contain reasoning parts. @@ -318,7 +319,14 @@ export function injectPostCompactionAttachments( const syntheticMessage: MuxMessage = { id: `post-compaction-${Date.now()}`, role: "user", - parts: [{ type: "text", text: renderAttachmentsToContent(attachments) }], + parts: [ + { + type: "text", + text: renderAttachmentsToContentWithBudget(attachments, { + maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, + }), + }, + ], metadata: { timestamp: Date.now(), synthetic: true, @@ -331,7 +339,14 @@ export function injectPostCompactionAttachments( const syntheticMessage: MuxMessage = { id: `post-compaction-${Date.now()}`, role: "user", - parts: [{ type: "text", text: renderAttachmentsToContent(attachments) }], + parts: [ + { + type: "text", + text: renderAttachmentsToContentWithBudget(attachments, { + maxChars: MAX_POST_COMPACTION_INJECTION_CHARS, + }), + }, + ], metadata: { timestamp: messages[compactionIndex].metadata?.timestamp ?? Date.now(), synthetic: true, diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index d70db6c108..2f1557e264 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -109,7 +109,6 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio providerOptions, disableWorkspaceAgents: disableWorkspaceAgents || undefined, // Only include if true experiments: { - postCompactionContext: isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT), programmaticToolCalling: isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING), programmaticToolCallingExclusive: isExperimentEnabled( EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE diff --git a/src/cli/run.ts b/src/cli/run.ts index c26718ca3a..5946cd34b1 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -203,7 +203,6 @@ function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["ex return { programmaticToolCalling: experimentIds.includes("programmatic-tool-calling"), programmaticToolCallingExclusive: experimentIds.includes("programmatic-tool-calling-exclusive"), - postCompactionContext: experimentIds.includes("post-compaction-context"), system1: experimentIds.includes("system-1"), }; } diff --git a/src/common/constants/attachments.ts b/src/common/constants/attachments.ts index 750707a9d9..3987cd5bb0 100644 --- a/src/common/constants/attachments.ts +++ b/src/common/constants/attachments.ts @@ -10,3 +10,14 @@ export const MAX_FILE_CONTENT_SIZE = 50_000; /** Maximum number of edited files to include in attachments */ export const MAX_EDITED_FILES = 10; + +/** + * Maximum total size of the post-compaction context injection. + * + * Note: This is a character-based heuristic (provider-agnostic) to avoid large diffs/plan files + * causing context_exceeded loops even after compaction. + */ +export const MAX_POST_COMPACTION_INJECTION_CHARS = 80_000; + +/** Maximum size of plan content included in post-compaction attachments */ +export const MAX_POST_COMPACTION_PLAN_CHARS = 30_000; diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index e54faa8f85..96ff5ca752 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -6,7 +6,6 @@ */ export const EXPERIMENT_IDS = { - POST_COMPACTION_CONTEXT: "post-compaction-context", PROGRAMMATIC_TOOL_CALLING: "programmatic-tool-calling", PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive", CONFIGURABLE_BIND_URL: "configurable-bind-url", @@ -38,14 +37,6 @@ export interface ExperimentDefinition { * Use Record to ensure exhaustive coverage. */ export const EXPERIMENTS: Record = { - [EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: { - id: EXPERIMENT_IDS.POST_COMPACTION_CONTEXT, - name: "Post-Compaction Context", - description: "Re-inject plan file and edited file diffs after compaction to preserve context", - enabledByDefault: false, - userOverridable: true, // User can opt-out via Settings - showInSettings: true, - }, [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: { id: EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING, name: "Programmatic Tool Calling", diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 39300d5486..c9c52f71e9 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -437,7 +437,6 @@ export const ToolPolicySchema = z.array(ToolPolicyFilterSchema).meta({ // Experiments schema for feature gating export const ExperimentsSchema = z.object({ - postCompactionContext: z.boolean().optional(), programmaticToolCalling: z.boolean().optional(), programmaticToolCallingExclusive: z.boolean().optional(), system1: z.boolean().optional(), diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index dbb9f36092..db8b6b06ad 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -316,7 +316,7 @@ export interface ErrorOccurredPayload { * This helps measure opt-out rates and understand user preferences */ export interface ExperimentOverriddenPayload { - /** Experiment identifier (e.g., 'post-compaction-context') */ + /** Experiment identifier (e.g., 'system-1') */ experimentId: string; /** The variant PostHog assigned (null if not remote-controlled) */ assignedVariant: string | boolean | null; diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index dda39a4e0b..95edb1d63f 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -55,7 +55,10 @@ describe("AgentSession continue-message agentId fallback", () => { setMessageQueued: mock(() => undefined), } as unknown as BackgroundProcessManager; - const config: Config = { srcDir: "/tmp" } as unknown as Config; + const config: Config = { + srcDir: "/tmp", + getSessionDir: mock(() => "/tmp"), + } as unknown as Config; const partialService: PartialService = {} as unknown as PartialService; const session = new AgentSession({ diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 72c26c3480..7da0c14f8d 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -58,7 +58,10 @@ describe("AgentSession disposal race conditions", () => { setMessageQueued: mock(() => undefined), } as unknown as BackgroundProcessManager; - const config: Config = { srcDir: "/tmp" } as unknown as Config; + const config: Config = { + srcDir: "/tmp", + getSessionDir: mock(() => "/tmp"), + } as unknown as Config; const partialService: PartialService = {} as unknown as PartialService; const session = new AgentSession({ diff --git a/src/node/services/agentSession.postCompactionRefresh.test.ts b/src/node/services/agentSession.postCompactionRefresh.test.ts index d9e2fcf875..bc2206a6a7 100644 --- a/src/node/services/agentSession.postCompactionRefresh.test.ts +++ b/src/node/services/agentSession.postCompactionRefresh.test.ts @@ -11,7 +11,7 @@ import type { BackgroundProcessManager } from "./backgroundProcessManager"; // The actual post-compaction state computation is covered elsewhere. describe("AgentSession post-compaction refresh trigger", () => { - test("triggers callback on file_edit_* tool-call-end when experiment enabled", () => { + test("triggers callback on file_edit_* tool-call-end", () => { const handlers = new Map void>(); const aiService: AIService = { @@ -43,7 +43,10 @@ describe("AgentSession post-compaction refresh trigger", () => { cleanup: mock(() => Promise.resolve()), } as unknown as BackgroundProcessManager; - const config: Config = { srcDir: "/tmp" } as unknown as Config; + const config: Config = { + srcDir: "/tmp", + getSessionDir: mock(() => "/tmp"), + } as unknown as Config; const partialService: PartialService = {} as unknown as PartialService; const onPostCompactionStateChange = mock(() => undefined); @@ -59,10 +62,6 @@ describe("AgentSession post-compaction refresh trigger", () => { onPostCompactionStateChange, }); - // Enable the experiment gate (normally set during sendMessage()). - (session as unknown as { postCompactionContextEnabled: boolean }).postCompactionContextEnabled = - true; - const toolEnd = handlers.get("tool-call-end"); expect(toolEnd).toBeDefined(); @@ -100,69 +99,4 @@ describe("AgentSession post-compaction refresh trigger", () => { session.dispose(); }); - - test("does not trigger callback when experiment disabled", () => { - const handlers = new Map void>(); - - const aiService: AIService = { - on(eventName: string | symbol, listener: (...args: unknown[]) => void) { - handlers.set(String(eventName), listener); - return this; - }, - off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { - return this; - }, - stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), - } as unknown as AIService; - - const initStateManager: InitStateManager = { - on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { - return this; - }, - off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { - return this; - }, - } as unknown as InitStateManager; - - const backgroundProcessManager: BackgroundProcessManager = { - setMessageQueued: mock(() => undefined), - cleanup: mock(() => Promise.resolve()), - } as unknown as BackgroundProcessManager; - - const config: Config = { srcDir: "/tmp" } as unknown as Config; - const historyService: HistoryService = { - getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })), - } as unknown as HistoryService; - const partialService: PartialService = {} as unknown as PartialService; - - const onPostCompactionStateChange = mock(() => undefined); - - const session = new AgentSession({ - workspaceId: "ws", - config, - historyService, - partialService, - aiService, - initStateManager, - backgroundProcessManager, - onPostCompactionStateChange, - }); - - const toolEnd = handlers.get("tool-call-end"); - expect(toolEnd).toBeDefined(); - - toolEnd!({ - type: "tool-call-end", - workspaceId: "ws", - messageId: "m1", - toolCallId: "t1", - toolName: "file_edit_replace_string", - result: {}, - timestamp: Date.now(), - }); - - expect(onPostCompactionStateChange).toHaveBeenCalledTimes(0); - - session.dispose(); - }); }); diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts new file mode 100644 index 0000000000..6dfa2a66ab --- /dev/null +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, mock } from "bun:test"; +import { EventEmitter } from "events"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +import { AgentSession } from "./agentSession"; +import type { Config } from "@/node/config"; +import type { HistoryService } from "./historyService"; +import type { PartialService } from "./partialService"; +import type { AIService } from "./aiService"; +import type { InitStateManager } from "./initStateManager"; +import type { BackgroundProcessManager } from "./backgroundProcessManager"; + +import type { MuxMessage } from "@/common/types/message"; +import type { SendMessageOptions } from "@/common/orpc/types"; + +function createPersistedPostCompactionState(options: { + filePath: string; + diffs: Array<{ path: string; diff: string; truncated: boolean }>; +}): Promise { + const payload = { + version: 1 as const, + createdAt: Date.now(), + diffs: options.diffs, + }; + + return fsPromises.writeFile(options.filePath, JSON.stringify(payload)); +} + +describe("AgentSession post-compaction context retry", () => { + test("retries once without post-compaction injection on context_exceeded", async () => { + const workspaceId = "ws"; + const sessionDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + 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[] = [ + { + id: "compaction-summary", + role: "assistant", + parts: [{ type: "text", text: "Summary" }], + metadata: { timestamp: 1000, compacted: "user" }, + }, + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 1100 }, + }, + ]; + + const historyService: HistoryService = { + getHistory: mock(() => Promise.resolve({ success: true as const, data: history })), + deleteMessage: mock(() => Promise.resolve({ success: true as const, data: undefined })), + } as unknown as HistoryService; + + const partialService: PartialService = { + commitToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })), + deletePartial: mock(() => Promise.resolve({ success: true as const, data: undefined })), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + + let resolveSecondCall: (() => void) | undefined; + const secondCall = new Promise((resolve) => { + resolveSecondCall = resolve; + }); + + let callCount = 0; + const streamMessage = mock((..._args: unknown[]) => { + callCount += 1; + + if (callCount === 1) { + // Simulate a provider context limit error before any deltas. + aiEmitter.emit("error", { + workspaceId, + messageId: "assistant-ctx-exceeded", + error: "Context length exceeded", + errorType: "context_exceeded", + }); + + return Promise.resolve({ success: true as const, data: undefined }); + } + + resolveSecondCall?.(); + return Promise.resolve({ success: true as const, data: undefined }); + }); + + const aiService: AIService = { + on(eventName: string | symbol, listener: (...args: unknown[]) => void) { + aiEmitter.on(String(eventName), listener); + return this; + }, + off(eventName: string | symbol, listener: (...args: unknown[]) => void) { + aiEmitter.off(String(eventName), listener); + return this; + }, + streamMessage, + getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "nope" })), + stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), + } as unknown as AIService; + + const initStateManager: InitStateManager = { + on() { + return this; + }, + off() { + return this; + }, + } as unknown as InitStateManager; + + const backgroundProcessManager: BackgroundProcessManager = { + setMessageQueued: mock(() => undefined), + cleanup: mock(() => Promise.resolve()), + } as unknown as BackgroundProcessManager; + + const config: Config = { + srcDir: "/tmp", + getSessionDir: mock(() => sessionDir), + } as unknown as Config; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const options: SendMessageOptions = { + model: "openai:gpt-4o", + agentId: "exec", + } as unknown as SendMessageOptions; + + // Call streamWithHistory directly (private) to avoid needing a full user send pipeline. + await ( + session as unknown as { + streamWithHistory: (m: string, o: SendMessageOptions) => Promise; + } + ).streamWithHistory(options.model, options); + + // Wait for the retry call to happen. + await Promise.race([ + secondCall, + new Promise((_, reject) => setTimeout(() => reject(new Error("retry timeout")), 1000)), + ]); + + expect(streamMessage).toHaveBeenCalledTimes(2); + + const firstAttachments = (streamMessage as ReturnType).mock + .calls[0][12] as unknown; + expect(Array.isArray(firstAttachments)).toBe(true); + + const secondAttachments = (streamMessage as ReturnType).mock + .calls[1][12] as unknown; + expect(secondAttachments).toBeNull(); + + expect((historyService.deleteMessage as ReturnType).mock.calls[0][1]).toBe( + "assistant-ctx-exceeded" + ); + + // Pending post-compaction state should be discarded. + let exists = true; + try { + await fsPromises.stat(postCompactionPath); + } catch { + exists = false; + } + expect(exists).toBe(false); + + session.dispose(); + }); +}); diff --git a/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts index be91be55fd..949e56fbb0 100644 --- a/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts +++ b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts @@ -40,7 +40,10 @@ describe("AgentSession.resumeStream", () => { setMessageQueued: mock(() => undefined), } as unknown as BackgroundProcessManager; - const config: Config = { srcDir: "/tmp" } as unknown as Config; + const config: Config = { + srcDir: "/tmp", + getSessionDir: mock(() => "/tmp"), + } as unknown as Config; const session = new AgentSession({ workspaceId: "ws", diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8a6be10835..035541c863 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -178,6 +178,13 @@ export class AgentSession { * Used to enable the cooldown-based attachment injection. */ private compactionOccurred = false; + + /** + * When true, clear any persisted post-compaction state after the next successful non-compaction stream. + * + * This is intentionally delayed until stream-end so a crash mid-stream doesn't lose the diffs. + */ + private ackPendingPostCompactionStateOnStreamEnd = false; /** * Cache the last-known experiment state so we don't spam metadata refresh * when post-compaction context is disabled. @@ -187,12 +194,31 @@ export class AgentSession { /** * Active compaction request metadata for retry decisions (cleared on stream end/abort). */ + + /** Tracks the user message id that initiated the currently active stream (for retry guards). */ + private activeStreamUserMessageId?: string; + + /** Track user message ids that already retried without post-compaction injection. */ + private readonly postCompactionRetryAttempts = new Set(); + + /** True once we see any model/tool output for the current stream (retry guard). */ + private activeStreamHadAnyDelta = false; + + /** Tracks whether the current stream included post-compaction attachments. */ + private activeStreamHadPostCompactionInjection = false; + + /** Context needed to retry the current stream (cleared on stream end/abort/error). */ + private activeStreamContext?: { + modelString: string; + options?: SendMessageOptions; + openaiTruncationModeOverride?: "auto" | "disabled"; + }; + private activeCompactionRequest?: { id: string; modelString: string; options?: SendMessageOptions; }; - private postCompactionContextEnabled = false; constructor(options: AgentSessionOptions) { assert(options, "AgentSession requires options"); @@ -226,8 +252,9 @@ export class AgentSession { this.compactionHandler = new CompactionHandler({ workspaceId: this.workspaceId, historyService: this.historyService, - telemetryService, partialService: this.partialService, + sessionDir: this.config.getSessionDir(this.workspaceId), + telemetryService, emitter: this.emitter, onCompactionComplete, }); @@ -852,25 +879,37 @@ export class AgentSession { private async streamWithHistory( modelString: string, options?: SendMessageOptions, - openaiTruncationModeOverride?: "auto" | "disabled" + openaiTruncationModeOverride?: "auto" | "disabled", + disablePostCompactionAttachments?: boolean ): Promise> { if (this.disposed) { return Ok(undefined); } + // Reset per-stream flags (used for retries / crash-safe bookkeeping). + this.ackPendingPostCompactionStateOnStreamEnd = false; + this.activeStreamHadAnyDelta = false; + this.activeStreamHadPostCompactionInjection = false; + this.activeStreamContext = { + modelString, + options, + openaiTruncationModeOverride, + }; + this.activeStreamUserMessageId = undefined; + const commitResult = await this.partialService.commitToHistory(this.workspaceId); if (!commitResult.success) { return Err(createUnknownSendMessageError(commitResult.error)); } const historyResult = await this.historyService.getHistory(this.workspaceId); - // Cache whether post-compaction context is enabled for this session. - // Used to decide whether tool-call-end should trigger metadata refresh. - this.postCompactionContextEnabled = Boolean(options?.experiments?.postCompactionContext); - if (!historyResult.success) { return Err(createUnknownSendMessageError(historyResult.error)); } + // Capture the current user message id so retries are stable across assistant message ids. + const lastUserMessage = [...historyResult.data].reverse().find((m) => m.role === "user"); + this.activeStreamUserMessageId = lastUserMessage?.id; + if (historyResult.data.length === 0) { return Err( createUnknownSendMessageError( @@ -888,10 +927,13 @@ export class AgentSession { // Check for external file edits (timestamp-based polling) const changedFileAttachments = await this.fileChangeTracker.getChangedAttachments(); - // Check if post-compaction attachments should be injected (gated by experiment) - const postCompactionAttachments = options?.experiments?.postCompactionContext - ? await this.getPostCompactionAttachmentsIfNeeded() - : null; + // Check if post-compaction attachments should be injected. + const postCompactionAttachments = + disablePostCompactionAttachments === true + ? null + : await this.getPostCompactionAttachmentsIfNeeded(); + this.activeStreamHadPostCompactionInjection = + postCompactionAttachments !== null && postCompactionAttachments.length > 0; // Enforce thinking policy for the specified model (single source of truth) // This ensures model-specific requirements are met regardless of where the request originates @@ -963,25 +1005,16 @@ export class AgentSession { return undefined; } - private async finalizeCompactionRetry(messageId: string): Promise { - this.activeCompactionRequest = undefined; - this.emitChatEvent({ - type: "stream-abort", - workspaceId: this.workspaceId, - messageId, - }); - await this.clearFailedCompaction(messageId); - } - - private async clearFailedCompaction(messageId: string): Promise { + private async clearFailedAssistantMessage(messageId: string, reason: string): Promise { const [partialResult, deleteMessageResult] = await Promise.all([ this.partialService.deletePartial(this.workspaceId), this.historyService.deleteMessage(this.workspaceId, messageId), ]); if (!partialResult.success) { - log.warn("Failed to clear partial before compaction retry", { + log.warn("Failed to clear partial before retry", { workspaceId: this.workspaceId, + reason, error: partialResult.error, }); } @@ -993,13 +1026,25 @@ export class AgentSession { deleteMessageResult.error.includes("not found in history") ) ) { - log.warn("Failed to delete failed compaction placeholder", { + log.warn("Failed to delete failed assistant placeholder", { workspaceId: this.workspaceId, + reason, error: deleteMessageResult.error, }); } } + private async finalizeCompactionRetry(messageId: string): Promise { + this.activeCompactionRequest = undefined; + this.resetActiveStreamState(); + this.emitChatEvent({ + type: "stream-abort", + workspaceId: this.workspaceId, + messageId, + }); + await this.clearFailedAssistantMessage(messageId, "compaction-retry"); + } + private isSonnet45Model(modelString: string): boolean { const normalized = normalizeGatewayModel(modelString); const [provider, modelName] = normalized.split(":", 2); @@ -1107,6 +1152,95 @@ export class AgentSession { return true; } + private async maybeRetryWithoutPostCompactionOnContextExceeded(data: { + messageId: string; + errorType?: string; + }): Promise { + if (data.errorType !== "context_exceeded") { + return false; + } + + // Only retry if we actually injected post-compaction context. + if (!this.activeStreamHadPostCompactionInjection) { + return false; + } + + // Guardrail: don't retry if we've already emitted any meaningful output. + if (this.activeStreamHadAnyDelta) { + return false; + } + + const requestId = this.activeStreamUserMessageId; + const context = this.activeStreamContext; + if (!requestId || !context) { + return false; + } + + if (this.postCompactionRetryAttempts.has(requestId)) { + 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 diffs are likely the culprit; discard them so we don't loop. + try { + await this.compactionHandler.discardPendingDiffs("context_exceeded"); + this.onPostCompactionStateChange?.(); + } catch (error) { + log.warn("Failed to discard pending post-compaction state", { + workspaceId: this.workspaceId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // Abort the failed assistant placeholder and clean up persisted partial/history state. + this.resetActiveStreamState(); + this.emitChatEvent({ + type: "stream-abort", + workspaceId: this.workspaceId, + messageId: data.messageId, + }); + await this.clearFailedAssistantMessage(data.messageId, "post-compaction-retry"); + + // Retry the same request, but without post-compaction injection. + this.streamStarting = true; + let retryResult: Result; + try { + retryResult = await this.streamWithHistory( + context.modelString, + context.options, + context.openaiTruncationModeOverride, + true + ); + } finally { + this.streamStarting = false; + } + + if (!retryResult.success) { + log.error("Post-compaction retry failed to start", { + workspaceId: this.workspaceId, + error: retryResult.error, + }); + return false; + } + + return true; + } + + private resetActiveStreamState(): void { + this.activeStreamContext = undefined; + this.activeStreamUserMessageId = undefined; + this.activeStreamHadPostCompactionInjection = false; + this.activeStreamHadAnyDelta = false; + this.ackPendingPostCompactionStateOnStreamEnd = false; + } + private async handleStreamError(data: StreamErrorPayload): Promise { const hadCompactionRequest = this.activeCompactionRequest !== undefined; if ( @@ -1118,7 +1252,17 @@ export class AgentSession { return; } + if ( + await this.maybeRetryWithoutPostCompactionOnContextExceeded({ + messageId: data.messageId, + errorType: data.errorType, + }) + ) { + return; + } + this.activeCompactionRequest = undefined; + this.resetActiveStreamState(); if (hadCompactionRequest && !this.disposed) { this.clearQueue(); @@ -1149,30 +1293,45 @@ export class AgentSession { }; forward("stream-start", (payload) => this.emitChatEvent(payload)); - forward("stream-delta", (payload) => this.emitChatEvent(payload)); - forward("tool-call-start", (payload) => this.emitChatEvent(payload)); - forward("bash-output", (payload) => this.emitChatEvent(payload)); - forward("tool-call-delta", (payload) => this.emitChatEvent(payload)); + forward("stream-delta", (payload) => { + this.activeStreamHadAnyDelta = true; + this.emitChatEvent(payload); + }); + forward("tool-call-start", (payload) => { + this.activeStreamHadAnyDelta = true; + this.emitChatEvent(payload); + }); + forward("bash-output", (payload) => { + this.activeStreamHadAnyDelta = true; + this.emitChatEvent(payload); + }); + forward("tool-call-delta", (payload) => { + this.activeStreamHadAnyDelta = true; + this.emitChatEvent(payload); + }); forward("tool-call-end", (payload) => { + this.activeStreamHadAnyDelta = true; this.emitChatEvent(payload); - // If post-compaction context is enabled, certain tools can change what should - // be displayed/injected (plan writes, tracked file diffs). Trigger a metadata - // refresh so the right sidebar updates without requiring an experiment toggle. + // Post-compaction context state depends on plan writes + tracked file diffs. + // Trigger a metadata refresh so the right sidebar updates immediately. if ( - this.postCompactionContextEnabled && payload.type === "tool-call-end" && (payload.toolName === "propose_plan" || payload.toolName.startsWith("file_edit_")) ) { this.onPostCompactionStateChange?.(); } }); - forward("reasoning-delta", (payload) => this.emitChatEvent(payload)); + forward("reasoning-delta", (payload) => { + this.activeStreamHadAnyDelta = true; + this.emitChatEvent(payload); + }); forward("reasoning-end", (payload) => this.emitChatEvent(payload)); forward("usage-delta", (payload) => this.emitChatEvent(payload)); forward("stream-abort", (payload) => { const hadCompactionRequest = this.activeCompactionRequest !== undefined; this.activeCompactionRequest = undefined; + this.resetActiveStreamState(); if (hadCompactionRequest && !this.disposed) { this.clearQueue(); } @@ -1186,12 +1345,27 @@ export class AgentSession { if (!handled) { this.emitChatEvent(payload); + + if (this.ackPendingPostCompactionStateOnStreamEnd) { + this.ackPendingPostCompactionStateOnStreamEnd = false; + try { + await this.compactionHandler.ackPendingDiffsConsumed(); + } catch (error) { + log.warn("Failed to ack pending post-compaction state", { + workspaceId: this.workspaceId, + error: error instanceof Error ? error.message : String(error), + }); + } + this.onPostCompactionStateChange?.(); + } } else { // Compaction completed - notify to trigger metadata refresh // This allows the frontend to get updated postCompaction state this.onCompactionComplete?.(); } + this.resetActiveStreamState(); + // Stream end: auto-send queued messages this.sendQueuedMessages(); }); @@ -1369,8 +1543,9 @@ export class AgentSession { */ private async getPostCompactionAttachmentsIfNeeded(): Promise { // Check if compaction just occurred (immediate injection with cached diffs) - const pendingDiffs = this.compactionHandler.consumePendingDiffs(); + const pendingDiffs = await this.compactionHandler.peekPendingDiffs(); if (pendingDiffs !== null) { + this.ackPendingPostCompactionStateOnStreamEnd = true; this.compactionOccurred = true; this.turnsSinceLastAttachment = 0; // Clear file state cache since history context is gone diff --git a/src/node/services/attachmentService.ts b/src/node/services/attachmentService.ts index ca98906573..f5a427715a 100644 --- a/src/node/services/attachmentService.ts +++ b/src/node/services/attachmentService.ts @@ -8,6 +8,18 @@ import type { FileEditDiff } from "@/common/utils/messages/extractEditedFiles"; import type { Runtime } from "@/node/runtime/Runtime"; import { readFileString } from "@/node/utils/runtime/helpers"; import { expandTilde } from "@/node/runtime/tildeExpansion"; +import { MAX_POST_COMPACTION_PLAN_CHARS } from "@/common/constants/attachments"; + +const TRUNCATED_PLAN_NOTE = "\n\n...(truncated)\n"; + +function truncatePlanContent(planContent: string): string { + if (planContent.length <= MAX_POST_COMPACTION_PLAN_CHARS) { + return planContent; + } + + const sliceLength = Math.max(0, MAX_POST_COMPACTION_PLAN_CHARS - TRUNCATED_PLAN_NOTE.length); + return `${planContent.slice(0, sliceLength)}${TRUNCATED_PLAN_NOTE}`; +} /** * Service for generating post-compaction attachments. @@ -37,7 +49,7 @@ export class AttachmentService { return { type: "plan_file_reference", planFilePath, - planContent, + planContent: truncatePlanContent(planContent), }; } } catch { @@ -51,7 +63,7 @@ export class AttachmentService { return { type: "plan_file_reference", planFilePath: legacyPlanPath, - planContent, + planContent: truncatePlanContent(planContent), }; } } catch { diff --git a/src/node/services/compactionHandler.test.ts b/src/node/services/compactionHandler.test.ts index b0a43a97b7..c9e4cfa564 100644 --- a/src/node/services/compactionHandler.test.ts +++ b/src/node/services/compactionHandler.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, mock } from "bun:test"; import { CompactionHandler } from "./compactionHandler"; import type { HistoryService } from "./historyService"; import type { PartialService } from "./partialService"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + import type { EventEmitter } from "events"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import type { StreamEndEvent } from "@/common/types/stream"; @@ -121,10 +125,11 @@ describe("CompactionHandler", () => { let mockEmitter: EventEmitter; let telemetryCapture: ReturnType; let telemetryService: TelemetryService; + let sessionDir: string; let emittedEvents: EmittedEvent[]; const workspaceId = "test-workspace"; - beforeEach(() => { + beforeEach(async () => { const { emitter, events } = createMockEmitter(); mockEmitter = emitter; emittedEvents = events; @@ -134,14 +139,17 @@ describe("CompactionHandler", () => { }); telemetryService = { capture: telemetryCapture } as unknown as TelemetryService; + sessionDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-compaction-handler-")); + mockHistoryService = createMockHistoryService(); mockPartialService = createMockPartialService(); handler = new CompactionHandler({ workspaceId, historyService: mockHistoryService as unknown as HistoryService, - telemetryService, partialService: mockPartialService as unknown as PartialService, + sessionDir, + telemetryService, emitter: mockEmitter, }); }); @@ -201,6 +209,68 @@ describe("CompactionHandler", () => { }); }); + it("persists pending diffs to disk and reloads them on restart", async () => { + const compactionReq = createCompactionRequest(); + + const fileEditMessage: MuxMessage = { + id: "assistant-edit", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "t1", + toolName: "file_edit_replace_string", + state: "output-available", + input: { file_path: "/tmp/foo.ts" }, + output: { success: true, diff: "@@ -1 +1 @@\n-foo\n+bar\n" }, + }, + ], + metadata: { timestamp: 1234 }, + }; + + setupSuccessfulCompaction(mockHistoryService, [fileEditMessage, compactionReq]); + + const event = createStreamEndEvent("Summary"); + const handled = await handler.handleCompletion(event); + expect(handled).toBe(true); + + const persistedPath = path.join(sessionDir, "post-compaction.json"); + const raw = await fsPromises.readFile(persistedPath, "utf-8"); + const parsed = JSON.parse(raw) as { version?: unknown; diffs?: unknown }; + expect(parsed.version).toBe(1); + + const diffs = (parsed as { diffs?: Array<{ path: string; diff: string }> }).diffs; + expect(Array.isArray(diffs)).toBe(true); + if (Array.isArray(diffs)) { + expect(diffs[0]?.path).toBe("/tmp/foo.ts"); + expect(diffs[0]?.diff).toContain("@@ -1 +1 @@"); + } + + // Simulate a restart: create a new handler and load from disk. + const { emitter: newEmitter } = createMockEmitter(); + const reloaded = new CompactionHandler({ + workspaceId, + historyService: mockHistoryService as unknown as HistoryService, + partialService: mockPartialService as unknown as PartialService, + sessionDir, + telemetryService, + emitter: newEmitter, + }); + + const pending = await reloaded.peekPendingDiffs(); + expect(pending).not.toBeNull(); + expect(pending?.[0]?.path).toBe("/tmp/foo.ts"); + + await reloaded.ackPendingDiffsConsumed(); + + let exists = true; + try { + await fsPromises.stat(persistedPath); + } catch { + exists = false; + } + expect(exists).toBe(false); + }); it("should return true when successful", async () => { const compactionReq = createCompactionRequest(); mockHistoryService.mockGetHistory(Ok([compactionReq])); @@ -441,6 +511,16 @@ describe("CompactionHandler", () => { expect(result).toBe(false); expect(mockHistoryService.appendToHistory.mock.calls).toHaveLength(0); + + // Ensure we don't keep a persisted snapshot when compaction didn't clear history. + const persistedPath = path.join(sessionDir, "post-compaction.json"); + let exists = true; + try { + await fsPromises.stat(persistedPath); + } catch { + exists = false; + } + expect(exists).toBe(false); }); it("should return false when appendToHistory() fails", async () => { diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts index 4fa5a9cf55..d548db2056 100644 --- a/src/node/services/compactionHandler.ts +++ b/src/node/services/compactionHandler.ts @@ -1,4 +1,8 @@ import type { EventEmitter } from "events"; +import * as fsPromises from "fs/promises"; +import assert from "@/common/utils/assert"; +import * as path from "path"; + import type { HistoryService } from "./historyService"; import type { PartialService } from "./partialService"; @@ -11,8 +15,10 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { createCompactionSummaryMessageId } from "@/node/services/utils/messageIds"; import type { TelemetryService } from "@/node/services/telemetryService"; +import { MAX_EDITED_FILES, MAX_FILE_CONTENT_SIZE } from "@/common/constants/attachments"; import { roundToBase2 } from "@/common/telemetry/utils"; import { log } from "@/node/services/log"; +import { computeRecencyFromMessages } from "@/common/utils/recency"; import { extractEditedFileDiffs, type FileEditDiff, @@ -42,12 +48,83 @@ function looksLikeRawJsonObject(text: string): boolean { } } -import { computeRecencyFromMessages } from "@/common/utils/recency"; +const POST_COMPACTION_STATE_FILENAME = "post-compaction.json"; + +interface PersistedPostCompactionStateV1 { + version: 1; + createdAt: number; + diffs: FileEditDiff[]; +} + +function coerceFileEditDiffs(value: unknown): FileEditDiff[] { + if (!Array.isArray(value)) { + return []; + } + + const diffs: FileEditDiff[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + continue; + } + + const filePath = (item as { path?: unknown }).path; + const diff = (item as { diff?: unknown }).diff; + const truncated = (item as { truncated?: unknown }).truncated; + + if (typeof filePath !== "string") continue; + const trimmedPath = filePath.trim(); + if (trimmedPath.length === 0) continue; + + if (typeof diff !== "string") continue; + if (typeof truncated !== "boolean") continue; + + const clampedDiff = + diff.length > MAX_FILE_CONTENT_SIZE ? diff.slice(0, MAX_FILE_CONTENT_SIZE) : diff; + + diffs.push({ + path: trimmedPath, + diff: clampedDiff, + truncated: truncated || diff.length > MAX_FILE_CONTENT_SIZE, + }); + + if (diffs.length >= MAX_EDITED_FILES) { + break; + } + } + + return diffs; +} + +function coercePersistedPostCompactionState(value: unknown): PersistedPostCompactionStateV1 | null { + if (!value || typeof value !== "object") { + return null; + } + + const version = (value as { version?: unknown }).version; + if (version !== 1) { + return null; + } + + const createdAt = (value as { createdAt?: unknown }).createdAt; + if (typeof createdAt !== "number") { + return null; + } + + const diffsRaw = (value as { diffs?: unknown }).diffs; + const diffs = coerceFileEditDiffs(diffsRaw); + + return { + version: 1, + createdAt, + diffs, + }; +} interface CompactionHandlerOptions { workspaceId: string; historyService: HistoryService; partialService: PartialService; + sessionDir: string; telemetryService?: TelemetryService; emitter: EventEmitter; /** Called when compaction completes successfully (e.g., to clear idle compaction pending state) */ @@ -65,6 +142,9 @@ interface CompactionHandlerOptions { export class CompactionHandler { private readonly workspaceId: string; private readonly historyService: HistoryService; + private readonly sessionDir: string; + private readonly postCompactionStatePath: string; + private persistedPendingStateLoaded = false; private readonly partialService: PartialService; private readonly telemetryService?: TelemetryService; private readonly emitter: EventEmitter; @@ -78,26 +158,134 @@ export class CompactionHandler { private cachedFileDiffs: FileEditDiff[] = []; constructor(options: CompactionHandlerOptions) { + assert(options, "CompactionHandler requires options"); + assert(typeof options.sessionDir === "string", "sessionDir must be a string"); + const trimmedSessionDir = options.sessionDir.trim(); + assert(trimmedSessionDir.length > 0, "sessionDir must not be empty"); + this.workspaceId = options.workspaceId; this.historyService = options.historyService; + this.sessionDir = trimmedSessionDir; + this.postCompactionStatePath = path.join(trimmedSessionDir, POST_COMPACTION_STATE_FILENAME); this.partialService = options.partialService; this.telemetryService = options.telemetryService; this.emitter = options.emitter; this.onCompactionComplete = options.onCompactionComplete; } + private async loadPersistedPendingStateIfNeeded(): Promise { + if (this.persistedPendingStateLoaded || this.postCompactionAttachmentsPending) { + return; + } + + this.persistedPendingStateLoaded = true; + + let raw: string; + try { + raw = await fsPromises.readFile(this.postCompactionStatePath, "utf-8"); + } catch { + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + log.warn("Invalid post-compaction state JSON; ignoring", { workspaceId: this.workspaceId }); + await this.deletePersistedPendingStateBestEffort(); + return; + } + + const state = coercePersistedPostCompactionState(parsed); + if (!state) { + log.warn("Invalid post-compaction state schema; ignoring", { workspaceId: this.workspaceId }); + await this.deletePersistedPendingStateBestEffort(); + return; + } + + // Note: We intentionally do not validate against chat history here. + // The presence of this file is the source of truth that a compaction occurred (or at least started), + // and pre-compaction diffs may have been deleted from history. + + this.cachedFileDiffs = state.diffs; + this.postCompactionAttachmentsPending = true; + } + /** - * Consume pending post-compaction diffs and clear them. + * Peek pending post-compaction diffs without consuming them. * Returns null if no compaction occurred, otherwise returns the cached diffs. */ - consumePendingDiffs(): FileEditDiff[] | null { + async peekPendingDiffs(): Promise { + if (!this.postCompactionAttachmentsPending) { + await this.loadPersistedPendingStateIfNeeded(); + } + if (!this.postCompactionAttachmentsPending) { return null; } + + return this.cachedFileDiffs; + } + + /** + * Acknowledge that pending post-compaction state has been consumed successfully. + * Clears in-memory state and deletes the persisted snapshot from disk. + */ + async ackPendingDiffsConsumed(): Promise { + // If we never loaded persisted state but it exists, clear it anyway. + if (!this.postCompactionAttachmentsPending && !this.persistedPendingStateLoaded) { + await this.loadPersistedPendingStateIfNeeded(); + } + this.postCompactionAttachmentsPending = false; - const diffs = this.cachedFileDiffs; this.cachedFileDiffs = []; - return diffs; + await this.deletePersistedPendingStateBestEffort(); + } + + /** + * Drop pending post-compaction state (e.g., because it caused context_exceeded). + */ + async discardPendingDiffs(reason: string): Promise { + await this.loadPersistedPendingStateIfNeeded(); + + if (!this.postCompactionAttachmentsPending) { + return; + } + + log.warn("Discarding pending post-compaction state", { + workspaceId: this.workspaceId, + reason, + trackedFiles: this.cachedFileDiffs.length, + }); + + await this.ackPendingDiffsConsumed(); + } + + private async deletePersistedPendingStateBestEffort(): Promise { + try { + await fsPromises.unlink(this.postCompactionStatePath); + } catch { + // ignore + } + } + + private async persistPendingStateBestEffort(diffs: FileEditDiff[]): Promise { + try { + await fsPromises.mkdir(this.sessionDir, { recursive: true }); + + const persisted: PersistedPostCompactionStateV1 = { + version: 1, + createdAt: Date.now(), + diffs, + }; + + await fsPromises.writeFile(this.postCompactionStatePath, JSON.stringify(persisted)); + } catch (error) { + log.warn("Failed to persist post-compaction state", { + workspaceId: this.workspaceId, + error: error instanceof Error ? error.message : String(error), + }); + } } /** @@ -256,9 +444,19 @@ export class CompactionHandler { // Extract diffs BEFORE clearing history (they'll be gone after clear) this.cachedFileDiffs = extractEditedFileDiffs(messages); + // Persist pending state BEFORE clearing history so pre-compaction diffs survive crashes/restarts. + // Best-effort: compaction must not fail just because persistence fails. + await this.persistPendingStateBestEffort(this.cachedFileDiffs); + // Clear entire history and get deleted sequences const clearResult = await this.historyService.clearHistory(this.workspaceId); if (!clearResult.success) { + // We persist post-compaction state before clearing history for crash safety. + // If clearHistory fails, the pre-compaction messages are still intact, so keeping the + // persisted snapshot would cause redundant injection on the next send. + this.cachedFileDiffs = []; + await this.deletePersistedPendingStateBestEffort(); + return Err(`Failed to clear history: ${clearResult.error}`); } const deletedSequences = clearResult.data; diff --git a/src/node/services/experimentsService.test.ts b/src/node/services/experimentsService.test.ts index 3e205236b9..258c8ecb66 100644 --- a/src/node/services/experimentsService.test.ts +++ b/src/node/services/experimentsService.test.ts @@ -26,7 +26,7 @@ describe("ExperimentsService", () => { { version: 1, experiments: { - [EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]: { + [EXPERIMENT_IDS.SYSTEM_1]: { value: "test", fetchedAtMs: Date.now(), }, @@ -58,15 +58,12 @@ describe("ExperimentsService", () => { await service.initialize(); const values = service.getAll(); - expect(values[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]).toEqual({ + expect(values[EXPERIMENT_IDS.SYSTEM_1]).toEqual({ value: "test", source: "cache", }); - expect(setFeatureFlagVariant).toHaveBeenCalledWith( - EXPERIMENT_IDS.POST_COMPACTION_CONTEXT, - "test" - ); + expect(setFeatureFlagVariant).toHaveBeenCalledWith(EXPERIMENT_IDS.SYSTEM_1, "test"); }); test("refreshExperiment updates cache and writes it to disk", async () => { @@ -88,9 +85,9 @@ describe("ExperimentsService", () => { }); await service.initialize(); - await service.refreshExperiment(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + await service.refreshExperiment(EXPERIMENT_IDS.SYSTEM_1); - const value = service.getExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT); + const value = service.getExperimentValue(EXPERIMENT_IDS.SYSTEM_1); expect(value.value).toBe("test"); expect(value.source).toBe("posthog"); @@ -100,13 +97,10 @@ describe("ExperimentsService", () => { expect((disk as { version: unknown }).version).toBe(1); expect((disk as { experiments: Record }).experiments).toHaveProperty( - EXPERIMENT_IDS.POST_COMPACTION_CONTEXT + EXPERIMENT_IDS.SYSTEM_1 ); - expect(setFeatureFlagVariant).toHaveBeenCalledWith( - EXPERIMENT_IDS.POST_COMPACTION_CONTEXT, - "test" - ); + expect(setFeatureFlagVariant).toHaveBeenCalledWith(EXPERIMENT_IDS.SYSTEM_1, "test"); }); test("returns disabled when telemetry is disabled", async () => { @@ -120,11 +114,11 @@ describe("ExperimentsService", () => { await service.initialize(); const values = service.getAll(); - expect(values[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]).toEqual({ + expect(values[EXPERIMENT_IDS.SYSTEM_1]).toEqual({ value: null, source: "disabled", }); - expect(service.isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBe(false); + expect(service.isExperimentEnabled(EXPERIMENT_IDS.SYSTEM_1)).toBe(false); }); }); diff --git a/src/node/services/telemetryService.featureFlags.test.ts b/src/node/services/telemetryService.featureFlags.test.ts index d7fb9def86..c16a4b4a1e 100644 --- a/src/node/services/telemetryService.featureFlags.test.ts +++ b/src/node/services/telemetryService.featureFlags.test.ts @@ -52,7 +52,7 @@ describe("TelemetryService feature flag properties", () => { // @ts-expect-error - Accessing private property for test telemetry.distinctId = "distinct-id"; - telemetry.setFeatureFlagVariant("post-compaction-context", "test"); + telemetry.setFeatureFlagVariant("system-1", "test"); const payload: TelemetryEventPayload = { event: "message_sent", @@ -78,7 +78,7 @@ describe("TelemetryService feature flag properties", () => { | { properties?: Record } | undefined; expect(call?.properties).toBeDefined(); - expect(call?.properties?.["$feature/post-compaction-context"]).toBe("test"); + expect(call?.properties?.["$feature/system-1"]).toBe("test"); } finally { // Restore all env vars for (const [key, value] of Object.entries(savedEnv)) { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5b23d6b8b2..980fd9f70f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -443,6 +443,36 @@ export class WorkspaceService extends EventEmitter { this.sessions.delete(trimmed); } + private async getPersistedPostCompactionDiffPaths(workspaceId: string): Promise { + const postCompactionPath = path.join( + this.config.getSessionDir(workspaceId), + "post-compaction.json" + ); + + try { + const raw = await fsPromises.readFile(postCompactionPath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + const diffsRaw = (parsed as { diffs?: unknown }).diffs; + if (!Array.isArray(diffsRaw)) { + return null; + } + + const result: string[] = []; + for (const diff of diffsRaw) { + if (!diff || typeof diff !== "object") continue; + const p = (diff as { path?: unknown }).path; + if (typeof p !== "string") continue; + const trimmed = p.trim(); + if (trimmed.length === 0) continue; + result.push(trimmed); + } + + return result; + } catch { + return null; + } + } + /** * Get post-compaction context state for a workspace. * Returns info about what will be injected after compaction. @@ -506,6 +536,17 @@ export class WorkspaceService extends EventEmitter { }; } + // Fallback (crash-safe): if a post-compaction snapshot exists on disk, use it. + const persistedPaths = await this.getPersistedPostCompactionDiffPaths(workspaceId); + if (persistedPaths !== null) { + const trackedFilePaths = persistedPaths.filter((p) => !isPlanPath(p)); + return { + planPath: activePlanPath, + trackedFilePaths, + excludedItems: exclusions.excludedItems, + }; + } + // Fallback: compute tracked files from message history (survives reloads) const historyResult = await this.historyService.getHistory(workspaceId); const messages = historyResult.success ? historyResult.data : []; @@ -1727,26 +1768,9 @@ export class WorkspaceService extends EventEmitter { // - If userOverridable && frontend provides a value (explicit override) → use frontend value // - Else if remote evaluation enabled → use PostHog assignment // - Else → use frontend value (dev fallback) or default - const postCompactionExperiment = EXPERIMENTS[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT]; - const postCompactionFrontendValue = options?.experiments?.postCompactionContext; - const system1Experiment = EXPERIMENTS[EXPERIMENT_IDS.SYSTEM_1]; const system1FrontendValue = options?.experiments?.system1; - let postCompactionContextEnabled: boolean | undefined; - if (postCompactionExperiment.userOverridable && postCompactionFrontendValue !== undefined) { - // User-overridable: trust frontend value (user's explicit choice) - postCompactionContextEnabled = postCompactionFrontendValue; - } else if (this.experimentsService?.isRemoteEvaluationEnabled() === true) { - // Remote evaluation: use PostHog assignment - postCompactionContextEnabled = this.experimentsService.isExperimentEnabled( - EXPERIMENT_IDS.POST_COMPACTION_CONTEXT - ); - } else { - // Fallback to frontend value (dev mode or telemetry disabled) - postCompactionContextEnabled = postCompactionFrontendValue; - } - let system1Enabled: boolean | undefined; if (system1Experiment.userOverridable && system1FrontendValue !== undefined) { // User-overridable: trust frontend value (user's explicit choice) @@ -1760,9 +1784,6 @@ export class WorkspaceService extends EventEmitter { } const resolvedExperiments: Record = {}; - if (postCompactionContextEnabled !== undefined) { - resolvedExperiments.postCompactionContext = postCompactionContextEnabled; - } if (system1Enabled !== undefined) { resolvedExperiments.system1 = system1Enabled; }