diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index be8cebf4e0..ae8b6fe993 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -123,6 +123,10 @@ export default function FileAuthoringCall({ }) { const localize = useLocalize(); const isCreate = toolName === 'create_file'; + /** `create_file` can overwrite an existing file (sandbox `overwrite: true`, + * or skill SKILL.md updates). The host-authored summary always opens with + * `Created`/`Updated`, so key the finished label off it for truthfulness. */ + const overwrote = isCreate && output.startsWith('Updated '); const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]); const authoredContent = useMemo(() => parseJsonField(args, 'content'), [args]); const editArgsPreview = useMemo(() => buildEditArgsPreview(args), [args]); @@ -145,7 +149,12 @@ export default function FileAuthoringCall({ useToolCallState(initialProgress, isSubmitting, output, !!filePath || !!preview, onExpand); const highlighted = useLazyHighlight(preview || undefined, previewLang); - const Icon = isCreate ? FilePlus2 : FilePenLine; + const Icon = isCreate && !overwrote ? FilePlus2 : FilePenLine; + let finishedKey: 'com_ui_created_file' | 'com_ui_updated_file' | 'com_ui_edited_file' = + 'com_ui_edited_file'; + if (isCreate) { + finishedKey = overwrote ? 'com_ui_updated_file' : 'com_ui_created_file'; + } return ( <> @@ -157,9 +166,7 @@ export default function FileAuthoringCall({ 0: fileName, })} finishedText={ - cancelled - ? localize('com_ui_cancelled') - : localize(isCreate ? 'com_ui_created_file' : 'com_ui_edited_file', { 0: fileName }) + cancelled ? localize('com_ui_cancelled') : localize(finishedKey, { 0: fileName }) } errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined} icon={ diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx index 5565e94919..a57a00c954 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx @@ -9,6 +9,7 @@ jest.mock('~/hooks', () => ({ const translations: Record = { com_ui_created_file: 'Created {{0}}', com_ui_creating_file: 'Creating {{0}}', + com_ui_updated_file: 'Updated {{0}}', com_ui_edited_file: 'Edited {{0}}', com_ui_editing_file: 'Editing {{0}}', com_ui_cancelled: 'Cancelled', @@ -104,6 +105,23 @@ describe('FileAuthoringCall', () => { expect(screen.getByText('Created skills/demo/SKILL.md (4096 chars).')).toBeInTheDocument(); }); + it('labels a create_file overwrite as Updated when the output summary says so', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Updated SKILL.md'); + }); + it('prefers the output diff over the args preview after edit_file completes', () => { const output = [ 'Edited skills/demo/SKILL.md (exact match).', diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 2ee88a59c8..bb8db1a770 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -1,11 +1,14 @@ import React, { useState, useMemo, useCallback } from 'react'; import { X } from 'lucide-react'; +import { useQueries } from '@tanstack/react-query'; import { Switch, useToastContext } from '@librechat/client'; import { Controller, useWatch, useFormContext } from 'react-hook-form'; import { EModelEndpoint, PermissionTypes, Permissions, + QueryKeys, + dataService, getEndpointField, } from 'librechat-data-provider'; import type { AgentForm, IconComponentTypes } from '~/common'; @@ -18,14 +21,14 @@ import { cn, } from '~/utils'; import { ToolSelectDialog, MCPToolSelectDialog } from '~/components/Tools'; -import { SkillSelectDialog } from '~/components/Skills/dialogs'; import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities'; +import { useListSkillsQuery, useGetAgentFiles } from '~/data-provider'; import { useFileMapContext, useAgentPanelContext } from '~/Providers'; +import { useLocalize, useVisibleTools, useHasAccess } from '~/hooks'; +import { SkillSelectDialog } from '~/components/Skills/dialogs'; import AgentCategorySelector from './AgentCategorySelector'; import Action from '~/components/SidePanel/Builder/Action'; -import { useLocalize, useVisibleTools, useHasAccess } from '~/hooks'; import { Panel, isEphemeralAgent } from '~/common'; -import { useListSkillsQuery, useGetAgentFiles } from '~/data-provider'; import { icons } from '~/hooks/Endpoint/Icons'; import Instructions from './Instructions'; import AgentAvatar from './AgentAvatar'; @@ -37,6 +40,14 @@ import AgentTool from './AgentTool'; import CodeForm from './Code/Form'; import MCPTools from './MCPTools'; +/** A skill lookup only counts as a confirmed miss on 404/403 — deleted or no + * longer shared. Transient/network/server errors must not present a valid + * configured skill as removable. */ +const isConfirmedSkillMiss = (error: unknown): boolean => { + const status = (error as { response?: { status?: number } } | null)?.response?.status; + return status === 404 || status === 403; +}; + const labelClass = 'mb-2 text-token-text-primary block text-sm font-medium'; const inputClass = cn( defaultTextProps, @@ -111,6 +122,36 @@ export default function AgentConfig() { return map; }, [skillsData?.skills]); + /** Allowlist ids missing from the first catalog page (`limit: 100`) are + * resolved individually — a cache miss alone must never present a valid + * configured skill as unavailable and invite its removal. */ + const unresolvedSkillIds = useMemo( + () => (skillsData === undefined ? [] : (skills ?? []).filter((id) => !skillsMap.has(id))), + [skills, skillsMap, skillsData], + ); + const unresolvedSkillQueries = useQueries({ + queries: unresolvedSkillIds.map((skillId) => ({ + queryKey: [QueryKeys.skill, skillId], + queryFn: () => dataService.getSkill(skillId), + retry: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + })), + }); + const unresolvedSkills = useMemo(() => { + const map = new Map(); + unresolvedSkillIds.forEach((skillId, index) => { + const query = unresolvedSkillQueries[index]; + if (query?.isError === true && isConfirmedSkillMiss(query.error)) { + map.set(skillId, { missing: true }); + } else if (query?.data?.name) { + map.set(skillId, { name: query.data.name, missing: false }); + } + }); + return map; + }, [unresolvedSkillIds, unresolvedSkillQueries]); + const { data: agentFiles = [] } = useGetAgentFiles(agent_id); const mergedFileMap = useMemo(() => { @@ -380,16 +421,31 @@ export default function AgentConfig() { >
{(skills ?? []).map((skillId) => { - const skillName = skillsMap.get(skillId); - if (!skillName) { + const skillName = skillsMap.get(skillId) ?? unresolvedSkills.get(skillId)?.name; + /** Hide chips while the catalog page or per-id lookup is in + * flight. Once the backend confirms a miss (deleted or no + * longer shared), the id must stay visible and removable — + * otherwise the allowlist silently scopes the agent to + * zero skills with no way to fix it in the UI. */ + if (!skillName && unresolvedSkills.get(skillId)?.missing !== true) { return null; } + const isUnavailable = !skillName; return (
- {skillName} + + {skillName ?? localize('com_ui_skill_unavailable')} +