diff --git a/src/components/automations/composer-invocations.tsx b/src/components/automations/composer-invocations.tsx index 7c191145e0..45ef7316a0 100644 --- a/src/components/automations/composer-invocations.tsx +++ b/src/components/automations/composer-invocations.tsx @@ -87,7 +87,7 @@ export interface ComposerInvocations { * `isExternalMenuOpen`/`onExternalMenuKeyDown` escape hatch; a selected item * becomes a badge that serializes to its literal `/cmd` / `$skill` token on save. * Antigravity's and OpenCode's skills, which their servers never advertise, - * join their commands under `/`. + * and a custom agent's declared skills join their commands under `/`. */ export function useComposerInvocations({ editorRef, diff --git a/src/components/chat/composer/invocation-reference.test.ts b/src/components/chat/composer/invocation-reference.test.ts index ade3877ac7..0baf35c074 100644 --- a/src/components/chat/composer/invocation-reference.test.ts +++ b/src/components/chat/composer/invocation-reference.test.ts @@ -130,8 +130,16 @@ describe("diskSkillTrigger", () => { expect(diskSkillTrigger("open_code")).toBe("/") }) + // Custom ACP agents (e.g. user-configured Antigravity, or agents with declared + // skill directories) do not advertise skills as ACP commands; their skills + // live on disk and are invoked via `/`. + it("lists custom ACP agents' disk skills under `/`", () => { + expect(diskSkillTrigger("custom:antigravity-b")).toBe("/") + expect(diskSkillTrigger("custom:my-agent")).toBe("/") + }) + it("scans nothing for an agent that advertises its skills as commands", () => { - for (const agent of ["claude_code", "kimi_code", "gemini", "custom:x"]) { + for (const agent of ["claude_code", "kimi_code", "gemini"]) { expect(diskSkillTrigger(agent)).toBeNull() } expect(diskSkillTrigger(null)).toBeNull() diff --git a/src/components/chat/composer/invocation-reference.ts b/src/components/chat/composer/invocation-reference.ts index 977df932aa..65fcd9c287 100644 --- a/src/components/chat/composer/invocation-reference.ts +++ b/src/components/chat/composer/invocation-reference.ts @@ -1,8 +1,9 @@ import type { KnownInvocations } from "@/lib/invocation-token" -import type { - AgentSkillItem, - AgentType, - AvailableCommandInfo, +import { + isCustomAgentType, + type AgentSkillItem, + type AgentType, + type AvailableCommandInfo, } from "@/lib/types" import type { ReferenceAttrs } from "./types" @@ -36,12 +37,27 @@ export type InvocationPrefix = "/" | "$" * configured and MCP-prompt ones), while it still loads every skill, names * each to the model and gives the model a `skill` tool to load one by id. A * `/` the user sends reaches the model as written. + * - Custom ACP agents (`custom:`): `/`. codeg cannot tell whether an + * arbitrary ACP server advertises its skills (Antigravity's + * `agy_acp_server` run as a custom agent never does), so the scan follows the + * user's own declaration instead: a custom agent that declares a dedicated + * skills directory or the shared `.agents/skills` store lists those skills, + * and one that declares neither gets `supported: false` and an empty list. A + * declared skill the agent also advertises as a command is dropped by + * {@link skillsForMenu}, so it is not listed twice. */ export function diskSkillTrigger( agentType: AgentType | null | undefined ): InvocationPrefix | null { + if (!agentType) return null if (agentType === "codex") return "$" - if (agentType === "antigravity" || agentType === "open_code") return "/" + if ( + agentType === "antigravity" || + agentType === "open_code" || + isCustomAgentType(agentType) + ) { + return "/" + } return null } diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 1391fb4c23..3cef470850 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -1970,6 +1970,27 @@ describe("MessageInput slash menu for skills an agent does not advertise", () => expect(agentSkills).toHaveBeenCalledWith("antigravity", null) }) + it("lists custom ACP agents' skills from disk after their commands", async () => { + agentSkills.mockReturnValue([DEPLOY, SHADOWED]) + await mountAndType({ + agentType: "custom:antigravity-b", + availableCommands: COMMANDS, + }) + + const menu = await screen.findByTestId("slash-menu") + const rows = within(menu) + .getAllByRole("button") + .map((row) => row.textContent) + expect(rows).toEqual([ + expect.stringMatching(/^\/plan/), + expect.stringMatching(/^\/logout/), + expect.stringMatching(/^\/usage/), + "DeployShip the build", + ]) + expect(within(menu).queryByText("Plan skill")).toBeNull() + expect(agentSkills).toHaveBeenCalledWith("custom:antigravity-b", null) + }) + it("inserts a picked Antigravity skill as its `/` token", async () => { agentSkills.mockReturnValue([DEPLOY]) const { editor } = await mountAndType( diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index eaf650c401..519b3ab593 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -381,9 +381,11 @@ export function MessageInput({ // Skills read from disk feed the menu only for an agent that does not // advertise them itself: Codex under `$` (its native command set is very // small), Antigravity and OpenCode under `/` beside their commands (their - // ACP servers list only commands, never a skill). Every other agent surfaces - // its skills through ACP `availableCommands`, so a disk scan would only - // repeat them — skip it. + // ACP servers list only commands, never a skill), and custom agents under `/` + // from the skill directories their user declared (codeg cannot know what an + // arbitrary ACP server advertises). Every other built-in agent surfaces its + // skills through ACP `availableCommands`, so a disk scan would only repeat + // them — skip it. const skillTrigger = diskSkillTrigger(agentType) // Pass the working dir so we see both global skills and folder-scoped // project skills (e.g. `{folder}/.codex/skills`). Without this, users