Skip to content

Commit b47c23b

Browse files
authored
🤖 fix: refresh post-compaction context after plan writes (#1119)
🤖 Fix post-compaction context sidebar staleness after plan writes. - Backend now emits refreshed workspace metadata (including `postCompaction` state) after relevant tool completions (`file_edit_*`, `propose_plan`) when the experiment is enabled. - Refresh is debounced in `WorkspaceService` to coalesce rapid tool sequences. Validation: - `make static-check` - Added unit tests for the tool-call-end trigger and the debounce behavior. --- <details> <summary>📋 Implementation Plan</summary> # Fix: Post-compaction context UI not updating when plan is written ## Summary When the **Post-Compaction Context** experiment is enabled, the right-sidebar section (Costs tab) should show a “Plan file” item as soon as the agent writes the plan file. Currently it **doesn’t update until the user toggles the experiment off/on**. ## Repro (current behavior) 1. Enable **Settings → Experiments → Post-Compaction Context**. 2. In a workspace, have the agent write a plan (uses `file_edit_*` tools to create/update the plan file). 3. Observe: the right-sidebar **Post-Compaction Context** section does **not** appear / does not show “Plan file”. 4. Toggle the experiment off then on. 5. Observe: the section updates and now shows the “Plan file” item. ## Root cause (why this happens) **Frontend data source is stale.** - UI path: - `CostsTab.tsx` renders `<PostCompactionSection … />` only when `postCompactionEnabled`. - `usePostCompactionState(workspaceId)` reads `workspaceMetadata.get(workspaceId)?.postCompaction` from `WorkspaceContext`. - If `planPath` is `null`, the section won’t render (it early-returns when there’s no plan and no tracked files). - Where does `workspaceMetadata[*].postCompaction` come from? - It is only included when `WorkspaceContext.loadWorkspaceMetadata()` calls `api.workspace.list({ includePostCompaction: true })`. - That list call is triggered on mount and when the user toggles the experiment (see `ExperimentsSection.tsx` calling `refreshWorkspaceMetadata()`). - Critically: - Writing the plan uses `file_edit_*` tools → creates history entries and writes the plan file on disk. - **No code path automatically refreshes workspace metadata (or postCompaction state) after a file-edit tool finishes.** - Backend only emits metadata enriched with postCompaction in one place today: **after compaction completes** (`onCompactionComplete` callback wired in `WorkspaceService.getOrCreateSession`). So the UI is correct given its inputs; it’s just never told to recompute/reload post-compaction state when plan/file edits happen. ## Recommended fix (Approach A): backend-driven metadata refresh on tool completion **Goal:** after a `file_edit_*` tool finishes (and after `propose_plan`), emit an updated workspace metadata event that includes fresh `postCompaction` state. This keeps the UI model simple: `WorkspaceContext` already subscribes to metadata events and will update `workspaceMetadata`, which will flow into `usePostCompactionState` and re-render the right sidebar. ### Implementation plan #### 1) Add a new “post-compaction state may have changed” callback to `AgentSession` - Extend `AgentSession` constructor args to include something like: - `onPostCompactionStateChange?: () => void` - Store it on the session. #### 2) Trigger that callback on relevant tool completions In `AgentSession.attachAiListeners()`: - In the `"tool-call-end"` handler, after `emitChatEvent(payload)`: - If tool name is one of: - `file_edit_insert` - `file_edit_replace_string` - (optionally) `propose_plan` (since it can create/validate plan state) - Then call `this.onPostCompactionStateChange?.()`. **Optional gating:** only do this if the session has seen `options?.experiments?.postCompactionContext === true` recently. - Store `this.postCompactionExperimentEnabled` from the latest `sendMessage` options. - Gate callback invocation behind it. #### 3) Implement the callback in `WorkspaceService` (debounced + safe) In `WorkspaceService.getOrCreateSession()` pass: - `onPostCompactionStateChange: () => schedulePostCompactionMetadataRefresh(workspaceId)` Implement `schedulePostCompactionMetadataRefresh` in `WorkspaceService`: - Coalesce bursts (multiple file edits) with a short debounce (e.g. 100–250ms). - On fire: - `const metadata = await this.getInfo(workspaceId)` - If present, compute `const postCompaction = await this.getPostCompactionState(workspaceId)` - `this.sessions.get(workspaceId)?.emitMetadata({ ...metadata, postCompaction })` - Wrap in try/catch; treat runtime-unreachable as “don’t crash; skip emitting postCompaction”. #### 4) Ensure metadata updates don’t accidentally drop postCompaction - Today, some metadata events may not include `postCompaction`. - With this approach, we explicitly re-emit enriched metadata after file edits. - (Optional follow-up) When experiment is enabled, also enrich *other* metadata emits (create/title update/etc.) to avoid losing the field. ### Validation - Manual: - Enable experiment. - Write plan. - Confirm Post-Compaction Context section appears immediately (no experiment toggle needed). - Edit plan again; confirm it remains visible. - Edit a normal file (exec mode) and ensure tracked files list updates as expected. - Regression: - With experiment disabled, ensure no extra metadata spam and section stays hidden. ### Tests - Add a unit test around the new trigger behavior: - Simulate an `AgentSession` receiving a `tool-call-end` event for `file_edit_*`. - Assert `onPostCompactionStateChange` callback is invoked (and debounced once for bursts). - Add a `WorkspaceService` unit test for debounced refresh: - Stub `getInfo()` and `getPostCompactionState()`. - Call scheduler multiple times; assert a single `session.emitMetadata()` with enriched metadata. ### Net LoC estimate (product code) - **~80–160 LoC** - AgentSession: add callback field + invoke on tool-call-end. - WorkspaceService: debounce + enriched metadata emit. ## Alternative fix (Approach B): frontend-driven fetch of post-compaction state Instead of relying on `workspaceMetadata.postCompaction`, update `usePostCompactionState` to call `api.workspace.getPostCompactionState({ workspaceId })`: - On mount / workspace change. - On tool-call-end events (would require subscribing to WorkspaceStore or a custom event). ### Pros - Uses existing API (`getPostCompactionState`) without expanding backend event behavior. ### Cons - Needs a reliable “tool-call-end happened” signal on the frontend. - More moving parts in UI (event wiring, polling, or store integration). ### Net LoC estimate (product code) - **~120–220 LoC** - Hook changes + new event/listener plumbing. ## Decision Proceed with **Approach A (backend-driven metadata refresh)**: - It matches existing architecture (metadata subscription already exists). - It fixes the immediate bug (plan write doesn’t update sidebar) without adding UI polling. - It can be debounced centrally and is straightforward to reason about. </details> --- _Generated with `mux`_ --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 17ff207 commit b47c23b

File tree

4 files changed

+375
-17
lines changed

4 files changed

+375
-17
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, expect, test, mock } from "bun:test";
2+
import { AgentSession } from "./agentSession";
3+
import type { Config } from "@/node/config";
4+
import type { HistoryService } from "./historyService";
5+
import type { PartialService } from "./partialService";
6+
import type { AIService } from "./aiService";
7+
import type { InitStateManager } from "./initStateManager";
8+
import type { BackgroundProcessManager } from "./backgroundProcessManager";
9+
10+
// NOTE: These tests focus on the event wiring (tool-call-end -> callback).
11+
// The actual post-compaction state computation is covered elsewhere.
12+
13+
describe("AgentSession post-compaction refresh trigger", () => {
14+
test("triggers callback on file_edit_* tool-call-end when experiment enabled", () => {
15+
const handlers = new Map<string, (...args: unknown[]) => void>();
16+
17+
const aiService: AIService = {
18+
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
19+
handlers.set(String(eventName), listener);
20+
return this;
21+
},
22+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
23+
return this;
24+
},
25+
stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })),
26+
} as unknown as AIService;
27+
28+
const historyService: HistoryService = {
29+
getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })),
30+
} as unknown as HistoryService;
31+
32+
const initStateManager: InitStateManager = {
33+
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
34+
return this;
35+
},
36+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
37+
return this;
38+
},
39+
} as unknown as InitStateManager;
40+
41+
const backgroundProcessManager: BackgroundProcessManager = {
42+
setMessageQueued: mock(() => undefined),
43+
cleanup: mock(() => Promise.resolve()),
44+
} as unknown as BackgroundProcessManager;
45+
46+
const config: Config = { srcDir: "/tmp" } as unknown as Config;
47+
const partialService: PartialService = {} as unknown as PartialService;
48+
49+
const onPostCompactionStateChange = mock(() => undefined);
50+
51+
const session = new AgentSession({
52+
workspaceId: "ws",
53+
config,
54+
historyService,
55+
partialService,
56+
aiService,
57+
initStateManager,
58+
backgroundProcessManager,
59+
onPostCompactionStateChange,
60+
});
61+
62+
// Enable the experiment gate (normally set during sendMessage()).
63+
(session as unknown as { postCompactionContextEnabled: boolean }).postCompactionContextEnabled =
64+
true;
65+
66+
const toolEnd = handlers.get("tool-call-end");
67+
expect(toolEnd).toBeDefined();
68+
69+
toolEnd!({
70+
type: "tool-call-end",
71+
workspaceId: "ws",
72+
messageId: "m1",
73+
toolCallId: "t1b",
74+
toolName: "file_edit_replace_lines",
75+
result: {},
76+
timestamp: Date.now(),
77+
});
78+
79+
toolEnd!({
80+
type: "tool-call-end",
81+
workspaceId: "ws",
82+
messageId: "m1",
83+
toolCallId: "t1",
84+
toolName: "file_edit_insert",
85+
result: {},
86+
timestamp: Date.now(),
87+
});
88+
89+
toolEnd!({
90+
type: "tool-call-end",
91+
workspaceId: "ws",
92+
messageId: "m1",
93+
toolCallId: "t2",
94+
toolName: "bash",
95+
result: {},
96+
timestamp: Date.now(),
97+
});
98+
99+
expect(onPostCompactionStateChange).toHaveBeenCalledTimes(2);
100+
101+
session.dispose();
102+
});
103+
104+
test("does not trigger callback when experiment disabled", () => {
105+
const handlers = new Map<string, (...args: unknown[]) => void>();
106+
107+
const aiService: AIService = {
108+
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
109+
handlers.set(String(eventName), listener);
110+
return this;
111+
},
112+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
113+
return this;
114+
},
115+
stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })),
116+
} as unknown as AIService;
117+
118+
const initStateManager: InitStateManager = {
119+
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
120+
return this;
121+
},
122+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
123+
return this;
124+
},
125+
} as unknown as InitStateManager;
126+
127+
const backgroundProcessManager: BackgroundProcessManager = {
128+
setMessageQueued: mock(() => undefined),
129+
cleanup: mock(() => Promise.resolve()),
130+
} as unknown as BackgroundProcessManager;
131+
132+
const config: Config = { srcDir: "/tmp" } as unknown as Config;
133+
const historyService: HistoryService = {
134+
getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })),
135+
} as unknown as HistoryService;
136+
const partialService: PartialService = {} as unknown as PartialService;
137+
138+
const onPostCompactionStateChange = mock(() => undefined);
139+
140+
const session = new AgentSession({
141+
workspaceId: "ws",
142+
config,
143+
historyService,
144+
partialService,
145+
aiService,
146+
initStateManager,
147+
backgroundProcessManager,
148+
onPostCompactionStateChange,
149+
});
150+
151+
const toolEnd = handlers.get("tool-call-end");
152+
expect(toolEnd).toBeDefined();
153+
154+
toolEnd!({
155+
type: "tool-call-end",
156+
workspaceId: "ws",
157+
messageId: "m1",
158+
toolCallId: "t1",
159+
toolName: "file_edit_replace_string",
160+
result: {},
161+
timestamp: Date.now(),
162+
});
163+
164+
expect(onPostCompactionStateChange).toHaveBeenCalledTimes(0);
165+
166+
session.dispose();
167+
});
168+
});

src/node/services/agentSession.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ interface AgentSessionOptions {
9191
backgroundProcessManager: BackgroundProcessManager;
9292
/** Called after compaction completes to trigger metadata refresh */
9393
onCompactionComplete?: () => void;
94+
/** Called when post-compaction context state may have changed (plan/file edits) */
95+
onPostCompactionStateChange?: () => void;
9496
}
9597

9698
export class AgentSession {
@@ -102,6 +104,7 @@ export class AgentSession {
102104
private readonly initStateManager: InitStateManager;
103105
private readonly backgroundProcessManager: BackgroundProcessManager;
104106
private readonly onCompactionComplete?: () => void;
107+
private readonly onPostCompactionStateChange?: () => void;
105108
private readonly emitter = new EventEmitter();
106109
private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> =
107110
[];
@@ -128,6 +131,11 @@ export class AgentSession {
128131
* Used to enable the cooldown-based attachment injection.
129132
*/
130133
private compactionOccurred = false;
134+
/**
135+
* Cache the last-known experiment state so we don't spam metadata refresh
136+
* when post-compaction context is disabled.
137+
*/
138+
private postCompactionContextEnabled = false;
131139

132140
constructor(options: AgentSessionOptions) {
133141
assert(options, "AgentSession requires options");
@@ -140,6 +148,7 @@ export class AgentSession {
140148
initStateManager,
141149
backgroundProcessManager,
142150
onCompactionComplete,
151+
onPostCompactionStateChange,
143152
} = options;
144153

145154
assert(typeof workspaceId === "string", "workspaceId must be a string");
@@ -154,6 +163,7 @@ export class AgentSession {
154163
this.initStateManager = initStateManager;
155164
this.backgroundProcessManager = backgroundProcessManager;
156165
this.onCompactionComplete = onCompactionComplete;
166+
this.onPostCompactionStateChange = onPostCompactionStateChange;
157167

158168
this.compactionHandler = new CompactionHandler({
159169
workspaceId: this.workspaceId,
@@ -546,6 +556,10 @@ export class AgentSession {
546556
}
547557

548558
const historyResult = await this.historyService.getHistory(this.workspaceId);
559+
// Cache whether post-compaction context is enabled for this session.
560+
// Used to decide whether tool-call-end should trigger metadata refresh.
561+
this.postCompactionContextEnabled = Boolean(options?.experiments?.postCompactionContext);
562+
549563
if (!historyResult.success) {
550564
return Err(createUnknownSendMessageError(historyResult.error));
551565
}
@@ -611,6 +625,18 @@ export class AgentSession {
611625
forward("tool-call-delta", (payload) => this.emitChatEvent(payload));
612626
forward("tool-call-end", (payload) => {
613627
this.emitChatEvent(payload);
628+
629+
// If post-compaction context is enabled, certain tools can change what should
630+
// be displayed/injected (plan writes, tracked file diffs). Trigger a metadata
631+
// refresh so the right sidebar updates without requiring an experiment toggle.
632+
if (
633+
this.postCompactionContextEnabled &&
634+
payload.type === "tool-call-end" &&
635+
(payload.toolName === "propose_plan" || payload.toolName.startsWith("file_edit_"))
636+
) {
637+
this.onPostCompactionStateChange?.();
638+
}
639+
614640
// Tool call completed: auto-send queued messages
615641
this.sendQueuedMessages();
616642
});

src/node/services/workspaceService.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { PartialService } from "./partialService";
66
import type { AIService } from "./aiService";
77
import type { InitStateManager } from "./initStateManager";
88
import type { ExtensionMetadataService } from "./ExtensionMetadataService";
9+
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
910
import type { BackgroundProcessManager } from "./backgroundProcessManager";
1011

1112
// Helper to access private renamingWorkspaces set
@@ -14,6 +15,8 @@ function addToRenamingWorkspaces(service: WorkspaceService, workspaceId: string)
1415
(service as any).renamingWorkspaces.add(workspaceId);
1516
}
1617

18+
// NOTE: This test file uses bun:test mocks (not Jest).
19+
1720
describe("WorkspaceService rename lock", () => {
1821
let workspaceService: WorkspaceService;
1922
let mockAIService: AIService;
@@ -116,3 +119,116 @@ describe("WorkspaceService rename lock", () => {
116119
}
117120
});
118121
});
122+
123+
describe("WorkspaceService post-compaction metadata refresh", () => {
124+
let workspaceService: WorkspaceService;
125+
126+
beforeEach(() => {
127+
const aiService: AIService = {
128+
isStreaming: mock(() => false),
129+
getWorkspaceMetadata: mock(() =>
130+
Promise.resolve({ success: false as const, error: "not found" })
131+
),
132+
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
133+
return this;
134+
},
135+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
136+
return this;
137+
},
138+
} as unknown as AIService;
139+
140+
const mockHistoryService: Partial<HistoryService> = {
141+
getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })),
142+
appendToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })),
143+
};
144+
145+
const mockConfig: Partial<Config> = {
146+
srcDir: "/tmp/test",
147+
getSessionDir: mock(() => "/tmp/test/sessions"),
148+
generateStableId: mock(() => "test-id"),
149+
findWorkspace: mock(() => null),
150+
};
151+
152+
const mockPartialService: Partial<PartialService> = {
153+
commitToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })),
154+
};
155+
156+
const mockInitStateManager: Partial<InitStateManager> = {};
157+
const mockExtensionMetadataService: Partial<ExtensionMetadataService> = {};
158+
const mockBackgroundProcessManager: Partial<BackgroundProcessManager> = {
159+
cleanup: mock(() => Promise.resolve()),
160+
};
161+
162+
workspaceService = new WorkspaceService(
163+
mockConfig as Config,
164+
mockHistoryService as HistoryService,
165+
mockPartialService as PartialService,
166+
aiService,
167+
mockInitStateManager as InitStateManager,
168+
mockExtensionMetadataService as ExtensionMetadataService,
169+
mockBackgroundProcessManager as BackgroundProcessManager
170+
);
171+
});
172+
173+
test("debounces multiple refresh requests into a single metadata emit", async () => {
174+
const workspaceId = "ws-post-compaction";
175+
176+
const emitMetadata = mock(() => undefined);
177+
178+
interface WorkspaceServiceTestAccess {
179+
sessions: Map<string, { emitMetadata: (metadata: unknown) => void }>;
180+
getInfo: (workspaceId: string) => Promise<FrontendWorkspaceMetadata | null>;
181+
getPostCompactionState: (workspaceId: string) => Promise<{
182+
planPath: string | null;
183+
trackedFilePaths: string[];
184+
excludedItems: string[];
185+
}>;
186+
schedulePostCompactionMetadataRefresh: (workspaceId: string) => void;
187+
}
188+
189+
const svc = workspaceService as unknown as WorkspaceServiceTestAccess;
190+
svc.sessions.set(workspaceId, { emitMetadata });
191+
192+
const fakeMetadata: FrontendWorkspaceMetadata = {
193+
id: workspaceId,
194+
name: "ws",
195+
projectName: "proj",
196+
projectPath: "/tmp/proj",
197+
namedWorkspacePath: "/tmp/proj/ws",
198+
runtimeConfig: { type: "local", srcBaseDir: "/tmp" },
199+
};
200+
201+
const getInfoMock: WorkspaceServiceTestAccess["getInfo"] = mock(() =>
202+
Promise.resolve(fakeMetadata)
203+
);
204+
205+
const postCompactionState = {
206+
planPath: "~/.mux/plans/cmux/plan.md",
207+
trackedFilePaths: ["/tmp/proj/file.ts"],
208+
excludedItems: [],
209+
};
210+
211+
const getPostCompactionStateMock: WorkspaceServiceTestAccess["getPostCompactionState"] = mock(
212+
() => Promise.resolve(postCompactionState)
213+
);
214+
215+
svc.getInfo = getInfoMock;
216+
svc.getPostCompactionState = getPostCompactionStateMock;
217+
218+
svc.schedulePostCompactionMetadataRefresh(workspaceId);
219+
svc.schedulePostCompactionMetadataRefresh(workspaceId);
220+
svc.schedulePostCompactionMetadataRefresh(workspaceId);
221+
222+
// Debounce is short, but use a safe buffer.
223+
await new Promise((resolve) => setTimeout(resolve, 150));
224+
225+
expect(getInfoMock).toHaveBeenCalledTimes(1);
226+
expect(getPostCompactionStateMock).toHaveBeenCalledTimes(1);
227+
expect(emitMetadata).toHaveBeenCalledTimes(1);
228+
229+
const enriched = (emitMetadata as ReturnType<typeof mock>).mock.calls[0][0] as {
230+
postCompaction?: { planPath: string | null };
231+
};
232+
expect(enriched.postCompaction?.planPath).toBe(postCompactionState.planPath);
233+
});
234+
});

0 commit comments

Comments
 (0)