mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-30 05:52:07 +00:00
Compare commits
3 Commits
eugenethed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd46c866d | ||
|
|
0f78dbbdd0 | ||
|
|
8bd0bc9b03 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.99.0"
|
||||
".": "0.99.1"
|
||||
}
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
188
src/tests/fork-session.test.ts
Normal file
188
src/tests/fork-session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user