mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-08 02:11:38 +00:00
Compare commits
16 Commits
v0.82.0
...
v0.85.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 | ||
|
|
c84845272f | ||
|
|
af0b43de94 | ||
|
|
f53b0d7f40 | ||
|
|
4af807968c | ||
|
|
bdb50ad984 | ||
|
|
d5e6f33046 | ||
|
|
691328a919 | ||
|
|
49858b5273 | ||
|
|
77ceab0339 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.82.0"
|
||||
".": "0.85.1"
|
||||
}
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
|
||||
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
|
||||
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.286 ([#1211](https://github.com/agentclientprotocol/claude-agent-acp/issues/1211)) ([f53b0d7](https://github.com/agentclientprotocol/claude-agent-acp/commit/f53b0d7f40bcd0e9bddace85f123ff4851afcb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt to SDK 0.3.284 replay and ultracode changes ([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208)) ([4af8079](https://github.com/agentclientprotocol/claude-agent-acp/commit/4af807968c7f0813bcb20fe2bcab2370320e9074))
|
||||
* send the legacy custom answer key to AIR ([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210)) ([af0b43d](https://github.com/agentclientprotocol/claude-agent-acp/commit/af0b43de944e36f81343f40b7aa1a96cd48c3218))
|
||||
|
||||
## [0.84.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.83.0...v0.84.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1189](https://github.com/agentclientprotocol/claude-agent-acp/issues/1189)) ([d5e6f33](https://github.com/agentclientprotocol/claude-agent-acp/commit/d5e6f33046614fca4798d49f0c77edba951a6771))
|
||||
|
||||
## [0.83.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.82.0...v0.83.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.283 ([#1186](https://github.com/agentclientprotocol/claude-agent-acp/issues/1186)) ([49858b5](https://github.com/agentclientprotocol/claude-agent-acp/commit/49858b527397a08d343fe2c148a5fe9e3eb84547))
|
||||
|
||||
## [0.82.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.2...v0.82.0) (2026-09-28)
|
||||
|
||||
|
||||
|
||||
@@ -1204,6 +1204,10 @@ For AIR, the companion field carries `_meta.jetbrains.air.customAnswer`:
|
||||
`questionId` names the select field of the same question.
|
||||
A client can render the companion as the free-text choice of that question.
|
||||
|
||||
For AIR, the companion field also carries the same value under the root key `_meta._askUserQuestionCustomAnswer`.
|
||||
Released AIR versions read only that key.
|
||||
A client that is not AIR gets neither key.
|
||||
|
||||
## Session fork point
|
||||
|
||||
AIR can fork a session at one agent message.
|
||||
@@ -1255,12 +1259,11 @@ The adapter sends the key only to an AIR client.
|
||||
|
||||
These keys moved into the AIR namespace. The adapter sends the new key only to AIR, and the old key to no client.
|
||||
|
||||
| Old key | New key |
|
||||
| --------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
|
||||
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
|
||||
| elicitation property `_meta._askUserQuestionCustomAnswer` | `_meta.jetbrains.air.customAnswer`, same value |
|
||||
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
|
||||
| Old key | New key |
|
||||
| --------------------------------------------------------- | --------------------------------------- |
|
||||
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
|
||||
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
|
||||
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
|
||||
|
||||
The adapter does not send `_meta.claudeCode.title`, `claudeCode.subagent`, `claudeCode.skill`, or `claudeCode.skillPath` to any client.
|
||||
AIR reads `_meta.jetbrains.air.commandTitle`, `subagent`, and `skill` instead.
|
||||
|
||||
828
package-lock.json
generated
828
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.82.0",
|
||||
"version": "0.85.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,26 +65,26 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.280",
|
||||
"diff": "^8.0.3",
|
||||
"@agentclientprotocol/sdk": "1.7.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.127.0",
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.1",
|
||||
"@typescript-eslint/parser": "8.70.1",
|
||||
"@types/node": "26.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.1"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
701
src/acp-agent.ts
701
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,12 @@ export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { AIR_CUSTOM_ANSWER_KEY, withAirMeta } from "./air-extension.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
@@ -204,6 +208,7 @@ export function askUserQuestionsToCreateRequest(
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
@@ -211,13 +216,15 @@ export function askUserQuestionsToCreateRequest(
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
// Marks the field as the custom answer companion of a select question,
|
||||
// under `_meta.jetbrains.air.customAnswer`. Only AIR gets the marker.
|
||||
// under `_meta.jetbrains.air.customAnswer` and under the root key that
|
||||
// released AIR versions read. Only AIR gets the marker.
|
||||
...(airClient
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_CUSTOM_ANSWER_KEY, {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
}),
|
||||
_meta: withAirMeta(
|
||||
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
customAnswer,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -82,10 +83,13 @@ if (process.argv.includes("--cli")) {
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
// The draft ACP v2 is opt-in while it is a draft.
|
||||
const acp = serveAcp(logger, {
|
||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
await acp.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
@@ -94,7 +98,7 @@ if (process.argv.includes("--cli")) {
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
acp.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
51
src/serve.ts
Normal file
51
src/serve.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
@@ -12,6 +12,18 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
return value === undefined || value === "default" ? null : (value as EffortLevel);
|
||||
}
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -260,6 +260,39 @@ function resolveSettingsModel(
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** A model id without the spellings `deniedModels` ignores: provider
|
||||
* prefixes, dates, version suffixes, `-fast` and context hints. */
|
||||
function normalizeDeniedModelId(value: string): string {
|
||||
let id = stripContextHints(value.trim().toLowerCase());
|
||||
const providerPrefix = id.lastIndexOf("anthropic.");
|
||||
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
|
||||
return id
|
||||
.replace(/@.*$/, "")
|
||||
.replace(/-v\d+(?::\d+)?$/, "")
|
||||
.replace(/-fast$/, "")
|
||||
.replace(/-\d{8}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
|
||||
* closely enough for the picker: a family alias blocks the whole family, and a
|
||||
* model id blocks that version in every spelling plus the later minor versions
|
||||
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays
|
||||
* the enforcer; this only keeps the picker from offering a refused row.
|
||||
*/
|
||||
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
|
||||
const id = normalizeDeniedModelId(modelId);
|
||||
if (!id) return false;
|
||||
return deniedModels.some((entry) => {
|
||||
const denied = normalizeDeniedModelId(entry);
|
||||
if (!denied) return false;
|
||||
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
|
||||
return id === denied || id.startsWith(`${denied}-`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
||||
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
||||
@@ -275,11 +308,14 @@ function resolveSettingsModel(
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
|
||||
* dropped even though the CLI would otherwise leave it unmatched.
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
deniedModels: readonly string[] = [],
|
||||
): ModelInfo[] {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
@@ -310,6 +346,13 @@ export function applyAvailableModelsAllowlist(
|
||||
if (seen.has(effective)) continue;
|
||||
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (
|
||||
[trimmed, effective, sdkMatch?.resolvedModel].some(
|
||||
(id) => id != null && isDeniedModel(id, deniedModels),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: effective });
|
||||
} else {
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface SettingsManagerOptions {
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -106,9 +107,14 @@ export class SettingsManager {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
// The CLI reads `deniedModels` from managed settings only.
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,14 @@ export class SettingsManager {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
*/
|
||||
getManagedDeniedModels(): string[] {
|
||||
return this.managedDeniedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
|
||||
@@ -154,6 +154,51 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a settings-requested ultracode on when applying effort", async () => {
|
||||
// Since CLI 2.1.284 an effortLevel apply without an ultracode key turns
|
||||
// ultracode off; the CLI's own effort control keeps it on.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ ultracode: true }),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high", ultracode: true });
|
||||
});
|
||||
|
||||
it("seeds effort from higher-priority programmatic model settings", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
ClaudeAcpAgent,
|
||||
@@ -1948,6 +1949,17 @@ describe("stripLocalCommandMetadata", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("strips a persisted task notification, keeping what the user typed", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata(
|
||||
"<task-notification>\n<task-id>bg1</task-id>\n</task-notification>\nwhat now?",
|
||||
),
|
||||
).toBe("\nwhat now?");
|
||||
expect(
|
||||
stripLocalCommandMetadata("<task-notification><task-id>bg1</task-id></task-notification>"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops reminder-only blocks from mixed arrays, keeping real blocks", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata([
|
||||
@@ -2527,6 +2539,365 @@ describe("synthetic login message (issue #863)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task notification replay", () => {
|
||||
type History = Awaited<ReturnType<typeof getSessionMessages>>;
|
||||
const record = (fields: Record<string, unknown>) =>
|
||||
({
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
...fields,
|
||||
}) as unknown as History[number];
|
||||
const notificationText = (status: string, toolUseId = "toolu_bash") =>
|
||||
"<task-notification>\n<task-id>bg1</task-id>\n" +
|
||||
`<tool-use-id>${toolUseId}</tool-use-id>\n` +
|
||||
"<output-file>/tmp/tasks/bg1.output</output-file>\n" +
|
||||
`<status>${status}</status>\n` +
|
||||
'<summary>Background command "npm test" finished</summary>\n' +
|
||||
"<note>A task-notification fires each time the task stops.</note>\n" +
|
||||
"<result><status>failed</status></result>\n</task-notification>";
|
||||
const notification = (status: string, fields: Record<string, unknown> = {}) =>
|
||||
record({
|
||||
type: "user",
|
||||
uuid: `notification-${status}`,
|
||||
message: { role: "user", content: notificationText(status) },
|
||||
origin: { kind: "task-notification" },
|
||||
...fields,
|
||||
});
|
||||
const bashLaunch = [
|
||||
record({ type: "user", uuid: "u1", message: { role: "user", content: "run the tests" } }),
|
||||
record({
|
||||
type: "assistant",
|
||||
uuid: "a1",
|
||||
message: {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "npm test", run_in_background: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u2",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: "Command running in background with ID: bg1.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
async function replay(
|
||||
history: History,
|
||||
capabilities: ClientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
},
|
||||
): Promise<AcpSessionNotification[]> {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) =>
|
||||
updates.push(update as AcpSessionNotification),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, capabilities);
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce(history);
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
return updates;
|
||||
}
|
||||
|
||||
const userChunks = (updates: AcpSessionNotification[]) =>
|
||||
updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "user_message_chunk" && update.content.type === "text"
|
||||
? [update.content.text]
|
||||
: [],
|
||||
);
|
||||
const asyncTaskUpdates = (updates: AcpSessionNotification[]) =>
|
||||
updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate.startsWith("async_task_") ? [update] : [],
|
||||
);
|
||||
|
||||
it("hides the notification and restores the task state like the live frame", async () => {
|
||||
const updates = await replay([...bashLaunch, notification("completed")]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["run the tests"]);
|
||||
expect(JSON.stringify(updates)).not.toContain("task-notification");
|
||||
expect(asyncTaskUpdates(updates)).toEqual([
|
||||
{
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: "bg1",
|
||||
name: "npm test",
|
||||
taskType: "shell",
|
||||
description: "npm test",
|
||||
showInTranscript: true,
|
||||
canStop: true,
|
||||
outputFilePath: "/tmp/tasks/bg1.output",
|
||||
toolCallId: "toolu_bash",
|
||||
},
|
||||
{
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: "bg1",
|
||||
state: "completed",
|
||||
summary: 'Background command "npm test" finished',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a notification without an origin by its text and maps killed to stopped", async () => {
|
||||
const updates = await replay([...bashLaunch, notification("killed", { origin: undefined })]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["run the tests"]);
|
||||
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: "bg1",
|
||||
state: "stopped",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["server_tool_use", "mcp_tool_use"])(
|
||||
"restores a background task launched by a %s block",
|
||||
async (type) => {
|
||||
const launch = record({
|
||||
...bashLaunch[1],
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type, id: "toolu_bash", name: "Bash", input: { command: "npm test" } }],
|
||||
},
|
||||
});
|
||||
const updates = await replay([
|
||||
bashLaunch[0],
|
||||
launch,
|
||||
bashLaunch[2],
|
||||
notification("completed"),
|
||||
]);
|
||||
|
||||
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "completed" });
|
||||
},
|
||||
);
|
||||
|
||||
it("routes a task to the session of its launching tool even when the root receives the stop", async () => {
|
||||
const updates = await replay(
|
||||
[
|
||||
...bashLaunch.map((message, index) =>
|
||||
index === 0 ? message : record({ ...message, parent_tool_use_id: "toolu_agent" }),
|
||||
),
|
||||
notification("completed"),
|
||||
],
|
||||
{
|
||||
subagents: {},
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
} as ClientCapabilities,
|
||||
);
|
||||
|
||||
const tasks = updates.filter(({ update }) => update.sessionUpdate.startsWith("async_task_"));
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map(({ sessionId }) => sessionId)).toEqual([
|
||||
"s1:replay-subagent:toolu_agent",
|
||||
"s1:replay-subagent:toolu_agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the user text next to an embedded notification", async () => {
|
||||
const updates = await replay([
|
||||
...bashLaunch,
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u3",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: notificationText("failed") },
|
||||
{ type: "text", text: "why did it fail?" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["run the tests", "why did it fail?"]);
|
||||
expect(JSON.stringify(updates)).not.toContain("task-notification");
|
||||
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "failed" });
|
||||
});
|
||||
|
||||
it("sends nothing for a task that no tool call in the history started", async () => {
|
||||
const updates = await replay([
|
||||
record({ type: "user", uuid: "u1", message: { role: "user", content: "hi" } }),
|
||||
notification("completed"),
|
||||
]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["hi"]);
|
||||
expect(asyncTaskUpdates(updates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends one terminal state for a repeated notification", async () => {
|
||||
const updates = await replay([
|
||||
...bashLaunch,
|
||||
notification("completed"),
|
||||
notification("completed", { uuid: "notification-again" }),
|
||||
]);
|
||||
|
||||
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores the notified state of a background Agent after its launch result", async () => {
|
||||
const updates = await replay(
|
||||
[
|
||||
record({
|
||||
type: "assistant",
|
||||
uuid: "a1",
|
||||
message: {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_agent",
|
||||
name: "Agent",
|
||||
input: { description: "Explore", prompt: "Look around", run_in_background: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u2",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_agent", content: "Async agent launched." },
|
||||
],
|
||||
},
|
||||
}),
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u3",
|
||||
message: { role: "user", content: notificationText("killed", "toolu_agent") },
|
||||
origin: { kind: "task-notification" },
|
||||
}),
|
||||
],
|
||||
{
|
||||
subagents: {},
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
} as ClientCapabilities,
|
||||
);
|
||||
|
||||
expect(userChunks(updates)).toEqual([]);
|
||||
expect(asyncTaskUpdates(updates)).toEqual([]);
|
||||
expect(updates.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "subagent_state_update",
|
||||
subagentSessionId: "s1:replay-subagent:toolu_agent",
|
||||
state: "cancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a task-notification delivery with a subkind as a prompt", async () => {
|
||||
const updates = await replay([
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
message: { role: "user", content: "Run the nightly report" },
|
||||
origin: { kind: "task-notification", subkind: "scheduled-trigger" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["Run the nightly report"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("meta messages from other agents, sessions and channels", () => {
|
||||
// The shape SDK 0.3.284 getSessionMessages returns for a host-injected peer
|
||||
// message (live probe): is_meta plus origin, content is the full framing.
|
||||
const peerMessage = {
|
||||
type: "user",
|
||||
uuid: "peer-1",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
is_meta: true,
|
||||
origin: { kind: "peer", from: "uds:/tmp/fake.sock", hostInjected: true },
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
'Another Claude session sent a message:\n<cross-session-message from="uds:/tmp/fake.sock">enveloped hi</cross-session-message>\n\nThis came from another Claude session — not typed by your user.',
|
||||
},
|
||||
};
|
||||
|
||||
it("isReplayHiddenMetaMessage matches only meta messages with an inter-agent origin", () => {
|
||||
expect(isReplayHiddenMetaMessage(peerMessage)).toBe(true);
|
||||
for (const kind of ["channel", "observer", "observer-activity", "slack-ping"]) {
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind } })).toBe(true);
|
||||
}
|
||||
// Not meta: an un-framed message persisted as a normal user turn.
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, is_meta: undefined })).toBe(false);
|
||||
// Meta without an inter-agent origin, such as a compact summary.
|
||||
expect(
|
||||
isReplayHiddenMetaMessage({ ...peerMessage, origin: undefined, isCompactSummary: true }),
|
||||
).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind: "human" } })).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, type: "assistant" })).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("loadSession replay skips them but keeps the user's own prompt", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (u: SessionNotification) => {
|
||||
updates.push(u);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||
{
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
message: { role: "user", content: [{ type: "text", text: "hi, say one word" }] },
|
||||
},
|
||||
peerMessage,
|
||||
] as Awaited<ReturnType<typeof getSessionMessages>>);
|
||||
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
|
||||
expect(
|
||||
updates.some(
|
||||
(u) =>
|
||||
u.update.sessionUpdate === "user_message_chunk" &&
|
||||
u.update.content.type === "text" &&
|
||||
u.update.content.text.includes("hi, say one word"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(updates)).not.toContain("cross-session-message");
|
||||
expect(JSON.stringify(updates)).not.toContain("Another Claude session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage-limit failure replay", () => {
|
||||
const usageLimitMessage = {
|
||||
type: "assistant" as const,
|
||||
@@ -16312,11 +16683,25 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
},
|
||||
);
|
||||
|
||||
// The fresh query activates the same turn again; it is inserted once.
|
||||
const inserted: string[] = [];
|
||||
const startTurn = agent.startTurn.bind(agent);
|
||||
vi.spyOn(agent, "startTurn").mockImplementation((params, events) =>
|
||||
startTurn(params, {
|
||||
...events,
|
||||
inserted: (messageId) => {
|
||||
inserted.push(messageId);
|
||||
events.inserted(messageId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "plan" }],
|
||||
});
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/test" }),
|
||||
expect.objectContaining({ publicSessionId: "test-session", permissionMode: "auto" }),
|
||||
@@ -19310,6 +19695,59 @@ describe("turn steering (_session/steering)", () => {
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
// CLI 2.1.286: a steer that lands during a foreground tool call moves the tool
|
||||
// to the background and joins the running cycle instead of aborting it, so
|
||||
// the turn ends in ONE result stamped with both the prompt and the steer.
|
||||
it.each([
|
||||
{ name: "without an echo", echo: false },
|
||||
{ name: "with a mid-cycle echo", echo: true },
|
||||
])("settles a steer that joined the running cycle $name", async ({ echo }) => {
|
||||
const timeline: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value);
|
||||
yield createAssistantText("working on it");
|
||||
const steered = await iter.next();
|
||||
if (echo) yield userEcho(steered.value);
|
||||
yield createAssistantText("STEERED-OK");
|
||||
yield {
|
||||
...createResultMessage(),
|
||||
user_message_uuid: u1.value.uuid,
|
||||
user_message_uuids: [u1.value.uuid, steered.value.uuid],
|
||||
};
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`prompt:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await expect(
|
||||
agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also handle X" }] }),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
|
||||
const response = await turn;
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
|
||||
// One cycle ran, so usage is the single result's.
|
||||
expect(response.usage?.inputTokens).toBe(10);
|
||||
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
|
||||
expect(agent.sessions["test-session"].owedTrailingIdles ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// Issue #1114's other hang, and #1063's: the steer aborts an autonomous
|
||||
// followup, whose result counts a trailing idle that the abort then folds into
|
||||
// the one idle spanning both cycles. Absorbing that idle as debt would starve
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
@@ -661,6 +662,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
@@ -689,7 +691,13 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
...Object.keys(meta).filter((k) => AIR_ONLY_META_KEYS.has(k) && k !== "jetbrains"),
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
513
src/tests/acp-v2.test.ts
Normal file
513
src/tests/acp-v2.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* The experimental draft ACP v2 surface, driven through the protocol router
|
||||
* with the SDK's own v1 and v2 client apps. The v2 client app validates every
|
||||
* response, session update, and elicitation it receives against the v2
|
||||
* schema, so a malformed v2 message fails the test.
|
||||
*/
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as v1 from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AuthStatusUpdateNotification } from "../auth-status.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import { clientSupportsNotices } from "../session-notices.js";
|
||||
import { v2SessionUpdate } from "../v2/session-update.js";
|
||||
import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return { ...actual, execFile: execFileSpy };
|
||||
});
|
||||
|
||||
/** What the mocked Claude Agent SDK saw and answers. Reset before each test. */
|
||||
const sdk = vi.hoisted(() => ({
|
||||
queryOptions: [] as Options[],
|
||||
mcpServerStatus: async (): Promise<Array<{ name: string; status: string }>> => [],
|
||||
mcpAuthenticate: async (
|
||||
_serverName: string,
|
||||
): Promise<{ authUrl?: string; requiresUserAction: boolean; callbackExpected: boolean }> => ({
|
||||
requiresUserAction: false,
|
||||
callbackExpected: false,
|
||||
}),
|
||||
}));
|
||||
const sdkDefaults = { ...sdk };
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: ({ options }: { options: Options }) => {
|
||||
sdk.queryOptions.push(options);
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
supportedCommands: async () => [
|
||||
{ name: "review", description: "Review a change", argumentHint: "<pull request>" },
|
||||
],
|
||||
mcpServerStatus: () => sdk.mcpServerStatus(),
|
||||
mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName),
|
||||
});
|
||||
},
|
||||
listSessions: vi.fn(async () => [
|
||||
{
|
||||
sessionId: "11111111-2222-4333-8444-555555555555",
|
||||
cwd: "/workspace/project",
|
||||
summary: "Fix the build",
|
||||
lastModified: Date.UTC(2026, 9, 1),
|
||||
},
|
||||
]),
|
||||
getSessionMessages: vi.fn(async () => []),
|
||||
deleteSession: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
const CLI_SUBSCRIPTION = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
email: "user@example.com",
|
||||
subscriptionType: "max",
|
||||
});
|
||||
|
||||
const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" };
|
||||
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
/** An empty Claude config directory and a session cwd, so no test reads the real ones. */
|
||||
let tempDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// `claude auth status --json` reports a subscription, `claude auth logout` succeeds.
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const args = invocation[1] as string[];
|
||||
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
callback(null, { stdout: args[1] === "status" ? CLI_SUBSCRIPTION : "", stderr: "" });
|
||||
});
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-"));
|
||||
cwd = await mkdtemp(path.join(tempDir, "project-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude"));
|
||||
Object.assign(sdk, sdkDefaults, { queryOptions: [] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
|
||||
execFileSpy.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
||||
function connectRouter(): v2.Stream {
|
||||
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
return { readable: toClient.readable, writable: toAgent.writable };
|
||||
}
|
||||
|
||||
/**
|
||||
* A v2 client app that collects every `_auth/status_update`, session update,
|
||||
* and elicitation it receives, and accepts every elicitation.
|
||||
*/
|
||||
function v2Client() {
|
||||
const authUpdates: AuthStatusUpdateNotification[] = [];
|
||||
const sessionUpdates: v2.UpdateSessionNotification[] = [];
|
||||
const elicitations: v2.CreateElicitationRequest[] = [];
|
||||
const completedElicitations: v2.CompleteElicitationNotification[] = [];
|
||||
let notifyAuthUpdate = () => {};
|
||||
const app = v2
|
||||
.client({ name: V2_CLIENT_INFO.name })
|
||||
.onNotification(
|
||||
"_auth/status_update",
|
||||
(params) => params as AuthStatusUpdateNotification,
|
||||
({ params }) => {
|
||||
authUpdates.push(params);
|
||||
notifyAuthUpdate();
|
||||
},
|
||||
)
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => {
|
||||
sessionUpdates.push(params);
|
||||
})
|
||||
.onRequest(v2.methods.client.elicitation.create, ({ params }) => {
|
||||
elicitations.push(params);
|
||||
return { action: "accept" };
|
||||
})
|
||||
.onNotification(v2.methods.client.elicitation.complete, ({ params }) => {
|
||||
completedElicitations.push(params);
|
||||
});
|
||||
/** Resolves once the client has received `count` auth status updates. */
|
||||
const authUpdate = (count: number) =>
|
||||
new Promise<AuthStatusUpdateNotification>((resolve) => {
|
||||
notifyAuthUpdate = () => {
|
||||
if (authUpdates.length >= count) {
|
||||
resolve(authUpdates[count - 1]);
|
||||
}
|
||||
};
|
||||
notifyAuthUpdate();
|
||||
});
|
||||
/** The session updates of one kind that the client has received. */
|
||||
const updates = <Kind extends string>(kind: Kind) =>
|
||||
sessionUpdates
|
||||
.map((notification) => notification.update)
|
||||
.filter(
|
||||
(update): update is Extract<v2.SessionUpdate, { sessionUpdate: Kind }> =>
|
||||
update.sessionUpdate === kind,
|
||||
);
|
||||
return { app, authUpdate, sessionUpdates, updates, elicitations, completedElicitations };
|
||||
}
|
||||
|
||||
async function initializeV2(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) {
|
||||
return agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACP protocol routing", () => {
|
||||
it("serves the unchanged v1 handshake to a v1 client", async () => {
|
||||
let authUpdated!: () => void;
|
||||
const authUpdate = new Promise<void>((resolve) => (authUpdated = resolve));
|
||||
const response = await v1
|
||||
.client({ name: "v1-test-client" })
|
||||
.onNotification(
|
||||
"_auth/status_update",
|
||||
(params) => params,
|
||||
() => authUpdated(),
|
||||
)
|
||||
.connectWith(connectRouter() as unknown as v1.Stream, async (agent) => {
|
||||
const response = await agent.request(v1.methods.agent.initialize, {
|
||||
protocolVersion: v1.PROTOCOL_VERSION,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
await authUpdate;
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(1);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
|
||||
expect(response.authMethods?.length).toBeGreaterThan(0);
|
||||
for (const method of response.authMethods ?? []) {
|
||||
expect(method).toHaveProperty("id");
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the v2 handshake to a v2 client", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
const response = await app.connectWith(connectRouter(), async (agent) => {
|
||||
const response = await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities: { auth: { terminal: {} } },
|
||||
});
|
||||
await authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(2);
|
||||
expect(response.info).toEqual({
|
||||
name: packageJson.name,
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
});
|
||||
// The whole v2 session baseline and the providers methods are not served yet.
|
||||
expect(response.capabilities?.session).toBeUndefined();
|
||||
expect(response.capabilities?.providers).toBeUndefined();
|
||||
expect(response.authMethods?.length).toBeGreaterThan(0);
|
||||
for (const method of response.authMethods ?? []) {
|
||||
expect(method).not.toHaveProperty("id");
|
||||
expect(method).toMatchObject({ methodId: expect.any(String), type: "terminal" });
|
||||
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects v2 prompts, which the v2 surface does not serve yet", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32601 });
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 sessions", () => {
|
||||
it("creates a session with v2 MCP servers, config options, and commands", async () => {
|
||||
const client = v2Client();
|
||||
const response = await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const response = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [
|
||||
{ type: "stdio", name: "files", command: "/usr/local/bin/mcp-files" },
|
||||
{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" },
|
||||
],
|
||||
});
|
||||
await vi.waitFor(() => expect(client.updates("available_commands_update")).toHaveLength(1));
|
||||
await client.authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
// A v2 stdio server has a `type`, which v1 omits, and may omit its lists.
|
||||
expect(sdk.queryOptions[0].mcpServers).toMatchObject({
|
||||
files: { type: "stdio", command: "/usr/local/bin/mcp-files", args: [] },
|
||||
linear: { type: "http", url: "https://mcp.linear.app/mcp" },
|
||||
});
|
||||
expect(response).not.toHaveProperty("modes");
|
||||
expect(response.configOptions?.map((option) => option.configId)).toEqual(["mode", "model"]);
|
||||
for (const option of response.configOptions ?? []) {
|
||||
expect(option).not.toHaveProperty("id");
|
||||
}
|
||||
expect(response.configOptions?.[0]).toMatchObject({ category: "mode", type: "select" });
|
||||
expect(client.updates("available_commands_update")[0].availableCommands).toEqual([
|
||||
{
|
||||
name: "review",
|
||||
description: "Review a change",
|
||||
input: { type: "text", hint: "<pull request>" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an MCP transport that v1 cannot express", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [{ type: "_custom", name: "custom" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
await authUpdate(1);
|
||||
});
|
||||
expect(sdk.queryOptions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lists, resumes, closes, and deletes sessions", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
expect(await agent.request(v2.methods.agent.session.list, {})).toEqual({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: "11111111-2222-4333-8444-555555555555",
|
||||
cwd: "/workspace/project",
|
||||
title: "Fix the build",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.close, { sessionId });
|
||||
// A closed session is resumed from its transcript.
|
||||
const resumed = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
|
||||
expect(sdk.queryOptions.at(-1)?.resume).toBe(sessionId);
|
||||
expect(resumed).not.toHaveProperty("modes");
|
||||
expect(resumed.configOptions?.[0].configId).toBe("mode");
|
||||
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.resume, {
|
||||
sessionId,
|
||||
cwd,
|
||||
replayFrom: { type: "start" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
|
||||
await agent.request(v2.methods.agent.session.delete, { sessionId });
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a config option with a v2 value and reports modes only as config options", async () => {
|
||||
const client = v2Client();
|
||||
const response = await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
const response = await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "mode",
|
||||
type: "id",
|
||||
value: "plan",
|
||||
});
|
||||
await client.authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.configOptions[0]).toMatchObject({ configId: "mode", currentValue: "plan" });
|
||||
expect(client.updates("current_mode_update")).toEqual([]);
|
||||
});
|
||||
|
||||
it("tells the agent that a v2 client takes notices", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
await authUpdate(1);
|
||||
});
|
||||
expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => {
|
||||
let statusCall = 0;
|
||||
sdk.mcpServerStatus = async () =>
|
||||
statusCall++ === 0
|
||||
? [{ name: "linear", status: "needs-auth" }]
|
||||
: [{ name: "linear", status: "connected" }];
|
||||
sdk.mcpAuthenticate = async () => ({
|
||||
authUrl: "https://example.com/oauth/authorize",
|
||||
requiresUserAction: true,
|
||||
callbackExpected: true,
|
||||
});
|
||||
const client = v2Client();
|
||||
const sessionId = await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent, { elicitation: { url: {} } });
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
|
||||
});
|
||||
await vi.waitFor(() => expect(client.completedElicitations).toHaveLength(1));
|
||||
await client.authUpdate(1);
|
||||
return sessionId;
|
||||
});
|
||||
|
||||
expect(client.elicitations).toEqual([
|
||||
{
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: "Authenticate with MCP server linear",
|
||||
url: "https://example.com/oauth/authorize",
|
||||
elicitationId: expect.stringMatching(/^mcp-oauth-/),
|
||||
},
|
||||
]);
|
||||
expect(client.completedElicitations).toEqual([
|
||||
{ elicitationId: (client.elicitations[0] as { elicitationId: string }).elicitationId },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 session translation", () => {
|
||||
it("renames the group id of grouped select options", () => {
|
||||
expect(
|
||||
v2ConfigOptions([
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ group: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
configId: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ groupId: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a config option value type that v1 cannot express", () => {
|
||||
expect(() =>
|
||||
v1SetSessionConfigOptionRequest({
|
||||
sessionId: "s",
|
||||
configId: "c",
|
||||
type: "_range",
|
||||
value: 3,
|
||||
}),
|
||||
).toThrow("Config option values of type _range are not supported");
|
||||
});
|
||||
|
||||
it("fails on session updates that it does not translate yet", () => {
|
||||
expect(() =>
|
||||
v2SessionUpdate({ sessionUpdate: "tool_call", toolCallId: "t", title: "Read" }),
|
||||
).toThrow("does not translate tool_call session updates yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 auth", () => {
|
||||
it("pushes the auth status of the connection after initialize", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
const update = await app.connectWith(connectRouter(), async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
});
|
||||
return authUpdate(1);
|
||||
});
|
||||
|
||||
expect(update.authStatus).toMatchObject({
|
||||
kind: "account",
|
||||
account: { email: "user@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("logs in to a gateway with auth/login and out with auth/logout", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
const response = await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities: { auth: { _meta: { gateway: true } } },
|
||||
});
|
||||
expect(response.authMethods).toContainEqual({
|
||||
methodId: "gateway",
|
||||
type: "agent",
|
||||
name: "Custom model gateway",
|
||||
description: "Use a custom gateway to authenticate and access models",
|
||||
_meta: { gateway: { protocol: "anthropic" } },
|
||||
});
|
||||
await authUpdate(1);
|
||||
|
||||
await agent.request(v2.methods.agent.auth.login, {
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example.com", headers: {} } },
|
||||
});
|
||||
expect((await authUpdate(2)).authStatus).toMatchObject({
|
||||
kind: "gateway",
|
||||
detail: "gateway.example.com",
|
||||
});
|
||||
|
||||
await agent.request(v2.methods.agent.auth.logout, {});
|
||||
expect(execFileSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
["auth", "logout"],
|
||||
expect.any(Function),
|
||||
);
|
||||
expect((await authUpdate(3)).authStatus).toMatchObject({ kind: "account" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ let sessionMessages: Record<string, unknown>[];
|
||||
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
|
||||
let initModels: Record<string, unknown>[] | undefined;
|
||||
let setModelImpl: ((model: string) => Promise<void>) | undefined;
|
||||
let interruptImpl: (() => Promise<undefined>) | undefined;
|
||||
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
|
||||
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
|
||||
let mcpAuthenticateImpl: (serverName: string) => Promise<{
|
||||
authUrl?: string;
|
||||
@@ -34,8 +36,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return makeMockQuery({
|
||||
interrupt: async () => undefined,
|
||||
close: () => {},
|
||||
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
|
||||
close: () => closeSpy(),
|
||||
initializationResult: async () => ({
|
||||
models: initModels ?? [
|
||||
{
|
||||
@@ -86,6 +88,8 @@ describe("createSession options merging", () => {
|
||||
vi.mocked(getSessionMessages).mockClear();
|
||||
initModels = undefined;
|
||||
setModelImpl = undefined;
|
||||
interruptImpl = undefined;
|
||||
closeSpy = vi.fn<() => void>();
|
||||
mcpServerStatusResult = async () => [];
|
||||
mcpAuthenticateImpl = async () => ({
|
||||
requiresUserAction: false,
|
||||
@@ -847,31 +851,75 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close right after session/new", () => {
|
||||
it("answers at once and closes the query", async () => {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await agent.closeSession({ sessionId });
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
|
||||
// SDK control requests are serialized. On a fresh session the background
|
||||
// getContextUsage is still in flight, so a real CLI answers an interrupt
|
||||
// only after it, which took ~3s and timed out the client's close.
|
||||
contextUsageResult = () => new Promise<never>(() => {});
|
||||
interruptImpl = () => new Promise<never>(() => {});
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
const closed = await Promise.race([
|
||||
agent.closeSession({ sessionId }).then(() => "closed"),
|
||||
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
|
||||
]);
|
||||
|
||||
expect(closed).toBe("closed");
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window seeding", () => {
|
||||
function sessionFor(sessionId: string) {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
|
||||
}
|
||||
|
||||
it("does not call getContextUsage during session creation", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn has run
|
||||
// (it is not serviced pre-turn), so session/new must never call it —
|
||||
it("does not wait for getContextUsage during session creation", async () => {
|
||||
// SDK control requests are serialized and getContextUsage used to stall
|
||||
// until the first prompt turn, so session/new only kicks it off —
|
||||
// awaiting it inline is what regressed session/new latency in 0.59.0.
|
||||
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
|
||||
contextUsageResult = ctxSpy;
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(ctxSpy).not.toHaveBeenCalled();
|
||||
expect(ctxSpy).toHaveBeenCalledOnce();
|
||||
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds contextWindowSize from text inference, falling back to the default when it misses", async () => {
|
||||
it("refines a guessed window from getContextUsage in the background", async () => {
|
||||
// The mock model ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") carries
|
||||
// no "1m" token anywhere, so inference misses and the window falls back to
|
||||
// the default; the authoritative value arrives later via result.modelUsage.
|
||||
// no "1m" token anywhere, so inference misses and the seed is the default
|
||||
// until the background getContextUsage answers.
|
||||
contextUsageResult = async () => ({ rawMaxTokens: 967000 });
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await vi.waitFor(() => expect(sessionFor(response.sessionId).contextWindowSize).toBe(967000));
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the guessed window when getContextUsage reports a non-positive size", async () => {
|
||||
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 0 }));
|
||||
contextUsageResult = ctxSpy;
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
await vi.waitFor(() => expect(ctxSpy).toHaveBeenCalled());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
@@ -918,7 +966,7 @@ describe("createSession options merging", () => {
|
||||
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
|
||||
"haiku",
|
||||
);
|
||||
expect(ctxSpy).not.toHaveBeenCalled();
|
||||
// Kicked off in the background, never awaited: it never answers here.
|
||||
expect(sessionFor("resumed-model-probe").contextWindowAuthoritative).toBe(false);
|
||||
expect(getSessionMessages).toHaveBeenCalledTimes(1);
|
||||
expect(getSessionMessages).toHaveBeenCalledWith("resumed-model-probe");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { applyPatch } from "diff";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -260,13 +260,32 @@ describe("approval patch previews", () => {
|
||||
.join("");
|
||||
const filePath = await temporaryFile(oldText);
|
||||
|
||||
// 200 changed lines. A diff that runs out of its time budget gives no
|
||||
// patch, and patchText then throws.
|
||||
const patch = patchText(
|
||||
await previewPatchContent("Write", { file_path: filePath, content: newText }),
|
||||
);
|
||||
// Verify the 200-line patch independently of the runner's speed. The
|
||||
// timeout fallback is exercised separately with an advancing clock.
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
try {
|
||||
const patch = patchText(
|
||||
await previewPatchContent("Write", { file_path: filePath, content: newText }),
|
||||
);
|
||||
expect(applyPatch(oldText, patch)).toBe(newText);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
expect(applyPatch(oldText, patch)).toBe(newText);
|
||||
it("falls back to the standard Write diff when the patch budget expires", async () => {
|
||||
const oldText = "before\n";
|
||||
const newText = "after\n";
|
||||
const filePath = await temporaryFile(oldText);
|
||||
let now = 0;
|
||||
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000));
|
||||
try {
|
||||
expect(await previewPatchContent("Write", { file_path: filePath, content: newText })).toEqual(
|
||||
[{ type: "diff", path: filePath, oldText, newText }],
|
||||
);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds patches that turn the old text into the new text", async () => {
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
@@ -274,6 +275,7 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { customAnswer: { questionId: "question_1", isCustomAnswer: true } },
|
||||
},
|
||||
|
||||
321
src/tests/incomplete-tools.test.ts
Normal file
321
src/tests/incomplete-tools.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
const sessionId = "test-session";
|
||||
const toolCallId = "unfinished-bash";
|
||||
|
||||
afterEach(() => clearHookCallbacks(sessionId));
|
||||
|
||||
describe("incomplete foreground tools", () => {
|
||||
it.each(["result", "EOF"] as const)(
|
||||
"fails the tool and prompt at %s even after the input stream closes",
|
||||
async (ending) => {
|
||||
const { prompt, updates, logError } = createTestSession((input) =>
|
||||
unfinishedToolMessages(input, ending),
|
||||
);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
expect(updates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "failed",
|
||||
}),
|
||||
);
|
||||
expect(logError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.stringMatching(
|
||||
/Session test-session, turn .+, stopReason=end_turn:.*unfinished-bash/,
|
||||
),
|
||||
);
|
||||
const failed = updates.find((u) => u.toolCallId === toolCallId && u.status === "failed");
|
||||
expect(failed.content[0].content.text).toContain("without returning a result");
|
||||
expect(failed).not.toHaveProperty("rawOutput");
|
||||
expect(hasHookCallback(toolCallId)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("detects a tool emitted before the user echo", async () => {
|
||||
const { prompt } = createTestSession(toolBeforeUserEchoMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
});
|
||||
|
||||
it("detects a permission tool whose streamed tool_use never arrived", async () => {
|
||||
const { prompt, updates } = createTestSession(permissionOnlyToolMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
expect(updates).toContainEqual(expect.objectContaining({ toolCallId, status: "failed" }));
|
||||
});
|
||||
|
||||
it("allows a completed tool with a late PostToolUse hook", async () => {
|
||||
const { prompt, logError } = createTestSession(completedToolMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(hasHookCallback(toolCallId)).toBe(true);
|
||||
await createPostToolUseHook()(
|
||||
{
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "echo test" },
|
||||
tool_response: { stdout: "test", stderr: "", interrupted: false },
|
||||
} as any,
|
||||
toolCallId,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a tool explicitly handed off to a background task", async () => {
|
||||
const { prompt, updates } = createTestSession(backgroundToolMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not claim a subagent's tool as a foreground tool", async () => {
|
||||
const { prompt } = createTestSession(subagentToolMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("does not treat a request to run in the background as a confirmed handoff", async () => {
|
||||
const { prompt } = createTestSession(unconfirmedBackgroundToolMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
});
|
||||
|
||||
it("waits for deferred settlement before checking unfinished tools", async () => {
|
||||
const { prompt, logError } = createTestSession(deferredSettlementMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
|
||||
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
expect(updates.filter((u) => u.status === "failed").map((u) => u.toolCallId)).toEqual([
|
||||
toolCallId,
|
||||
"second-tool",
|
||||
]);
|
||||
expect(agent.sessions[sessionId].emittedToolCalls.size).toBe(0);
|
||||
expect(agent.sessions[sessionId].toolUseCache).toEqual({});
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("preserves cancellation and does not attribute its unfinished tool to the next turn", async () => {
|
||||
const { prompt, updates } = createTestSession(cancelledThenSuccessfulTurnMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "cancelled" });
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an existing SDK failure", async () => {
|
||||
const { prompt } = createTestSession(sdkFailureMessages);
|
||||
|
||||
await expect(prompt()).rejects.toThrow("original failure");
|
||||
});
|
||||
});
|
||||
|
||||
function toolStart(id = toolCallId, parent: string | null = null) {
|
||||
return {
|
||||
type: "stream_event",
|
||||
session_id: sessionId,
|
||||
uuid: "stream-message",
|
||||
parent_tool_use_id: parent,
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id, name: "Bash", input: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolResult(id = toolCallId) {
|
||||
return {
|
||||
type: "user",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: id, content: "done" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTestSession(
|
||||
createSdkMessages: (input: Pushable<any>, agent: ClaudeAcpAgent) => AsyncGenerator<any>,
|
||||
) {
|
||||
const updates: any[] = [];
|
||||
const logError = vi.fn();
|
||||
const input = new Pushable<any>();
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification.update);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } }),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: logError },
|
||||
);
|
||||
agent.sessions[sessionId] = mockSessionState({
|
||||
input,
|
||||
// The real consumer reads the scripted SDK events from this generator.
|
||||
query: wrapQuery(createSdkMessages(input, agent)),
|
||||
});
|
||||
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] });
|
||||
return { agent, updates, logError, prompt };
|
||||
}
|
||||
|
||||
// Wait for prompt() to submit input, then echo it as the SDK would.
|
||||
async function* echoNextPrompt(input: Pushable<any>) {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(value);
|
||||
}
|
||||
|
||||
async function* unfinishedToolMessages(input: Pushable<any>, ending: "result" | "EOF") {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield {
|
||||
...toolStart(),
|
||||
event: { type: "content_block_stop", index: 0 },
|
||||
};
|
||||
// The input block closed, but no tool_result arrived. Returning ends the stream.
|
||||
if (ending === "result") yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* toolBeforeUserEchoMessages(input: Pushable<any>) {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield toolStart();
|
||||
yield userEcho(value);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* permissionOnlyToolMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
|
||||
yield* echoNextPrompt(input);
|
||||
await agent.canUseTool(sessionId)("Bash", { command: "echo test" }, {
|
||||
toolUseID: toolCallId,
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
} as any);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* completedToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield toolResult();
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* backgroundToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
session_id: sessionId,
|
||||
task_id: "background-shell",
|
||||
tool_use_id: toolCallId,
|
||||
description: "dev server",
|
||||
};
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* subagentToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart("child-tool", "parent-agent");
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* unconfirmedBackgroundToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield {
|
||||
type: "assistant",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage: successfulResultMessage().usage,
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "Bash",
|
||||
input: { command: "sleep 100", run_in_background: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* deferredSettlementMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
session_id: sessionId,
|
||||
task_id: "child",
|
||||
tool_use_id: "parent-agent",
|
||||
subagent_type: "Explore",
|
||||
description: "investigate",
|
||||
};
|
||||
// The result arrives while the subagent is still running, so settlement waits.
|
||||
yield successfulResultMessage();
|
||||
yield toolResult();
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
session_id: sessionId,
|
||||
task_id: "child",
|
||||
tool_use_id: "parent-agent",
|
||||
status: "completed",
|
||||
summary: "done",
|
||||
};
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
|
||||
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
|
||||
const messages = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield toolStart();
|
||||
yield toolStart("second-tool");
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
// Keep the SDK stream open for the next prompt in the same session.
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* cancelledThenSuccessfulTurnMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
|
||||
const messages = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield toolStart();
|
||||
await agent.cancel({ sessionId });
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* sdkFailureMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield successfulResultMessage({
|
||||
subtype: "error_during_execution",
|
||||
is_error: true,
|
||||
errors: ["original failure"],
|
||||
});
|
||||
}
|
||||
@@ -156,7 +156,7 @@ describe("versioned model display names", () => {
|
||||
// Account-dependent model availability requires the same authenticated environment
|
||||
// as the adapter. No prompt is sent: inspect only the SDK initialization response.
|
||||
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"the real SDK exposes one named Opus with the expected context suffix",
|
||||
"the real SDK exposes one plain Opus row",
|
||||
async () => {
|
||||
let finishInput!: () => void;
|
||||
const inputGate = new Promise<void>((resolve) => {
|
||||
@@ -178,12 +178,14 @@ it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
|
||||
);
|
||||
expect(opusModels).toHaveLength(1);
|
||||
// CLI 2.1.283 dropped the "[1m]" / "(1M context)" spelling from the Opus
|
||||
// row: Opus is natively 1M, so the row names the plain model.
|
||||
expect(opusModels[0]).toMatchObject({
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus (1M context)",
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-.*\[1m\]$/),
|
||||
description: expect.stringContaining("1M context"),
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-[\d-]+$/),
|
||||
});
|
||||
expect(opusModels[0].description).not.toMatch(/\b1m\b/i);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
finishInput();
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
matchResumedModel,
|
||||
} from "../session-model.js";
|
||||
import { settingsEffortForModel } from "../session-effort.js";
|
||||
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
@@ -234,6 +235,49 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
|
||||
it("drops an allowlisted model that deniedModels blocks, keeping Default", () => {
|
||||
const result = applyAvailableModelsAllowlist(
|
||||
LIVE_SHAPED_MODELS,
|
||||
["claude-sonnet-5", "claude-opus-5-5"],
|
||||
undefined,
|
||||
["claude-opus-5"],
|
||||
);
|
||||
expect(result.map((m) => m.value)).toEqual(["default", "claude-sonnet-5"]);
|
||||
});
|
||||
|
||||
it("checks an alias entry against the row it resolves to", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
|
||||
"claude-sonnet-5",
|
||||
]);
|
||||
expect(result.map((m) => m.value)).toEqual(["default"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeniedModel", () => {
|
||||
it("blocks every spelling of a denied version and its later minors", () => {
|
||||
const denied = ["claude-opus-5"];
|
||||
expect(isDeniedModel("claude-opus-5", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-20260101", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5[1m]", denied)).toBe(true);
|
||||
expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks only that version when the entry names a minor", () => {
|
||||
expect(isDeniedModel("claude-opus-5", ["claude-opus-5-5"])).toBe(false);
|
||||
expect(isDeniedModel("claude-opus-5-5-fast", ["claude-opus-5-5"])).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a whole family for a family alias and ignores settings-dependent aliases", () => {
|
||||
expect(isDeniedModel("claude-haiku-4-5-20251001", ["haiku"])).toBe(true);
|
||||
expect(isDeniedModel("haiku", ["haiku"])).toBe(true);
|
||||
expect(isDeniedModel("claude-sonnet-5", ["haiku"])).toBe(false);
|
||||
expect(isDeniedModel("claude-opus-5", ["default", "best", "opusplan"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Runs against the real SDK (no mocks) so the fixture above is flagged the
|
||||
// moment the SDK's actual model list shape drifts from it. Requires a usable
|
||||
// ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.
|
||||
@@ -325,3 +369,18 @@ describe("settingsEffortForModel", () => {
|
||||
expect(settingsEffortForModel({ modelSettings: {} }, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effortFlagSettings", () => {
|
||||
it("carries ultracode only when settings request it", () => {
|
||||
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
|
||||
effortLevel: "high",
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
|
||||
effortLevel: null,
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1155,13 +1155,11 @@ describe("session config options", () => {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
}
|
||||
|
||||
it("sets the window from text inference on model switch, without any getContextUsage IPC", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn, so the
|
||||
// switch path must never call it; the window is seeded from the text
|
||||
// heuristic (here via the new model's resolvedModel) and later confirmed
|
||||
// by result.modelUsage.
|
||||
it("seeds the window from text inference on model switch", async () => {
|
||||
// The window is seeded synchronously from the text heuristic (here via
|
||||
// the new model's resolvedModel); the background refresh never answers.
|
||||
const session = getSession();
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.query.getContextUsage = vi.fn(() => new Promise<never>(() => {}));
|
||||
session.modelInfos = session.modelInfos.map((m: ModelInfo) =>
|
||||
m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m,
|
||||
);
|
||||
@@ -1172,15 +1170,17 @@ describe("session config options", () => {
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(1_000_000);
|
||||
expect(session.contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the default window when inference misses, without any getContextUsage IPC", async () => {
|
||||
it("falls back to the default window when inference misses, then refines it in the background", async () => {
|
||||
const session = getSession();
|
||||
session.contextWindowSize = 1_000_000;
|
||||
// Present but must NOT be called; the switch never consults it.
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
let answer!: (usage: { rawMaxTokens: number }) => void;
|
||||
session.query.getContextUsage = vi.fn(
|
||||
() => new Promise<{ rawMaxTokens: number }>((resolve) => (answer = resolve)),
|
||||
);
|
||||
// claude-sonnet-4-6 carries no "1m" token in its id, resolvedModel,
|
||||
// displayName, or description, so inference misses → default window.
|
||||
|
||||
@@ -1190,22 +1190,40 @@ describe("session config options", () => {
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
// The switch resolved without waiting for getContextUsage.
|
||||
expect(session.query.getContextUsage).toHaveBeenCalledOnce();
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
|
||||
answer({ rawMaxTokens: 967000 });
|
||||
await vi.waitFor(() => expect(session.contextWindowSize).toBe(967000));
|
||||
expect(session.contextWindowAuthoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("does not call getContextUsage even when switching to a fresh model", async () => {
|
||||
it("drops a background answer that arrives after another switch", async () => {
|
||||
const session = getSession();
|
||||
const spy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.query.getContextUsage = spy;
|
||||
let answer!: (usage: { rawMaxTokens: number }) => void;
|
||||
session.query.getContextUsage = vi.fn(
|
||||
() => new Promise<{ rawMaxTokens: number }>((resolve) => (answer = resolve)),
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
const staleAnswer = answer;
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
const windowAfterSecondSwitch = session.contextWindowSize;
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
staleAnswer({ rawMaxTokens: 967000 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(session.contextWindowSize).toBe(windowAfterSecondSwitch);
|
||||
expect(session.contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the learned window when re-asserting the current model", async () => {
|
||||
|
||||
400
src/tests/turn-events.test.ts
Normal file
400
src/tests/turn-events.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* The turn lifecycle that the agent reports through `TurnEvents`, which each
|
||||
* ACP version maps to its own messages. A scripted SDK query stands in for
|
||||
* Claude Code: it echoes each prompt, then runs the turn's script.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { TurnEvents } from "../turn-events.js";
|
||||
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const mockQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
// The agent reads the CLI's auth status on each prompt; keep it off the real CLI.
|
||||
vi.mock("node:child_process", async () => ({
|
||||
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
|
||||
execFile: (...args: unknown[]) =>
|
||||
(args[args.length - 1] as (error: Error) => void)(new Error("no CLI in tests")),
|
||||
}));
|
||||
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
type QueryOptions = {
|
||||
sessionId?: string;
|
||||
canUseTool: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
extra: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/** The SDK side of one turn, after the echo of its prompt. */
|
||||
type TurnScript = (options: QueryOptions) => AsyncGenerator<Record<string, unknown>>;
|
||||
|
||||
function result(sessionId: string) {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "done",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Scripts the query of the next session: each prompt runs the next turn script. */
|
||||
function scriptTurns(turns: TurnScript[], end: "stay open" | "end stream" = "stay open") {
|
||||
const promptUuids: string[] = [];
|
||||
mockQuery.mockImplementation(
|
||||
({ prompt, options }: { prompt: AsyncIterable<any>; options: QueryOptions }) => {
|
||||
const sessionId = options.sessionId ?? "session";
|
||||
async function* run() {
|
||||
const input = prompt[Symbol.asyncIterator]();
|
||||
for (const turn of turns) {
|
||||
const { value, done } = await input.next();
|
||||
if (done) return;
|
||||
promptUuids.push(value.uuid);
|
||||
yield {
|
||||
type: "user",
|
||||
message: value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value.uuid,
|
||||
session_id: sessionId,
|
||||
isReplay: true,
|
||||
};
|
||||
for await (const message of turn(options)) yield message;
|
||||
}
|
||||
if (end === "stay open") await input.next();
|
||||
}
|
||||
return Object.assign(run(), {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "default", displayName: "Default", description: "" }],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
close: () => {},
|
||||
interrupt: async () => {},
|
||||
stopTask: async () => {},
|
||||
});
|
||||
},
|
||||
);
|
||||
return promptUuids;
|
||||
}
|
||||
|
||||
/** Records the events of one turn as readable strings. */
|
||||
function recordEvents() {
|
||||
const log: string[] = [];
|
||||
const done = Promise.withResolvers<void>();
|
||||
const events: TurnEvents = {
|
||||
inserted: (messageId) => log.push(`inserted ${messageId}`),
|
||||
awaitingUser: () => log.push("awaitingUser"),
|
||||
resumed: () => log.push("resumed"),
|
||||
ended: (outcome) => {
|
||||
log.push(`ended ${outcome.stopReason}`);
|
||||
done.resolve();
|
||||
},
|
||||
failed: (error) => {
|
||||
log.push(`failed ${error instanceof Error ? error.message : String(error)}`);
|
||||
done.resolve();
|
||||
},
|
||||
};
|
||||
return { log, events, done: done.promise };
|
||||
}
|
||||
|
||||
describe("turn events", () => {
|
||||
let agent: ClaudeAcpAgent;
|
||||
let permissionRequests: RequestPermissionRequest[];
|
||||
/** The user answers a request for this tool call only once it resolves. */
|
||||
let backgroundAnswer: PromiseWithResolvers<void>;
|
||||
/** Sees each session update; the agent waits until it returns. */
|
||||
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
permissionRequests = [];
|
||||
backgroundAnswer = Promise.withResolvers<void>();
|
||||
onSessionUpdate = () => {};
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
|
||||
extNotification: async () => {},
|
||||
requestPermission: async (request: RequestPermissionRequest) => {
|
||||
permissionRequests.push(request);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
|
||||
const allow = request.options.find((option) => option.kind === "allow_once");
|
||||
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
|
||||
},
|
||||
} as unknown as AcpClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
async function newSession() {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const prompt = (sessionId: string, text: string) => ({
|
||||
sessionId,
|
||||
prompt: [{ type: "text" as const, text }],
|
||||
});
|
||||
|
||||
it("reports a turn as inserted under the uuid of its user message, then ended", async () => {
|
||||
const promptUuids = scriptTurns([
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "hello"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(turn.log).toEqual([`inserted ${promptUuids[0]}`, "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("reports waiting on a permission request, then resuming", async () => {
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
await options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
|
||||
);
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(permissionRequests).toHaveLength(1);
|
||||
expect(turn.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
|
||||
const first = recordEvents();
|
||||
const second = recordEvents();
|
||||
let backgroundRequest: Promise<unknown> | undefined;
|
||||
let atStart: string[] = [];
|
||||
let afterOwnAnswer: string[] | undefined;
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
await first.done;
|
||||
// A request that no active turn opened, such as a background task's.
|
||||
backgroundRequest = options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "sleep 60" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
|
||||
);
|
||||
},
|
||||
async function* (options) {
|
||||
atStart = [...second.log];
|
||||
await options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
|
||||
);
|
||||
afterOwnAnswer = [...second.log];
|
||||
await backgroundRequest;
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "start a background task"), first.events);
|
||||
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "list files"), second.events);
|
||||
await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined());
|
||||
backgroundAnswer.resolve();
|
||||
await second.done;
|
||||
|
||||
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
|
||||
// Waiting from insertion, through its own answered request, until the
|
||||
// earlier request is answered too.
|
||||
expect(atStart.slice(1)).toEqual(["awaitingUser"]);
|
||||
expect(afterOwnAnswer!.slice(1)).toEqual(["awaitingUser"]);
|
||||
expect(second.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {
|
||||
const release = Promise.withResolvers<void>();
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
// Claude Code is busy with the first prompt when the cancel arrives.
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_working",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "default",
|
||||
content: [{ type: "text", text: "Working on it" }],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
await release.promise;
|
||||
},
|
||||
]);
|
||||
agent.forceCancelGraceMs = 10;
|
||||
const sessionId = await newSession();
|
||||
const first = recordEvents();
|
||||
const queued = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "first"), first.events);
|
||||
await vi.waitFor(() => expect(first.log).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "second"), queued.events);
|
||||
await agent.cancel({ sessionId });
|
||||
await Promise.all([first.done, queued.done]);
|
||||
release.resolve();
|
||||
|
||||
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended cancelled"]);
|
||||
expect(queued.log).toEqual(["ended cancelled"]);
|
||||
});
|
||||
|
||||
it("does not insert a turn that a cancel ended while the previous turn was handed off", async () => {
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
// The turn ends with a tool call that never got its result.
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_tool",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "default",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_open", name: "Bash", input: { command: "ls" } },
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
},
|
||||
async function* () {},
|
||||
]);
|
||||
agent.forceCancelGraceMs = 10;
|
||||
// The hand-off to the second turn fails the open tool call; hold that update.
|
||||
const failedToolCall = Promise.withResolvers<void>();
|
||||
const releaseUpdate = Promise.withResolvers<void>();
|
||||
onSessionUpdate = async ({ update }) => {
|
||||
if (update.sessionUpdate === "tool_call_update" && update.status === "failed") {
|
||||
failedToolCall.resolve();
|
||||
await releaseUpdate.promise;
|
||||
}
|
||||
};
|
||||
const sessionId = await newSession();
|
||||
const first = recordEvents();
|
||||
const second = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "first"), first.events);
|
||||
await vi.waitFor(() => expect(first.log).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "second"), second.events);
|
||||
await failedToolCall.promise;
|
||||
const cancelling = agent.cancel({ sessionId });
|
||||
await vi.waitFor(() => expect(second.log).toEqual(["ended cancelled"]));
|
||||
releaseUpdate.resolve();
|
||||
await Promise.all([cancelling, first.done]);
|
||||
// The hand-off then activates the second turn in promise continuations,
|
||||
// which all run before a timer.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(second.log).toEqual(["ended cancelled"]);
|
||||
});
|
||||
|
||||
it("fails a queued turn without inserting it when the query ends", async () => {
|
||||
scriptTurns(
|
||||
[
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
],
|
||||
"end stream",
|
||||
);
|
||||
const sessionId = await newSession();
|
||||
const first = recordEvents();
|
||||
const queued = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "first"), first.events);
|
||||
await agent.startTurn(prompt(sessionId, "second"), queued.events);
|
||||
await Promise.all([first.done, queued.done]);
|
||||
|
||||
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended end_turn"]);
|
||||
expect(queued.log).toEqual([expect.stringMatching(/^failed /)]);
|
||||
});
|
||||
|
||||
it("rejects a prompt that it refuses before starting a turn, and reports nothing", async () => {
|
||||
const turn = recordEvents();
|
||||
|
||||
await expect(agent.startTurn(prompt("no-such-session", "hello"), turn.events)).rejects.toThrow(
|
||||
"Session not found",
|
||||
);
|
||||
|
||||
expect(turn.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
67
src/turn-events.ts
Normal file
67
src/turn-events.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* A prompt turn as the adapter understands it, independent of the ACP version.
|
||||
*
|
||||
* The agent reports each turn through {@link TurnEvents}, and each ACP version
|
||||
* maps the events to its own messages. ACP v1 answers `session/prompt` when the
|
||||
* turn ends or fails. ACP v2 answers it when the prompt is inserted, and
|
||||
* reports the rest as session state.
|
||||
*
|
||||
* The agent calls these synchronously while it updates its own state, so a
|
||||
* handler must not throw, nor call back into the agent before it returns.
|
||||
*/
|
||||
export interface TurnEvents {
|
||||
/**
|
||||
* Claude Code took the prompt in as the user message `messageId` of the
|
||||
* conversation. Reported at most once per turn, and never after
|
||||
* {@link ended} or {@link failed}. For a prompt Claude Code echoes, that is
|
||||
* the echo; for a command it does not echo (such as `/compact`), it is the
|
||||
* command's result, after any output the command streamed.
|
||||
*/
|
||||
inserted(messageId: string): void;
|
||||
/**
|
||||
* The turn waits for the user: a permission request or a question is open in
|
||||
* the session. Requests do not say which prompt they are for, so this
|
||||
* includes one opened before the turn was inserted. Only reported after
|
||||
* {@link inserted}.
|
||||
*/
|
||||
awaitingUser(): void;
|
||||
/**
|
||||
* The turn works again: no request is open any more. A turn that ends or
|
||||
* fails while it waits reports that instead.
|
||||
*/
|
||||
resumed(): void;
|
||||
/**
|
||||
* The turn ended. A queued turn that is cancelled before it is inserted
|
||||
* ends too, without {@link inserted}; Claude Code may still run its prompt.
|
||||
*/
|
||||
ended(outcome: TurnOutcome): void;
|
||||
/**
|
||||
* The turn failed. Without {@link inserted} before, Claude Code was not seen
|
||||
* taking the prompt in.
|
||||
*/
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
|
||||
/** Why a turn ended. Every ACP version has these reasons. */
|
||||
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
|
||||
|
||||
/** How a turn ended. */
|
||||
export type TurnOutcome = {
|
||||
stopReason: StopReason;
|
||||
/** The tokens that the main agent loop of the turn used. */
|
||||
usage?: TurnUsage;
|
||||
/**
|
||||
* Extension data for the client, such as the quota breakdown of the turn
|
||||
* and a terminal session failure.
|
||||
*/
|
||||
_meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TurnUsage = {
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens?: number;
|
||||
cachedReadTokens?: number;
|
||||
cachedWriteTokens?: number;
|
||||
};
|
||||
132
src/v2/agent.ts
Normal file
132
src/v2/agent.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The experimental draft ACP v2 surface of the adapter.
|
||||
*
|
||||
* `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2
|
||||
* request into the v1 request that the agent serves, and each v1 message that
|
||||
* the agent sends into its v2 form, so v1 and v2 share one implementation.
|
||||
*/
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
|
||||
import {
|
||||
v1NewSessionRequest,
|
||||
v1ResumeSessionRequest,
|
||||
v1SetSessionConfigOptionRequest,
|
||||
v2ConfigOptions,
|
||||
v2NewSessionResponse,
|
||||
v2ResumeSessionResponse,
|
||||
} from "./session.js";
|
||||
import { v2SessionUpdate } from "./session-update.js";
|
||||
|
||||
/**
|
||||
* The ACP v2 surface, for one connection.
|
||||
*
|
||||
* As in `v1AgentApp`, the agent of the connection is created when the
|
||||
* connection opens, before the connection processes any inbound message.
|
||||
* `onAgent` receives it for the owner of the process (shutdown).
|
||||
*/
|
||||
export function v2AgentApp(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentApp {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
return v2
|
||||
.agent({ name: "claude-code-acp" })
|
||||
.onConnect((connection) => {
|
||||
agent = new ClaudeAcpAgent(new V2ClientConnection(connection.client), logger);
|
||||
onAgent(agent);
|
||||
})
|
||||
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
|
||||
v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))),
|
||||
)
|
||||
.onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params))
|
||||
.onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params))
|
||||
.onRequest(v2.methods.agent.session.new, async ({ params }) =>
|
||||
v2NewSessionResponse(await agent.newSession(v1NewSessionRequest(params))),
|
||||
)
|
||||
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
|
||||
.onRequest(v2.methods.agent.session.resume, async ({ params }) =>
|
||||
v2ResumeSessionResponse(await agent.resumeSession(v1ResumeSessionRequest(params))),
|
||||
)
|
||||
.onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params))
|
||||
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
|
||||
.onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => {
|
||||
const { configOptions, ...response } = await agent.setSessionConfigOption(
|
||||
v1SetSessionConfigOptionRequest(params),
|
||||
);
|
||||
return { ...response, configOptions: v2ConfigOptions(configOptions) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of
|
||||
* the agent as v2 messages.
|
||||
*
|
||||
* The v2 surface does not serve prompts yet, so permission requests, which
|
||||
* only a prompt turn makes, still reject.
|
||||
*/
|
||||
class V2ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: v2.AgentContext) {}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
if (!isExtensionMethod(method)) {
|
||||
return Promise.reject(new Error(`${method} is not an ACP extension method`));
|
||||
}
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
|
||||
async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise<void> {
|
||||
const v2Update = v2SessionUpdate(update);
|
||||
if (v2Update) {
|
||||
await this.ctx.notify(v2.methods.client.session.update, {
|
||||
...notification,
|
||||
update: v2Update,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestPermission(): Promise<never> {
|
||||
return Promise.reject(
|
||||
new Error("The ACP v2 surface does not serve session/request_permission yet"),
|
||||
);
|
||||
}
|
||||
|
||||
// Elicitation is the same in v1 and v2.
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.ctx.request(
|
||||
v2.methods.client.elicitation.create,
|
||||
// The v1 types accept a property schema with any tag; the v2 types accept
|
||||
// one only in a received value. As on v1, an MCP server's schema is
|
||||
// relayed here unchanged, so this cast does not check it.
|
||||
params as v2.CreateElicitationRequest,
|
||||
{ cancellationSignal: signal },
|
||||
);
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
// v2 has no client file system. The agent never calls these on v2, because
|
||||
// `v1InitializeRequest` reports no `fs` capability.
|
||||
readTextFile(): Promise<never> {
|
||||
return Promise.reject(new Error("ACP v2 has no client file system"));
|
||||
}
|
||||
|
||||
writeTextFile(): Promise<never> {
|
||||
return Promise.reject(new Error("ACP v2 has no client file system"));
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionMethod(method: string): method is v2.ExtensionMethod {
|
||||
return method.startsWith("_");
|
||||
}
|
||||
82
src/v2/initialize.ts
Normal file
82
src/v2/initialize.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The ACP v2 `initialize` handshake, expressed through the v1 handshake of
|
||||
* `ClaudeAcpAgent`.
|
||||
*
|
||||
* The agent decides its capabilities and auth methods in v1 terms. The v2
|
||||
* request becomes the v1 request that the agent reads, and the v1 response of
|
||||
* the agent becomes the v2 response, so both protocol versions advertise from
|
||||
* one source.
|
||||
*/
|
||||
import {
|
||||
PROTOCOL_VERSION as V1_PROTOCOL_VERSION,
|
||||
type AuthMethod,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
|
||||
/** The v1 `initialize` request that a v2 client means. */
|
||||
export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest {
|
||||
const capabilities = request.capabilities;
|
||||
return {
|
||||
protocolVersion: V1_PROTOCOL_VERSION,
|
||||
clientInfo: request.info,
|
||||
clientCapabilities: {
|
||||
// v2 has no client file system and no client terminals: the agent
|
||||
// reads, writes, and runs commands itself.
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
terminal: false,
|
||||
// Every v2 client handles boolean config options and notices; v1 asks
|
||||
// for a marker. With notices, the agent reports advisories as notices
|
||||
// rather than as transcript messages.
|
||||
session: { configOptions: { boolean: {} }, notices: {} },
|
||||
// v1 marks terminal auth support with a boolean, v2 with an object.
|
||||
auth: {
|
||||
terminal: capabilities?.auth?.terminal != null,
|
||||
...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}),
|
||||
},
|
||||
...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}),
|
||||
...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}),
|
||||
},
|
||||
...(request._meta != null ? { _meta: request._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v2 `initialize` response for the v1 response of the agent.
|
||||
*
|
||||
* It advertises no `session` capability yet. A v2 agent that advertises
|
||||
* `session` must serve the whole session baseline (`session/new`, `list`,
|
||||
* `resume`, `close`, `prompt`, `cancel`, and `update`), and the v2 surface of
|
||||
* the adapter does not serve it yet. Nor does it advertise the unstable
|
||||
* `providers` methods, which the v2 surface does not serve yet either.
|
||||
*/
|
||||
export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse {
|
||||
if (!response.agentInfo) {
|
||||
throw new Error("ACP v2 requires agentInfo, and the agent reported none");
|
||||
}
|
||||
const meta = response.agentCapabilities?._meta;
|
||||
return {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: response.agentInfo,
|
||||
// v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth
|
||||
// methods must serve `auth/logout`.
|
||||
capabilities: meta != null ? { _meta: meta } : {},
|
||||
authMethods: (response.authMethods ?? []).map(v2AuthMethod),
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */
|
||||
function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
|
||||
if ("type" in method && method.type === "terminal") {
|
||||
const { id, env, ...terminal } = method;
|
||||
return {
|
||||
...terminal,
|
||||
methodId: id,
|
||||
...(env ? { env: Object.entries(env).map(([name, value]) => ({ name, value })) } : {}),
|
||||
};
|
||||
}
|
||||
const { id, ...agent } = method;
|
||||
return { ...agent, methodId: id, type: "agent" };
|
||||
}
|
||||
38
src/v2/session-update.ts
Normal file
38
src/v2/session-update.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The v2 form of the session updates that `ClaudeAcpAgent` sends as v1
|
||||
* updates.
|
||||
*/
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
|
||||
|
||||
/**
|
||||
* The v2 form of a v1 session update, or `undefined` when v2 has no
|
||||
* counterpart and the update tells a v2 client nothing new.
|
||||
*
|
||||
* Update kinds that the v2 surface does not translate yet throw, so a gap is
|
||||
* an error rather than a silently missing update.
|
||||
*/
|
||||
export function v2SessionUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
): v2.SessionUpdate | undefined {
|
||||
switch (update.sessionUpdate) {
|
||||
case "available_commands_update":
|
||||
return { ...update, availableCommands: v2AvailableCommands(update.availableCommands) };
|
||||
case "config_option_update":
|
||||
return { ...update, configOptions: v2ConfigOptions(update.configOptions) };
|
||||
case "session_info_update":
|
||||
case "usage_update":
|
||||
case "notice":
|
||||
return update;
|
||||
case "current_mode_update":
|
||||
// v2 has no modes: the mode is the `mode` config option. Every mode
|
||||
// change also reaches the client as a `config_option_update` or in the
|
||||
// `configOptions` of a `session/set_config_option` response.
|
||||
return undefined;
|
||||
default:
|
||||
throw new Error(
|
||||
`The ACP v2 surface does not translate ${update.sessionUpdate} session updates yet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
131
src/v2/session.ts
Normal file
131
src/v2/session.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The ACP v2 session lifecycle, expressed through the v1 session methods of
|
||||
* `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves,
|
||||
* and its v1 responses become v2 responses.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type AvailableCommand,
|
||||
type McpServer,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type ResumeSessionRequest,
|
||||
type ResumeSessionResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionConfigSelectGroup,
|
||||
type SessionConfigSelectOptions,
|
||||
type SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
|
||||
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
|
||||
const { mcpServers, ...request } = params;
|
||||
// v1 requires the list; v2 treats an omitted list as an empty one.
|
||||
return { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `replayFrom` is rejected until the v2 surface can translate the replayed
|
||||
* history, which needs the message and tool call updates.
|
||||
*/
|
||||
export function v1ResumeSessionRequest(params: v2.ResumeSessionRequest): ResumeSessionRequest {
|
||||
const { replayFrom, mcpServers, ...request } = params;
|
||||
if (replayFrom != null) {
|
||||
throw RequestError.invalidParams(
|
||||
{ replayFrom },
|
||||
"The ACP v2 surface does not replay session history yet",
|
||||
);
|
||||
}
|
||||
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
|
||||
}
|
||||
|
||||
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
|
||||
export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse {
|
||||
return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) };
|
||||
}
|
||||
|
||||
export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse {
|
||||
return {
|
||||
...v2ConfigOptionsField(response.configOptions),
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
|
||||
export function v1SetSessionConfigOptionRequest(
|
||||
params: v2.SetSessionConfigOptionRequest,
|
||||
): SetSessionConfigOptionRequest {
|
||||
const request = {
|
||||
sessionId: params.sessionId,
|
||||
configId: params.configId,
|
||||
...(params._meta != null ? { _meta: params._meta } : {}),
|
||||
};
|
||||
if (v2.SetSessionConfigOptionRequest.isId(params)) {
|
||||
return { ...request, value: params.value };
|
||||
}
|
||||
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
|
||||
return { ...request, type: "boolean", value: params.value };
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
{ type: params.type },
|
||||
`Config option values of type ${params.type} are not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
|
||||
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
|
||||
return options.map((option) => {
|
||||
const { id, ...rest } = option;
|
||||
if (rest.type === "select") {
|
||||
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
|
||||
}
|
||||
return { ...rest, configId: id };
|
||||
});
|
||||
}
|
||||
|
||||
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
|
||||
if (!isGrouped(options)) return options;
|
||||
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
|
||||
}
|
||||
|
||||
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
|
||||
return options.some((entry) => "group" in entry);
|
||||
}
|
||||
|
||||
/** v2 tags the free-text input of a command with `type: "text"`. */
|
||||
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
|
||||
return commands.map(({ input, ...command }) => ({
|
||||
...command,
|
||||
...(input != null ? { input: { type: "text", ...input } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** v1 responses may send `null`; v2 omits the field instead. */
|
||||
function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): {
|
||||
configOptions?: v2.SessionConfigOption[];
|
||||
} {
|
||||
return options != null ? { configOptions: v2ConfigOptions(options) } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 requires a `type` on every transport and makes the empty lists optional.
|
||||
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
|
||||
* is dropped. A transport that v1 cannot express is rejected rather than
|
||||
* silently left out of the session.
|
||||
*/
|
||||
function v1McpServer(server: v2.McpServer): McpServer {
|
||||
if (v2.McpServer.isStdio(server)) {
|
||||
const { name, command, args, env, _meta } = server;
|
||||
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
|
||||
}
|
||||
if (v2.McpServer.isHttp(server)) {
|
||||
return { ...server, headers: server.headers ?? [] };
|
||||
}
|
||||
if (v2.McpServer.isAcp(server)) {
|
||||
return server;
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
{ type: server.type },
|
||||
`MCP servers of type ${server.type} are not supported`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user