Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ lint-zizmor: ## Run zizmor security analysis on GitHub Actions workflows
@./scripts/zizmor.sh --min-confidence high .

# Shell files to lint (excludes node_modules, build artifacts, .git)
SHELL_SRC_FILES := $(shell find . -not \( -path '*/.git/*' -o -path './node_modules/*' -o -path './build/*' -o -path './dist/*' \) -type f -name '*.sh' 2>/dev/null)
SHELL_SRC_FILES := $(shell find . -not \( -path '*/.git/*' -o -path '*/node_modules/*' -o -path '*/build/*' -o -path '*/dist/*' -o -path '*/.venv/*' -o -path '*/Pods/*' \) -type f -name '*.sh' 2>/dev/null)

lint-shellcheck: ## Run shellcheck on shell scripts
shellcheck --external-sources $(SHELL_SRC_FILES)
Expand Down
38 changes: 35 additions & 3 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,13 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (!isMounted || agentSkillsRequestIdRef.current !== requestId) {
return;
}

const errorMessage = error instanceof Error ? error.message : String(error);
pushToast({
type: "error",
title: "Agent skills unavailable",
message: `Failed to load agent skills: ${errorMessage}`,
});
setAgentSkillDescriptors([]);
}
};
Expand All @@ -1039,7 +1046,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return () => {
isMounted = false;
};
}, [api, variant, workspaceId, atMentionProjectPath, sendMessageOptions.disableWorkspaceAgents]);
}, [
api,
variant,
workspaceId,
atMentionProjectPath,
sendMessageOptions.disableWorkspaceAgents,
pushToast,
]);

// Voice input: track whether OpenAI API key is configured (subscribe to provider config changes)
useEffect(() => {
Expand Down Expand Up @@ -1422,7 +1436,16 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
description: pkg.frontmatter.description,
scope: pkg.scope,
};
} catch {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (!/Agent skill not found/i.test(errorMessage)) {
pushToast({
type: "error",
message: `Failed to resolve skill '${command}': ${errorMessage}`,
});
return;
}

// Not a skill (or not available yet) - fall through.
}
}
Expand Down Expand Up @@ -1514,7 +1537,16 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
description: pkg.frontmatter.description,
scope: pkg.scope,
};
} catch {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (!/Agent skill not found/i.test(errorMessage)) {
pushToast({
type: "error",
message: `Failed to resolve skill '${command}': ${errorMessage}`,
});
return;
}

// Not a skill (or not available yet) - fall through.
}
}
Expand Down
42 changes: 42 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
AgentDefinitionDescriptor,
AgentDefinitionPackage,
} from "@/common/types/agentDefinition";
import type { AgentSkillDescriptor, AgentSkillPackage } from "@/common/types/agentSkill";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { ProjectConfig } from "@/node/config";
import {
Expand Down Expand Up @@ -81,6 +82,8 @@ export interface MockORPCClientOptions {
modeAiDefaults?: ModeAiDefaults;
/** Initial unified AI defaults for agents (plan/exec/compact + subagents) */
agentAiDefaults?: AgentAiDefaults;
/** Agent skills to expose via agentSkills.list */
agentSkills?: AgentSkillDescriptor[];
/** Agent definitions to expose via agents.list */
agentDefinitions?: AgentDefinitionDescriptor[];
/** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */
Expand Down Expand Up @@ -192,6 +195,23 @@ interface MockMcpOverrides {
toolAllowlist?: Record<string, string[]>;
}

const DEFAULT_AGENT_SKILLS: AgentSkillDescriptor[] = [
{
name: "mux-docs",
description: "Search the Mux documentation snapshot.",
scope: "built-in",
},
{
name: "react-effects",
description: "Guidelines for when to use (and avoid) useEffect in React components.",
scope: "built-in",
},
{
name: "dev-server-sandbox",
description: "Run multiple isolated mux dev-server instances.",
scope: "built-in",
},
];
type MockMcpTestResult = { success: true; tools: string[] } | { success: false; error: string };

/**
Expand Down Expand Up @@ -233,6 +253,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
modeAiDefaults: initialModeAiDefaults,
subagentAiDefaults: initialSubagentAiDefaults,
agentAiDefaults: initialAgentAiDefaults,
agentSkills: initialAgentSkills,
agentDefinitions: initialAgentDefinitions,
listBranches: customListBranches,
gitInit: customGitInit,
Expand Down Expand Up @@ -302,6 +323,9 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
},
] satisfies AgentDefinitionDescriptor[]);

const agentSkills: AgentSkillDescriptor[] = initialAgentSkills ?? DEFAULT_AGENT_SKILLS;
const agentSkillsByName = new Map(agentSkills.map((skill) => [skill.name, skill]));

let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS);

let agentAiDefaults = normalizeAgentAiDefaults(
Expand Down Expand Up @@ -466,6 +490,24 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
return Promise.resolve(agentPackage);
},
},
agentSkills: {
list: () => Promise.resolve(agentSkills),
get: (input: { skillName: string }) => {
const descriptor = agentSkillsByName.get(input.skillName);
if (!descriptor) {
throw new Error(`Agent skill not found: ${input.skillName}`);
}

const pkg = {
scope: descriptor.scope,
directoryName: descriptor.name,
frontmatter: { name: descriptor.name, description: descriptor.description },
body: "",
} satisfies AgentSkillPackage;

return Promise.resolve(pkg);
},
},
providers: {
list: () => Promise.resolve(providersList),
getConfig: () => Promise.resolve(providersConfig),
Expand Down
26 changes: 26 additions & 0 deletions src/browser/utils/messages/StreamingMessageAggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,32 @@ describe("StreamingMessageAggregator", () => {
});
});

describe("agent skill display", () => {
test("shows muxMetadata.rawCommand in the user transcript", () => {
const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT);

const msg = createMuxMessage("u1", "user", 'Run the "mux-docs" skill.', {
timestamp: 1,
historySequence: 1,
muxMetadata: {
type: "agent-skill",
rawCommand: "/mux-docs",
skillName: "mux-docs",
scope: "built-in",
},
});

aggregator.loadHistoricalMessages([msg], false);

const displayed = aggregator.getDisplayedMessages();
const userMsg = displayed.find((m) => m.type === "user");
expect(userMsg).toBeDefined();
if (userMsg?.type === "user") {
expect(userMsg.content).toBe("/mux-docs");
}
});
});

describe("compaction detection", () => {
test("treats active stream as compacting on reconnect when stream-start has no mode", () => {
const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT);
Expand Down
8 changes: 7 additions & 1 deletion src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1740,14 +1740,20 @@ export class StreamingMessageAggregator {
}
: undefined;

const agentSkillCommand = muxMeta?.type === "agent-skill" ? muxMeta.rawCommand : undefined;

const displayContent = compactionRequest
? buildCompactionDisplayText(compactionRequest)
: (agentSkillCommand ?? content);

// Extract reviews from muxMetadata for rich UI display (orthogonal to message type)
const reviews = muxMeta?.reviews;

displayedMessages.push({
type: "user",
id: message.id,
historyId: message.id,
content: compactionRequest ? buildCompactionDisplayText(compactionRequest) : content,
content: displayContent,
imageParts: imageParts.length > 0 ? imageParts : undefined,
historySequence,
isSynthetic: message.metadata?.synthetic === true ? true : undefined,
Expand Down
32 changes: 32 additions & 0 deletions src/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,38 @@ describe("oRPC Server Endpoints", () => {
await fs.rm(projectPath, { recursive: true, force: true });
}
});
test("agentSkills.list and agentSkills.get work with workspaceId", async () => {
const client = createHttpClient(serverHandle.server.baseUrl);

const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agent-skills-workspace-"));
const branchName = `agent-skills-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;

try {
const createResult = await client.workspace.create({
projectPath,
branchName,
runtimeConfig: { type: "local" },
});

expect(createResult.success).toBe(true);
if (!createResult.success) {
return;
}

const workspaceId = createResult.metadata.id;

const descriptors = await client.agentSkills.list({ workspaceId });
expect(descriptors.length).toBeGreaterThan(0);
expect(descriptors.some((d) => d.name === "mux-docs" && d.scope === "built-in")).toBe(true);

const pkg = await client.agentSkills.get({ workspaceId, skillName: "mux-docs" });
expect(pkg.frontmatter.name).toBe("mux-docs");
expect(pkg.scope).toBe("built-in");
} 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("");
Expand Down
26 changes: 18 additions & 8 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,30 @@ async function resolveAgentDiscoveryContext(
throw new Error(metadataResult.error);
}
const metadata = metadataResult.data;
const runtime = createRuntime(metadata.runtimeConfig, { projectPath: metadata.projectPath });
// When workspace agents disabled, discover from project path instead of worktree
// (but still use the workspace's runtime for SSH compatibility)
const discoveryPath = input.disableWorkspaceAgents

// Align with aiService.sendMessage: pass workspaceName so Docker can derive containerName
// when runtimeConfig.containerName is missing.
const runtime = createRuntime(metadata.runtimeConfig ?? { type: "local" }, {
projectPath: metadata.projectPath,
workspaceName: metadata.name,
});

// In-place workspaces (CLI/benchmarks) have projectPath === name.
// Use 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 disabled, discover from project path instead of worktree
// (but still use the workspace's runtime for SSH compatibility)
const discoveryPath = input.disableWorkspaceAgents ? metadata.projectPath : workspacePath;

return { runtime, discoveryPath };
}

// No workspace - use local runtime with project path
const runtime = createRuntime(
{ type: "local", srcBaseDir: context.config.srcDir },
{ projectPath: input.projectPath! }
);
const runtime = createRuntime({ type: "local" }, { projectPath: input.projectPath! });
return { runtime, discoveryPath: input.projectPath! };
}

Expand Down
40 changes: 21 additions & 19 deletions src/node/services/agentSkills/agentSkillsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";

import type { Runtime } from "@/node/runtime/Runtime";
import { SSHRuntime } from "@/node/runtime/SSHRuntime";
import { RemoteRuntime } from "@/node/runtime/RemoteRuntime";
import { shellQuote } from "@/node/runtime/backgroundCommands";
import { execBuffered, readFileString } from "@/node/utils/runtime/helpers";

Expand Down Expand Up @@ -57,30 +57,32 @@ async function listSkillDirectoriesFromLocalFs(root: string): Promise<string[]>
}

async function listSkillDirectoriesFromRuntime(
runtime: Runtime,
root: string,
options: { cwd: string }
runtime: RemoteRuntime,
root: string
): Promise<string[]> {
if (!options.cwd) {
throw new Error("listSkillDirectoriesFromRuntime: options.cwd is required");
}

const quotedRoot = shellQuote(root);
const command =
`if [ -d ${quotedRoot} ]; then ` +
`find ${quotedRoot} -mindepth 1 -maxdepth 1 -type d -exec basename {} \\; ; ` +
`fi`;

const result = await execBuffered(runtime, command, { cwd: options.cwd, timeout: 10 });
if (result.exitCode !== 0) {
log.warn(`Failed to read skills directory ${root}: ${result.stderr || result.stdout}`);
try {
// Use a stable cwd so discovery works even when workspacePath is invalid (e.g., remote runtimes
// where projectPath is a local absolute path).
const result = await execBuffered(runtime, command, { cwd: "/tmp", timeout: 10 });
if (result.exitCode !== 0) {
log.warn(`Failed to read skills directory ${root}: ${result.stderr || result.stdout}`);
return [];
}

return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
} catch (err) {
log.warn(`Failed to read skills directory ${root}: ${formatError(err)}`);
return [];
}

return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}

async function readSkillDescriptorFromDir(
Expand Down Expand Up @@ -173,8 +175,8 @@ export async function discoverAgentSkills(
}

const directoryNames =
runtime instanceof SSHRuntime
? await listSkillDirectoriesFromRuntime(runtime, resolvedRoot, { cwd: workspacePath })
runtime instanceof RemoteRuntime
? await listSkillDirectoriesFromRuntime(runtime, resolvedRoot)
: await listSkillDirectoriesFromLocalFs(resolvedRoot);

for (const directoryNameRaw of directoryNames) {
Expand Down Expand Up @@ -336,7 +338,7 @@ export function resolveAgentSkillFilePath(
throw new Error(`Invalid filePath (must be relative to the skill directory): ${filePath}`);
}

const pathModule = runtime instanceof SSHRuntime ? path.posix : path;
const pathModule = runtime instanceof RemoteRuntime ? path.posix : path;

// Resolve relative to skillDir and ensure it stays within skillDir.
const resolved = pathModule.resolve(skillDir, filePath);
Expand Down
1 change: 1 addition & 0 deletions src/node/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ function shellEscape(str: string): string {
* Build script prelude that sources .mux/tool_env if present.
* Returns empty string if no tool_env path is provided.
*/

function buildToolEnvPrelude(toolEnvPath: string | null): string {
if (!toolEnvPath) return "";
// Source the tool_env file; fail with clear error if sourcing fails
Expand Down
Loading
Loading