Compare commits

...

3 Commits

Author SHA1 Message Date
mark.tkachenko
0dd9139965 fix: run built-in slash commands when the client attaches context 2026-10-30 13:50:59 +01:00
Ben Brandt
2fd46c866d fix: report the fresh context's usage when ExitPlanMode clears context (#1484)
Choosing one of `ExitPlanMode`'s "clear context" options restarts
Claude's conversation with only the plan. The ACP session ID and the
turn stay the same, and the adapter resets its context count to 0, but
it didn't tell the client. Clients kept showing the old context's usage
until the fresh context first reported its own, so nothing on the client
side showed that the context had been cleared.

`publishSessionState` now sends a `usage_update` for the empty fresh
context (`used: 0`, the fresh session's context window as `size`) right
after the `current_mode_update` and `config_option_update` it already
sends. `used: 0` slightly understates the fresh context, which already
holds the system prompt and the plan, until Claude's next usage report.

Testing:
- `npm run build`, `npm run test:run`, and `npm run check` pass.
- `clear-context-coordinator.test.ts` checks the update's order and
values.
2026-10-29 22:09:59 +01:00
acp-release-bot[bot]
0f78dbbdd0 chore(main): release 0.99.1 (#1472)
🤖 I have created a release *beep* *boop*
---


##
[0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1)
(2026-10-29)


### Bug Fixes

* add approval id to plan
([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469))
([4b4b2c4](4b4b2c4de7))
* allow forking sessions into a different directory
([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483))
([8bd0bc9](8bd0bc9b03))
* detect "Run /login" login-expired messages as authRequired
([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481))
([fccaebc](fccaebcf87))
* mark plugin, bundled and nested Claude skills as skills for AIR
([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473))
([52c241e](52c241e715))
* title subagent sessions by their task
([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479))
([7dcb629](7dcb62923d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-29 20:34:22 +01:00
10 changed files with 128 additions and 15 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.99.0"
".": "0.99.1"
}

View File

@@ -1,5 +1,16 @@
# Changelog
## [0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1) (2026-10-29)
### Bug Fixes
* add approval id to plan ([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469)) ([4b4b2c4](https://github.com/agentclientprotocol/claude-agent-acp/commit/4b4b2c4de7c492070f6260cacea5680b2e045a5a))
* allow forking sessions into a different directory ([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483)) ([8bd0bc9](https://github.com/agentclientprotocol/claude-agent-acp/commit/8bd0bc9b03fb4a30e0b1755128baad12a214a58d))
* detect "Run /login" login-expired messages as authRequired ([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481)) ([fccaebc](https://github.com/agentclientprotocol/claude-agent-acp/commit/fccaebcf87e8f37144ec1a64920c0c53997edabe))
* mark plugin, bundled and nested Claude skills as skills for AIR ([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473)) ([52c241e](https://github.com/agentclientprotocol/claude-agent-acp/commit/52c241e71512ec19c4dd79c95c51ce67f04d7aac))
* title subagent sessions by their task ([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479)) ([7dcb629](https://github.com/agentclientprotocol/claude-agent-acp/commit/7dcb62923d38ac24e386b0626fe123334341c445))
## [0.99.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.98.0...v0.99.0) (2026-10-28)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.99.0",
"version": "0.99.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.99.0",
"version": "0.99.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.1.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.99.0",
"version": "0.99.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -2190,6 +2190,31 @@ const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start
// message and without invoking the model.
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
// Built-in commands that never use attached context. Clients like Air attach
// editor context (resources, extra text chunks) to every prompt, but Claude
// Code runs a slash command only when the user message is a single text block.
const CONTEXT_FREE_COMMANDS = new Set([
...LOCAL_ONLY_COMMANDS,
"/compact",
"/usage",
"/cost",
"/stats",
"/status",
"/model",
"/mcp",
"/remote-control",
]);
/** Drops the chunks after a context-free built-in command (see
* `CONTEXT_FREE_COMMANDS`), keeping the command and its arguments. Returns
* `prompt` itself when it isn't such a command. */
export function withoutCommandContext(prompt: PromptRequest["prompt"]): PromptRequest["prompt"] {
const first = prompt[0];
if (prompt.length < 2 || first?.type !== "text") return prompt;
const command = first.text.trimStart().split(/\s/, 1)[0];
return CONTEXT_FREE_COMMANDS.has(command) ? [first] : prompt;
}
// Commands whose marker-only transcript records are ACP/client-local UI noise,
// not model-visible user prompts. Marker-only custom slash skills are not in
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
@@ -3901,6 +3926,9 @@ export class ClaudeAcpAgent {
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
}
const prompt = withoutCommandContext(params.prompt);
if (prompt !== params.prompt) params = { ...params, prompt };
const remoteControlCommand = parseRemoteControlCommand(params.prompt);
if (
remoteControlCommand &&

View File

@@ -45,6 +45,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
turnQueue?: Turn[];
pendingExitPlanContextReset?: ClearContextReset;
contextUsedTokens?: number;
contextWindowSize: number;
sentUserUuids?: Set<string>;
input: { push(message: SDKUserMessage): unknown };
};
@@ -61,10 +62,13 @@ export type ClearContextCoordinatorHost<
): Promise<Session>;
applyFastMode(session: Session, enabled: boolean): Promise<void>;
applyUltracode(session: Session, enabled: boolean): Promise<void>;
/** Publishes the fresh context's mode, config options, and usage: the
* context starts empty in a window of `contextWindowSize` tokens. */
publishSessionState(
sessionId: string,
mode: PermissionMode,
configOptions: SessionConfigOption[],
contextWindowSize: number,
): Promise<void>;
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
ensureConsumer(session: Session, sessionId: string): void;
@@ -165,7 +169,12 @@ export async function continuePlanInFreshContext<
freshSession.contextUsedTokens = 0;
try {
await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
await host.publishSessionState(
sessionId,
reset.mode,
freshSession.configOptions,
freshSession.contextWindowSize,
);
} catch (error) {
host.logError("Failed to publish clear-context session state:", error);
}

View File

@@ -213,7 +213,7 @@ export class ExitPlanCoordinator<
try {
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
...this.host,
publishSessionState: async (id, mode, configOptions) => {
publishSessionState: async (id, mode, configOptions, contextWindowSize) => {
await this.host.sessionUpdate({
sessionId: id,
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
@@ -222,6 +222,12 @@ export class ExitPlanCoordinator<
sessionId: id,
update: { sessionUpdate: "config_option_update", configOptions },
});
// Otherwise the client shows the old context's usage until the fresh
// context first reports its own.
await this.host.sessionUpdate({
sessionId: id,
update: { sessionUpdate: "usage_update", used: 0, size: contextWindowSize },
});
// The approved plan becomes the session's plan once the fresh
// context that carries it out exists. Its ExitPlanMode ended with
// an error result, so toAcpNotifications did not publish it.

View File

@@ -36,6 +36,7 @@ import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import {
toAcpNotifications,
promptToClaude,
withoutCommandContext,
isLocalCommandMetadata,
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
@@ -4716,6 +4717,50 @@ describe("escape markdown", () => {
});
});
describe("withoutCommandContext", () => {
const link = { type: "resource_link", uri: "file:///a.ts", name: "a.ts" } as const;
const resource = {
type: "resource",
resource: { uri: "file:///a.ts", text: "const a = 1;" },
} as const;
/** `/compact` with attached editor context keeps only the text chunk. */
it("drops context after /compact", () => {
const text = { type: "text", text: "/compact" } as const;
expect(withoutCommandContext([text, link, resource])).toEqual([text]);
});
/** Arguments in the command chunk survive; the attached resource goes. */
it("keeps command arguments and drops context", () => {
const text = { type: "text", text: "/compact focus on tests" } as const;
expect(withoutCommandContext([text, resource])).toEqual([text]);
});
/** An extra text chunk after `/usage` is dropped too. */
it("drops trailing text chunks after /usage", () => {
const text = { type: "text", text: "/usage" } as const;
expect(withoutCommandContext([text, { type: "text", text: "editor context" }])).toEqual([text]);
});
/** Skills and custom commands use attached context, so the prompt is untouched. */
it("leaves custom commands unchanged", () => {
const prompt = [{ type: "text", text: "/code-review" } as const, resource];
expect(withoutCommandContext(prompt)).toBe(prompt);
});
/** A plain text prompt with a resource is untouched. */
it("leaves plain prompts unchanged", () => {
const prompt = [{ type: "text", text: "explain this" } as const, resource];
expect(withoutCommandContext(prompt)).toBe(prompt);
});
/** `/mcp:server:cmd` is an MCP prompt, not the `/mcp` built-in. */
it("leaves MCP prompt commands unchanged", () => {
const prompt = [{ type: "text", text: "/mcp:server:cmd" } as const, resource];
expect(withoutCommandContext(prompt)).toBe(prompt);
});
});
describe("prompt conversion", () => {
it("should not change built-in slash commands", () => {
const message = promptToClaude({

View File

@@ -28,6 +28,7 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
},
},
models: { currentModelId: "default" },
contextWindowSize: 200_000,
configOptions: [],
currentAgent: "default",
fastModeEnabled: false,
@@ -464,6 +465,7 @@ describe("ExitPlanCoordinator", () => {
expect(order).toEqual([
"current_mode_update",
"config_option_update",
"usage_update",
...(supportsPlanOperations
? [
"plan_update " +
@@ -476,6 +478,10 @@ describe("ExitPlanCoordinator", () => {
: []),
"continuation",
]);
expect(host.sessionUpdate).toHaveBeenCalledWith({
sessionId: "public-session",
update: { sessionUpdate: "usage_update", used: 0, size: 200_000 },
});
if (airClient) {
expect(host.sessionUpdate).toHaveBeenCalledWith({
sessionId: "public-session",

View File

@@ -635,25 +635,33 @@ describe("/mcp", () => {
expect(text()).toBe(formatMcpStatus(SERVERS));
});
it("leaves the text of other /mcp prompts unchanged", async () => {
const { agent, sdkQuery, forwarded, text, prompt } = setup();
/** A client like Air attaches editor context to `/mcp`; the adapter drops it and still shows the status. */
it("shows the status for /mcp with attached context", async () => {
const { agent, sdkQuery, forwarded, text } = setup();
await prompt("/mcp:github:prompt");
await prompt("/mcp help");
await agent.prompt({
sessionId: "test-session",
prompt: [
{ type: "text", text: "/mcp" },
{ type: "text", text: "more" },
{ type: "resource", resource: { uri: "file:///a.ts", text: "const a = 1;" } },
],
});
expect(forwarded).toEqual(["/mcp"]);
expect(text()).toBe(formatMcpStatus(SERVERS));
expect(sdkQuery.mcpServerStatus).toHaveBeenCalled();
});
it("leaves the text of other /mcp prompts unchanged", async () => {
const { sdkQuery, forwarded, text, prompt } = setup();
await prompt("/mcp:github:prompt");
await prompt("/mcp help");
// The adapter rewrites an MCP prompt command into the Claude Code form.
expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help", "/mcp more"]);
expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help"]);
expect(text()).toBe(
["/github:prompt (MCP)", "/mcp help", "/mcp more"]
.map((command) => cliText(command, SERVERS))
.join(""),
["/github:prompt (MCP)", "/mcp help"].map((command) => cliText(command, SERVERS)).join(""),
);
expect(sdkQuery.mcpServerStatus).not.toHaveBeenCalled();
});