mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 13:01:20 +00:00
Compare commits
5 Commits
v0.98.1-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ee30813f | ||
|
|
54b35ee0f4 | ||
|
|
93c1f43519 | ||
|
|
f646c821f1 | ||
|
|
54b869c990 |
126
src/acp-agent.ts
126
src/acp-agent.ts
@@ -85,6 +85,7 @@ import {
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKSystemMessage,
|
||||
SessionMessage,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
@@ -104,6 +105,7 @@ import {
|
||||
parseGoalRequest,
|
||||
toGoalSnapshot,
|
||||
} from "./goal-extension.js";
|
||||
import { blobResourceToClaude } from "./blob-resources.js";
|
||||
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
|
||||
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
|
||||
import {
|
||||
@@ -276,6 +278,7 @@ import {
|
||||
normalizeNoticeText,
|
||||
noticeOrTranscriptUpdate,
|
||||
sentenceCase,
|
||||
type SessionNotice,
|
||||
splitNoticeText,
|
||||
} from "./session-notices.js";
|
||||
import {
|
||||
@@ -1143,6 +1146,10 @@ export type Session = {
|
||||
/** Serialized `system`/init `plugin_errors` last logged, so the per-turn
|
||||
* init re-emit logs a plugin load failure once, not every turn. */
|
||||
loggedPluginErrors?: string;
|
||||
/** Names of client-provided MCP servers already reported as failed or
|
||||
* needing authentication. `system`/init re-emits on every turn with the
|
||||
* same statuses, and each server is reported once per session. */
|
||||
reportedMcpServers?: Set<string>;
|
||||
/** The long-lived consumer task. Lazily started on the first `prompt()` and
|
||||
* kept alive for the session so between-turn/background messages are still
|
||||
* drained and forwarded. */
|
||||
@@ -4151,6 +4158,56 @@ export class ClaudeAcpAgent {
|
||||
return respawned;
|
||||
}
|
||||
|
||||
/** Tells the user about client-provided MCP servers that `system`/init
|
||||
* reports as `failed` or `needs-auth`, once per server per session. Servers
|
||||
* from the user's Claude Code settings are left to `/mcp`: they are the same
|
||||
* in every session, so a broken one would be reported in every session. A
|
||||
* `needs-auth` server is reported only when the OAuth flow can't run (no
|
||||
* URL elicitation on the client, or no `mcpAuthenticate` on the query);
|
||||
* otherwise `startMcpAuthentication` asks the user to sign in. */
|
||||
private async reportMcpServerStartupFailures(
|
||||
session: Session,
|
||||
servers: SDKSystemMessage["mcp_servers"] | undefined,
|
||||
supportsNotices: boolean,
|
||||
sendUpdate: (notification: AcpSessionNotification) => Promise<void>,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (!servers?.length) return;
|
||||
const clientServers = new Set(
|
||||
(session.creationParams?.mcpServers ?? []).map((server) => server.name),
|
||||
);
|
||||
const oauthAvailable =
|
||||
!!this.clientCapabilities?.elicitation?.url && supportsMcpOAuth(session.query);
|
||||
for (const server of servers) {
|
||||
if (!clientServers.has(server.name) || session.reportedMcpServers?.has(server.name)) {
|
||||
continue;
|
||||
}
|
||||
let notice: SessionNotice;
|
||||
if (server.status === "failed") {
|
||||
notice = {
|
||||
severity: "warning",
|
||||
title: `MCP server "${server.name}" failed to start`,
|
||||
description: "Its tools are unavailable in this session. Run /mcp for the error.",
|
||||
};
|
||||
} else if (server.status === "needs-auth" && !oauthAvailable) {
|
||||
notice = {
|
||||
severity: "info",
|
||||
title: `MCP server "${server.name}" needs authentication`,
|
||||
description: "Its tools are unavailable until the server is authenticated.",
|
||||
};
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
(session.reportedMcpServers ??= new Set()).add(server.name);
|
||||
await sendUpdate({
|
||||
sessionId,
|
||||
update: noticeOrTranscriptUpdate(notice, supportsNotices, undefined, {
|
||||
claudeCode: { kind: "mcpServerStatus", server: server.name, status: server.status },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async recreateSignedOutQuery(sessionId: string, session: Session): Promise<void> {
|
||||
const creationParams = session.creationParams;
|
||||
if (!creationParams) {
|
||||
@@ -4166,6 +4223,12 @@ export class ClaudeAcpAgent {
|
||||
await this.recreateLiveSession(creationParams, sessionId, {
|
||||
permissionMode: session.modes.currentModeId as PermissionMode,
|
||||
});
|
||||
// The new Session has the same MCP servers; keep the ones already
|
||||
// reported so its first `system`/init doesn't repeat their notices.
|
||||
const recreated = this.sessions[sessionId];
|
||||
if (recreated && session.reportedMcpServers) {
|
||||
recreated.reportedMcpServers = new Set(session.reportedMcpServers);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep the husk addressable: the client answers the refusal with its own
|
||||
// auth flow and then retries this session.
|
||||
@@ -6139,6 +6202,13 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.reportMcpServerStartupFailures(
|
||||
session,
|
||||
message.mcp_servers,
|
||||
supportsNotices,
|
||||
sendUpdate,
|
||||
params.sessionId,
|
||||
);
|
||||
break;
|
||||
case "status": {
|
||||
// A model request that serves none of the prompts a cancel
|
||||
@@ -10665,6 +10735,23 @@ export class ClaudeAcpAgent {
|
||||
previewContent,
|
||||
);
|
||||
if (signal.aborted) throw new Error("Tool use aborted");
|
||||
// On `session/cancel` the client answers every open permission request
|
||||
// `cancelled`, including those of a background subagent, which the
|
||||
// cancel does not stop. Answer such a request with a denial so the
|
||||
// subagent reads "the user refused" and goes on. Throwing instead makes
|
||||
// Claude Code deny it as "Tool permission request failed".
|
||||
if (
|
||||
response.outcome?.outcome === "cancelled" &&
|
||||
this.sessions[sessionId] === session &&
|
||||
!blocksForeground(session, agentID)
|
||||
) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
toolUseID,
|
||||
decisionClassification: "user_reject",
|
||||
};
|
||||
}
|
||||
const decodedPermission = decodeClaudePermissionResponse(
|
||||
response,
|
||||
toolName,
|
||||
@@ -10730,19 +10817,21 @@ export class ClaudeAcpAgent {
|
||||
|
||||
/**
|
||||
* Handle elicitation requests that originate from MCP servers by forwarding
|
||||
* them to the client over ACP. Modes the client did not advertise (or
|
||||
* requests we can't represent) are declined.
|
||||
* them to the client over ACP. Only the user's own decline reaches the MCP
|
||||
* server as `decline`. A request the user never saw, because the client did
|
||||
* not advertise its mode, we can't represent it, or the client answered with
|
||||
* an error, is a `cancel`.
|
||||
*/
|
||||
private handleMcpElicitation(sessionId: string, support: ElicitationSupport): OnElicitation {
|
||||
return async (request, { signal }) => {
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "decline" };
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "decline" };
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -10759,8 +10848,11 @@ export class ClaudeAcpAgent {
|
||||
if (signal.aborted) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
// The client answered with an error, e.g. -32602 for a form it cannot
|
||||
// render, so the user never saw the request. MCP's `decline` means the
|
||||
// user refused; `cancel` means they made no explicit choice.
|
||||
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "decline" };
|
||||
return { action: "cancel" };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -13289,18 +13381,26 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
break;
|
||||
}
|
||||
case "resource": {
|
||||
if ("text" in chunk.resource) {
|
||||
const formattedUri = formatUriAsLink(chunk.resource.uri);
|
||||
content.push({
|
||||
type: "text",
|
||||
text: formattedUri,
|
||||
});
|
||||
const { uri } = chunk.resource;
|
||||
// A blob resource becomes an image or document block, or its decoded
|
||||
// text. A v1 prompt skips a blob of any other type, e.g.
|
||||
// `application/zip`; a v2 prompt rejects it (see `v1PromptRequest`).
|
||||
const blob = "blob" in chunk.resource ? blobResourceToClaude(chunk.resource) : undefined;
|
||||
const text =
|
||||
"text" in chunk.resource
|
||||
? chunk.resource.text
|
||||
: blob?.kind === "text"
|
||||
? blob.text
|
||||
: undefined;
|
||||
if (blob?.kind === "image" || blob?.kind === "document") {
|
||||
content.push({ type: "text", text: formatUriAsLink(uri) }, blob.block);
|
||||
} else if (text !== undefined) {
|
||||
content.push({ type: "text", text: formatUriAsLink(uri) });
|
||||
context.push({
|
||||
type: "text",
|
||||
text: `\n<context ref="${chunk.resource.uri}">\n${chunk.resource.text}\n</context>`,
|
||||
text: `\n<context ref="${uri}">\n${text}\n</context>`,
|
||||
});
|
||||
}
|
||||
// Ignore blob resources (unsupported)
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
|
||||
125
src/blob-resources.ts
Normal file
125
src/blob-resources.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Embedded `resource` prompt blocks whose resource is a base64 `blob`, and the
|
||||
* Claude content each supported MIME type becomes.
|
||||
*/
|
||||
import type { BlobResourceContents } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
/** Image types the Messages API accepts as a base64 `image` block. */
|
||||
const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const;
|
||||
type ImageMimeType = (typeof IMAGE_MIME_TYPES)[number];
|
||||
|
||||
/** Non-`text/*` types whose bytes are text, e.g. a `.json` or `.ts` file. */
|
||||
const TEXT_APPLICATION_MIME_TYPES = new Set([
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/typescript",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"application/toml",
|
||||
"application/x-sh",
|
||||
"application/sql",
|
||||
"application/graphql",
|
||||
]);
|
||||
|
||||
export type BlobResourceContent =
|
||||
| {
|
||||
kind: "image";
|
||||
block: {
|
||||
type: "image";
|
||||
source: { type: "base64"; media_type: ImageMimeType; data: string };
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "document";
|
||||
block: {
|
||||
type: "document";
|
||||
source: { type: "base64"; media_type: "application/pdf"; data: string };
|
||||
};
|
||||
}
|
||||
/** The decoded text, to send like a text resource. */
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
/**
|
||||
* The MIME type without parameters or case, e.g. `text/plain; charset=utf-8` ->
|
||||
* `text/plain`. `image/jpg`, which some clients send, becomes `image/jpeg`.
|
||||
*/
|
||||
function baseMimeType(mimeType: string | null | undefined): string | undefined {
|
||||
const base = mimeType?.split(";")[0].trim().toLowerCase();
|
||||
if (base === "image/jpg") return "image/jpeg";
|
||||
return base || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a text blob with the `charset` parameter of its MIME type, e.g.
|
||||
* `text/csv; charset=iso-8859-1`, and UTF-8 when there is none or the label is
|
||||
* unknown. A leading byte order mark is dropped.
|
||||
*/
|
||||
function decodeText(blob: string, mimeType: string | null | undefined): string {
|
||||
const charset = /;\s*charset\s*=\s*"?([^";\s]+)/i.exec(mimeType ?? "")?.[1];
|
||||
let decoder: TextDecoder;
|
||||
try {
|
||||
decoder = new TextDecoder(charset ?? "utf-8");
|
||||
} catch {
|
||||
decoder = new TextDecoder("utf-8");
|
||||
}
|
||||
return decoder.decode(Buffer.from(blob, "base64"));
|
||||
}
|
||||
|
||||
function isImageMimeType(mimeType: string): mimeType is ImageMimeType {
|
||||
return (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
|
||||
}
|
||||
|
||||
function isTextMimeType(mimeType: string): boolean {
|
||||
return (
|
||||
mimeType.startsWith("text/") ||
|
||||
TEXT_APPLICATION_MIME_TYPES.has(mimeType) ||
|
||||
mimeType.endsWith("+json") ||
|
||||
mimeType.endsWith("+xml")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link blobResourceToClaude} can send a blob of this MIME type. A blob
|
||||
* without a MIME type is not supported: its bytes could be anything.
|
||||
*/
|
||||
export function isSupportedBlobMimeType(mimeType: string | null | undefined): boolean {
|
||||
const base = baseMimeType(mimeType);
|
||||
if (!base) return false;
|
||||
return isImageMimeType(base) || base === "application/pdf" || isTextMimeType(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude content for a blob resource: an `image` block for PNG, JPEG, GIF
|
||||
* and WebP, a `document` block for PDF, and the decoded text for text
|
||||
* types such as `text/markdown` or `application/json`. Returns `undefined` for
|
||||
* any other type, e.g. `application/zip`.
|
||||
*/
|
||||
export function blobResourceToClaude(
|
||||
resource: BlobResourceContents,
|
||||
): BlobResourceContent | undefined {
|
||||
const mimeType = baseMimeType(resource.mimeType);
|
||||
if (!mimeType) return undefined;
|
||||
if (isImageMimeType(mimeType)) {
|
||||
return {
|
||||
kind: "image",
|
||||
block: {
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: mimeType, data: resource.blob },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (mimeType === "application/pdf") {
|
||||
return {
|
||||
kind: "document",
|
||||
block: {
|
||||
type: "document",
|
||||
source: { type: "base64", media_type: "application/pdf", data: resource.blob },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (isTextMimeType(mimeType)) {
|
||||
return { kind: "text", text: decodeText(resource.blob, resource.mimeType) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -4730,6 +4730,61 @@ describe("prompt conversion", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends image, PDF and text blob resources to Claude, and skips other blobs", () => {
|
||||
const blob = (uri: string, mimeType: string, data: string) => ({
|
||||
type: "resource" as const,
|
||||
resource: { uri, mimeType, blob: data },
|
||||
});
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
blob("file:///work/shot.png", "image/png", "iVBORw0K"),
|
||||
blob("file:///work/spec.pdf", "application/pdf", "JVBERi0x"),
|
||||
blob("file:///work/notes.md", "text/markdown; charset=utf-8", "IyBOb3Rlcw=="),
|
||||
blob("file:///work/data.json", "application/json", "eyJhIjoxfQ=="),
|
||||
blob("file:///work/archive.zip", "application/zip", "UEsDBA=="),
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: "[@shot.png](file:///work/shot.png)" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0K" } },
|
||||
{ type: "text", text: "[@spec.pdf](file:///work/spec.pdf)" },
|
||||
{
|
||||
type: "document",
|
||||
source: { type: "base64", media_type: "application/pdf", data: "JVBERi0x" },
|
||||
},
|
||||
{ type: "text", text: "[@notes.md](file:///work/notes.md)" },
|
||||
{ type: "text", text: "[@data.json](file:///work/data.json)" },
|
||||
{ type: "text", text: '\n<context ref="file:///work/notes.md">\n# Notes\n</context>' },
|
||||
{ type: "text", text: '\n<context ref="file:///work/data.json">\n{"a":1}\n</context>' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads image/jpg as JPEG and decodes text blobs with their charset", () => {
|
||||
const blob = (uri: string, mimeType: string, data: string) => ({
|
||||
type: "resource" as const,
|
||||
resource: { uri, mimeType, blob: data },
|
||||
});
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
blob("file:///work/shot.jpg", "image/jpg", "/9j/4AAQ"),
|
||||
// "café" in ISO-8859-1: 63 61 66 e9.
|
||||
blob("file:///work/menu.csv", "text/csv; charset=ISO-8859-1", "Y2Fm6Q=="),
|
||||
// "hi" in UTF-8 with a byte order mark: ef bb bf 68 69.
|
||||
blob("file:///work/bom.txt", "text/plain", "77u/aGk="),
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{ type: "text", text: "[@shot.jpg](file:///work/shot.jpg)" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "/9j/4AAQ" } },
|
||||
{ type: "text", text: "[@menu.csv](file:///work/menu.csv)" },
|
||||
{ type: "text", text: "[@bom.txt](file:///work/bom.txt)" },
|
||||
{ type: "text", text: '\n<context ref="file:///work/menu.csv">\ncafé\n</context>' },
|
||||
{ type: "text", text: '\n<context ref="file:///work/bom.txt">\nhi\n</context>' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
@@ -5066,6 +5121,56 @@ describe("permission request cancellation", () => {
|
||||
await expect(pending).rejects.toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
describe("a cancelled outcome", () => {
|
||||
function setup() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (): Promise<RequestPermissionResponse> => ({
|
||||
outcome: { outcome: "cancelled" },
|
||||
}),
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
const session = injectSession(agent, "session-1");
|
||||
session.emittedToolCalls.add("tool-1");
|
||||
const ask = (agentID?: string) =>
|
||||
agent.canUseTool("session-1")("Bash", { command: "ls" }, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "tool-1",
|
||||
...(agentID ? { agentID } : {}),
|
||||
} as any);
|
||||
return { session, ask };
|
||||
}
|
||||
|
||||
it("aborts the tool use of foreground work", async () => {
|
||||
const { session, ask } = setup();
|
||||
// A subagent that runs in the foreground blocks the turn like the main thread.
|
||||
session.liveBackgroundTasks.set("agent-fg", { isSubagent: true });
|
||||
|
||||
await expect(ask()).rejects.toThrow("Tool use aborted");
|
||||
await expect(ask("agent-fg")).rejects.toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
it("denies the tool use of a background subagent", async () => {
|
||||
const { session, ask } = setup();
|
||||
session.liveBackgroundTasks.set("agent-bg", { isSubagent: true, backgrounded: true });
|
||||
// A subagent that a background subagent started is background work too.
|
||||
session.liveBackgroundTasks.set("agent-nested", {
|
||||
isSubagent: true,
|
||||
parentTaskId: "agent-bg",
|
||||
});
|
||||
|
||||
for (const agentID of ["agent-bg", "agent-nested"]) {
|
||||
await expect(ask(agentID)).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
toolUseID: "tool-1",
|
||||
decisionClassification: "user_reject",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** The requests to the client that are elicitations, and how each ends when its signal aborts. */
|
||||
const elicitations = [
|
||||
{
|
||||
@@ -13326,6 +13431,146 @@ describe("model refusal fallback handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP server startup failures", () => {
|
||||
function setup(mcpServersStatus: { name: string; status: string; source?: string }[]) {
|
||||
const sessionUpdate = vi.fn(async () => {});
|
||||
const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
const init = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "test-session",
|
||||
mcp_servers: mcpServersStatus,
|
||||
};
|
||||
async function* generator(input: Pushable<any>) {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iter.next();
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
// init re-emits on every turn with the same statuses.
|
||||
yield init;
|
||||
yield init;
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
result: "",
|
||||
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: "test-session",
|
||||
};
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
injectGeneratorSession(agent, generator, {
|
||||
creationParams: {
|
||||
cwd: "/tmp",
|
||||
mcpServers: [
|
||||
{ name: "client-fs", command: "fs-server", args: [], env: [] },
|
||||
{ name: "client-api", type: "http", url: "https://example.com/mcp", headers: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
const updates = () =>
|
||||
sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
it("sends one notice per failed client-provided server and ignores settings servers", async () => {
|
||||
const { agent, updates } = setup([
|
||||
{ name: "client-fs", status: "failed", source: "dynamic" },
|
||||
{ name: "client-api", status: "connected", source: "dynamic" },
|
||||
{ name: "from-settings", status: "failed", source: "user" },
|
||||
]);
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { session: { notices: {} } },
|
||||
});
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
expect(updates().filter((u) => u.sessionUpdate === "notice")).toEqual([
|
||||
{
|
||||
sessionUpdate: "notice",
|
||||
severity: "warning",
|
||||
title: 'MCP server "client-fs" failed to start',
|
||||
description: "Its tools are unavailable in this session. Run /mcp for the error.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a needs-auth server only when the client can't run OAuth", async () => {
|
||||
const withoutUrlElicitation = setup([{ name: "client-api", status: "needs-auth" }]);
|
||||
await withoutUrlElicitation.agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { session: { notices: {} } },
|
||||
});
|
||||
await withoutUrlElicitation.agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "hi" }],
|
||||
});
|
||||
expect(withoutUrlElicitation.updates().filter((u) => u.sessionUpdate === "notice")).toEqual([
|
||||
{
|
||||
sessionUpdate: "notice",
|
||||
severity: "info",
|
||||
title: 'MCP server "client-api" needs authentication',
|
||||
description: "Its tools are unavailable until the server is authenticated.",
|
||||
},
|
||||
]);
|
||||
|
||||
const withUrlElicitation = setup([{ name: "client-api", status: "needs-auth" }]);
|
||||
// The OAuth flow also needs the query's `mcpAuthenticate`.
|
||||
(withUrlElicitation.agent as any).sessions["test-session"].query.mcpAuthenticate = vi.fn(
|
||||
async () => ({}),
|
||||
);
|
||||
await withUrlElicitation.agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { session: { notices: {} }, elicitation: { url: {} } },
|
||||
});
|
||||
await withUrlElicitation.agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "hi" }],
|
||||
});
|
||||
expect(withUrlElicitation.updates().filter((u) => u.sessionUpdate === "notice")).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to a transcript line without the notices capability", async () => {
|
||||
const { agent, updates } = setup([{ name: "client-fs", status: "failed" }]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const lines = updates().filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u._meta?.claudeCode?.kind === "mcpServerStatus",
|
||||
);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].content.text).toBe(
|
||||
'**MCP server "client-fs" failed to start:** Its tools are unavailable in this session. Run /mcp for the error.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal slash command filtering", () => {
|
||||
it("latches init's terminal_slash_commands and re-advertises without them", async () => {
|
||||
const sessionUpdate = vi.fn(async () => {});
|
||||
|
||||
@@ -861,6 +861,71 @@ describe("ACP v2 prompts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers an MCP server's elicitation with cancel when the client cannot render it", async () => {
|
||||
let answer: unknown;
|
||||
scriptTurns(async function* (options) {
|
||||
answer = await options.onElicitation!(
|
||||
{
|
||||
serverName: "linear",
|
||||
message: "Which team?",
|
||||
mode: "form",
|
||||
requestedSchema: { type: "object", properties: { team: { type: "string" } } },
|
||||
},
|
||||
{ signal: new AbortController().signal } as Parameters<
|
||||
NonNullable<Options["onElicitation"]>
|
||||
>[1],
|
||||
);
|
||||
yield assistantText(options, "No answer.");
|
||||
yield result(options);
|
||||
});
|
||||
// The client rejects a form it cannot render with -32602.
|
||||
const client = v2Client({
|
||||
answerElicitation: () => {
|
||||
throw v1.RequestError.invalidParams(undefined, "Cannot render this form");
|
||||
},
|
||||
});
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent, { elicitation: { form: {} } });
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
|
||||
});
|
||||
|
||||
expect(client.elicitations).toHaveLength(1);
|
||||
// The user never saw the form, so the MCP server is not told they declined.
|
||||
expect(answer).toEqual({ action: "cancel" });
|
||||
});
|
||||
|
||||
it("answers an MCP server's URL elicitation with cancel when the client supports only forms", async () => {
|
||||
let answer: unknown;
|
||||
scriptTurns(async function* (options) {
|
||||
answer = await options.onElicitation!(
|
||||
{
|
||||
serverName: "linear",
|
||||
message: "Sign in",
|
||||
mode: "url",
|
||||
url: "https://linear.app/oauth",
|
||||
elicitationId: "e1",
|
||||
},
|
||||
{ signal: new AbortController().signal } as Parameters<
|
||||
NonNullable<Options["onElicitation"]>
|
||||
>[1],
|
||||
);
|
||||
yield assistantText(options, "No answer.");
|
||||
yield result(options);
|
||||
});
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent, { elicitation: { form: {} } });
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
|
||||
});
|
||||
|
||||
expect(client.elicitations).toHaveLength(0);
|
||||
expect(answer).toEqual({ action: "cancel" });
|
||||
});
|
||||
|
||||
it("does not report the next turn as waiting for a question that its client never settled", async () => {
|
||||
// Claude Code's interrupt withdraws the question. The client holds it and
|
||||
// ignores $/cancel_request, so only the agent can stop waiting for it.
|
||||
@@ -5570,23 +5635,23 @@ describe("ACP v2 session translation", () => {
|
||||
prompt: [{ type: "audio", data: "AAAA", mimeType: "audio/wav" }],
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: -32602, data: { type: "audio" } }));
|
||||
// An embedded blob resource still converts.
|
||||
expect(
|
||||
});
|
||||
|
||||
it("rejects an embedded blob that Claude cannot read, and passes one it can", () => {
|
||||
const prompt = (mimeType?: string) =>
|
||||
v1PromptRequest({
|
||||
sessionId: "s",
|
||||
prompt: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///a.bin", blob: "AAAA", mimeType: "application/octet-stream" },
|
||||
},
|
||||
],
|
||||
}).prompt,
|
||||
).toEqual([
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///a.bin", blob: "AAAA", mimeType: "application/octet-stream" },
|
||||
},
|
||||
]);
|
||||
prompt: [{ type: "resource", resource: { uri: "file:///work/a", blob: "AAAA", mimeType } }],
|
||||
}).prompt[0];
|
||||
expect(prompt("application/pdf")).toEqual({
|
||||
type: "resource",
|
||||
resource: { uri: "file:///work/a", blob: "AAAA", mimeType: "application/pdf" },
|
||||
});
|
||||
expect(() => prompt("application/zip")).toThrow(
|
||||
"Embedded blob resources of type application/zip are not supported",
|
||||
);
|
||||
expect(() => prompt("application/zip")).toThrow(expect.objectContaining({ code: -32602 }));
|
||||
expect(() => prompt()).toThrow("Embedded blob resources of type unknown are not supported");
|
||||
});
|
||||
|
||||
it("fails on a message chunk without a messageId, which v2 requires", () => {
|
||||
|
||||
@@ -2448,6 +2448,18 @@ describe("toolInfoFromToolUse - ExitPlanMode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - EnterPlanMode", () => {
|
||||
it("reports a mode switch", () => {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: "EnterPlanMode", id: "toolu_enter", input: {} },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.title).toBe("EnterPlanMode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - Write input aliases", () => {
|
||||
it.each([
|
||||
["path + file_text", { path: "/repo/a.ts", file_text: "x" }],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EditReporter, NotebookEditReporter, WriteReporter } from "./file-edit.j
|
||||
import {
|
||||
AgentControlReporter,
|
||||
AskUserQuestionReporter,
|
||||
EnterPlanModeReporter,
|
||||
ExitPlanModeReporter,
|
||||
GenericReporter,
|
||||
PlanToolReporter,
|
||||
@@ -35,6 +36,7 @@ const reporters: Record<string, ToolReporter> = {
|
||||
TaskList: new PlanToolReporter("TaskList"),
|
||||
TaskGet: new PlanToolReporter("TaskGet"),
|
||||
ReportFindings: new ReportFindingsReporter(),
|
||||
EnterPlanMode: new EnterPlanModeReporter(),
|
||||
ExitPlanMode: new ExitPlanModeReporter(),
|
||||
AskUserQuestion: new AskUserQuestionReporter(),
|
||||
Skill: new SkillReporter(),
|
||||
|
||||
@@ -72,6 +72,13 @@ export class ExitPlanModeReporter implements ToolReporter {
|
||||
}
|
||||
}
|
||||
|
||||
/** EnterPlanMode switches the session to plan mode, so it has the `switch_mode` kind like ExitPlanMode. */
|
||||
export class EnterPlanModeReporter implements ToolReporter {
|
||||
toolUse(): ToolUseFacts {
|
||||
return { title: "EnterPlanMode", kind: "switch_mode" };
|
||||
}
|
||||
}
|
||||
|
||||
/** The plan file of a result: the path of the input, else the `filePath` of the structured result. */
|
||||
function resultPlanFile({ toolUse, structured, capabilities }: ToolResultContext) {
|
||||
if (!capabilities.air.planFile) return undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { isSupportedBlobMimeType } from "../blob-resources.js";
|
||||
import type { ForegroundEvents, StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
|
||||
|
||||
/**
|
||||
@@ -145,10 +146,28 @@ function idle({ stopReason, usage, _meta }: TurnOutcome): v2.SessionUpdate {
|
||||
* which it does not advertise and Claude cannot read, a custom one, or one
|
||||
* from a newer ACP version. Clients send only the kinds the agent advertised,
|
||||
* so such a block fails the request with invalid params (-32602) rather than
|
||||
* being dropped from the prompt.
|
||||
* being dropped from the prompt. The same goes for an embedded blob that
|
||||
* Claude cannot read, e.g. an `application/zip` resource: `promptToClaude`
|
||||
* would drop it.
|
||||
*/
|
||||
export function v1PromptRequest({ prompt, ...request }: v2.PromptRequest): PromptRequest {
|
||||
return { ...request, prompt: prompt.map(v1ContentBlock) };
|
||||
return {
|
||||
...request,
|
||||
prompt: prompt.map((block) => {
|
||||
if (
|
||||
v2.ContentBlock.isResource(block) &&
|
||||
"blob" in block.resource &&
|
||||
!isSupportedBlobMimeType(block.resource.mimeType)
|
||||
) {
|
||||
const mimeType = block.resource.mimeType ?? null;
|
||||
throw RequestError.invalidParams(
|
||||
{ uri: block.resource.uri, mimeType },
|
||||
`Embedded blob resources of type ${mimeType ?? "unknown"} are not supported`,
|
||||
);
|
||||
}
|
||||
return v1ContentBlock(block);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function v1ContentBlock(block: v2.ContentBlock): ContentBlock {
|
||||
|
||||
Reference in New Issue
Block a user