Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions plugins/provider-acp/src/delta-translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,95 @@ describe("acp delta translation (native kinds → core kinds)", () => {
});
});

// The live data-loss case: one unseen `kind` used to fail the wire parse,
// so the tool_call never opened and its `completed` update closed a bare
// untitled generic row. The call now opens under the agent's own kind word
// and closes on the same item.
it("keeps a call whose kind the schema does not know", () => {
const harness = createHarness();
harness.translate(turnStartedEvent());
const opened = harness.translate(
updateEvent({
sessionUpdate: "tool_call",
toolCallId: "call-deploy",
title: "Deploy preview",
kind: "deploy",
status: "in_progress",
}),
);
expect(opened).toHaveLength(1);
expect(opened[0]).toMatchObject({
type: "item/started",
item: {
type: "toolCall",
tool: "deploy",
status: "pending",
presentation: {
label: { pending: "Running tool", completed: "Ran tool" },
icon: { glyph: "Toolbox" },
title: "Deploy preview",
},
},
});
const openedId =
opened[0]?.type === "item/started" ? opened[0].item.id : "";

const closed = harness.translate(
updateEvent({
sessionUpdate: "tool_call_update",
toolCallId: "call-deploy",
status: "completed",
rawOutput: { url: "https://preview.example" },
}),
);
expect(closed).toHaveLength(1);
expect(closed[0]).toMatchObject({
type: "item/completed",
item: {
type: "toolCall",
id: openedId,
tool: "deploy",
status: "completed",
presentation: { title: "Deploy preview" },
},
});
});

it("settles a cancelled call as interrupted and a switch_mode call as its own kind", () => {
const harness = createHarness();
harness.translate(turnStartedEvent());
harness.translate(
updateEvent({
sessionUpdate: "tool_call",
toolCallId: "call-mode",
title: "Switch to plan mode",
kind: "switch_mode",
status: "pending",
}),
);
const closed = harness.translate(
updateEvent({
sessionUpdate: "tool_call_update",
toolCallId: "call-mode",
status: "cancelled",
}),
);
expect(closed).toHaveLength(1);
expect(closed[0]).toMatchObject({
type: "item/completed",
item: {
type: "toolCall",
tool: "switch_mode",
status: "interrupted",
presentation: {
label: { pending: "Switching mode", completed: "Switched mode" },
icon: { glyph: "SlidersHorizontal" },
title: "Switch to plan mode",
},
},
});
});

it("names a generic call by its kind and keeps the title as the headline", () => {
expect(
openItem({ toolCallId: "other-1", title: "MCP: tool", kind: "other" }),
Expand Down
23 changes: 20 additions & 3 deletions plugins/provider-acp/src/delta-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ const PLAN_STEPS_CHANNEL = "planSteps";
function isTerminalAcpStatus(
status: AcpToolCallUpdateEvent["status"],
): boolean {
return status === "completed" || status === "failed";
return (
status === "completed" || status === "failed" || status === "cancelled"
);
}

function mapAcpToolCallStatus(
Expand All @@ -107,6 +109,8 @@ function mapAcpToolCallStatus(
return "completed";
case "failed":
return "failed";
case "cancelled":
return "interrupted";
default:
return "pending";
}
Expand All @@ -124,10 +128,23 @@ function mergeAcpToolCallEvents(
if (!started) {
return update;
}
// A kind on the update replaces the started kind together with its raw
// form: a known kind clears a stale `rawKind`, an unknown one carries its
// own.
const { rawKind: startedRawKind, ...startedRest } = started;
const kindFields =
update.kind !== undefined
? {
kind: update.kind,
...(update.rawKind !== undefined ? { rawKind: update.rawKind } : {}),
}
: startedRawKind !== undefined
? { rawKind: startedRawKind }
: {};
return {
...started,
...startedRest,
...kindFields,
...(update.title !== undefined ? { title: update.title } : {}),
...(update.kind !== undefined ? { kind: update.kind } : {}),
...(update.status !== undefined ? { status: update.status } : {}),
...(update.content !== undefined ? { content: update.content } : {}),
...(update.locations !== undefined ? { locations: update.locations } : {}),
Expand Down
4 changes: 4 additions & 0 deletions plugins/provider-acp/src/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ const KIND_PRESENTATIONS: Readonly<Record<AcpToolKind, KindPresentationSpec>> =
label: { pending: "Fetching", completed: "Fetched" },
glyph: "Globe",
},
switch_mode: {
label: { pending: "Switching mode", completed: "Switched mode" },
glyph: "SlidersHorizontal",
},
other: {
label: { pending: "Running tool", completed: "Ran tool" },
glyph: "Toolbox",
Expand Down
21 changes: 13 additions & 8 deletions plugins/provider-acp/src/tool-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import {
import {
extractAcpContentText,
type AcpToolCallUpdateEvent,
type AcpToolKind,
} from "./wire.js";

/** A tool call's item shape plus the presentation that rides its lifecycle. */
Expand Down Expand Up @@ -315,13 +314,18 @@ function reasoningItem(event: AcpToolCallUpdateEvent): AcpClassifiedToolCall {
};
}

/**
* A generic tool names itself by its kind; a kind the wire schema did not
* know keeps the agent's own word (`rawKind`) in the tool slot and presents
* as `other`.
*/
function genericToolItem(
kind: AcpToolKind | undefined,
event: Pick<AcpToolCallUpdateEvent, "kind" | "rawKind">,
title: string | undefined,
): AcpClassifiedToolCall {
return {
item: { type: "tool", tool: kind ?? "tool" },
presentation: toolKindPresentation({ kind, title }),
item: { type: "tool", tool: event.rawKind ?? event.kind ?? "tool" },
presentation: toolKindPresentation({ kind: event.kind, title }),
};
}

Expand Down Expand Up @@ -368,19 +372,20 @@ export function classifyAcpToolCall(
const title = toOptionalString(event.title);
switch (event.kind) {
case "read":
return fileReadItem(event, title) ?? genericToolItem(event.kind, title);
return fileReadItem(event, title) ?? genericToolItem(event, title);
case "search":
return searchItem(event) ?? genericToolItem(event.kind, title);
return searchItem(event) ?? genericToolItem(event, title);
case "fetch":
return webFetchItem(event, title) ?? genericToolItem(event.kind, title);
return webFetchItem(event, title) ?? genericToolItem(event, title);
case "think":
return reasoningItem(event);
case "execute":
case "edit":
case "delete":
case "move":
case "switch_mode":
case "other":
case undefined:
return genericToolItem(event.kind, title);
return genericToolItem(event, title);
}
}
82 changes: 82 additions & 0 deletions plugins/provider-acp/src/wire.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,92 @@
import { describe, expect, it } from "vitest";
import {
acpInitializeResultSchema,
acpRequestPermissionParamsSchema,
acpSessionForkResultSchema,
acpSessionNewResultSchema,
acpToolCallUpdateEventSchema,
} from "./wire.js";

describe("acpToolCallUpdateEventSchema", () => {
// ACP's ToolKind is an open enum upstream (`#[serde(other)]`). A closed zod
// enum rejected the whole tool_call for one unseen value, so the call never
// opened and its later `completed` update merged into nothing.
it("parses an unknown kind as `other` and keeps the agent's word on rawKind", () => {
const parsed = acpToolCallUpdateEventSchema.parse({
sessionUpdate: "tool_call",
toolCallId: "call-1",
title: "Deploy preview",
kind: "deploy",
status: "in_progress",
});

expect(parsed.kind).toBe("other");
expect(parsed.rawKind).toBe("deploy");
expect(parsed.status).toBe("in_progress");
});

it("accepts switch_mode and the v2 cancelled status", () => {
const parsed = acpToolCallUpdateEventSchema.parse({
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
kind: "switch_mode",
status: "cancelled",
});

expect(parsed.kind).toBe("switch_mode");
expect(parsed.rawKind).toBeUndefined();
expect(parsed.status).toBe("cancelled");
});

it("parses an unknown status as pending and a null kind or status as absent", () => {
const unknownStatus = acpToolCallUpdateEventSchema.parse({
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
status: "queued",
});
expect(unknownStatus.status).toBe("pending");

const nulls = acpToolCallUpdateEventSchema.parse({
sessionUpdate: "tool_call",
toolCallId: "call-2",
kind: null,
status: null,
});
expect(nulls.kind).toBeUndefined();
expect(nulls.status).toBeUndefined();
});

it("skips a content entry of an unknown type instead of dropping the call", () => {
const parsed = acpToolCallUpdateEventSchema.parse({
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
status: "completed",
content: [
{ type: "hologram", frames: 3 },
{ type: "content", content: { type: "text", text: "done" } },
],
});

expect(parsed.content).toEqual([
{ type: "content", content: { type: "text", text: "done" } },
]);
});

it("opens the enums on a permission request's tool call too", () => {
const parsed = acpRequestPermissionParamsSchema.parse({
sessionId: "s",
toolCall: { toolCallId: "call-1", kind: "deploy", status: "queued" },
options: [{ optionId: "y", name: "Allow", kind: "allow_once" }],
});

expect(parsed.toolCall).toMatchObject({
kind: "other",
rawKind: "deploy",
status: "pending",
});
});
});

describe("acpInitializeResultSchema", () => {
it("exposes the unstable session fork capability", () => {
const parsed = acpInitializeResultSchema.parse({
Expand Down
Loading
Loading