Fix workspace skills for disabled extensions and ACP preheat (#6534)

* fix(cli): keep workspace skills in sync with extensions

* fix(cli): address workspace skills review feedback

* test(cli): cover synthesized inactive extension skills

* fix(cli): address workspace skills review issues

* fix(cli): address workspace skills review followups

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-07-09 17:11:17 +08:00 committed by GitHub
parent 5c82857fea
commit e64010c116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1611 additions and 59 deletions

View file

@ -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();

View file

@ -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) {

View file

@ -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['getExtensions']>,
): Config {
return {
getExtensions: () => extensions,
} as unknown as Config;
}
type TestExtension = ReturnType<Config['getExtensions']>[number];
function extension(
fields: Pick<TestExtension, 'isActive' | 'name'> &
Partial<Omit<TestExtension, 'isActive' | 'name'>>,
): 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<SkillConfig, 'extensionName' | 'level' | 'name'> {
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);
});
});

View file

@ -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<string> {
const refs = new Set<string>();
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<string> {
const names = new Set<string>();
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<SkillConfig, 'extensionName' | 'level' | 'name'>,
inactiveSkillRefs: Set<string>,
): boolean {
return (
skill.level === 'extension' &&
skill.extensionName !== undefined &&
inactiveSkillRefs.has(extensionSkillRef(skill.extensionName, skill.name))
);
}

View file

@ -469,6 +469,7 @@ describe('Session', () => {
getMonitorRegistry: vi.fn().mockReturnValue(mockMonitorRegistry),
getFileHistoryService: vi.fn().mockReturnValue(mockFileHistoryService),
getDisabledSkillNames: vi.fn().mockReturnValue(new Set<string>()),
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'),

View file

@ -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,

View file

@ -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;

View file

@ -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());

View file

@ -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,

View file

@ -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'] });

View file

@ -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,
});

View file

@ -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<void>(() => 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({

View file

@ -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<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
return await new Promise<T>((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<void> | undefined;
// -- Facade --
return {
@ -256,6 +286,75 @@ export function createDaemonWorkspaceService(
);
},
async preheatAcpChild(
_ctx: WorkspaceRequestContext,
opts?: { timeoutMs?: number },
): Promise<WorkspaceAcpPreheatResult> {
const startedAt = Date.now();
const channelLive = () => isChannelLive?.() ?? false;
const finish = (
result: Omit<WorkspaceAcpPreheatResult, 'durationMs'>,
): 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<WorkspaceAcpStatusResult> {
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;
}
},
};

View file

@ -149,6 +149,17 @@ export interface DaemonWorkspaceService {
ctx: WorkspaceRequestContext,
): Promise<QwenPermissionSettings>;
/** Start the ACP child/channel without creating a user-visible session. */
preheatAcpChild(
ctx: WorkspaceRequestContext,
opts?: { timeoutMs?: number },
): Promise<WorkspaceAcpPreheatResult>;
/** Current ACP child/channel liveness for the bound workspace. */
getWorkspaceAcpStatus(
ctx: WorkspaceRequestContext,
): Promise<WorkspaceAcpStatusResult>;
/** 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<ReloadResponse>;
/** 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<EnvReloadResult>;
/** Eagerly start the ACP child/channel without creating a session. */
preheatAcpChild?: () => Promise<void>;
/**
* Query workspace status from the ACP child. The bridge owns the
* child lifecycle; this callback abstracts that dependency.

View file

@ -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(

View file

@ -17,12 +17,13 @@ import type { ServeWorkspaceSkillStatus } from '@qwen-code/acp-bridge/status';
export function mapSkillConfigToStatus(
skill: SkillConfig,
disabledSkillNames: ReadonlySet<string> = 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,

View file

@ -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<SlashCommandActionReturn> => {
// Auto-approve the skill's declared allowedTools before its body is submitted.

View file

@ -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.

View file

@ -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

View file

@ -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<DaemonWorkspaceAcpPreheatResult> {
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<DaemonWorkspaceAcpStatusResult> {
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<DaemonWorkspaceProvidersStatus> {
return await this.fetchWithTimeout(
`${this.baseUrl}/workspace/providers`,

View file

@ -488,6 +488,8 @@ export type {
DaemonWorkspaceMemoryTaskStatus,
DaemonWorkspaceMemoryTopic,
DaemonWorkspacePreflightStatus,
DaemonWorkspaceAcpStatusResult,
DaemonWorkspaceAcpPreheatResult,
DaemonWorkspaceProviderCurrent,
DaemonWorkspaceProviderModel,
DaemonWorkspaceProviderStatus,

View file

@ -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;

View file

@ -199,6 +199,8 @@ export {
type DaemonFollowupSuggestionEvent,
type DaemonWorkspaceMcpServerStatus,
type DaemonWorkspaceMcpStatus,
type DaemonWorkspaceAcpStatusResult,
type DaemonWorkspaceAcpPreheatResult,
type DaemonWorkspaceProviderCurrent,
type DaemonWorkspaceProviderModel,
type DaemonWorkspaceProviderStatus,

View file

@ -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<Response>((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,

View file

@ -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) => {

View file

@ -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([

View file

@ -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(
<I18nProvider language="en">
<ExtensionsDialog />
</I18nProvider>,
);
});
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',
);
});
});

View file

@ -151,9 +151,13 @@ export function ExtensionsDialog() {
}, [load, signals?.extensionsVersion]);
const runMutation = useCallback(
(name: string, run: (clientId: string) => Promise<unknown>) => {
(
name: string,
run: (clientId?: string) => Promise<unknown>,
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={() =>

View file

@ -100,6 +100,8 @@ interface MockClient {
workspaceMcpTools: () => Promise<unknown>;
restartMcpServer: () => Promise<unknown>;
workspaceSkills: () => Promise<unknown>;
workspaceAcpStatus: () => Promise<unknown>;
workspaceAcpPreheat: () => Promise<unknown>;
workspaceTools: () => Promise<unknown>;
setWorkspaceToolEnabled: () => Promise<unknown>;
workspaceMemory: () => Promise<unknown>;
@ -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(<Harness />, {
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(<Harness />, {
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,

View file

@ -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 =

View file

@ -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,