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:
nikita-ashihmin
2026-08-12 14:51:31 +04:00
committed by GitHub
parent ec73cd8560
commit 1f09e9a3ca
3 changed files with 224 additions and 8 deletions

View File

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

View File

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

View File

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