fix(chat): query expert skills via symlinks and use $ prefix for Codex

Expert skills in the chat session were derived by intersecting built-in
experts with ACP availableCommands, which caused Codex experts to never
appear since Codex does not advertise skills through ACP.

- Add `experts_list_for_agent` backend API that checks symlink status
  across all global skill dirs for the given agent type
- Replace availableCommands-based expert filtering with symlink-based
  query, making the settings page the single source of truth
- Use `$` prefix for Codex expert skills while keeping `/` for slash
  commands and other agents' experts
- Disable the expert button when no experts are linked for the agent
- Invalidate per-agent expert cache after link/unlink in settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-11 00:13:42 +08:00
parent e4eb7f67eb
commit ade59f474c
11 changed files with 187 additions and 29 deletions

View File

@@ -682,6 +682,47 @@ pub async fn experts_list() -> Result<Vec<ExpertListItem>, ExpertsError> {
Ok(out)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_list_for_agent(
agent_type: AgentType,
) -> Result<Vec<ExpertListItem>, ExpertsError> {
let _ = skill_storage_spec(agent_type)
.ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
.map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;
let meta_list = bundled_metadata().to_vec();
let manifest = load_manifest();
let mut out = Vec::new();
for meta in meta_list {
let central_path = expert_central_path(&meta.id);
let is_linked = dirs.iter().any(|dir| {
let candidate = dir.join(&meta.id);
classify_link(&candidate, &central_path) == ExpertLinkState::LinkedToCodeg
});
if !is_linked {
continue;
}
let installed_centrally = central_path.exists();
let user_modified = manifest
.experts
.get(&meta.id)
.map(|e| e.pending_user_review)
.unwrap_or(false);
out.push(ExpertListItem {
metadata: meta,
installed_centrally,
user_modified,
central_path: central_path.to_string_lossy().to_string(),
});
}
Ok(out)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_get_install_status(
expert_id: String,

View File

@@ -376,6 +376,7 @@ mod tauri_app {
acp_commands::acp_save_agent_skill,
acp_commands::acp_delete_agent_skill,
experts_commands::experts_list,
experts_commands::experts_list_for_agent,
experts_commands::experts_get_install_status,
experts_commands::experts_link_to_agent,
experts_commands::experts_unlink_from_agent,

View File

@@ -12,6 +12,12 @@ pub struct ExpertIdParams {
pub expert_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentTypeOnlyParams {
pub agent_type: AgentType,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExpertAgentParams {
@@ -26,6 +32,15 @@ pub async fn experts_list() -> Result<Json<Vec<ExpertListItem>>, AppCommandError
Ok(Json(result))
}
pub async fn experts_list_for_agent(
Json(params): Json<AgentTypeOnlyParams>,
) -> Result<Json<Vec<ExpertListItem>>, AppCommandError> {
let result = experts_commands::experts_list_for_agent(params.agent_type)
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
Ok(Json(result))
}
pub async fn experts_get_install_status(
Json(params): Json<ExpertIdParams>,
) -> Result<Json<Vec<ExpertInstallStatus>>, AppCommandError> {

View File

@@ -177,6 +177,7 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
.route("/acp_delete_agent_skill", post(handlers::acp::acp_delete_agent_skill))
// ─── Experts ───
.route("/experts_list", post(handlers::experts::experts_list))
.route("/experts_list_for_agent", post(handlers::experts::experts_list_for_agent))
.route("/experts_get_install_status", post(handlers::experts::experts_get_install_status))
.route("/experts_link_to_agent", post(handlers::experts::experts_link_to_agent))
.route("/experts_unlink_from_agent", post(handlers::experts::experts_unlink_from_agent))

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl"
import type {
AgentType,
ConnectionStatus,
PromptCapabilitiesInfo,
PromptDraft,
@@ -29,6 +30,7 @@ interface ChatInputProps {
selectedModeId?: string | null
onModeChange?: (modeId: string) => void
onConfigOptionChange?: (configId: string, valueId: string) => void
agentType?: AgentType | null
availableCommands?: AvailableCommandInfo[] | null
attachmentTabId?: string | null
draftStorageKey?: string | null
@@ -62,6 +64,7 @@ export function ChatInput({
selectedModeId,
onModeChange,
onConfigOptionChange,
agentType,
availableCommands,
attachmentTabId,
draftStorageKey,
@@ -113,6 +116,7 @@ export function ChatInput({
selectedModeId={selectedModeId}
onModeChange={onModeChange}
onConfigOptionChange={onConfigOptionChange}
agentType={agentType}
availableCommands={availableCommands}
attachmentTabId={attachmentTabId}
draftStorageKey={draftStorageKey}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react"
import type {
AgentType,
ConnectionStatus,
PromptCapabilitiesInfo,
PromptDraft,
@@ -38,6 +39,7 @@ interface ConversationShellProps {
selectedModeId?: string | null
onModeChange?: (modeId: string) => void
onConfigOptionChange?: (configId: string, valueId: string) => void
agentType?: AgentType | null
availableCommands?: AvailableCommandInfo[] | null
attachmentTabId?: string | null
draftStorageKey?: string | null
@@ -78,6 +80,7 @@ export function ConversationShell({
selectedModeId,
onModeChange,
onConfigOptionChange,
agentType,
availableCommands,
attachmentTabId,
draftStorageKey,
@@ -123,6 +126,7 @@ export function ConversationShell({
selectedModeId={selectedModeId}
onModeChange={onModeChange}
onConfigOptionChange={onConfigOptionChange}
agentType={agentType}
availableCommands={availableCommands}
attachmentTabId={attachmentTabId}
draftStorageKey={draftStorageKey}

View File

@@ -41,6 +41,7 @@ import { readFileBase64 } from "@/lib/api"
import { openFileDialog } from "@/lib/platform"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type {
AgentType,
AvailableCommandInfo,
ExpertListItem,
PromptCapabilitiesInfo,
@@ -65,6 +66,7 @@ import { FileMentionMenu } from "@/components/chat/file-mention-menu"
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
import { useFileTree } from "@/hooks/use-file-tree"
import { useBuiltInExperts } from "@/hooks/use-built-in-experts"
import { useAgentExperts } from "@/hooks/use-agent-experts"
import { joinFsPath } from "@/lib/path-utils"
import {
clearMessageInputDraft,
@@ -89,6 +91,7 @@ interface MessageInputProps {
selectedModeId?: string | null
onModeChange?: (modeId: string) => void
onConfigOptionChange?: (configId: string, valueId: string) => void
agentType?: AgentType | null
availableCommands?: AvailableCommandInfo[] | null
promptCapabilities: PromptCapabilitiesInfo
attachmentTabId?: string | null
@@ -294,6 +297,7 @@ export function MessageInput({
selectedModeId,
onModeChange,
onConfigOptionChange,
agentType,
availableCommands,
promptCapabilities,
attachmentTabId,
@@ -315,19 +319,10 @@ export function MessageInput({
() => new Set(builtInExperts.map((item) => item.metadata.id)),
[builtInExperts]
)
// Derive the list of experts this specific agent session actually knows
// about. The backend advertises every enabled expert via its skill
// directory, so any expert whose id appears in `availableCommands` is
// guaranteed to be linked for the current agent. Using this intersection
// keeps the experts button in lockstep with what the agent will accept
// — an expert disabled in settings simply never reaches this dropdown.
const availableExperts = useMemo(() => {
if (!availableCommands || availableCommands.length === 0) return []
const agentCommandNames = new Set(availableCommands.map((cmd) => cmd.name))
return builtInExperts.filter((item) =>
agentCommandNames.has(item.metadata.id)
)
}, [availableCommands, builtInExperts])
// Experts linked to the current agent via symlinks in the settings page.
// This is the single source of truth — no dependency on ACP availableCommands.
const availableExperts = useAgentExperts(agentType ?? null)
const expertPrefix = agentType === "codex" ? "$" : "/"
// Stable presentation order for expert categories in the button
// dropdown. Keep this in sync with experts-settings.tsx so both surfaces
// group experts the same way.
@@ -519,6 +514,15 @@ export function MessageInput({
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
[availableCommands, expertIdSet]
)
// For Codex the menu triggers on both "/" (commands) and "$" (experts).
const menuTriggerRegex = useMemo(
() => (agentType === "codex" ? /^[/$](\S*)$/ : /^\/(\S*)$/),
[agentType]
)
const expertPrefixRegex = useMemo(
() => (agentType === "codex" ? /^\$(\S*)$/ : /^\/(\S*)$/),
[agentType]
)
const filteredSlashCommands = useMemo(() => {
if (!slashMenuOpen || slashCommands.length === 0) return []
const match = text.match(/^\/(\S*)$/)
@@ -530,13 +534,13 @@ export function MessageInput({
}, [slashMenuOpen, slashCommands, text])
const filteredSlashExperts = useMemo(() => {
if (!slashMenuOpen || availableExperts.length === 0) return []
const match = text.match(/^\/(\S*)$/)
const match = text.match(expertPrefixRegex)
if (!match) return []
const filter = match[1].toLowerCase()
return availableExperts.filter((item) =>
item.metadata.id.toLowerCase().startsWith(filter)
)
}, [slashMenuOpen, availableExperts, text])
}, [slashMenuOpen, availableExperts, text, expertPrefixRegex])
const slashAutocompleteCount =
filteredSlashCommands.length + filteredSlashExperts.length
@@ -916,24 +920,27 @@ export function MessageInput({
const handleExpertAutocompleteSelect = useCallback(
(expert: ExpertListItem) => {
setText(`/${expert.metadata.id} `)
setText(`${expertPrefix}${expert.metadata.id} `)
setSlashMenuOpen(false)
},
[]
[expertPrefix]
)
// Experts always inject `/expert-id ` at the very front of the input,
// never at the cursor. The expert skill is a whole-turn directive that
// the agent inspects first, so prepending keeps semantics unambiguous
// Experts always inject `prefix + expert-id ` at the very front of the
// input, never at the cursor. The expert skill is a whole-turn directive
// that the agent inspects first, so prepending keeps semantics unambiguous
// regardless of what the user has already typed. If another expert prefix
// is already at the front (from a prior click), replace it instead of
// stacking — the agent only honors the first slash command, so a stacked
// prefix would silently drop the earlier choice.
// stacking — the agent only honors the first command, so a stacked prefix
// would silently drop the earlier choice.
const handleExpertPopoverSelect = useCallback(
(expert: ExpertListItem) => {
const current = textRef.current
const insertion = `/${expert.metadata.id} `
const existingPrefix = current.match(/^\/([A-Za-z0-9_-]+)\s/)
const insertion = `${expertPrefix}${expert.metadata.id} `
const escapedPrefix = expertPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const existingPrefix = current.match(
new RegExp(`^${escapedPrefix}([A-Za-z0-9_-]+)\\s`)
)
let base = current
if (existingPrefix && expertIdSet.has(existingPrefix[1])) {
base = current.slice(existingPrefix[0].length)
@@ -951,7 +958,7 @@ export function MessageInput({
}
})
},
[expertIdSet]
[expertIdSet, expertPrefix]
)
const atTriggerPosRef = useRef(atTriggerPos)
@@ -994,7 +1001,7 @@ export function MessageInput({
// the menu whenever at least one of them is available.
const hasSlashSource =
slashCommands.length > 0 || availableExperts.length > 0
if (hasSlashSource && /^\/(\S*)$/.test(value)) {
if (hasSlashSource && menuTriggerRegex.test(value)) {
setSlashSelectedIndex(0)
setSlashMenuOpen(true)
setAtMenuOpen(false)
@@ -1020,7 +1027,12 @@ export function MessageInput({
}
setAtMenuOpen(false)
},
[slashCommands.length, availableExperts.length, defaultPath]
[
slashCommands.length,
availableExperts.length,
defaultPath,
menuTriggerRegex,
]
)
const handlePickFiles = useCallback(async () => {
@@ -1626,7 +1638,8 @@ export function MessageInput({
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
/{expert.metadata.id}
{expertPrefix}
{expert.metadata.id}
</span>
</div>
{description && (
@@ -1744,7 +1757,7 @@ export function MessageInput({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disabled}
disabled={disabled || availableExperts.length === 0}
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 bg-transparent"

View File

@@ -895,6 +895,7 @@ const ConversationTabView = memo(function ConversationTabView({
selectedModeId={selectedModeId}
onModeChange={handleModeChange}
onConfigOptionChange={handleSetConfigOption}
agentType={selectedAgent}
availableCommands={connectionCommands}
attachmentTabId={tabId}
draftStorageKey={draftStorageKey}
@@ -966,6 +967,7 @@ const ConversationTabView = memo(function ConversationTabView({
selectedModeId={selectedModeId}
onModeChange={handleModeChange}
onConfigOptionChange={handleSetConfigOption}
agentType={selectedAgent}
availableCommands={connectionCommands}
attachmentTabId={tabId}
draftStorageKey={draftStorageKey}

View File

@@ -46,6 +46,7 @@ import {
expertsUnlinkFromAgent,
openFolderWindow,
} from "@/lib/api"
import { invalidateAgentExpertsCache } from "@/hooks/use-agent-experts"
import type {
AcpAgentInfo,
AgentType,
@@ -336,6 +337,7 @@ export function ExpertsSettings() {
if (enable) {
const next = await expertsLinkToAgent({ expertId, agentType })
setStatuses((prev) => ({ ...prev, [agentType]: next }))
invalidateAgentExpertsCache(agentType)
toast.success(t("toasts.enabled"))
} else {
await expertsUnlinkFromAgent({ expertId, agentType })
@@ -346,6 +348,7 @@ export function ExpertsSettings() {
map[entry.agentType] = entry
}
setStatuses(map)
invalidateAgentExpertsCache(agentType)
toast.success(t("toasts.disabled"))
}
} catch (err) {

View File

@@ -0,0 +1,68 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { expertsListForAgent } from "@/lib/api"
import type { AgentType, ExpertListItem } from "@/lib/types"
const agentCache = new Map<AgentType, ExpertListItem[]>()
const inflightMap = new Map<AgentType, Promise<ExpertListItem[]>>()
const EMPTY: ExpertListItem[] = []
export function useAgentExperts(agentType: AgentType | null): ExpertListItem[] {
const cached = useMemo(
() => (agentType ? (agentCache.get(agentType) ?? null) : null),
[agentType]
)
// Track which agent type the fetched result belongs to so stale data
// from a previous agent is never returned after a switch.
const [fetched, setFetched] = useState<{
agentType: AgentType
experts: ExpertListItem[]
} | null>(null)
useEffect(() => {
if (!agentType || agentCache.has(agentType)) return
let cancelled = false
let promise = inflightMap.get(agentType)
if (!promise) {
promise = expertsListForAgent(agentType)
.then((list) => {
agentCache.set(agentType, list)
inflightMap.delete(agentType)
return list
})
.catch((err) => {
inflightMap.delete(agentType)
console.warn("[useAgentExperts] failed:", err)
return EMPTY
})
inflightMap.set(agentType, promise)
}
promise.then((list) => {
if (!cancelled) setFetched({ agentType, experts: list })
})
return () => {
cancelled = true
}
}, [agentType])
if (!agentType) return EMPTY
if (cached) return cached
if (fetched && fetched.agentType === agentType) return fetched.experts
return EMPTY
}
export function invalidateAgentExpertsCache(agentType?: AgentType) {
if (agentType) {
agentCache.delete(agentType)
inflightMap.delete(agentType)
} else {
agentCache.clear()
inflightMap.clear()
}
}

View File

@@ -340,6 +340,12 @@ export async function expertsList(): Promise<ExpertListItem[]> {
return getTransport().call("experts_list")
}
export async function expertsListForAgent(
agentType: AgentType
): Promise<ExpertListItem[]> {
return getTransport().call("experts_list_for_agent", { agentType })
}
export async function expertsGetInstallStatus(
expertId: string
): Promise<ExpertInstallStatus[]> {