From 18a16274212027dd4f14b7435afeac992942081b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Thu, 27 Aug 2026 21:02:53 +0800 Subject: [PATCH] fix(skills): separate extension identity from display name --- docs/design/extension-skill-owner-identity.md | 38 ++++++++++++++++ docs/developers/qwen-serve-protocol.md | 16 +++++++ packages/acp-bridge/src/status.ts | 3 ++ .../cli/src/acp-integration/acpAgent.test.ts | 45 +++++++++++++++++-- packages/cli/src/acp-integration/acpAgent.ts | 10 ++--- .../acp-integration/extension-skills.test.ts | 17 +++++-- .../src/acp-integration/extension-skills.ts | 4 -- .../acp-integration/session/Session.test.ts | 6 ++- .../runtime/workspace-skills-mapping.test.ts | 21 ++++++--- .../src/runtime/workspace-skills-mapping.ts | 7 ++- .../src/services/SkillCommandLoader.test.ts | 7 ++- .../cli/src/services/SkillCommandLoader.ts | 2 +- .../core/src/skills/skill-manager.test.ts | 37 +++++++++++++++ packages/core/src/skills/skill-manager.ts | 3 +- packages/core/src/skills/types.ts | 8 +++- packages/sdk-typescript/src/daemon/types.ts | 3 ++ .../components/skills/SkillsManagerPage.tsx | 3 +- .../skills/skills-manager-logic.test.ts | 18 +++++++- .../components/skills/skills-manager-logic.ts | 4 ++ 19 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 docs/design/extension-skill-owner-identity.md diff --git a/docs/design/extension-skill-owner-identity.md b/docs/design/extension-skill-owner-identity.md new file mode 100644 index 00000000000..ede6275bf5b --- /dev/null +++ b/docs/design/extension-skill-owner-identity.md @@ -0,0 +1,38 @@ +# Extension Skill Owner Identity + +## Problem + +`GET /workspace/skills` exposes the owner of an extension-level Skill through +`extensionName`. Extension discovery currently stores the localized Extension +display name in that field. Display names can change with locale and are not +unique, so clients cannot use the response to join a Skill to its Extension. + +## Contract + +- `extensionName` is the canonical `name` from the loaded Extension manifest. +- `extensionDisplayName` is the optional, locale-resolved display name. +- Identity matching, inactive-state checks, and snapshot deduplication use only + `extensionName`. +- Presentation uses `extensionDisplayName ?? extensionName`. + +Both fields remain optional because non-Extension Skills do not have an owner +and older daemons do not expose `extensionDisplayName`. The status schema stays +at version 1 because the new field is additive. + +## Data flow + +The Skill manager records both values when it enumerates active Extensions. +The ACP workspace snapshot applies the same rule when it synthesizes Skills +from inactive Extensions. The shared workspace-skills mapper then projects the +two values into the ACP Bridge and TypeScript SDK status types. + +CLI and Web Shell presentation read the display field with a canonical-name +fallback. MCP and Agent ownership metadata are separate contracts and are not +changed here. + +## Compatibility + +Existing third-party clients continue to receive `extensionName`, but its value +is corrected from display text to the manifest identity. Clients that need a +friendly label can adopt `extensionDisplayName`; new clients remain compatible +with older daemons by falling back to `extensionName`. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 4147504603f..b94eb0b785f 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1386,6 +1386,17 @@ Recommended poll cadence: aligned with whatever already polls `/workspace/mcp`; "userInvocable": false, "installedPath": "/home/alice/project/.qwen/skills/review/SKILL.md", "argumentHint": "[path]" + }, + { + "kind": "skill", + "status": "ok", + "name": "database-review", + "description": "Review database changes", + "level": "extension", + "modelInvocable": true, + "installedPath": "/home/alice/.qwen/extensions/alibabacloud-database-suite/skills/database-review/SKILL.md", + "extensionName": "alibabacloud-database-suite", + "extensionDisplayName": "Alibaba Cloud Database Suite" } ] } @@ -1403,6 +1414,11 @@ tolerate its absence from older v1 daemons. Skill bodies, hooks, `skillRoot`, and other skill configuration remain excluded. `errors` is omitted when discovery succeeds. +For extension-owned skills, `extensionName` is the canonical manifest name and +is safe to use as the owner identity. `extensionDisplayName` is an optional, +localized presentation value and may be non-unique. New clients should display +`extensionDisplayName ?? extensionName`; older daemons omit the display field. + Repeated reads are served from the last committed workspace snapshot, periodically revalidated against the child's in-memory cache. A read never scans skill directories or reparses `SKILL.md` files. The child does verify diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index d4eee47dd17..c4e1d8960fa 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -480,7 +480,10 @@ export interface ServeWorkspaceSkillStatus extends ServeStatusCell { installedPath?: string; argumentHint?: string; model?: string; + /** Canonical name of the extension that provides this skill. */ extensionName?: string; + /** Localized presentation name; never use as an extension identity. */ + extensionDisplayName?: string; } export interface ServeWorkspaceSkillsRefreshResult { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c498e778969..c018858a254 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -6298,6 +6298,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { description: 'Cross-phase audit', level: 'extension', extensionName: 'gsd-core', + extensionDisplayName: 'GSD Core', disableModelInvocation: false, body: 'extension secret body', filePath: '/ext/gsd-core/skills/gsd-audit-uat/SKILL.md', @@ -6306,7 +6307,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { name: 'gsd-display-stale', description: 'Display-name stale extension skill', level: 'extension', - extensionName: 'GSD Core', + extensionName: 'gsd-core', + extensionDisplayName: 'GSD Core', disableModelInvocation: false, body: 'display stale body', filePath: '/ext/gsd-core/skills/gsd-display-stale/SKILL.md', @@ -6396,6 +6398,26 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], }, + { + id: 'gsd-tools', + name: 'gsd-tools', + displayName: 'GSD Core', + version: '1.0.0', + isActive: false, + path: '/ext/gsd-tools', + config: { name: 'gsd-tools', version: '1.0.0' }, + contextFiles: [], + skills: [ + { + name: 'gsd-config-only', + description: 'Colliding config-only extension skill', + level: 'extension', + disableModelInvocation: false, + body: 'colliding config only body', + filePath: '/ext/gsd-tools/skills/gsd-config-only/SKILL.md', + }, + ], + }, ]), getAuthType: vi.fn().mockReturnValue('qwen'), getAllConfiguredModels: vi.fn().mockReturnValue([ @@ -6602,6 +6624,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { description: 'Cross-phase audit', level: 'extension', extensionName: 'gsd-core', + extensionDisplayName: 'GSD Core', modelInvocable: true, }), expect.objectContaining({ @@ -6610,7 +6633,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { name: 'gsd-display-stale', description: 'Display-name stale extension skill', level: 'extension', - extensionName: 'GSD Core', + extensionName: 'gsd-core', + extensionDisplayName: 'GSD Core', modelInvocable: true, installedPath: '/ext/gsd-core/skills/gsd-display-stale/SKILL.md', }), @@ -6620,10 +6644,22 @@ describe('QwenAgent MCP SSE/HTTP support', () => { name: 'gsd-config-only', description: 'Config-only extension skill', level: 'extension', - extensionName: 'GSD Core', + extensionName: 'gsd-core', + extensionDisplayName: 'GSD Core', modelInvocable: true, installedPath: '/ext/gsd-core/skills/gsd-config-only/SKILL.md', }), + expect.objectContaining({ + kind: 'skill', + status: 'disabled', + name: 'gsd-config-only', + description: 'Colliding config-only extension skill', + level: 'extension', + extensionName: 'gsd-tools', + extensionDisplayName: 'GSD Core', + modelInvocable: true, + installedPath: '/ext/gsd-tools/skills/gsd-config-only/SKILL.md', + }), ]), }); expect( @@ -6634,7 +6670,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ).toHaveLength(1); expect( skills.skills.filter((skill) => skill.name === 'gsd-config-only'), - ).toHaveLength(1); + ).toHaveLength(2); expect(skillsAgain).toEqual(skills); expect(getCachedSkills).toHaveBeenCalledTimes(2); // Each read validates that the extension sources have not moved, but an @@ -6648,6 +6684,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(JSON.stringify(skills)).not.toContain('extension secret body'); expect(JSON.stringify(skills)).not.toContain('display stale body'); expect(JSON.stringify(skills)).not.toContain('config only body'); + expect(JSON.stringify(skills)).not.toContain('colliding config only body'); expect(JSON.stringify(skills)).not.toContain('"skillRoot"'); expect(JSON.stringify(skills)).not.toContain('secret-hook'); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9513c606002..57a8b613dc6 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6833,14 +6833,9 @@ class QwenAgent implements Agent { for (const extension of config.getExtensions()) { if (extension.isActive) continue; for (const skill of extension.skills ?? []) { - const extensionName = extension.displayName ?? extension.name; + const extensionName = extension.name; const key = `extension:${extensionName}:${skill.name}`; - if ( - skillsByKey.has(`extension:${extension.name}:${skill.name}`) || - skillsByKey.has(key) - ) { - continue; - } + if (skillsByKey.has(key)) continue; skillsByKey.set( key, mapSkillConfigToStatus( @@ -6848,6 +6843,7 @@ class QwenAgent implements Agent { ...skill, level: 'extension', extensionName, + extensionDisplayName: extension.displayName, }, disablements, { disabled: true }, diff --git a/packages/cli/src/acp-integration/extension-skills.test.ts b/packages/cli/src/acp-integration/extension-skills.test.ts index 184d4018ba6..c6d2d97eec0 100644 --- a/packages/cli/src/acp-integration/extension-skills.test.ts +++ b/packages/cli/src/acp-integration/extension-skills.test.ts @@ -56,23 +56,32 @@ function extensionSkill(name: string): SkillConfig { } describe('extension skill activity helpers', () => { - it('matches skills from inactive extensions by name and displayName', () => { + it('matches skills from inactive extensions only by canonical name', () => { const refs = inactiveExtensionSkillRefs( configWithExtensions([ extension({ name: 'canonical-ext', - displayName: 'Display Ext', + displayName: 'Shared Display', isActive: false, skills: [extensionSkill('audit')], }), + extension({ + name: 'active-ext', + displayName: 'Shared Display', + isActive: true, + skills: [extensionSkill('audit')], + }), ]), ); expect( isInactiveExtensionSkill(skill('audit', 'canonical-ext'), refs), ).toBe(true); - expect(isInactiveExtensionSkill(skill('audit', 'Display Ext'), refs)).toBe( - true, + expect( + isInactiveExtensionSkill(skill('audit', 'Shared Display'), refs), + ).toBe(false); + expect(isInactiveExtensionSkill(skill('audit', 'active-ext'), refs)).toBe( + false, ); }); diff --git a/packages/cli/src/acp-integration/extension-skills.ts b/packages/cli/src/acp-integration/extension-skills.ts index 901316f0253..a7afd981344 100644 --- a/packages/cli/src/acp-integration/extension-skills.ts +++ b/packages/cli/src/acp-integration/extension-skills.ts @@ -16,10 +16,6 @@ export function inactiveExtensionSkillRefs(config: Config): Set { if (extension.isActive) continue; for (const skill of extension.skills ?? []) { refs.add(extensionSkillRef(extension.name, skill.name)); - // SkillManager exposes extensionName as displayName ?? name. - if (extension.displayName) { - refs.add(extensionSkillRef(extension.displayName, skill.name)); - } } } return refs; diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 4c72337caad..016eca126af 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5416,7 +5416,8 @@ describe('Session', () => { body: 'Visible collision instructions', filePath: '/skills/collision/SKILL.md', level: 'extension', - extensionName: 'Disabled Extension', + extensionName: 'active-ext', + extensionDisplayName: 'Disabled Extension', }, { name: 'disabled-extension-skill', @@ -5424,7 +5425,8 @@ describe('Session', () => { body: 'Hidden instructions', filePath: '/skills/disabled/SKILL.md', level: 'extension', - extensionName: 'Disabled Extension', + extensionName: 'disabled-ext', + extensionDisplayName: 'Disabled Extension', }, ]), }); diff --git a/packages/cli/src/runtime/workspace-skills-mapping.test.ts b/packages/cli/src/runtime/workspace-skills-mapping.test.ts index cd936cb2b38..1883cf02a02 100644 --- a/packages/cli/src/runtime/workspace-skills-mapping.test.ts +++ b/packages/cli/src/runtime/workspace-skills-mapping.test.ts @@ -82,16 +82,27 @@ describe('mapSkillConfigToStatus', () => { expect(status).not.toHaveProperty('lockedScope'); }); - it('surfaces optional model and extensionName only when present', () => { - expect(mapSkillConfigToStatus(makeSkill())).not.toHaveProperty('model'); - expect(mapSkillConfigToStatus(makeSkill())).not.toHaveProperty( - 'extensionName', + it('surfaces optional model and extension identity only when present', () => { + const nonExtension = mapSkillConfigToStatus( + makeSkill({ + extensionName: 'ignored', + extensionDisplayName: 'Ignored', + }), ); + expect(nonExtension).not.toHaveProperty('model'); + expect(nonExtension).not.toHaveProperty('extensionName'); + expect(nonExtension).not.toHaveProperty('extensionDisplayName'); const status = mapSkillConfigToStatus( - makeSkill({ model: 'gpt-4o', extensionName: 'acme' }), + makeSkill({ + level: 'extension', + model: 'gpt-4o', + extensionName: 'acme', + extensionDisplayName: 'Acme Tools', + }), ); expect(status.model).toBe('gpt-4o'); expect(status.extensionName).toBe('acme'); + expect(status.extensionDisplayName).toBe('Acme Tools'); }); }); diff --git a/packages/cli/src/runtime/workspace-skills-mapping.ts b/packages/cli/src/runtime/workspace-skills-mapping.ts index fc3dd0cca11..bb63330781d 100644 --- a/packages/cli/src/runtime/workspace-skills-mapping.ts +++ b/packages/cli/src/runtime/workspace-skills-mapping.ts @@ -40,6 +40,11 @@ export function mapSkillConfigToStatus( installedPath: skill.filePath, ...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}), ...(skill.model ? { model: skill.model } : {}), - ...(skill.extensionName ? { extensionName: skill.extensionName } : {}), + ...(skill.level === 'extension' && skill.extensionName + ? { extensionName: skill.extensionName } + : {}), + ...(skill.level === 'extension' && skill.extensionDisplayName + ? { extensionDisplayName: skill.extensionDisplayName } + : {}), }; } diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts index d9c200ccfa1..f803c27ab7e 100644 --- a/packages/cli/src/services/SkillCommandLoader.test.ts +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -330,6 +330,7 @@ describe('SkillCommandLoader', () => { const skill = makeSkill({ level: 'extension', extensionName: 'superpowers-lab', + extensionDisplayName: 'Superpowers Lab', description: 'Use tmux for interactive commands', }); mockSkillManager.listSkills.mockImplementation( @@ -342,8 +343,11 @@ describe('SkillCommandLoader', () => { expect(commands[0].modelInvocable).toBe(true); expect(commands[0].source).toBe('plugin-command'); - expect(commands[0].sourceLabel).toBe('Extension: superpowers-lab'); + expect(commands[0].sourceLabel).toBe('Extension: Superpowers Lab'); expect(commands[0].sourceDetail).toBe('extension'); + expect(commands[0].skillDetail).toMatchObject({ + extensionName: 'superpowers-lab', + }); }); it('should be modelInvocable when whenToUse is present', async () => { @@ -362,6 +366,7 @@ describe('SkillCommandLoader', () => { const commands = await loader.loadCommands(signal); expect(commands[0].modelInvocable).toBe(true); + expect(commands[0].sourceLabel).toBe('Extension: superpowers-lab'); }); it('should NOT be modelInvocable when description and whenToUse are absent', async () => { diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts index fd0b594b08b..90f9a645a78 100644 --- a/packages/cli/src/services/SkillCommandLoader.ts +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -117,7 +117,7 @@ export class SkillCommandLoader implements ICommandLoader { : true; const sourceLabel = isExtension - ? `${t('Extension:')} ${skill.extensionName ?? 'unknown'}` + ? `${t('Extension:')} ${skill.extensionDisplayName ?? skill.extensionName ?? 'unknown'}` : skill.level === 'project' ? t('Project') : t('User'); diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index d267cafe32d..164727bf2a1 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -880,6 +880,43 @@ Body`); expect(badSkill?.priority).toBe(0); }); + it('uses the canonical extension name for extension-owned skills', async () => { + vi.spyOn(mockConfig, 'getActiveExtensions').mockReturnValue([ + { + id: 'database-suite', + name: 'alibabacloud-database-suite', + displayName: 'Alibaba Cloud Database Suite', + version: '1.0.0', + isActive: true, + path: '/extension', + config: { + name: 'alibabacloud-database-suite', + version: '1.0.0', + }, + contextFiles: [], + skills: [ + { + name: 'database-review', + description: 'Review database changes', + body: 'Body', + filePath: '/extension/skills/database-review/SKILL.md', + level: 'extension', + }, + ], + }, + ]); + + const skills = await manager.listSkills({ + level: 'extension', + force: true, + }); + + expect(skills[0]?.extensionName).toBe('alibabacloud-database-suite'); + expect(skills[0]?.extensionDisplayName).toBe( + 'Alibaba Cloud Database Suite', + ); + }); + it('should deduplicate same-name skills across provider dirs within a level', async () => { // Override readdir to return the same skill name from both .qwen and .agents dirs vi.mocked(fs.readdir).mockReset(); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index f44ffc554e1..b610cd91dd8 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -1003,7 +1003,8 @@ export class SkillManager { } skills.push({ ...skill, - extensionName: extension.displayName ?? extension.name, + extensionName: extension.name, + extensionDisplayName: extension.displayName, // Normalize so downstream consumers reading `skill.priority` // (e.g. the `/skills` display sort) observe the same value // reflected by the warning above. diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index ebaa9b44b01..8cad8e7a29c 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -89,10 +89,16 @@ export interface SkillConfig { body: string; /** - * For extension-level skills: the name of the providing extension + * For extension-level skills: the canonical name of the providing extension */ extensionName?: string; + /** + * For extension-level skills: the localized display name of the providing + * extension. Presentation only; never use this field as an identity. + */ + extensionDisplayName?: string; + /** * Argument hint shown after the slash command name in completion menus. * Parsed from the `argument-hint` frontmatter field in SKILL.md. diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 551915637a3..23075ec0113 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1883,7 +1883,10 @@ export interface DaemonWorkspaceSkillStatus extends DaemonStatusCell { installedPath?: string; argumentHint?: string; model?: string; + /** Canonical name of the extension that provides this skill. */ extensionName?: string; + /** Localized presentation name; never use as an extension identity. */ + extensionDisplayName?: string; } export interface DaemonWorkspaceSkillsStatus { diff --git a/packages/web-shell/client/components/skills/SkillsManagerPage.tsx b/packages/web-shell/client/components/skills/SkillsManagerPage.tsx index 644381b442f..28595ba98b7 100644 --- a/packages/web-shell/client/components/skills/SkillsManagerPage.tsx +++ b/packages/web-shell/client/components/skills/SkillsManagerPage.tsx @@ -18,6 +18,7 @@ import { useI18n } from '../../i18n'; import { filterSkills, preserveSkillSelection, + skillExtensionLabel, type SkillLevelFilter, type SkillStatusFilter, } from './skills-manager-logic'; @@ -517,7 +518,7 @@ export function SkillsManagerPage({ /> {selectedSkill.hint ? (
diff --git a/packages/web-shell/client/components/skills/skills-manager-logic.test.ts b/packages/web-shell/client/components/skills/skills-manager-logic.test.ts index 8caca361283..f3111ffc48c 100644 --- a/packages/web-shell/client/components/skills/skills-manager-logic.test.ts +++ b/packages/web-shell/client/components/skills/skills-manager-logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-sdk'; -import { filterSkills, preserveSkillSelection } from './skills-manager-logic'; +import { + filterSkills, + preserveSkillSelection, + skillExtensionLabel, +} from './skills-manager-logic'; const skills: DaemonWorkspaceSkillStatus[] = [ { @@ -11,6 +15,7 @@ const skills: DaemonWorkspaceSkillStatus[] = [ level: 'extension', modelInvocable: true, extensionName: 'design-pack', + extensionDisplayName: 'Design Pack', }, { kind: 'skill', @@ -55,4 +60,15 @@ describe('skills manager logic', () => { expect(preserveSkillSelection('review', skills)).toBe('review'); expect(preserveSkillSelection('removed', skills)).toBeNull(); }); + + it('uses the extension display name only for presentation', () => { + expect(skillExtensionLabel(skills[0])).toBe('Design Pack'); + expect( + skillExtensionLabel({ + ...skills[0], + extensionDisplayName: undefined, + }), + ).toBe('design-pack'); + expect(skillExtensionLabel(skills[1])).toBe('-'); + }); }); diff --git a/packages/web-shell/client/components/skills/skills-manager-logic.ts b/packages/web-shell/client/components/skills/skills-manager-logic.ts index ee63bcb17e2..8b020a22578 100644 --- a/packages/web-shell/client/components/skills/skills-manager-logic.ts +++ b/packages/web-shell/client/components/skills/skills-manager-logic.ts @@ -3,6 +3,10 @@ import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-s export type SkillLevelFilter = 'all' | DaemonWorkspaceSkillStatus['level']; export type SkillStatusFilter = 'all' | 'enabled' | 'disabled'; +export function skillExtensionLabel(skill: DaemonWorkspaceSkillStatus): string { + return skill.extensionDisplayName ?? skill.extensionName ?? '-'; +} + export function filterSkills( skills: readonly DaemonWorkspaceSkillStatus[], query: string,