mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
5c82857fea
commit
e64010c116
31 changed files with 1611 additions and 59 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
129
packages/cli/src/acp-integration/extension-skills.test.ts
Normal file
129
packages/cli/src/acp-integration/extension-skills.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/acp-integration/extension-skills.ts
Normal file
48
packages/cli/src/acp-integration/extension-skills.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -488,6 +488,8 @@ export type {
|
|||
DaemonWorkspaceMemoryTaskStatus,
|
||||
DaemonWorkspaceMemoryTopic,
|
||||
DaemonWorkspacePreflightStatus,
|
||||
DaemonWorkspaceAcpStatusResult,
|
||||
DaemonWorkspaceAcpPreheatResult,
|
||||
DaemonWorkspaceProviderCurrent,
|
||||
DaemonWorkspaceProviderModel,
|
||||
DaemonWorkspaceProviderStatus,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -199,6 +199,8 @@ export {
|
|||
type DaemonFollowupSuggestionEvent,
|
||||
type DaemonWorkspaceMcpServerStatus,
|
||||
type DaemonWorkspaceMcpStatus,
|
||||
type DaemonWorkspaceAcpStatusResult,
|
||||
type DaemonWorkspaceAcpPreheatResult,
|
||||
type DaemonWorkspaceProviderCurrent,
|
||||
type DaemonWorkspaceProviderModel,
|
||||
type DaemonWorkspaceProviderStatus,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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={() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue