mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: surface Skill tool calls with name and kind in _meta (#986)
## Summary
- Add `skill?: string` to `ToolUpdateMeta.claudeCode`, populated from
`toolUse.input.skill` in `claudeCodeMetaFromToolUse`. Lets ACP clients
detect skill invocations from structured metadata rather than parsing
the title string.
- Add `case "Skill":` to `toolInfoFromToolUse`: sets `title` to `"Load
skill: <name>"` and returns empty content — readable fallback for
clients that don't read `_meta`.
- Add `case "Skill":` to `toolUpdateFromToolResult`: returns `{}` to
suppress the raw `"Launching skill: <name>"` result text, matching the
pattern used for `Edit`/`Write`.
## Motivation
Without this, the Skill tool falls through to the `default` case and
renders as a generic `kind: "other"` block with title `"Skill"` and an
expand control showing the raw result text. With this change, clients
that read `_meta.claudeCode.skill` can render the block with the
appropriate icon, the skill name, and no expand control.
## Test plan
- [ ] Invoke a skill (e.g. `/commits`) and confirm
`_meta.claudeCode.skill` is present in the emitted `tool_call` update
- [ ] Confirm title is `"Load skill: commits"` in the ACP event stream
- [ ] Confirm no content is emitted for the skill tool result
This commit is contained in:
@@ -102,6 +102,7 @@ import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/re
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Stats } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -899,6 +900,12 @@ export type ToolUpdateMeta = {
|
||||
transcripts need a namespaced marker instead of inferring from
|
||||
`toolName` or the generic `think` kind. */
|
||||
subagent?: true;
|
||||
/* For Skill tool calls: the name of the skill being loaded (e.g. "commits").
|
||||
Lets clients render a "Load skill: <name>" block without parsing the title. */
|
||||
skill?: string;
|
||||
/* For Skill tool calls: absolute path of that skill's SKILL.md, when it could be
|
||||
located on disk. Lets clients turn the rendered skill name into a link to it. */
|
||||
skillPath?: string;
|
||||
};
|
||||
/* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */
|
||||
terminal_info?: {
|
||||
@@ -7668,10 +7675,13 @@ function shouldEmitToolCall(toolName: string): boolean {
|
||||
* are kept out of ACP's standard `title`, which clients may use as the shell
|
||||
* command preview, while still giving clients access to Claude's concise
|
||||
* human-readable title. */
|
||||
function claudeCodeMetaFromToolUse(toolUse: {
|
||||
name: string;
|
||||
input?: unknown;
|
||||
}): NonNullable<ToolUpdateMeta["claudeCode"]> {
|
||||
function claudeCodeMetaFromToolUse(
|
||||
toolUse: {
|
||||
name: string;
|
||||
input?: unknown;
|
||||
},
|
||||
cwd?: string,
|
||||
): NonNullable<ToolUpdateMeta["claudeCode"]> {
|
||||
const description =
|
||||
toolUse.name === "Bash" &&
|
||||
toolUse.input !== null &&
|
||||
@@ -7680,13 +7690,58 @@ function claudeCodeMetaFromToolUse(toolUse: {
|
||||
typeof toolUse.input.description === "string"
|
||||
? toolUse.input.description
|
||||
: undefined;
|
||||
const skillName =
|
||||
toolUse.name === "Skill"
|
||||
? (toolUse.input as { skill?: string } | null | undefined)?.skill
|
||||
: undefined;
|
||||
const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
|
||||
return {
|
||||
toolName: toolUse.name,
|
||||
...(description ? { title: description } : {}),
|
||||
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true as const }),
|
||||
...(skillName ? { skill: skillName } : {}),
|
||||
...(skillPath ? { skillPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
||||
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"] as const;
|
||||
|
||||
/**
|
||||
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
|
||||
*
|
||||
* The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
|
||||
* actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
|
||||
* for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
|
||||
* directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
|
||||
* on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
|
||||
*/
|
||||
function resolveSkillPath(skillName: string, cwd?: string): string | undefined {
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
const colon = skillName.indexOf(":");
|
||||
const scope = colon < 0 ? undefined : skillName.slice(0, colon);
|
||||
const name = colon < 0 ? skillName : skillName.slice(colon + 1);
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
const addCandidates = (base: string) => {
|
||||
for (const container of SKILL_CONTAINER_DIRS) {
|
||||
candidates.push(path.join(base, container, name, "SKILL.md"));
|
||||
}
|
||||
};
|
||||
if (scope) {
|
||||
// A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
|
||||
addCandidates(path.join(cwd, scope));
|
||||
candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
|
||||
}
|
||||
addCandidates(cwd);
|
||||
addCandidates(os.homedir());
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
||||
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
||||
* the streamed tool_use path (first encounter → tool_call, later encounter →
|
||||
@@ -7703,7 +7758,7 @@ function toolCallNotification(
|
||||
): SessionNotification["update"] {
|
||||
if (refine) {
|
||||
return {
|
||||
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) } satisfies ToolUpdateMeta,
|
||||
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) } satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput,
|
||||
@@ -7712,7 +7767,7 @@ function toolCallNotification(
|
||||
}
|
||||
return {
|
||||
_meta: {
|
||||
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
||||
claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
|
||||
...(toolUse.name === "Bash" && supportsTerminalOutput
|
||||
? { terminal_info: { terminal_id: toolUse.id } }
|
||||
: {}),
|
||||
@@ -7748,7 +7803,7 @@ function streamedInputRefinement(
|
||||
);
|
||||
return {
|
||||
_meta: {
|
||||
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
||||
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
@@ -3174,3 +3177,147 @@ describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skill tool rendering", () => {
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
describe("toolInfoFromToolUse", () => {
|
||||
it("sets title to 'Load skill: <name>' and returns empty content", () => {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: "Skill", id: "toolu_1", input: { skill: "commits" } },
|
||||
false,
|
||||
);
|
||||
expect(info.title).toBe("Load skill: commits");
|
||||
expect(info.kind).toBe("other");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to 'Load skill' when skill name is absent", () => {
|
||||
const info = toolInfoFromToolUse({ name: "Skill", id: "toolu_2", input: {} }, false);
|
||||
expect(info.title).toBe("Load skill");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not throw when input is undefined", () => {
|
||||
const info = toolInfoFromToolUse({ name: "Skill", id: "toolu_3", input: undefined }, false);
|
||||
expect(info.title).toBe("Load skill");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUpdateFromToolResult", () => {
|
||||
it("suppresses the raw 'Launching skill' result text", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_4",
|
||||
name: "Skill",
|
||||
input: { skill: "commits" },
|
||||
};
|
||||
const toolResult = {
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: "toolu_4",
|
||||
content: "Launching skill: commits",
|
||||
is_error: false,
|
||||
};
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse, false);
|
||||
expect(update).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("_meta.claudeCode.skill in tool_call notification", () => {
|
||||
it("includes skill name in _meta.claudeCode when Skill tool is invoked", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{ type: "tool_use", id: "toolu_5", name: "Skill", input: { skill: "commits", args: "" } },
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
);
|
||||
expect(notifications[0]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
_meta: { claudeCode: { toolName: "Skill", skill: "commits" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits skill from _meta.claudeCode when skill name is missing", () => {
|
||||
const notifications = toAcpNotifications(
|
||||
[{ type: "tool_use", id: "toolu_6", name: "Skill", input: {} }] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
);
|
||||
const meta = (notifications[0]?.update as any)?._meta?.claudeCode;
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.skill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("_meta.claudeCode.skillPath", () => {
|
||||
const skillMeta = (skill: string, cwd?: string) =>
|
||||
(
|
||||
toAcpNotifications(
|
||||
[{ type: "tool_use", id: "toolu_skill_path", name: "Skill", input: { skill } }] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
cwd ? { cwd } : undefined,
|
||||
)[0]?.update as any
|
||||
)?._meta?.claudeCode;
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(path.join(tmpdir(), "acp-skill-path-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeSkill = (relativeDir: string) => {
|
||||
const dir = path.join(root, relativeDir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "SKILL.md");
|
||||
writeFileSync(file, "# skill\n");
|
||||
return file;
|
||||
};
|
||||
|
||||
it("resolves a project-level .claude/skills skill", () => {
|
||||
const file = writeSkill(".claude/skills/commits");
|
||||
expect(skillMeta("commits", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("resolves a project-level .agents/skills skill", () => {
|
||||
const file = writeSkill(".agents/skills/commits");
|
||||
expect(skillMeta("commits", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("resolves a directory-scoped skill spelled prefix:name", () => {
|
||||
const file = writeSkill("apps/web/.claude/skills/deploy");
|
||||
expect(skillMeta("apps/web:deploy", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("resolves a plugin skill spelled plugin:name", () => {
|
||||
const file = writeSkill(".claude/plugins/reviewer/skills/audit");
|
||||
expect(skillMeta("reviewer:audit", root).skillPath).toBe(file);
|
||||
});
|
||||
|
||||
it("omits skillPath when no known layout holds the skill", () => {
|
||||
const meta = skillMeta("nonexistent", root);
|
||||
expect(meta.skill).toBe("nonexistent");
|
||||
expect(meta.skillPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits skillPath when the session has no cwd", () => {
|
||||
writeSkill(".claude/skills/commits");
|
||||
expect(skillMeta("commits").skillPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
14
src/tools.ts
14
src/tools.ts
@@ -441,6 +441,16 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
const input = toolUse.input as { skill?: string; args?: string } | undefined;
|
||||
const skillName = input?.skill;
|
||||
return {
|
||||
title: skillName ? `Load skill: ${skillName}` : "Load skill",
|
||||
kind: "other",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
@@ -863,6 +873,10 @@ export function toolUpdateFromToolResult(
|
||||
);
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Edit": // Edit is handled in hooks
|
||||
case "Write": {
|
||||
return {};
|
||||
|
||||
Reference in New Issue
Block a user