diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 5a58970e..9fa2ebe3 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -259,6 +259,10 @@ type Session = { * DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and * invalidated when the user switches the session's model. */ contextWindowSize: number; + /** True while contextWindowSize is still the generic fallback placeholder + * rather than a model-name heuristic or the authoritative window reported by + * modelUsage. Mid-stream usage_update events are suppressed while this holds. */ + contextWindowSizeIsDefault: boolean; /** Accumulated task list for the session, keyed by task ID. Task IDs are * per-session, so this state must not be shared across sessions. */ taskState: TaskState; @@ -1538,6 +1542,7 @@ export class ClaudeAcpAgent { // leave the next prompt's mid-stream updates reporting 200k. if (matchingModelUsage) { session.contextWindowSize = matchingModelUsage.contextWindow; + session.contextWindowSizeIsDefault = false; } // Send usage_update notification @@ -1713,10 +1718,11 @@ export class ClaudeAcpAgent { // Model switches invalidate the cached window via // `syncSessionConfigState`, which resets us back to the // default so this branch runs again for the new model. - if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) { + if (session.contextWindowSizeIsDefault) { const inferred = inferContextWindowFromModel(model); if (inferred !== null) { session.contextWindowSize = inferred; + session.contextWindowSizeIsDefault = false; } } } @@ -1740,14 +1746,16 @@ export class ClaudeAcpAgent { const nextUsage = totalTokens(lastAssistantUsage); if (nextUsage !== lastAssistantTotalUsage) { lastAssistantTotalUsage = nextUsage; - await this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "usage_update", - used: nextUsage, - size: session.contextWindowSize, - }, - }); + if (!session.contextWindowSizeIsDefault) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "usage_update", + used: nextUsage, + size: session.contextWindowSize, + }, + }); + } } } for (const notification of streamEventToAcpNotifications( @@ -2657,7 +2665,9 @@ export class ClaudeAcpAgent { // to the new model's heuristic so mid-stream updates between now and // the next `result` reflect the user's selection instead of the old // model's window. - session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW; + const inferredContextWindowSize = inferContextWindowFromModel(value); + session.contextWindowSize = inferredContextWindowSize ?? DEFAULT_CONTEXT_WINDOW; + session.contextWindowSizeIsDefault = inferredContextWindowSize === null; } session.models = { ...session.models, currentModelId: value }; @@ -3197,6 +3207,7 @@ export class ClaudeAcpAgent { effortLevel: initialEffort.currentValue as Settings["effortLevel"], }); } + const inferredContextWindowSize = inferContextWindowFromModel(models.currentModelId); this.sessions[sessionId] = { query: q, input: input, @@ -3218,8 +3229,8 @@ export class ClaudeAcpAgent { currentAgent, abortController, emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, - contextWindowSize: - inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW, + contextWindowSize: inferredContextWindowSize ?? DEFAULT_CONTEXT_WINDOW, + contextWindowSizeIsDefault: inferredContextWindowSize === null, taskState, toolUseCache: {}, messageIdToUuid: new Map(), diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 14e0d344..752bcc81 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -113,6 +113,7 @@ function mockSessionState(overrides: Record = {}) { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), @@ -2319,6 +2320,7 @@ describe("session/close", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), @@ -2404,6 +2406,7 @@ describe("session/delete", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), @@ -2506,6 +2509,7 @@ describe("getOrCreateSession param change detection", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), @@ -2809,7 +2813,7 @@ describe("usage_update computation", () => { } }); - it("stream_event message_start emits usage_update before result", async () => { + it("stream_event message_start suppresses default-window usage_update before result", async () => { const { agent, updates } = createMockAgentWithCapture(); injectSession(agent, [ createStreamEvent("message_start", { @@ -2841,15 +2845,10 @@ describe("usage_update computation", () => { await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); - expect(usageUpdates).toHaveLength(2); + expect(usageUpdates).toHaveLength(1); expect(usageUpdates[0].update.used).toBe(1800); - // First prompt of a session has no prior result to learn the window from, - // so the mid-stream update falls back to the default context window. - expect(usageUpdates[0].update.size).toBe(200000); - expect(usageUpdates[0].update.cost).toBeUndefined(); - expect(usageUpdates[1].update.used).toBe(1800); - expect(usageUpdates[1].update.size).toBe(1000000); - expect(usageUpdates[1].update.cost).toBeDefined(); + expect(usageUpdates[0].update.size).toBe(1000000); + expect(usageUpdates[0].update.cost).toBeDefined(); }); it("stream_event message_delta patches previous snapshot", async () => { @@ -2887,13 +2886,10 @@ describe("usage_update computation", () => { await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); - expect(usageUpdates).toHaveLength(3); - expect(usageUpdates[0].update.used).toBe(1300); - expect(usageUpdates[0].update.cost).toBeUndefined(); - expect(usageUpdates[1].update.used).toBe(1800); - expect(usageUpdates[1].update.cost).toBeUndefined(); - expect(usageUpdates[2].update.used).toBe(1800); - expect(usageUpdates[2].update.cost).toBeDefined(); + expect(usageUpdates).toHaveLength(1); + expect(usageUpdates[0].update.used).toBe(1800); + expect(usageUpdates[0].update.size).toBe(1000000); + expect(usageUpdates[0].update.cost).toBeDefined(); }); it("mid-stream size is inferred from a 1M model name before the first result", async () => { @@ -2973,11 +2969,9 @@ describe("usage_update computation", () => { await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); - expect(usageUpdates).toHaveLength(2); + expect(usageUpdates).toHaveLength(1); expect(usageUpdates[0].update.used).toBe(1800); - expect(usageUpdates[0].update.cost).toBeUndefined(); - expect(usageUpdates[1].update.used).toBe(1800); - expect(usageUpdates[1].update.cost).toBeDefined(); + expect(usageUpdates[0].update.cost).toBeDefined(); }); it("mid-stream size uses the session's learned context window", async () => { @@ -3013,6 +3007,7 @@ describe("usage_update computation", () => { ]); // Simulate a prior prompt having learned the 1M window for this model. agent.sessions["test-session"].contextWindowSize = 1000000; + agent.sessions["test-session"].contextWindowSizeIsDefault = false; await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); @@ -3063,6 +3058,7 @@ describe("usage_update computation", () => { "claude-opus-4-6-1m", ); expect(session.contextWindowSize).toBe(1000000); + expect(session.contextWindowSizeIsDefault).toBe(false); await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); @@ -3097,6 +3093,7 @@ describe("usage_update computation", () => { ]); const session = agent.sessions["test-session"]; session.contextWindowSize = 1000000; + session.contextWindowSizeIsDefault = false; await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); @@ -3143,6 +3140,7 @@ describe("usage_update computation", () => { ]); const session = agent.sessions["test-session"]; session.contextWindowSize = 1000000; + session.contextWindowSizeIsDefault = false; session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" }; // User flips the selector to a 200k model. @@ -3152,13 +3150,14 @@ describe("usage_update computation", () => { "model", "claude-sonnet-4-6", ); + expect(session.contextWindowSizeIsDefault).toBe(true); await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); - expect(usageUpdates).toHaveLength(2); + expect(usageUpdates).toHaveLength(1); expect(usageUpdates[0].update.size).toBe(200000); - expect(usageUpdates[1].update.size).toBe(200000); + expect(usageUpdates[0].update.cost).toBeDefined(); }); it("non-usage stream events do not re-emit usage_update", async () => { @@ -3227,12 +3226,12 @@ describe("usage_update computation", () => { await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); const usageUpdates = updates.filter((u: any) => u.update?.sessionUpdate === "usage_update"); - // Exactly three: message_start (1000), message_delta (1200), result (1200 + cost). - expect(usageUpdates).toHaveLength(3); - expect(usageUpdates[0].update.used).toBe(1000); - expect(usageUpdates[1].update.used).toBe(1200); - expect(usageUpdates[2].update.used).toBe(1200); - expect(usageUpdates[2].update.cost).toBeDefined(); + // Stream events update the accumulator, but the default placeholder window + // is not emitted; only the final result carries the authoritative size. + expect(usageUpdates).toHaveLength(1); + expect(usageUpdates[0].update.used).toBe(1200); + expect(usageUpdates[0].update.size).toBe(1000000); + expect(usageUpdates[0].update.cost).toBeDefined(); }); it("subagent stream_event does not emit usage_update", async () => { @@ -3565,6 +3564,7 @@ describe("usage_update computation", () => { // A 1M window learned earlier (e.g. from modelUsage) must survive compaction // — getContextUsage's window field under-reports it, so we don't use it. session.contextWindowSize = 1000000; + session.contextWindowSizeIsDefault = false; (session.query as any).getContextUsage = vi .fn() .mockResolvedValue({ totalTokens: 12345, rawMaxTokens: 200000 }); @@ -4572,6 +4572,7 @@ describe("post-error recovery", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), @@ -5142,6 +5143,7 @@ describe("session/cancel wedge recovery (issue #680)", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), @@ -5595,6 +5597,7 @@ describe("agent selection config option", () => { abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, + contextWindowSizeIsDefault: true, taskState: new Map(), toolUseCache: {}, messageIdToUuid: new Map(), diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 1e39bd60..cac52545 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -124,6 +124,7 @@ describe("session config options", () => { ), configOptions: structuredClone(MOCK_CONFIG_OPTIONS), contextWindowSize: 200000, + contextWindowSizeIsDefault: true, }; }