diff --git a/docs/design/daemon-skill-toggle.md b/docs/design/daemon-skill-toggle.md new file mode 100644 index 00000000000..760bb26cb95 --- /dev/null +++ b/docs/design/daemon-skill-toggle.md @@ -0,0 +1,66 @@ +# Daemon Skill Toggle + +## Goal + +Expose the CLI `/skills` panel's workspace enable/disable behavior through daemon REST and the TypeScript SDK, including immediate refresh of active ACP sessions. + +## Public contract + +- `POST /workspace/skills/:name/enable` +- `POST /workspaces/:workspace/skills/:name/enable` +- Request body: `{ "enabled": boolean }` +- SDK: `DaemonClient.setWorkspaceSkillEnabled` and `WorkspaceDaemonClient.setWorkspaceSkillEnabled` +- Capability: `workspace_skill_toggle` + +The response contains the canonical skill name, requested state, whether persistence changed, activation state, and session refresh counts. `applied` means every active session refreshed, `deferred` means no ACP child was running, and `partial` means at least one session failed to refresh after persistence committed. + +## Semantics + +The API changes only workspace `skills.disabled`. Skill lookup is case-insensitive, but the canonical discovered name is persisted. Updating one target removes target duplicates and case variants without deleting orphan entries for unavailable skills. A second identical request is a no-op. + +The route rejects states the CLI panel cannot toggle: + +- unknown skill: `404 skill_not_found`; +- `userInvocable === false`: `409 skill_not_toggleable`; +- skill from an inactive extension: `409 skill_not_toggleable`; +- disabled in system defaults, user, or system scope: `409 skill_not_toggleable` with the locking scope; +- untrusted workspace: `403 untrusted_workspace`. + +The scope lock check and workspace read-modify-write happen inside the daemon's per-workspace settings lock. A failed write stops before refresh and event publication. + +## `skills.disabled` versus `disable-model-invocation` + +`skills.disabled` is an operator setting merged as a case-insensitive union across scopes. It removes matching skill slash commands and model-visible skill entries, and execution-time validation rejects the skill. The daemon endpoint writes the workspace member of this union. + +`disable-model-invocation` is SKILL.md metadata. It hides a skill from model invocation while preserving direct user invocation. The existing managed-skill ACP operation edits that metadata and is intentionally not reused by this API. + +## Activation flow + +1. Resolve the canonical, toggleable skill from the workspace status snapshot. +2. Under the workspace settings lock, re-read every scope, reject higher-scope locks, and commit the canonical workspace list. +3. Invalidate the daemon's cached skill status. +4. If an ACP child is live, invoke `qwen/control/workspace/skills/refresh`. +5. The child reloads workspace-scope settings and refreshes every active session, including busy sessions. +6. Each session reloads its own workspace settings, rebuilds and pushes `available_commands_update`, and notifies SkillManager consumers. +7. Publish the existing workspace `settings_changed` event for `skills.disabled`. + +An in-flight model request cannot be rewritten. Subsequent skill execution checks, command snapshots, and model contexts read the new state. + +## Downstream consumers + +- Settings merge: system defaults, user, workspace, and system `skills.disabled` form the effective disabled-name set. +- Workspace status: ACP and daemon-local skill mapping expose disabled state and false-only `userInvocable`. +- Slash commands: available-command construction removes disabled skills and sends updated command metadata to daemon clients. +- Model context: SkillManager change listeners refresh the Skill tool description and available-skill context. +- Execution validation: the Skill tool re-reads the disabled-name provider before invocation, so later calls are rejected immediately. +- Extension state: inactive extension skills remain non-toggleable even when they are not disabled by settings. +- Daemon cache: the cached live-child skill snapshot is invalidated after persistence so later GET requests cannot replay stale state. +- SDK consumers: both primary-workspace and workspace-qualified clients share the response and error contract. +- Events: existing `settings_changed` consumers observe the committed `skills.disabled` value; there is no new event type. + +## Failure behavior + +- Persistence failure: the HTTP request fails; no ACP refresh and no event. +- No child: persistence succeeds with `deferred`; the next child loads the setting at startup. +- Per-session refresh failure: persistence remains committed; successful sessions stay refreshed and the response is `partial`. +- Child transport race: if the child disappears after the liveness check, the response is `deferred`; other refresh failures are reported as `partial`. diff --git a/docs/developers/daemon/13-sdk-daemon-client.md b/docs/developers/daemon/13-sdk-daemon-client.md index 0a777ea1cc8..cb9b55f72db 100644 --- a/docs/developers/daemon/13-sdk-daemon-client.md +++ b/docs/developers/daemon/13-sdk-daemon-client.md @@ -44,17 +44,17 @@ new DaemonClient({ Method groups (every method takes an optional `clientId` to stamp `X-Qwen-Client-Id`): -| Group | Methods | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Plumbing | `health()`, `capabilities()`, `auth` (lazy `DaemonAuthFlow` accessor) | -| Sessions | `createOrAttachSession`, `loadSession`, `resumeSession`, `listSessions`, `closeSession`, `setSessionMetadata`, `getSessionContext`, `getSessionSupportedCommands`, `setSessionApprovalMode`, `setSessionModel` | -| Prompting | `prompt`, `cancel`, `heartbeat` | -| Events | `subscribeEvents` (SSE generator), `subscribeEventsStream` (raw response) | -| Permissions | `respondToPermission`, `respondToSessionPermission` | -| Workspace snapshots | `getWorkspaceMcp`, `getWorkspaceSkills`, `getWorkspaceProviders`, `getWorkspaceEnv`, `getWorkspacePreflight` | -| Workspace mutations | `writeWorkspaceMemory`, `readWorkspaceMemory`, `rememberWorkspaceMemory`, `getWorkspaceMemoryRememberTask`, `forgetWorkspaceMemory`, `getWorkspaceMemoryForgetTask`, `dreamWorkspaceMemory`, `getWorkspaceMemoryDreamTask`, `listWorkspaceAgents`, `getWorkspaceAgent`, `createWorkspaceAgent`, `updateWorkspaceAgent`, `deleteWorkspaceAgent`, `toggleWorkspaceTool`, `restartMcpServer`, `initializeWorkspace` | -| Files | `readFile`, `readFileBytes`, `writeFile`, `editFile`, `listDirectory`, `globPaths`, `statPath` | -| Auth | `startDeviceFlow`, `pollDeviceFlow`, `cancelDeviceFlow`, `getAuthStatus` | +| Group | Methods | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Plumbing | `health()`, `capabilities()`, `auth` (lazy `DaemonAuthFlow` accessor) | +| Sessions | `createOrAttachSession`, `loadSession`, `resumeSession`, `listSessions`, `closeSession`, `setSessionMetadata`, `getSessionContext`, `getSessionSupportedCommands`, `setSessionApprovalMode`, `setSessionModel` | +| Prompting | `prompt`, `cancel`, `heartbeat` | +| Events | `subscribeEvents` (SSE generator), `subscribeEventsStream` (raw response) | +| Permissions | `respondToPermission`, `respondToSessionPermission` | +| Workspace snapshots | `getWorkspaceMcp`, `getWorkspaceSkills`, `getWorkspaceProviders`, `getWorkspaceEnv`, `getWorkspacePreflight` | +| Workspace mutations | `writeWorkspaceMemory`, `readWorkspaceMemory`, `rememberWorkspaceMemory`, `getWorkspaceMemoryRememberTask`, `forgetWorkspaceMemory`, `getWorkspaceMemoryForgetTask`, `dreamWorkspaceMemory`, `getWorkspaceMemoryDreamTask`, `listWorkspaceAgents`, `getWorkspaceAgent`, `createWorkspaceAgent`, `updateWorkspaceAgent`, `deleteWorkspaceAgent`, `setWorkspaceToolEnabled`, `setWorkspaceSkillEnabled`, `restartMcpServer`, `initWorkspace` | +| Files | `readFile`, `readFileBytes`, `writeFile`, `editFile`, `listDirectory`, `globPaths`, `statPath` | +| Auth | `startDeviceFlow`, `pollDeviceFlow`, `cancelDeviceFlow`, `getAuthStatus` | ### `fetchWithTimeout` @@ -141,6 +141,19 @@ await client.dreamWorkspaceMemory(); await client.getWorkspaceMemoryDreamTask('dream-...'); ``` +Workspace skill toggles are available on both client shapes: + +```ts +await client.setWorkspaceSkillEnabled('review', false, { + clientId: 'dashboard-1', +}); +await client + .workspaceByCwd('/work/secondary') + .setWorkspaceSkillEnabled('review', true, { clientId: 'dashboard-1' }); +``` + +Pre-flight `capabilities.features.includes('workspace_skill_toggle')`. The typed `DaemonSkillToggleResult` reports the canonical `skillName`, whether disk state `changed`, activation state (`applied`, `deferred`, or `partial`), and refreshed/failed session counts. `DaemonWorkspaceSkillStatus.userInvocable` is an optional false-only field; absence means the skill is user-invocable. + ## Workflow ### Create-or-attach + first prompt diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index f142c471af0..c472099ceb5 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -178,7 +178,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_mcp_manage', 'mcp_guardrail_events', 'mcp_server_runtime_mutation', 'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write', - 'session_approval_mode_control', 'workspace_tool_toggle', + 'session_approval_mode_control', 'workspace_tool_toggle', 'workspace_skill_toggle', 'workspace_settings', 'workspace_init', 'workspace_mcp_restart', 'session_recap', 'session_btw', 'session_shell_command', 'mcp_workspace_pool', 'mcp_pool_restart', @@ -219,13 +219,13 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived. -`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. On single-workspace daemons, `workspaces[]` is absent unless `multi_workspace_sessions` is also advertised, so clients use `capabilities.workspaceCwd` as the cwd selector. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool toggle, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy. +`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. On single-workspace daemons, `workspaces[]` is absent unless `multi_workspace_sessions` is also advertised, so clients use `capabilities.workspaceCwd` as the cwd selector. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy. `session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. `session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id. In addition to `clientCount` and `hasActivePrompt`, live sessions expose `isWaitingForPermission`, `isWaitingForUserQuestion`, `pendingInteractionCount`, and a retained `turnError` after a failed turn. The error clears when the next prompt actually starts. Both the single-session status response and workspace session lists include `turnError` and `pendingInteractions`: render-ready permission actions or `ask_user_question` questions plus the `requestId` and selectable options required by the existing permission vote routes. Each user question has an `answerKey`; vote with `answers`, for example `{ "0": "Polling" }`, keyed by that value. Persisted-only sessions omit runtime state because no runtime exists. Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list. -`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_init`, and `workspace_mcp_restart` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) advertise the four mutation control routes documented under "Mutation: approval, tools, init, MCP restart" below. All four are strict-gated by the PR 15 mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance. +`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_toggle`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. They are strict-gated by the mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance. `mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention. @@ -851,6 +851,7 @@ Recommended poll cadence: aligned with whatever already polls `/workspace/mcp`; "description": "Review code", "level": "project", "modelInvocable": true, + "userInvocable": false, "installedPath": "/home/alice/project/.qwen/skills/review/SKILL.md", "argumentHint": "[path]" } @@ -859,6 +860,10 @@ Recommended poll cadence: aligned with whatever already polls `/workspace/mcp`; ``` `level` is one of `project`, `user`, `extension`, or `bundled`. +`userInvocable` (boolean, optional) is omitted for normal skills (meaning +`true`) and is present only as `false` when the skill cannot be invoked manually +or toggled through the skill API. `modelInvocable` is independent: `false` +means the skill remains manually available but is hidden from model invocation. `installedPath` is the existing absolute path to the skill's `SKILL.md`; the daemon returns it as stored without separately resolving symlinks or canonicalizing it. Current daemons emit it for every skill, while clients must @@ -1983,15 +1988,15 @@ Errors: Cancellation: **none in v1**. The route does not listen for HTTP client disconnect, no `AbortSignal` is plumbed into the bridge, and the ACP child runs the side-query to completion regardless of whether the caller has disconnected. The only ceilings are the bridge's 60s backstop timeout (`SESSION_RECAP_TIMEOUT_MS`) and the transport-closed race against ACP channel death. This is acceptable because recap is short (single-attempt, `maxOutputTokens: 300`, ~1–5s typical); a request-id-based cancel ext-method can plumb full end-to-end cancellation in a future release if the bandwidth cost ever justifies it. -### Mutation: approval, tools, init, MCP restart +### Mutation: approval, tools, skills, init, MCP restart -Issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) Wave 4 PR 17 adds four mutation control routes that let remote clients change runtime posture without touching the daemon host's CLI. All four: +The daemon exposes five mutation control routes that let remote clients change runtime posture without touching the daemon host's CLI. All five: - Are gated by the **strict** mutation gate from PR 15. A daemon configured without a bearer token rejects them with `401 {code: 'token_required'}`. Configure `--token` (or `QWEN_SERVER_TOKEN`) before opting in. - Accept and stamp the `X-Qwen-Client-Id` header (PR 7 audit chain). When the header carries a trusted id, the daemon emits `originatorClientId` on the corresponding SSE event so cross-client UIs can suppress echoes of their own mutations. - Pre-flight each per-tag capability before exposing the affordance. Older daemons return `404` for the route. -Three of the four routes (`tools/:name/enable`, `init`, `mcp/:server/restart`) emit **workspace-scoped** events: every active session SSE bus receives the event, regardless of which session was attached when the mutation was triggered. `approval-mode` emits a **session-scoped** event because the change is local to one session's `Config`. +The tool toggle, skill toggle, init, and MCP restart routes emit **workspace-scoped** events: every active session SSE bus receives the event, regardless of which session was attached when the mutation was triggered. `approval-mode` emits a **session-scoped** event because the change is local to one session's `Config`. #### `POST /session/:id/approval-mode` @@ -2058,6 +2063,45 @@ Errors: SSE event (workspace-scoped): `tool_toggled` with `{toolName, enabled, originatorClientId?}`. +#### `POST /workspace/skills/:name/enable` + +Capability tag: `workspace_skill_toggle`. The workspace-qualified form is `POST /workspaces/:workspace/skills/:name/enable`. + +Toggle a loaded, user-invocable skill through the workspace `skills.disabled` list, matching the CLI `/skills` panel's Space-key behavior. Lookup is case-insensitive, while persistence and the response use the skill's canonical name. Existing disabled entries for skills that are no longer loaded are preserved, and duplicate/case-variant entries for the target are collapsed. A disable entry inherited from system defaults, user, or system scope locks the skill: workspace scope cannot override the merged union. + +This is different from the ACP `qwen/skills/setEnabled` managed-skill operation and the `disable-model-invocation` frontmatter field. `skills.disabled` removes the skill from slash-command/model availability and rejects later skill execution. `disable-model-invocation: true` keeps direct user invocation available and only hides the skill from model invocation. + +Request: + +```json +{ "enabled": false } +``` + +Response (200): + +```json +{ + "skillName": "review", + "enabled": false, + "changed": true, + "activation": "applied", + "sessionsRefreshed": 2, + "sessionsFailed": 0 +} +``` + +`activation` is `applied` when every active session refreshed, `deferred` when no ACP child exists (the persisted setting is used when one starts), and `partial` when at least one active session failed to refresh. Busy sessions are included. The daemon reloads workspace settings for the ACP child and every active session, notifies SkillManager consumers, and pushes `available_commands_update`. A request already sent to the model is not rewritten; subsequent validation, command snapshots, and model contexts use the new state. If persistence fails, no refresh or event is emitted. If a session refresh fails, the committed setting is retained. When the child returns per-session results, the session counts are exact. If the refresh control itself fails before returning those results, `sessionsFailed: 1` is a conservative lower bound indicating that the refresh request failed. + +Errors: + +- `400 {code: 'invalid_skill_name'}` — empty path parameter, or more than 256 characters. +- `400 {code: 'invalid_enabled_flag'}` — `enabled` missing or non-boolean. +- `403 {code: 'untrusted_workspace'}` — the selected workspace is not trusted. +- `404 {code: 'skill_not_found'}` — no loaded skill matches the name. +- `409 {code: 'skill_not_toggleable', reason: 'not_user_invocable' | 'inactive_extension' | 'locked', lockedScope?: 'system' | 'user' | 'systemDefaults'}` — the CLI panel would not allow the target to be toggled. `lockedScope` is present only when `reason` is `locked`. + +The mutation reuses the workspace-scoped `settings_changed` event with `key: 'skills.disabled'`; it does not add a new event type. + #### `POST /workspace/init` Capability tag: `workspace_init`. Pure file IO — no ACP roundtrip, **no LLM invocation**. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index fc19433379d..9da50ebb310 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -17,7 +17,7 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs, - **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins. - **One daemon, one or more workspaces** — repeat `--workspace` to register isolated workspace runtimes under one listener. The first workspace is primary and remains the default for requests that omit `cwd`. - **Experimental daemon-managed channels** — start with `qwen serve --channel `, or start without a channel and select one later with `qwen channel set`. Workers are separate processes owned by the daemon lifecycle. Their selection can be queried, replaced, reloaded, and stopped without restarting the daemon. -- **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`), or add/remove MCP servers at runtime without a daemon restart (`POST /workspace/mcp/servers`, `DELETE /workspace/mcp/servers/:name`). All strict-gated — configure `--token` first. +- **Remote runtime control** — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool (`POST /workspace/tools/:name/enable`) or loaded skill (`POST /workspace/skills/:name/enable`) per workspace, scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`), or add/remove MCP servers at runtime without a daemon restart (`POST /workspace/mcp/servers`, `DELETE /workspace/mcp/servers/:name`). All strict-gated — configure `--token` first. - **Session recap** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) follow-up) — fetch a one-sentence "where did I leave off" summary of an active session (`POST /session/:id/recap`). Wraps core's `generateSessionRecap` as a side-query against the fast model; pollutes neither the main chat history nor the SSE stream. Non-strict gate (same posture as `/prompt`); SDK helper `client.recapSession(sessionId)`. - **Known limit — token-cost amplification:** the route is a pure-cost endpoint (each call is an LLM side-query, no state benefit) and the daemon has no per-route rate limit in v1. On a no-token loopback default a buggy or malicious local client can spam it to burn tokens. Configure `--token` (and optionally `--require-auth`) on shared dev hosts before exposing the daemon. - **Concurrent recap safety:** two simultaneous `/recap` calls on the same session run two independent side-queries. `generateSessionRecap` reads a snapshot of the chat history via `GeminiClient.getChat().getHistory()` and feeds it to a separate `BaseLlmClient.generateText` call (via `runSideQuery`); it never appends to or mutates the session's `GeminiChat`. Safe to call from multiple clients without coordination. @@ -166,6 +166,8 @@ idle daemon returns `initialized: false` with an empty snapshot. Once a session is alive they switch to `initialized: true` and surface the real state. +To mirror the CLI `/skills` panel remotely, call `POST /workspace/skills/:name/enable` with `{ "enabled": true | false }` after checking the `workspace_skill_toggle` capability. The route writes only workspace `skills.disabled`, rejects unknown, hidden, inactive-extension, higher-scope-locked, and untrusted targets, and immediately refreshes active ACP sessions. A `deferred` response means the setting was saved while no ACP child was running; it will apply when the child starts. `skills.disabled` disables both manual and model use, unlike `disable-model-invocation: true`, which keeps direct `/skill-name` invocation available. + `GET /workspace/env` and `GET /workspace/preflight` always answer with `initialized: true` regardless of ACP state. `env` never consults ACP (daemon-process info only); `preflight` answers daemon-level cells from diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 6f0be47757f..c1d22742332 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -352,6 +352,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_file_write', 'session_approval_mode_control', 'workspace_tool_toggle', + 'workspace_skill_toggle', 'workspace_settings', 'workspace_permissions', 'workspace_voice', diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index af57ab209a4..643d4989d47 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -155,6 +155,7 @@ export const SERVE_CONTROL_EXT_METHODS = { workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', workspaceReload: 'qwen/control/workspace/reload', + workspaceSkillsRefresh: 'qwen/control/workspace/skills/refresh', workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh', /** * Reverse tool channel (issue #5626, Phase 2). Unlike every other entry @@ -416,12 +417,18 @@ export interface ServeWorkspaceSkillStatus extends ServeStatusCell { description: string; level: ServeSkillLevel; modelInvocable: boolean; + userInvocable?: false; installedPath?: string; argumentHint?: string; model?: string; extensionName?: string; } +export interface ServeWorkspaceSkillsRefreshResult { + sessionsRefreshed: number; + sessionsFailed: number; +} + export interface ServeWorkspaceSkillsStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index fc1267e6940..706892034da 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11345,6 +11345,71 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); + it('refreshes busy skill sessions and reports per-session failures', async () => { + const bootstrapSettings = { + merged: {}, + reloadScopeFromDisk: vi.fn(), + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + const cfg1 = makeConfig({ + getSessionId: vi.fn().mockReturnValue('skill-1'), + }); + const cfg2 = makeConfig({ + getSessionId: vi.fn().mockReturnValue('skill-2'), + }); + const refresh1 = vi.fn().mockResolvedValue(undefined); + const refresh2 = vi.fn().mockRejectedValue(new Error('client closed')); + + vi.mocked(loadSettings).mockReturnValue(bootstrapSettings); + vi.mocked(loadCliConfig) + .mockResolvedValueOnce(cfg1 as unknown as Config) + .mockResolvedValueOnce(cfg2 as unknown as Config); + vi.mocked(Session).mockImplementation( + (id) => + ({ + getId: vi.fn().mockReturnValue(id), + getConfig: vi.fn().mockReturnValue(id === 'skill-1' ? cfg1 : cfg2), + isIdle: vi.fn().mockReturnValue(false), + refreshSkillsFromSettings: id === 'skill-1' ? refresh1 : refresh2, + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + bootstrapSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/skills', mcpServers: [] }); + await agent.newSession({ cwd: '/skills', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {}), + ).resolves.toEqual({ sessionsRefreshed: 1, sessionsFailed: 1 }); + + expect(bootstrapSettings.reloadScopeFromDisk).toHaveBeenCalledWith( + SettingScope.Workspace, + ); + expect(refresh1).toHaveBeenCalledOnce(); + expect(refresh2).toHaveBeenCalledOnce(); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'Session skill-2 skill refresh failed: Error: client closed', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('refreshes extension commands for the live session', async () => { const extensionManager = { refreshCache: vi.fn().mockResolvedValue(undefined), diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 21024f108e4..a15a76e62e0 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -8355,6 +8355,29 @@ class QwenAgent implements Agent { sessionsSkipped: skipped, }; } + case SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh: { + this.settings.reloadScopeFromDisk(SettingScope.Workspace); + const sessions = this.getActiveSessions(); + const results = await Promise.allSettled( + sessions.map((session) => session.refreshSkillsFromSettings()), + ); + for (let i = 0; i < results.length; i++) { + if (results[i]!.status === 'rejected') { + const reason = (results[i] as PromiseRejectedResult).reason; + debugLogger.warn( + `Session ${sessions[i]!.getId()} skill refresh failed: ${reason}`, + ); + } + } + return { + sessionsRefreshed: results.filter( + (result) => result.status === 'fulfilled', + ).length, + sessionsFailed: results.filter( + (result) => result.status === 'rejected', + ).length, + }; + } default: throw RequestError.methodNotFound(method); } diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index f77b740ac42..2bad043012b 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -518,6 +518,7 @@ describe('Session', () => { user: { settings: {} }, workspace: { settings: {} }, setValue: vi.fn(), + reloadScopeFromDisk: vi.fn(), } as unknown as LoadedSettings; getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands) @@ -1939,6 +1940,74 @@ describe('Session', () => { ).resolves.toBeUndefined(); expect(mockClient.sessionUpdate).not.toHaveBeenCalled(); }); + + it('refreshes workspace skill settings, commands, and SkillManager consumers', async () => { + const suppressNextSlashReload = vi.fn(); + const notifyConfigChanged = vi.fn().mockResolvedValue(undefined); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([]), + suppressNextSlashReload, + notifyConfigChanged, + }); + + await session.refreshSkillsFromSettings(); + + expect(mockSettings.reloadScopeFromDisk).toHaveBeenCalledWith( + SettingScope.Workspace, + ); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + }), + }), + ); + expect(suppressNextSlashReload).toHaveBeenCalledTimes(1); + expect(notifyConfigChanged).toHaveBeenCalledTimes(1); + }); + + it('notifies SkillManager when the command update fails', async () => { + const suppressNextSlashReload = vi.fn(); + const notifyConfigChanged = vi.fn().mockResolvedValue(undefined); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([]), + suppressNextSlashReload, + notifyConfigChanged, + }); + vi.mocked(mockClient.sessionUpdate).mockRejectedValueOnce( + new Error('client update failed'), + ); + + await expect(session.refreshSkillsFromSettings()).rejects.toThrow( + 'client update failed', + ); + + expect(mockSettings.reloadScopeFromDisk).toHaveBeenCalledWith( + SettingScope.Workspace, + ); + expect(suppressNextSlashReload).toHaveBeenCalledTimes(1); + expect(notifyConfigChanged).toHaveBeenCalledTimes(1); + }); + + it('preserves the command update error when SkillManager notification also fails', async () => { + const notifyConfigChanged = vi + .fn() + .mockRejectedValue(new Error('notification failed')); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([]), + suppressNextSlashReload: vi.fn(), + notifyConfigChanged, + }); + vi.mocked(mockClient.sessionUpdate).mockRejectedValueOnce( + new Error('client update failed'), + ); + + await expect(session.refreshSkillsFromSettings()).rejects.toThrow( + 'client update failed', + ); + + expect(notifyConfigChanged).toHaveBeenCalledTimes(1); + }); }); describe('prompt', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d73c3c0c1aa..8db40a97e11 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -165,7 +165,7 @@ import type { SetSessionModelResponse, AgentSideConnection, } from '@agentclientprotocol/sdk'; -import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; import { z } from 'zod'; import { insertAfterFunctionResponses, @@ -3812,33 +3812,61 @@ export class Session implements SessionContext { async sendAvailableCommandsUpdate(): Promise { try { - const { availableCommands, availableSkills, availableSkillDetails } = - await buildAvailableCommandsSnapshot( - this.config, - undefined, - this.settings, - ); - - const update: SessionUpdate = { - sessionUpdate: 'available_commands_update', - availableCommands, - ...(availableSkills !== undefined - ? { - _meta: { - availableSkills, - ...(availableSkillDetails ? { availableSkillDetails } : {}), - }, - } - : {}), - }; - - await this.sendUpdate(update); + await this.sendAvailableCommandsUpdateOrThrow(); } catch (error) { // Log error but don't fail session creation debugLogger.error('Error sending available commands update:', error); } } + async refreshSkillsFromSettings(): Promise { + this.settings.reloadScopeFromDisk(SettingScope.Workspace); + const skillManager = this.config.getSkillManager(); + let updateFailed = false; + let updateError: unknown; + try { + await this.sendAvailableCommandsUpdateOrThrow(); + } catch (error) { + updateFailed = true; + updateError = error; + } + if (skillManager) { + try { + skillManager.suppressNextSlashReload(); + await skillManager.notifyConfigChanged(); + } catch (error) { + if (!updateFailed) throw error; + debugLogger.error( + 'SkillManager refresh failed after command update failure:', + error, + ); + } + } + if (updateFailed) throw updateError; + } + + private async sendAvailableCommandsUpdateOrThrow(): Promise { + const { availableCommands, availableSkills, availableSkillDetails } = + await buildAvailableCommandsSnapshot( + this.config, + undefined, + this.settings, + ); + const update: SessionUpdate = { + sessionUpdate: 'available_commands_update', + availableCommands, + ...(availableSkills !== undefined + ? { + _meta: { + availableSkills, + ...(availableSkillDetails ? { availableSkillDetails } : {}), + }, + } + : {}), + }; + await this.sendUpdate(update); + } + /** * Requests permission from the client for a tool call. * Used by SubAgentTracker for sub-agent approval requests. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 7579e6442a4..fbeab4b8db7 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -143,6 +143,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // unregistered — the toggle takes effect on the next ACP child spawn // (`tools.disabled` is consulted at `Config` construction time). workspace_tool_toggle: { since: 'v1' }, + workspace_skill_toggle: { since: 'v1' }, workspace_settings: { since: 'v1' }, // `GET /workspace/permissions` is always available when this tag is // advertised. `POST /workspace/permissions` updates the active ACP diff --git a/packages/cli/src/serve/daemon-status-provider.test.ts b/packages/cli/src/serve/daemon-status-provider.test.ts index daa0c85e956..f4aa15ec54a 100644 --- a/packages/cli/src/serve/daemon-status-provider.test.ts +++ b/packages/cli/src/serve/daemon-status-provider.test.ts @@ -52,6 +52,7 @@ function makeWorkspaceServiceWithProvider( statusProvider, isChannelLive: opts.isChannelLive ?? (() => false), persistDisabledTools: async () => {}, + persistDisabledSkills: async () => ({ changed: false, disabled: [] }), queryWorkspaceStatus: opts.queryWorkspaceStatus ?? noopQueryWorkspaceStatus, invokeWorkspaceCommand: async () => { throw new Error('not wired'); diff --git a/packages/cli/src/serve/routes/workspace-skills.ts b/packages/cli/src/serve/routes/workspace-skills.ts new file mode 100644 index 00000000000..9c615f8d8c9 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-skills.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, RequestHandler, Response } from 'express'; +import type { SendBridgeError } from '../server/error-response.js'; +import { + createBuildWorkspaceCtx, + MAX_SKILL_NAME_LENGTH, + parseAndValidateWorkspaceClientId, +} from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; + +interface RegisterWorkspaceSkillsRoutesDeps { + workspaceRuntime: WorkspaceRuntime; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; + sendBridgeError: SendBridgeError; + parseAndValidateClientId: ( + req: Request, + res: Response, + ) => string | undefined | null; +} + +function parseSkillToggleRequest( + req: Request, + res: Response, + safeBody: (req: Request) => Record, +): { skillName: string; enabled: boolean } | undefined { + const rawSkillName = req.params['name']; + if (!rawSkillName || typeof rawSkillName !== 'string') { + res.status(400).json({ + error: 'Skill name path parameter is required', + code: 'invalid_skill_name', + }); + return undefined; + } + const skillName = rawSkillName.trim(); + if (skillName.length === 0) { + res.status(400).json({ + error: 'Skill name path parameter is required', + code: 'invalid_skill_name', + }); + return undefined; + } + if (skillName.length > MAX_SKILL_NAME_LENGTH) { + res.status(400).json({ + error: `Skill name exceeds ${MAX_SKILL_NAME_LENGTH}-character limit`, + code: 'invalid_skill_name', + }); + return undefined; + } + const enabled = safeBody(req)['enabled']; + if (typeof enabled !== 'boolean') { + res.status(400).json({ + error: '`enabled` is required and must be a boolean', + code: 'invalid_enabled_flag', + }); + return undefined; + } + return { skillName, enabled }; +} + +export function registerWorkspaceSkillsRoutes( + app: Application, + deps: RegisterWorkspaceSkillsRoutesDeps, +): void { + const buildWorkspaceCtx = createBuildWorkspaceCtx( + deps.workspaceRuntime.workspaceCwd, + ); + const route = 'POST /workspace/skills/:name/enable'; + app.post( + '/workspace/skills/:name/enable', + deps.mutate({ strict: true }), + async (req, res) => { + if (!requireTrustedWorkspaceRuntime(deps.workspaceRuntime, res)) return; + const input = parseSkillToggleRequest(req, res, deps.safeBody); + if (!input) return; + const clientId = deps.parseAndValidateClientId(req, res); + if (clientId === null) return; + try { + const result = + await deps.workspaceRuntime.workspaceService.setWorkspaceSkillEnabled( + buildWorkspaceCtx(route, clientId), + input.skillName, + input.enabled, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); +} + +export function registerWorkspaceQualifiedSkillsRoutes( + app: Application, + deps: Pick< + RegisterWorkspaceSkillsRoutesDeps, + 'mutate' | 'safeBody' | 'sendBridgeError' + > & { workspaceRegistry: WorkspaceRegistry }, +): void { + const route = 'POST /workspaces/:workspace/skills/:name/enable'; + app.post( + '/workspaces/:workspace/skills/:name/enable', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const input = parseSkillToggleRequest(req, res, deps.safeBody); + if (!input) return; + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + try { + const result = await runtime.workspaceService.setWorkspaceSkillEnabled( + createBuildWorkspaceCtx(runtime.workspaceCwd)(route, clientId), + input.skillName, + input.enabled, + ); + res.status(200).json(result); + } catch (err) { + deps.sendBridgeError(res, err, { route }); + } + }, + ); +} diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index b17a90701f2..f818a2520e0 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -119,6 +119,98 @@ vi.mock('@qwen-code/acp-bridge/spawnChannel', async (importOriginal) => { }; }); +describe('workspace skill settings persistence', () => { + let handle: RunHandle | undefined; + let workspace = ''; + let qwenHome = ''; + let previousQwenHome: string | undefined; + + afterEach(async () => { + await handle?.close(); + if (workspace) fs.rmSync(workspace, { recursive: true, force: true }); + if (qwenHome) fs.rmSync(qwenHome, { recursive: true, force: true }); + if (previousQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = previousQwenHome; + settingsRuntime.resetHomeEnvBootstrapForTesting(); + vi.restoreAllMocks(); + }); + + it('canonicalizes, deduplicates, preserves orphans, serializes updates, and enforces user locks', async () => { + workspace = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-skill-settings-')), + ); + qwenHome = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-skill-home-')), + ); + previousQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = qwenHome; + settingsRuntime.resetHomeEnvBootstrapForTesting(); + fs.mkdirSync(path.join(workspace, '.qwen'), { recursive: true }); + fs.writeFileSync( + path.join(workspace, '.qwen', 'settings.json'), + JSON.stringify({ + skills: { disabled: ['orphan', ' ReViEw ', 'review'] }, + }), + ); + fs.writeFileSync( + path.join(qwenHome, 'settings.json'), + JSON.stringify({ skills: { disabled: ['locked-skill'] } }), + ); + + const originalCreateServeApp = serverModule.createServeApp; + let persistDisabledSkills: + | NonNullable< + Parameters[2] + >['persistDisabledSkills'] + | undefined; + vi.spyOn(serverModule, 'createServeApp').mockImplementation((...args) => { + persistDisabledSkills = args[2]?.persistDisabledSkills; + return originalCreateServeApp(...args); + }); + handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace, + serveWebShell: false, + }, + { bridge: makeRuntimeBridge() }, + ); + await handle.runtimeReady; + expect(persistDisabledSkills).toBeDefined(); + + await expect( + persistDisabledSkills!(workspace, 'review', false), + ).resolves.toEqual({ + changed: true, + disabled: ['orphan', 'review'], + }); + await expect( + persistDisabledSkills!(workspace, 'review', false), + ).resolves.toEqual({ + changed: false, + disabled: ['orphan', 'review'], + }); + + await Promise.all([ + persistDisabledSkills!(workspace, 'alpha', false), + persistDisabledSkills!(workspace, 'beta', false), + ]); + await expect( + persistDisabledSkills!(workspace, 'review', true), + ).resolves.toMatchObject({ changed: true }); + + const saved = JSON.parse( + fs.readFileSync(path.join(workspace, '.qwen', 'settings.json'), 'utf8'), + ) as { skills: { disabled: string[] } }; + expect(saved.skills.disabled).toEqual(['orphan', 'alpha', 'beta']); + await expect( + persistDisabledSkills!(workspace, 'locked-skill', true), + ).rejects.toMatchObject({ reason: 'locked', lockedScope: 'user' }); + }); +}); + /** * #4297 fold-in 7 (deepseek S1, addresses #3262690842). Lock the * `context.fileName` extraction logic so a regression doesn't diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index e06b454bf0e..a79ea437f82 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -897,6 +897,8 @@ async function loadServeRuntimeModules() { createDaemonWorkspaceService: workspaceModule.createDaemonWorkspaceService, WorkspaceSettingsPartialPersistError: workspaceTypesModule.WorkspaceSettingsPartialPersistError, + WorkspaceSkillNotToggleableError: + workspaceTypesModule.WorkspaceSkillNotToggleableError, createDaemonStatusProvider: daemonStatusProviderModule.createDaemonStatusProvider, createWorkspaceProvidersStatusProvider: @@ -2944,6 +2946,75 @@ export async function runQwenServe( [...next].sort(), ); }); + const persistDisabledSkillsFn = ( + workspace: string, + skillName: string, + enabled: boolean, + ) => + withSettingsLock(workspace, async () => { + const fresh = settingsRuntime.settings.loadSettings(workspace); + const normalizedName = skillName.trim().toLowerCase(); + const disabledNames = (value: unknown): string[] => + Array.isArray(value) + ? value.filter( + (entry): entry is string => typeof entry === 'string', + ) + : []; + const lockedScopes = [ + ['system', fresh.system.settings.skills?.disabled], + ['user', fresh.user.settings.skills?.disabled], + ['systemDefaults', fresh.systemDefaults.settings.skills?.disabled], + ] as const; + for (const [scope, names] of lockedScopes) { + if ( + disabledNames(names).some( + (name) => name.trim().toLowerCase() === normalizedName, + ) + ) { + throw new runtime.WorkspaceSkillNotToggleableError( + skillName, + 'locked', + scope, + ); + } + } + + const workspaceDisabled = disabledNames( + fresh.workspace.settings.skills?.disabled, + ); + const next: string[] = []; + let found = false; + let changed = false; + for (const name of workspaceDisabled) { + if (name.trim().toLowerCase() !== normalizedName) { + next.push(name); + continue; + } + if (enabled) { + changed = true; + continue; + } + if (!found) { + next.push(skillName); + found = true; + if (name !== skillName) changed = true; + } else { + changed = true; + } + } + if (!enabled && !found) { + next.push(skillName); + changed = true; + } + if (!changed) return { changed: false, disabled: workspaceDisabled }; + + fresh.setValue( + WORKSPACE_SETTING_SCOPE, + 'skills.disabled', + next.length > 0 ? next : undefined, + ); + return { changed: true, disabled: next }; + }); const persistSettingFn = ( workspace: string, scope: import('../config/settings.js').SettingScope, @@ -3072,6 +3143,7 @@ export async function runQwenServe( workspaceSkillsStatusProvider, isChannelLive: () => bridge.isChannelLive(), persistDisabledTools: persistDisabledToolsFn, + persistDisabledSkills: persistDisabledSkillsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, preheatAcpChild: () => bridge.preheat(), @@ -3379,6 +3451,7 @@ export async function runQwenServe( isChannelLive: () => secondaryBridge.isChannelLive(), preheatAcpChild: () => secondaryBridge.preheat(), persistDisabledTools: persistDisabledToolsFn, + persistDisabledSkills: persistDisabledSkillsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, reloadDaemonEnv: (workspace) => @@ -3747,6 +3820,7 @@ export async function runQwenServe( isChannelLive: () => wsBridge.isChannelLive(), preheatAcpChild: () => wsBridge.preheat(), persistDisabledTools: persistDisabledToolsFn, + persistDisabledSkills: persistDisabledSkillsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, reloadDaemonEnv: (workspace) => @@ -4041,6 +4115,7 @@ export async function runQwenServe( // so the WS provider and the child-answering bridge share one sender map. clientMcpSenderRegistry, persistDisabledTools: persistDisabledToolsFn, + persistDisabledSkills: persistDisabledSkillsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, sessionArtifactsPersistenceAvailable: diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 73dc76027e1..45e7b56d6d7 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -121,7 +121,10 @@ import { CAPABILITIES_SCHEMA_VERSION, type ServeOptions } from './types.js'; import type { DaemonLogger } from './daemon-logger.js'; import { FsError, type WorkspaceFileSystemFactory } from './fs/index.js'; import { getRateLimiter } from './rate-limit.js'; -import type { DaemonWorkspaceService } from './workspace-service/types.js'; +import { + WorkspaceSkillNotToggleableError, + type DaemonWorkspaceService, +} from './workspace-service/types.js'; import type { WorkspaceRegistrationStore } from './workspace-registration-store.js'; import { createWorkspaceRegistry, @@ -282,10 +285,11 @@ const EXPECTED_STAGE1_FEATURES = [ // hash-aware text mutation routes behind the strict mutation gate. 'workspace_file_bytes', 'workspace_file_write', - // #4175 Wave 4 PR 17. Mutation control routes (approval mode toggle, - // workspace tool enable/disable, init scaffold, MCP server restart). + // Mutation control routes (approval mode, workspace tool/skill toggles, + // init scaffold, and MCP server restart). 'session_approval_mode_control', 'workspace_tool_toggle', + 'workspace_skill_toggle', 'workspace_permissions', 'workspace_trust', 'workspace_init', @@ -11859,6 +11863,200 @@ describe('createServeApp', () => { }); }); + describe('POST /workspace/skills/:name/enable', () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const auth = (req: request.Test): request.Test => + req + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + const reviewSkill = { + kind: 'skill' as const, + status: 'ok' as const, + name: 'review', + description: 'Review changed code', + level: 'bundled' as const, + modelInvocable: true, + }; + + it('requires the strict bearer-auth mutation gate', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/workspace/skills/review/enable') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ enabled: false }); + expect(res.status).toBe(401); + expect(res.body.code).toBe('token_required'); + }); + + it('validates skill names and the enabled body', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { + bridge, + primaryWorkspaceTrusted: true, + }); + const empty = await auth( + request(app).post('/workspace/skills/%20%20/enable'), + ).send({ enabled: false }); + expect(empty.status).toBe(400); + expect(empty.body.code).toBe('invalid_skill_name'); + + const tooLong = await auth( + request(app).post(`/workspace/skills/${'a'.repeat(257)}/enable`), + ).send({ enabled: false }); + expect(tooLong.status).toBe(400); + expect(tooLong.body.code).toBe('invalid_skill_name'); + + const badBody = await auth( + request(app).post('/workspace/skills/review/enable'), + ).send({ enabled: 'no' }); + expect(badBody.status).toBe(400); + expect(badBody.body.code).toBe('invalid_enabled_flag'); + }); + + it('returns the canonical name and deferred activation without a child', async () => { + const bridge = fakeBridge({ + workspaceSkillsImpl: async () => ({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [reviewSkill], + }), + }); + const persistDisabledSkills = vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }); + const app = createServeApp(tokenOpts, undefined, { + bridge, + boundWorkspace: WS_BOUND, + persistDisabledSkills, + primaryWorkspaceTrusted: true, + }); + const res = await auth( + request(app).post('/workspace/skills/ReViEw/enable'), + ).send({ enabled: false }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + skillName: 'review', + enabled: false, + changed: true, + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }); + expect(persistDisabledSkills).toHaveBeenCalledWith( + WS_BOUND, + 'review', + false, + ); + }); + + it('returns 404 for an unknown skill', async () => { + const persistDisabledSkills = vi.fn(); + const app = createServeApp(tokenOpts, undefined, { + bridge: fakeBridge({ + workspaceSkillsImpl: async () => ({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [reviewSkill], + }), + }), + persistDisabledSkills, + primaryWorkspaceTrusted: true, + }); + const res = await auth( + request(app).post('/workspace/skills/missing/enable'), + ).send({ enabled: false }); + expect(res.status).toBe(404); + expect(res.body.code).toBe('skill_not_found'); + expect(persistDisabledSkills).not.toHaveBeenCalled(); + }); + + it('rejects an unknown workspace client id before persistence', async () => { + const persistDisabledSkills = vi.fn(); + const app = createServeApp(tokenOpts, undefined, { + bridge: fakeBridge(), + persistDisabledSkills, + primaryWorkspaceTrusted: true, + }); + const res = await auth( + request(app).post('/workspace/skills/review/enable'), + ) + .set('X-Qwen-Client-Id', 'forged-client') + .send({ enabled: false }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + expect(persistDisabledSkills).not.toHaveBeenCalled(); + }); + + it('returns 409 without persisting a non-user-invocable skill', async () => { + const persistDisabledSkills = vi.fn(); + const app = createServeApp(tokenOpts, undefined, { + bridge: fakeBridge({ + workspaceSkillsImpl: async () => ({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [{ ...reviewSkill, userInvocable: false }], + }), + }), + persistDisabledSkills, + primaryWorkspaceTrusted: true, + }); + const res = await auth( + request(app).post('/workspace/skills/review/enable'), + ).send({ enabled: false }); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'skill_not_toggleable', + reason: 'not_user_invocable', + }); + expect(persistDisabledSkills).not.toHaveBeenCalled(); + }); + + it('returns the locked scope from persistence validation', async () => { + const app = createServeApp(tokenOpts, undefined, { + bridge: fakeBridge({ + workspaceSkillsImpl: async () => ({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [reviewSkill], + }), + }), + persistDisabledSkills: vi + .fn() + .mockRejectedValue( + new WorkspaceSkillNotToggleableError('review', 'locked', 'user'), + ), + primaryWorkspaceTrusted: true, + }); + const res = await auth( + request(app).post('/workspace/skills/review/enable'), + ).send({ enabled: true }); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'skill_not_toggleable', + reason: 'locked', + lockedScope: 'user', + }); + }); + + it('rejects writes to an untrusted primary workspace', async () => { + const app = createServeApp(tokenOpts, undefined, { + bridge: fakeBridge(), + }); + const res = await auth( + request(app).post('/workspace/skills/review/enable'), + ).send({ enabled: false }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + }); + }); + describe('POST /session/:id/permission/:requestId', () => { it('200 when bridge accepts the scoped vote', async () => { const bridge = fakeBridge(); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index b266a02390e..1f08a3e6afc 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -113,6 +113,7 @@ import { import { createDaemonWorkspaceService, type DaemonWorkspaceService, + type DaemonWorkspaceServiceDeps, } from './workspace-service/index.js'; import { registerCapabilitiesRoutes } from './routes/capabilities.js'; import { @@ -197,6 +198,10 @@ import { registerWorkspaceQualifiedToolsRoutes, registerWorkspaceToolsRoutes, } from './routes/workspace-tools.js'; +import { + registerWorkspaceQualifiedSkillsRoutes, + registerWorkspaceSkillsRoutes, +} from './routes/workspace-skills.js'; import { registerChannelWebhookRoutes } from './routes/channel-webhooks.js'; import { parseChannelWebhookConfigLenient, @@ -427,6 +432,7 @@ export interface ServeAppDeps { toolName: string, enabled: boolean, ) => Promise; + persistDisabledSkills?: DaemonWorkspaceServiceDeps['persistDisabledSkills']; contextFilename?: string; persistSetting?: ( workspace: string, @@ -806,6 +812,13 @@ export function createServeApp( 'setWorkspaceToolEnabled requires persistDisabledTools in ServeAppDeps', ); }), + persistDisabledSkills: + deps.persistDisabledSkills ?? + (async () => { + throw new Error( + 'setWorkspaceSkillEnabled requires persistDisabledSkills in ServeAppDeps', + ); + }), queryWorkspaceStatus: (method, idle) => bridge.queryWorkspaceStatus(method, idle), invokeWorkspaceCommand: (method, params, invokeOpts) => @@ -1454,6 +1467,20 @@ export function createServeApp( safeBody, sendBridgeError, }); + registerWorkspaceSkillsRoutes(app, { + workspaceRuntime: primaryRuntime, + mutate, + safeBody, + sendBridgeError, + parseAndValidateClientId: (req, res) => + parseAndValidateWorkspaceClientId(req, res, primaryBridge), + }); + registerWorkspaceQualifiedSkillsRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); // Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page). // Reads/writes the per-project cron file only; firing stays with the diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 38b59ed0b67..1731353615b 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -49,6 +49,10 @@ import { TotalSessionLimitExceededError, } from '../acp-session-bridge.js'; import type { DaemonLogger } from '../daemon-logger.js'; +import { + WorkspaceSkillNotFoundError, + WorkspaceSkillNotToggleableError, +} from '../workspace-service/types.js'; export type BridgeErrorContext = { route?: string; @@ -153,6 +157,24 @@ export function sendBridgeError( ctx?: BridgeErrorContext, daemonLog?: DaemonLogger, ): void { + if (err instanceof WorkspaceSkillNotFoundError) { + res.status(404).json({ + error: err.message, + code: 'skill_not_found', + skillName: err.skillName, + }); + return; + } + if (err instanceof WorkspaceSkillNotToggleableError) { + res.status(409).json({ + error: err.message, + code: 'skill_not_toggleable', + skillName: err.skillName, + reason: err.reason, + ...(err.lockedScope ? { lockedScope: err.lockedScope } : {}), + }); + return; + } if (err instanceof InvalidSessionTranscriptCursorError) { res.status(400).json({ error: err.message, diff --git a/packages/cli/src/serve/server/request-helpers.ts b/packages/cli/src/serve/server/request-helpers.ts index 843a18f65e2..64e178418cd 100644 --- a/packages/cli/src/serve/server/request-helpers.ts +++ b/packages/cli/src/serve/server/request-helpers.ts @@ -66,6 +66,7 @@ const PROTOTYPE_POLLUTION_KEYS: ReadonlySet = new Set([ export const CLIENT_ID_HEADER = 'x-qwen-client-id'; export const MAX_CLIENT_ID_LENGTH = 128; export const MAX_TOOL_NAME_LENGTH = 256; +export const MAX_SKILL_NAME_LENGTH = 256; export const MAX_SERVER_NAME_LENGTH = 256; export const CLIENT_ID_RE = /^[A-Za-z0-9._:-]+$/; const INVALID_PERMISSION_OUTCOME_ERROR = diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index 8d744b40343..38b54569876 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -22,7 +22,10 @@ import { type WorkspaceRuntime, } from './workspace-registry.js'; import type { AcpSessionBridge } from './acp-session-bridge.js'; -import type { DaemonWorkspaceService } from './workspace-service/types.js'; +import { + WorkspaceSkillNotFoundError, + type DaemonWorkspaceService, +} from './workspace-service/types.js'; const baseOpts: ServeOptions = { hostname: '127.0.0.1', @@ -121,6 +124,14 @@ function makeWorkspaceService(label: string): DaemonWorkspaceService { toolName, enabled, })), + setWorkspaceSkillEnabled: vi.fn(async (_ctx, skillName, enabled) => ({ + skillName, + enabled, + changed: true, + activation: 'deferred' as const, + sessionsRefreshed: 0, + sessionsFailed: 0, + })), initWorkspace: vi.fn(async (ctx) => ({ path: `${ctx.workspaceCwd}/QWEN.md`, action: 'created' as const, @@ -201,6 +212,8 @@ async function makeHarness(opts?: { emit: () => {}, }); + const primaryWorkspaceService = makeWorkspaceService('primary'); + const secondaryWorkspaceService = makeWorkspaceService('secondary'); const primary: WorkspaceRuntime = { workspaceId: 'same-as-path', workspaceCwd: primaryCwd, @@ -208,7 +221,7 @@ async function makeHarness(opts?: { trusted: true, env: { mode: 'parent-process', overlayKeys: [] }, bridge: makeBridge(), - workspaceService: makeWorkspaceService('primary'), + workspaceService: primaryWorkspaceService, routeFileSystemFactory: primaryFsFactory, clientMcpSenderRegistry: new ClientMcpSenderRegistry(), }; @@ -219,7 +232,7 @@ async function makeHarness(opts?: { trusted: opts?.secondaryTrusted ?? true, env: { mode: 'parent-process', overlayKeys: [] }, bridge: makeBridge(), - workspaceService: makeWorkspaceService('secondary'), + workspaceService: secondaryWorkspaceService, routeFileSystemFactory: opts?.secondaryTrusted === false ? untrustedFsFactory @@ -243,6 +256,7 @@ async function makeHarness(opts?: { primaryCwd, secondaryCwd, secondaryId: secondary.workspaceId, + secondaryWorkspaceService, persistSetting, }; } @@ -875,6 +889,73 @@ describe('workspace-qualified core REST', () => { } }); + it('routes workspace-qualified skill toggles and trust-gates writes', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const res = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/skills/review/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .set('Host', host()) + .send({ enabled: false }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + skillName: 'review', + enabled: false, + changed: true, + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }); + + vi.mocked( + h.secondaryWorkspaceService.setWorkspaceSkillEnabled, + ).mockRejectedValueOnce(new WorkspaceSkillNotFoundError('missing')); + const missing = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/skills/missing/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ enabled: false }); + expect(missing.status).toBe(404); + expect(missing.body.code).toBe('skill_not_found'); + + const invalidClient = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/skills/review/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'forged-client') + .set('Host', host()) + .send({ enabled: false }); + expect(invalidClient.status).toBe(400); + expect(invalidClient.body.code).toBe('invalid_client_id'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + + const untrusted = await makeHarness({ + secondaryTrusted: false, + token: 'secret', + }); + try { + const res = await request(untrusted.app) + .post( + `/workspaces/${encodeURIComponent(untrusted.secondaryId)}/skills/review/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ enabled: false }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(untrusted.scratch, { recursive: true, force: true }); + } + }); + it('routes project agents to the selected workspace', async () => { const h = await makeHarness({ token: 'secret' }); try { diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index 1094f616b95..0cec1c0d4ca 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -92,6 +92,7 @@ vi.mock('../../../utils/stdioHelpers.js', () => ({ const { createDaemonWorkspaceService } = await import('../index.js'); import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; +import { BridgeChannelClosedError } from '@qwen-code/acp-bridge/status'; import { resetHomeEnvBootstrapForTesting, SettingScope, @@ -105,6 +106,7 @@ import { import { WorkspaceVoiceError } from '../../../services/voice-service.js'; import { WorkspacePermissionRulesSessionRequiredError, + WorkspaceSkillNotFoundError, WorkspaceSettingsPartialPersistError, } from '../types.js'; import type { @@ -123,6 +125,10 @@ function makeDeps( boundWorkspace: '/workspace', contextFilename: 'QWEN.md', persistDisabledTools: vi.fn().mockResolvedValue(undefined), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: [], + }), queryWorkspaceStatus: vi .fn() .mockImplementation((_method: string, idle: () => unknown) => @@ -1212,6 +1218,327 @@ describe('createDaemonWorkspaceService', () => { }); }); + describe('setWorkspaceSkillEnabled', () => { + const skillStatus = ( + overrides: Record = {}, + ): Record => ({ + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review changed code', + level: 'bundled', + modelInvocable: true, + ...overrides, + }); + const statusQuery = (skill = skillStatus()) => + vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + skills: [skill], + }); + + it('uses the canonical skill name and refreshes every active session', async () => { + const persistDisabledSkills = vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }); + const invokeWorkspaceCommand = vi.fn().mockResolvedValue({ + sessionsRefreshed: 2, + sessionsFailed: 0, + }); + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills, + invokeWorkspaceCommand, + publishWorkspaceEvent, + isChannelLive: () => true, + }), + ); + + const result = await svc.setWorkspaceSkillEnabled( + makeCtx({ originatorClientId: 'client-1' }), + 'ReViEw', + false, + ); + + expect(persistDisabledSkills).toHaveBeenCalledWith( + '/workspace', + 'review', + false, + ); + expect(invokeWorkspaceCommand).toHaveBeenCalledWith( + 'qwen/control/workspace/skills/refresh', + { cwd: '/workspace' }, + ); + expect(result).toEqual({ + skillName: 'review', + enabled: false, + changed: true, + activation: 'applied', + sessionsRefreshed: 2, + sessionsFailed: 0, + }); + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'settings_changed', + data: { + key: 'skills.disabled', + value: ['review'], + scope: 'workspace', + }, + originatorClientId: 'client-1', + }); + }); + + it('publishes the reduced disabled list when enabling a skill', async () => { + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['orphan'], + }), + invokeWorkspaceCommand: vi.fn().mockResolvedValue({ + sessionsRefreshed: 1, + sessionsFailed: 0, + }), + publishWorkspaceEvent, + isChannelLive: () => true, + }), + ); + + await expect( + svc.setWorkspaceSkillEnabled(makeCtx(), 'review', true), + ).resolves.toMatchObject({ + skillName: 'review', + enabled: true, + activation: 'applied', + }); + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'settings_changed', + data: { + key: 'skills.disabled', + value: ['orphan'], + scope: 'workspace', + }, + originatorClientId: 'client-1', + }); + }); + + it('reports partial activation when a session refresh fails', async () => { + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }), + invokeWorkspaceCommand: vi.fn().mockResolvedValue({ + sessionsRefreshed: 1, + sessionsFailed: 1, + }), + isChannelLive: () => true, + }), + ); + + await expect( + svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).resolves.toMatchObject({ + activation: 'partial', + sessionsRefreshed: 1, + sessionsFailed: 1, + }); + }); + + it('defers refresh when no child exists or the child closes mid-refresh', async () => { + const noChildCommand = vi.fn(); + const noChild = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }), + invokeWorkspaceCommand: noChildCommand, + isChannelLive: () => false, + }), + ); + await expect( + noChild.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).resolves.toMatchObject({ + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }); + expect(noChildCommand).not.toHaveBeenCalled(); + + const closedChild = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }), + invokeWorkspaceCommand: vi + .fn() + .mockRejectedValue( + new BridgeChannelClosedError('mid-request (workspace status)'), + ), + isChannelLive: () => true, + }), + ); + await expect( + closedChild.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).resolves.toMatchObject({ + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }); + }); + + it('defers refresh when the child reports no session', async () => { + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }), + invokeWorkspaceCommand: vi + .fn() + .mockRejectedValue(new SessionNotFoundError('session-1')), + isChannelLive: () => true, + }), + ); + + await expect( + svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).resolves.toMatchObject({ + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }); + }); + + it('reports partial activation on an unexpected refresh error', async () => { + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }), + invokeWorkspaceCommand: vi + .fn() + .mockRejectedValue(new Error('network timeout')), + isChannelLive: () => true, + }), + ); + + await expect( + svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).resolves.toMatchObject({ + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }); + }); + + it('does not refresh or publish an idempotent mutation', async () => { + const invokeWorkspaceCommand = vi.fn(); + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: false, + disabled: ['review'], + }), + invokeWorkspaceCommand, + publishWorkspaceEvent, + isChannelLive: () => true, + }), + ); + + await expect( + svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).resolves.toMatchObject({ changed: false, activation: 'applied' }); + expect(invokeWorkspaceCommand).not.toHaveBeenCalled(); + expect(publishWorkspaceEvent).not.toHaveBeenCalled(); + }); + + it('rejects unknown, hidden, and inactive extension skills before persisting', async () => { + const persistDisabledSkills = vi.fn(); + const unknown = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills, + }), + ); + await expect( + unknown.setWorkspaceSkillEnabled(makeCtx(), 'missing', false), + ).rejects.toBeInstanceOf(WorkspaceSkillNotFoundError); + + const hidden = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery( + skillStatus({ userInvocable: false }), + ), + persistDisabledSkills, + }), + ); + await expect( + hidden.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).rejects.toMatchObject({ + reason: 'not_user_invocable', + }); + + const inactive = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery( + skillStatus({ + status: 'disabled', + level: 'extension', + extensionName: 'review-ext', + }), + ), + persistDisabledSkills, + }), + ); + await expect( + inactive.setWorkspaceSkillEnabled(makeCtx(), 'review', true), + ).rejects.toMatchObject({ + reason: 'inactive_extension', + }); + expect(persistDisabledSkills).not.toHaveBeenCalled(); + }); + + it('does not refresh or publish when persistence fails', async () => { + const invokeWorkspaceCommand = vi.fn(); + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus: statusQuery(), + persistDisabledSkills: vi + .fn() + .mockRejectedValue(new Error('disk full')), + invokeWorkspaceCommand, + publishWorkspaceEvent, + isChannelLive: () => true, + }), + ); + + await expect( + svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false), + ).rejects.toThrow('disk full'); + expect(invokeWorkspaceCommand).not.toHaveBeenCalled(); + expect(publishWorkspaceEvent).not.toHaveBeenCalled(); + }); + }); + describe('requestWorkspaceTrustChange', () => { it('publishes trust_change_requested with originatorClientId', async () => { const publishWorkspaceEvent = vi.fn(); diff --git a/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts b/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts index e0a048e6b55..748bacae864 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts @@ -240,6 +240,38 @@ describe('workspace service REST integration', () => { // No client-id header on GET — should be undefined expect(ctx.originatorClientId).toBeUndefined(); }); + + it('only includes userInvocable when manual invocation is disabled', async () => { + const { app } = createTestApp({ + workspaceOverrides: { + getWorkspaceSkillsStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [ + { name: 'normal-skill', source: 'project' }, + { + name: 'hidden-skill', + source: 'project', + userInvocable: false, + }, + ], + }), + }, + }); + + const res = await request(app).get('/workspace/skills').set(hostHeader()); + + expect(res.status).toBe(200); + expect(res.body.skills).toEqual([ + { name: 'normal-skill', source: 'project' }, + { + name: 'hidden-skill', + source: 'project', + userInvocable: false, + }, + ]); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index e8e1ae06441..f847b9d5ed2 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -19,6 +19,7 @@ import * as path from 'node:path'; import { SERVE_STATUS_EXT_METHODS, SERVE_CONTROL_EXT_METHODS, + BridgeChannelClosedError, STATUS_SCHEMA_VERSION, createIdleWorkspaceMcpStatus, createIdleWorkspaceSkillsStatus, @@ -27,7 +28,9 @@ import { createIdleWorkspaceHooksStatus, createIdleEnvStatus, createIdleAcpPreflightCells, + mapDomainErrorToErrorKind, type ServeWorkspacePreflightStatus, + type ServeWorkspaceSkillsRefreshResult, type ServeWorkspaceSkillsStatus, } from '@qwen-code/acp-bridge/status'; @@ -41,7 +44,6 @@ import { SessionNotFoundError, } from '@qwen-code/acp-bridge/bridgeErrors'; -import { mapDomainErrorToErrorKind } from '@qwen-code/acp-bridge/status'; import { MCP_RESTART_SERVER_DEADLINE_MS } from '@qwen-code/acp-bridge/mcpTimeouts'; import { loadSettings } from '../../config/settings.js'; @@ -59,6 +61,8 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { WorkspacePermissionRulesSessionRequiredError, + WorkspaceSkillNotFoundError, + WorkspaceSkillNotToggleableError, WorkspaceSettingsPartialPersistError, } from './types.js'; import type { @@ -71,6 +75,7 @@ import type { WorkspaceVoiceSettingsUpdate, WorkspaceAcpPreheatResult, WorkspaceAcpStatusResult, + WorkspaceSkillToggleResult, } from './types.js'; // Re-export types for consumers. @@ -86,11 +91,17 @@ export type { WorkspaceVoiceSettingsUpdate, WorkspaceAcpPreheatResult, WorkspaceAcpStatusResult, + WorkspaceSkillToggleResult, + WorkspaceSkillToggleActivation, EnvReloadResult, ReloadResponse, } from './types.js'; -export { WorkspacePermissionRulesSessionRequiredError } from './types.js'; +export { + WorkspacePermissionRulesSessionRequiredError, + WorkspaceSkillNotFoundError, + WorkspaceSkillNotToggleableError, +} from './types.js'; // --------------------------------------------------------------------------- // Helpers @@ -193,6 +204,7 @@ export function createDaemonWorkspaceService( workspaceSkillsStatusProvider, isChannelLive, persistDisabledTools, + persistDisabledSkills, persistSetting, persistSettings, preheatAcpChild: preheatAcpChildOnBridge, @@ -208,27 +220,8 @@ export function createDaemonWorkspaceService( let lastWorkspaceSkillsStatus: ServeWorkspaceSkillsStatus | undefined; let inFlightAcpPreheat: Promise | undefined; - // -- Facade -- - return { - // -- Status queries (delegate to ACP child via queryWorkspaceStatus) -- - - async getWorkspaceMcpStatus(_ctx: WorkspaceRequestContext) { - return queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => - createIdleWorkspaceMcpStatus(boundWorkspace), - ); - }, - - async getWorkspaceSkillsStatus(_ctx: WorkspaceRequestContext) { - // Skills are enumerated by the ACP child, which owns the live - // SkillManager (including extension-provided skills). `queryWorkspaceStatus` - // returns the idle placeholder (`initialized: false`, empty `skills`) - // whenever no child channel is live — before the first session, after - // the child is reaped on session close (`--channel-idle-timeout-ms` - // defaults to an immediate kill), and when a cold-start preheat times - // out before the child ever answers. In those windows the Web Shell's - // pre-first-prompt slash-command list would otherwise drop every skill, - // so `/rev` stops autocompleting `/review`. `initialized` cleanly - // separates a real child answer (always `true`) from the placeholder. + const getWorkspaceSkillsStatus = + async (): Promise => { let status: ServeWorkspaceSkillsStatus; try { status = await queryWorkspaceStatus( @@ -252,9 +245,7 @@ export function createDaemonWorkspaceService( } // Live child unavailable. Prefer the last answer it produced (keeps the // full, extension-aware list available across a reap)... - if (lastWorkspaceSkillsStatus) { - return lastWorkspaceSkillsStatus; - } + if (lastWorkspaceSkillsStatus) return lastWorkspaceSkillsStatus; // ...then fall back to daemon-local enumeration, so a child that has not // answered even once (e.g. a preheat that times out under `npm run dev`) // still yields the on-disk skills — `/review` included. The provider @@ -271,6 +262,30 @@ export function createDaemonWorkspaceService( } } return status; + }; + + // -- Facade -- + return { + // -- Status queries (delegate to ACP child via queryWorkspaceStatus) -- + + async getWorkspaceMcpStatus(_ctx: WorkspaceRequestContext) { + return queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => + createIdleWorkspaceMcpStatus(boundWorkspace), + ); + }, + + async getWorkspaceSkillsStatus(_ctx: WorkspaceRequestContext) { + // Skills are enumerated by the ACP child, which owns the live + // SkillManager (including extension-provided skills). `queryWorkspaceStatus` + // returns the idle placeholder (`initialized: false`, empty `skills`) + // whenever no child channel is live — before the first session, after + // the child is reaped on session close (`--channel-idle-timeout-ms` + // defaults to an immediate kill), and when a cold-start preheat times + // out before the child ever answers. In those windows the Web Shell's + // pre-first-prompt slash-command list would otherwise drop every skill, + // so `/rev` stops autocompleting `/review`. `initialized` cleanly + // separates a real child answer (always `true`) from the placeholder. + return getWorkspaceSkillsStatus(); }, async getWorkspaceProvidersStatus(_ctx: WorkspaceRequestContext) { @@ -620,6 +635,104 @@ export function createDaemonWorkspaceService( return { toolName, enabled }; }, + async setWorkspaceSkillEnabled( + ctx: WorkspaceRequestContext, + requestedSkillName: string, + enabled: boolean, + ): Promise { + const normalizedName = requestedSkillName.trim().toLowerCase(); + const status = await getWorkspaceSkillsStatus(); + const skill = status.skills.find( + (candidate) => candidate.name.trim().toLowerCase() === normalizedName, + ); + if (!skill) throw new WorkspaceSkillNotFoundError(requestedSkillName); + if (skill.userInvocable === false) { + throw new WorkspaceSkillNotToggleableError( + skill.name, + 'not_user_invocable', + ); + } + + const disabled = loadSettings(boundWorkspace).merged.skills?.disabled; + const disabledNames = new Set( + (Array.isArray(disabled) ? disabled : []) + .filter((name): name is string => typeof name === 'string') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean), + ); + if ( + skill.level === 'extension' && + skill.status === 'disabled' && + !disabledNames.has(normalizedName) + ) { + throw new WorkspaceSkillNotToggleableError( + skill.name, + 'inactive_extension', + ); + } + + const persisted = await persistDisabledSkills( + boundWorkspace, + skill.name, + enabled, + ); + const channelLive = isChannelLive?.() ?? false; + let activation: WorkspaceSkillToggleResult['activation'] = channelLive + ? 'applied' + : 'deferred'; + let sessionsRefreshed = 0; + let sessionsFailed = 0; + + if (persisted.changed) { + lastWorkspaceSkillsStatus = undefined; + if (channelLive) { + try { + const refreshed = + await invokeWorkspaceCommand( + SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, + { cwd: boundWorkspace }, + ); + sessionsRefreshed = refreshed.sessionsRefreshed; + sessionsFailed = refreshed.sessionsFailed; + if (sessionsFailed > 0) activation = 'partial'; + } catch (err) { + if ( + err instanceof SessionNotFoundError || + err instanceof BridgeChannelClosedError + ) { + activation = 'deferred'; + } else { + activation = 'partial'; + sessionsFailed = 1; + writeStderrLine( + `qwen serve: workspace skill refresh failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + publishWorkspaceEvent({ + type: 'settings_changed', + data: { + key: 'skills.disabled', + value: + persisted.disabled.length > 0 ? persisted.disabled : undefined, + scope: 'workspace', + }, + originatorClientId: ctx.originatorClientId, + }); + } + + return { + skillName: skill.name, + enabled, + changed: persisted.changed, + activation, + sessionsRefreshed, + sessionsFailed, + }; + }, + async initWorkspace( ctx: WorkspaceRequestContext, opts: { force?: boolean }, diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index ce12e5e7e38..e16ab1fe31b 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -192,6 +192,13 @@ export interface DaemonWorkspaceService { enabled: boolean, ): Promise<{ toolName: string; enabled: boolean }>; + /** Toggle a skill in the workspace's skills.disabled settings list. */ + setWorkspaceSkillEnabled( + ctx: WorkspaceRequestContext, + skillName: string, + enabled: boolean, + ): Promise; + /** Scaffold (init) a QWEN.md file in the workspace. */ initWorkspace( ctx: WorkspaceRequestContext, @@ -300,6 +307,49 @@ export interface WorkspaceVoiceSettingsUpdate { voiceModel?: string; } +export type WorkspaceSkillToggleActivation = 'applied' | 'deferred' | 'partial'; + +export interface WorkspaceSkillToggleResult { + skillName: string; + enabled: boolean; + changed: boolean; + activation: WorkspaceSkillToggleActivation; + sessionsRefreshed: number; + sessionsFailed: number; +} + +export interface PersistDisabledSkillResult { + changed: boolean; + disabled: string[]; +} + +export type WorkspaceSkillNotToggleableReason = + | 'not_user_invocable' + | 'inactive_extension' + | 'locked'; + +export class WorkspaceSkillNotFoundError extends Error { + constructor(readonly skillName: string) { + super(`Skill not found: ${skillName}`); + this.name = 'WorkspaceSkillNotFoundError'; + } +} + +export class WorkspaceSkillNotToggleableError extends Error { + constructor( + readonly skillName: string, + readonly reason: WorkspaceSkillNotToggleableReason, + readonly lockedScope?: 'system' | 'user' | 'systemDefaults', + ) { + super( + lockedScope + ? `Skill ${skillName} is locked by ${lockedScope} settings` + : `Skill ${skillName} is not toggleable: ${reason}`, + ); + this.name = 'WorkspaceSkillNotToggleableError'; + } +} + /** Discriminated union for MCP server restart outcomes. */ export type RestartMcpServerResult = | { serverName: string; restarted: true; durationMs: number } @@ -374,6 +424,13 @@ export interface DaemonWorkspaceServiceDeps { enabled: boolean, ) => Promise; + /** Persist a skill enable/disable change to workspace settings. */ + persistDisabledSkills: ( + workspace: string, + skillName: string, + enabled: boolean, + ) => Promise; + persistSetting?: ( workspace: string, scope: SettingScope, diff --git a/packages/cli/src/serve/workspace-skills-mapping.test.ts b/packages/cli/src/serve/workspace-skills-mapping.test.ts index ba7241e9068..7c439c0b3a0 100644 --- a/packages/cli/src/serve/workspace-skills-mapping.test.ts +++ b/packages/cli/src/serve/workspace-skills-mapping.test.ts @@ -47,6 +47,15 @@ describe('mapSkillConfigToStatus', () => { expect(status.name).toBe('internal'); }); + it('only emits userInvocable when manual invocation is disabled', () => { + expect(mapSkillConfigToStatus(makeSkill())).not.toHaveProperty( + 'userInvocable', + ); + expect( + mapSkillConfigToStatus(makeSkill({ userInvocable: false })), + ).toMatchObject({ userInvocable: false }); + }); + it('marks a settings-disabled skill as disabled', () => { const status = mapSkillConfigToStatus( makeSkill({ name: 'internal' }), diff --git a/packages/cli/src/serve/workspace-skills-mapping.ts b/packages/cli/src/serve/workspace-skills-mapping.ts index 2ab0cd14992..f3ebebd2c68 100644 --- a/packages/cli/src/serve/workspace-skills-mapping.ts +++ b/packages/cli/src/serve/workspace-skills-mapping.ts @@ -28,6 +28,7 @@ export function mapSkillConfigToStatus( description: skill.description, level: skill.level, modelInvocable, + ...(skill.userInvocable === false ? { userInvocable: false as const } : {}), installedPath: skill.filePath, ...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}), ...(skill.model ? { model: skill.model } : {}), diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index ce0124490e3..a423a39cd9c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -118,6 +118,7 @@ import type { DaemonRuntimeMcpAddResult, DaemonRuntimeMcpRemoveResult, DaemonToolToggleResult, + DaemonSkillToggleResult, DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, DaemonSessionArtifactsEnvelope, @@ -2314,6 +2315,40 @@ export class DaemonClient { ); } + /** + * Toggle a user-invocable skill in workspace `skills.disabled` settings. + * Active ACP sessions refresh their skill validation and command lists before + * the response returns; `activation` reports deferred or partial refreshes. + * + * Pre-flight `caps.features.includes('workspace_skill_toggle')` before calling. + */ + async setWorkspaceSkillEnabled( + skillName: string, + enabled: boolean, + opts?: { clientId?: string }, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/workspace/skills/${urlEncode(skillName)}/enable`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + opts?.clientId, + ), + body: JSON.stringify({ enabled }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'POST /workspace/skills/:name/enable', + ); + } + return (await res.json()) as DaemonSkillToggleResult; + }, + ); + } + async workspaceSettings(opts?: { clientId?: string; }): Promise { @@ -4051,6 +4086,19 @@ export class WorkspaceDaemonClient { ); } + setWorkspaceSkillEnabled( + skillName: string, + enabled: boolean, + opts?: { clientId?: string }, + ): Promise { + return this.post( + `/skills/${urlEncode(skillName)}/enable`, + 'POST /workspaces/:workspace/skills/:name/enable', + { enabled }, + opts?.clientId, + ); + } + restartMcpServer( serverName: string, opts?: { clientId?: string; entryIndex?: number | '*'; timeoutMs?: number }, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index da996389864..04f8ebe196e 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -355,6 +355,8 @@ export type { DaemonRuntimeMcpAddResult, DaemonRuntimeMcpRemoveResult, DaemonToolToggleResult, + DaemonSkillToggleActivation, + DaemonSkillToggleResult, DaemonSettingDescriptor, DaemonPermissionRuleSet, DaemonPermissionRuleType, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 9d1dd389072..a55d19e5960 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1093,6 +1093,7 @@ export interface DaemonWorkspaceSkillStatus extends DaemonStatusCell { description: string; level: DaemonSkillLevel; modelInvocable: boolean; + userInvocable?: false; installedPath?: string; argumentHint?: string; model?: string; @@ -1987,6 +1988,17 @@ export interface DaemonToolToggleResult { enabled: boolean; } +export type DaemonSkillToggleActivation = 'applied' | 'deferred' | 'partial'; + +export interface DaemonSkillToggleResult { + skillName: string; + enabled: boolean; + changed: boolean; + activation: DaemonSkillToggleActivation; + sessionsRefreshed: number; + sessionsFailed: number; +} + export interface DaemonSettingDescriptor { key: string; type: string; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 0d914621bd1..e6fa6e1fb23 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -64,6 +64,8 @@ export { type DaemonSettingsReloadedData, type DaemonSettingsReloadedEvent, type DaemonToolToggleResult, + type DaemonSkillToggleActivation, + type DaemonSkillToggleResult, type DaemonToolToggledData, type DaemonToolToggledEvent, type DaemonTrustChangeRequestedData, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 37768b312e4..0497621abbe 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -204,7 +204,7 @@ describe('DaemonClient', () => { supported: ['v1'], }, mode: 'http-bridge' as const, - features: ['health', 'capabilities'], + features: ['health', 'capabilities', 'workspace_skill_toggle'], modelServices: [], workspaceCwd: '/work/bound', }; @@ -212,6 +212,7 @@ describe('DaemonClient', () => { const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); const caps = await client.capabilities(); expect(caps).toEqual(envelope); + expect(caps.features).toContain('workspace_skill_toggle'); // #3803 §02: clients use `workspaceCwd` to pre-flight check + // omit `cwd` from `POST /session` (route falls back). expect(caps.workspaceCwd).toBe('/work/bound'); @@ -3155,6 +3156,75 @@ describe('DaemonClient', () => { }); }); + describe('setWorkspaceSkillEnabled', () => { + const response = { + skillName: 'review/strict', + enabled: false, + changed: true, + activation: 'applied', + sessionsRefreshed: 2, + sessionsFailed: 0, + }; + + it('POSTs the flag, client id, and URL-encoded skill name', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.setWorkspaceSkillEnabled('review/strict', false, { + clientId: 'client-1', + }), + ).resolves.toEqual(response); + expect(calls[0]).toMatchObject({ + url: 'http://daemon/workspace/skills/review%2Fstrict/enable', + method: 'POST', + body: JSON.stringify({ enabled: false }), + }); + expect(calls[0]?.headers['content-type']).toBe('application/json'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + + it('supports the workspace-qualified helper', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client + .workspaceByCwd('/tmp/work space') + .setWorkspaceSkillEnabled('review/strict', false, { + clientId: 'client-2', + }); + + expect(calls[0]).toMatchObject({ + url: 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/skills/review%2Fstrict/enable', + method: 'POST', + body: JSON.stringify({ enabled: false }), + }); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-2'); + }); + + it('passes structured daemon errors through', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(409, { + error: 'Skill review is locked', + code: 'skill_not_toggleable', + reason: 'locked', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.setWorkspaceSkillEnabled('review', true), + ).rejects.toMatchObject({ + status: 409, + body: expect.objectContaining({ code: 'skill_not_toggleable' }), + }); + }); + }); + describe('initWorkspace (#4175 Wave 4 PR 17)', () => { it('POSTs an empty body when force is omitted', async () => { const { fetch, calls } = recordingFetch(() =>