Compare commits

...

2 Commits

Author SHA1 Message Date
Ben Brandt
1d2b92feae fix: update to @agentclientprotocol/sdk 2.2.0 (#1491)
Moves to ACP schema v1.28.0 and v2.0.0-alpha.11.

- v2 dropped JSON-RPC batching, so `AnyWireMessage` is gone. The test
harnesses use `AnyMessage`, and a request id can now be `null` in the
types.
- The v2 elicitation schema now requires `properties`. A forwarded MCP
schema without them, such as `{ "type": "object" }`, gets an empty map,
so clients on SDK 2.2.0 don't reject it.

Runtime changes that come with the SDK and need no agent code:
- The SDK handles a repeated `initialize`, rejects batches, and rejects
messages sent before initialization. A failed `initialize` now leaves
the connection open.
- v2 `usage_update.cost` is now a patch field, where omitted means
unchanged. The agent sends `cost` only on the result update, so the
client keeps showing the last cost during a turn.

Follow-up PRs for the spec changes in this range are stacked on this
branch.
2026-10-30 14:39:09 +00:00
xiaofeilovecoding
99a6e76158 fix: reject unreadable skill manifests (#1482)
Fixes #1478.

Skip SKILL.md candidates when opening or reading the file fails, while
preserving readable unnamed manifests. Do not cache failed reads so
later resolutions can recover.

Adds nine resolver regressions for open/read errors, descriptor cleanup,
recovery, and valid alternatives.

Validation (Windows, Node 24.18.0):
- Nine new tests, TypeScript build, and ESLint pass.
- Full suite: 3,354 passed, 57 failed, 52 skipped. The same 57 failures
reproduce on unmodified 52c241e in the affected files.
- npm run check fails only on unchanged CLAUDE.md: Windows checked out
the symlink as a plain target file. The baseline has the same issue.

Co-authored-by: xiaofeilovecoding <fxy850328059@gmail.com>
2026-10-30 13:57:42 +01:00
9 changed files with 148 additions and 27 deletions

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.99.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.1.0",
"@agentclientprotocol/sdk": "2.2.0",
"@anthropic-ai/claude-agent-sdk": "0.3.312",
"diff": "9.0.0",
"zod": "4.6.7"
@@ -38,9 +38,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.1.0.tgz",
"integrity": "sha512-IuKjJmHLPzbmbg26eK9Jopd8AVxgLuu8fX114YzYAU6tLYclCo2DUzLL4vbCpYGr9mzvs2T0kigE3ZCueL06tg==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.2.0.tgz",
"integrity": "sha512-o1+eDQVb05yzPY+5v54awHyONU1oeLsbLVavkwrMQaZDuJDh/38pQiXJS4ix4d6QA3Y4bU0lHFRjwg99KKKSaQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@modelcontextprotocol/server": "^2.4.0",

View File

@@ -65,7 +65,7 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "2.1.0",
"@agentclientprotocol/sdk": "2.2.0",
"@anthropic-ai/claude-agent-sdk": "0.3.312",
"diff": "9.0.0",
"zod": "4.6.7"

View File

@@ -377,7 +377,9 @@ export function applyAskElicitationResponse(
/**
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
* The two are structurally compatible JSON Schemas; we just guarantee the
* `type: "object"` discriminator is present.
* `type: "object"` discriminator and a `properties` map are present. The v2
* schema requires both, so a client on SDK 2.2.0 or later rejects an MCP
* schema such as `{ type: "object" }` that has no `properties`.
*/
function normalizeElicitationSchema(
schema: Record<string, unknown> | undefined,
@@ -385,7 +387,8 @@ function normalizeElicitationSchema(
if (!schema || typeof schema !== "object") {
return { type: "object", properties: {} };
}
return { ...(schema as ElicitationSchema), type: "object" };
const { properties } = schema as Partial<ElicitationSchema>;
return { ...(schema as ElicitationSchema), type: "object", properties: properties ?? {} };
}
/**

View File

@@ -500,21 +500,17 @@ export async function runScenarioV2(
const idles: unknown[] = [];
const failures: string[] = [];
const each = (message: v2.AnyWireMessage) =>
(Array.isArray(message) ? message : [message]) as Record<string, any>[];
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>({
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>({
transform(message, controller) {
for (const m of each(message)) {
if ("method" in m && "id" in m) methodsOfClientRequests.set(m.id, m.method);
if ("method" in message && "id" in message && message.id !== null) {
methodsOfClientRequests.set(message.id, message.method);
}
controller.enqueue(message);
},
});
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>({
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>({
transform(message, controller) {
for (const m of each(message)) {
if (recording) raw.push(wireRecord(m, methodsOfClientRequests));
}
if (recording) raw.push(wireRecord(message, methodsOfClientRequests));
controller.enqueue(message);
},
});

View File

@@ -162,8 +162,8 @@ afterEach(async () => {
/** Connects a fresh router to a client, and returns the client end of the stream. */
function connectRouter(protocols?: AcpProtocols): v2.Stream {
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
acpProtocolRouter(undefined, (agent) => agents.push(agent), protocols).connect({
readable: toAgent.readable,
writable: toClient.writable,
@@ -333,11 +333,11 @@ describe("ACP protocol routing", () => {
params: { protocolVersion: v2.PROTOCOL_VERSION, info: V2_CLIENT_INFO, capabilities: {} },
});
const reader = stream.readable.getReader();
let response: v2.AnyWireMessage | undefined;
let response: v2.AnyMessage | undefined;
while (!response) {
const { value, done } = await reader.read();
if (done) break;
if (!Array.isArray(value) && "id" in value && value.id === 1) response = value;
if ("id" in value && value.id === 1) response = value;
}
await writer.close();

View File

@@ -60,6 +60,19 @@ describe("mcpElicitationToCreateRequest", () => {
});
});
it("adds an empty properties map when the MCP schema has none", () => {
const request: ElicitationRequest = {
serverName: "server",
message: "Confirm?",
mode: "form",
requestedSchema: { type: "object" },
};
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
expect(result).toMatchObject({ requestedSchema: { type: "object", properties: {} } });
});
it("maps a url-mode request and preserves the elicitation id", () => {
const request: ElicitationRequest = {
serverName: "server",

View File

@@ -750,8 +750,8 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
it("publishes the restored configuration through the v2 protocol router", async () => {
await agent.dispose();
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
readable: toAgent.readable,
writable: toClient.writable,

View File

@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
closeSync,
mkdirSync,
mkdtempSync,
openSync,
readSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveSkillPath } from "../tool-calls/reporters/interaction.js";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
openSync: vi.fn(actual.openSync),
readSync: vi.fn(actual.readSync),
closeSync: vi.fn(actual.closeSync),
};
});
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
describe("skill manifest read errors", () => {
let root: string;
beforeEach(() => {
vi.mocked(openSync).mockReset().mockImplementation(actualFs.openSync);
vi.mocked(readSync).mockReset().mockImplementation(actualFs.readSync);
vi.mocked(closeSync).mockReset().mockImplementation(actualFs.closeSync);
root = mkdtempSync(path.join(os.tmpdir(), "acp-skill-read-"));
vi.spyOn(os, "homedir").mockReturnValue(path.join(root, "home"));
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
const writeSkill = (directory: string, content: string) => {
const file = path.join(root, directory, "SKILL.md");
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, content);
return file;
};
it.each(["EACCES", "ENOENT"])("rejects a manifest when opening it fails with %s", (code) => {
writeSkill(".claude/skills/review", "---\nname: another-skill\n---\n");
vi.mocked(openSync).mockImplementation(() => {
throw Object.assign(new Error("Cannot open fixture manifest"), { code });
});
expect(resolveSkillPath("review", root)).toBeUndefined();
});
it("rejects a manifest when reading it fails and closes the descriptor", () => {
writeSkill(".claude/skills/review", "---\nname: another-skill\n---\n");
vi.mocked(readSync).mockImplementation(() => {
throw Object.assign(new Error("Cannot read fixture manifest"), { code: "EIO" });
});
expect(resolveSkillPath("review", root)).toBeUndefined();
expect(closeSync).toHaveBeenCalled();
expect(vi.mocked(closeSync).mock.calls.length).toBe(vi.mocked(openSync).mock.calls.length);
});
it("retries a failed read even when the manifest's stat signature is unchanged", () => {
const file = writeSkill(".claude/skills/review", "# Skill without a declared name\n");
const before = statSync(file);
vi.mocked(readSync).mockImplementation(() => {
throw Object.assign(new Error("Temporary read failure"), { code: "EIO" });
});
expect(resolveSkillPath("review", root)).toBeUndefined();
vi.mocked(readSync).mockImplementation(actualFs.readSync);
expect(statSync(file)).toMatchObject({ mtimeMs: before.mtimeMs, ctimeMs: before.ctimeMs });
expect(resolveSkillPath("review", root)).toBe(file);
});
it("continues to a readable skill declaring the requested name", () => {
const unreadable = writeSkill(".claude/skills/review", "---\nname: another-skill\n---\n");
const readable = writeSkill(".agents/skills/other", "---\nname: review\n---\n");
vi.mocked(openSync).mockImplementation((file, flags, mode) => {
if (file === unreadable) {
throw Object.assign(new Error("Cannot open fixture manifest"), { code: "EACCES" });
}
return actualFs.openSync(file, flags, mode);
});
expect(resolveSkillPath("review", root)).toBe(readable);
});
it.each(["", "# Skill\n", "---\ndescription: review\n---\n", "---\nname: review\n---\n"])(
"keeps resolving a readable manifest with content %j",
(content) => {
const file = writeSkill(".claude/skills/review", content);
expect(resolveSkillPath("review", root)).toBe(file);
},
);
});

View File

@@ -293,14 +293,15 @@ function frontmatterScalar(raw: string): string {
}
/**
* The `name:` that the frontmatter of [skillMdPath] declares, or `undefined` when it declares none.
* The `name:` that the frontmatter of [skillMdPath] declares, `undefined` when it declares none,
* or `null` when the file could not be read.
*
* Only the frontmatter block is parsed, so a `name:` further down the body is not a name.
*/
function declaredSkillName(skillMdPath: string): string | undefined {
function declaredSkillName(skillMdPath: string): string | null | undefined {
const head = frontmatterHead(skillMdPath);
if (head === undefined) {
return undefined;
return null;
}
const lines = head.split(/\r?\n/);
if (lines[0]?.trim() !== "---") {
@@ -333,13 +334,17 @@ const scannedSkills = new Map<string, ScannedSkill>();
* old modification time — `git checkout`, `cp -p`, a filesystem whose times have a one-second resolution —
* still moves the change time, and a name that silently stayed behind resolves to the wrong skill forever.
*/
function cachedDeclaredName(skillMdPath: string, stats: Stats): string | undefined {
function cachedDeclaredName(skillMdPath: string, stats: Stats): string | null | undefined {
const signature = `${stats.mtimeMs}:${stats.ctimeMs}:${stats.size}:${stats.ino}:${stats.dev}`;
const cached = scannedSkills.get(skillMdPath);
if (cached?.signature === signature) {
return cached.declaredName;
}
const declaredName = declaredSkillName(skillMdPath);
// A transient read error can clear without changing the stat signature, so the next pass must retry it.
if (declaredName === null) {
return null;
}
if (scannedSkills.size >= MAX_CACHED_SKILLS) {
scannedSkills.clear();
}
@@ -382,7 +387,7 @@ function skillsByDeclaredName(containerDir: string, scans?: SkillScanCache): Map
continue;
}
const name = cachedDeclaredName(file, stats);
if (name !== undefined && !byName.has(name)) {
if (typeof name === "string" && !byName.has(name)) {
byName.set(name, file);
}
}