diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 005f87e96b..32fd270b41 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -16,12 +16,13 @@ This keeps the system prompt small while still making skills discoverable. ## Where skills live -mux discovers skills from two roots: +mux discovers skills from three roots: - **Project-local**: `/.mux/skills//SKILL.md` - **Global**: `~/.mux/skills//SKILL.md` +- **Built-in**: shipped with mux -If a skill exists in both locations, **project-local overrides global**. +If a skill exists in multiple locations, the precedence order is: **project-local > global > built-in**. mux reads skills using the active workspace runtime. For SSH workspaces, skills are read from the @@ -80,6 +81,13 @@ metadata: mux injects an `` block into the system prompt listing the available skills. +You can apply a skill in two ways: + +- **Explicit (slash command)**: type `/{skill-name}` (optionally followed by a message: `/{skill-name} ...`) in the chat input. mux will send your message normally (or a small default message if you omit one), and persist a synthetic snapshot of the skill body in history immediately before that message. + - Example: `/init` runs the built-in `init` skill to bootstrap `AGENTS.md`. You can override it with `~/.mux/skills/init/SKILL.md` (or `.mux/skills/init/SKILL.md` for a single project). + - Type `/` to see skills in the suggestions list. +- **Agent-initiated (tool call)**: the agent can load skills on-demand. + To load a skill, the agent calls: ```ts @@ -101,7 +109,8 @@ agent_skill_read_file({ name: "my-skill", filePath: "references/template.md" }); ## Current limitations -- There is no `/skill` command or UI activation flow yet; skills are loaded on-demand via tools. +- Slash command invocation supports only a single skill as the first token (for example `/{skill-name}` or `/{skill-name} ...`). +- Skill bodies may be truncated when injected to avoid accidental mega-prompts. - `allowed-tools` is not enforced by mux (it is tolerated in frontmatter, but ignored). ## Further reading diff --git a/scripts/zizmor.sh b/scripts/zizmor.sh index 9827cfea56..e095789e81 100755 --- a/scripts/zizmor.sh +++ b/scripts/zizmor.sh @@ -8,6 +8,16 @@ # Fallback: if Docker isn't available/running (common on dev machines), download a # prebuilt zizmor binary from GitHub Releases and run it directly. +if ! command -v docker >/dev/null 2>&1; then + echo "⚠️ docker not found; skipping zizmor" >&2 + exit 0 +fi + +if ! docker info >/dev/null 2>&1; then + echo "⚠️ docker daemon not running; skipping zizmor" >&2 + exit 0 +fi + set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index f9c823310b..5a2d885c23 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -9,6 +9,7 @@ import { ConnectionStatusToast } from "../ConnectionStatusToast"; import { ChatInputToast } from "../ChatInputToast"; import { createCommandToast, createErrorToast } from "../ChatInputToasts"; import { ConfirmationModal } from "../ConfirmationModal"; +import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; import { readPersistedState, @@ -82,6 +83,7 @@ import { processImageFiles, } from "@/browser/utils/imageHandling"; +import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { ModeAiDefaults } from "@/common/types/modeAiDefaults"; import type { ParsedRuntime } from "@/common/types/runtime"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; @@ -104,15 +106,21 @@ import { } from "./draftImagesStorage"; import { RecordingOverlay } from "./RecordingOverlay"; import { ReviewBlockFromData } from "../shared/ReviewBlock"; -import initMessage from "@/browser/assets/initMessage.txt?raw"; // localStorage quotas are environment-dependent and relatively small. // Be conservative here so we can warn the user before writes start failing. const MAX_PERSISTED_IMAGE_DRAFT_CHARS = 4_000_000; +// Unknown slash commands are used for agent-skill invocations (/{skillName} ...). +type UnknownSlashCommand = Extract; + +function isUnknownSlashCommand(value: ParsedCommand): value is UnknownSlashCommand { + return value !== null && value.type === "unknown-command"; +} + // Import types from local types file import type { ChatInputProps, ChatInputAPI } from "./types"; -import type { ImagePart } from "@/common/orpc/types"; +import type { ImagePart, SendMessageOptions } from "@/common/orpc/types"; type CreationRuntimeValidationError = | { mode: "docker"; kind: "missingImage" } @@ -203,6 +211,7 @@ const ChatInputInner: React.FC = (props) => { const [hideReviewsDuringSend, setHideReviewsDuringSend] = useState(false); const [showAtMentionSuggestions, setShowAtMentionSuggestions] = useState(false); const [atMentionSuggestions, setAtMentionSuggestions] = useState([]); + const agentSkillsRequestIdRef = useRef(0); const atMentionDebounceRef = useRef | null>(null); const atMentionRequestIdRef = useRef(0); const lastAtMentionScopeIdRef = useRef(null); @@ -211,6 +220,7 @@ const ChatInputInner: React.FC = (props) => { const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState([]); + const [agentSkillDescriptors, setAgentSkillDescriptors] = useState([]); const [providerNames, setProviderNames] = useState([]); const [toast, setToast] = useState(null); // State for destructive command confirmation modal @@ -945,10 +955,14 @@ const ChatInputInner: React.FC = (props) => { // Watch input for slash commands useEffect(() => { - const suggestions = getSlashCommandSuggestions(input, { providerNames, variant }); + const suggestions = getSlashCommandSuggestions(input, { + providerNames, + agentSkills: agentSkillDescriptors, + variant, + }); setCommandSuggestions(suggestions); setShowCommandSuggestions(suggestions.length > 0); - }, [input, providerNames, variant]); + }, [input, providerNames, agentSkillDescriptors, variant]); // Load provider names for suggestions useEffect(() => { @@ -972,8 +986,61 @@ const ChatInputInner: React.FC = (props) => { }; }, [api]); - // Check if OpenAI API key is configured (for voice input) - // Subscribe to config changes so key status updates immediately when set in Settings + // Load agent skills for suggestions + useEffect(() => { + let isMounted = true; + const requestId = ++agentSkillsRequestIdRef.current; + + const loadAgentSkills = async () => { + if (!api) { + if (isMounted && agentSkillsRequestIdRef.current === requestId) { + setAgentSkillDescriptors([]); + } + return; + } + + const discoveryInput = + variant === "workspace" && workspaceId + ? { + workspaceId, + disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, + } + : variant === "creation" && atMentionProjectPath + ? { projectPath: atMentionProjectPath } + : null; + + if (!discoveryInput) { + if (isMounted && agentSkillsRequestIdRef.current === requestId) { + setAgentSkillDescriptors([]); + } + return; + } + + try { + const skills = await api.agentSkills.list(discoveryInput); + if (!isMounted || agentSkillsRequestIdRef.current !== requestId) { + return; + } + if (Array.isArray(skills)) { + setAgentSkillDescriptors(skills); + } + } catch (error) { + console.error("Failed to load agent skills:", error); + if (!isMounted || agentSkillsRequestIdRef.current !== requestId) { + return; + } + setAgentSkillDescriptors([]); + } + }; + + void loadAgentSkills(); + + return () => { + isMounted = false; + }; + }, [api, variant, workspaceId, atMentionProjectPath, sendMessageOptions.disableWorkspaceAgents]); + + // Voice input: track whether OpenAI API key is configured (subscribe to provider config changes) useEffect(() => { if (!api) return; const abortController = new AbortController(); @@ -1326,13 +1393,62 @@ const ChatInputInner: React.FC = (props) => { // Route to creation handler for creation variant if (variant === "creation") { - // Handle /init command in creation variant - populate input with init message + let creationMessageTextForSend = messageText; + let creationOptionsOverride: Partial | undefined; + if (messageText.startsWith("/")) { const parsed = parseCommand(messageText); - if (parsed?.type === "init") { - setInput(initMessage); - focusMessageInput(); - return; + + if (isUnknownSlashCommand(parsed)) { + const command = parsed.command; + const prefix = `/${command}`; + const afterPrefix = messageText.slice(prefix.length); + const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); + + if (hasSeparator) { + let skill: AgentSkillDescriptor | undefined = agentSkillDescriptors.find( + (candidate) => candidate.name === command + ); + + if (!skill && api && atMentionProjectPath) { + try { + const pkg = await api.agentSkills.get({ + projectPath: atMentionProjectPath, + skillName: command, + }); + skill = { + name: pkg.frontmatter.name, + description: pkg.frontmatter.description, + scope: pkg.scope, + }; + } catch { + // Not a skill (or not available yet) - fall through. + } + } + + if (skill) { + const userText = afterPrefix.trimStart() || `Run the "${skill.name}" skill.`; + + if (!api) { + pushToast({ type: "error", message: "Not connected to server" }); + return; + } + + creationMessageTextForSend = userText; + creationOptionsOverride = { + muxMetadata: { + type: "agent-skill", + rawCommand: messageText, + skillName: skill.name, + scope: skill.scope, + }, + // In the creation flow, skills are discovered from the project path. If the skill is + // project-scoped (often untracked in git), it may not exist in the new worktree. + // Force project-path discovery for this send so resolution matches suggestions. + ...(skill.scope === "project" ? { disableWorkspaceAgents: true } : {}), + }; + } + } } } @@ -1349,8 +1465,9 @@ const ChatInputInner: React.FC = (props) => { // Creation variant: simple message send + workspace creation const creationImageParts = imageAttachmentsToImageParts(imageAttachments); const ok = await creationState.handleSend( - messageText, - creationImageParts.length > 0 ? creationImageParts : undefined + creationMessageTextForSend, + creationImageParts.length > 0 ? creationImageParts : undefined, + creationOptionsOverride ); if (ok) { setInput(""); @@ -1369,7 +1486,46 @@ const ChatInputInner: React.FC = (props) => { try { // Parse command - const parsed = parseCommand(messageText); + let parsed = parseCommand(messageText); + + let skillInvocation: { descriptor: AgentSkillDescriptor; userText: string } | null = null; + + if (isUnknownSlashCommand(parsed)) { + const command = parsed.command; + const prefix = `/${command}`; + const afterPrefix = messageText.slice(prefix.length); + const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); + + if (hasSeparator) { + let skill: AgentSkillDescriptor | undefined = agentSkillDescriptors.find( + (candidate) => candidate.name === command + ); + + if (!skill && api && workspaceId) { + try { + const pkg = await api.agentSkills.get({ + workspaceId, + disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, + skillName: command, + }); + skill = { + name: pkg.frontmatter.name, + description: pkg.frontmatter.description, + scope: pkg.scope, + }; + } catch { + // Not a skill (or not available yet) - fall through. + } + } + + if (skill) { + const userText = afterPrefix.trimStart() || `Run the "${skill.name}" skill.`; + + skillInvocation = { descriptor: skill, userText }; + parsed = null; + } + } + } if (parsed) { // Handle /clear command - show confirmation modal @@ -1436,13 +1592,6 @@ const ChatInputInner: React.FC = (props) => { return; } - // Handle /init command - populate input with init message - if (parsed.type === "init") { - setInput(initMessage); - focusMessageInput(); - return; - } - // Handle other non-API commands (help, invalid args, etc) const commandToast = createCommandToast(parsed); if (commandToast) { @@ -1658,6 +1807,8 @@ const ChatInputInner: React.FC = (props) => { } // Regular message - send directly via API + const messageTextForSend = skillInvocation?.userText ?? messageText; + if (!api) { pushToast({ type: "error", message: "Not connected to server" }); return; @@ -1694,6 +1845,11 @@ const ChatInputInner: React.FC = (props) => { // Clear input immediately for responsive UX setInput(""); + + const compactionSendMessageOptions: SendMessageOptions = { + ...sendMessageOptions, + }; + setImageAttachments([]); setHideReviewsDuringSend(true); @@ -1702,13 +1858,21 @@ const ChatInputInner: React.FC = (props) => { api, workspaceId: props.workspaceId, continueMessage: buildContinueMessage({ - text: messageText, + text: messageTextForSend, imageParts, reviews: reviewsData, + muxMetadata: skillInvocation + ? { + type: "agent-skill", + rawCommand: messageText, + skillName: skillInvocation.descriptor.name, + scope: skillInvocation.descriptor.scope, + } + : undefined, model: sendMessageOptions.model, agentId: sendMessageOptions.agentId ?? "exec", }), - sendMessageOptions, + sendMessageOptions: compactionSendMessageOptions, }); if (!result.success) { @@ -1761,11 +1925,18 @@ const ChatInputInner: React.FC = (props) => { attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined; // When editing a /compact command, regenerate the actual summarization request - let actualMessageText = messageText; - let muxMetadata: MuxFrontendMetadata | undefined; - let compactionOptions = {}; + let actualMessageText = messageTextForSend; + let muxMetadata: MuxFrontendMetadata | undefined = skillInvocation + ? { + type: "agent-skill", + rawCommand: messageText, + skillName: skillInvocation.descriptor.name, + scope: skillInvocation.descriptor.scope, + } + : undefined; + let compactionOptions: Partial = {}; - if (editingMessage && messageText.startsWith("/")) { + if (editingMessage && actualMessageText.startsWith("/")) { const parsed = parseCommand(messageText); if (parsed?.type === "compact") { const { @@ -1798,6 +1969,12 @@ const ChatInputInner: React.FC = (props) => { { text: actualMessageText, reviews: reviewsData }, muxMetadata ); + // When editing /compact, compactionOptions already includes the base sendMessageOptions. + // Avoid duplicating additionalSystemInstructions. + const additionalSystemInstructions = + compactionOptions.additionalSystemInstructions ?? + sendMessageOptions.additionalSystemInstructions; + muxMetadata = reviewMetadata; // Capture review IDs before clearing (for marking as checked on success) @@ -1820,6 +1997,7 @@ const ChatInputInner: React.FC = (props) => { options: { ...sendMessageOptions, ...compactionOptions, + additionalSystemInstructions, editMessageId: editingMessage?.id, imageParts: sendImageParts, muxMetadata, diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index c12ee6dcb7..a4f50ca3c3 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -18,7 +18,7 @@ import { } from "@/common/constants/storage"; import type { Toast } from "@/browser/components/ChatInputToast"; import { useAPI } from "@/browser/contexts/API"; -import type { ImagePart } from "@/common/orpc/types"; +import type { ImagePart, SendMessageOptions } from "@/common/orpc/types"; import { useWorkspaceName, type WorkspaceNameState, @@ -96,7 +96,11 @@ interface UseCreationWorkspaceReturn { toast: Toast | null; setToast: (toast: Toast | null) => void; isSending: boolean; - handleSend: (message: string, imageParts?: ImagePart[]) => Promise; + handleSend: ( + message: string, + imageParts?: ImagePart[], + optionsOverride?: Partial + ) => Promise; /** Workspace name/title generation state and actions (for CreationControls) */ nameState: WorkspaceNameState; /** The confirmed identity being used for creation (null until generation resolves) */ @@ -211,7 +215,11 @@ export function useCreationWorkspace({ }, [projectPath, api]); const handleSend = useCallback( - async (messageText: string, imageParts?: ImagePart[]): Promise => { + async ( + messageText: string, + imageParts?: ImagePart[], + optionsOverride?: Partial + ): Promise => { if (!messageText.trim() || isSending || !api) return false; // Build runtime config early (used later for workspace creation) @@ -303,11 +311,22 @@ export function useCreationWorkspace({ // Fire sendMessage in the background - stream errors will be shown in the workspace UI // via the normal stream-error event handling. We don't await this. + const additionalSystemInstructions = [ + sendMessageOptions.additionalSystemInstructions, + optionsOverride?.additionalSystemInstructions, + ] + .filter((part) => typeof part === "string" && part.trim().length > 0) + .join("\n\n"); + void api.workspace.sendMessage({ workspaceId: metadata.id, message: messageText, options: { ...sendMessageOptions, + ...optionsOverride, + additionalSystemInstructions: additionalSystemInstructions.length + ? additionalSystemInstructions + : undefined, imageParts: imageParts && imageParts.length > 0 ? imageParts : undefined, }, }); diff --git a/src/browser/components/CommandPalette.tsx b/src/browser/components/CommandPalette.tsx index cf60bcd6e2..e3cdce8d83 100644 --- a/src/browser/components/CommandPalette.tsx +++ b/src/browser/components/CommandPalette.tsx @@ -1,6 +1,9 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Command } from "cmdk"; import { useCommandRegistry } from "@/browser/contexts/CommandRegistryContext"; +import { useAPI } from "@/browser/contexts/API"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; import { formatKeybind, @@ -11,6 +14,7 @@ import { import { stopKeyboardPropagation } from "@/browser/utils/events"; import { getSlashCommandSuggestions } from "@/browser/utils/slashCommands/suggestions"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; +import { getDisableWorkspaceAgentsKey, GLOBAL_SCOPE_ID } from "@/common/constants/storage"; import { filterCommandsByPrefix } from "@/browser/utils/commandPaletteFiltering"; interface CommandPaletteProps { @@ -38,6 +42,19 @@ interface PaletteGroup { } export const CommandPalette: React.FC = ({ getSlashContext }) => { + const { api } = useAPI(); + + const slashContext = getSlashContext?.(); + const slashWorkspaceId = slashContext?.workspaceId; + + const [disableWorkspaceAgents] = usePersistedState( + getDisableWorkspaceAgentsKey(slashWorkspaceId ?? GLOBAL_SCOPE_ID), + false, + { listener: true } + ); + + const [agentSkills, setAgentSkills] = useState([]); + const agentSkillsCacheRef = useRef>(new Map()); const { isOpen, close, getActions, addRecent, recent } = useCommandRegistry(); const [query, setQuery] = useState(""); const [activePrompt, setActivePrompt] = useState = ({ getSlashContext } }, [isOpen, resetPaletteState]); + useEffect(() => { + if (!isOpen || !api || !slashWorkspaceId) { + setAgentSkills([]); + return; + } + + const cacheKey = `${slashWorkspaceId}:${disableWorkspaceAgents ? "project" : "worktree"}`; + + const cached = agentSkillsCacheRef.current.get(cacheKey); + if (cached) { + setAgentSkills(cached); + return; + } + + let cancelled = false; + api.agentSkills + .list({ + workspaceId: slashWorkspaceId, + disableWorkspaceAgents: disableWorkspaceAgents || undefined, + }) + .then((skills) => { + if (cancelled) return; + agentSkillsCacheRef.current.set(cacheKey, skills); + setAgentSkills(skills); + }) + .catch(() => { + if (cancelled) return; + setAgentSkills([]); + }); + + return () => { + cancelled = true; + }; + }, [api, isOpen, slashWorkspaceId, disableWorkspaceAgents]); + const rawActions = getActions(); const recentIndex = useMemo(() => { @@ -184,8 +236,12 @@ export const CommandPalette: React.FC = ({ getSlashContext const q = query.trim(); if (q.startsWith("/")) { - const ctx = getSlashContext?.() ?? { providerNames: [] }; - const suggestions = getSlashCommandSuggestions(q, { providerNames: ctx.providerNames }); + const ctx = getSlashContext?.() ?? { providerNames: [] as string[] }; + const suggestions = getSlashCommandSuggestions(q, { + providerNames: ctx.providerNames, + agentSkills, + variant: ctx.workspaceId ? "workspace" : "creation", + }); const section = "Slash Commands"; const groups: PaletteGroup[] = [ { @@ -241,7 +297,7 @@ export const CommandPalette: React.FC = ({ getSlashContext groups, emptyText: filtered.length ? undefined : "No results", } satisfies { groups: PaletteGroup[]; emptyText: string | undefined }; - }, [query, rawActions, recentIndex, getSlashContext]); + }, [query, rawActions, recentIndex, getSlashContext, agentSkills]); useEffect(() => { if (!activePrompt) return; diff --git a/src/browser/components/CommandSuggestions.tsx b/src/browser/components/CommandSuggestions.tsx index 2d1ad2bc3c..28470ba77f 100644 --- a/src/browser/components/CommandSuggestions.tsx +++ b/src/browser/components/CommandSuggestions.tsx @@ -283,7 +283,10 @@ export const CommandSuggestions: React.FC = ({
-
+
{suggestion.description}
diff --git a/src/browser/components/ProjectPage.tsx b/src/browser/components/ProjectPage.tsx index 4df7004b90..7bc856604c 100644 --- a/src/browser/components/ProjectPage.tsx +++ b/src/browser/components/ProjectPage.tsx @@ -16,7 +16,6 @@ import { ConfigureProvidersPrompt } from "./ConfigureProvidersPrompt"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import { AgentsInitBanner } from "./AgentsInitBanner"; -import initMessage from "@/browser/assets/initMessage.txt?raw"; import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getAgentIdKey, @@ -201,16 +200,16 @@ export const ProjectPage: React.FC = ({ // Switch project-scope mode to exec. updatePersistedState(getAgentIdKey(getProjectScopeId(projectPath)), "exec"); - // Prefill the AGENTS bootstrap prompt and start the creation chat. + // Run the /init skill and start the creation chat. if (chatInputRef.current) { - chatInputRef.current.restoreText(initMessage); + chatInputRef.current.restoreText("/init"); requestAnimationFrame(() => { void chatInputRef.current?.send(); }); } else { pendingAgentsInitSendRef.current = true; const pendingScopeId = getPendingScopeId(projectPath); - updatePersistedState(getInputKey(pendingScopeId), initMessage); + updatePersistedState(getInputKey(pendingScopeId), "/init"); } setShowAgentsInitNudge(false); @@ -222,7 +221,7 @@ export const ProjectPage: React.FC = ({ if (pendingAgentsInitSendRef.current) { pendingAgentsInitSendRef.current = false; didAutoFocusRef.current = true; - api.restoreText(initMessage); + api.restoreText("/init"); requestAnimationFrame(() => { void api.send(); }); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 4759c441dc..32fa2a136c 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -1739,6 +1739,10 @@ export class StreamingMessageAggregator { } : undefined; + const userDisplayText = + compactionRequest?.rawCommand ?? + (muxMeta?.type === "agent-skill" ? muxMeta.rawCommand : content); + // Extract reviews from muxMetadata for rich UI display (orthogonal to message type) const reviews = muxMeta?.reviews; @@ -1746,7 +1750,7 @@ export class StreamingMessageAggregator { type: "user", id: message.id, historyId: message.id, - content: compactionRequest ? compactionRequest.rawCommand : content, + content: userDisplayText, imageParts: imageParts.length > 0 ? imageParts : undefined, historySequence, isSynthetic: message.metadata?.synthetic === true ? true : undefined, diff --git a/src/browser/utils/slashCommands/parser.test.ts b/src/browser/utils/slashCommands/parser.test.ts index 929291cf7a..72ae02c7c1 100644 --- a/src/browser/utils/slashCommands/parser.test.ts +++ b/src/browser/utils/slashCommands/parser.test.ts @@ -228,11 +228,15 @@ describe("plan commands", () => { }); describe("init command", () => { - it("should parse /init", () => { - expectParse("/init", { type: "init" }); + it("should parse /init as unknown-command (handled as a skill invocation)", () => { + expectParse("/init", { + type: "unknown-command", + command: "init", + subcommand: undefined, + }); }); - it("should reject /init with arguments", () => { + it("should parse /init with arguments as unknown-command", () => { expectParse("/init extra", { type: "unknown-command", command: "init", diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index 50e2c4640d..01a0251bb5 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -447,23 +447,6 @@ const vimCommandDefinition: SlashCommandDefinition = { }, }; -const initCommandDefinition: SlashCommandDefinition = { - key: "init", - description: "Bootstrap an AGENTS.md file in a new or existing project", - appendSpace: false, - handler: ({ cleanRemainingTokens }): ParsedCommand => { - if (cleanRemainingTokens.length > 0) { - return { - type: "unknown-command", - command: "init", - subcommand: cleanRemainingTokens[0], - }; - } - - return { type: "init" }; - }, -}; - const planOpenCommandDefinition: SlashCommandDefinition = { key: "open", description: "Open plan in external editor", @@ -715,7 +698,6 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ vimCommandDefinition, mcpCommandDefinition, idleCommandDefinition, - initCommandDefinition, debugLlmRequestCommandDefinition, ]; diff --git a/src/browser/utils/slashCommands/suggestions.test.ts b/src/browser/utils/slashCommands/suggestions.test.ts index def6a313ac..8ef289cb68 100644 --- a/src/browser/utils/slashCommands/suggestions.test.ts +++ b/src/browser/utils/slashCommands/suggestions.test.ts @@ -16,6 +16,23 @@ describe("getSlashCommandSuggestions", () => { expect(labels).toContain("/providers"); }); + it("includes agent skills when provided in context", () => { + const suggestions = getSlashCommandSuggestions("/", { + agentSkills: [ + { + name: "test-skill", + description: "Test skill description", + scope: "project", + }, + ], + }); + + const skillSuggestion = suggestions.find((s) => s.display === "/test-skill"); + expect(skillSuggestion).toBeTruthy(); + expect(skillSuggestion?.replacement).toBe("/test-skill "); + expect(skillSuggestion?.description).toContain("(project)"); + }); + it("filters top level commands by partial input", () => { const suggestions = getSlashCommandSuggestions("/cl"); expect(suggestions).toHaveLength(1); diff --git a/src/browser/utils/slashCommands/suggestions.ts b/src/browser/utils/slashCommands/suggestions.ts index e6a23f01ac..be974f581c 100644 --- a/src/browser/utils/slashCommands/suggestions.ts +++ b/src/browser/utils/slashCommands/suggestions.ts @@ -39,7 +39,7 @@ function buildTopLevelSuggestions( ): SlashSuggestion[] { const isCreation = context.variant === "creation"; - return filterAndMapSuggestions( + const commandSuggestions = filterAndMapSuggestions( COMMAND_DEFINITIONS, partial, (definition) => { @@ -55,6 +55,33 @@ function buildTopLevelSuggestions( // In creation mode, filter out workspace-only commands isCreation ? (definition) => !WORKSPACE_ONLY_COMMANDS.has(definition.key) : undefined ); + + const formatScopeLabel = (scope: string): string => { + if (scope === "global") { + return "user"; + } + return scope; + }; + + const skillDefinitions: SuggestionDefinition[] = (context.agentSkills ?? []) + .filter((skill) => !SLASH_COMMAND_DEFINITION_MAP.has(skill.name)) + .map((skill) => ({ + key: skill.name, + description: `${skill.description} (${formatScopeLabel(skill.scope)})`, + appendSpace: true, + })); + + const skillSuggestions = filterAndMapSuggestions(skillDefinitions, partial, (definition) => { + const replacement = `/${definition.key} `; + return { + id: `skill:${definition.key}`, + display: `/${definition.key}`, + description: definition.description, + replacement, + }; + }); + + return [...commandSuggestions, ...skillSuggestions]; } function buildSubcommandSuggestions( diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index ad2a63e9c1..b1b118a5cb 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -9,6 +9,8 @@ * for new commands. */ +import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; + export type ParsedCommand = | { type: "providers-set"; provider: string; keyPath: string[]; value: string } | { type: "providers-help" } @@ -36,7 +38,6 @@ export type ParsedCommand = | { type: "plan-show" } | { type: "plan-open" } | { type: "debug-llm-request" } - | { type: "init" } | { type: "unknown-command"; command: string; subcommand?: string } | { type: "idle-compaction"; hours: number | null } | null; @@ -78,6 +79,7 @@ export interface SlashSuggestion { } export interface SlashSuggestionContext { + agentSkills?: AgentSkillDescriptor[]; providerNames?: string[]; /** Variant determines which commands are available */ variant?: "workspace" | "creation"; diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index c8ab80d6ed..70c670b2cd 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -184,6 +184,30 @@ describe("oRPC Server Endpoints", () => { expect(result).toBe("Pong: hello"); }); + test("agentSkills.list and agentSkills.get work with projectPath", async () => { + const client = createHttpClient(serverHandle.server.baseUrl); + + const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agent-skills-project-")); + const skillName = `test-skill-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + try { + const skillDir = path.join(projectPath, ".mux", "skills", skillName); + await fs.mkdir(skillDir, { recursive: true }); + + const skillContent = `---\nname: ${skillName}\ndescription: Test skill\n---\n\nTest body\n`; + await fs.writeFile(path.join(skillDir, "SKILL.md"), skillContent, "utf-8"); + + const descriptors = await client.agentSkills.list({ projectPath }); + expect(descriptors.some((d) => d.name === skillName && d.scope === "project")).toBe(true); + + const pkg = await client.agentSkills.get({ projectPath, skillName }); + expect(pkg.frontmatter.name).toBe(skillName); + expect(pkg.scope).toBe("project"); + expect(pkg.body).toContain("Test body"); + } finally { + await fs.rm(projectPath, { recursive: true, force: true }); + } + }); test("ping with empty string", async () => { const client = createHttpClient(serverHandle.server.baseUrl); const result = await client.general.ping(""); diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 88a9d89060..ecda7ac27c 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -146,6 +146,7 @@ export { features, general, menu, + agentSkills, agents, nameGeneration, projects, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 5e6bef6edd..753a26eb0b 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -18,6 +18,7 @@ import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; import { WorkspaceStatsSnapshotSchema } from "./workspaceStats"; import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace"; import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { AgentSkillDescriptorSchema, AgentSkillPackageSchema, SkillNameSchema } from "./agentSkill"; import { AgentDefinitionDescriptorSchema, AgentDefinitionPackageSchema, @@ -726,6 +727,18 @@ export const agents = { }, }; +// Agent skills +export const agentSkills = { + list: { + input: AgentDiscoveryInputSchema, + output: z.array(AgentSkillDescriptorSchema), + }, + get: { + input: AgentDiscoveryInputSchema.and(z.object({ skillName: SkillNameSchema })), + output: AgentSkillPackageSchema, + }, +}; + // Name generation for new workspaces (decoupled from workspace creation) export const nameGeneration = { generate: { diff --git a/src/common/orpc/schemas/message.ts b/src/common/orpc/schemas/message.ts index d7ceb27c8b..584829493d 100644 --- a/src/common/orpc/schemas/message.ts +++ b/src/common/orpc/schemas/message.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { AgentModeSchema } from "../../types/mode"; import { StreamErrorTypeSchema } from "./errors"; +import { AgentSkillScopeSchema, SkillNameSchema } from "./agentSkill"; export const ImagePartSchema = z.object({ url: z.string(), @@ -112,6 +113,14 @@ export const MuxMessageSchema = z.object({ mode: AgentModeSchema.optional().catch(undefined), partial: z.boolean().optional(), synthetic: z.boolean().optional(), + + agentSkillSnapshot: z + .object({ + skillName: SkillNameSchema, + scope: AgentSkillScopeSchema, + sha256: z.string(), + }) + .optional(), error: z.string().optional(), errorType: StreamErrorTypeSchema.optional(), }) diff --git a/src/common/types/message.test.ts b/src/common/types/message.test.ts index a2f31f12a8..6e7b6b091e 100644 --- a/src/common/types/message.test.ts +++ b/src/common/types/message.test.ts @@ -53,6 +53,23 @@ describe("buildContinueMessage", () => { expect(result?.agentId).toBe("plan"); }); + test("preserves muxMetadata when provided", () => { + const muxMetadata = { + type: "agent-skill", + rawCommand: "/test-skill hello", + skillName: "test-skill", + scope: "project", + } as const; + + const result = buildContinueMessage({ + text: "hello", + muxMetadata, + model: "test-model", + agentId: "exec", + }); + + expect(result?.muxMetadata).toEqual(muxMetadata); + }); test("returns message when only reviews provided", () => { const result = buildContinueMessage({ reviews: [makeReview("a.ts")], @@ -110,6 +127,21 @@ describe("rebuildContinueMessage", () => { expect(result?.agentId).toBe("plan"); }); + test("preserves muxMetadata from persisted data", () => { + const muxMetadata = { + type: "agent-skill", + rawCommand: "/test-skill hello", + skillName: "test-skill", + scope: "project", + } as const; + + const result = rebuildContinueMessage( + { text: "continue", muxMetadata }, + { model: "m", agentId: "exec" } + ); + + expect(result?.muxMetadata).toEqual(muxMetadata); + }); test("preserves reviews from persisted data", () => { const review = makeReview("a.ts"); const result = rebuildContinueMessage( diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 3eb8ec16ef..da8caee14d 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -5,6 +5,7 @@ import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ImagePart, MuxToolPartSchema } from "@/common/orpc/schemas"; import type { AgentMode } from "@/common/types/mode"; import type { z } from "zod"; +import type { AgentSkillScope } from "./agentSkill"; import { type ReviewNoteData, formatReviewForModel } from "./review"; /** @@ -39,6 +40,8 @@ export type ContinueMessage = UserMessageContent & { model?: string; /** Agent ID for the continue message (determines tool policy via agent definitions). Defaults to 'exec'. */ agentId?: string; + /** Frontend metadata to apply to the queued follow-up user message (e.g., preserve /skill display) */ + muxMetadata?: MuxFrontendMetadata; /** Brand marker - not present at runtime, enforces factory usage at compile time */ readonly [ContinueMessageBrand]: true; }; @@ -51,6 +54,8 @@ export interface BuildContinueMessageOptions { text?: string; imageParts?: ImagePart[]; reviews?: ReviewNoteDataForDisplay[]; + /** Optional frontend metadata to carry through to the queued follow-up user message */ + muxMetadata?: MuxFrontendMetadata; model: string; agentId: string; } @@ -75,6 +80,7 @@ export function buildContinueMessage( text: opts.text ?? "", imageParts: opts.imageParts, reviews: opts.reviews, + muxMetadata: opts.muxMetadata, model: opts.model, agentId: opts.agentId, } as ContinueMessage; @@ -119,6 +125,7 @@ export function rebuildContinueMessage( text: persisted.text, imageParts: persisted.imageParts, reviews: persisted.reviews, + muxMetadata: persisted.muxMetadata, model: persisted.model ?? defaults.model, agentId: persistedAgentId ?? legacyAgentId ?? defaults.agentId, }); @@ -184,6 +191,13 @@ export type MuxFrontendMetadata = MuxFrontendMetadataBase & /** Transient status to display in sidebar during this operation */ displayStatus?: DisplayStatus; } + | { + type: "agent-skill"; + /** The original /{skillName} invocation as typed by user (for display) */ + rawCommand: string; + skillName: string; + scope: "project" | "global" | "built-in"; + } | { type: "plan-display"; // Ephemeral plan display from /plan command path: string; @@ -225,6 +239,15 @@ export interface MuxMetadata { * preserving prompt cache stability across turns. */ fileAtMentionSnapshot?: string[]; + + /** + * Agent skill snapshot metadata for synthetic messages that inject skill bodies. + */ + agentSkillSnapshot?: { + skillName: string; + scope: AgentSkillScope; + sha256: string; + }; } // Extended tool part type that supports interrupted tool calls (input-available state) diff --git a/src/browser/assets/initMessage.txt b/src/node/builtinSkills/init.md similarity index 66% rename from src/browser/assets/initMessage.txt rename to src/node/builtinSkills/init.md index edc7503ea0..fd6560059e 100644 --- a/src/browser/assets/initMessage.txt +++ b/src/node/builtinSkills/init.md @@ -1,61 +1,71 @@ +--- +name: init +description: Bootstrap an AGENTS.md file in a new or existing project +--- + Use your tools to create or improve an AGENTS.md file in the root of the workspace which will serve as a contribution guide for AI agents. If an AGENTS.md file already exists, focus on additive improvement (preserve intent and useful information; refine, extend, and reorganize as needed) rather than replacing it wholesale. Inspect the workspace layout, code, documentation and git history to ensure correctness and accuracy. Ensure the following preamble exists at the top of the file before any other sections. Do not include the surrounding code fence backticks; only include the text. + ```md You are an experienced, pragmatic software engineering AI agent. Do not over-engineer a solution when a simple one is possible. Keep edits minimal. If you want an exception to ANY rule, you MUST stop and get permission first. ``` Recommended sections: + - Project Overview (mandatory) - - Basic details about the project (e.g., high-level overview and goals). - - Technology choices (e.g., languages, databases, frameworks, libraries, build tools). + - Basic details about the project (e.g., high-level overview and goals). + - Technology choices (e.g., languages, databases, frameworks, libraries, build tools). - Reference (mandatory) - - List important code files. - - List important directories and basic code structure tips. - - Project architecture. + - List important code files. + - List important directories and basic code structure tips. + - Project architecture. - Essential commands (mandatory) - - build - - format - - lint - - test - - clean - - development server - - other *important* scripts (use `find -type f -name '*.sh'` or similar) + - build + - format + - lint + - test + - clean + - development server + - other _important_ scripts (use `find -type f -name '*.sh'` or similar) - Patterns (optional) - - List any important or uncommon patterns (compared to other similar codebases), with examples (e.g., how to authorize an HTTP request). - - List any important workflows and their steps (e.g., how to make a database migration). - - Testing patterns. + - List any important or uncommon patterns (compared to other similar codebases), with examples (e.g., how to authorize an HTTP request). + - List any important workflows and their steps (e.g., how to make a database migration). + - Testing patterns. - Anti-patterns (optional) - - Search git history and comments to find recurring mistakes or forbidden patterns. - - List each pattern and its reason. + - Search git history and comments to find recurring mistakes or forbidden patterns. + - List each pattern and its reason. - Code style (optional) - - Style guide to follow (with link). + - Style guide to follow (with link). - Commit and Pull Request Guidelines (mandatory) - - Required steps for validating changes before committing. - - Commit message conventions (read `git log`, or use `type: message` by default). - - Pull request description requirements. + - Required steps for validating changes before committing. + - Commit message conventions (read `git log`, or use `type: message` by default). + - Pull request description requirements. You can add other sections if they are necessary. If the information required for mandatory sections isn't available due to the workspace being empty or sparse, add TODO text in its place. Optional sections should be scrapped if the information is too thin. Some investigation tips: - - Read existing lint configs, tsconfig, and CI workflows to find any style or layout rules. - - Search for "TODO", "HACK", "FIXME", "don't", "never", "always" in comments. - - Examine test files for patterns. - - Read PR templates and issue templates if they exist. - - Check for existing CONTRIBUTING.md, CODE_OF_CONDUCT.md, or similar documentation files. + +- Read existing lint configs, tsconfig, and CI workflows to find any style or layout rules. +- Search for "TODO", "HACK", "FIXME", "don't", "never", "always" in comments. +- Examine test files for patterns. +- Read PR templates and issue templates if they exist. +- Check for existing CONTRIBUTING.md, CODE_OF_CONDUCT.md, or similar documentation files. Some writing tips: + - Each "do X" should have a corresponding "don't Y" where applicable. - Commands should be easily copy-pastable and tested. - Terms or phrases specific to this project should be explained on first use. - Anything that is against the norm should be explicitly highlighted and called out. Above all things: + - The document must be clear and concise. Simple projects should need less than 400 words, but larger and more mature codebases will likely need 700+. Prioritize completeness over brevity. - Don't include useless fluff. - The document must be in Markdown format and use headings for structure. @@ -64,6 +74,7 @@ Above all things: - Maintain a professional, instructional tone. If the workspace is empty or sparse, ask the user for more information. Avoid hallucinating important decisions. You can provide suggestions to the user for language/technology/tool choices, but always respect the user's decision. + - Project description and goals. - Language(s). - Technologies (database?), frameworks, libraries. @@ -71,7 +82,8 @@ If the workspace is empty or sparse, ask the user for more information. Avoid ha - Any other questions as you deem necessary. For empty or sparse workspaces ONLY, when finished writing/updating AGENTS.md, ask the user if they would like you to do the following: + - initialize git IF it's not already set up (e.g., `git init`, `git remote add`, etc.) - write a concise README.md file - generate the bare minimum project scaffolding (e.g., initializing the package manager, writing a minimal build tool config) - + diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index ad9c1a04f0..a19096e76a 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -27,6 +27,10 @@ import { normalizeSubagentAiDefaults, normalizeTaskSettings, } from "@/common/types/tasks"; +import { + discoverAgentSkills, + readAgentSkill, +} from "@/node/services/agentSkills/agentSkillsService"; import { discoverAgentDefinitions, readAgentDefinition, @@ -449,7 +453,7 @@ export const router = (authToken?: string) => { .input(schemas.agents.list.input) .output(schemas.agents.list.output) .handler(async ({ context, input }) => { - // Wait for workspace init before agent discovery (SSH may not be ready yet) + // Wait for workspace init before discovery (SSH may not be ready yet) if (input.workspaceId) { await context.aiService.waitForInit(input.workspaceId); } @@ -487,7 +491,7 @@ export const router = (authToken?: string) => { .input(schemas.agents.get.input) .output(schemas.agents.get.output) .handler(async ({ context, input }) => { - // Wait for workspace init before agent discovery (SSH may not be ready yet) + // Wait for workspace init before discovery (SSH may not be ready yet) if (input.workspaceId) { await context.aiService.waitForInit(input.workspaceId); } @@ -495,6 +499,31 @@ export const router = (authToken?: string) => { return readAgentDefinition(runtime, discoveryPath, input.agentId); }), }, + agentSkills: { + list: t + .input(schemas.agentSkills.list.input) + .output(schemas.agentSkills.list.output) + .handler(async ({ context, input }) => { + // Wait for workspace init before agent discovery (SSH may not be ready yet) + if (input.workspaceId) { + await context.aiService.waitForInit(input.workspaceId); + } + const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); + return discoverAgentSkills(runtime, discoveryPath); + }), + get: t + .input(schemas.agentSkills.get.input) + .output(schemas.agentSkills.get.output) + .handler(async ({ context, input }) => { + // Wait for workspace init before agent discovery (SSH may not be ready yet) + if (input.workspaceId) { + await context.aiService.waitForInit(input.workspaceId); + } + const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); + const result = await readAgentSkill(runtime, discoveryPath, input.skillName); + return result.package; + }), + }, providers: { list: t .input(schemas.providers.list.input) diff --git a/src/node/services/agentSession.agentSkillSnapshot.test.ts b/src/node/services/agentSession.agentSkillSnapshot.test.ts new file mode 100644 index 0000000000..5c613aa3d8 --- /dev/null +++ b/src/node/services/agentSession.agentSkillSnapshot.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "events"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { AIService } from "@/node/services/aiService"; +import type { HistoryService } from "@/node/services/historyService"; +import type { PartialService } from "@/node/services/partialService"; +import type { InitStateManager } from "@/node/services/initStateManager"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { Config } from "@/node/config"; + +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import type { SendMessageError } from "@/common/types/errors"; +import type { Result } from "@/common/types/result"; +import { Ok } from "@/common/types/result"; + +import { AgentSession } from "./agentSession"; + +describe("AgentSession.sendMessage (agent skill snapshots)", () => { + async function createTestWorkspaceWithSkill(args: { skillName: string; skillBody: string }) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agent-skill-")); + const skillDir = path.join(tmp, ".mux", "skills", args.skillName); + await fs.mkdir(skillDir, { recursive: true }); + + const skillMarkdown = `---\nname: ${args.skillName}\ndescription: Test skill\n---\n\n${args.skillBody}\n`; + await fs.writeFile(path.join(skillDir, "SKILL.md"), skillMarkdown, "utf-8"); + + return { workspacePath: tmp }; + } + + it("persists a synthetic agent skill snapshot before the user message", async () => { + const workspaceId = "ws-test"; + + const { workspacePath } = await createTestWorkspaceWithSkill({ + skillName: "test-skill", + skillBody: "Follow this skill.", + }); + + const config = { + srcDir: "/tmp", + getSessionDir: (_workspaceId: string) => "/tmp", + } as unknown as Config; + + const messages: MuxMessage[] = []; + let nextSeq = 0; + + const appendToHistory = mock((_workspaceId: string, message: MuxMessage) => { + message.metadata = { ...(message.metadata ?? {}), historySequence: nextSeq++ }; + messages.push(message); + return Promise.resolve(Ok(undefined)); + }); + + const historyService = { + appendToHistory, + truncateAfterMessage: mock((_workspaceId: string, _messageId: string) => { + void _messageId; + return Promise.resolve(Ok(undefined)); + }), + getHistory: mock((_workspaceId: string): Promise> => { + return Promise.resolve(Ok([...messages])); + }), + } as unknown as HistoryService; + + const partialService = { + commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + + const workspaceMeta: FrontendWorkspaceMetadata = { + id: workspaceId, + name: "ws", + projectName: "proj", + projectPath: workspacePath, + namedWorkspacePath: workspacePath, + runtimeConfig: { type: "local" }, + } as unknown as FrontendWorkspaceMetadata; + + const streamMessage = mock((_messages: MuxMessage[]) => { + return Promise.resolve(Ok(undefined)); + }); + + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + getWorkspaceMetadata: mock((_workspaceId: string) => Promise.resolve(Ok(workspaceMeta))), + streamMessage: streamMessage as unknown as ( + ...args: Parameters + ) => Promise>, + }) as unknown as AIService; + + const initStateManager = new EventEmitter() as unknown as InitStateManager; + + const backgroundProcessManager = { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { + void _queued; + }), + } as unknown as BackgroundProcessManager; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const result = await session.sendMessage("do X", { + model: "anthropic:claude-3-5-sonnet-latest", + muxMetadata: { + type: "agent-skill", + rawCommand: "/test-skill do X", + skillName: "test-skill", + scope: "project", + }, + }); + + expect(result.success).toBe(true); + + expect(appendToHistory.mock.calls).toHaveLength(2); + const [snapshotMessage, userMessage] = messages; + + expect(snapshotMessage.role).toBe("user"); + expect(snapshotMessage.metadata?.synthetic).toBe(true); + expect(snapshotMessage.metadata?.agentSkillSnapshot?.skillName).toBe("test-skill"); + expect(snapshotMessage.metadata?.agentSkillSnapshot?.sha256).toBeTruthy(); + + const snapshotText = snapshotMessage.parts.find((p) => p.type === "text")?.text; + expect(snapshotText).toContain(" p.type === "text")?.text; + expect(userText).toBe("do X"); + }); + + it("honors disableWorkspaceAgents when resolving skill snapshots", async () => { + const workspaceId = "ws-test"; + + const { workspacePath: projectPath } = await createTestWorkspaceWithSkill({ + // Built-in: use a project-local override to ensure we don't accidentally fall back. + skillName: "init", + skillBody: "Project override for init skill.", + }); + + const srcBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agent-skill-src-")); + + const config = { + srcDir: "/tmp", + getSessionDir: (_workspaceId: string) => "/tmp", + } as unknown as Config; + + const messages: MuxMessage[] = []; + let nextSeq = 0; + + const appendToHistory = mock((_workspaceId: string, message: MuxMessage) => { + message.metadata = { ...(message.metadata ?? {}), historySequence: nextSeq++ }; + messages.push(message); + return Promise.resolve(Ok(undefined)); + }); + + const historyService = { + appendToHistory, + truncateAfterMessage: mock((_workspaceId: string, _messageId: string) => { + void _messageId; + return Promise.resolve(Ok(undefined)); + }), + getHistory: mock((_workspaceId: string): Promise> => { + return Promise.resolve(Ok([...messages])); + }), + } as unknown as HistoryService; + + const partialService = { + commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + + const workspaceMeta: FrontendWorkspaceMetadata = { + id: workspaceId, + name: "ws", + projectName: "proj", + projectPath, + namedWorkspacePath: projectPath, + runtimeConfig: { type: "worktree", srcBaseDir }, + } as unknown as FrontendWorkspaceMetadata; + + const streamMessage = mock((_messages: MuxMessage[]) => { + return Promise.resolve(Ok(undefined)); + }); + + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + getWorkspaceMetadata: mock((_workspaceId: string) => Promise.resolve(Ok(workspaceMeta))), + streamMessage: streamMessage as unknown as ( + ...args: Parameters + ) => Promise>, + }) as unknown as AIService; + + const initStateManager = new EventEmitter() as unknown as InitStateManager; + + const backgroundProcessManager = { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { + void _queued; + }), + } as unknown as BackgroundProcessManager; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const result = await session.sendMessage("do X", { + model: "anthropic:claude-3-5-sonnet-latest", + disableWorkspaceAgents: true, + muxMetadata: { + type: "agent-skill", + rawCommand: "/init", + skillName: "init", + scope: "project", + }, + }); + + expect(result.success).toBe(true); + + expect(appendToHistory.mock.calls).toHaveLength(2); + const [snapshotMessage] = messages; + + const snapshotText = snapshotMessage.parts.find((p) => p.type === "text")?.text; + expect(snapshotText).toContain("Project override for init skill."); + }); + + it("dedupes identical skill snapshots when recently inserted", async () => { + const workspaceId = "ws-test"; + + const { workspacePath } = await createTestWorkspaceWithSkill({ + skillName: "test-skill", + skillBody: "Follow this skill.", + }); + + const config = { + srcDir: "/tmp", + getSessionDir: (_workspaceId: string) => "/tmp", + } as unknown as Config; + + const messages: MuxMessage[] = []; + let nextSeq = 0; + + const appendToHistory = mock((_workspaceId: string, message: MuxMessage) => { + message.metadata = { ...(message.metadata ?? {}), historySequence: nextSeq++ }; + messages.push(message); + return Promise.resolve(Ok(undefined)); + }); + + const historyService = { + appendToHistory, + truncateAfterMessage: mock((_workspaceId: string, _messageId: string) => { + void _messageId; + return Promise.resolve(Ok(undefined)); + }), + getHistory: mock((_workspaceId: string): Promise> => { + return Promise.resolve(Ok([...messages])); + }), + } as unknown as HistoryService; + + const partialService = { + commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + + const workspaceMeta: FrontendWorkspaceMetadata = { + id: workspaceId, + name: "ws", + projectName: "proj", + projectPath: workspacePath, + namedWorkspacePath: workspacePath, + runtimeConfig: { type: "local" }, + } as unknown as FrontendWorkspaceMetadata; + + const streamMessage = mock((_messages: MuxMessage[]) => { + return Promise.resolve(Ok(undefined)); + }); + + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + getWorkspaceMetadata: mock((_workspaceId: string) => Promise.resolve(Ok(workspaceMeta))), + streamMessage: streamMessage as unknown as ( + ...args: Parameters + ) => Promise>, + }) as unknown as AIService; + + const initStateManager = new EventEmitter() as unknown as InitStateManager; + + const backgroundProcessManager = { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { + void _queued; + }), + } as unknown as BackgroundProcessManager; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const baseOptions = { + model: "anthropic:claude-3-5-sonnet-latest", + muxMetadata: { + type: "agent-skill", + rawCommand: "/test-skill do X", + skillName: "test-skill", + scope: "project", + }, + }; + + const first = await session.sendMessage("do X", baseOptions); + expect(first.success).toBe(true); + expect(appendToHistory.mock.calls).toHaveLength(2); + + const second = await session.sendMessage("do Y", { + ...baseOptions, + muxMetadata: { + ...baseOptions.muxMetadata, + rawCommand: "/test-skill do Y", + }, + }); + + expect(second.success).toBe(true); + // First send: snapshot + user. Second send: user only. + expect(appendToHistory.mock.calls).toHaveLength(3); + + const appendedIds = appendToHistory.mock.calls.map((call) => call[1].id); + const secondSendAppendedIds = appendedIds.slice(2); + expect(secondSendAppendedIds).toHaveLength(1); + expect(secondSendAppendedIds[0]).toStartWith("user-"); + }); + + it("truncates edits starting from preceding skill/file snapshots", async () => { + const workspaceId = "ws-test"; + + const config = { + srcDir: "/tmp", + getSessionDir: (_workspaceId: string) => "/tmp", + } as unknown as Config; + + const fileSnapshotId = "file-snapshot-0"; + const skillSnapshotId = "agent-skill-snapshot-0"; + const userMessageId = "user-0"; + + const historyMessages: MuxMessage[] = [ + createMuxMessage(fileSnapshotId, "user", "...", { + historySequence: 0, + synthetic: true, + fileAtMentionSnapshot: ["@file:foo.txt"], + }), + createMuxMessage(skillSnapshotId, "user", "...", { + historySequence: 1, + synthetic: true, + agentSkillSnapshot: { + skillName: "test-skill", + scope: "project", + sha256: "abc", + }, + }), + createMuxMessage(userMessageId, "user", "do X", { + historySequence: 2, + muxMetadata: { + type: "agent-skill", + rawCommand: "/test-skill do X", + skillName: "test-skill", + scope: "project", + }, + }), + ]; + + const truncateAfterMessage = mock((_workspaceId: string, _messageId: string) => { + void _workspaceId; + void _messageId; + return Promise.resolve(Ok(undefined)); + }); + + const historyService = { + truncateAfterMessage, + appendToHistory: mock((_workspaceId: string, _message: MuxMessage) => { + return Promise.resolve(Ok(undefined)); + }), + getHistory: mock((_workspaceId: string): Promise> => { + return Promise.resolve(Ok([...historyMessages])); + }), + } as unknown as HistoryService; + + const partialService = { + commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + streamMessage: mock((_messages: MuxMessage[]) => + Promise.resolve(Ok(undefined)) + ) as unknown as ( + ...args: Parameters + ) => Promise>, + }) as unknown as AIService; + + const initStateManager = new EventEmitter() as unknown as InitStateManager; + + const backgroundProcessManager = { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { + void _queued; + }), + } as unknown as BackgroundProcessManager; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const result = await session.sendMessage("edited", { + model: "anthropic:claude-3-5-sonnet-latest", + editMessageId: userMessageId, + }); + + expect(result.success).toBe(true); + expect(truncateAfterMessage.mock.calls).toHaveLength(1); + // Should truncate from the earliest contiguous snapshot (file snapshot). + expect(truncateAfterMessage.mock.calls[0][1]).toBe(fileSnapshotId); + }); +}); diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index bc8acba9ad..9c556063e6 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -93,6 +93,7 @@ describe("AgentSession continue-message agentId fallback", () => { model: "openai:gpt-4o", agentId: "compact", mode: "compact", + disableWorkspaceAgents: true, toolPolicy: [{ regex_match: ".*", action: "disable" }], muxMetadata: { type: "compaction-request", @@ -108,6 +109,7 @@ describe("AgentSession continue-message agentId fallback", () => { const queued = internals.messageQueue.produceMessage(); expect(queued.message).toBe("follow up"); expect(queued.options?.agentId).toBe("plan"); + expect(queued.options?.disableWorkspaceAgents).toBe(true); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 50757efed2..5f224ac508 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1,6 +1,7 @@ import assert from "@/common/utils/assert"; import { EventEmitter } from "events"; import * as path from "path"; +import { createHash } from "crypto"; import { stat, readFile } from "fs/promises"; import { PlatformPaths } from "@/common/utils/paths"; import { log } from "@/node/services/log"; @@ -20,6 +21,7 @@ import type { ImagePart, } from "@/common/orpc/types"; import type { SendMessageError } from "@/common/types/errors"; +import { SkillNameSchema } from "@/common/orpc/schemas"; import { createUnknownSendMessageError } from "@/node/services/utils/sendMessageError"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -45,6 +47,7 @@ import type { PostCompactionAttachment, PostCompactionExclusions } from "@/commo import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; import { extractEditedFileDiffs } from "@/common/utils/messages/extractEditedFiles"; import { getModelName, getModelProvider, isValidModelFormat } from "@/common/utils/ai/models"; +import { readAgentSkill } from "@/node/services/agentSkills/agentSkillsService"; import { materializeFileAtMentions } from "@/node/services/fileAtMentions"; /** @@ -81,6 +84,8 @@ function isCompactionRequestMetadata(meta: unknown): meta is CompactionRequestMe return true; } +const MAX_AGENT_SKILL_SNAPSHOT_CHARS = 50_000; + export interface AgentSessionChatEvent { workspaceId: string; message: WorkspaceChatMessage; @@ -448,7 +453,7 @@ export class AgentSession { } } - // Find the truncation target: the edited message or its preceding @file snapshot + // Find the truncation target: the edited message or any immediately-preceding snapshots. // (snapshots are persisted immediately before their corresponding user message) let truncateTargetId = options.editMessageId; const historyResult = await this.historyService.getHistory(this.workspaceId); @@ -456,10 +461,14 @@ export class AgentSession { const messages = historyResult.data; const editIndex = messages.findIndex((m) => m.id === options.editMessageId); if (editIndex > 0) { - const precedingMsg = messages[editIndex - 1]; - // Check if the preceding message is a @file snapshot (synthetic with fileAtMentionSnapshot) - if (precedingMsg.metadata?.synthetic && precedingMsg.metadata?.fileAtMentionSnapshot) { - truncateTargetId = precedingMsg.id; + // Walk backwards over contiguous synthetic snapshots so we don't orphan them. + for (let i = editIndex - 1; i >= 0; i--) { + const msg = messages[i]; + const isSnapshot = + msg.metadata?.synthetic && + (msg.metadata?.fileAtMentionSnapshot ?? msg.metadata?.agentSkillSnapshot); + if (!isSnapshot) break; + truncateTargetId = msg.id; } } } @@ -550,10 +559,20 @@ export class AgentSession { // so subsequent turns don't re-read (which would change the prompt prefix if files changed). // File changes after this point are surfaced via diffs instead. const snapshotResult = await this.materializeFileAtMentionsSnapshot(trimmedMessage); + let skillSnapshotResult: { snapshotMessage: MuxMessage } | null = null; + try { + skillSnapshotResult = await this.materializeAgentSkillSnapshot( + typedMuxMetadata, + options?.disableWorkspaceAgents + ); + } catch (error) { + return Err( + createUnknownSendMessageError(error instanceof Error ? error.message : String(error)) + ); + } - // Persist snapshot (if any) BEFORE user message so file content precedes the instruction - // in the prompt (matching injectFileAtMentions ordering). Both must succeed or neither - // is persisted to avoid orphaned snapshots. + // Persist snapshots (if any) BEFORE the user message so they precede it in the prompt. + // Order matters: @file snapshot first, then agent-skill snapshot. if (snapshotResult?.snapshotMessage) { const snapshotAppendResult = await this.historyService.appendToHistory( this.workspaceId, @@ -564,9 +583,19 @@ export class AgentSession { } } + if (skillSnapshotResult?.snapshotMessage) { + const skillSnapshotAppendResult = await this.historyService.appendToHistory( + this.workspaceId, + skillSnapshotResult.snapshotMessage + ); + if (!skillSnapshotAppendResult.success) { + return Err(createUnknownSendMessageError(skillSnapshotAppendResult.error)); + } + } + const appendResult = await this.historyService.appendToHistory(this.workspaceId, userMessage); if (!appendResult.success) { - // Note: If we get here with a snapshot, the snapshot is already persisted but user message + // Note: If we get here with snapshots, one or more snapshots may already be persisted but user message // failed. This is a rare edge case (disk full mid-operation). The next edit will clean up // the orphan via the truncation logic that removes preceding snapshots. return Err(createUnknownSendMessageError(appendResult.error)); @@ -578,11 +607,15 @@ export class AgentSession { return Ok(undefined); } - // Emit snapshot first (if any), then user message - maintains prompt ordering in UI + // Emit snapshots first (if any), then user message - maintains prompt ordering in UI if (snapshotResult?.snapshotMessage) { this.emitChatEvent({ ...snapshotResult.snapshotMessage, type: "message" }); } + if (skillSnapshotResult?.snapshotMessage) { + this.emitChatEvent({ ...skillSnapshotResult.snapshotMessage, type: "message" }); + } + // Add type: "message" for discriminated union (createMuxMessage doesn't add it) this.emitChatEvent({ ...userMessage, type: "message" }); @@ -605,7 +638,10 @@ export class AgentSession { const continueMessage = typedMuxMetadata.parsed.continueMessage; // Process the continue message content (handles reviews -> text formatting + metadata) - const { finalText, metadata } = prepareUserMessageForSend(continueMessage); + const { finalText, metadata } = prepareUserMessageForSend( + continueMessage, + continueMessage.muxMetadata + ); // Legacy compatibility: older clients stored `continueMessage.mode` (exec/plan) and compaction // requests run with agentId="compact". Avoid falling back to the compact agent for the @@ -630,6 +666,7 @@ export class AgentSession { additionalSystemInstructions: options.additionalSystemInstructions, providerOptions: options.providerOptions, experiments: options.experiments, + disableWorkspaceAgents: options.disableWorkspaceAgents, }; // Add image parts if present @@ -1332,6 +1369,91 @@ export class AgentSession { return { snapshotMessage, materializedTokens: tokens }; } + private async materializeAgentSkillSnapshot( + muxMetadata: MuxFrontendMetadata | undefined, + disableWorkspaceAgents: boolean | undefined + ): Promise<{ snapshotMessage: MuxMessage } | null> { + if (!muxMetadata || muxMetadata.type !== "agent-skill") { + return null; + } + + // Guard for test mocks that may not implement getWorkspaceMetadata. + if (typeof this.aiService.getWorkspaceMetadata !== "function") { + return null; + } + + const parsedName = SkillNameSchema.safeParse(muxMetadata.skillName); + if (!parsedName.success) { + throw new Error(`Invalid agent skill name: ${muxMetadata.skillName}`); + } + + const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success) { + throw new Error("Cannot materialize agent skill: workspace metadata not found"); + } + + const metadata = metadataResult.data; + const runtime = createRuntime( + metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath: metadata.projectPath, workspaceName: metadata.name } + ); + + // In-place workspaces (CLI/benchmarks) have projectPath === name. + // Use the path directly instead of reconstructing via getWorkspacePath. + const isInPlace = metadata.projectPath === metadata.name; + const workspacePath = isInPlace + ? metadata.projectPath + : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + // When workspace agents are disabled, resolve skills from the project path instead of + // the worktree so skill invocation uses the same precedence/discovery root as the UI. + const skillDiscoveryPath = disableWorkspaceAgents ? metadata.projectPath : workspacePath; + + const resolved = await readAgentSkill(runtime, skillDiscoveryPath, parsedName.data); + const skill = resolved.package; + + const body = + skill.body.length > MAX_AGENT_SKILL_SNAPSHOT_CHARS + ? `${skill.body.slice(0, MAX_AGENT_SKILL_SNAPSHOT_CHARS)}\n\n[Skill body truncated to ${MAX_AGENT_SKILL_SNAPSHOT_CHARS} characters]` + : skill.body; + + const snapshotText = `\n${body}\n`; + const sha256 = createHash("sha256").update(snapshotText).digest("hex"); + + // Dedupe: if we recently persisted the same snapshot, avoid inserting again. + const historyResult = await this.historyService.getHistory(this.workspaceId); + if (historyResult.success) { + const recentMessages = historyResult.data.slice(Math.max(0, historyResult.data.length - 5)); + const recentSnapshot = [...recentMessages] + .reverse() + .find((msg) => msg.metadata?.synthetic && msg.metadata?.agentSkillSnapshot); + const recentMeta = recentSnapshot?.metadata?.agentSkillSnapshot; + + if ( + recentMeta && + recentMeta.skillName === skill.frontmatter.name && + recentMeta.sha256 === sha256 + ) { + return null; + } + } + + const snapshotId = `agent-skill-snapshot-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 9)}`; + const snapshotMessage = createMuxMessage(snapshotId, "user", snapshotText, { + timestamp: Date.now(), + synthetic: true, + agentSkillSnapshot: { + skillName: skill.frontmatter.name, + scope: skill.scope, + sha256, + }, + }); + + return { snapshotMessage }; + } + /** * Load excluded items from the exclusions file. * Returns empty set if file doesn't exist or can't be read. diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 9e640eb2e0..06c300c351 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -38,7 +38,7 @@ describe("agentSkillsService", () => { const skills = await discoverAgentSkills(runtime, project.path, { roots }); // Should include project/global skills plus built-in skills - expect(skills.map((s) => s.name)).toEqual(["bar", "foo", "mux-docs"]); + expect(skills.map((s) => s.name)).toEqual(["bar", "foo", "init", "mux-docs"]); const foo = skills.find((s) => s.name === "foo"); expect(foo).toBeDefined(); diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index b351ad91a8..6487ddbc72 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -198,6 +198,46 @@ describe("MessageQueue", () => { } }); + it("should throw when adding agent-skill invocation after normal message", () => { + queue.add("First message"); + + const metadata: MuxFrontendMetadata = { + type: "agent-skill", + rawCommand: "/init", + skillName: "init", + scope: "built-in", + }; + + const options: SendMessageOptions = { + model: "claude-3-5-sonnet-20241022", + muxMetadata: metadata, + }; + + expect(() => queue.add('Run the "init" skill.', options)).toThrow( + /Cannot queue agent skill invocation/ + ); + }); + + it("should throw when adding normal message after agent-skill invocation", () => { + const metadata: MuxFrontendMetadata = { + type: "agent-skill", + rawCommand: "/init", + skillName: "init", + scope: "built-in", + }; + + queue.add('Run the "init" skill.', { + model: "claude-3-5-sonnet-20241022", + muxMetadata: metadata, + }); + + expect(queue.getDisplayText()).toBe("/init"); + + expect(() => queue.add("Follow-up message")).toThrow( + /agent skill invocation is already queued/ + ); + }); + it("should produce combined message for API call", () => { queue.add("First message", { model: "gpt-4" }); queue.add("Second message"); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index e2f11a47c0..22ff7aeb87 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -7,6 +7,24 @@ interface CompactionMetadata { rawCommand: string; } +// Type guard for agent skill metadata (for display + batching constraints) +interface AgentSkillMetadata { + type: "agent-skill"; + rawCommand: string; + skillName: string; + scope: "project" | "global" | "built-in"; +} + +function isAgentSkillMetadata(meta: unknown): meta is AgentSkillMetadata { + if (typeof meta !== "object" || meta === null) return false; + const obj = meta as Record; + if (obj.type !== "agent-skill") return false; + if (typeof obj.rawCommand !== "string") return false; + if (typeof obj.skillName !== "string") return false; + if (obj.scope !== "project" && obj.scope !== "global" && obj.scope !== "built-in") return false; + return true; +} + function isCompactionMetadata(meta: unknown): meta is CompactionMetadata { if (typeof meta !== "object" || meta === null) return false; const obj = meta as Record; @@ -33,12 +51,14 @@ function hasReviews(meta: unknown): meta is MetadataWithReviews { * - Latest options (model, etc. - updated on each add) * - Image parts (accumulated across all messages) * - * IMPORTANT: muxMetadata from the first message is preserved even when - * subsequent messages are added. This prevents compaction requests from - * losing their metadata when follow-up messages are queued. + * IMPORTANT: + * - Compaction requests must preserve their muxMetadata even when follow-up messages are queued. + * - Agent-skill invocations cannot be batched with other messages; otherwise the skill metadata would + * “leak” onto later queued sends. * * Display logic: * - Single compaction request → shows rawCommand (/compact) + * - Single agent-skill invocation → shows rawCommand (/{skill}) * - Multiple messages → shows all actual message texts */ export class MessageQueue { @@ -71,7 +91,18 @@ export class MessageQueue { } const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata); + const incomingIsAgentSkill = isAgentSkillMetadata(options?.muxMetadata); const queueHasMessages = !this.isEmpty(); + const queueHasAgentSkill = isAgentSkillMetadata(this.firstMuxMetadata); + + // Avoid leaking agent-skill metadata to later queued messages. + // A skill invocation must be sent alone (or the user should restore/edit the queued message). + if (queueHasAgentSkill) { + throw new Error( + "Cannot queue additional messages: an agent skill invocation is already queued. " + + "Wait for the current stream to complete before sending another message." + ); + } // Cannot add compaction to a queue that already has messages // (user should wait for those messages to send first) @@ -82,6 +113,14 @@ export class MessageQueue { ); } + // Cannot batch agent-skill metadata with other messages (it would apply to the whole batch). + if (incomingIsAgentSkill && queueHasMessages) { + throw new Error( + "Cannot queue agent skill invocation: queue already has messages. " + + "Wait for the current stream to complete before running a skill." + ); + } + // Add text message if non-empty if (trimmedMessage.length > 0) { this.messages.push(trimmedMessage); @@ -112,6 +151,7 @@ export class MessageQueue { /** * Get display text for queued messages. * - Single compaction request shows rawCommand (/compact) + * - Single agent-skill invocation shows rawCommand (/{skill}) * - Multiple messages show all actual message texts */ getDisplayText(): string { @@ -120,6 +160,12 @@ export class MessageQueue { return this.firstMuxMetadata.rawCommand; } + // Only show rawCommand for a single agent-skill invocation. + // (Batching agent-skill with other messages is disallowed.) + if (this.messages.length <= 1 && isAgentSkillMetadata(this.firstMuxMetadata)) { + return this.firstMuxMetadata.rawCommand; + } + return this.messages.join("\n"); } @@ -148,7 +194,7 @@ export class MessageQueue { options?: SendMessageOptions & { imageParts?: ImagePart[] }; } { const joinedMessages = this.messages.join("\n"); - // First metadata takes precedence (preserves compaction requests) + // First metadata takes precedence (preserves compaction + agent-skill invocations) const muxMetadata = this.firstMuxMetadata !== undefined ? this.firstMuxMetadata