Compare commits

..

3 Commits

Author SHA1 Message Date
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
Ben Brandt
8bd0bc9b03 fix: allow forking sessions into a different directory (#1483)
Fix `session/fork` when the requested working directory differs from the
directory containing the source session's transcript, such as a
subthread in another worktree.

The Claude Agent SDK's `dir` option restricts **source transcript
lookup**; it does not select the fork's runtime directory. Passing ACP's
destination `cwd` therefore prevents the SDK from finding a source
session stored elsewhere.

- Omit `dir` from both SDK fork calls, active-history lookup, and
full-history import so every fork-resolution path searches all projects.
- Continue starting the forked query with the requested `cwd`, MCP
servers, and other session settings.
- Add 12 real-SDK tests using isolated temporary transcripts, covering
same- and cross-directory forks through latest history, active messages,
inactive branches, fingerprints, and live message maps, plus
missing-message errors. Check copied history, fresh UUIDs, and an
unchanged source transcript.
- Extend ACP v2 coverage to verify same- and cross-directory forks
remain immediately promptable and use the requested runtime directory.

## Validation

- Before the fix, all six cross-directory regression cases failed while
the six same-directory cases passed; after the fix, all 12 pass.
- `npm run check` — passed.
- `npm run build` — passed.
- `npm run test:run -- --reporter=dot --silent` — passed: 60 files,
3,437 tests; 4 files and 30 tests skipped.
- `git diff --check` — passed.

Final validation ran on macOS with Node 24. Earlier Windows validation
passed all fork-related tests but encountered unrelated path-separator
assertions and checkout line-ending formatting failures; those are
outside this change.

No authenticated Claude prompt or end-to-end Delta subthread test was
run. The new transcript tests use the real SDK without starting Claude
Code; ACP query execution is mocked in the protocol test.
2026-10-29 19:20:46 +00:00
11 changed files with 240 additions and 25 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

@@ -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

@@ -69,10 +69,7 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
}
async function loadFullSessionHistory(
sessionId: string,
cwd: string,
): Promise<SessionStoreEntry[]> {
async function loadFullSessionHistory(sessionId: string): Promise<SessionStoreEntry[]> {
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
const entries: SessionStoreEntry[] = [];
const store: SessionStore = {
@@ -81,7 +78,7 @@ async function loadFullSessionHistory(
},
load: async () => null,
};
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
await importSessionToStore(sessionId, store, { includeSubagents: false });
return entries;
}
@@ -185,8 +182,10 @@ export async function forkSession(
): Promise<ForkSessionResponse> {
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
const point = forkPoint(params._meta);
// SDK `dir` scopes source lookup, not the fork's working directory. Search
// all projects: params.cwd can name a different workspace for the new session.
if (!point) {
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
const forked = await forkClaudeSession(params.sessionId);
timing.phase("sdk-fork", " resolution=latest");
return { sessionId: forked.sessionId };
}
@@ -195,9 +194,7 @@ export async function forkSession(
const liveUuid = candidateIds
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
.find(Boolean);
const history = liveUuid
? undefined
: await getSessionMessages(params.sessionId, { dir: params.cwd });
const history = liveUuid ? undefined : await getSessionMessages(params.sessionId);
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
const messageUuid =
liveUuid ??
@@ -213,7 +210,7 @@ export async function forkSession(
const fullHistoryUuid = messageUuid
? undefined
: resolveFromFullHistory(
await loadFullSessionHistory(params.sessionId, params.cwd),
await loadFullSessionHistory(params.sessionId),
candidateIds,
point,
dependencies.messageIdForGrouping,
@@ -231,7 +228,6 @@ export async function forkSession(
}
const forked = await forkClaudeSession(params.sessionId, {
dir: params.cwd,
upToMessageId: messageUuid ?? fullHistoryUuid,
});
timing.phase("sdk-fork");

View File

@@ -14496,7 +14496,7 @@ describe("session/fork", () => {
});
expect(response.sessionId).toBe("fork-id");
expect(forkSession).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
expect(forkSession).toHaveBeenCalledWith("source-id");
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/workspace", additionalDirectories: ["/workspace/extra"] }),
expect.objectContaining({ resume: "fork-id" }),
@@ -14540,10 +14540,9 @@ describe("session/fork", () => {
});
expect(response.sessionId).toBe("fork-id");
expect(getSessionMessages).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
expect(getSessionMessages).toHaveBeenCalledWith("source-id");
expect(importSessionToStore).not.toHaveBeenCalled();
expect(forkSession).toHaveBeenCalledWith("source-id", {
dir: "/workspace",
upToMessageId: "assistant-uuid",
});
});
@@ -14668,11 +14667,9 @@ describe("session/fork", () => {
expect(response.sessionId).toBe("fork-id");
expect(importSessionToStore).toHaveBeenLastCalledWith("source-id", expect.any(Object), {
dir: "/workspace",
includeSubagents: false,
});
expect(forkSession).toHaveBeenLastCalledWith("source-id", {
dir: "/workspace",
upToMessageId: "inactive-assistant-uuid",
});
});

View File

@@ -1079,8 +1079,9 @@ describe("ACP v2 prompts", () => {
]);
});
it("forks a session into one that the client can prompt at once", async () => {
it.each(["same", "different"])("forks a promptable session in the %s cwd", async (dir) => {
const forkId = "99999999-8888-4777-8666-555555555555";
const forkCwd = dir === "same" ? cwd : await mkdtemp(path.join(tempDir, "fork-"));
const echoed = scriptTurns(async function* (options) {
yield assistantText(options, "Forked.");
yield result(options);
@@ -1091,17 +1092,18 @@ describe("ACP v2 prompts", () => {
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const fork = await agent.request(v2.methods.agent.session.fork, {
sessionId,
cwd,
cwd: forkCwd,
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
});
expect(fork.sessionId).toBe(forkId);
expect(fork.configOptions?.map((option) => option.configId)).toContain("mode");
expect(forkSession).toHaveBeenCalledWith(sessionId, { dir: cwd });
expect(forkSession).toHaveBeenCalledWith(sessionId);
// The fork runs as its own Claude Code session, resumed from the copied
// transcript, with the MCP servers of the fork request.
const forkQuery = sdk.queryOptions.at(-1)!;
expect(forkQuery.resume).toBe(forkId);
expect(forkQuery.cwd).toBe(forkCwd);
expect(Object.keys(forkQuery.mcpServers ?? {})).toContain("linear");
// No session/resume needed before the first prompt.

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

@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { messageIdForGrouping } from "../acp-agent.js";
import { forkSession } from "../fork-session.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
// Use the real SDK against temporary transcripts, without starting Claude Code.
let root: string;
let sourceCwd: string;
let destinationCwd: string;
let sessionId: string;
let sourceFile: string;
let transcript: string;
let activeUuid: string;
beforeEach(async () => {
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "fork-session-")));
sourceCwd = path.join(root, "source");
destinationCwd = path.join(root, "destination");
const configDir = path.join(root, "config");
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
await fs.mkdir(sourceCwd);
await fs.mkdir(destinationCwd);
sessionId = randomUUID();
const userUuid = randomUUID();
const firstUuid = randomUUID();
const followupUuid = randomUUID();
activeUuid = randomUUID();
const common = { sessionId, cwd: sourceCwd, isSidechain: false };
const entries = [
{
...common,
type: "user",
uuid: userUuid,
parentUuid: null,
message: { role: "user", content: "First prompt" },
},
{
...common,
type: "assistant",
uuid: firstUuid,
parentUuid: userUuid,
message: {
id: "msg_first",
role: "assistant",
content: [{ type: "text", text: "First answer" }],
},
},
{
...common,
type: "user",
uuid: followupUuid,
parentUuid: firstUuid,
message: { role: "user", content: "Follow-up prompt" },
},
{
...common,
type: "assistant",
uuid: randomUUID(),
parentUuid: followupUuid,
message: {
id: "msg_inactive",
role: "assistant",
content: [{ type: "text", text: "Inactive answer" }],
},
},
{
...common,
type: "assistant",
uuid: activeUuid,
parentUuid: followupUuid,
message: {
id: "msg_active",
role: "assistant",
content: [{ type: "text", text: "Active answer" }],
},
},
];
transcript =
entries
.map((entry, index) =>
JSON.stringify({ ...entry, timestamp: new Date(1_700_000_000_000 + index).toISOString() }),
)
.join("\n") + "\n";
const projectDir = path.join(configDir, "projects", encodeProjectPath(sourceCwd));
await fs.mkdir(projectDir, { recursive: true });
sourceFile = path.join(projectDir, `${sessionId}.jsonl`);
await fs.writeFile(sourceFile, transcript);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
});
describe.each(["same directory", "different directory"])("fork into %s", (directory) => {
it.each([
{ resolution: "latest", messageId: undefined, expectedAnswer: "Active answer", count: 4 },
{
resolution: "active history",
messageId: "msg_first",
expectedAnswer: "First answer",
count: 2,
},
{
resolution: "inactive branch",
messageId: "msg_inactive",
expectedAnswer: "Inactive answer",
count: 4,
},
{
resolution: "fingerprint",
messageId: "stale-message-id",
expectedAnswer: "Inactive answer",
count: 4,
},
{ resolution: "live map", messageId: "msg_active", expectedAnswer: "Active answer", count: 4 },
])("resolves the fork point through $resolution", async (scenario) => {
const originalMessages = await getSessionMessages(sessionId);
expect(originalMessages.map(messageIdForGrouping)).not.toContain("msg_inactive");
const cwd = directory === "same directory" ? sourceCwd : destinationCwd;
const result = await forkSession(
{
sessionId,
cwd,
mcpServers: [],
...(scenario.messageId && {
_meta: {
jetbrains: {
air: {
fork: {
version: 1,
messageId: scenario.messageId,
...(scenario.resolution === "fingerprint" && {
messageFingerprint: `sha256:${createHash("sha256").update("Inactive answer").digest("hex")}`,
messageOccurrence: 1,
}),
},
},
},
},
}),
},
{
messageIdForGrouping,
...(scenario.resolution === "live map" && {
liveMessageIdToUuid: new Map([["msg_active", activeUuid]]),
}),
},
);
expect(result.sessionId).not.toBe(sessionId);
const messages = await getSessionMessages(result.sessionId);
expect(messages).toHaveLength(scenario.count);
expect(messages.at(-1)?.message).toMatchObject({
content: [{ type: "text", text: scenario.expectedAnswer }],
});
const originalUuids = originalMessages.map((message) => message.uuid);
for (const message of messages) {
expect(originalUuids).not.toContain(message.uuid);
}
expect(await fs.readFile(sourceFile, "utf8")).toBe(transcript);
});
it("rejects a fork point that is not in the source session", async () => {
await expect(
forkSession(
{
sessionId,
cwd: directory === "same directory" ? sourceCwd : destinationCwd,
mcpServers: [],
_meta: { jetbrains: { air: { fork: { version: 1, messageId: "missing" } } } },
},
{ messageIdForGrouping },
),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining(
`Fork point message missing was not found in session ${sessionId}`,
),
});
expect(await fs.readFile(sourceFile, "utf8")).toBe(transcript);
});
});