Compare commits

..

5 Commits

Author SHA1 Message Date
Ben Brandt
d0ee30813f feat: send embedded blob resources in prompts to Claude (#1460)
Stacked on #1457 (SDK 2.1.0).

`promptToClaude` used to drop embedded `resource` blocks whose content
is a base64 `blob`, even though the agent advertises `embeddedContext`.
They are now sent to Claude:

- **Images** (PNG, JPEG, including `image/jpg`, GIF, WebP) become
`image` blocks, right after the `[@name](uri)` link.
- **PDFs** become `document` blocks.
- **Text types** (`text/*`, JSON, XML, YAML, TOML, JS/TS, shell, SQL,
GraphQL, `+json`/`+xml`) are decoded with their `charset` parameter
(UTF-8 by default, BOM dropped) and sent like text resources.
- **Other types:** v2 rejects them with `-32602`. v1 still skips them,
as before.

Known gap: a replayed user PDF does not appear in the replayed history,
because `document` blocks have no ACP mapping on replay.
2026-10-28 12:45:07 +00:00
Ben Brandt
54b35ee0f4 feat: tell the client when an MCP server fails to start (#1464)
Stacked on #1457 (SDK 2.1.0).

Agents SHOULD let the user know when a client-provided MCP server fails
(agentclientprotocol/agent-client-protocol#2512). Before this PR,
failures only showed up in `/mcp`.

- **On `system`/init:**
  - A client-provided server with status `failed` gets a warning notice.
- A `needs-auth` server gets an info notice when the OAuth flow can't
run: the client has no URL elicitation, or the query has no
`mcpAuthenticate`.
- **Once per server per session.** This holds across repeated inits and
across the session rebuilt after a sign-out.
- **Servers from the user's settings are left to `/mcp`.** Otherwise a
broken one would be reported in every session.
- **Rendering:** v1 clients without the notices capability get a
transcript line instead.
2026-10-28 13:43:34 +01:00
Ben Brandt
93c1f43519 fix: report EnterPlanMode tool calls with the switch_mode kind (#1467)
Stacked on #1457 (SDK 2.1.0).

EnterPlanMode tool calls now use `kind: "switch_mode"`, like
ExitPlanMode (agentclientprotocol/agent-client-protocol#2543). The title
is unchanged.
2026-10-28 13:40:19 +01:00
Ben Brandt
f646c821f1 fix: deny a background permission request that the client cancels (#1466)
Stacked on #1457 (SDK 2.1.0).

Per agentclientprotocol/agent-client-protocol#2508, `session/cancel`
cancels foreground work, and the client answers every pending permission
request with `cancelled`, including background ones.

- **Before:** for a background subagent, which keeps running after the
interrupt, `canUseTool` threw "Tool use aborted". The CLI turned that
into "Tool permission request failed", which the model can take for a
transient failure and retry.
- **Now:** a `cancelled` outcome for background work (decided by
`blocksForeground`, including subagents nested under a backgrounded one)
returns a `deny` with `decisionClassification: "user_reject"`.
- **Foreground** work still throws, and its turn ends through
`query.interrupt()`.

Open question: AskUserQuestion's `cancel` still throws for background
work.
2026-10-28 13:39:46 +01:00
Ben Brandt
54b869c990 fix: don't report decline when the client can't render an MCP elicitation (#1465)
Stacked on #1457 (SDK 2.1.0).

When an MCP server's elicitation is forwarded and the client answers
with an error (for example `-32602` for a form it can't render,
agentclientprotocol/agent-client-protocol#2556), the agent used to tell
the MCP server `decline`. That reported a choice the user never made.

- It now returns `cancel`.
- The same applies to the two early returns where the user never saw
anything: a mode the client didn't advertise, and a request that can't
be converted to ACP. These apply to v1 clients too.
- `decline` now only comes from an actual decline.
2026-10-28 13:39:04 +01:00
8 changed files with 605 additions and 30 deletions

View File

@@ -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
View 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;
}

View File

@@ -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 () => {});

View File

@@ -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", () => {

View File

@@ -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" }],

View File

@@ -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(),

View File

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

View File

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