Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/acp-context-window-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

ACP agents now push live context window usage via the `usage_update` session notification so compatible clients (Zed, JetBrains) can render a real-time token consumption indicator.
31 changes: 31 additions & 0 deletions packages/acp-adapter/src/events-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,34 @@ export function configOptionUpdateNotification(
},
};
}

/**
* Build an ACP `usage_update` session notification from contextual
* token usage data (e.g. an `agent.status.updated` SDK event).
*
* Wire shape ({@link https://agentclientprotocol.com/ protocol} UNSTABLE /
* `@experimental`):
* - `size` = total context window capacity (maxContextTokens)
* - `used` = tokens currently in context (contextTokens)
* - `cost` = cumulative cost snapshot (unset for now — can be wired
* later from the session cost tracker).
*
* Returns `null` when either value is missing so callers can silently
* skip rather than pushing a degenerate update.
*/
export function contextUsageToUsageUpdate(
sessionId: string,
contextTokens: number | undefined,
maxContextTokens: number | undefined,
): SessionNotification | null {
if (contextTokens === undefined || maxContextTokens === undefined) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid sending zero-sized usage updates

When a model/provider does not advertise a context-window capability, the SDK's v1 getStatus() normalizes that to maxContextTokens: 0, but this guard treats 0 as a valid ACP size. ACP clients compute the display percentage from used / size and the usage RFD says agents should skip usage_update when there is no meaningful context window size; after /compact or any status event carrying 0, this can push size: 0 to Zed/JetBrains and produce a bogus/undefined context indicator. Please skip maxContextTokens <= 0 as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed — maxContextTokens <= 0 now returns null in that same commit. Also addressed the other review feedback:

  • Added pushContextUsage() so session load/resume/new pushes context usage immediately instead of waiting for the first prompt.
  • Wrapped the post-compaction status refresh in its own try-catch so a getStatus() failure doesn't turn a successful compaction into a failure.

if (maxContextTokens <= 0) return null;
return {
sessionId,
update: {
sessionUpdate: 'usage_update',
size: maxContextTokens,
used: contextTokens,
},
};
}
1 change: 1 addition & 0 deletions packages/acp-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
export {
acpToolCallId,
assistantDeltaToSessionUpdate,
contextUsageToUsageUpdate,
inferToolKind,
stringifyArgs,
thinkingDeltaToSessionUpdate,
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export class AcpServer implements Agent {
DEFAULT_MODE_ID,
);
this.scheduleAvailableCommandsUpdate(session.id);
void acpSession.pushContextUsage();
return {
sessionId: session.id,
configOptions,
Expand Down Expand Up @@ -464,6 +465,7 @@ export class AcpServer implements Agent {
// `resumeSession`, which intentionally omits this step.
await acpSession.replayHistory();
this.scheduleAvailableCommandsUpdate(session.id);
void acpSession.pushContextUsage();
return { configOptions };
}

Expand Down Expand Up @@ -494,6 +496,7 @@ export class AcpServer implements Agent {
mode: 'resume',
});
this.scheduleAvailableCommandsUpdate(session.id);
void this.sessions.get(session.id)?.pushContextUsage();
return { configOptions };
}

Expand Down
81 changes: 81 additions & 0 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
acpToolCallId,
assistantDeltaToSessionUpdate,
configOptionUpdateNotification,
contextUsageToUsageUpdate,
planFromDisplayBlock,
stringifyArgs,
thinkingDeltaToSessionUpdate,
Expand Down Expand Up @@ -550,6 +551,41 @@ export class AcpSession {
}
}

/**
* Push a one-shot `usage_update` snapshot derived from the SDK
* `getStatus()` call. Best-effort — failures are logged and never
* surface to the client as errors.
*
* Called on session creation and after history replay so the client
* sees context usage immediately instead of waiting for the next
* `agent.status.updated` event (which only fires inside an active
* turn, via `runTurnBody`).
*/
async pushContextUsage(): Promise<void> {
if (typeof this.session.getStatus !== 'function') return;
try {
const status = await this.session.getStatus();
const update = contextUsageToUsageUpdate(
this.id,
status.contextTokens,
status.maxContextTokens,
);
if (update !== null) {
this.conn.sessionUpdate(update).catch((err) => {
log.warn('acp: failed to push usage_update', {
sessionId: this.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
} catch (err) {
log.warn('acp: failed to push usage_update', {
sessionId: this.id,
error: err instanceof Error ? err.message : String(err),
});
}
}

/**
* Replay the underlying SDK session's persisted history as a stream
* of ACP `session/update` notifications.
Expand Down Expand Up @@ -964,6 +1000,34 @@ export class AcpSession {
const outcome = await completion;
if (outcome.kind === 'completed') {
await this.emitLocalCommandMessage(formatCompactionCompleted(outcome.result));
// After compaction, the core updates context token counts via
// emitStatusUpdated(), but the agent.status.updated handler is
// only registered in runTurnBody(). Push a one-shot usage_update
// so the client reflects the compacted context size immediately
// rather than waiting for the next normal prompt.
// Best-effort only — a failed status refresh must not turn a
// successful compaction into a failure.
try {
const status = await this.session.getStatus();
const update = contextUsageToUsageUpdate(
this.id,
status.contextTokens,
status.maxContextTokens,
);
if (update !== null) {
this.conn.sessionUpdate(update).catch((err) => {
log.warn('acp: failed to push usage_update after compaction', {
sessionId: this.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
} catch (err) {
log.warn('acp: failed to refresh usage after compaction', {
sessionId: this.id,
error: err instanceof Error ? err.message : String(err),
});
}
} else {
await this.emitLocalCommandMessage('Compaction cancelled.');
}
Expand Down Expand Up @@ -1097,6 +1161,23 @@ export class AcpSession {
});
return;
}
if (event.type === 'agent.status.updated') {
if (!isFromMainAgent(event)) return;
const update = contextUsageToUsageUpdate(
sessionId,
event.contextTokens,
event.maxContextTokens,
);
Comment on lines +1164 to +1170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward usage updates after ACP compaction

When the user runs ACP /compact, prompt() routes to runBuiltInCommand/runCompactCommand instead of runTurnBody, so this newly added agent.status.updated handler is not installed. The core updates the context token count during compaction via emitStatusUpdated(), but runCompactCommand has its own event listener that ignores that event, leaving clients such as Zed showing the pre-compaction usage until a later normal prompt happens. Please share this forwarding with the command/compaction event path or emit a usage_update after compaction completes.

Useful? React with 👍 / 👎.

@iluv7 iluv7 Jul 31, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — runCompactCommand now pushes a one-shot usage_update via getStatus() after compaction completes. Best-effort with its own try-catch so a status refresh failure never turns a successful compaction into an error.

if (update !== null) {
conn.sessionUpdate(update).catch((err) => {
log.warn('acp: failed to push usage_update', {
sessionId,
error: err instanceof Error ? err.message : String(err),
});
});
}
return;
}
if (event.type === 'tool.call.started') {
if (!isFromMainAgent(event)) return;
// Seed the accumulator with the **stringified initial args**.
Expand Down
160 changes: 160 additions & 0 deletions packages/acp-adapter/test/plan-and-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AcpServer } from '../src/server';
import { AUTHED_STATUS } from './_helpers/harness-stubs';
import {
availableCommandsUpdateNotification,
contextUsageToUsageUpdate,
planFromDisplayBlock,
todoListToSessionUpdate,
} from '../src/events-map';
Expand Down Expand Up @@ -359,3 +360,162 @@ describe('Phase 9.3 e2e · todo_list display block becomes a plan session_update
expect(planUpdates).toHaveLength(0);
});
});

describe('contextUsageToUsageUpdate', () => {
it('builds a usage_update notification with size and used', () => {
const note = contextUsageToUsageUpdate('sess-u', 12345, 200000);
expect(note).not.toBeNull();
expect(note?.sessionId).toBe('sess-u');
expect(note?.update).toEqual({
sessionUpdate: 'usage_update',
size: 200000,
used: 12345,
});
});

it('returns null when contextTokens is undefined', () => {
expect(contextUsageToUsageUpdate('sess-u', undefined, 200000)).toBeNull();
});

it('returns null when maxContextTokens is undefined', () => {
expect(contextUsageToUsageUpdate('sess-u', 12345, undefined)).toBeNull();
});

it('returns null when both values are undefined', () => {
expect(contextUsageToUsageUpdate('sess-u', undefined, undefined)).toBeNull();
});

it('returns null when maxContextTokens is zero or negative', () => {
expect(contextUsageToUsageUpdate('sess-u', 100, 0)).toBeNull();
expect(contextUsageToUsageUpdate('sess-u', 100, -1)).toBeNull();
});
});

describe('e2e · agent.status.updated emits usage_update', () => {
it('forwards agent.status.updated events as usage_update session notifications', async () => {
const sessionId = 'sess-usage';
const turnId = 1;
const session = makeScriptedSession(sessionId, [
{
type: 'agent.status.updated',
sessionId,
agentId: 'main',
contextTokens: 5000,
maxContextTokens: 100000,
contextUsage: 0.05,
} as Event,
{
type: 'turn.ended',
sessionId,
agentId: 'main',
turnId,
reason: 'completed',
} as Event,
]);
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => session,
} as unknown as KimiHarness;

const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const collecting = new CollectingClient();
const client = new ClientSideConnection(() => collecting, clientStream);

await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
await client.prompt({ sessionId, prompt: [textBlock('hello')] });
await flushNdjson();

const usageUpdates = collecting.updates.filter(
(n) =>
(n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update',
);
expect(usageUpdates).toHaveLength(1);
expect(usageUpdates[0]?.sessionId).toBe(sessionId);
expect(usageUpdates[0]?.update).toEqual({
sessionUpdate: 'usage_update',
size: 100000,
used: 5000,
});
});

it('does not emit usage_update for sub-agent status events', async () => {
const sessionId = 'sess-usage-sub';
const turnId = 1;
const session = makeScriptedSession(sessionId, [
{
type: 'agent.status.updated',
sessionId,
agentId: 'sub-1',
contextTokens: 100,
maxContextTokens: 50000,
} as Event,
{
type: 'turn.ended',
sessionId,
agentId: 'main',
turnId,
reason: 'completed',
} as Event,
]);
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => session,
} as unknown as KimiHarness;

const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const collecting = new CollectingClient();
const client = new ClientSideConnection(() => collecting, clientStream);

await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
await client.prompt({ sessionId, prompt: [textBlock('hello')] });
await flushNdjson();

const usageUpdates = collecting.updates.filter(
(n) =>
(n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update',
);
expect(usageUpdates).toHaveLength(0);
});

it('skips status events with missing contextTokens', async () => {
const sessionId = 'sess-usage-skip';
const turnId = 1;
const session = makeScriptedSession(sessionId, [
{
type: 'agent.status.updated',
sessionId,
agentId: 'main',
maxContextTokens: 100000,
// contextTokens deliberately omitted
} as Event,
{
type: 'turn.ended',
sessionId,
agentId: 'main',
turnId,
reason: 'completed',
} as Event,
]);
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => session,
} as unknown as KimiHarness;

const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const collecting = new CollectingClient();
const client = new ClientSideConnection(() => collecting, clientStream);

await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
await client.prompt({ sessionId, prompt: [textBlock('hello')] });
await flushNdjson();

const usageUpdates = collecting.updates.filter(
(n) =>
(n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update',
);
expect(usageUpdates).toHaveLength(0);
});
});
4 changes: 3 additions & 1 deletion packages/acp-adapter/test/session-new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ describe('AcpServer session/new', () => {

const response = await client.newSession({ cwd: '/tmp/work', mcpServers: [] });

expect(fakeSession.getStatus).toHaveBeenCalledOnce();
// Called twice: once by resolveCurrentThinkingEffort, once by
// pushContextUsage (best-effort usage_update after session creation).
expect(fakeSession.getStatus).toHaveBeenCalledTimes(2);
const thinking = response.configOptions?.find((option) => option.id === 'thinking');
if (thinking?.type !== 'select') throw new Error('thinking option must be a select');
expect(thinking.currentValue).toBe(expected);
Expand Down
Loading