Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
}
Expand All @@ -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(
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
59 changes: 31 additions & 28 deletions src/tests/acp-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ function mockSessionState(overrides: Record<string, any> = {}) {
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
contextWindowSizeIsDefault: true,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
Expand Down Expand Up @@ -2319,6 +2320,7 @@ describe("session/close", () => {
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
contextWindowSizeIsDefault: true,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
Expand Down Expand Up @@ -2404,6 +2406,7 @@ describe("session/delete", () => {
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
contextWindowSizeIsDefault: true,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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" }] });

Expand Down Expand Up @@ -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" }] });

Expand Down Expand Up @@ -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" }] });

Expand Down Expand Up @@ -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.
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -4572,6 +4572,7 @@ describe("post-error recovery", () => {
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
contextWindowSizeIsDefault: true,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/tests/session-config-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe("session config options", () => {
),
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
contextWindowSize: 200000,
contextWindowSizeIsDefault: true,
};
}

Expand Down