From 103a392342a0f0ec039adc74745d286577ca0ae0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 14:13:29 +0100 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=A4=96=20feat:=20expose=20agent=20s?= =?UTF-8?q?kills=20as=20slash=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I55eed13ec907180d9526df43e945e3e6fbda52e5 Signed-off-by: Thomas Kosiewski --- scripts/zizmor.sh | 10 + src/browser/components/ChatInput/index.tsx | 286 +++++++++++++++++- .../ChatInput/useCreationWorkspace.ts | 25 +- .../messages/StreamingMessageAggregator.ts | 6 +- .../utils/slashCommands/suggestions.test.ts | 17 ++ .../utils/slashCommands/suggestions.ts | 29 +- src/browser/utils/slashCommands/types.ts | 3 + src/cli/server.test.ts | 24 ++ src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 13 + src/common/types/message.ts | 7 + src/node/builtinSkills/mux-docs.md | 2 +- src/node/orpc/router.ts | 21 ++ 13 files changed, 424 insertions(+), 20 deletions(-) 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..7cdc87e13a 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, AgentSkillPackage } 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"; @@ -110,9 +112,49 @@ import initMessage from "@/browser/assets/initMessage.txt?raw"; // Be conservative here so we can warn the user before writes start failing. const MAX_PERSISTED_IMAGE_DRAFT_CHARS = 4_000_000; +// Guardrail: prevent accidental mega-prompts when injecting skill bodies. +type UnknownSlashCommand = Extract; + +function isUnknownSlashCommand(value: ParsedCommand): value is UnknownSlashCommand { + return value !== null && value.type === "unknown-command"; +} + +const MAX_AGENT_SKILL_BODY_CHARS = 50_000; + +function mergeAdditionalSystemInstructions( + ...parts: Array +): string | undefined { + const filtered = parts.filter((part) => typeof part === "string" && part.trim().length > 0); + if (filtered.length === 0) { + return undefined; + } + return filtered.join("\n\n"); +} + +function buildAgentSkillSystemInstructionsForAutoCompaction(skill: AgentSkillPackage): string { + return ( + "The following skill instructions are for the user's next message after compaction. " + + "If you are currently summarizing/compacting, ignore them.\n\n" + + buildAgentSkillSystemInstructions(skill) + ); +} +function buildAgentSkillSystemInstructions(skill: AgentSkillPackage): string { + const scopeLabel = skill.scope === "global" ? "user" : skill.scope; + const skillName = skill.frontmatter.name; + const header = `Agent Skill applied: ${skillName} (${scopeLabel})`; + const invocationHint = `If the user's message starts with /${skillName}, ignore that leading token.`; + + const body = + skill.body.length > MAX_AGENT_SKILL_BODY_CHARS + ? `${skill.body.slice(0, MAX_AGENT_SKILL_BODY_CHARS)}\n\n[Skill body truncated to ${MAX_AGENT_SKILL_BODY_CHARS} characters]` + : skill.body; + + return `${header}\n${invocationHint}\n\n${body}`; +} + // 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" } @@ -211,6 +253,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 +988,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 +1019,53 @@ 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 loadAgentSkills = async () => { + if (!api) { + if (isMounted) { + setAgentSkillDescriptors([]); + } + return; + } + + const discoveryInput = + variant === "workspace" && workspaceId + ? { workspaceId } + : variant === "creation" && atMentionProjectPath + ? { projectPath: atMentionProjectPath } + : null; + + if (!discoveryInput) { + if (isMounted) { + setAgentSkillDescriptors([]); + } + return; + } + + try { + const skills = await api.agentSkills.list(discoveryInput); + if (isMounted && Array.isArray(skills)) { + setAgentSkillDescriptors(skills); + } + } catch (error) { + console.error("Failed to load agent skills:", error); + if (isMounted) { + setAgentSkillDescriptors([]); + } + } + }; + + void loadAgentSkills(); + + return () => { + isMounted = false; + }; + }, [api, variant, workspaceId, atMentionProjectPath]); + + // Voice input: track whether OpenAI API key is configured (subscribe to provider config changes) useEffect(() => { if (!api) return; const abortController = new AbortController(); @@ -1326,14 +1418,81 @@ const ChatInputInner: React.FC = (props) => { // Route to creation handler for creation variant if (variant === "creation") { + // Validate runtime fields before creating workspace + if (isDockerMissingImage) { + setRuntimeFieldError("docker"); + return; + } + if (isSshMissingHost) { + setRuntimeFieldError("ssh"); + return; + } + + let creationMessageTextForSend = messageText; + let creationOptionsOverride: Partial | undefined; + // Handle /init command in creation variant - populate input with init message if (messageText.startsWith("/")) { const parsed = parseCommand(messageText); + if (parsed?.type === "init") { setInput(initMessage); focusMessageInput(); return; } + + if (isUnknownSlashCommand(parsed)) { + const command = parsed.command; + const maybeSkill = agentSkillDescriptors.find((skill) => skill.name === command); + if (maybeSkill) { + const prefix = `/${maybeSkill.name}`; + const afterPrefix = messageText.slice(prefix.length); + const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); + + if (hasSeparator) { + const userText = afterPrefix.trimStart(); + if (!userText) { + pushToast({ + type: "error", + message: `Please add a message after ${prefix}`, + }); + return; + } + + if (!api) { + pushToast({ type: "error", message: "Not connected to server" }); + return; + } + + try { + const skillPackage = await api.agentSkills.get({ + projectPath: props.projectPath, + skillName: maybeSkill.name, + }); + creationMessageTextForSend = userText; + creationOptionsOverride = { + additionalSystemInstructions: buildAgentSkillSystemInstructions(skillPackage), + muxMetadata: { + type: "agent-skill", + rawCommand: messageText, + skillName: maybeSkill.name, + scope: maybeSkill.scope, + }, + }; + } catch (error) { + console.error("Failed to load agent skill:", error); + pushToast({ + type: "error", + message: + error instanceof Error + ? error.message + : `Failed to load agent skill ${maybeSkill.name}`, + }); + return; + } + } + } + } } setHasAttemptedCreateSend(true); @@ -1349,8 +1508,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 +1529,33 @@ 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 maybeSkill = agentSkillDescriptors.find((skill) => skill.name === command); + if (maybeSkill) { + const prefix = `/${maybeSkill.name}`; + const afterPrefix = messageText.slice(prefix.length); + const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); + + if (hasSeparator) { + const userText = afterPrefix.trimStart(); + if (!userText) { + pushToast({ + type: "error", + message: `Please add a message after ${prefix}`, + }); + return; + } + + skillInvocation = { descriptor: maybeSkill, userText }; + parsed = null; + } + } + } if (parsed) { // Handle /clear command - show confirmation modal @@ -1658,6 +1844,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 +1882,40 @@ const ChatInputInner: React.FC = (props) => { // Clear input immediately for responsive UX setInput(""); + + let compactionSkillInstructions: string | undefined; + if (skillInvocation) { + try { + const skillPackage = await api.agentSkills.get({ + workspaceId: props.workspaceId, + skillName: skillInvocation.descriptor.name, + }); + compactionSkillInstructions = + buildAgentSkillSystemInstructionsForAutoCompaction(skillPackage); + } catch (error) { + console.error("Failed to load agent skill:", error); + setDraft(preSendDraft); + pushToast({ + type: "error", + title: "Auto-Compaction Failed", + message: + error instanceof Error + ? error.message + : `Failed to load agent skill ${skillInvocation.descriptor.name}`, + }); + setIsSending(false); + return; + } + } + + const compactionSendMessageOptions: SendMessageOptions = { + ...sendMessageOptions, + additionalSystemInstructions: mergeAdditionalSystemInstructions( + sendMessageOptions.additionalSystemInstructions, + compactionSkillInstructions + ), + }; + setImageAttachments([]); setHideReviewsDuringSend(true); @@ -1702,13 +1924,13 @@ const ChatInputInner: React.FC = (props) => { api, workspaceId: props.workspaceId, continueMessage: buildContinueMessage({ - text: messageText, + text: messageTextForSend, imageParts, reviews: reviewsData, model: sendMessageOptions.model, agentId: sendMessageOptions.agentId ?? "exec", }), - sendMessageOptions, + sendMessageOptions: compactionSendMessageOptions, }); if (!result.success) { @@ -1760,12 +1982,41 @@ const ChatInputInner: React.FC = (props) => { const reviewsData = attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined; + // Load agent skill content (if invoked via /{skillName}) + let skillSystemInstructions: string | undefined; + if (skillInvocation) { + try { + const skillPackage = await api.agentSkills.get({ + workspaceId: props.workspaceId, + skillName: skillInvocation.descriptor.name, + }); + skillSystemInstructions = buildAgentSkillSystemInstructions(skillPackage); + } catch (error) { + console.error("Failed to load agent skill:", error); + pushToast({ + type: "error", + message: + error instanceof Error + ? error.message + : `Failed to load agent skill ${skillInvocation.descriptor.name}`, + }); + return; + } + } + // 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 +2049,12 @@ const ChatInputInner: React.FC = (props) => { { text: actualMessageText, reviews: reviewsData }, muxMetadata ); + const additionalSystemInstructions = mergeAdditionalSystemInstructions( + sendMessageOptions.additionalSystemInstructions, + compactionOptions.additionalSystemInstructions, + skillSystemInstructions + ); + muxMetadata = reviewMetadata; // Capture review IDs before clearing (for marking as checked on success) @@ -1820,6 +2077,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/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/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..0797ef9020 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" } @@ -78,6 +80,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/types/message.ts b/src/common/types/message.ts index 3eb8ec16ef..2d20edd215 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -184,6 +184,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; diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index 263d6423bd..2f22d4a248 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -75,7 +75,7 @@ Use this index to find a page's: - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx` — Block dangerous commands, lint after edits, and set up your environment - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx` — Environment variables available in agent bash commands and hooks - **Agents** - - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) with Markdown files + - AGENTS.md (`/agents`) → `references/docs/agents.md` — Agent instructions for AI assistants working on the Mux codebase - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx` — Configure agent behavior with AGENTS.md files - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx` — Share reusable workflows and references with skills - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx` — Review and collaborate on plans before execution diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index ad9c1a04f0..31c121c12b 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, @@ -495,6 +499,23 @@ 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 }) => { + 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 }) => { + 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) From 4e686fa09a4a24ef55498437bdf1d1252d6bb83a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 16:38:33 +0100 Subject: [PATCH 02/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resolve=20docs=20pa?= =?UTF-8?q?ges=20case-sensitively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I95c996c0d23c7025ada4a5689cf50ba798a4f485 Signed-off-by: Thomas Kosiewski --- src/node/builtinSkills/mux-docs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index 2f22d4a248..263d6423bd 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -75,7 +75,7 @@ Use this index to find a page's: - Tool Hooks (`/hooks/tools`) → `references/docs/hooks/tools.mdx` — Block dangerous commands, lint after edits, and set up your environment - Environment Variables (`/hooks/environment-variables`) → `references/docs/hooks/environment-variables.mdx` — Environment variables available in agent bash commands and hooks - **Agents** - - AGENTS.md (`/agents`) → `references/docs/agents.md` — Agent instructions for AI assistants working on the Mux codebase + - Agents (`/agents`) → `references/docs/agents/index.mdx` — Define custom agents (modes + subagents) with Markdown files - Instruction Files (`/agents/instruction-files`) → `references/docs/agents/instruction-files.mdx` — Configure agent behavior with AGENTS.md files - Agent Skills (`/agents/agent-skills`) → `references/docs/agents/agent-skills.mdx` — Share reusable workflows and references with skills - Plan Mode (`/agents/plan-mode`) → `references/docs/agents/plan-mode.mdx` — Review and collaborate on plans before execution From edbcfc6df8a88d19dddeb9870123cf1fcb191a4d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 16:44:18 +0100 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20respect=20disableWo?= =?UTF-8?q?rkspaceAgents=20for=20skill=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I02c5eb492aa2c6ab8bfbdbec1342294fda663396 Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 7cdc87e13a..b511767ef6 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1033,7 +1033,10 @@ const ChatInputInner: React.FC = (props) => { const discoveryInput = variant === "workspace" && workspaceId - ? { workspaceId } + ? { + workspaceId, + disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, + } : variant === "creation" && atMentionProjectPath ? { projectPath: atMentionProjectPath } : null; @@ -1063,7 +1066,7 @@ const ChatInputInner: React.FC = (props) => { return () => { isMounted = false; }; - }, [api, variant, workspaceId, atMentionProjectPath]); + }, [api, variant, workspaceId, atMentionProjectPath, sendMessageOptions.disableWorkspaceAgents]); // Voice input: track whether OpenAI API key is configured (subscribe to provider config changes) useEffect(() => { @@ -1888,6 +1891,7 @@ const ChatInputInner: React.FC = (props) => { try { const skillPackage = await api.agentSkills.get({ workspaceId: props.workspaceId, + disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, skillName: skillInvocation.descriptor.name, }); compactionSkillInstructions = @@ -1988,6 +1992,7 @@ const ChatInputInner: React.FC = (props) => { try { const skillPackage = await api.agentSkills.get({ workspaceId: props.workspaceId, + disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, skillName: skillInvocation.descriptor.name, }); skillSystemInstructions = buildAgentSkillSystemInstructions(skillPackage); From 469aea9966208c2b4d45a56b4afbd979ff0bd1cf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 16:57:49 +0100 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20/skill?= =?UTF-8?q?=20metadata=20through=20auto-compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ieca203f2a1cd456dab7b50f6fbe9acd3abe305a1 Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 8 ++++++ src/common/types/message.test.ts | 32 ++++++++++++++++++++++ src/common/types/message.ts | 6 ++++ src/node/services/agentSession.ts | 5 +++- 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b511767ef6..7954f52fb5 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1931,6 +1931,14 @@ const ChatInputInner: React.FC = (props) => { 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", }), 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 2d20edd215..9c5ff13669 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -39,6 +39,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 +53,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 +79,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 +124,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, }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 50757efed2..c61ee40510 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -605,7 +605,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 From fbc01ec70fd03d571e4d536fb17637e399678070 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 17:16:32 +0100 Subject: [PATCH 05/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20duplicate?= =?UTF-8?q?=20system=20instructions=20on=20compact=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ic80b33dc4ab431bc97100a293f031436af4b5aa9 Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 7954f52fb5..a04d7417df 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -2062,9 +2062,11 @@ const ChatInputInner: React.FC = (props) => { { text: actualMessageText, reviews: reviewsData }, muxMetadata ); + // When editing /compact, compactionOptions already includes the base sendMessageOptions. + // Avoid duplicating additionalSystemInstructions. const additionalSystemInstructions = mergeAdditionalSystemInstructions( - sendMessageOptions.additionalSystemInstructions, - compactionOptions.additionalSystemInstructions, + compactionOptions.additionalSystemInstructions ?? + sendMessageOptions.additionalSystemInstructions, skillSystemInstructions ); From 7c1bd67d0d09c188e31650db9215c295f1b54d1f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 17:16:52 +0100 Subject: [PATCH 06/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20document=20agent=20?= =?UTF-8?q?skills=20slash=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Id856c06b6b75f3dbfb28fc2e7b60231e9c8b9bcd Signed-off-by: Thomas Kosiewski --- docs/agents/agent-skills.mdx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 005f87e96b..6623500d88 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -80,6 +80,12 @@ 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} ...` in the chat input. mux will send your message normally, but inject the skill content into the system context for that send. + - 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 +107,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} ...`). +- 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 From 72763709054ea3887219ddba882a911a61732ad0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 15 Jan 2026 21:00:21 +0100 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20improve=20slash=20c?= =?UTF-8?q?ommand=20suggestions=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ie3201a314e2c2b804a103c0437a0c1964baea5df Signed-off-by: Thomas Kosiewski --- src/browser/components/CommandPalette.tsx | 51 +++++++++++++++++-- src/browser/components/CommandSuggestions.tsx | 5 +- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/browser/components/CommandPalette.tsx b/src/browser/components/CommandPalette.tsx index cf60bcd6e2..732e7140db 100644 --- a/src/browser/components/CommandPalette.tsx +++ b/src/browser/components/CommandPalette.tsx @@ -1,6 +1,8 @@ -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 type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { CommandAction } from "@/browser/contexts/CommandRegistryContext"; import { formatKeybind, @@ -38,6 +40,13 @@ interface PaletteGroup { } export const CommandPalette: React.FC = ({ getSlashContext }) => { + const { api } = useAPI(); + + const slashContext = getSlashContext?.(); + const slashWorkspaceId = slashContext?.workspaceId; + + 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 cached = agentSkillsCacheRef.current.get(slashWorkspaceId); + if (cached) { + setAgentSkills(cached); + return; + } + + let cancelled = false; + api.agentSkills + .list({ workspaceId: slashWorkspaceId }) + .then((skills) => { + if (cancelled) return; + agentSkillsCacheRef.current.set(slashWorkspaceId, skills); + setAgentSkills(skills); + }) + .catch(() => { + if (cancelled) return; + setAgentSkills([]); + }); + + return () => { + cancelled = true; + }; + }, [api, isOpen, slashWorkspaceId]); + const rawActions = getActions(); const recentIndex = useMemo(() => { @@ -184,8 +223,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 +284,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}
From 4f6d08bc33a75a9ccd1b680bcd3ccf173c57a5b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 16 Jan 2026 09:37:43 +0100 Subject: [PATCH 08/16] feat: persist agent-skill snapshots as synthetic messages Change-Id: I5c7323a6734201ca19b83a82c0885a381141a05d Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 129 +------ src/common/orpc/schemas/message.ts | 9 + src/common/types/message.ts | 10 + .../agentSession.agentSkillSnapshot.test.ts | 353 ++++++++++++++++++ src/node/services/agentSession.ts | 124 +++++- 5 files changed, 498 insertions(+), 127 deletions(-) create mode 100644 src/node/services/agentSession.agentSkillSnapshot.test.ts diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index a04d7417df..5ac195dde9 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -83,7 +83,7 @@ import { processImageFiles, } from "@/browser/utils/imageHandling"; -import type { AgentSkillDescriptor, AgentSkillPackage } from "@/common/types/agentSkill"; +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"; @@ -112,46 +112,13 @@ import initMessage from "@/browser/assets/initMessage.txt?raw"; // Be conservative here so we can warn the user before writes start failing. const MAX_PERSISTED_IMAGE_DRAFT_CHARS = 4_000_000; -// Guardrail: prevent accidental mega-prompts when injecting skill bodies. +// 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"; } -const MAX_AGENT_SKILL_BODY_CHARS = 50_000; - -function mergeAdditionalSystemInstructions( - ...parts: Array -): string | undefined { - const filtered = parts.filter((part) => typeof part === "string" && part.trim().length > 0); - if (filtered.length === 0) { - return undefined; - } - return filtered.join("\n\n"); -} - -function buildAgentSkillSystemInstructionsForAutoCompaction(skill: AgentSkillPackage): string { - return ( - "The following skill instructions are for the user's next message after compaction. " + - "If you are currently summarizing/compacting, ignore them.\n\n" + - buildAgentSkillSystemInstructions(skill) - ); -} -function buildAgentSkillSystemInstructions(skill: AgentSkillPackage): string { - const scopeLabel = skill.scope === "global" ? "user" : skill.scope; - const skillName = skill.frontmatter.name; - const header = `Agent Skill applied: ${skillName} (${scopeLabel})`; - const invocationHint = `If the user's message starts with /${skillName}, ignore that leading token.`; - - const body = - skill.body.length > MAX_AGENT_SKILL_BODY_CHARS - ? `${skill.body.slice(0, MAX_AGENT_SKILL_BODY_CHARS)}\n\n[Skill body truncated to ${MAX_AGENT_SKILL_BODY_CHARS} characters]` - : skill.body; - - return `${header}\n${invocationHint}\n\n${body}`; -} - // Import types from local types file import type { ChatInputProps, ChatInputAPI } from "./types"; import type { ImagePart, SendMessageOptions } from "@/common/orpc/types"; @@ -1467,32 +1434,15 @@ const ChatInputInner: React.FC = (props) => { return; } - try { - const skillPackage = await api.agentSkills.get({ - projectPath: props.projectPath, + creationMessageTextForSend = userText; + creationOptionsOverride = { + muxMetadata: { + type: "agent-skill", + rawCommand: messageText, skillName: maybeSkill.name, - }); - creationMessageTextForSend = userText; - creationOptionsOverride = { - additionalSystemInstructions: buildAgentSkillSystemInstructions(skillPackage), - muxMetadata: { - type: "agent-skill", - rawCommand: messageText, - skillName: maybeSkill.name, - scope: maybeSkill.scope, - }, - }; - } catch (error) { - console.error("Failed to load agent skill:", error); - pushToast({ - type: "error", - message: - error instanceof Error - ? error.message - : `Failed to load agent skill ${maybeSkill.name}`, - }); - return; - } + scope: maybeSkill.scope, + }, + }; } } } @@ -1886,38 +1836,8 @@ const ChatInputInner: React.FC = (props) => { // Clear input immediately for responsive UX setInput(""); - let compactionSkillInstructions: string | undefined; - if (skillInvocation) { - try { - const skillPackage = await api.agentSkills.get({ - workspaceId: props.workspaceId, - disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, - skillName: skillInvocation.descriptor.name, - }); - compactionSkillInstructions = - buildAgentSkillSystemInstructionsForAutoCompaction(skillPackage); - } catch (error) { - console.error("Failed to load agent skill:", error); - setDraft(preSendDraft); - pushToast({ - type: "error", - title: "Auto-Compaction Failed", - message: - error instanceof Error - ? error.message - : `Failed to load agent skill ${skillInvocation.descriptor.name}`, - }); - setIsSending(false); - return; - } - } - const compactionSendMessageOptions: SendMessageOptions = { ...sendMessageOptions, - additionalSystemInstructions: mergeAdditionalSystemInstructions( - sendMessageOptions.additionalSystemInstructions, - compactionSkillInstructions - ), }; setImageAttachments([]); @@ -1994,29 +1914,6 @@ const ChatInputInner: React.FC = (props) => { const reviewsData = attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined; - // Load agent skill content (if invoked via /{skillName}) - let skillSystemInstructions: string | undefined; - if (skillInvocation) { - try { - const skillPackage = await api.agentSkills.get({ - workspaceId: props.workspaceId, - disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, - skillName: skillInvocation.descriptor.name, - }); - skillSystemInstructions = buildAgentSkillSystemInstructions(skillPackage); - } catch (error) { - console.error("Failed to load agent skill:", error); - pushToast({ - type: "error", - message: - error instanceof Error - ? error.message - : `Failed to load agent skill ${skillInvocation.descriptor.name}`, - }); - return; - } - } - // When editing a /compact command, regenerate the actual summarization request let actualMessageText = messageTextForSend; let muxMetadata: MuxFrontendMetadata | undefined = skillInvocation @@ -2064,11 +1961,9 @@ const ChatInputInner: React.FC = (props) => { ); // When editing /compact, compactionOptions already includes the base sendMessageOptions. // Avoid duplicating additionalSystemInstructions. - const additionalSystemInstructions = mergeAdditionalSystemInstructions( + const additionalSystemInstructions = compactionOptions.additionalSystemInstructions ?? - sendMessageOptions.additionalSystemInstructions, - skillSystemInstructions - ); + sendMessageOptions.additionalSystemInstructions; muxMetadata = reviewMetadata; 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.ts b/src/common/types/message.ts index 9c5ff13669..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"; /** @@ -238,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/node/services/agentSession.agentSkillSnapshot.test.ts b/src/node/services/agentSession.agentSkillSnapshot.test.ts new file mode 100644 index 0000000000..b7703b718e --- /dev/null +++ b/src/node/services/agentSession.agentSkillSnapshot.test.ts @@ -0,0 +1,353 @@ +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("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.ts b/src/node/services/agentSession.ts index c61ee40510..5d0b9036e4 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,17 @@ 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); + } 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 +580,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 +604,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" }); @@ -1335,6 +1365,80 @@ export class AgentSession { return { snapshotMessage, materializedTokens: tokens }; } + private async materializeAgentSkillSnapshot( + muxMetadata: MuxFrontendMetadata | 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 } + ); + const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + const resolved = await readAgentSkill(runtime, workspacePath, 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. From 6b83c2bddb6746dfd3b097b8e59ba03556efcd82 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 16 Jan 2026 09:43:47 +0100 Subject: [PATCH 09/16] fix: remove stale creation runtime checks Change-Id: I58d46699537fb3139a40c96d775ef39d0646a2ca Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5ac195dde9..0ca2d1d17b 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1388,16 +1388,6 @@ const ChatInputInner: React.FC = (props) => { // Route to creation handler for creation variant if (variant === "creation") { - // Validate runtime fields before creating workspace - if (isDockerMissingImage) { - setRuntimeFieldError("docker"); - return; - } - if (isSshMissingHost) { - setRuntimeFieldError("ssh"); - return; - } - let creationMessageTextForSend = messageText; let creationOptionsOverride: Partial | undefined; From a96c3ec2d816fa12637004a8383d80fe9f8b862f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 17 Jan 2026 10:14:24 +0100 Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20make=20/init?= =?UTF-8?q?=20a=20built-in=20agent=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert `/init` from a hardcoded slash command into a built-in agent skill. - Allow `/{skill-name}` invocations with no trailing message (send a tiny default message). - Update project “Initialize” banner to send `/init`. - Document built-in skill precedence + `/init` override behavior. Signed-off-by: Thomas Kosiewski ---
📋 Implementation Plan - Remove `/init` from the hardcoded slash-command registry + ChatInput branching. - Treat `/init` as a normal **built-in agent skill** (discovered via `agentSkills.list`). - Allow invoking a skill with **no trailing message** (e.g. `/init`) so it can fully replace “macro” slash commands. - **Registry**: `/init` is a hardcoded slash command (`initCommandDefinition`) in `src/browser/utils/slashCommands/registry.ts`, parsed as `{ type: "init" }`. - **UI special-case**: `src/browser/components/ChatInput/index.tsx` intercepts `parsed.type === "init"` and replaces the input text with `src/browser/assets/initMessage.txt` (`?raw`). - **Project banner**: `src/browser/components/ProjectPage.tsx` restores the same text and auto-sends it; it also flips project-scope agent mode to `exec`. - **Backend**: no special handling—`/init` ultimately becomes a normal user message containing a large prompt (currently wrapped in ``). - `src/browser/components/ChatInput/index.tsx` - Imports `initMessage` from `@/browser/assets/initMessage.txt?raw`. - Two `/init` special-cases: `handleSend` checks `parsed?.type === "init"` in both the **creation** path (~line 1402) and **workspace** path (~line 1567). - Two “skill invocation requires trailing message” toasts: - **Creation** skill path: toast around ~line 1421. - **Workspace** skill path: toast around ~line 1496. - `src/browser/components/ProjectPage.tsx` - Imports `initMessage` from `@/browser/assets/initMessage.txt?raw` and uses it in the Agents init banner flow. - `src/browser/utils/slashCommands/registry.ts` - Defines `initCommandDefinition` and registers it. - `src/browser/utils/slashCommands/types.ts` - Includes `{ type: "init" }` in `ParsedCommand` union. - `src/browser/utils/slashCommands/parser.test.ts` - Contains tests asserting `/init` parses to `{ type: "init" }`. **Net LoC estimate (product code only): ~+30–80** Core idea: make `init` a built-in skill and invoke it like any other skill. To support `/init` with no extra text, we must avoid sending an empty message because `AgentSession.sendMessage` rejects empty messages. When the user invokes a skill with no trailing message, send a tiny default message (while still displaying the raw command via `muxMetadata.rawCommand`). Example: - Raw input: `/init` - Message sent to backend: `Run the "init" skill.` - `muxMetadata`: `{ type: "agent-skill", rawCommand: "/init", skillName: "init", scope: "built-in" }` This keeps the system general (no `/init` special casing) and satisfies the backend’s non-empty constraint. Skill resolution already supports overrides in this order: 1. **Project**: `.mux/skills//SKILL.md` 2. **Global**: `~/.mux/skills//SKILL.md` 3. **Built-in**: `src/node/builtinSkills/.md` So yes—once `init` is shipped as a built-in skill, a user can override it by creating `~/.mux/skills/init/SKILL.md` (and a project can override that via `.mux/skills/init/SKILL.md`).
Tradeoff This changes `/init` from an editable “template prefill” into an immediate command. If we still want editability later, we can add a separate “insert skill into input” UI affordance, but that’s not required for this cleanup.
1) **Allow skills to be sent without a message (creation + workspace ChatInput)** - In `src/browser/components/ChatInput/index.tsx`, update both “unknown slash command → agent skill” paths: - **Creation path** (~line 1421): remove the “Please add a message after /{skill}” toast + early return. - **Workspace path** (~line 1496): same. - If trailing text is empty, send a synthetic default message, e.g. ```ts const userText = afterPrefix.trimStart() || `Run the "${skillName}" skill.`; ``` - Keep setting `muxMetadata = { type: "agent-skill", rawCommand: messageText, ... }` so the UI shows the original `/skill` (not the synthetic message). 2) **Add built-in skill `init`** - Create `src/node/builtinSkills/init.md` (frontmatter: `name: init`, description matching current `/init`). - Move/copy the existing `src/browser/assets/initMessage.txt` content into the skill body (keep as-is initially for minimal behavior change). - Regenerate bundled skill content (`scripts/gen_builtin_skills.ts` → `builtInSkillContent.generated.ts`). - Notes / gotchas: - Frontmatter must start at the very top of the file (`---` as first bytes). - Keep skill content < 1MB. 3) **Remove `/init` as a hardcoded slash command + UI branch** - Delete `initCommandDefinition` from `src/browser/utils/slashCommands/registry.ts`. - Remove `{ type: "init" }` from `src/browser/utils/slashCommands/types.ts`. - Remove the `parsed.type === "init"` branches in `ChatInput` (both creation + workspace). - Update/remove `/init` parsing expectations in `src/browser/utils/slashCommands/parser.test.ts`. - Result: `/init` will be parsed as an unknown command and then resolved via `agentSkills.list` as a skill. 4) **Update the project “Initialize” banner to invoke the skill** - In `src/browser/components/ProjectPage.tsx`: - Replace `restoreText(initMessage)` with `restoreText("/init")` (and keep the auto-send behavior). - Keep the existing “switch project-scope mode to exec” behavior. 5) **Remove the old browser asset** - Delete `src/browser/assets/initMessage.txt` and remove its imports once no longer referenced. 6) **Docs** - Update docs to reflect that `/init` is now just the `init` skill (and can be overridden by users/projects via `~/.mux/skills/init/SKILL.md` / `.mux/skills/init/SKILL.md`). 7) **Validation checklist** - Typing `/init` and hitting send triggers the init skill (no “empty message” backend error; no “add a message” toast). - Project page “Initialize” banner still runs init automatically. - `/init` appears once in suggestions (as a skill, not a hardcoded command). - Other skills still work both with and without trailing messages. **Net LoC estimate (product code only): ~+20–60** Keep “prefill editable template” UX, but fetch the template body from `agentSkills.get("init")` instead of a browser asset. Tradeoff: still requires `/init`-specific behavior (or introducing a generalized “insert skill into input” feature).
Follow-up: reduce other “prompt template” slash commands Once skills can be invoked with no trailing message, we can eliminate other *macro-style* slash commands by applying the same pattern: - Move the instruction text into `src/node/builtinSkills/.md`. - Remove the hardcoded command definition + `ChatInput` branch. - Let `/` resolve via `agentSkills.list`. Keep hardcoded slash commands only for **UI/backend actions** (clear history, model/provider settings, MCP management, etc.).
--- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh` • Cost: $30.06_ Change-Id: Iaf4c066db3fc75bf52993552b2d0d6ee9655bb28 --- docs/agents/agent-skills.mdx | 10 +- src/browser/components/ChatInput/index.tsx | 33 +----- src/browser/components/ProjectPage.tsx | 9 +- .../utils/slashCommands/parser.test.ts | 10 +- src/browser/utils/slashCommands/registry.ts | 18 --- src/browser/utils/slashCommands/types.ts | 1 - .../builtinSkills/init.md} | 66 ++++++----- .../agentSession.agentSkillSnapshot.test.ts | 103 ++++++++++++++++++ ...gentSession.continueMessageAgentId.test.ts | 2 + src/node/services/agentSession.ts | 23 +++- .../agentSkills/agentSkillsService.test.ts | 2 +- 11 files changed, 183 insertions(+), 94 deletions(-) rename src/{browser/assets/initMessage.txt => node/builtinSkills/init.md} (66%) diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 6623500d88..ba9384bd7e 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 @@ -82,7 +83,8 @@ mux injects an `` block into the system prompt listing the availab You can apply a skill in two ways: -- **Explicit (slash command)**: type `/{skill-name} ...` in the chat input. mux will send your message normally, but inject the skill content into the system context for that send. +- **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 inject the skill content into the system context for that send. + - 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. @@ -107,7 +109,7 @@ agent_skill_read_file({ name: "my-skill", filePath: "references/template.md" }); ## Current limitations -- Slash command invocation supports only a single skill as the first token (for example `/{skill-name} ...`). +- 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). diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 0ca2d1d17b..08a1c05e3c 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -106,7 +106,6 @@ 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. @@ -1391,16 +1390,9 @@ const ChatInputInner: React.FC = (props) => { let creationMessageTextForSend = messageText; let creationOptionsOverride: Partial | undefined; - // Handle /init command in creation variant - populate input with init message if (messageText.startsWith("/")) { const parsed = parseCommand(messageText); - if (parsed?.type === "init") { - setInput(initMessage); - focusMessageInput(); - return; - } - if (isUnknownSlashCommand(parsed)) { const command = parsed.command; const maybeSkill = agentSkillDescriptors.find((skill) => skill.name === command); @@ -1410,14 +1402,7 @@ const ChatInputInner: React.FC = (props) => { const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); if (hasSeparator) { - const userText = afterPrefix.trimStart(); - if (!userText) { - pushToast({ - type: "error", - message: `Please add a message after ${prefix}`, - }); - return; - } + const userText = afterPrefix.trimStart() || `Run the "${maybeSkill.name}" skill.`; if (!api) { pushToast({ type: "error", message: "Not connected to server" }); @@ -1485,14 +1470,7 @@ const ChatInputInner: React.FC = (props) => { const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); if (hasSeparator) { - const userText = afterPrefix.trimStart(); - if (!userText) { - pushToast({ - type: "error", - message: `Please add a message after ${prefix}`, - }); - return; - } + const userText = afterPrefix.trimStart() || `Run the "${maybeSkill.name}" skill.`; skillInvocation = { descriptor: maybeSkill, userText }; parsed = null; @@ -1565,13 +1543,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) { 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/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/types.ts b/src/browser/utils/slashCommands/types.ts index 0797ef9020..b1b118a5cb 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -38,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; 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/services/agentSession.agentSkillSnapshot.test.ts b/src/node/services/agentSession.agentSkillSnapshot.test.ts index b7703b718e..5c613aa3d8 100644 --- a/src/node/services/agentSession.agentSkillSnapshot.test.ts +++ b/src/node/services/agentSession.agentSkillSnapshot.test.ts @@ -140,6 +140,109 @@ describe("AgentSession.sendMessage (agent skill snapshots)", () => { 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"; 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 5d0b9036e4..5f224ac508 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -561,7 +561,10 @@ export class AgentSession { const snapshotResult = await this.materializeFileAtMentionsSnapshot(trimmedMessage); let skillSnapshotResult: { snapshotMessage: MuxMessage } | null = null; try { - skillSnapshotResult = await this.materializeAgentSkillSnapshot(typedMuxMetadata); + skillSnapshotResult = await this.materializeAgentSkillSnapshot( + typedMuxMetadata, + options?.disableWorkspaceAgents + ); } catch (error) { return Err( createUnknownSendMessageError(error instanceof Error ? error.message : String(error)) @@ -663,6 +666,7 @@ export class AgentSession { additionalSystemInstructions: options.additionalSystemInstructions, providerOptions: options.providerOptions, experiments: options.experiments, + disableWorkspaceAgents: options.disableWorkspaceAgents, }; // Add image parts if present @@ -1366,7 +1370,8 @@ export class AgentSession { } private async materializeAgentSkillSnapshot( - muxMetadata: MuxFrontendMetadata | undefined + muxMetadata: MuxFrontendMetadata | undefined, + disableWorkspaceAgents: boolean | undefined ): Promise<{ snapshotMessage: MuxMessage } | null> { if (!muxMetadata || muxMetadata.type !== "agent-skill") { return null; @@ -1392,9 +1397,19 @@ export class AgentSession { metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, { projectPath: metadata.projectPath, workspaceName: metadata.name } ); - const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); - const resolved = await readAgentSkill(runtime, workspacePath, parsedName.data); + // 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 = 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(); From b18ee9dd54c95343eccbdddcf7f22573f8ee8052 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 17 Jan 2026 11:15:17 +0100 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resolve=20agent=20s?= =?UTF-8?q?kills=20even=20before=20suggestions=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I3d44a0e733613e52933e13928200d994158a7aa6 Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 73 +++++++++++++++++----- src/browser/components/CommandPalette.tsx | 21 +++++-- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 08a1c05e3c..b353eaaec5 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1395,14 +1395,33 @@ const ChatInputInner: React.FC = (props) => { if (isUnknownSlashCommand(parsed)) { const command = parsed.command; - const maybeSkill = agentSkillDescriptors.find((skill) => skill.name === command); - if (maybeSkill) { - const prefix = `/${maybeSkill.name}`; - const afterPrefix = messageText.slice(prefix.length); - const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); + const prefix = `/${command}`; + const afterPrefix = messageText.slice(prefix.length); + const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); - if (hasSeparator) { - const userText = afterPrefix.trimStart() || `Run the "${maybeSkill.name}" skill.`; + 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" }); @@ -1414,8 +1433,8 @@ const ChatInputInner: React.FC = (props) => { muxMetadata: { type: "agent-skill", rawCommand: messageText, - skillName: maybeSkill.name, - scope: maybeSkill.scope, + skillName: skill.name, + scope: skill.scope, }, }; } @@ -1463,16 +1482,36 @@ const ChatInputInner: React.FC = (props) => { if (isUnknownSlashCommand(parsed)) { const command = parsed.command; - const maybeSkill = agentSkillDescriptors.find((skill) => skill.name === command); - if (maybeSkill) { - const prefix = `/${maybeSkill.name}`; - const afterPrefix = messageText.slice(prefix.length); - const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); + const prefix = `/${command}`; + const afterPrefix = messageText.slice(prefix.length); + const hasSeparator = afterPrefix.length === 0 || /^\s/.test(afterPrefix); - if (hasSeparator) { - const userText = afterPrefix.trimStart() || `Run the "${maybeSkill.name}" skill.`; + 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: maybeSkill, userText }; + skillInvocation = { descriptor: skill, userText }; parsed = null; } } diff --git a/src/browser/components/CommandPalette.tsx b/src/browser/components/CommandPalette.tsx index 732e7140db..e3cdce8d83 100644 --- a/src/browser/components/CommandPalette.tsx +++ b/src/browser/components/CommandPalette.tsx @@ -2,6 +2,7 @@ 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 { @@ -13,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 { @@ -45,6 +47,12 @@ export const CommandPalette: React.FC = ({ getSlashContext 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(); @@ -93,7 +101,9 @@ export const CommandPalette: React.FC = ({ getSlashContext return; } - const cached = agentSkillsCacheRef.current.get(slashWorkspaceId); + const cacheKey = `${slashWorkspaceId}:${disableWorkspaceAgents ? "project" : "worktree"}`; + + const cached = agentSkillsCacheRef.current.get(cacheKey); if (cached) { setAgentSkills(cached); return; @@ -101,10 +111,13 @@ export const CommandPalette: React.FC = ({ getSlashContext let cancelled = false; api.agentSkills - .list({ workspaceId: slashWorkspaceId }) + .list({ + workspaceId: slashWorkspaceId, + disableWorkspaceAgents: disableWorkspaceAgents || undefined, + }) .then((skills) => { if (cancelled) return; - agentSkillsCacheRef.current.set(slashWorkspaceId, skills); + agentSkillsCacheRef.current.set(cacheKey, skills); setAgentSkills(skills); }) .catch(() => { @@ -115,7 +128,7 @@ export const CommandPalette: React.FC = ({ getSlashContext return () => { cancelled = true; }; - }, [api, isOpen, slashWorkspaceId]); + }, [api, isOpen, slashWorkspaceId, disableWorkspaceAgents]); const rawActions = getActions(); From c8f823eb70732b3b753d0cd609994ab37504cc0a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 09:34:26 +0100 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20wait=20for=20init?= =?UTF-8?q?=20before=20agent=20skill=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I4c7d46ae84d02756979d6fe2a0897183699887ce Signed-off-by: Thomas Kosiewski --- src/node/orpc/router.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 31c121c12b..a19096e76a 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -453,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); } @@ -491,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); } @@ -504,6 +504,10 @@ export const router = (authToken?: string) => { .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); }), @@ -511,6 +515,10 @@ export const router = (authToken?: string) => { .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; From 1cd3349fc63f4ccf8927df9fe97a506cbe98e4bb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 12:37:17 +0100 Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20isolate=20agent-ski?= =?UTF-8?q?ll=20metadata=20in=20queued=20sends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I4959d46c388d7e2a3917c61d702119b8d534dfa9 Signed-off-by: Thomas Kosiewski --- src/node/services/messageQueue.test.ts | 40 +++++++++++++++++++ src/node/services/messageQueue.ts | 54 ++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) 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 From 4cd9d2976659d678af5bc68f7cfd4d5adc4e1b5c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 13:31:29 +0100 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20project-?= =?UTF-8?q?scope=20skill=20discovery=20in=20create=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I8c1e7241f8e93d8ed67051f4881c1b3da7c12c5f Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b353eaaec5..b4c377c771 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1436,6 +1436,10 @@ const ChatInputInner: React.FC = (props) => { 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 } : {}), }; } } From 6460b0aab698e8a5d87138ece499c5faaf0d776e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 18 Jan 2026 13:49:10 +0100 Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20stale=20a?= =?UTF-8?q?gent=20skill=20suggestion=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ic1209de000b69724b0ddff25cadaafb6aa8241d5 Signed-off-by: Thomas Kosiewski --- src/browser/components/ChatInput/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index b4c377c771..5a2d885c23 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -211,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); @@ -988,10 +989,11 @@ const ChatInputInner: React.FC = (props) => { // Load agent skills for suggestions useEffect(() => { let isMounted = true; + const requestId = ++agentSkillsRequestIdRef.current; const loadAgentSkills = async () => { if (!api) { - if (isMounted) { + if (isMounted && agentSkillsRequestIdRef.current === requestId) { setAgentSkillDescriptors([]); } return; @@ -1008,7 +1010,7 @@ const ChatInputInner: React.FC = (props) => { : null; if (!discoveryInput) { - if (isMounted) { + if (isMounted && agentSkillsRequestIdRef.current === requestId) { setAgentSkillDescriptors([]); } return; @@ -1016,14 +1018,18 @@ const ChatInputInner: React.FC = (props) => { try { const skills = await api.agentSkills.list(discoveryInput); - if (isMounted && Array.isArray(skills)) { + if (!isMounted || agentSkillsRequestIdRef.current !== requestId) { + return; + } + if (Array.isArray(skills)) { setAgentSkillDescriptors(skills); } } catch (error) { console.error("Failed to load agent skills:", error); - if (isMounted) { - setAgentSkillDescriptors([]); + if (!isMounted || agentSkillsRequestIdRef.current !== requestId) { + return; } + setAgentSkillDescriptors([]); } }; From 7d738d5430994aca3031063f5ee29f439200d1d7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 19 Jan 2026 11:17:35 +0100 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=A4=96=20docs:=20clarify=20skill=20?= =?UTF-8?q?snapshot=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I7f4fd9e7b75dbeb2de8dd2104341696f8c476a51 Signed-off-by: Thomas Kosiewski --- docs/agents/agent-skills.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index ba9384bd7e..32fd270b41 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -83,7 +83,7 @@ mux injects an `` block into the system prompt listing the availab 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 inject the skill content into the system context for that send. +- **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.