diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index b9339a0bbc..9de54cd526 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -650,6 +650,7 @@ import { SERVE_STATUS_EXT_METHODS, SERVE_CONTROL_EXT_METHODS, } from '@qwen-code/acp-bridge/status'; +import type { ServeWorkspaceSkillsStatus } from '@qwen-code/acp-bridge/status'; import { updateOutputLanguageFile, writeOutputLanguageAndRegisterPath, @@ -1864,10 +1865,31 @@ describe('QwenAgent MCP SSE/HTTP support', () => { body: 'disabled secret body', filePath: '/disabled/SKILL.md', }, + { + name: 'gsd-audit-uat', + description: 'Cross-phase audit', + level: 'extension', + extensionName: 'gsd-core', + disableModelInvocation: false, + body: 'extension secret body', + filePath: '/ext/gsd-core/skills/gsd-audit-uat/SKILL.md', + }, + { + name: 'gsd-display-stale', + description: 'Display-name stale extension skill', + level: 'extension', + extensionName: 'GSD Core', + disableModelInvocation: false, + body: 'display stale body', + filePath: '/ext/gsd-core/skills/gsd-display-stale/SKILL.md', + }, ]); mockConfig = { ...mockConfig, getTargetDir: vi.fn().mockReturnValue('/work/status'), + getExtensionManager: vi.fn().mockReturnValue({ + refreshCache: vi.fn().mockResolvedValue(undefined), + }), getMcpServers: vi.fn().mockReturnValue({ docs: { command: 'node', @@ -1896,7 +1918,48 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getDisabledSkillNames: vi .fn() .mockReturnValue(new Set(['disabled-skill'])), - getSkillManager: vi.fn().mockReturnValue({ listSkills }), + getSkillManager: vi.fn().mockReturnValue({ + refreshCache: vi.fn().mockResolvedValue(undefined), + listSkills, + }), + getExtensions: vi.fn().mockReturnValue([ + { + id: 'gsd-core', + name: 'gsd-core', + displayName: 'GSD Core', + version: '1.0.0', + isActive: false, + path: '/ext/gsd-core', + config: { name: 'gsd-core', version: '1.0.0' }, + contextFiles: [], + skills: [ + { + name: 'gsd-audit-uat', + description: 'Cross-phase audit', + level: 'extension', + disableModelInvocation: false, + body: 'extension secret body', + filePath: '/ext/gsd-core/skills/gsd-audit-uat/SKILL.md', + }, + { + name: 'gsd-display-stale', + description: 'Display-name stale extension skill', + level: 'extension', + disableModelInvocation: false, + body: 'display stale body', + filePath: '/ext/gsd-core/skills/gsd-display-stale/SKILL.md', + }, + { + name: 'gsd-config-only', + description: 'Config-only extension skill', + level: 'extension', + disableModelInvocation: false, + body: 'config only body', + filePath: '/ext/gsd-core/skills/gsd-config-only/SKILL.md', + }, + ], + }, + ]), getAuthType: vi.fn().mockReturnValue('qwen'), getAllConfiguredModels: vi.fn().mockReturnValue([ { @@ -1959,10 +2022,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.workspaceMcpResources, { serverName: 'not-configured' }, ); - const skills = await agent.extMethod( + const skills = (await agent.extMethod( SERVE_STATUS_EXT_METHODS.workspaceSkills, {}, - ); + )) as unknown as ServeWorkspaceSkillsStatus; const providers = await agent.extMethod( SERVE_STATUS_EXT_METHODS.workspaceProviders, {}, @@ -2059,8 +2122,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { v: 1, workspaceCwd: '/work/status', initialized: true, - skills: [ - { + skills: expect.arrayContaining([ + expect.objectContaining({ kind: 'skill', status: 'ok', name: 'review', @@ -2068,8 +2131,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { level: 'project', argumentHint: '[path]', modelInvocable: true, - }, - { + }), + expect.objectContaining({ kind: 'skill', status: 'ok', name: 'manual-only', @@ -2077,20 +2140,59 @@ describe('QwenAgent MCP SSE/HTTP support', () => { level: 'project', argumentHint: '[topic]', modelInvocable: false, - }, - { + }), + expect.objectContaining({ kind: 'skill', status: 'disabled', name: 'disabled-skill', description: 'Disabled by settings', level: 'project', modelInvocable: true, - }, - ], + }), + expect.objectContaining({ + kind: 'skill', + status: 'disabled', + name: 'gsd-audit-uat', + description: 'Cross-phase audit', + level: 'extension', + extensionName: 'gsd-core', + modelInvocable: true, + }), + expect.objectContaining({ + kind: 'skill', + status: 'disabled', + name: 'gsd-display-stale', + description: 'Display-name stale extension skill', + level: 'extension', + extensionName: 'GSD Core', + modelInvocable: true, + }), + expect.objectContaining({ + kind: 'skill', + status: 'disabled', + name: 'gsd-config-only', + description: 'Config-only extension skill', + level: 'extension', + extensionName: 'GSD Core', + modelInvocable: true, + }), + ]), }); + expect( + skills.skills.filter((skill) => skill.name === 'gsd-audit-uat'), + ).toHaveLength(1); + expect( + skills.skills.filter((skill) => skill.name === 'gsd-display-stale'), + ).toHaveLength(1); + expect( + skills.skills.filter((skill) => skill.name === 'gsd-config-only'), + ).toHaveLength(1); expect(JSON.stringify(skills)).not.toContain('secret skill body'); expect(JSON.stringify(skills)).not.toContain('manual secret body'); expect(JSON.stringify(skills)).not.toContain('disabled secret body'); + 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('/secret'); expect(JSON.stringify(skills)).not.toContain('secret-hook'); @@ -9594,9 +9696,13 @@ describe('sessionLanguage multi-session propagation', () => { refreshCache: vi.fn().mockResolvedValue(undefined), refreshTools: vi.fn().mockResolvedValue(undefined), }; + const skillManager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + }; const cfg = makeConfig({ getSessionId: vi.fn().mockReturnValue('s-ext'), getExtensionManager: vi.fn().mockReturnValue(extensionManager), + getSkillManager: vi.fn().mockReturnValue(skillManager), }); const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); @@ -9638,6 +9744,7 @@ describe('sessionLanguage multi-session propagation', () => { ).resolves.toEqual({ ok: true }); expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(skillManager.refreshCache).toHaveBeenCalledOnce(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); @@ -9650,9 +9757,13 @@ describe('sessionLanguage multi-session propagation', () => { refreshCache: vi.fn().mockResolvedValue(undefined), refreshTools: vi.fn().mockRejectedValue(new Error('bad tool schema')), }; + const skillManager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + }; const cfg = makeConfig({ getSessionId: vi.fn().mockReturnValue('s-ext'), getExtensionManager: vi.fn().mockReturnValue(extensionManager), + getSkillManager: vi.fn().mockReturnValue(skillManager), }); const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); @@ -9694,6 +9805,7 @@ describe('sessionLanguage multi-session propagation', () => { ).resolves.toEqual({ ok: true }); expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(skillManager.refreshCache).toHaveBeenCalledOnce(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 90c775a236..9ea1e59feb 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -176,6 +176,10 @@ import { } from '../serve/workspace-remember-errors.js'; import { formatWorkspaceMemoryForgetSummary } from '../serve/workspace-memory-summaries.js'; import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js'; +import { + inactiveExtensionSkillRefs, + isInactiveExtensionSkill, +} from './extension-skills.js'; import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; import { buildSessionTasksStatus } from './session/tasksSnapshot.js'; import { HistoryReplayer } from './session/HistoryReplayer.js'; @@ -4218,12 +4222,58 @@ class QwenAgent implements Agent { try { const disabled = config.getDisabledSkillNames(); + try { + await config.getExtensionManager().refreshCache(); + } catch (error) { + debugLogger.warn('Extension cache refresh failed:', error); + } + try { + await skillManager.refreshCache(); + } catch (error) { + debugLogger.warn('Skill cache refresh failed:', error); + } const skills = await skillManager.listSkills(); + const inactiveSkillRefs = inactiveExtensionSkillRefs(config); + const skillsByKey = new Map( + skills.map((skill) => [ + `${skill.level}:${skill.extensionName ?? ''}:${skill.name}`, + mapSkillConfigToStatus(skill, disabled, { + disabled: isInactiveExtensionSkill(skill, inactiveSkillRefs), + }), + ]), + ); + for (const extension of config.getExtensions()) { + if (extension.isActive) continue; + for (const skill of extension.skills ?? []) { + const extensionName = extension.displayName ?? extension.name; + const key = `extension:${extensionName}:${skill.name}`; + if ( + skillsByKey.has(`extension:${extension.name}:${skill.name}`) || + skillsByKey.has(key) + ) { + continue; + } + skillsByKey.set( + key, + mapSkillConfigToStatus( + { + ...skill, + level: 'extension', + extensionName, + }, + disabled, + { disabled: true }, + ), + ); + } + } return { v: STATUS_SCHEMA_VERSION, workspaceCwd: this.workspaceCwd(config), initialized: true, - skills: skills.map((skill) => mapSkillConfigToStatus(skill, disabled)), + skills: Array.from(skillsByKey.values()).sort((a, b) => + a.name.localeCompare(b.name), + ), }; } catch (error) { return { @@ -7273,7 +7323,23 @@ class QwenAgent implements Agent { const sessionId = params['sessionId'] as string; const session = this.sessionOrThrow(sessionId); const extensionManager = session.getConfig().getExtensionManager(); - await extensionManager.refreshCache(); + const skillManager = session.getConfig().getSkillManager(); + await Promise.all([ + extensionManager.refreshCache().catch((err: unknown) => { + debugLogger.warn( + `Extension refresh failed for session ${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }), + skillManager?.refreshCache().catch((err: unknown) => { + debugLogger.warn( + `Skill refresh failed after extension refresh for session ${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }), + ]); try { await extensionManager.refreshTools(); } catch (err) { diff --git a/packages/cli/src/acp-integration/extension-skills.test.ts b/packages/cli/src/acp-integration/extension-skills.test.ts new file mode 100644 index 0000000000..184d4018ba --- /dev/null +++ b/packages/cli/src/acp-integration/extension-skills.test.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config, SkillConfig } from '@qwen-code/qwen-code-core'; +import { describe, expect, it } from 'vitest'; + +import { + inactiveExtensionSkillNames, + inactiveExtensionSkillRefs, + isInactiveExtensionSkill, +} from './extension-skills.js'; + +function configWithExtensions( + extensions: ReturnType, +): Config { + return { + getExtensions: () => extensions, + } as unknown as Config; +} + +type TestExtension = ReturnType[number]; + +function extension( + fields: Pick & + Partial>, +): TestExtension { + return { + id: fields.name, + version: '1.0.0', + path: `/extensions/${fields.name}`, + config: { name: fields.name, version: '1.0.0' }, + contextFiles: [], + ...fields, + }; +} + +function skill( + name: string, + extensionName: string, + level: SkillConfig['level'] = 'extension', +): Pick { + return { name, extensionName, level }; +} + +function extensionSkill(name: string): SkillConfig { + return { + name, + description: `${name} description`, + body: `${name} body`, + filePath: `/skills/${name}/SKILL.md`, + level: 'extension', + }; +} + +describe('extension skill activity helpers', () => { + it('matches skills from inactive extensions by name and displayName', () => { + const refs = inactiveExtensionSkillRefs( + configWithExtensions([ + extension({ + name: 'canonical-ext', + displayName: 'Display Ext', + isActive: false, + skills: [extensionSkill('audit')], + }), + ]), + ); + + expect( + isInactiveExtensionSkill(skill('audit', 'canonical-ext'), refs), + ).toBe(true); + expect(isInactiveExtensionSkill(skill('audit', 'Display Ext'), refs)).toBe( + true, + ); + }); + + it('ignores skills from active extensions', () => { + const refs = inactiveExtensionSkillRefs( + configWithExtensions([ + extension({ + name: 'active-ext', + isActive: true, + skills: [extensionSkill('audit')], + }), + ]), + ); + + expect(isInactiveExtensionSkill(skill('audit', 'active-ext'), refs)).toBe( + false, + ); + }); + + it('collects inactive extension skill names for commands without extensionName', () => { + const names = inactiveExtensionSkillNames( + configWithExtensions([ + extension({ + name: 'inactive-ext', + isActive: false, + skills: [extensionSkill('Audit')], + }), + extension({ + name: 'active-ext', + isActive: true, + skills: [extensionSkill('Review')], + }), + ]), + ); + + expect(names).toEqual(new Set(['audit'])); + }); + + it('ignores non-extension skills', () => { + const refs = inactiveExtensionSkillRefs( + configWithExtensions([ + extension({ + name: 'inactive-ext', + isActive: false, + skills: [extensionSkill('audit')], + }), + ]), + ); + + expect( + isInactiveExtensionSkill(skill('audit', 'inactive-ext', 'user'), refs), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/acp-integration/extension-skills.ts b/packages/cli/src/acp-integration/extension-skills.ts new file mode 100644 index 0000000000..901316f025 --- /dev/null +++ b/packages/cli/src/acp-integration/extension-skills.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config, SkillConfig } from '@qwen-code/qwen-code-core'; + +function extensionSkillRef(extensionName: string, skillName: string): string { + return `${extensionName}\0${skillName}`; +} + +export function inactiveExtensionSkillRefs(config: Config): Set { + const refs = new Set(); + for (const extension of config.getExtensions()) { + 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; +} + +export function inactiveExtensionSkillNames(config: Config): Set { + const names = new Set(); + for (const extension of config.getExtensions()) { + if (extension.isActive) continue; + for (const skill of extension.skills ?? []) { + names.add(skill.name.toLowerCase()); + } + } + return names; +} + +export function isInactiveExtensionSkill( + skill: Pick, + inactiveSkillRefs: Set, +): boolean { + return ( + skill.level === 'extension' && + skill.extensionName !== undefined && + inactiveSkillRefs.has(extensionSkillRef(skill.extensionName, skill.name)) + ); +} diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index fcaa63246b..8464387679 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -469,6 +469,7 @@ describe('Session', () => { getMonitorRegistry: vi.fn().mockReturnValue(mockMonitorRegistry), getFileHistoryService: vi.fn().mockReturnValue(mockFileHistoryService), getDisabledSkillNames: vi.fn().mockReturnValue(new Set()), + getExtensions: vi.fn().mockReturnValue([]), } as unknown as Config; mockClient = { @@ -1639,6 +1640,211 @@ describe('Session', () => { ]); }); + it('omits inactive extension skills from availableSkills and details', async () => { + mockConfig.getExtensions = vi.fn().mockReturnValue([ + { + name: 'disabled-ext', + displayName: 'Disabled Extension', + isActive: false, + skills: [ + { + name: 'disabled-extension-skill', + description: 'Disabled extension skill', + body: 'Hidden instructions', + filePath: '/skills/disabled/SKILL.md', + level: 'extension', + }, + ], + }, + ]); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([ + { + name: 'active-extension-skill', + description: 'Active extension skill', + body: 'Visible instructions', + filePath: '/skills/active/SKILL.md', + level: 'extension', + extensionName: 'active-ext', + }, + { + name: 'display-name-collision-skill', + description: 'Active extension skill with colliding name', + body: 'Visible collision instructions', + filePath: '/skills/collision/SKILL.md', + level: 'extension', + extensionName: 'Disabled Extension', + }, + { + name: 'disabled-extension-skill', + description: 'Disabled extension skill', + body: 'Hidden instructions', + filePath: '/skills/disabled/SKILL.md', + level: 'extension', + extensionName: 'Disabled Extension', + }, + ]), + }); + + await session.sendAvailableCommandsUpdate(); + + const update = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([call]) => call) + .find( + (call) => call.update.sessionUpdate === 'available_commands_update', + ) as { + update: { + _meta: { + availableSkills: string[]; + availableSkillDetails: Array<{ name: string }>; + }; + }; + }; + const meta = update.update._meta; + expect(meta.availableSkills).toEqual([ + 'active-extension-skill', + 'display-name-collision-skill', + ]); + expect(meta.availableSkillDetails.map((detail) => detail.name)).toEqual([ + 'active-extension-skill', + 'display-name-collision-skill', + ]); + }); + + it('does not restore inactive extension skills from skill slash commands', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'disabled-extension-skill', + description: 'Disabled extension skill', + kind: 'skill', + skillDetail: { + name: 'disabled-extension-skill', + description: 'Disabled extension skill', + body: 'Hidden instructions', + level: 'extension', + extensionName: 'disabled-ext', + }, + }, + ]); + mockConfig.getExtensions = vi.fn().mockReturnValue([ + { + name: 'disabled-ext', + isActive: false, + skills: [ + { + name: 'disabled-extension-skill', + description: 'Disabled extension skill', + body: 'Hidden instructions', + filePath: '/skills/disabled/SKILL.md', + level: 'extension', + }, + ], + }, + ]); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([ + { + name: 'disabled-extension-skill', + description: 'Disabled extension skill', + body: 'Hidden instructions', + filePath: '/skills/disabled/SKILL.md', + level: 'extension', + extensionName: 'disabled-ext', + }, + ]), + }); + + await session.sendAvailableCommandsUpdate(); + + const update = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([call]) => call) + .find( + (call) => call.update.sessionUpdate === 'available_commands_update', + ) as { + update: { + availableCommands: Array<{ name: string }>; + _meta?: { + availableSkills: string[]; + availableSkillDetails: Array<{ name: string }>; + }; + }; + }; + expect( + update.update.availableCommands.map((command) => command.name), + ).not.toContain('disabled-extension-skill'); + expect(update.update._meta).toBeUndefined(); + }); + + it('keeps active extension slash commands that share a skill name with inactive extensions', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'review', + description: 'Active review skill', + kind: 'skill', + skillDetail: { + name: 'review', + description: 'Active review skill', + body: 'Visible instructions', + level: 'extension', + extensionName: 'active-ext', + }, + }, + ]); + mockConfig.getExtensions = vi.fn().mockReturnValue([ + { + name: 'disabled-ext', + isActive: false, + skills: [ + { + name: 'review', + description: 'Disabled review skill', + body: 'Hidden instructions', + filePath: '/skills/disabled-review/SKILL.md', + level: 'extension', + }, + ], + }, + ]); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([ + { + name: 'review', + description: 'Active review skill', + body: 'Visible instructions', + filePath: '/skills/active-review/SKILL.md', + level: 'extension', + extensionName: 'active-ext', + }, + ]), + }); + + await session.sendAvailableCommandsUpdate(); + + const update = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([call]) => call) + .find( + (call) => call.update.sessionUpdate === 'available_commands_update', + ) as { + update: { + availableCommands: Array<{ name: string }>; + _meta: { + availableSkills: string[]; + availableSkillDetails: Array<{ name: string }>; + }; + }; + }; + expect( + update.update.availableCommands.map((command) => command.name), + ).toContain('review'); + expect(update.update._meta.availableSkills).toEqual(['review']); + expect(update.update._meta.availableSkillDetails).toEqual([ + expect.objectContaining({ name: 'review' }), + ]); + }); + it('swallows errors and does not throw', async () => { getAvailableCommandsSpy.mockRejectedValueOnce( new Error('Command discovery failed'), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c1410bd362..b619198627 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -133,6 +133,10 @@ import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/b import { MID_TURN_QUEUE_DRAIN_METHOD } from '@qwen-code/acp-bridge/bridgeTypes'; import { getCommandSubcommandNames } from '../../services/commandMetadata.js'; import { getEffectiveSupportedModes } from '../../services/commandUtils.js'; +import { + inactiveExtensionSkillRefs, + isInactiveExtensionSkill, +} from '../extension-skills.js'; import { RequestError } from '@agentclientprotocol/sdk'; import type { @@ -737,10 +741,26 @@ export async function buildAvailableCommandsSnapshot( settings, ); const disabledSkillNames = config.getDisabledSkillNames(); + const inactiveSkillRefs = inactiveExtensionSkillRefs(config); const visibleSlashCommands = slashCommands.filter((cmd) => { if (cmd.kind !== CommandKind.SKILL || !cmd.skillDetail) return true; - return !disabledSkillNames.has(cmd.skillDetail.name.toLowerCase()); + const skillName = cmd.skillDetail.name.toLowerCase(); + const isInactiveExtensionCommand = + cmd.skillDetail.level === 'extension' && + isInactiveExtensionSkill( + { + name: cmd.skillDetail.name, + level: 'extension', + extensionName: + 'extensionName' in cmd.skillDetail && + typeof cmd.skillDetail.extensionName === 'string' + ? cmd.skillDetail.extensionName + : undefined, + }, + inactiveSkillRefs, + ); + return !disabledSkillNames.has(skillName) && !isInactiveExtensionCommand; }); const availableCommands: AvailableCommand[] = visibleSlashCommands.map( @@ -783,7 +803,9 @@ export async function buildAvailableCommandsSnapshot( const skillManager = config.getSkillManager(); if (skillManager) { const skills = (await skillManager.listSkills()).filter( - (skill) => !disabledSkillNames.has(skill.name.toLowerCase()), + (skill) => + !disabledSkillNames.has(skill.name.toLowerCase()) && + !isInactiveExtensionSkill(skill, inactiveSkillRefs), ); availableSkills = skills.map((skill) => skill.name); for (const skill of skills) { @@ -806,6 +828,9 @@ export async function buildAvailableCommandsSnapshot( continue; } const existing = skillDetailsByName.get(command.skillDetail.name); + if (command.skillDetail.level === 'extension' && !existing) { + continue; + } skillDetailsByName.set(command.skillDetail.name, { ...existing, ...command.skillDetail, diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 610a3fc1ed..ce527fefd4 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -131,10 +131,11 @@ export function registerWorkspaceExtensionRoutes( req: Request, res: Response, route: string, + opts: { requireClientId?: boolean } = {}, ): boolean => { const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); if (clientId === null) return false; - if (clientId === undefined) { + if (clientId === undefined && opts.requireClientId !== false) { res.status(400).json({ error: 'Missing X-Qwen-Client-Id header', code: 'missing_client_id', @@ -349,6 +350,7 @@ export function registerWorkspaceExtensionRoutes( `extension ${operation}`, ); extensionsStatusCache = undefined; + workspace.invalidateWorkspaceSkillsStatus(); try { const result = await bridge.refreshExtensionsForAllSessions(event); updateExtensionOperation(operationId, { @@ -762,6 +764,7 @@ export function registerWorkspaceExtensionRoutes( 'extension refresh', ), ); + extensionsStatusCache = undefined; res.status(200).json(result); } catch (err) { if (isExtensionQueueFullError(err)) { @@ -785,6 +788,7 @@ export function registerWorkspaceExtensionRoutes( req, res, 'POST /workspace/extensions/:name/enable', + { requireClientId: false }, ) ) { return; @@ -831,6 +835,7 @@ export function registerWorkspaceExtensionRoutes( req, res, 'POST /workspace/extensions/:name/disable', + { requireClientId: false }, ) ) { return; diff --git a/packages/cli/src/serve/routes/workspace-status.ts b/packages/cli/src/serve/routes/workspace-status.ts index 18574a713c..c2e0839a1f 100644 --- a/packages/cli/src/serve/routes/workspace-status.ts +++ b/packages/cli/src/serve/routes/workspace-status.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Application } from 'express'; +import type { Application, RequestHandler } from 'express'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; import type { SendBridgeError } from '../server/error-response.js'; import { @@ -13,10 +13,13 @@ import { } from '../server/request-helpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +const MAX_ACP_PREHEAT_TIMEOUT_MS = 60_000; + interface RegisterWorkspaceStatusRoutesDeps { boundWorkspace: string; bridge: AcpSessionBridge; workspace: DaemonWorkspaceService; + mutate: (opts?: { strict?: boolean }) => RequestHandler; sendBridgeError: SendBridgeError; } @@ -24,7 +27,7 @@ export function registerWorkspaceStatusRoutes( app: Application, deps: RegisterWorkspaceStatusRoutesDeps, ): void { - const { boundWorkspace, bridge, workspace, sendBridgeError } = deps; + const { boundWorkspace, bridge, workspace, mutate, sendBridgeError } = deps; const buildWorkspaceCtx = createBuildWorkspaceCtx(boundWorkspace); app.get('/workspace/mcp', async (_req, res) => { @@ -95,6 +98,46 @@ export function registerWorkspaceStatusRoutes( } }); + app.post('/workspace/acp/preheat', mutate(), async (req, res) => { + try { + const ctx = buildWorkspaceCtx('POST /workspace/acp/preheat'); + const timeoutMsRaw = req.query['timeoutMs']; + const timeoutMs = + typeof timeoutMsRaw === 'string' && timeoutMsRaw.trim() + ? Number(timeoutMsRaw) + : undefined; + if ( + timeoutMs !== undefined && + (!Number.isFinite(timeoutMs) || + !Number.isInteger(timeoutMs) || + timeoutMs <= 0 || + timeoutMs > MAX_ACP_PREHEAT_TIMEOUT_MS) + ) { + res.status(400).json({ + error: '`timeoutMs` must be a positive integer no greater than 60000', + code: 'invalid_timeout', + }); + return; + } + res.status(200).json( + await workspace.preheatAcpChild(ctx, { + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }), + ); + } catch (err) { + sendBridgeError(res, err, { route: 'POST /workspace/acp/preheat' }); + } + }); + + app.get('/workspace/acp/status', async (_req, res) => { + try { + const ctx = buildWorkspaceCtx('GET /workspace/acp/status'); + res.status(200).json(await workspace.getWorkspaceAcpStatus(ctx)); + } catch (err) { + sendBridgeError(res, err, { route: 'GET /workspace/acp/status' }); + } + }); + app.get('/workspace/tools', async (_req, res) => { try { res.status(200).json(await bridge.getWorkspaceToolsStatus()); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 362e27f17b..905df849ea 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -2600,6 +2600,7 @@ export async function runQwenServe( persistDisabledTools: persistDisabledToolsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, + preheatAcpChild: () => bridge.preheat(), reloadDaemonEnv: (workspace) => withSettingsLock(workspace, async () => { const fresh = settingsRuntime.settings.loadSettings(workspace, { @@ -2868,6 +2869,7 @@ export async function runQwenServe( workspaceSkillsStatusProvider: runtime.createWorkspaceSkillsStatusProvider(), isChannelLive: () => secondaryBridge.isChannelLive(), + preheatAcpChild: () => secondaryBridge.preheat(), persistDisabledTools: persistDisabledToolsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 23f55be291..8e99a68922 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -2993,6 +2993,104 @@ describe('createServeApp', () => { } }); + it('rejects workspace ACP preheat timeouts above the route cap', async () => { + const bridge = fakeBridge(); + const opts = { ...baseOpts, workspace: WS_BOUND, token: 'secret' }; + const app = createServeApp(opts, undefined, { bridge }); + + const res = await request(app) + .post('/workspace/acp/preheat?timeoutMs=60001') + .set('Host', `127.0.0.1:${opts.port}`) + .set('Authorization', 'Bearer secret'); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'invalid_timeout', + error: '`timeoutMs` must be a positive integer no greater than 60000', + }); + }); + + it('returns workspace ACP preheat status without a timeout override', async () => { + const bridge = fakeBridge(); + const opts = { ...baseOpts, workspace: WS_BOUND, token: 'secret' }; + const app = createServeApp(opts, undefined, { bridge }); + + const res = await request(app) + .post('/workspace/acp/preheat') + .set('Host', `127.0.0.1:${opts.port}`) + .set('Authorization', 'Bearer secret'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + ready: false, + channelLive: false, + }); + expect(typeof res.body.durationMs).toBe('number'); + }); + + it('returns workspace ACP preheat status with a valid timeout override', async () => { + const bridge = fakeBridge(); + const opts = { ...baseOpts, workspace: WS_BOUND, token: 'secret' }; + const app = createServeApp(opts, undefined, { bridge }); + + const res = await request(app) + .post('/workspace/acp/preheat?timeoutMs=5000') + .set('Host', `127.0.0.1:${opts.port}`) + .set('Authorization', 'Bearer secret'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + ready: false, + channelLive: false, + }); + expect(typeof res.body.durationMs).toBe('number'); + }); + + it('allows loopback mutation auth before preheating the ACP child', async () => { + const preheatAcpChild = vi.fn().mockResolvedValue({ + ready: true, + channelLive: true, + durationMs: 0, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { + bridge: fakeBridge(), + workspace: { + preheatAcpChild, + } as unknown as DaemonWorkspaceService, + }, + ); + + const res = await request(app) + .post('/workspace/acp/preheat') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + ready: true, + channelLive: true, + }); + expect(preheatAcpChild).toHaveBeenCalledOnce(); + }); + + it('returns workspace ACP channel status', async () => { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/workspace/acp/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ channelLive: false }); + }); + it('returns workspace tools status from the bridge', async () => { const tools: ServeWorkspaceToolsStatus = { v: 1, @@ -4450,6 +4548,44 @@ describe('createServeApp', () => { } }); + it('queues extension enable and disable without a workspace client id', async () => { + const restore = mockExtensionManagerMethods(); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge(); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const enable = await request(app) + .post('/workspace/extensions/TEST-EXT/enable') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ scope: 'workspace' }); + expect(enable.status).toBe(202); + + const disable = await request(app) + .post('/workspace/extensions/TEST-EXT/disable') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ scope: 'user' }); + expect(disable.status).toBe(202); + + await vi.waitFor(() => { + expect( + vi.mocked(ExtensionManager.prototype.enableExtension), + ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + expect( + vi.mocked(ExtensionManager.prototype.disableExtension), + ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + }); + } finally { + restore(); + } + }); + it('refreshes extensions for all sessions on request', async () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 90393a1a87..111eb0b047 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -611,6 +611,7 @@ export function createServeApp( bridge.queryWorkspaceStatus(method, idle), invokeWorkspaceCommand: (method, params, invokeOpts) => bridge.invokeWorkspaceCommand(method, params, invokeOpts), + preheatAcpChild: () => bridge.preheat(), refreshExtensionsForAllSessions: () => bridge.refreshExtensionsForAllSessions(), ...(deps.persistSetting ? { persistSetting: deps.persistSetting } : {}), @@ -807,6 +808,7 @@ export function createServeApp( boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, workspace: primaryWorkspace, + mutate, sendBridgeError, }); @@ -837,6 +839,7 @@ export function createServeApp( boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, workspace: primaryWorkspace, + mutate, sendBridgeError, }); diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index 07a770257a..1094f616b9 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -715,6 +715,55 @@ describe('createDaemonWorkspaceService', () => { expect(refreshed.skills.map((s) => s.name)).toEqual(['review', 'plan']); }); + it('invalidateWorkspaceSkillsStatus drops the cached child skills answer', async () => { + const liveStatus = { + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'pdf', + description: 'PDF skill', + level: 'extension', + extensionName: 'pdf-tools', + modelInvocable: true, + }, + ], + }; + let channelLive = true; + const localStatus = { + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [], + }; + const queryWorkspaceStatus = vi + .fn() + .mockImplementation((_m: string, idle: () => unknown) => + Promise.resolve(channelLive ? liveStatus : idle()), + ); + const workspaceSkillsStatusProvider = vi + .fn() + .mockResolvedValue(localStatus); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + workspaceSkillsStatusProvider, + boundWorkspace: '/ws', + }), + ); + + await svc.getWorkspaceSkillsStatus(makeCtx()); + channelLive = false; + svc.invalidateWorkspaceSkillsStatus(); + + const result = await svc.getWorkspaceSkillsStatus(makeCtx()); + expect(result.skills).toEqual([]); + expect(workspaceSkillsStatusProvider).toHaveBeenCalledWith('/ws'); + }); + it('getWorkspaceSkillsStatus falls back to the daemon-local provider when the child never answered', async () => { const queryWorkspaceStatus = vi .fn() @@ -1233,6 +1282,87 @@ describe('createDaemonWorkspaceService', () => { }); }); + describe('preheatAcpChild', () => { + it('returns the current ACP channel status', async () => { + const svc = createDaemonWorkspaceService( + makeDeps({ isChannelLive: () => true }), + ); + + await expect(svc.getWorkspaceAcpStatus(makeCtx())).resolves.toEqual({ + channelLive: true, + }); + }); + + it('returns ready without preheating when the channel is already live', async () => { + const preheatAcpChild = vi.fn().mockResolvedValue(undefined); + const svc = createDaemonWorkspaceService( + makeDeps({ isChannelLive: () => true, preheatAcpChild }), + ); + + const result = await svc.preheatAcpChild(makeCtx()); + + expect(result).toMatchObject({ ready: true, channelLive: true }); + expect(preheatAcpChild).not.toHaveBeenCalled(); + }); + + it('preheats the ACP child when the channel is not live', async () => { + let live = false; + const preheatAcpChild = vi.fn().mockImplementation(async () => { + live = true; + }); + const svc = createDaemonWorkspaceService( + makeDeps({ isChannelLive: () => live, preheatAcpChild }), + ); + + const result = await svc.preheatAcpChild(makeCtx()); + + expect(result).toMatchObject({ ready: true, channelLive: true }); + expect(preheatAcpChild).toHaveBeenCalledOnce(); + }); + + it('returns timeout when preheat does not finish before the deadline', async () => { + const preheatAcpChild = vi.fn(() => new Promise(() => undefined)); + const svc = createDaemonWorkspaceService( + makeDeps({ isChannelLive: () => false, preheatAcpChild }), + ); + + const result = await svc.preheatAcpChild(makeCtx(), { timeoutMs: 1 }); + const retry = await svc.preheatAcpChild(makeCtx(), { timeoutMs: 1 }); + + expect(result).toMatchObject({ + ready: false, + channelLive: false, + reason: 'timeout', + }); + expect(retry).toMatchObject({ + ready: false, + channelLive: false, + reason: 'timeout', + }); + expect(preheatAcpChild).toHaveBeenCalledTimes(2); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'qwen serve: ACP preheat timed out after 1ms', + ); + }); + + it('returns error when preheat fails before the deadline', async () => { + const preheatAcpChild = vi + .fn() + .mockRejectedValue(new Error('preheat failed')); + const svc = createDaemonWorkspaceService( + makeDeps({ isChannelLive: () => false, preheatAcpChild }), + ); + + const result = await svc.preheatAcpChild(makeCtx(), { timeoutMs: 1000 }); + + expect(result).toMatchObject({ + ready: false, + channelLive: false, + reason: 'error', + }); + }); + }); + describe('restartMcpServer', () => { it('calls invokeWorkspaceCommand with correct method and params', async () => { const invokeWorkspaceCommand = vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 9a696bf435..e8e1ae0644 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -69,6 +69,8 @@ import type { WorkspaceTrustChangeRequest, WorkspacePermissionRulesUpdate, WorkspaceVoiceSettingsUpdate, + WorkspaceAcpPreheatResult, + WorkspaceAcpStatusResult, } from './types.js'; // Re-export types for consumers. @@ -82,6 +84,8 @@ export type { WorkspaceTrustDesiredState, WorkspacePermissionRulesUpdate, WorkspaceVoiceSettingsUpdate, + WorkspaceAcpPreheatResult, + WorkspaceAcpStatusResult, EnvReloadResult, ReloadResponse, } from './types.js'; @@ -150,6 +154,30 @@ async function verifyParentPostOpen( ); } +class TimeoutError extends Error {} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new TimeoutError(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timeout); + reject(err); + }, + ); + }); +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -167,6 +195,7 @@ export function createDaemonWorkspaceService( persistDisabledTools, persistSetting, persistSettings, + preheatAcpChild: preheatAcpChildOnBridge, queryWorkspaceStatus, invokeWorkspaceCommand, refreshExtensionsForAllSessions: refreshExtensionsForAllSessionsOnBridge, @@ -177,6 +206,7 @@ export function createDaemonWorkspaceService( // skill-backed slash commands (e.g. `/review`) keep autocompleting after // the child channel has been reaped. See `getWorkspaceSkillsStatus`. let lastWorkspaceSkillsStatus: ServeWorkspaceSkillsStatus | undefined; + let inFlightAcpPreheat: Promise | undefined; // -- Facade -- return { @@ -256,6 +286,75 @@ export function createDaemonWorkspaceService( ); }, + async preheatAcpChild( + _ctx: WorkspaceRequestContext, + opts?: { timeoutMs?: number }, + ): Promise { + const startedAt = Date.now(); + const channelLive = () => isChannelLive?.() ?? false; + const finish = ( + result: Omit, + ): WorkspaceAcpPreheatResult => ({ + ...result, + durationMs: Date.now() - startedAt, + }); + + if (channelLive()) { + return finish({ ready: true, channelLive: true }); + } + if (!preheatAcpChildOnBridge) { + return finish({ + ready: false, + channelLive: false, + reason: 'error', + error: 'ACP preheat is not wired', + }); + } + + if (!inFlightAcpPreheat) { + inFlightAcpPreheat = preheatAcpChildOnBridge().finally(() => { + inFlightAcpPreheat = undefined; + }); + void inFlightAcpPreheat.catch((err) => { + writeStderrLine( + `qwen serve: ACP preheat failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } + + try { + await withTimeout( + inFlightAcpPreheat, + opts?.timeoutMs ?? 5_000, + 'ACP preheat', + ); + } catch (err) { + if (err instanceof TimeoutError) { + inFlightAcpPreheat = undefined; + writeStderrLine( + `qwen serve: ACP preheat timed out after ${opts?.timeoutMs ?? 5_000}ms`, + ); + } + const live = channelLive(); + const message = err instanceof Error ? err.message : String(err); + return finish({ + ready: live, + channelLive: live, + reason: err instanceof TimeoutError ? 'timeout' : 'error', + error: message, + }); + } + + const live = channelLive(); + return finish({ ready: live, channelLive: live }); + }, + + async getWorkspaceAcpStatus( + _ctx: WorkspaceRequestContext, + ): Promise { + return { channelLive: isChannelLive?.() ?? false }; + }, + async getWorkspaceEnvStatus(_ctx: WorkspaceRequestContext) { // Env status is answered daemon-locally from process state — no ACP // query needed. The old bridge used statusProvider.getEnvStatus() @@ -860,6 +959,10 @@ export function createDaemonWorkspaceService( }; }, + invalidateWorkspaceSkillsStatus() { + lastWorkspaceSkillsStatus = undefined; + }, + async refreshExtensionsForAllSessions() { try { if (!refreshExtensionsForAllSessionsOnBridge) { @@ -871,6 +974,8 @@ export function createDaemonWorkspaceService( `qwen serve: refreshExtensionsForAllSessions failed: ${err instanceof Error ? err.message : String(err)}`, ); return { refreshed: 0, failed: 1 }; + } finally { + lastWorkspaceSkillsStatus = undefined; } }, }; diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index 96337078b5..ce12e5e7e3 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -149,6 +149,17 @@ export interface DaemonWorkspaceService { ctx: WorkspaceRequestContext, ): Promise; + /** Start the ACP child/channel without creating a user-visible session. */ + preheatAcpChild( + ctx: WorkspaceRequestContext, + opts?: { timeoutMs?: number }, + ): Promise; + + /** Current ACP child/channel liveness for the bound workspace. */ + getWorkspaceAcpStatus( + ctx: WorkspaceRequestContext, + ): Promise; + /** Voice settings and selectable transcription models for the workspace. */ getWorkspaceVoiceStatus( ctx: WorkspaceRequestContext, @@ -197,6 +208,9 @@ export interface DaemonWorkspaceService { /** Reload all settings (env + model + permissions + tools + memory). */ reload(ctx: WorkspaceRequestContext): Promise; + /** Drop cached skill status so extension skill changes are re-enumerated. */ + invalidateWorkspaceSkillsStatus(): void; + /** Broadcast extension refresh to all active sessions (fire-and-forget). */ refreshExtensionsForAllSessions(): Promise<{ refreshed: number; @@ -217,6 +231,18 @@ export interface ReloadResponse { childError?: string; } +export interface WorkspaceAcpPreheatResult { + ready: boolean; + channelLive: boolean; + durationMs: number; + reason?: 'timeout' | 'error'; + error?: string; +} + +export interface WorkspaceAcpStatusResult { + channelLive: boolean; +} + export type WorkspaceTrustDesiredState = 'trusted' | 'untrusted'; export interface WorkspaceTrustChangeRequest { @@ -363,6 +389,9 @@ export interface DaemonWorkspaceServiceDeps { /** Reload daemon-side process.env from .env / settings.env. */ reloadDaemonEnv?: (workspace: string) => Promise; + /** Eagerly start the ACP child/channel without creating a session. */ + preheatAcpChild?: () => Promise; + /** * Query workspace status from the ACP child. The bridge owns the * child lifecycle; this callback abstracts that dependency. diff --git a/packages/cli/src/serve/workspace-skills-mapping.test.ts b/packages/cli/src/serve/workspace-skills-mapping.test.ts index f8d6dad38f..60bed6d927 100644 --- a/packages/cli/src/serve/workspace-skills-mapping.test.ts +++ b/packages/cli/src/serve/workspace-skills-mapping.test.ts @@ -55,6 +55,15 @@ describe('mapSkillConfigToStatus', () => { expect(status.name).toBe('internal'); }); + it('marks a forced-disabled skill as disabled', () => { + const status = mapSkillConfigToStatus(makeSkill(), new Set(), { + disabled: true, + }); + + expect(status.status).toBe('disabled'); + expect(status.modelInvocable).toBe(true); + }); + it('surfaces optional model and extensionName only when present', () => { expect(mapSkillConfigToStatus(makeSkill())).not.toHaveProperty('model'); expect(mapSkillConfigToStatus(makeSkill())).not.toHaveProperty( diff --git a/packages/cli/src/serve/workspace-skills-mapping.ts b/packages/cli/src/serve/workspace-skills-mapping.ts index 2a7355bffa..44a37cafa7 100644 --- a/packages/cli/src/serve/workspace-skills-mapping.ts +++ b/packages/cli/src/serve/workspace-skills-mapping.ts @@ -17,12 +17,13 @@ import type { ServeWorkspaceSkillStatus } from '@qwen-code/acp-bridge/status'; export function mapSkillConfigToStatus( skill: SkillConfig, disabledSkillNames: ReadonlySet = new Set(), + opts: { disabled?: boolean } = {}, ): ServeWorkspaceSkillStatus { const userDisabled = disabledSkillNames.has(skill.name.toLowerCase()); const modelInvocable = skill.disableModelInvocation !== true; return { kind: 'skill', - status: userDisabled ? 'disabled' : 'ok', + status: opts.disabled || userDisabled ? 'disabled' : 'ok', name: skill.name, description: skill.description, level: skill.level, diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts index 88ce7dbd49..29e74e8d66 100644 --- a/packages/cli/src/services/SkillCommandLoader.ts +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -116,6 +116,9 @@ export class SkillCommandLoader implements ICommandLoader { description: skill.description, body: skill.body, level: skill.level, + ...(isExtension && skill.extensionName + ? { extensionName: skill.extensionName } + : {}), }, action: async (context, _args): Promise => { // Auto-approve the skill's declared allowedTools before its body is submitted. diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index cc13196547..19714c3374 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -427,6 +427,7 @@ export interface SlashCommand { body?: string; filePath?: string; level?: string; + extensionName?: string; }; // The action to run. Optional for parent commands that only group sub-commands. diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 9c4ac20d95..7cbdb46cf9 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -44,7 +44,8 @@ const rootDir = join(__dirname, '..'); // Bumped from 136KB to 137KB for EventBus byte-backlog telemetry validation. // Bumped from 137KB to 138KB for history_truncated event validation and // transcript status projection. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 138 * 1024; +// Bumped from 138KB to 139KB for workspace ACP status/preheat APIs. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 139 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index f0d101ec94..2e2ff0b7e4 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -70,6 +70,8 @@ import type { DaemonWorkspaceMemoryStatus, DaemonWorkspacePreflightStatus, DaemonWorkspaceProvidersStatus, + DaemonWorkspaceAcpStatusResult, + DaemonWorkspaceAcpPreheatResult, DaemonWorkspaceSkillsStatus, DaemonWorkspaceToolsStatus, DaemonWriteMemoryRequest, @@ -786,6 +788,40 @@ export class DaemonClient { ); } + async workspaceAcpPreheat( + timeoutMs?: number, + ): Promise { + const serverBudgetMs = timeoutMs ?? 5_000; + const suffix = + timeoutMs !== undefined + ? `?timeoutMs=${encodeURIComponent(timeoutMs)}` + : ''; + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/acp/preheat${suffix}`, + { method: 'POST', headers: this.headers() }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'POST /workspace/acp/preheat'); + } + return (await res.json()) as DaemonWorkspaceAcpPreheatResult; + }, + serverBudgetMs + 2_000, + ); + } + + async workspaceAcpStatus(): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/acp/status`, + { headers: this.headers() }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /workspace/acp/status'); + } + return (await res.json()) as DaemonWorkspaceAcpStatusResult; + }, + ); + } + async workspaceProviders(): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/workspace/providers`, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 0b5c856347..37cf4d60fb 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -488,6 +488,8 @@ export type { DaemonWorkspaceMemoryTaskStatus, DaemonWorkspaceMemoryTopic, DaemonWorkspacePreflightStatus, + DaemonWorkspaceAcpStatusResult, + DaemonWorkspaceAcpPreheatResult, DaemonWorkspaceProviderCurrent, DaemonWorkspaceProviderModel, DaemonWorkspaceProviderStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 1a0a82b6fb..f1738edf21 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -946,6 +946,18 @@ export interface DaemonWorkspaceSkillsStatus { errors?: DaemonStatusCell[]; } +export interface DaemonWorkspaceAcpStatusResult { + channelLive: boolean; +} + +export interface DaemonWorkspaceAcpPreheatResult { + ready: boolean; + channelLive: boolean; + durationMs: number; + reason?: 'timeout' | 'error'; + error?: string; +} + export interface DaemonWorkspaceProviderCurrent { authType?: string; modelId?: string; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index aea123677d..504e2b0c99 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -199,6 +199,8 @@ export { type DaemonFollowupSuggestionEvent, type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceMcpStatus, + type DaemonWorkspaceAcpStatusResult, + type DaemonWorkspaceAcpPreheatResult, type DaemonWorkspaceProviderCurrent, type DaemonWorkspaceProviderModel, type DaemonWorkspaceProviderStatus, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 8995a2f815..008ff06ed8 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -561,11 +561,23 @@ describe('DaemonClient', () => { }, ], }; + const preheat = { + ready: true, + channelLive: true, + durationMs: 12, + }; + const acpStatus = { channelLive: true }; const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/workspace/mcp')) return jsonResponse(200, mcp); if (req.url.endsWith('/workspace/skills')) { return jsonResponse(200, skills); } + if (req.url.endsWith('/workspace/acp/preheat?timeoutMs=1234')) { + return jsonResponse(200, preheat); + } + if (req.url.endsWith('/workspace/acp/status')) { + return jsonResponse(200, acpStatus); + } if (req.url.endsWith('/workspace/providers')) { return jsonResponse(200, providers); } @@ -575,14 +587,55 @@ describe('DaemonClient', () => { await expect(client.workspaceMcp()).resolves.toEqual(mcp); await expect(client.workspaceSkills()).resolves.toEqual(skills); + await expect(client.workspaceAcpPreheat(1234)).resolves.toEqual(preheat); + await expect(client.workspaceAcpStatus()).resolves.toEqual(acpStatus); await expect(client.workspaceProviders()).resolves.toEqual(providers); expect(calls.map((c) => [c.method, c.url])).toEqual([ ['GET', 'http://daemon/workspace/mcp'], ['GET', 'http://daemon/workspace/skills'], + ['POST', 'http://daemon/workspace/acp/preheat?timeoutMs=1234'], + ['GET', 'http://daemon/workspace/acp/status'], ['GET', 'http://daemon/workspace/providers'], ]); }); + it('lets ACP preheat wait longer than the client default timeout', async () => { + let resolveResponse: ((value: Response) => void) | undefined; + const slowFetch = vi.fn( + (_input: RequestInfo | URL, init?: { signal?: AbortSignal | null }) => + new Promise((resolve, reject) => { + resolveResponse = resolve; + init?.signal?.addEventListener('abort', () => { + reject( + init.signal!.reason ?? + new DOMException('aborted', 'AbortError'), + ); + }); + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch: slowFetch as unknown as typeof globalThis.fetch, + fetchTimeoutMs: 1, + }); + + const inflight = client.workspaceAcpPreheat(50); + setTimeout(() => { + resolveResponse?.( + jsonResponse(200, { + ready: true, + channelLive: true, + durationMs: 5, + }), + ); + }, 5); + + await expect(inflight).resolves.toMatchObject({ + ready: true, + channelLive: true, + }); + }); + it('GETs /workspace/preflight and returns the preflight envelope unchanged', async () => { const preflight: DaemonWorkspacePreflightStatus = { v: 1, diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 678d881a8a..2b36f5201e 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -30,6 +30,7 @@ type ChatEditorTestProps = { images?: undefined, commitAccepted?: () => void, ) => boolean | void; + skills?: Array<{ name: string; description: string }>; isPreparing?: boolean; dialogOpen?: boolean; }; @@ -590,6 +591,30 @@ afterEach(() => { }); describe('App session callbacks', () => { + it('filters disabled skills from the web-shell skills list', async () => { + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [ + { + name: 'enabled-skill', + description: 'Enabled', + status: 'ok', + }, + { + name: 'disabled-extension-skill', + description: 'Disabled', + status: 'disabled', + }, + ], + }); + + renderApp(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'enabled-skill', description: 'Enabled' }, + ]); + }); + it.each([404, 410])( 'shows a missing-session empty state with a new-session action for %d', async (status) => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index d8592e0e4b..74652947cd 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -239,6 +239,18 @@ const MAX_TOASTS = 4; // active before giving up, so the scheduled-tasks UI can't stay stuck disabled // if the switch never completes. const BOUND_RUN_SWITCH_TIMEOUT_MS = 30_000; + +function availableSkillInfos(status: { + skills?: Array<{ status?: string; name: string; description?: string }>; +}): SkillInfo[] { + return (status.skills ?? []) + .filter((skill) => skill.status === 'ok') + .map((skill) => ({ + name: skill.name, + description: skill.description ?? '', + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -1222,11 +1234,7 @@ export function App({ workspaceActions .loadSkillsStatus() .then((status) => { - setLoadedSkills( - (status?.skills ?? []) - .map((s) => ({ name: s.name, description: s.description ?? '' })) - .sort((a, b) => a.name.localeCompare(b.name)), - ); + setLoadedSkills(availableSkillInfos(status)); }) .catch(() => {}); }, [connected, workspaceActions]); @@ -3377,12 +3385,7 @@ export function App({ workspaceActions .loadSkillsStatus() .then((status) => { - const skills = (status?.skills ?? []) - .map((s) => ({ - name: s.name, - description: s.description ?? '', - })) - .sort((a, b) => a.name.localeCompare(b.name)); + const skills = availableSkillInfos(status); setLoadedSkills(skills); if (skills.length === 0) { store.dispatch([ diff --git a/packages/web-shell/client/components/dialogs/ExtensionsDialog.test.tsx b/packages/web-shell/client/components/dialogs/ExtensionsDialog.test.tsx new file mode 100644 index 0000000000..2fd077a9c0 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/ExtensionsDialog.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nProvider } from '../../i18n'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const { actions, connection } = vi.hoisted(() => ({ + actions: { + loadExtensionsStatus: vi.fn(), + checkExtensionUpdates: vi.fn(), + refreshExtensions: vi.fn(), + enableExtension: vi.fn(), + disableExtension: vi.fn(), + updateExtension: vi.fn(), + uninstallExtension: vi.fn(), + extensionOperationStatus: vi.fn(), + }, + connection: { + clientId: undefined as string | undefined, + }, +})); + +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useConnection: () => connection, + useWorkspaceActions: () => actions, + useWorkspaceEventSignals: () => ({ extensionsVersion: 0 }), +})); + +const { ExtensionsDialog } = await import('./ExtensionsDialog'); + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +const extension = (isActive: boolean) => ({ + kind: 'extension' as const, + id: 'ext-demo', + name: 'demo', + displayName: 'Demo', + version: '1.0.0', + isActive, + path: '/tmp/demo', + capabilities: { + mcpServers: [], + commands: [], + skills: [], + agents: [], + contextFiles: [], + settings: [], + }, +}); + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function mount() { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render( + + + , + ); + }); + await flush(); +} + +function click(el: Element | undefined) { + if (!el) throw new Error('click target not found'); + act(() => { + el.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ); + }); +} + +function buttonIncluding(text: string): HTMLButtonElement | undefined { + return Array.from(document.querySelectorAll('button')).find((button) => + button.textContent?.includes(text), + ); +} + +function buttonsIncluding(text: string): HTMLButtonElement[] { + return Array.from(document.querySelectorAll('button')).filter((button) => + button.textContent?.includes(text), + ); +} + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + connection.clientId = undefined; + vi.clearAllMocks(); +}); + +describe('ExtensionsDialog', () => { + it('disables an extension without requiring a session client id', async () => { + actions.loadExtensionsStatus + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/tmp/workspace', + initialized: true, + extensions: [extension(true)], + }) + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/tmp/workspace', + initialized: true, + extensions: [extension(false)], + }); + actions.checkExtensionUpdates.mockResolvedValue({ states: {} }); + actions.disableExtension.mockResolvedValue({ + accepted: true, + operationId: 'op-1', + }); + + await mount(); + + click(buttonIncluding('Demo')); + click(buttonIncluding('Disable Extension')); + await flush(); + + expect(actions.disableExtension).toHaveBeenCalledWith( + 'demo', + { scope: 'user' }, + undefined, + ); + expect(actions.extensionOperationStatus).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain('disabled'); + }); + + it('requires a session client id before uninstalling an extension', async () => { + actions.loadExtensionsStatus.mockResolvedValue({ + v: 1, + workspaceCwd: '/tmp/workspace', + initialized: true, + extensions: [extension(true)], + }); + actions.checkExtensionUpdates.mockResolvedValue({ states: {} }); + + await mount(); + + click(buttonIncluding('Demo')); + click(buttonIncluding('Uninstall Extension')); + click(buttonsIncluding('Uninstall Extension').at(-1)); + await flush(); + + expect(actions.uninstallExtension).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + 'Wait for the session to connect', + ); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx b/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx index 17472a8735..f04380d16c 100644 --- a/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx @@ -151,9 +151,13 @@ export function ExtensionsDialog() { }, [load, signals?.extensionsVersion]); const runMutation = useCallback( - (name: string, run: (clientId: string) => Promise) => { + ( + name: string, + run: (clientId?: string) => Promise, + options: { allowWithoutClientId?: boolean } = {}, + ) => { const clientId = connection.clientId; - if (!clientId) { + if (!clientId && !options.allowWithoutClientId) { setMessage(t('extensions.install.waitForSession')); return; } @@ -164,9 +168,12 @@ export function ExtensionsDialog() { .catch((error: unknown) => { setMessage(error instanceof Error ? error.message : String(error)); }) - .finally(() => setBusyName(null)); + .finally(() => { + setBusyName(null); + void load(); + }); }, - [connection.clientId, t], + [connection.clientId, load, t], ); const summary = useMemo(() => { @@ -277,18 +284,21 @@ export function ExtensionsDialog() { ) } onToggleScope={(mutation, scope) => - runMutation(extension.name, (clientId) => - mutation === 'enable' - ? actions.enableExtension( - extension.name, - { scope }, - clientId, - ) - : actions.disableExtension( - extension.name, - { scope }, - clientId, - ), + runMutation( + extension.name, + (clientId) => + mutation === 'enable' + ? actions.enableExtension( + extension.name, + { scope }, + clientId, + ) + : actions.disableExtension( + extension.name, + { scope }, + clientId, + ), + { allowWithoutClientId: true }, ) } onRequestUninstall={() => diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 7187590ea0..36d24597f3 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -100,6 +100,8 @@ interface MockClient { workspaceMcpTools: () => Promise; restartMcpServer: () => Promise; workspaceSkills: () => Promise; + workspaceAcpStatus: () => Promise; + workspaceAcpPreheat: () => Promise; workspaceTools: () => Promise; setWorkspaceToolEnabled: () => Promise; workspaceMemory: () => Promise; @@ -140,6 +142,8 @@ const sdkMocks = vi.hoisted(() => { const workspaceMcpTools = vi.fn(); const restartMcpServer = vi.fn(); const workspaceSkills = vi.fn(); + const workspaceAcpStatus = vi.fn(); + const workspaceAcpPreheat = vi.fn(); const workspaceTools = vi.fn(); const setWorkspaceToolEnabled = vi.fn(); const workspaceMemory = vi.fn(); @@ -168,6 +172,8 @@ const sdkMocks = vi.hoisted(() => { workspaceMcpTools = workspaceMcpTools; restartMcpServer = restartMcpServer; workspaceSkills = workspaceSkills; + workspaceAcpStatus = workspaceAcpStatus; + workspaceAcpPreheat = workspaceAcpPreheat; workspaceTools = workspaceTools; setWorkspaceToolEnabled = setWorkspaceToolEnabled; workspaceMemory = workspaceMemory; @@ -210,6 +216,8 @@ const sdkMocks = vi.hoisted(() => { capabilities, workspaceProviders, workspaceSkills, + workspaceAcpStatus, + workspaceAcpPreheat, MockDaemonClient, MockDaemonSessionClient, workspaceMcpTools, @@ -258,6 +266,14 @@ const sdkMocks = vi.hoisted(() => { initialized: true, skills: [], }); + workspaceAcpStatus.mockReset(); + workspaceAcpStatus.mockResolvedValue({ channelLive: true }); + workspaceAcpPreheat.mockReset(); + workspaceAcpPreheat.mockResolvedValue({ + ready: true, + channelLive: true, + durationMs: 1, + }); workspaceTools.mockReset(); workspaceTools.mockResolvedValue({ v: 1, @@ -472,6 +488,112 @@ describe('DaemonSessionProvider', () => { ]); }); + it('preheats ACP and refreshes deferred skills when ACP is not running', async () => { + sdkMocks.workspaceAcpStatus.mockResolvedValue({ channelLive: false }); + sdkMocks.workspaceSkills + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'bundled', + modelInvocable: true, + }, + ], + }) + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'bundled', + modelInvocable: true, + }, + { + kind: 'skill', + status: 'ok', + name: 'pdf', + description: 'Work with PDFs', + level: 'extension', + modelInvocable: true, + }, + ], + }); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + await act(async () => { + await flushPromises(); + await flushPromises(); + }); + + expect(sdkMocks.workspaceAcpPreheat).toHaveBeenCalledWith(5000); + expect(sdkMocks.workspaceSkills).toHaveBeenCalledTimes(2); + expect(connection?.skills).toEqual(['review', 'pdf']); + }); + + it('clears deferred skills when ACP refresh returns an empty list', async () => { + sdkMocks.workspaceAcpStatus.mockResolvedValue({ channelLive: false }); + sdkMocks.workspaceSkills + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'bundled', + modelInvocable: true, + }, + ], + }) + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + skills: [], + }); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + await act(async () => { + await flushPromises(); + await flushPromises(); + }); + + expect(connection?.skills).toEqual([]); + expect(connection?.commands).toEqual([]); + }); + it('warns when deferred workspace providers fail', async () => { const error = new Error('providers unavailable'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -516,12 +638,14 @@ describe('DaemonSessionProvider', () => { }); // Skills failing must not block the deferred connect: providers still - // resolve and the connection reports connected, just without skill commands. + // resolve and the connection reports connected, without clearing any + // previous skill commands. expect(connection).toMatchObject({ status: 'connected', workspaceCwd: '/mock-workspace', }); - expect(connection).not.toHaveProperty('commands'); + expect(connection?.commands).toBeUndefined(); + expect(connection?.skills).toBeUndefined(); expect(warn).toHaveBeenCalledWith( '[DaemonSessionProvider] workspaceSkills failed in deferred connect:', error, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index bf99824312..4ce682c317 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -227,6 +227,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { workspaceCapabilitiesRef.current = workspace?.capabilities; const workspaceGetCapabilitiesRef = useRef(workspace?.getCapabilities); workspaceGetCapabilitiesRef.current = workspace?.getCapabilities; + const workspaceAcpPreheatInFlightRef = useRef(false); const initialRestoreSessionIdRef = useRef(sessionId); const initialRestoreSessionId = initialRestoreSessionIdRef.current; // Captured once at mount: if the host did not provide an initial session, @@ -451,10 +452,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // session-scoped supported-commands snapshot (which also carries // custom/MCP/workflow commands) still lands once the first prompt // creates a session. - const [providerResult, skillsResult] = await Promise.allSettled([ - client.workspaceProviders(), - client.workspaceSkills(), - ]); + const [providerResult, skillsResult, acpStatusResult] = + await Promise.allSettled([ + client.workspaceProviders(), + client.workspaceSkills(), + client.workspaceAcpStatus(), + ]); if (providerResult.status === 'rejected') { console.warn( '[DaemonSessionProvider] workspaceProviders failed in deferred connect:', @@ -467,6 +470,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { skillsResult.reason, ); } + if (acpStatusResult.status === 'rejected') { + console.warn( + '[DaemonSessionProvider] workspaceAcpStatus failed in deferred connect:', + acpStatusResult.reason, + ); + } const providers = providerResult.status === 'fulfilled' ? providerResult.value @@ -480,6 +489,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ? skillsResult.value : undefined, ); + const preserveClearedSessionCommands = + skillsResult.status === 'rejected' || + (manualSessionClearRef.current && + deferredSkillCommands.length === 0); setConnection((current) => ({ ...current, status: 'connected', @@ -490,13 +503,54 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { contextWindow: providerModelStatus.contextWindow, providers, capabilities: caps, - ...(deferredSkillCommands.length > 0 - ? { commands: deferredSkillCommands } - : {}), - ...(deferredSkills.length > 0 - ? { skills: deferredSkills } - : {}), + commands: preserveClearedSessionCommands + ? current.commands + : deferredSkillCommands, + skills: preserveClearedSessionCommands + ? current.skills + : deferredSkills, })); + if ( + acpStatusResult.status === 'fulfilled' && + !acpStatusResult.value.channelLive && + !workspaceAcpPreheatInFlightRef.current + ) { + workspaceAcpPreheatInFlightRef.current = true; + void (async () => { + try { + const preheat = await client.workspaceAcpPreheat(5_000); + if ( + disposed || + abort.signal.aborted || + !preheat.ready || + connectionRef.current.sessionId + ) { + return; + } + const refreshed = await client.workspaceSkills(); + if ( + disposed || + abort.signal.aborted || + connectionRef.current.sessionId + ) { + return; + } + const { commands, skills } = mapWorkspaceSkills(refreshed); + setConnection((current) => + current.sessionId + ? current + : { ...current, commands, skills }, + ); + } catch (error) { + console.warn( + '[DaemonSessionProvider] ACP preheat for workspace skills failed:', + error, + ); + } finally { + workspaceAcpPreheatInFlightRef.current = false; + } + })(); + } return; } const restoreMethod = diff --git a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx b/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx index 3ef34f8ec7..7124a2959b 100644 --- a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx +++ b/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx @@ -25,6 +25,8 @@ const sdkMocks = vi.hoisted(() => { const workspaceMcpResources = vi.fn(); const restartMcpServer = vi.fn(); const workspaceSkills = vi.fn(); + const workspaceAcpStatus = vi.fn(); + const workspaceAcpPreheat = vi.fn(); const workspaceTools = vi.fn(); const setWorkspaceToolEnabled = vi.fn(); const workspaceMemory = vi.fn(); @@ -49,6 +51,8 @@ const sdkMocks = vi.hoisted(() => { workspaceMcpResources = workspaceMcpResources; restartMcpServer = restartMcpServer; workspaceSkills = workspaceSkills; + workspaceAcpStatus = workspaceAcpStatus; + workspaceAcpPreheat = workspaceAcpPreheat; workspaceTools = workspaceTools; setWorkspaceToolEnabled = setWorkspaceToolEnabled; workspaceMemory = workspaceMemory; @@ -74,6 +78,8 @@ const sdkMocks = vi.hoisted(() => { workspaceMcpResources, restartMcpServer, workspaceSkills, + workspaceAcpStatus, + workspaceAcpPreheat, workspaceTools, setWorkspaceToolEnabled, workspaceMemory, @@ -122,6 +128,14 @@ const sdkMocks = vi.hoisted(() => { initialized: true, skills: [], }); + workspaceAcpStatus.mockReset(); + workspaceAcpStatus.mockResolvedValue({ channelLive: true }); + workspaceAcpPreheat.mockReset(); + workspaceAcpPreheat.mockResolvedValue({ + ready: true, + channelLive: true, + durationMs: 1, + }); workspaceTools.mockReset(); workspaceTools.mockResolvedValue({ v: 1,