Skip to content
Merged
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
38 changes: 38 additions & 0 deletions docs/design/extension-skill-owner-identity.md
Original file line number Diff line number Diff line change
@@ -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`.
16 changes: 16 additions & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 41 additions & 4 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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({
Expand All @@ -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',
}),
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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');

Expand Down
10 changes: 3 additions & 7 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6833,21 +6833,17 @@ 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(
{
...skill,
level: 'extension',
extensionName,
extensionDisplayName: extension.displayName,
},
disablements,
{ disabled: true },
Expand Down
17 changes: 13 additions & 4 deletions packages/cli/src/acp-integration/extension-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});

Expand Down
4 changes: 0 additions & 4 deletions packages/cli/src/acp-integration/extension-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ export function inactiveExtensionSkillRefs(config: Config): Set<string> {
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;
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5416,15 +5416,17 @@ 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',
description: 'Disabled extension skill',
body: 'Hidden instructions',
filePath: '/skills/disabled/SKILL.md',
level: 'extension',
extensionName: 'Disabled Extension',
extensionName: 'disabled-ext',
extensionDisplayName: 'Disabled Extension',
},
]),
});
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/runtime/workspace-skills-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
7 changes: 6 additions & 1 deletion packages/cli/src/runtime/workspace-skills-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
};
}
7 changes: 6 additions & 1 deletion packages/cli/src/services/SkillCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/services/SkillCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading