From c5bcec5c7108677231f2c63fb48de7b245356b76 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 9 Jun 2026 17:15:03 +0700 Subject: [PATCH] Show skill autocomplete anywhere in prompts --- .../app/src/hooks/use-agent-autocomplete.ts | 182 ++++++++++++------ .../app/src/hooks/use-agent-commands-query.ts | 1 + .../utils/agent-command-autocomplete.test.ts | 85 +++++++- .../src/utils/agent-command-autocomplete.ts | 71 +++++++ .../src/messages.list-commands.test.ts | 59 +++++- packages/protocol/src/messages.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 3 + .../server/agent/providers/acp-agent.test.ts | 4 + .../src/server/agent/providers/acp-agent.ts | 1 + .../server/agent/providers/claude/agent.ts | 1 + .../providers/codex-app-server-agent.test.ts | 3 + .../agent/providers/codex-app-server-agent.ts | 5 + .../server/agent/providers/opencode-agent.ts | 15 +- .../server/agent/providers/pi/agent.test.ts | 12 +- .../src/server/agent/providers/pi/agent.ts | 11 ++ 15 files changed, 393 insertions(+), 61 deletions(-) diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 0e005da41..370609a6f 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -11,7 +11,13 @@ import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; -import { filterAndRankCommandAutocompleteEntries } from "@/utils/agent-command-autocomplete"; +import { + applySlashCommandReplacement, + filterAndRankCommandAutocompleteEntries, + filterInlineSkillCommandEntries, + findActiveSlashCommand, + type SlashCommandRange, +} from "@/utils/agent-command-autocomplete"; import { applyFileMentionReplacement, findActiveFileMention, @@ -133,6 +139,63 @@ function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption { type AutocompleteMode = "command" | "file" | null; +interface BuildAutocompleteOptionsInput { + isVisible: boolean; + mode: AutocompleteMode; + commands: AgentSlashCommand[]; + isDraftContext: boolean; + commandFilterQuery: string; + activeSlashCommand: SlashCommandRange | null; + activeFileMention: FileMentionRange | null; + fileSuggestions: DirectorySuggestionEntry[]; +} + +function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) { + if (!input.isVisible) { + return []; + } + + if (input.mode === "command") { + const providerCommands = input.commands.map( + (command): AvailableCommand => ({ source: "provider", command }), + ); + const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name)); + const rootCommands: AvailableCommand[] = input.isDraftContext + ? providerCommands + : [ + ...CLIENT_SLASH_COMMANDS.map( + (command): AvailableCommand => ({ source: "client", command }), + ), + ...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)), + ]; + const availableCommands = + input.activeSlashCommand?.position === "inline" + ? filterInlineSkillCommandEntries(providerCommands) + : rootCommands; + const matches = filterAndRankCommandAutocompleteEntries( + availableCommands, + input.commandFilterQuery, + ); + const orderedMatches = orderAutocompleteOptions(matches); + return orderedMatches.map(mapCommandToOption); + } + + const activeFileMention = input.activeFileMention; + if (input.mode === "file" && activeFileMention) { + const orderedEntries = orderAutocompleteOptions(input.fileSuggestions); + return orderedEntries.map((entry) => ({ + type: "workspace_entry" as const, + id: `${entry.kind}:${entry.path}`, + label: entry.path, + kind: entry.kind, + entryPath: entry.path, + mention: activeFileMention, + })); + } + + return []; +} + function resolveAutocompleteMode(args: { showFileAutocomplete: boolean; showCommandAutocomplete: boolean; @@ -161,6 +224,17 @@ function resolveAutocompleteIsVisible(args: { return false; } +function resolveCanLoadCommands(args: { + serverId: string; + agentId: string; + isDraftContext: boolean; +}): boolean { + if (!args.serverId) { + return false; + } + return Boolean(args.agentId) || args.isDraftContext; +} + function resolveAutocompleteIsLoading(args: { mode: AutocompleteMode; isCommandsLoading: boolean; @@ -209,8 +283,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut canExecuteClientSlashCommand, } = input; - const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); - const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ""; + const activeSlashCommand = useMemo( + () => + findActiveSlashCommand({ + text: userInput, + cursorIndex, + }), + [cursorIndex, userInput], + ); + const showCommandAutocomplete = activeSlashCommand !== null; + const commandFilterQuery = activeSlashCommand?.query ?? ""; const activeFileMention = useMemo( () => @@ -235,8 +317,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut ); const isDraftContext = normalizedDraftConfig !== undefined; - const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined; - const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext); + const queryDraftConfig = normalizedDraftConfig; + const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext }); const agentCwd = useSessionStore( (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "", @@ -309,54 +391,29 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut placeholderData: keepPreviousData, }); - const options = useMemo(() => { - if (!isVisible) { - return []; - } - - if (mode === "command") { - const providerCommands = commands.map( - (command): AvailableCommand => ({ source: "provider", command }), - ); - const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name)); - const availableCommands: AvailableCommand[] = isDraftContext - ? providerCommands - : [ - ...CLIENT_SLASH_COMMANDS.map( - (command): AvailableCommand => ({ source: "client", command }), - ), - ...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)), - ]; - const matches = filterAndRankCommandAutocompleteEntries( - availableCommands, + const options = useMemo( + () => + buildCommandAutocompleteOptions({ + activeFileMention, commandFilterQuery, - ); - const orderedMatches = orderAutocompleteOptions(matches); - return orderedMatches.map(mapCommandToOption); - } - - if (mode === "file" && activeFileMention) { - const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []); - return orderedEntries.map((entry) => ({ - type: "workspace_entry" as const, - id: `${entry.kind}:${entry.path}`, - label: entry.path, - kind: entry.kind, - entryPath: entry.path, - mention: activeFileMention, - })); - } - - return []; - }, [ - activeFileMention, - commandFilterQuery, - commands, - fileSuggestionsQuery.data, - isDraftContext, - isVisible, - mode, - ]); + commands, + activeSlashCommand, + fileSuggestions: fileSuggestionsQuery.data ?? [], + isDraftContext, + isVisible, + mode, + }), + [ + activeFileMention, + activeSlashCommand, + commandFilterQuery, + commands, + fileSuggestionsQuery.data, + isDraftContext, + isVisible, + mode, + ], + ); const onSelectOption = useCallback( (option: AutocompleteOption) => { @@ -372,7 +429,20 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut } if (selected.type === "client_command" || selected.type === "provider_command") { - setUserInput(`/${selected.id} `); + if (!activeSlashCommand) { + setUserInput(`/${selected.id} `); + onAutocompleteApplied?.(); + return; + } + + const nextInput = applySlashCommandReplacement({ + text: userInput, + command: activeSlashCommand, + commandName: selected.id, + }); + const shouldAppendSpace = + activeSlashCommand.position === "start" && activeSlashCommand.end === userInput.length; + setUserInput(shouldAppendSpace ? `${nextInput} ` : nextInput); onAutocompleteApplied?.(); return; } @@ -391,6 +461,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut onClientSlashCommand, setUserInput, userInput, + activeSlashCommand, ], ); @@ -399,7 +470,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut options, query: mode === "command" ? commandFilterQuery : fileFilterQuery, onSelectOption, - onEscape: mode === "command" ? () => setUserInput("") : undefined, + onEscape: + mode === "command" && activeSlashCommand?.position === "start" + ? () => setUserInput("") + : undefined, }); const isLoading = resolveAutocompleteIsLoading({ diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 05071d903..392c16f8e 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -9,6 +9,7 @@ export interface AgentSlashCommand { name: string; description: string; argumentHint: string; + kind?: string; } export interface DraftCommandConfig { diff --git a/packages/app/src/utils/agent-command-autocomplete.test.ts b/packages/app/src/utils/agent-command-autocomplete.test.ts index 53c01efd2..641081cfc 100644 --- a/packages/app/src/utils/agent-command-autocomplete.test.ts +++ b/packages/app/src/utils/agent-command-autocomplete.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { filterAndRankCommandAutocompleteEntries } from "./agent-command-autocomplete"; +import { + applySlashCommandReplacement, + filterAndRankCommandAutocompleteEntries, + filterInlineSkillCommandEntries, + findActiveSlashCommand, +} from "./agent-command-autocomplete"; describe("filterAndRankCommandAutocompleteEntries", () => { const entries = [ @@ -27,3 +32,81 @@ describe("filterAndRankCommandAutocompleteEntries", () => { expect(result.map((entry) => entry.command.name)).toEqual(["exit"]); }); }); + +describe("findActiveSlashCommand", () => { + it("detects a slash command token in the middle of the prompt", () => { + const text = "use /tas before implementation"; + + expect( + findActiveSlashCommand({ + text, + cursorIndex: "use /tas".length, + }), + ).toEqual({ + start: 4, + end: "use /tas".length, + query: "tas", + position: "inline", + }); + }); + + it("classifies a slash command token at the prompt start", () => { + expect( + findActiveSlashCommand({ + text: "/rew", + cursorIndex: "/rew".length, + }), + ).toEqual({ + start: 0, + end: "/rew".length, + query: "rew", + position: "start", + }); + }); + + it("returns null when the cursor is outside the slash token", () => { + expect( + findActiveSlashCommand({ + text: "use /taste now", + cursorIndex: "use /taste now".length, + }), + ).toBeNull(); + }); + + it("returns null for slash-delimited paths", () => { + expect( + findActiveSlashCommand({ + text: "read /tmp/project", + cursorIndex: "read /tmp/project".length, + }), + ).toBeNull(); + }); +}); + +describe("applySlashCommandReplacement", () => { + it("replaces only the active slash token", () => { + const text = "use /tas before implementation"; + + expect( + applySlashCommandReplacement({ + text, + command: { start: 4, end: "use /tas".length, query: "tas", position: "inline" }, + commandName: "taste", + }), + ).toBe("use /taste before implementation"); + }); +}); + +describe("filterInlineSkillCommandEntries", () => { + it("keeps provider skills and drops executable commands", () => { + const entries = [ + { source: "client" as const, command: { name: "clear", kind: "command" } }, + { source: "provider" as const, command: { name: "compact", kind: "command" } }, + { source: "provider" as const, command: { name: "taste", kind: "skill" } }, + ]; + + expect(filterInlineSkillCommandEntries(entries).map((entry) => entry.command.name)).toEqual([ + "taste", + ]); + }); +}); diff --git a/packages/app/src/utils/agent-command-autocomplete.ts b/packages/app/src/utils/agent-command-autocomplete.ts index 8beb0c07b..60edcca02 100644 --- a/packages/app/src/utils/agent-command-autocomplete.ts +++ b/packages/app/src/utils/agent-command-autocomplete.ts @@ -4,9 +4,34 @@ interface CommandAutocompleteEntry { command: { name: string; aliases?: readonly string[]; + kind?: string; }; } +interface InlineSkillCommandEntry extends CommandAutocompleteEntry { + source: "provider" | "client"; +} + +export type SlashCommandPosition = "start" | "inline"; + +export interface SlashCommandRange { + start: number; + end: number; + query: string; + position: SlashCommandPosition; +} + +interface FindActiveSlashCommandInput { + text: string; + cursorIndex: number; +} + +interface ApplySlashCommandReplacementInput { + text: string; + command: SlashCommandRange; + commandName: string; +} + interface ScoredCommandAutocompleteEntry { entry: TEntry; score: MatchScore; @@ -46,3 +71,49 @@ export function filterAndRankCommandAutocompleteEntries scored.entry); } + +export function filterInlineSkillCommandEntries( + entries: readonly TEntry[], +): TEntry[] { + return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill"); +} + +const INVALID_SLASH_COMMAND_QUERY_CHARS = /[/\s\n\r\t"']/; + +export function findActiveSlashCommand( + input: FindActiveSlashCommandInput, +): SlashCommandRange | null { + const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length)); + const beforeCursor = input.text.slice(0, clampedCursor); + + for ( + let slashIndex = beforeCursor.lastIndexOf("/"); + slashIndex >= 0; + slashIndex = slashIndex === 0 ? -1 : beforeCursor.lastIndexOf("/", slashIndex - 1) + ) { + const previousCharacter = slashIndex > 0 ? input.text[slashIndex - 1] : ""; + if (previousCharacter && !/\s/.test(previousCharacter)) { + continue; + } + + const query = beforeCursor.slice(slashIndex + 1); + if (INVALID_SLASH_COMMAND_QUERY_CHARS.test(query)) { + continue; + } + + return { + start: slashIndex, + end: clampedCursor, + query, + position: slashIndex === 0 ? "start" : "inline", + }; + } + + return null; +} + +export function applySlashCommandReplacement(input: ApplySlashCommandReplacementInput): string { + const before = input.text.slice(0, input.command.start); + const after = input.text.slice(input.command.end); + return `${before}/${input.commandName}${after}`; +} diff --git a/packages/protocol/src/messages.list-commands.test.ts b/packages/protocol/src/messages.list-commands.test.ts index 0896c4250..b8a3d2f88 100644 --- a/packages/protocol/src/messages.list-commands.test.ts +++ b/packages/protocol/src/messages.list-commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { SessionInboundMessageSchema } from "./messages.js"; +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; describe("list_commands_request schema", () => { test("accepts legacy agent-only payload", () => { @@ -49,4 +49,61 @@ describe("list_commands_request schema", () => { }, }); }); + + test("preserves command kind metadata in responses", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "list_commands_response", + payload: { + agentId: "agent-123", + requestId: "req-123", + error: null, + commands: [ + { + name: "taste", + description: "Apply code taste", + argumentHint: "", + kind: "skill", + }, + ], + }, + }); + + expect(parsed.type).toBe("list_commands_response"); + if (parsed.type !== "list_commands_response") { + throw new Error("Expected list_commands_response message"); + } + expect(parsed.payload.commands).toEqual([ + { + name: "taste", + description: "Apply code taste", + argumentHint: "", + kind: "skill", + }, + ]); + }); + + test("falls back to command for unknown future command kinds", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "list_commands_response", + payload: { + agentId: "agent-123", + requestId: "req-123", + error: null, + commands: [ + { + name: "future-command", + description: "Future command kind", + argumentHint: "", + kind: "future-kind", + }, + ], + }, + }); + + expect(parsed.type).toBe("list_commands_response"); + if (parsed.type !== "list_commands_response") { + throw new Error("Expected list_commands_response message"); + } + expect(parsed.payload.commands[0]?.kind).toBe("command"); + }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index cff305f13..a241f71b2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3557,6 +3557,7 @@ const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), argumentHint: z.string(), + kind: z.enum(["command", "skill"]).optional().catch("command"), }); export const ListCommandsResponseSchema = z.object({ diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 93d92be51..b99f85dcc 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -481,6 +481,8 @@ export interface AgentRuntimeInfo { extra?: AgentMetadata; } +export type AgentSlashCommandKind = "command" | "skill"; + /** * Represents a slash command available in an agent session. * Commands are executed by sending them as prompts with / prefix. @@ -489,6 +491,7 @@ export interface AgentSlashCommand { name: string; description: string; argumentHint: string; + kind?: AgentSlashCommandKind; } export interface ListPersistedAgentsOptions { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index cfab810bc..3f87abc86 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1486,11 +1486,13 @@ describe("ACPAgentSession slash commands", () => { name: "research_codebase", description: "Search the workspace for relevant files", argumentHint: "", + kind: "command", }, { name: "create_plan", description: "Draft a plan for the requested work", argumentHint: "", + kind: "command", }, ]); @@ -1499,11 +1501,13 @@ describe("ACPAgentSession slash commands", () => { name: "research_codebase", description: "Search the workspace for relevant files", argumentHint: "", + kind: "command", }, { name: "create_plan", description: "Draft a plan for the requested work", argumentHint: "", + kind: "command", }, ]); }); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 17ea0146f..7dec971a8 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1989,6 +1989,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { name: command.name, description: command.description, argumentHint: "", + kind: "command", })); this.settleCommandsReady(); return []; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 852471e42..0f3719aee 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2081,6 +2081,7 @@ class ClaudeAgentSession implements AgentSession { name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, + kind: "command", }); } } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 51d6a6825..ff8e5b8a9 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -725,6 +725,7 @@ describe("Codex app-server provider", () => { name: "shipper", description: "Ship changes carefully.", argumentHint: "", + kind: "skill", }); expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd); } finally { @@ -983,6 +984,7 @@ describe("Codex app-server provider", () => { name: "paseo", description: "Shared orchestration skill.", argumentHint: "", + kind: "skill", }, ]); }); @@ -1890,6 +1892,7 @@ describe("Codex app-server provider", () => { name: "compact", description: "Summarize conversation to prevent hitting the context limit", argumentHint: "", + kind: "command", }); const handler = session.tryHandleOutOfBand?.("/compact"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 9f1dc6aa4..735c2c44a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -631,6 +631,7 @@ async function listCodexCustomPrompts(): Promise { name: `prompts:${name}`, description, argumentHint, + kind: "command", }; }), ); @@ -695,6 +696,7 @@ export async function listCodexSkills( name, description, argumentHint: "", + kind: "skill", }); } } @@ -3949,6 +3951,7 @@ export class CodexAppServerAgentSession implements AgentSession { name: skill.name, description: skill.description, argumentHint: "", + kind: "skill" as const, })); const fallbackSkills = appServerSkills.length === 0 @@ -3959,6 +3962,7 @@ export class CodexAppServerAgentSession implements AgentSession { name: "compact", description: "Summarize conversation to prevent hitting the context limit", argumentHint: "", + kind: "command", }, ]; if (this.goalsEnabled) { @@ -3966,6 +3970,7 @@ export class CodexAppServerAgentSession implements AgentSession { name: "goal", description: "Set, pause, resume, or clear the agent's goal", argumentHint: "[|pause|resume|clear]", + kind: "command", }); } return [...builtin, ...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) => diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c7568d450..64015685e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -252,8 +252,18 @@ const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000; const OPENCODE_METADATA_CONCURRENCY = 4; const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY); const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ - { name: "compact", description: "Compact the current session", argumentHint: "" }, - { name: "summarize", description: "Compact the current session", argumentHint: "" }, + { + name: "compact", + description: "Compact the current session", + argumentHint: "", + kind: "command", + }, + { + name: "summarize", + description: "Compact the current session", + argumentHint: "", + kind: "command", + }, ]; const OPENCODE_HEADERS_TIMEOUT_TOKENS = [ "headers timeout", @@ -1720,6 +1730,7 @@ async function listOpenCodeCommandsFromSdk( name: cmd.name, description: cmd.description ?? "", argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "", + kind: cmd.source === "skill" ? "skill" : "command", }); } diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index d4718cbed..8274a0eb0 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -830,15 +830,17 @@ describe("PiRpcAgentClient", () => { name: "compact", description: "Manually compact the session context", argumentHint: "[instructions]", + kind: "command", }, { name: "autocompact", description: "Toggle automatic context compaction", argumentHint: "[on|off|toggle]", + kind: "command", }, - { name: "review", description: "Review changes", argumentHint: "" }, - { name: "fix-tests", description: "Fix tests", argumentHint: "" }, - { name: "skill:docs", description: "Read docs", argumentHint: "" }, + { name: "review", description: "Review changes", argumentHint: "", kind: "command" }, + { name: "fix-tests", description: "Fix tests", argumentHint: "", kind: "command" }, + { name: "skill:docs", description: "Read docs", argumentHint: "", kind: "skill" }, ]); }); @@ -852,11 +854,13 @@ describe("PiRpcAgentClient", () => { name: "compact", description: "Manually compact the session context", argumentHint: "[instructions]", + kind: "command", }); await expect(session.listCommands()).resolves.toContainEqual({ name: "autocompact", description: "Toggle automatic context compaction", argumentHint: "[on|off|toggle]", + kind: "command", }); }); @@ -872,11 +876,13 @@ describe("PiRpcAgentClient", () => { name: "compact", description: "Compact from RPC", argumentHint: "[instructions]", + kind: "command", }, { name: "autocompact", description: "Auto compact from RPC", argumentHint: "[on|off|toggle]", + kind: "command", }, ]); }); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 2cb4be782..117cae2c8 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -23,6 +23,7 @@ import { type AgentSession, type AgentSessionConfig, type AgentSlashCommand, + type AgentSlashCommandKind, type AgentStreamEvent, type AgentUsage, type ListPersistedAgentsOptions, @@ -101,14 +102,23 @@ const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ name: "compact", description: "Manually compact the session context", argumentHint: "[instructions]", + kind: "command", }, { name: "autocompact", description: "Toggle automatic context compaction", argumentHint: "[on|off|toggle]", + kind: "command", }, ]; +function mapPiCommandKind(source: PiRpcSlashCommand["source"]): AgentSlashCommandKind { + if (source === "skill") { + return "skill"; + } + return "command"; +} + const PI_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -1190,6 +1200,7 @@ export class PiRpcAgentSession implements AgentSession { name: command.name, description: command.description ?? command.source, argumentHint: knownCommand?.argumentHint ?? "", + kind: mapPiCommandKind(command.source), }); } return [...mappedCommands.values()];