From 6291b5314c91e197a8aeb060300474b3681d950f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Thu, 13 Aug 2026 13:02:26 +0800 Subject: [PATCH 01/18] feat(daemon): attach skill-toggle mutation metadata to settings_changed Hosts can apply Skill toggles incrementally without a full task reload or suppressing skills.* events. Co-authored-by: Cursor --- docs/developers/daemon/09-event-schema.md | 6 +- docs/developers/qwen-serve-protocol.md | 4 +- .../__tests__/facade.test.ts | 129 ++++++++++++------ .../cli/src/serve/workspace-service/index.ts | 41 +++++- packages/sdk-typescript/src/daemon/events.ts | 16 ++- packages/sdk-typescript/src/daemon/index.ts | 4 + packages/sdk-typescript/src/daemon/types.ts | 14 ++ .../src/daemon/ui/normalizer.ts | 47 +++++++ .../sdk-typescript/src/daemon/ui/types.ts | 2 + packages/sdk-typescript/src/index.ts | 4 + .../sdk-typescript/test/unit/daemonUi.test.ts | 47 +++++++ 11 files changed, 262 insertions(+), 52 deletions(-) diff --git a/docs/developers/daemon/09-event-schema.md b/docs/developers/daemon/09-event-schema.md index 7b0cec233f4..d42ef521a40 100644 --- a/docs/developers/daemon/09-event-schema.md +++ b/docs/developers/daemon/09-event-schema.md @@ -77,12 +77,14 @@ Grouped by domain. | `agent_changed` | S->C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` | | `approval_mode_changed` | S->C | `sessionId, previous, next, persisted: boolean` | | `tool_toggled` | S->C | `toolName, enabled`; affects the next ACP child spawn and does not mutate already-running sessions. | -| `settings_changed` | S->C | Workspace settings write completed. Payload is open; consumers should refresh with read-after-write. | +| `settings_changed` | S->C | Workspace settings write completed. Payload includes `key`, `value`, `scope`, and optional Skill-toggle `mutation`. | | `settings_reloaded` | S->C | Daemon workspace service reread settings. Payload is open. | | `trust_change_requested` | S->C | `workspaceCwd, desiredState: 'trusted' \| 'untrusted', reason?` | | `workspace_initialized` | S->C | `path, action: 'created' \| 'overwrote' \| 'noop', originatorClientId?` | | `github_setup_completed` | S->C | `releaseTag, readmeUrl, secretsUrl?, workflows: [{path, status, sizeBytes?, error?}], gitignore: {path, status, added?, error?}` | +Skill toggle APIs attach optional `mutation: { id, kind: 'skill_toggle', skills: [{ name, enabled }], activation, sessionsRefreshed, sessionsFailed }`. Every `skills.disabled` / `skills.enabled` event from the same request shares one mutation id. Other settings writes omit `mutation`. + `memory_changed` also covers sessionless managed-memory tasks. For those payloads, `scope` is `"managed"`, `source` is one of `"workspace_memory_remember"`, `"workspace_memory_forget"`, or @@ -174,7 +176,7 @@ These events are workspace-keyed, not session-keyed. The session reducer treats - `workspaceInitCount`, `lastWorkspaceInit?` - from `workspace_initialized`. - `mcpRestartCount`, `lastMcpRestart?` - from `mcp_server_restarted`. - `mcpRestartRefusedCount`, `lastMcpRestartRefused?` - from `mcp_server_restart_refused`. -- `settings_changed` / `settings_reloaded` - recognized by `asKnownDaemonEvent`; the session reducer does not maintain dedicated view-state fields, and UIs usually treat them as refresh signals. +- `settings_changed` / `settings_reloaded` - recognized by `asKnownDaemonEvent`; the session reducer does not maintain dedicated view-state fields. Skill-toggle `settings_changed` events carry optional `mutation` metadata so hosts can apply Skill-only changes incrementally instead of reloading the task. Other UIs may still treat the event as a refresh signal. - `permissionVoteProgress: Record` - consensus voting progress. - `forbiddenVotes: DaemonPermissionForbiddenData[]`, `forbiddenVoteCount` - policy-rejected vote records, capped at 32. - `awaitingResync: boolean` - set by `state_resync_required`; cleared when consumer resets view state. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 9835f423235..b57d29394b6 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2762,7 +2762,7 @@ Errors: - `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 for each changed key (`skills.disabled` and/or `skills.enabled`); it does not add a new event type. Workspace skill status cells include optional `disabledReason: 'hard' | 'default' | 'inactive_extension'` and `lockedScope: 'system' | 'user' | 'systemDefaults'` fields. +The mutation reuses the workspace-scoped `settings_changed` event for each changed key (`skills.disabled` and/or `skills.enabled`); it does not add a new event type. Each of those events includes the same `mutation` object: `{ id, kind: 'skill_toggle', skills: [{ name, enabled }], activation, sessionsRefreshed, sessionsFailed }`. `id` correlates every settings event produced by one toggle request. `skills` lists the canonical names and resulting enabled states of Skills that actually changed. Workspace skill status cells include optional `disabledReason: 'hard' | 'default' | 'inactive_extension'` and `lockedScope: 'system' | 'user' | 'systemDefaults'` fields. #### `POST /workspace/skills/enable` @@ -2809,7 +2809,7 @@ Response (200): } ``` -Target errors use `skill_not_found`, `skill_not_toggleable`, or `skill_inactive_extension`. Malformed requests return HTTP 400 with `invalid_skill_names`, `invalid_skill_name`, or `invalid_enabled_flag`. Authentication, workspace trust, client identity, unexpected persistence failures, and runtime-generation failures fail the whole request through the standard route gates. Batch-level `activation`, `sessionsRefreshed`, and `sessionsFailed` describe the single live-session refresh shared by all changed results. `activation` reports the refresh attempt rather than the outcome: a batch in which no target changed (for example, every target errored) still answers `applied` when a session is live, matching the single-Skill no-op response, so derive what actually changed from each result's `changed` flag and the `errors` array. +Target errors use `skill_not_found`, `skill_not_toggleable`, or `skill_inactive_extension`. Malformed requests return HTTP 400 with `invalid_skill_names`, `invalid_skill_name`, or `invalid_enabled_flag`. Authentication, workspace trust, client identity, unexpected persistence failures, and runtime-generation failures fail the whole request through the standard route gates. Batch-level `activation`, `sessionsRefreshed`, and `sessionsFailed` describe the single live-session refresh shared by all changed results. `activation` reports the refresh attempt rather than the outcome: a batch in which no target changed (for example, every target errored) still answers `applied` when a session is live, matching the single-Skill no-op response, so derive what actually changed from each result's `changed` flag and the `errors` array. When at least one target changes, the daemon emits the same `settings_changed` mutation metadata as the single-Skill route; every `skills.disabled` / `skills.enabled` event from that request shares one `mutation.id`. #### `POST /workspace/init` 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 db6637d7936..c42fc22feb2 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -167,6 +167,34 @@ function makeCtx( }; } +function skillToggleSettingsChanged(args: { + key: 'skills.disabled' | 'skills.enabled'; + value: unknown; + skills: Array<{ name: string; enabled: boolean }>; + activation: 'applied' | 'deferred' | 'partial'; + sessionsRefreshed: number; + sessionsFailed: number; + originatorClientId?: string; +}) { + return { + type: 'settings_changed', + data: { + key: args.key, + value: args.value, + scope: 'workspace', + mutation: { + id: expect.any(String), + kind: 'skill_toggle', + skills: args.skills, + activation: args.activation, + sessionsRefreshed: args.sessionsRefreshed, + sessionsFailed: args.sessionsFailed, + }, + }, + originatorClientId: args.originatorClientId ?? 'client-1', + }; +} + async function withIsolatedQwenHome(fn: () => Promise): Promise { return withIsolatedWorkspace(() => fn()); } @@ -1647,15 +1675,16 @@ describe('createDaemonWorkspaceService', () => { sessionsRefreshed: 2, sessionsFailed: 0, }); - expect(publishWorkspaceEvent).toHaveBeenCalledWith({ - type: 'settings_changed', - data: { + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ key: 'skills.disabled', value: ['review'], - scope: 'workspace', - }, - originatorClientId: 'client-1', - }); + skills: [{ name: 'review', enabled: false }], + activation: 'applied', + sessionsRefreshed: 2, + sessionsFailed: 0, + }), + ); }); it('does not retain a status snapshot read while a settings refresh is in flight', async () => { @@ -1750,15 +1779,16 @@ describe('createDaemonWorkspaceService', () => { enabled: true, activation: 'applied', }); - expect(publishWorkspaceEvent).toHaveBeenCalledWith({ - type: 'settings_changed', - data: { + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ key: 'skills.enabled', value: ['review'], - scope: 'workspace', - }, - originatorClientId: 'client-1', - }); + skills: [{ name: 'review', enabled: true }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }), + ); }); it('reports partial activation when a session refresh fails', async () => { @@ -2193,15 +2223,19 @@ describe('createDaemonWorkspaceService', () => { ], }); expect(publishWorkspaceEvent).toHaveBeenCalledOnce(); - expect(publishWorkspaceEvent).toHaveBeenCalledWith({ - type: 'settings_changed', - data: { + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ key: 'skills.disabled', value: ['review', 'deploy'], - scope: 'workspace', - }, - originatorClientId: 'client-1', - }); + skills: [ + { name: 'review', enabled: false }, + { name: 'deploy', enabled: false }, + ], + activation: 'applied', + sessionsRefreshed: 2, + sessionsFailed: 0, + }), + ); }); it('orders results and errors by request targets, not persist outcomes', async () => { @@ -2459,15 +2493,16 @@ describe('createDaemonWorkspaceService', () => { results: [{ skillName: 'review', enabled: true, changed: true }], }); expect(publishWorkspaceEvent).toHaveBeenCalledOnce(); - expect(publishWorkspaceEvent).toHaveBeenCalledWith({ - type: 'settings_changed', - data: { + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ key: 'skills.disabled', value: undefined, - scope: 'workspace', - }, - originatorClientId: 'client-1', - }); + skills: [{ name: 'review', enabled: true }], + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }), + ); }); it('publishes one settings_changed event per settingsChanges entry in order', async () => { @@ -2499,24 +2534,34 @@ describe('createDaemonWorkspaceService', () => { ); expect(publishWorkspaceEvent).toHaveBeenCalledTimes(2); - expect(publishWorkspaceEvent).toHaveBeenNthCalledWith(1, { - type: 'settings_changed', - data: { + expect(publishWorkspaceEvent).toHaveBeenNthCalledWith( + 1, + skillToggleSettingsChanged({ key: 'skills.disabled', value: undefined, - scope: 'workspace', - }, - originatorClientId: 'client-1', - }); - expect(publishWorkspaceEvent).toHaveBeenNthCalledWith(2, { - type: 'settings_changed', - data: { + skills: [{ name: 'review', enabled: true }], + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }), + ); + expect(publishWorkspaceEvent).toHaveBeenNthCalledWith( + 2, + skillToggleSettingsChanged({ key: 'skills.enabled', value: ['review'], - scope: 'workspace', - }, - originatorClientId: 'client-1', - }); + skills: [{ name: 'review', enabled: true }], + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }), + ); + const firstMutation = publishWorkspaceEvent.mock.calls[0]?.[0]?.data + ?.mutation as { id?: string } | undefined; + const secondMutation = publishWorkspaceEvent.mock.calls[1]?.[0]?.data + ?.mutation as { id?: string } | undefined; + expect(firstMutation?.id).toEqual(expect.any(String)); + expect(firstMutation?.id).toBe(secondMutation?.id); }); it('reports partial activation when the shared batch refresh fails', async () => { diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 599043574d6..309261216b7 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -13,6 +13,7 @@ * takes no direct reference to the bridge. */ +import { randomUUID } from 'node:crypto'; import { promises as fs, constants as fsConstants } from 'node:fs'; import * as path from 'node:path'; @@ -88,6 +89,7 @@ import type { WorkspaceSkillBatchToggleResult, WorkspaceSkillToggleError, WorkspaceSkillToggleResult, + WorkspaceSkillToggleActivation, PersistDisabledSkillsBatchResult, WorkspaceSkillInstallRequest, WorkspaceSkillMutationResult, @@ -130,6 +132,25 @@ export { const WORKSPACE_SKILLS_SNAPSHOT_TTL_MS = 5_000; +function createSkillToggleMutation(input: { + skills: ReadonlyArray<{ name: string; enabled: boolean }>; + activation: WorkspaceSkillToggleActivation; + sessionsRefreshed: number; + sessionsFailed: number; +}) { + return { + id: randomUUID(), + kind: 'skill_toggle' as const, + skills: input.skills.map((skill) => ({ + name: skill.name, + enabled: skill.enabled, + })), + activation: input.activation, + sessionsRefreshed: input.sessionsRefreshed, + sessionsFailed: input.sessionsFailed, + }; +} + /** * Walk up from `inputPath` until we find an ancestor that exists on disk, * then `realpath` it. Used by `initWorkspace` to canonicalize the parent @@ -908,6 +929,12 @@ export function createDaemonWorkspaceService( persisted.disabled.length > 0 ? persisted.disabled : undefined, }, ]; + const mutation = createSkillToggleMutation({ + skills: [{ name: skill.name, enabled }], + activation, + sessionsRefreshed, + sessionsFailed, + }); for (const change of settingsChanges) { publishWorkspaceEvent({ type: 'settings_changed', @@ -915,6 +942,7 @@ export function createDaemonWorkspaceService( key: change.key, value: change.value, scope: 'workspace', + mutation, }, originatorClientId: ctx.originatorClientId, }); @@ -1080,10 +1108,21 @@ export function createDaemonWorkspaceService( invalidateWorkspaceSkillsSnapshot(); } assertActiveGeneration(); + const mutation = createSkillToggleMutation({ + skills: results + .filter((result) => result.changed) + .map((result) => ({ + name: result.skillName, + enabled: result.enabled, + })), + activation, + sessionsRefreshed, + sessionsFailed, + }); for (const change of persisted.settingsChanges) { publishWorkspaceEvent({ type: 'settings_changed', - data: { ...change, scope: 'workspace' }, + data: { ...change, scope: 'workspace', mutation }, originatorClientId: ctx.originatorClientId, }); } diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 5a3a3ac5e54..25da4a1104f 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -9,6 +9,7 @@ import type { DaemonErrorKind, DaemonMcpTransport, DaemonSessionArtifactChange, + DaemonSkillToggleMutation, PermissionOutcome, } from './types.js'; // Single source of truth: the daemon publisher owns the wire literal in @@ -663,6 +664,14 @@ export interface DaemonToolToggledData { [key: string]: unknown; } +export interface DaemonSettingsChangedData { + key: string; + value?: unknown; + scope?: string; + mutation?: DaemonSkillToggleMutation; + [key: string]: unknown; +} + export interface DaemonTrustChangeRequestedData { workspaceCwd: string; desiredState: 'trusted' | 'untrusted'; @@ -1070,7 +1079,7 @@ export type DaemonToolToggledEvent = DaemonEventEnvelope< >; export type DaemonSettingsChangedEvent = DaemonEventEnvelope< 'settings_changed', - Record + DaemonSettingsChangedData >; export type DaemonTrustChangeRequestedEvent = DaemonEventEnvelope< 'trust_change_requested', @@ -1738,10 +1747,7 @@ export function asKnownDaemonEvent( : undefined; case 'settings_changed': return event.data != null && typeof event.data === 'object' - ? (event as DaemonEventEnvelope< - 'settings_changed', - Record - >) + ? (event as DaemonSettingsChangedEvent) : undefined; case 'trust_change_requested': return isTrustChangeRequestedData(event.data) diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 9a06d0eb07a..fab22396923 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -226,6 +226,8 @@ export type { DaemonMcpServerChangedEvent, DaemonSettingsReloadedData, DaemonSettingsReloadedEvent, + DaemonSettingsChangedData, + DaemonSettingsChangedEvent, DaemonSessionRewoundData, DaemonSessionRewoundEvent, DaemonSessionBranchedData, @@ -410,6 +412,8 @@ export type { DaemonSkillBatchToggleItem, DaemonSkillBatchToggleResult, DaemonSkillToggleActivation, + DaemonSkillToggleMutation, + DaemonSkillToggleMutationSkill, DaemonSkillToggleResult, DaemonSkillScope, DaemonSkillInstallSource, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index f3687ce52d5..206154d6a3b 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -2574,6 +2574,20 @@ export interface DaemonToolToggleResult { export type DaemonSkillToggleActivation = 'applied' | 'deferred' | 'partial'; +export interface DaemonSkillToggleMutationSkill { + name: string; + enabled: boolean; +} + +export interface DaemonSkillToggleMutation { + id: string; + kind: 'skill_toggle'; + skills: DaemonSkillToggleMutationSkill[]; + activation: DaemonSkillToggleActivation; + sessionsRefreshed: number; + sessionsFailed: number; +} + export interface DaemonSkillToggleResult { skillName: string; enabled: boolean; diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 97d43bac137..942abd715cf 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -10,6 +10,7 @@ import type { DaemonErrorKind, DaemonEvent, DaemonSessionArtifactChange, + DaemonSkillToggleMutation, } from '../types.js'; import { DAEMON_ERROR_KINDS } from '../types.js'; import type { @@ -1485,6 +1486,48 @@ function normalizeToolToggled( ]; } +function parseSkillToggleMutation( + value: unknown, +): DaemonSkillToggleMutation | undefined { + if (!isRecord(value) || value['kind'] !== 'skill_toggle') return undefined; + const id = typeof value['id'] === 'string' ? value['id'] : undefined; + const activation = value['activation']; + const skills = value['skills']; + const sessionsRefreshed = value['sessionsRefreshed']; + const sessionsFailed = value['sessionsFailed']; + if ( + !id || + (activation !== 'applied' && + activation !== 'deferred' && + activation !== 'partial') || + !Array.isArray(skills) || + skills.length === 0 || + typeof sessionsRefreshed !== 'number' || + !Number.isFinite(sessionsRefreshed) || + typeof sessionsFailed !== 'number' || + !Number.isFinite(sessionsFailed) + ) { + return undefined; + } + const parsedSkills: Array<{ name: string; enabled: boolean }> = []; + for (const skill of skills) { + if (!isRecord(skill)) return undefined; + const name = skill['name']; + const enabled = skill['enabled']; + if (typeof name !== 'string' || name.length === 0) return undefined; + if (typeof enabled !== 'boolean') return undefined; + parsedSkills.push({ name, enabled }); + } + return { + id, + kind: 'skill_toggle', + skills: parsedSkills, + activation, + sessionsRefreshed, + sessionsFailed, + }; +} + function normalizeSettingsChanged( event: DaemonEvent, base: NormalizedEventBase, @@ -1494,6 +1537,9 @@ function normalizeSettingsChanged( if (!key) { return fallbackDebug(event, base, 'malformed settings_changed payload'); } + const mutation = isRecord(event.data) + ? parseSkillToggleMutation(event.data['mutation']) + : undefined; return [ { ...base, @@ -1501,6 +1547,7 @@ function normalizeSettingsChanged( key, scope: scope ?? 'workspace', value: isRecord(event.data) ? event.data['value'] : undefined, + ...(mutation ? { mutation } : {}), }, ]; } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 5472701372a..c541a1fcef4 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -10,6 +10,7 @@ import type { DaemonEvent, DaemonErrorKind, DaemonSessionArtifactChange, + DaemonSkillToggleMutation, PermissionResponse, } from '../types.js'; @@ -480,6 +481,7 @@ export interface DaemonUiWorkspaceSettingsChangedEvent key: string; scope: string; value: unknown; + mutation?: DaemonSkillToggleMutation; } export interface DaemonUiTrustChangeRequestedEvent extends DaemonUiEventBase { diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index dca7a42b461..0b7b6adf496 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -101,12 +101,16 @@ export { type DaemonMcpServerRestartRefusedEvent, type DaemonSettingsReloadedData, type DaemonSettingsReloadedEvent, + type DaemonSettingsChangedData, + type DaemonSettingsChangedEvent, type DaemonToolToggleResult, type DaemonSkillBatchToggleError, type DaemonSkillBatchToggleErrorCode, type DaemonSkillBatchToggleItem, type DaemonSkillBatchToggleResult, type DaemonSkillToggleActivation, + type DaemonSkillToggleMutation, + type DaemonSkillToggleMutationSkill, type DaemonSkillToggleResult, type DaemonSkillScope, type DaemonSkillInstallSource, diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ffa42b564fb..081cba4bef6 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2773,6 +2773,53 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { ]); }); + it('normalizes skill-toggle mutation metadata on settings_changed', () => { + const mutation = { + id: 'mutation-1', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: true }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + const events = normalizeDaemonEvent( + envelopeOf('settings_changed', { + key: 'skills.disabled', + value: [], + scope: 'workspace', + mutation, + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.settings.changed', + key: 'skills.disabled', + scope: 'workspace', + value: [], + mutation, + }), + ]); + }); + + it('keeps settings_changed when skill-toggle mutation metadata is malformed', () => { + const events = normalizeDaemonEvent( + envelopeOf('settings_changed', { + key: 'skills.disabled', + value: ['skill-a'], + scope: 'workspace', + mutation: { kind: 'skill_toggle' }, + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.settings.changed', + key: 'skills.disabled', + value: ['skill-a'], + }), + ]); + expect(events[0]).not.toHaveProperty('mutation'); + }); + it('normalizes settings_reloaded as a settings refresh signal', () => { const events = normalizeDaemonEvent( envelopeOf('settings_reloaded', { From 123f74781eaa749a726c1e999c0e23abc38171c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Thu, 13 Aug 2026 14:07:21 +0800 Subject: [PATCH 02/18] fix(review): fit skill-toggle mutation metadata in the SDK bundle budget The new normalizer parser pushed the browser daemon bundle over the 186KB cap. Raise it to 187KB and pin the review gaps that were cheap to close. Co-authored-by: Cursor --- docs/developers/daemon/09-event-schema.md | 4 +- .../__tests__/facade.test.ts | 38 +++++++++++++- packages/sdk-typescript/scripts/build.js | 4 +- packages/sdk-typescript/src/daemon/events.ts | 8 ++- .../src/daemon/ui/normalizer.ts | 24 ++++----- .../sdk-typescript/test/unit/daemonUi.test.ts | 50 +++++++++++++------ 6 files changed, 93 insertions(+), 35 deletions(-) diff --git a/docs/developers/daemon/09-event-schema.md b/docs/developers/daemon/09-event-schema.md index d42ef521a40..c6f2027e574 100644 --- a/docs/developers/daemon/09-event-schema.md +++ b/docs/developers/daemon/09-event-schema.md @@ -77,13 +77,13 @@ Grouped by domain. | `agent_changed` | S->C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` | | `approval_mode_changed` | S->C | `sessionId, previous, next, persisted: boolean` | | `tool_toggled` | S->C | `toolName, enabled`; affects the next ACP child spawn and does not mutate already-running sessions. | -| `settings_changed` | S->C | Workspace settings write completed. Payload includes `key`, `value`, `scope`, and optional Skill-toggle `mutation`. | +| `settings_changed` | S->C | Workspace settings write completed. Payload includes `key` and `value`; `scope` and Skill-toggle `mutation` are optional. | | `settings_reloaded` | S->C | Daemon workspace service reread settings. Payload is open. | | `trust_change_requested` | S->C | `workspaceCwd, desiredState: 'trusted' \| 'untrusted', reason?` | | `workspace_initialized` | S->C | `path, action: 'created' \| 'overwrote' \| 'noop', originatorClientId?` | | `github_setup_completed` | S->C | `releaseTag, readmeUrl, secretsUrl?, workflows: [{path, status, sizeBytes?, error?}], gitignore: {path, status, added?, error?}` | -Skill toggle APIs attach optional `mutation: { id, kind: 'skill_toggle', skills: [{ name, enabled }], activation, sessionsRefreshed, sessionsFailed }`. Every `skills.disabled` / `skills.enabled` event from the same request shares one mutation id. Other settings writes omit `mutation`. +Skill toggle APIs attach optional `mutation: { id, kind: 'skill_toggle', skills: [{ name, enabled }], activation, sessionsRefreshed, sessionsFailed }`. Every `skills.disabled` / `skills.enabled` event from the same request shares one mutation id. Other settings writes omit `mutation`. Workspace-service writes include `scope`; some other emitters (for example session model switches) omit it. The SDK normalizer defaults missing `scope` to `'workspace'`. `memory_changed` also covers sessionless managed-memory tasks. For those payloads, `scope` is `"managed"`, `source` is one of 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 c42fc22feb2..bbc7a847585 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -119,6 +119,7 @@ import type { InvokeWorkspaceCommandFn, QueryWorkspaceStatusFn, WorkspaceRequestContext, + WorkspaceSkillToggleActivation, } from '../types.js'; // --------------------------------------------------------------------------- @@ -171,7 +172,7 @@ function skillToggleSettingsChanged(args: { key: 'skills.disabled' | 'skills.enabled'; value: unknown; skills: Array<{ name: string; enabled: boolean }>; - activation: 'applied' | 'deferred' | 'partial'; + activation: WorkspaceSkillToggleActivation; sessionsRefreshed: number; sessionsFailed: number; originatorClientId?: string; @@ -1792,6 +1793,7 @@ describe('createDaemonWorkspaceService', () => { }); it('reports partial activation when a session refresh fails', async () => { + const publishWorkspaceEvent = vi.fn(); const svc = createDaemonWorkspaceService( makeDeps({ queryWorkspaceStatus: statusQuery(), @@ -1803,6 +1805,7 @@ describe('createDaemonWorkspaceService', () => { sessionsRefreshed: 1, sessionsFailed: 1, }), + publishWorkspaceEvent, isChannelLive: () => true, }), ); @@ -1814,6 +1817,16 @@ describe('createDaemonWorkspaceService', () => { sessionsRefreshed: 1, sessionsFailed: 1, }); + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ + key: 'skills.disabled', + value: ['review'], + skills: [{ name: 'review', enabled: false }], + activation: 'partial', + sessionsRefreshed: 1, + sessionsFailed: 1, + }), + ); }); it('defers refresh when no child exists or the child closes mid-refresh', async () => { @@ -2565,6 +2578,7 @@ describe('createDaemonWorkspaceService', () => { }); it('reports partial activation when the shared batch refresh fails', async () => { + const publishWorkspaceEvent = vi.fn(); const failedSessions = createDaemonWorkspaceService( makeDeps({ queryWorkspaceStatus: vi.fn().mockResolvedValue({ @@ -2581,6 +2595,7 @@ describe('createDaemonWorkspaceService', () => { sessionsRefreshed: 1, sessionsFailed: 1, }), + publishWorkspaceEvent, isChannelLive: () => true, }), ); @@ -2591,6 +2606,16 @@ describe('createDaemonWorkspaceService', () => { sessionsRefreshed: 1, sessionsFailed: 1, }); + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ + key: 'skills.disabled', + value: ['review'], + skills: [{ name: 'review', enabled: false }], + activation: 'partial', + sessionsRefreshed: 1, + sessionsFailed: 1, + }), + ); const unexpectedError = createDaemonWorkspaceService( makeDeps({ @@ -2772,7 +2797,16 @@ describe('createDaemonWorkspaceService', () => { ], }); expect(invokeWorkspaceCommand).toHaveBeenCalledOnce(); - expect(publishWorkspaceEvent).toHaveBeenCalledOnce(); + expect(publishWorkspaceEvent).toHaveBeenCalledWith( + skillToggleSettingsChanged({ + key: 'skills.disabled', + value: ['deploy'], + skills: [{ name: 'deploy', enabled: false }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }), + ); }); it('drops the cached skill snapshot after a changed batch like the single-toggle path', async () => { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 2d2b3cd4b28..fff02751c26 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -81,7 +81,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 184KB to 185KB for the Live Voice lifecycle helpers on both // daemon client classes. // Bumped from 185KB to 186KB for daemon-owned mid-turn message APIs. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 186 * 1024; +// Bumped from 186KB to 187KB for skill-toggle mutation metadata on +// settings_changed in the UI normalizer. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 187 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 25da4a1104f..949ea67003f 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -1746,7 +1746,7 @@ export function asKnownDaemonEvent( ? (event as DaemonToolToggledEvent) : undefined; case 'settings_changed': - return event.data != null && typeof event.data === 'object' + return isSettingsChangedData(event.data) ? (event as DaemonSettingsChangedEvent) : undefined; case 'trust_change_requested': @@ -3002,6 +3002,12 @@ function isToolToggledData(value: unknown): value is DaemonToolToggledData { ); } +function isSettingsChangedData( + value: unknown, +): value is DaemonSettingsChangedData { + return isRecord(value) && isNonEmptyString(value['key']); +} + function isTrustChangeRequestedData( value: unknown, ): value is DaemonTrustChangeRequestedData { diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 942abd715cf..d92a79772b5 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -1490,11 +1490,11 @@ function parseSkillToggleMutation( value: unknown, ): DaemonSkillToggleMutation | undefined { if (!isRecord(value) || value['kind'] !== 'skill_toggle') return undefined; - const id = typeof value['id'] === 'string' ? value['id'] : undefined; + const id = stringField(value, 'id'); const activation = value['activation']; const skills = value['skills']; - const sessionsRefreshed = value['sessionsRefreshed']; - const sessionsFailed = value['sessionsFailed']; + const sessionsRefreshed = numberField(value, 'sessionsRefreshed'); + const sessionsFailed = numberField(value, 'sessionsFailed'); if ( !id || (activation !== 'applied' && @@ -1502,21 +1502,19 @@ function parseSkillToggleMutation( activation !== 'partial') || !Array.isArray(skills) || skills.length === 0 || - typeof sessionsRefreshed !== 'number' || - !Number.isFinite(sessionsRefreshed) || - typeof sessionsFailed !== 'number' || - !Number.isFinite(sessionsFailed) + sessionsRefreshed === undefined || + sessionsFailed === undefined ) { return undefined; } const parsedSkills: Array<{ name: string; enabled: boolean }> = []; for (const skill of skills) { - if (!isRecord(skill)) return undefined; - const name = skill['name']; - const enabled = skill['enabled']; - if (typeof name !== 'string' || name.length === 0) return undefined; - if (typeof enabled !== 'boolean') return undefined; - parsedSkills.push({ name, enabled }); + if (!isRecord(skill) || typeof skill['enabled'] !== 'boolean') { + return undefined; + } + const name = stringField(skill, 'name'); + if (!name) return undefined; + parsedSkills.push({ name, enabled: skill['enabled'] }); } return { id, diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 081cba4bef6..ff176d95d3e 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2802,22 +2802,40 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { }); it('keeps settings_changed when skill-toggle mutation metadata is malformed', () => { - const events = normalizeDaemonEvent( - envelopeOf('settings_changed', { - key: 'skills.disabled', - value: ['skill-a'], - scope: 'workspace', - mutation: { kind: 'skill_toggle' }, - }), - ); - expect(events).toEqual([ - expect.objectContaining({ - type: 'workspace.settings.changed', - key: 'skills.disabled', - value: ['skill-a'], - }), - ]); - expect(events[0]).not.toHaveProperty('mutation'); + const validMutation = { + id: 'mutation-1', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: true }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + const malformed = [ + { kind: 'skill_toggle' }, + { ...validMutation, kind: 'other' }, + { ...validMutation, activation: 'soon' }, + { ...validMutation, skills: [] }, + { ...validMutation, sessionsFailed: Number.POSITIVE_INFINITY }, + { ...validMutation, skills: [{ name: '', enabled: true }] }, + ]; + for (const mutation of malformed) { + const events = normalizeDaemonEvent( + envelopeOf('settings_changed', { + key: 'skills.disabled', + value: ['skill-a'], + scope: 'workspace', + mutation, + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.settings.changed', + key: 'skills.disabled', + value: ['skill-a'], + }), + ]); + expect(events[0]).not.toHaveProperty('mutation'); + } }); it('normalizes settings_reloaded as a settings refresh signal', () => { From 7c1c0f6497b53793bbe93e4819437c572e214360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Thu, 13 Aug 2026 17:22:48 +0800 Subject: [PATCH 03/18] test(daemon): pin skill-toggle mutation event count and parser edges Co-authored-by: Cursor --- docs/developers/daemon/09-event-schema.md | 2 +- .../__tests__/facade.test.ts | 1 + .../test/unit/daemonEvents.test.ts | 19 +++++++++++++++++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 5 +++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/developers/daemon/09-event-schema.md b/docs/developers/daemon/09-event-schema.md index c6f2027e574..b1e9d1a9150 100644 --- a/docs/developers/daemon/09-event-schema.md +++ b/docs/developers/daemon/09-event-schema.md @@ -77,7 +77,7 @@ Grouped by domain. | `agent_changed` | S->C | `change: 'created' \| 'updated' \| 'deleted', name, level: 'project' \| 'user'` | | `approval_mode_changed` | S->C | `sessionId, previous, next, persisted: boolean` | | `tool_toggled` | S->C | `toolName, enabled`; affects the next ACP child spawn and does not mutate already-running sessions. | -| `settings_changed` | S->C | Workspace settings write completed. Payload includes `key` and `value`; `scope` and Skill-toggle `mutation` are optional. | +| `settings_changed` | S->C | Workspace settings write completed. Payload includes `key`; `value`, `scope`, and Skill-toggle `mutation` are optional. | | `settings_reloaded` | S->C | Daemon workspace service reread settings. Payload is open. | | `trust_change_requested` | S->C | `workspaceCwd, desiredState: 'trusted' \| 'untrusted', reason?` | | `workspace_initialized` | S->C | `path, action: 'created' \| 'overwrote' \| 'noop', originatorClientId?` | 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 bbc7a847585..6c2c2167af5 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -2797,6 +2797,7 @@ describe('createDaemonWorkspaceService', () => { ], }); expect(invokeWorkspaceCommand).toHaveBeenCalledOnce(); + expect(publishWorkspaceEvent).toHaveBeenCalledOnce(); expect(publishWorkspaceEvent).toHaveBeenCalledWith( skillToggleSettingsChanged({ key: 'skills.disabled', diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 2227786a3f1..f6a8b2b03b5 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -482,6 +482,25 @@ describe('daemon event schema', () => { ).toBeUndefined(); }); + it('accepts settings_changed with a non-empty key and rejects missing key', () => { + expect( + asKnownDaemonEvent({ + id: 1, + v: 1, + type: 'settings_changed', + data: { key: 'skills.disabled' }, + }), + ).toBeDefined(); + expect( + asKnownDaemonEvent({ + id: 1, + v: 1, + type: 'settings_changed', + data: {}, + }), + ).toBeUndefined(); + }); + it('reduces permission, model, and terminal events into a session view', () => { const state = reduceDaemonSessionEvents([ { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ff176d95d3e..91e0e0b6fdf 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2815,8 +2815,13 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { { ...validMutation, kind: 'other' }, { ...validMutation, activation: 'soon' }, { ...validMutation, skills: [] }, + { ...validMutation, skills: 'not-an-array' }, { ...validMutation, sessionsFailed: Number.POSITIVE_INFINITY }, { ...validMutation, skills: [{ name: '', enabled: true }] }, + { + ...validMutation, + skills: [{ name: 'web-search', enabled: 'yes' }], + }, ]; for (const mutation of malformed) { const events = normalizeDaemonEvent( From c80362321ec03c9a5fa160f56851e124ccb98446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Fri, 14 Aug 2026 12:55:26 +0800 Subject: [PATCH 04/18] feat(web-shell): refresh skills incrementally after toggles --- docs/design/web-shell-skill-toggle-refresh.md | 38 +++ packages/web-shell/client/App.test.tsx | 272 +++++++++++++++++- packages/web-shell/client/App.tsx | 145 +++++++++- .../session/DaemonSessionProvider.test.tsx | 65 +++++ .../daemon/session/DaemonSessionProvider.tsx | 116 +++++--- packages/webui/src/daemon/session/types.ts | 3 + 6 files changed, 592 insertions(+), 47 deletions(-) create mode 100644 docs/design/web-shell-skill-toggle-refresh.md diff --git a/docs/design/web-shell-skill-toggle-refresh.md b/docs/design/web-shell-skill-toggle-refresh.md new file mode 100644 index 00000000000..1f5f9fadab1 --- /dev/null +++ b/docs/design/web-shell-skill-toggle-refresh.md @@ -0,0 +1,38 @@ +# Web Shell Skill Toggle Refresh + +## Context + +Skill toggle requests can emit two `settings_changed` events for one persisted +change. The daemon metadata added by #9051 gives both events the same mutation +id and reports whether live-session activation was applied, deferred, or +partial. + +Web Shell currently keeps a session-less `/workspace/skills` snapshot for the +composer. Once loaded, that snapshot overrides the active session's +`available_commands_update`, so a disabled Skill can remain in autocomplete. +The generic workspace settings signal also drops the mutation metadata and +cannot de-duplicate the two events. + +## Design + +The daemon React SDK exposes the latest Skill toggle mutation separately from +the generic settings version. Valid Skill toggle mutations do not increment the +generic settings signal, and consecutive events with the same mutation id +increment the Skill signal once. Events without valid mutation metadata retain +the existing settings behavior. + +With an active session, Web Shell treats `available_commands_update` as the +authoritative command and Skill snapshot. Without a session, it refreshes and +uses `/workspace/skills`. A deferred or partial activation also refreshes that +workspace snapshot and temporarily uses it for the affected active session; a +failed refresh is surfaced to the user. + +## Verification + +- An active-session command update can add a Skill and replace the last Skill + with an empty list without a workspace snapshot reload. +- A deferred pre-session mutation reloads `/workspace/skills` once. +- Duplicate settings events with one mutation id produce one Skill signal. +- A partial activation refreshes and uses the workspace snapshot. +- Unrelated and legacy settings events continue incrementing the generic + settings signal. diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 23e13439983..8a431cf5eb7 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -365,6 +365,21 @@ const { onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void; } | null, settings: [] as DaemonSettingDescriptor[], + workspaceEventSignals: { + artifactsVersion: 0, + extensionsVersion: 0, + skillsVersion: 0, + lastSkillMutation: undefined as + | { + id: string; + kind: 'skill_toggle'; + skills: Array<{ name: string; enabled: boolean }>; + activation: 'applied' | 'deferred' | 'partial'; + sessionsRefreshed: number; + sessionsFailed: number; + } + | undefined, + }, latestSettingsState: null as { settings: DaemonSettingDescriptor[]; } | null, @@ -472,10 +487,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => { useWorkspace: () => mockWorkspace, useWorkspaceActions: () => mockWorkspaceActions, useMcp: () => mockMcp, - useWorkspaceEventSignals: () => ({ - artifactsVersion: 0, - extensionsVersion: 0, - }), + useWorkspaceEventSignals: () => testState.workspaceEventSignals, }; }); @@ -4432,6 +4444,12 @@ beforeEach(() => { mockConnection.gitBranch = undefined; mockConnection.gitStatus = undefined; testState.ownerVersion = 0; + testState.workspaceEventSignals = { + artifactsVersion: 0, + extensionsVersion: 0, + skillsVersion: 0, + lastSkillMutation: undefined, + }; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; @@ -9623,6 +9641,7 @@ describe('App session callbacks', () => { }); it('filters disabled skills from the web-shell skills list', async () => { + mockConnection.sessionId = undefined; mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [ { @@ -9647,6 +9666,7 @@ describe('App session callbacks', () => { }); it('reloads skills when starting a new session', async () => { + mockConnection.sessionId = undefined; mockConnection.commands = [ { name: 'review', @@ -9690,6 +9710,7 @@ describe('App session callbacks', () => { }); it('adds an enabled skill command when starting a new session', async () => { + mockConnection.sessionId = undefined; mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [{ name: 'review', description: 'Review', status: 'disabled' }], }); @@ -9727,6 +9748,249 @@ describe('App session callbacks', () => { ); }); + it('uses the active session command snapshot for Skill enable and empty disable updates', async () => { + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [ + { + name: 'stale-skill', + description: 'Stale workspace snapshot', + status: 'ok', + }, + ], + }); + const { rerender } = renderApp(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'stale-skill' }), + ]), + ); + + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'enable-web-search', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: true }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }, + }; + rerender(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + + mockConnection.commands = []; + mockConnection.skills = []; + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 2, + lastSkillMutation: { + id: 'disable-web-search', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }, + }; + rerender(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(1); + expect(mockSessionActions.reloadSession).not.toHaveBeenCalled(); + }); + + it('refreshes session-less composer Skills once for a deferred mutation', async () => { + mockConnection.sessionId = undefined; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }); + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'deferred-web-search', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: true }], + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }, + }; + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + }); + + rerender(); + await flush(); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + expect(mockSessionActions.reloadSession).not.toHaveBeenCalled(); + }); + + it('uses a refreshed workspace Skill snapshot after partial activation', async () => { + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toHaveLength(1); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'partial-web-search', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }, + }; + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + + mockConnection.commands = [ + { + name: 'review', + description: 'Review changes', + source: 'skill', + raw: { + name: 'review', + description: 'Review changes', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['review']; + rerender(); + await flush(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'review', description: 'Review changes' }, + ]); + }); + + it('surfaces a failed deferred Skill snapshot refresh', async () => { + mockConnection.sessionId = undefined; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ skills: [] }) + .mockRejectedValueOnce(new Error('Skill snapshot unavailable')); + const onToast = vi.fn(); + const { rerender } = renderApp({ onToast }); + await flush(); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'failed-deferred-web-search', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: true }], + activation: 'deferred', + sessionsRefreshed: 0, + sessionsFailed: 0, + }, + }; + rerender(); + await flush(); + + expect(onToast).toHaveBeenCalledWith('error', 'Skill snapshot unavailable'); + }); + it.each([404, 410])( 'shows a missing-session empty state with a new-session action for %d', async (status) => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index fb637eecb66..1e28033549b 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -465,6 +465,31 @@ function availableSkillInfos(status: { })) .sort((a, b) => a.name.localeCompare(b.name)); } + +function availableSessionSkillInfos( + skills: readonly string[], + commands: readonly { + name: string; + description: string; + argumentHint?: string; + }[], +): SkillInfo[] { + const commandsByName = new Map( + commands.map((command) => [command.name.toLowerCase(), command]), + ); + return skills + .map((name) => { + const command = commandsByName.get(name.toLowerCase()); + return { + name, + description: command?.description ?? '', + ...(command?.argumentHint + ? { argumentHint: command.argumentHint } + : {}), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -4409,9 +4434,24 @@ export function App({ const workspaceEventSignals = useWorkspaceEventSignals(); const [loadedSkills, setLoadedSkills] = useState([]); const [loadedSkillsReady, setLoadedSkillsReady] = useState(false); + const [loadedSkillsFallbackSessionId, setLoadedSkillsFallbackSessionId] = + useState(); + const connectionSkillSnapshotRef = useRef({ + sessionId: connection.sessionId, + commands: connection.commands, + skills: connection.skills, + }); + connectionSkillSnapshotRef.current = { + sessionId: connection.sessionId, + commands: connection.commands, + skills: connection.skills, + }; + const loadedSkillsFallbackSnapshotRef = useRef< + typeof connectionSkillSnapshotRef.current | undefined + >(undefined); const loadedSkillsRequestRef = useRef(0); const reloadLoadedSkills = useCallback( - async (workspaceCwd?: string) => { + async (workspaceCwd?: string, notifyOnError = false) => { const request = ++loadedSkillsRequestRef.current; try { const status = @@ -4423,16 +4463,82 @@ export function App({ if (request !== loadedSkillsRequestRef.current) return; setLoadedSkills(availableSkillInfos(status)); setLoadedSkillsReady(true); - } catch { - return; + return true; + } catch (error) { + if (notifyOnError) { + pushToast( + 'error', + formatError(error, 'Failed to refresh composer skills'), + ); + } + return false; } }, - [workspace.client, workspaceActions], + [pushToast, workspace.client, workspaceActions], ); useEffect(() => { if (!connected) return; void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); + const handledSkillMutationIdRef = useRef(undefined); + useEffect(() => { + const mutation = workspaceEventSignals?.lastSkillMutation; + if (!mutation || handledSkillMutationIdRef.current === mutation.id) return; + handledSkillMutationIdRef.current = mutation.id; + const sessionId = connection.sessionId; + if ( + sessionId && + mutation.activation === 'applied' && + mutation.sessionsFailed === 0 + ) { + loadedSkillsFallbackSnapshotRef.current = undefined; + setLoadedSkillsFallbackSessionId(undefined); + return; + } + const sourceSnapshot = connectionSkillSnapshotRef.current; + let cancelled = false; + void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { + if (cancelled || !loaded || !sessionId) return; + const currentSnapshot = connectionSkillSnapshotRef.current; + if ( + currentSnapshot.sessionId !== sourceSnapshot.sessionId || + currentSnapshot.commands !== sourceSnapshot.commands || + currentSnapshot.skills !== sourceSnapshot.skills + ) { + return; + } + loadedSkillsFallbackSnapshotRef.current = sourceSnapshot; + setLoadedSkillsFallbackSessionId(sessionId); + }); + return () => { + cancelled = true; + }; + }, [ + connection.sessionId, + connection.workspaceCwd, + reloadLoadedSkills, + workspaceEventSignals?.lastSkillMutation, + workspaceEventSignals?.skillsVersion, + ]); + useEffect(() => { + if (!loadedSkillsFallbackSessionId) return; + const fallbackSnapshot = loadedSkillsFallbackSnapshotRef.current; + if ( + fallbackSnapshot && + fallbackSnapshot.sessionId === connection.sessionId && + fallbackSnapshot.commands === connection.commands && + fallbackSnapshot.skills === connection.skills + ) { + return; + } + loadedSkillsFallbackSnapshotRef.current = undefined; + setLoadedSkillsFallbackSessionId(undefined); + }, [ + connection.commands, + connection.sessionId, + connection.skills, + loadedSkillsFallbackSessionId, + ]); const [modelDialogMode, setModelDialogMode] = useState(null); @@ -10451,18 +10557,37 @@ export function App({ } }, [modelDialogMode, showFallbacksDialog, showAuthDialog]); + const useWorkspaceSkillSnapshot = + loadedSkillsReady && + (!connection.sessionId || + loadedSkillsFallbackSessionId === connection.sessionId); + const composerSkills = useMemo( + () => + useWorkspaceSkillSnapshot + ? loadedSkills + : availableSessionSkillInfos( + connection.skills ?? [], + connection.commands ?? [], + ), + [ + connection.commands, + connection.skills, + loadedSkills, + useWorkspaceSkillSnapshot, + ], + ); const commands = useMemo(() => { const previousSkillNames = new Set( (connection.skills ?? []).map((skill) => skill.toLowerCase()), ); - const retainedCommands = loadedSkillsReady + const retainedCommands = useWorkspaceSkillSnapshot ? (connection.commands ?? []).filter( (command) => command.source !== 'skill' && !previousSkillNames.has(command.name.toLowerCase()), ) : (connection.commands ?? []); - const refreshedSkillCommands = loadedSkillsReady + const refreshedSkillCommands = useWorkspaceSkillSnapshot ? loadedSkills.map((skill) => ({ name: skill.name, description: skill.description, @@ -10496,9 +10621,9 @@ export function App({ connection.skills, hiddenCommands, loadedSkills, - loadedSkillsReady, sideTasksAvailable, t, + useWorkspaceSkillSnapshot, ]); const welcomeHeaderProps = useMemo( @@ -12332,7 +12457,7 @@ export function App({ unknownPromptAdmission?.payloadAvailable === true } commands={commands} - skills={loadedSkills} + skills={composerSkills} slashCommandCategoryOrder={slashCommandCategoryOrder} builtinAtProviders={builtinAtProviders} atProviders={atProviders} @@ -12463,7 +12588,7 @@ export function App({ label: getModelDisplayName(m.label || m.id), contextWindow: m.contextWindow, }))} - skills={loadedSkills} + skills={composerSkills} onSelectMode={handleSetMode} onSelectModel={handleModelSelect} /> @@ -12490,7 +12615,7 @@ export function App({ label: getModelDisplayName(m.label || m.id), contextWindow: m.contextWindow, }))} - skills={loadedSkills} + skills={composerSkills} onSelectMode={handleSetMode} onSelectModel={handleModelSelect} /> diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index d3ba904b6ac..f8d8fe929dd 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -2801,6 +2801,71 @@ describe('DaemonSessionProvider', () => { }); }); + it('deduplicates skill toggle settings events from the generic settings signal', async () => { + const mutation = { + id: 'skill-toggle-1', + kind: 'skill_toggle' as const, + skills: [{ name: 'web-search', enabled: false }], + activation: 'applied' as const, + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + const session = createMockSession({ + events: async function* skillToggleEvents() { + yield { + id: 27, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.disabled', + scope: 'workspace', + value: ['web-search'], + mutation, + }, + }; + yield { + id: 28, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.enabled', + scope: 'workspace', + value: undefined, + mutation, + }, + }; + yield { + id: 29, + v: 1, + type: 'settings_changed', + data: { + key: 'ui.theme', + scope: 'workspace', + value: 'Qwen Dark', + }, + }; + }, + }); + sdkMocks.sessions.push(session); + let signals: DaemonWorkspaceEventSignals | undefined; + + function Harness() { + signals = useDaemonWorkspaceEventSignals(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + + expect(signals).toMatchObject({ + settingsVersion: 1, + skillsVersion: 1, + lastSkillMutation: mutation, + }); + }); + it('logs settings reloads without inserting daemon debug blocks', async () => { const debug = vi .spyOn(console, 'debug') diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 4db0402b4e4..27daf15c374 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -817,6 +817,7 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { agentsVersion: 0, toolsVersion: 0, settingsVersion: 0, + skillsVersion: 0, mcpVersion: 0, extensionsVersion: 0, artifactsVersion: 0, @@ -3563,25 +3564,40 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { clearNotices(); for (const notice of staged.notices) addNotice(notice); for (const id of staged.dismissNoticeIds) dismissNotice(id); - setWorkspaceEventSignals((currentSignals) => ({ - memoryVersion: - currentSignals.memoryVersion + staged.signals.memoryVersion, - agentsVersion: - currentSignals.agentsVersion + staged.signals.agentsVersion, - toolsVersion: currentSignals.toolsVersion + staged.signals.toolsVersion, - settingsVersion: - currentSignals.settingsVersion + staged.signals.settingsVersion, - mcpVersion: currentSignals.mcpVersion + staged.signals.mcpVersion, - extensionsVersion: - currentSignals.extensionsVersion + staged.signals.extensionsVersion, - artifactsVersion: - currentSignals.artifactsVersion + staged.signals.artifactsVersion, - initVersion: currentSignals.initVersion + staged.signals.initVersion, - authVersion: currentSignals.authVersion + staged.signals.authVersion, - ...(staged.signals.lastExtensionChange - ? { lastExtensionChange: staged.signals.lastExtensionChange } - : {}), - })); + setWorkspaceEventSignals((currentSignals) => { + const stagedSkillMutation = staged.signals.lastSkillMutation; + const hasNewSkillMutation = + stagedSkillMutation !== undefined && + stagedSkillMutation.id !== currentSignals.lastSkillMutation?.id; + return { + memoryVersion: + currentSignals.memoryVersion + staged.signals.memoryVersion, + agentsVersion: + currentSignals.agentsVersion + staged.signals.agentsVersion, + toolsVersion: + currentSignals.toolsVersion + staged.signals.toolsVersion, + settingsVersion: + currentSignals.settingsVersion + staged.signals.settingsVersion, + skillsVersion: + currentSignals.skillsVersion + + (hasNewSkillMutation ? staged.signals.skillsVersion : 0), + mcpVersion: currentSignals.mcpVersion + staged.signals.mcpVersion, + extensionsVersion: + currentSignals.extensionsVersion + staged.signals.extensionsVersion, + artifactsVersion: + currentSignals.artifactsVersion + staged.signals.artifactsVersion, + initVersion: currentSignals.initVersion + staged.signals.initVersion, + authVersion: currentSignals.authVersion + staged.signals.authVersion, + ...(hasNewSkillMutation + ? { lastSkillMutation: stagedSkillMutation } + : currentSignals.lastSkillMutation + ? { lastSkillMutation: currentSignals.lastSkillMutation } + : {}), + ...(staged.signals.lastExtensionChange + ? { lastExtensionChange: staged.signals.lastExtensionChange } + : {}), + }; + }); for (const event of staged.midTurnEvents) { const injected = parseSidechannelMidTurnInjected(event); if (injected) publishSidechannelMidTurnInjected(injected); @@ -5354,6 +5370,9 @@ function bumpWorkspaceEventSignals( let agents = 0; let tools = 0; let settings = 0; + let lastSkillMutation: + | DaemonWorkspaceEventSignals['lastSkillMutation'] + | undefined; let mcp = 0; let extensions = 0; let artifacts = 0; @@ -5375,7 +5394,11 @@ function bumpWorkspaceEventSignals( tools += 1; break; case 'workspace.settings.changed': - settings += 1; + if (event.mutation?.kind === 'skill_toggle') { + lastSkillMutation = event.mutation; + } else { + settings += 1; + } break; case 'workspace.mcp.budget_warning': case 'workspace.mcp.child_refused': @@ -5424,22 +5447,49 @@ function bumpWorkspaceEventSignals( artifacts + init + auth === - 0 + 0 && + !lastSkillMutation ) return; - setSignals((current) => ({ - memoryVersion: current.memoryVersion + memory, - agentsVersion: current.agentsVersion + agents, - toolsVersion: current.toolsVersion + tools, - settingsVersion: current.settingsVersion + settings, - mcpVersion: current.mcpVersion + mcp, - extensionsVersion: current.extensionsVersion + extensions, - artifactsVersion: current.artifactsVersion + artifacts, - ...(lastExtensionChange ? { lastExtensionChange } : {}), - initVersion: current.initVersion + init, - authVersion: current.authVersion + auth, - })); + setSignals((current) => { + const hasNewSkillMutation = + lastSkillMutation !== undefined && + lastSkillMutation.id !== current.lastSkillMutation?.id; + if ( + memory + + agents + + tools + + settings + + mcp + + extensions + + artifacts + + init + + auth === + 0 && + !hasNewSkillMutation + ) { + return current; + } + return { + memoryVersion: current.memoryVersion + memory, + agentsVersion: current.agentsVersion + agents, + toolsVersion: current.toolsVersion + tools, + settingsVersion: current.settingsVersion + settings, + skillsVersion: current.skillsVersion + (hasNewSkillMutation ? 1 : 0), + mcpVersion: current.mcpVersion + mcp, + extensionsVersion: current.extensionsVersion + extensions, + artifactsVersion: current.artifactsVersion + artifacts, + ...(hasNewSkillMutation + ? { lastSkillMutation } + : current.lastSkillMutation + ? { lastSkillMutation: current.lastSkillMutation } + : {}), + ...(lastExtensionChange ? { lastExtensionChange } : {}), + initVersion: current.initVersion + init, + authVersion: current.authVersion + auth, + }; + }); } function isTerminalSessionHttpError(error: unknown): boolean { diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index cb99220f0cd..3741e8c1c35 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -32,6 +32,7 @@ import type { DaemonSessionTasksStatus, DaemonSessionStatsStatus, DaemonSessionArtifactsEnvelope, + DaemonSkillToggleMutation, DaemonShellCommandResult, DaemonTranscriptBlock, DaemonTranscriptStore, @@ -505,6 +506,8 @@ export interface DaemonWorkspaceEventSignals { agentsVersion: number; toolsVersion: number; settingsVersion: number; + skillsVersion: number; + lastSkillMutation?: DaemonSkillToggleMutation; mcpVersion: number; extensionsVersion: number; artifactsVersion: number; From 07ce8eb5c86b71444448f204fc06c5f3e78f3be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Sun, 16 Aug 2026 02:18:48 +0800 Subject: [PATCH 05/18] fix(sdk): raise daemon browser bundle budget for skill-toggle metadata The 190KB cap overflowed by 491 bytes after merging main, so the SDK build fails before tests run. Co-authored-by: Cursor --- packages/sdk-typescript/scripts/build.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 3aa346524e0..0c9ecbde63e 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -87,7 +87,9 @@ const rootDir = join(__dirname, '..'); // APIs merged in from main. // Bumped from 189KB to 190KB for historical branch sessions and transcript // branch-point projection merged with the upload and reasoning APIs. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 190 * 1024; +// Bumped from 190KB to 191KB for skill-toggle mutation metadata on +// settings_changed (CI measured 195051 bytes against the 190KB cap). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 191 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't From 4d120f620699ac0c9eb3e45b51892675408607bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Tue, 18 Aug 2026 03:27:27 +0800 Subject: [PATCH 06/18] fix(web-shell): retry cancelled skill-toggle refresh per session Marking the mutation handled before the workspace reload settled dropped the fallback when the user switched sessions mid-flight. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 80 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 15 ++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f6266108b87..fd705afae0d 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -10295,6 +10295,86 @@ describe('App session callbacks', () => { expect(onToast).toHaveBeenCalledWith('error', 'Skill snapshot unavailable'); }); + it('retries a cancelled partial Skill refresh after returning to the session', async () => { + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + const pendingReload = deferred<{ + skills: Array<{ name: string; description: string; status: string }>; + }>(); + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockReturnValue(pendingReload.promise); + + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'partial-web-search-mid-switch', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }, + }; + rerender(); + await flush(); + + mockConnection.sessionId = 'session-2'; + rerender(); + await flush(); + mockConnection.sessionId = 'session-1'; + rerender(); + await flush(); + + await act(async () => { + pendingReload.resolve({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + await pendingReload.promise; + }); + await flush(); + + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + it.each([404, 410])( 'shows a missing-session empty state with a new-session action for %d', async (status) => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 626df59efdf..49e28a1be47 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4514,14 +4514,17 @@ export function App({ const handledSkillMutationIdRef = useRef(undefined); useEffect(() => { const mutation = workspaceEventSignals?.lastSkillMutation; - if (!mutation || handledSkillMutationIdRef.current === mutation.id) return; - handledSkillMutationIdRef.current = mutation.id; const sessionId = connection.sessionId; + const handleKey = mutation + ? `${mutation.id}::${sessionId ?? ''}` + : undefined; + if (!mutation || handledSkillMutationIdRef.current === handleKey) return; if ( sessionId && mutation.activation === 'applied' && mutation.sessionsFailed === 0 ) { + handledSkillMutationIdRef.current = handleKey; loadedSkillsFallbackSnapshotRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; @@ -4529,17 +4532,23 @@ export function App({ const sourceSnapshot = connectionSkillSnapshotRef.current; let cancelled = false; void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { - if (cancelled || !loaded || !sessionId) return; + if (cancelled || !loaded) return; + if (!sessionId) { + handledSkillMutationIdRef.current = handleKey; + return; + } const currentSnapshot = connectionSkillSnapshotRef.current; if ( currentSnapshot.sessionId !== sourceSnapshot.sessionId || currentSnapshot.commands !== sourceSnapshot.commands || currentSnapshot.skills !== sourceSnapshot.skills ) { + handledSkillMutationIdRef.current = handleKey; return; } loadedSkillsFallbackSnapshotRef.current = sourceSnapshot; setLoadedSkillsFallbackSessionId(sessionId); + handledSkillMutationIdRef.current = handleKey; }); return () => { cancelled = true; From e4dedf43e720a4551d72ce33f8b802fc6af70b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Tue, 18 Aug 2026 11:07:24 +0800 Subject: [PATCH 07/18] fix(web-shell): keep skill fallback until session snapshot reflects toggle Reference-identity snapshot checks dropped the workspace fallback on unrelated command updates, so a disabled Skill stayed in composer autocomplete. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 250 +++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 47 +++-- 2 files changed, 277 insertions(+), 20 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index fd705afae0d..ecf42d0d08e 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -10375,6 +10375,256 @@ describe('App session callbacks', () => { ); }); + it('keeps the workspace Skill fallback when an unrelated commands update races the refresh', async () => { + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + const pendingReload = deferred<{ + skills: Array<{ name: string; description: string; status: string }>; + }>(); + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockReturnValue(pendingReload.promise); + + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'partial-web-search-establish-race', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }, + }; + rerender(); + await flush(); + + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + + await act(async () => { + pendingReload.resolve({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + await pendingReload.promise; + }); + await flush(); + + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + + it('keeps the workspace Skill fallback after an unrelated commands update', async () => { + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toHaveLength(1); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'partial-web-search-clear-race', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }, + }; + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + + it('retries a cancelled partial Skill refresh after reconnecting', async () => { + mockConnection.commands = [ + { + name: 'web-search', + description: 'Search the web', + source: 'skill', + raw: { + name: 'web-search', + description: 'Search the web', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['web-search']; + const pendingReload = deferred<{ + skills: Array<{ name: string; description: string; status: string }>; + }>(); + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockReturnValue(pendingReload.promise); + + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 1, + lastSkillMutation: { + id: 'partial-web-search-reconnect', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }, + }; + rerender(); + await flush(); + + mockConnection.status = 'disconnected'; + rerender(); + await flush(); + mockConnection.status = 'connected'; + rerender(); + await flush(); + + await act(async () => { + pendingReload.resolve({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + await pendingReload.promise; + }); + await flush(); + + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + it.each([404, 410])( 'shows a missing-session empty state with a new-session action for %d', async (status) => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 49e28a1be47..daaa55d9142 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -495,6 +495,20 @@ function availableSessionSkillInfos( }) .sort((a, b) => a.name.localeCompare(b.name)); } + +function sessionSkillsReflectToggle( + sessionSkills: readonly string[] | undefined, + mutationSkills: ReadonlyArray<{ name: string; enabled: boolean }>, +): boolean { + const enabledNames = new Set( + (sessionSkills ?? []).map((name) => name.toLowerCase()), + ); + return mutationSkills.every((skill) => { + const enabled = enabledNames.has(skill.name.toLowerCase()); + return skill.enabled === enabled; + }); +} + const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -4469,17 +4483,12 @@ export function App({ useState(); const connectionSkillSnapshotRef = useRef({ sessionId: connection.sessionId, - commands: connection.commands, skills: connection.skills, }); connectionSkillSnapshotRef.current = { sessionId: connection.sessionId, - commands: connection.commands, skills: connection.skills, }; - const loadedSkillsFallbackSnapshotRef = useRef< - typeof connectionSkillSnapshotRef.current | undefined - >(undefined); const loadedSkillsRequestRef = useRef(0); const reloadLoadedSkills = useCallback( async (workspaceCwd?: string, notifyOnError = false) => { @@ -4513,6 +4522,7 @@ export function App({ }, [connected, connection.workspaceCwd, reloadLoadedSkills]); const handledSkillMutationIdRef = useRef(undefined); useEffect(() => { + if (!connected) return; const mutation = workspaceEventSignals?.lastSkillMutation; const sessionId = connection.sessionId; const handleKey = mutation @@ -4525,11 +4535,9 @@ export function App({ mutation.sessionsFailed === 0 ) { handledSkillMutationIdRef.current = handleKey; - loadedSkillsFallbackSnapshotRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; } - const sourceSnapshot = connectionSkillSnapshotRef.current; let cancelled = false; void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { if (cancelled || !loaded) return; @@ -4539,14 +4547,12 @@ export function App({ } const currentSnapshot = connectionSkillSnapshotRef.current; if ( - currentSnapshot.sessionId !== sourceSnapshot.sessionId || - currentSnapshot.commands !== sourceSnapshot.commands || - currentSnapshot.skills !== sourceSnapshot.skills + currentSnapshot.sessionId === sessionId && + sessionSkillsReflectToggle(currentSnapshot.skills, mutation.skills) ) { handledSkillMutationIdRef.current = handleKey; return; } - loadedSkillsFallbackSnapshotRef.current = sourceSnapshot; setLoadedSkillsFallbackSessionId(sessionId); handledSkillMutationIdRef.current = handleKey; }); @@ -4554,6 +4560,7 @@ export function App({ cancelled = true; }; }, [ + connected, connection.sessionId, connection.workspaceCwd, reloadLoadedSkills, @@ -4562,22 +4569,22 @@ export function App({ ]); useEffect(() => { if (!loadedSkillsFallbackSessionId) return; - const fallbackSnapshot = loadedSkillsFallbackSnapshotRef.current; + if (connection.sessionId !== loadedSkillsFallbackSessionId) { + setLoadedSkillsFallbackSessionId(undefined); + return; + } + const mutation = workspaceEventSignals?.lastSkillMutation; if ( - fallbackSnapshot && - fallbackSnapshot.sessionId === connection.sessionId && - fallbackSnapshot.commands === connection.commands && - fallbackSnapshot.skills === connection.skills + mutation && + sessionSkillsReflectToggle(connection.skills, mutation.skills) ) { - return; + setLoadedSkillsFallbackSessionId(undefined); } - loadedSkillsFallbackSnapshotRef.current = undefined; - setLoadedSkillsFallbackSessionId(undefined); }, [ - connection.commands, connection.sessionId, connection.skills, loadedSkillsFallbackSessionId, + workspaceEventSignals?.lastSkillMutation, ]); const [modelDialogMode, setModelDialogMode] = From f89bc733fd752a9bb8b70202ec6e16185cfab9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Tue, 18 Aug 2026 15:26:36 +0800 Subject: [PATCH 08/18] fix(web-shell): scope skill-toggle fallback to workspace and pending toggles A partial toggle from another workspace, a superseded later mutation, or an unknown session skill list could leave a stale Skill in composer autocomplete. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 217 ++++++++++++++++++++++++- packages/web-shell/client/App.tsx | 71 ++++++-- 2 files changed, 276 insertions(+), 12 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 98e23d92c1e..3803a78cb36 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -37,7 +37,7 @@ type MockConnection = { currentMode: string; models: Array<{ id: string; label?: string }>; commands: unknown[]; - skills: string[]; + skills: string[] | undefined; capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; @@ -4368,6 +4368,38 @@ function deferred(): { return { promise, resolve, reject }; } +function skillCommandFixture(name: string, description: string) { + return { + name, + description, + source: 'skill', + raw: { + name, + description, + input: null, + _meta: { source: 'skill' }, + }, + }; +} + +function emitPartialSkillMutation( + id: string, + skills: Array<{ name: string; enabled: boolean }>, +): void { + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: testState.workspaceEventSignals.skillsVersion + 1, + lastSkillMutation: { + id, + kind: 'skill_toggle', + skills, + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }, + }; +} + async function triggerAutoRecap(): Promise<{ recap: ReturnType< typeof deferred<{ sessionId: string; recap: string | null }> @@ -10614,6 +10646,189 @@ describe('App session callbacks', () => { ); }); + it('does not pin a later workspace to an earlier workspace Skill mutation', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + mockWorkspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: + cwd === '/tmp/other' + ? vi.fn().mockResolvedValue({ + skills: [ + { + name: 'review', + description: 'Review changes', + status: 'ok', + }, + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + : mockWorkspaceActions.loadSkillsStatus, + workspaceGitHubPullRequests: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: cwd, + available: true, + pullRequests: [], + }), + })); + + const { rerender } = renderApp(); + await flush(); + emitPartialSkillMutation('partial-web-search-workspace-a', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + mockConnection.workspaceCwd = '/tmp/other'; + mockConnection.sessionId = 'session-2'; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'review' })]), + ); + }); + + it('keeps an earlier partial Skill toggle when a later mutation is already reflected', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + const pendingReload = deferred<{ + skills: Array<{ name: string; description: string; status: string }>; + }>(); + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockReturnValue(pendingReload.promise); + + const { rerender } = renderApp(); + await flush(); + emitPartialSkillMutation('partial-web-search-first', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await flush(); + + emitPartialSkillMutation('partial-review-already-absent', [ + { name: 'review', enabled: false }, + ]); + rerender(); + await flush(); + + await act(async () => { + pendingReload.resolve({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + await pendingReload.promise; + }); + await flush(); + + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + + it('does not treat an unknown session Skill list as reflecting a disable', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = undefined; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + + const { rerender } = renderApp(); + await flush(); + emitPartialSkillMutation('partial-web-search-unknown-skills', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + it.each([404, 410])( 'shows a missing-session empty state with a new-session action for %d', async (status) => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 15903b0e059..93d8c3b49ba 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -499,15 +499,29 @@ function sessionSkillsReflectToggle( sessionSkills: readonly string[] | undefined, mutationSkills: ReadonlyArray<{ name: string; enabled: boolean }>, ): boolean { - const enabledNames = new Set( - (sessionSkills ?? []).map((name) => name.toLowerCase()), - ); + if (sessionSkills === undefined) { + return false; + } + const enabledNames = new Set(sessionSkills.map((name) => name.toLowerCase())); return mutationSkills.every((skill) => { const enabled = enabledNames.has(skill.name.toLowerCase()); return skill.enabled === enabled; }); } +function mergeSkillToggles( + current: ReadonlyArray<{ name: string; enabled: boolean }>, + incoming: ReadonlyArray<{ name: string; enabled: boolean }>, +): Array<{ name: string; enabled: boolean }> { + const byName = new Map( + current.map((skill) => [skill.name.toLowerCase(), skill] as const), + ); + for (const skill of incoming) { + byName.set(skill.name.toLowerCase(), skill); + } + return [...byName.values()]; +} + const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -4533,12 +4547,32 @@ export function App({ void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); const handledSkillMutationIdRef = useRef(undefined); + const skillMutationOriginRef = useRef<{ + id: string; + workspaceCwd?: string; + }>(); + const pendingSkillTogglesRef = useRef< + Array<{ name: string; enabled: boolean }> + >([]); useEffect(() => { if (!connected) return; const mutation = workspaceEventSignals?.lastSkillMutation; const sessionId = connection.sessionId; + const workspaceCwd = connection.workspaceCwd; + if (mutation && skillMutationOriginRef.current?.id !== mutation.id) { + skillMutationOriginRef.current = { + id: mutation.id, + workspaceCwd, + }; + } + if ( + mutation && + skillMutationOriginRef.current?.workspaceCwd !== workspaceCwd + ) { + return; + } const handleKey = mutation - ? `${mutation.id}::${sessionId ?? ''}` + ? `${mutation.id}::${sessionId ?? ''}::${workspaceCwd ?? ''}` : undefined; if (!mutation || handledSkillMutationIdRef.current === handleKey) return; if ( @@ -4547,11 +4581,16 @@ export function App({ mutation.sessionsFailed === 0 ) { handledSkillMutationIdRef.current = handleKey; + pendingSkillTogglesRef.current = []; setLoadedSkillsFallbackSessionId(undefined); return; } + pendingSkillTogglesRef.current = mergeSkillToggles( + pendingSkillTogglesRef.current, + mutation.skills, + ); let cancelled = false; - void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { + void reloadLoadedSkills(workspaceCwd, true).then((loaded) => { if (cancelled || !loaded) return; if (!sessionId) { handledSkillMutationIdRef.current = handleKey; @@ -4560,8 +4599,12 @@ export function App({ const currentSnapshot = connectionSkillSnapshotRef.current; if ( currentSnapshot.sessionId === sessionId && - sessionSkillsReflectToggle(currentSnapshot.skills, mutation.skills) + sessionSkillsReflectToggle( + currentSnapshot.skills, + pendingSkillTogglesRef.current, + ) ) { + pendingSkillTogglesRef.current = []; handledSkillMutationIdRef.current = handleKey; return; } @@ -4581,22 +4624,28 @@ export function App({ ]); useEffect(() => { if (!loadedSkillsFallbackSessionId) return; - if (connection.sessionId !== loadedSkillsFallbackSessionId) { + if ( + connection.sessionId !== loadedSkillsFallbackSessionId || + skillMutationOriginRef.current?.workspaceCwd !== connection.workspaceCwd + ) { setLoadedSkillsFallbackSessionId(undefined); return; } - const mutation = workspaceEventSignals?.lastSkillMutation; if ( - mutation && - sessionSkillsReflectToggle(connection.skills, mutation.skills) + pendingSkillTogglesRef.current.length > 0 && + sessionSkillsReflectToggle( + connection.skills, + pendingSkillTogglesRef.current, + ) ) { + pendingSkillTogglesRef.current = []; setLoadedSkillsFallbackSessionId(undefined); } }, [ connection.sessionId, connection.skills, + connection.workspaceCwd, loadedSkillsFallbackSessionId, - workspaceEventSignals?.lastSkillMutation, ]); const [modelDialogMode, setModelDialogMode] = From ac03fcc77aca8f876917161a06f45d1d29735d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Tue, 18 Aug 2026 17:01:12 +0800 Subject: [PATCH 09/18] fix(web-shell): pass initial value to skill-mutation origin ref React 19's useRef types require an argument; the missing initializer broke the web-shell build. Co-authored-by: Cursor --- packages/web-shell/client/App.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 93d8c3b49ba..80c2c2aa281 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4547,10 +4547,9 @@ export function App({ void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); const handledSkillMutationIdRef = useRef(undefined); - const skillMutationOriginRef = useRef<{ - id: string; - workspaceCwd?: string; - }>(); + const skillMutationOriginRef = useRef< + { id: string; workspaceCwd?: string } | undefined + >(undefined); const pendingSkillTogglesRef = useRef< Array<{ name: string; enabled: boolean }> >([]); From 848aee5edb4caf35b79fe3e73c53865e75bbee85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Tue, 18 Aug 2026 20:40:09 +0800 Subject: [PATCH 10/18] fix(web-shell): restore skill fallback after workspace round-trip Dropping the fallback on a workspace switch left the handled mark in place, so returning to the origin session never reinstalled it. An unknown session skill list now also uses the ready workspace snapshot instead of an empty composer. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 39 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 2 ++ 2 files changed, 41 insertions(+) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 3803a78cb36..4ebfde72e95 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -10724,6 +10724,21 @@ describe('App session callbacks', () => { expect(testState.latestChatEditorProps?.commands).not.toEqual( expect.arrayContaining([expect.objectContaining({ name: 'review' })]), ); + + mockConnection.workspaceCwd = '/tmp/project'; + mockConnection.sessionId = 'session-1'; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); }); it('keeps an earlier partial Skill toggle when a later mutation is already reflected', async () => { @@ -10782,6 +10797,30 @@ describe('App session callbacks', () => { ); }); + it('uses the workspace Skill snapshot while the session Skill list is unknown', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = undefined; + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }); + + renderApp(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + }); + }); + it('does not treat an unknown session Skill list as reflecting a disable', async () => { mockConnection.commands = [ skillCommandFixture('web-search', 'Search the web'), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 1f4c87af65d..25184b71714 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4627,6 +4627,7 @@ export function App({ connection.sessionId !== loadedSkillsFallbackSessionId || skillMutationOriginRef.current?.workspaceCwd !== connection.workspaceCwd ) { + handledSkillMutationIdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; } @@ -10819,6 +10820,7 @@ export function App({ const useWorkspaceSkillSnapshot = loadedSkillsReady && (!connection.sessionId || + connection.skills === undefined || loadedSkillsFallbackSessionId === connection.sessionId); const composerSkills = useMemo( () => From 1c2b8d3c333705646c6f3fb8662f40e85c73122d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Wed, 19 Aug 2026 03:16:07 +0800 Subject: [PATCH 11/18] fix(web-shell): keep skill mutations per workspace after toggles An intervening toggle in another workspace overwrote the only mutation slot, so a failed live refresh could not restore that workspace's composer snapshot. Also read nested _meta.availableSkills on live command updates so the session skill list is not wiped to empty. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 144 ++++++++++++++++-- packages/web-shell/client/App.tsx | 45 ++++-- .../session/DaemonSessionProvider.test.tsx | 3 + .../daemon/session/DaemonSessionProvider.tsx | 16 +- .../webui/src/daemon/session/mappers.test.ts | 22 +++ packages/webui/src/daemon/session/mappers.ts | 14 +- packages/webui/src/daemon/session/types.ts | 1 + 7 files changed, 219 insertions(+), 26 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 4ebfde72e95..e44bc6afbc9 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -386,6 +386,19 @@ const { sessionsFailed: number; } | undefined, + lastSkillMutationsByCwd: undefined as + | Record< + string, + { + id: string; + kind: 'skill_toggle'; + skills: Array<{ name: string; enabled: boolean }>; + activation: 'applied' | 'deferred' | 'partial'; + sessionsRefreshed: number; + sessionsFailed: number; + } + > + | undefined, }, latestSettingsState: null as { settings: DaemonSettingDescriptor[]; @@ -4382,24 +4395,42 @@ function skillCommandFixture(name: string, description: string) { }; } -function emitPartialSkillMutation( +function emitSkillMutation( id: string, skills: Array<{ name: string; enabled: boolean }>, + activation: 'applied' | 'deferred' | 'partial' = 'partial', ): void { + const mutation = { + id, + kind: 'skill_toggle' as const, + skills, + activation, + sessionsRefreshed: activation === 'applied' ? 1 : 0, + sessionsFailed: activation === 'partial' ? 1 : 0, + }; + const cwd = mockConnection.workspaceCwd; testState.workspaceEventSignals = { ...testState.workspaceEventSignals, skillsVersion: testState.workspaceEventSignals.skillsVersion + 1, - lastSkillMutation: { - id, - kind: 'skill_toggle', - skills, - activation: 'partial', - sessionsRefreshed: 0, - sessionsFailed: 1, - }, + lastSkillMutation: mutation, + ...(cwd + ? { + lastSkillMutationsByCwd: { + ...testState.workspaceEventSignals.lastSkillMutationsByCwd, + [cwd]: mutation, + }, + } + : {}), }; } +function emitPartialSkillMutation( + id: string, + skills: Array<{ name: string; enabled: boolean }>, +): void { + emitSkillMutation(id, skills, 'partial'); +} + async function triggerAutoRecap(): Promise<{ recap: ReturnType< typeof deferred<{ sessionId: string; recap: string | null }> @@ -4514,6 +4545,7 @@ beforeEach(() => { extensionsVersion: 0, skillsVersion: 0, lastSkillMutation: undefined, + lastSkillMutationsByCwd: undefined, }; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], @@ -10741,6 +10773,100 @@ describe('App session callbacks', () => { ); }); + it('restores an earlier workspace Skill fallback after an intervening toggle', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + mockWorkspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: + cwd === '/tmp/other' + ? vi.fn().mockResolvedValue({ + skills: [ + { + name: 'review', + description: 'Review changes', + status: 'ok', + }, + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + : mockWorkspaceActions.loadSkillsStatus, + workspaceGitHubPullRequests: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: cwd, + available: true, + pullRequests: [], + }), + })); + + const { rerender } = renderApp(); + await flush(); + emitPartialSkillMutation('partial-web-search-workspace-a', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + mockConnection.workspaceCwd = '/tmp/other'; + mockConnection.sessionId = 'session-2'; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + emitSkillMutation( + 'applied-review-workspace-b', + [{ name: 'review', enabled: false }], + 'applied', + ); + rerender(); + await flush(); + + mockConnection.workspaceCwd = '/tmp/project'; + mockConnection.sessionId = 'session-1'; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + it('keeps an earlier partial Skill toggle when a later mutation is already reflected', async () => { mockConnection.commands = [ skillCommandFixture('web-search', 'Search the web'), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 25184b71714..b0030a52b0f 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -41,6 +41,7 @@ import { import type { DaemonInputAnnotation, DaemonSessionAgentTaskStatus, + DaemonSkillToggleMutation, DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, @@ -509,6 +510,21 @@ function sessionSkillsReflectToggle( }); } +function skillMutationForWorkspace( + signals: + | { + lastSkillMutation?: DaemonSkillToggleMutation; + lastSkillMutationsByCwd?: Record; + } + | undefined, + workspaceCwd?: string, +): DaemonSkillToggleMutation | undefined { + if (workspaceCwd && signals?.lastSkillMutationsByCwd) { + return signals.lastSkillMutationsByCwd[workspaceCwd]; + } + return signals?.lastSkillMutation; +} + function mergeSkillToggles( current: ReadonlyArray<{ name: string; enabled: boolean }>, incoming: ReadonlyArray<{ name: string; enabled: boolean }>, @@ -4547,26 +4563,27 @@ export function App({ void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); const handledSkillMutationIdRef = useRef(undefined); - const skillMutationOriginRef = useRef< - { id: string; workspaceCwd?: string } | undefined - >(undefined); + const skillMutationOriginByIdRef = useRef>( + new Map(), + ); const pendingSkillTogglesRef = useRef< Array<{ name: string; enabled: boolean }> >([]); + const fallbackWorkspaceCwdRef = useRef(undefined); useEffect(() => { if (!connected) return; - const mutation = workspaceEventSignals?.lastSkillMutation; const sessionId = connection.sessionId; const workspaceCwd = connection.workspaceCwd; - if (mutation && skillMutationOriginRef.current?.id !== mutation.id) { - skillMutationOriginRef.current = { - id: mutation.id, - workspaceCwd, - }; + const mutation = skillMutationForWorkspace( + workspaceEventSignals, + workspaceCwd, + ); + if (mutation && !skillMutationOriginByIdRef.current.has(mutation.id)) { + skillMutationOriginByIdRef.current.set(mutation.id, workspaceCwd); } if ( mutation && - skillMutationOriginRef.current?.workspaceCwd !== workspaceCwd + skillMutationOriginByIdRef.current.get(mutation.id) !== workspaceCwd ) { return; } @@ -4581,6 +4598,7 @@ export function App({ ) { handledSkillMutationIdRef.current = handleKey; pendingSkillTogglesRef.current = []; + fallbackWorkspaceCwdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; } @@ -4607,12 +4625,16 @@ export function App({ handledSkillMutationIdRef.current = handleKey; return; } + fallbackWorkspaceCwdRef.current = workspaceCwd; setLoadedSkillsFallbackSessionId(sessionId); handledSkillMutationIdRef.current = handleKey; }); return () => { cancelled = true; }; + // Skill-specific fields only: other workspaceEventSignals versions must + // not cancel an in-flight /workspace/skills refresh. + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above }, [ connected, connection.sessionId, @@ -4625,9 +4647,10 @@ export function App({ if (!loadedSkillsFallbackSessionId) return; if ( connection.sessionId !== loadedSkillsFallbackSessionId || - skillMutationOriginRef.current?.workspaceCwd !== connection.workspaceCwd + fallbackWorkspaceCwdRef.current !== connection.workspaceCwd ) { handledSkillMutationIdRef.current = undefined; + fallbackWorkspaceCwdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; } diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c3e01c405e3..3d3fd5a23eb 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -2925,6 +2925,9 @@ describe('DaemonSessionProvider', () => { settingsVersion: 1, skillsVersion: 1, lastSkillMutation: mutation, + lastSkillMutationsByCwd: { + '/mock-workspace': mutation, + }, }); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 5ad2aeff6e1..7d81518cd79 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -1742,6 +1742,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { bumpWorkspaceEventSignals( sideEffectEvents, setWorkspaceEventSignals, + activeSession.workspaceCwd, ); } if (replayExceededCapacity && historyHasMore) { @@ -2130,7 +2131,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { break; } } - bumpWorkspaceEventSignals(uiEvents, setWorkspaceEventSignals); + bumpWorkspaceEventSignals( + uiEvents, + setWorkspaceEventSignals, + activeSession.workspaceCwd, + ); if (uiEvents.length > 0) { const hasGenerationSignal = hasActiveGenerationSignal(uiEvents); setPromptStatus((current) => @@ -3883,6 +3888,7 @@ function getNumber( function bumpWorkspaceEventSignals( events: readonly DaemonUiEvent[], setSignals: Dispatch>, + workspaceCwd?: string, ): void { let memory = 0; let agents = 0; @@ -3989,6 +3995,13 @@ function bumpWorkspaceEventSignals( ) { return current; } + const lastSkillMutationsByCwd = + hasNewSkillMutation && workspaceCwd && lastSkillMutation + ? { + ...current.lastSkillMutationsByCwd, + [workspaceCwd]: lastSkillMutation, + } + : current.lastSkillMutationsByCwd; return { memoryVersion: current.memoryVersion + memory, agentsVersion: current.agentsVersion + agents, @@ -4003,6 +4016,7 @@ function bumpWorkspaceEventSignals( : current.lastSkillMutation ? { lastSkillMutation: current.lastSkillMutation } : {}), + ...(lastSkillMutationsByCwd ? { lastSkillMutationsByCwd } : {}), ...(lastExtensionChange ? { lastExtensionChange } : {}), initVersion: current.initVersion + init, authVersion: current.authVersion + auth, diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index ebbe1d5eea4..495d9b59fad 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -341,6 +341,28 @@ describe('updateConnectionFromDaemonEvent', () => { expect(next.skills).toEqual(['review']); }); + it('reads availableSkills from nested _meta when the flat field is absent', () => { + const next = applyEvent( + { status: 'connected', workspaceCwd: '/workspace' }, + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'review', description: 'Review a PR', input: null }, + ], + _meta: { availableSkills: ['review'] }, + }, + }, + } as DaemonEvent, + ); + + expect(next.skills).toEqual(['review']); + }); + it('clears stale commands when the update reports an empty list', () => { // The daemon snapshot is authoritative: a list that shrank to empty must // not leave the previous commands autocompleting. Keying on length would diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 12f01ab3161..fe07e01d54b 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -483,11 +483,15 @@ function mapAvailableCommandsUpdate( }, ]; }); - const skills = Array.isArray(update['availableSkills']) - ? update['availableSkills'].filter( - (skill): skill is string => typeof skill === 'string', - ) - : []; + const nestedSkills = getRecord(update['_meta'])?.['availableSkills']; + const rawSkills = Array.isArray(update['availableSkills']) + ? update['availableSkills'] + : Array.isArray(nestedSkills) + ? nestedSkills + : []; + const skills = rawSkills.filter( + (skill): skill is string => typeof skill === 'string', + ); const skillCommands = skills.map((skill) => ({ name: skill, description: '', diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index fefbcda3289..52da50eb6bc 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -524,6 +524,7 @@ export interface DaemonWorkspaceEventSignals { settingsVersion: number; skillsVersion: number; lastSkillMutation?: DaemonSkillToggleMutation; + lastSkillMutationsByCwd?: Record; mcpVersion: number; extensionsVersion: number; artifactsVersion: number; From 9660540d90ef185000db6f9d89c4992e96e70a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Wed, 19 Aug 2026 12:54:11 +0800 Subject: [PATCH 12/18] fix(web-shell): retain batched skill mutations instead of the latest only Two distinct toggles in one replay or live commit used to keep only the last mutation, so a later applied toggle could drop an earlier partial fallback and leave a disabled skill in composer autocomplete. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 74 ++++++++++++++++++- packages/web-shell/client/App.tsx | 73 ++++++++++-------- .../session/DaemonSessionProvider.test.tsx | 67 ++++++++++++++++- .../daemon/session/DaemonSessionProvider.tsx | 34 ++++++--- packages/webui/src/daemon/session/types.ts | 2 +- 5 files changed, 203 insertions(+), 47 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index ded6f2ea9e9..8835c0e47ed 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -389,14 +389,14 @@ const { lastSkillMutationsByCwd: undefined as | Record< string, - { + Array<{ id: string; kind: 'skill_toggle'; skills: Array<{ name: string; enabled: boolean }>; activation: 'applied' | 'deferred' | 'partial'; sessionsRefreshed: number; sessionsFailed: number; - } + }> > | undefined, }, @@ -4409,6 +4409,8 @@ function emitSkillMutation( sessionsFailed: activation === 'partial' ? 1 : 0, }; const cwd = mockConnection.workspaceCwd; + const previousByCwd = testState.workspaceEventSignals.lastSkillMutationsByCwd; + const existing = cwd ? (previousByCwd?.[cwd] ?? []) : []; testState.workspaceEventSignals = { ...testState.workspaceEventSignals, skillsVersion: testState.workspaceEventSignals.skillsVersion + 1, @@ -4416,8 +4418,10 @@ function emitSkillMutation( ...(cwd ? { lastSkillMutationsByCwd: { - ...testState.workspaceEventSignals.lastSkillMutationsByCwd, - [cwd]: mutation, + ...previousByCwd, + [cwd]: existing.some((entry) => entry.id === mutation.id) + ? existing + : [...existing, mutation], }, } : {}), @@ -10867,6 +10871,68 @@ describe('App session callbacks', () => { ); }); + it('keeps a batched partial Skill toggle when a later applied mutation arrives', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + + const { rerender } = renderApp(); + await flush(); + + const partialDisable = { + id: 'partial-web-search-batch', + kind: 'skill_toggle' as const, + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial' as const, + sessionsRefreshed: 0, + sessionsFailed: 1, + }; + const appliedOther = { + id: 'applied-review-batch', + kind: 'skill_toggle' as const, + skills: [{ name: 'review', enabled: false }], + activation: 'applied' as const, + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: testState.workspaceEventSignals.skillsVersion + 2, + lastSkillMutation: appliedOther, + lastSkillMutationsByCwd: { + '/tmp/project': [partialDisable, appliedOther], + }, + }; + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + it('keeps an earlier partial Skill toggle when a later mutation is already reflected', async () => { mockConnection.commands = [ skillCommandFixture('web-search', 'Search the web'), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 53e78f1fa8c..80be2d6ca0d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -510,19 +510,19 @@ function sessionSkillsReflectToggle( }); } -function skillMutationForWorkspace( +function skillMutationsForWorkspace( signals: | { lastSkillMutation?: DaemonSkillToggleMutation; - lastSkillMutationsByCwd?: Record; + lastSkillMutationsByCwd?: Record; } | undefined, workspaceCwd?: string, -): DaemonSkillToggleMutation | undefined { +): DaemonSkillToggleMutation[] { if (workspaceCwd && signals?.lastSkillMutationsByCwd) { - return signals.lastSkillMutationsByCwd[workspaceCwd]; + return signals.lastSkillMutationsByCwd[workspaceCwd] ?? []; } - return signals?.lastSkillMutation; + return signals?.lastSkillMutation ? [signals.lastSkillMutation] : []; } function mergeSkillToggles( @@ -4562,7 +4562,7 @@ export function App({ if (!connected) return; void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); - const handledSkillMutationIdRef = useRef(undefined); + const handledSkillMutationKeysRef = useRef>(new Set()); const skillMutationOriginByIdRef = useRef>( new Map(), ); @@ -4574,43 +4574,56 @@ export function App({ if (!connected) return; const sessionId = connection.sessionId; const workspaceCwd = connection.workspaceCwd; - const mutation = skillMutationForWorkspace( + const mutations = skillMutationsForWorkspace( workspaceEventSignals, workspaceCwd, ); - if (mutation && !skillMutationOriginByIdRef.current.has(mutation.id)) { - skillMutationOriginByIdRef.current.set(mutation.id, workspaceCwd); - } - if ( - mutation && - skillMutationOriginByIdRef.current.get(mutation.id) !== workspaceCwd - ) { - return; + const unhandled: DaemonSkillToggleMutation[] = []; + for (const mutation of mutations) { + if (!skillMutationOriginByIdRef.current.has(mutation.id)) { + skillMutationOriginByIdRef.current.set(mutation.id, workspaceCwd); + } + if ( + skillMutationOriginByIdRef.current.get(mutation.id) !== workspaceCwd + ) { + continue; + } + const handleKey = `${mutation.id}::${sessionId ?? ''}::${workspaceCwd ?? ''}`; + if (handledSkillMutationKeysRef.current.has(handleKey)) continue; + unhandled.push(mutation); } - const handleKey = mutation - ? `${mutation.id}::${sessionId ?? ''}::${workspaceCwd ?? ''}` - : undefined; - if (!mutation || handledSkillMutationIdRef.current === handleKey) return; + if (unhandled.length === 0) return; + const markHandled = () => { + for (const mutation of unhandled) { + handledSkillMutationKeysRef.current.add( + `${mutation.id}::${sessionId ?? ''}::${workspaceCwd ?? ''}`, + ); + } + }; + const priorPending = pendingSkillTogglesRef.current; + pendingSkillTogglesRef.current = mergeSkillToggles( + priorPending, + unhandled.flatMap((mutation) => mutation.skills), + ); if ( sessionId && - mutation.activation === 'applied' && - mutation.sessionsFailed === 0 + priorPending.length === 0 && + unhandled.every( + (mutation) => + mutation.activation === 'applied' && mutation.sessionsFailed === 0, + ) ) { - handledSkillMutationIdRef.current = handleKey; + markHandled(); pendingSkillTogglesRef.current = []; fallbackWorkspaceCwdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; } - pendingSkillTogglesRef.current = mergeSkillToggles( - pendingSkillTogglesRef.current, - mutation.skills, - ); let cancelled = false; void reloadLoadedSkills(workspaceCwd, true).then((loaded) => { if (cancelled || !loaded) return; if (!sessionId) { - handledSkillMutationIdRef.current = handleKey; + markHandled(); return; } const currentSnapshot = connectionSkillSnapshotRef.current; @@ -4622,12 +4635,12 @@ export function App({ ) ) { pendingSkillTogglesRef.current = []; - handledSkillMutationIdRef.current = handleKey; + markHandled(); return; } fallbackWorkspaceCwdRef.current = workspaceCwd; setLoadedSkillsFallbackSessionId(sessionId); - handledSkillMutationIdRef.current = handleKey; + markHandled(); }); return () => { cancelled = true; @@ -4649,7 +4662,7 @@ export function App({ connection.sessionId !== loadedSkillsFallbackSessionId || fallbackWorkspaceCwdRef.current !== connection.workspaceCwd ) { - handledSkillMutationIdRef.current = undefined; + handledSkillMutationKeysRef.current.clear(); fallbackWorkspaceCwdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index b1900752739..f23806cb4fc 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -2926,7 +2926,72 @@ describe('DaemonSessionProvider', () => { skillsVersion: 1, lastSkillMutation: mutation, lastSkillMutationsByCwd: { - '/mock-workspace': mutation, + '/mock-workspace': [mutation], + }, + }); + }); + + it('retains distinct skill toggle mutations that arrive together', async () => { + const mutationA = { + id: 'skill-toggle-A', + kind: 'skill_toggle' as const, + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial' as const, + sessionsRefreshed: 0, + sessionsFailed: 1, + }; + const mutationB = { + id: 'skill-toggle-B', + kind: 'skill_toggle' as const, + skills: [{ name: 'review', enabled: false }], + activation: 'applied' as const, + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + const session = createMockSession({ + events: async function* skillToggleBatchEvents() { + yield { + id: 27, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.disabled', + scope: 'workspace', + value: ['web-search'], + mutation: mutationA, + }, + }; + yield { + id: 28, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.disabled', + scope: 'workspace', + value: ['web-search', 'review'], + mutation: mutationB, + }, + }; + }, + }); + sdkMocks.sessions.push(session); + let signals: DaemonWorkspaceEventSignals | undefined; + + function Harness() { + signals = useDaemonWorkspaceEventSignals(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + + expect(signals).toMatchObject({ + skillsVersion: 2, + lastSkillMutation: mutationB, + lastSkillMutationsByCwd: { + '/mock-workspace': [mutationA, mutationB], }, }); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 002240fbd86..c252c7efe2d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -3901,9 +3901,10 @@ function bumpWorkspaceEventSignals( let agents = 0; let tools = 0; let settings = 0; - let lastSkillMutation: - | DaemonWorkspaceEventSignals['lastSkillMutation'] - | undefined; + const skillMutations: Array> = []; + const seenSkillMutationIds = new Set(); let mcp = 0; let extensions = 0; let artifacts = 0; @@ -3926,7 +3927,10 @@ function bumpWorkspaceEventSignals( break; case 'workspace.settings.changed': if (event.mutation?.kind === 'skill_toggle') { - lastSkillMutation = event.mutation; + if (!seenSkillMutationIds.has(event.mutation.id)) { + seenSkillMutationIds.add(event.mutation.id); + skillMutations.push(event.mutation); + } } else { settings += 1; } @@ -3968,6 +3972,8 @@ function bumpWorkspaceEventSignals( } } + const lastSkillMutation = skillMutations[skillMutations.length - 1]; + if ( memory + agents + @@ -3984,9 +3990,15 @@ function bumpWorkspaceEventSignals( return; setSignals((current) => { - const hasNewSkillMutation = - lastSkillMutation !== undefined && - lastSkillMutation.id !== current.lastSkillMutation?.id; + const existing = + workspaceCwd && current.lastSkillMutationsByCwd + ? (current.lastSkillMutationsByCwd[workspaceCwd] ?? []) + : []; + const existingIds = new Set(existing.map((mutation) => mutation.id)); + const toAppend = skillMutations.filter( + (mutation) => !existingIds.has(mutation.id), + ); + const hasNewSkillMutation = toAppend.length > 0; if ( memory + agents + @@ -4003,10 +4015,10 @@ function bumpWorkspaceEventSignals( return current; } const lastSkillMutationsByCwd = - hasNewSkillMutation && workspaceCwd && lastSkillMutation + hasNewSkillMutation && workspaceCwd ? { ...current.lastSkillMutationsByCwd, - [workspaceCwd]: lastSkillMutation, + [workspaceCwd]: [...existing, ...toAppend], } : current.lastSkillMutationsByCwd; return { @@ -4014,11 +4026,11 @@ function bumpWorkspaceEventSignals( agentsVersion: current.agentsVersion + agents, toolsVersion: current.toolsVersion + tools, settingsVersion: current.settingsVersion + settings, - skillsVersion: current.skillsVersion + (hasNewSkillMutation ? 1 : 0), + skillsVersion: current.skillsVersion + toAppend.length, mcpVersion: current.mcpVersion + mcp, extensionsVersion: current.extensionsVersion + extensions, artifactsVersion: current.artifactsVersion + artifacts, - ...(hasNewSkillMutation + ...(lastSkillMutation ? { lastSkillMutation } : current.lastSkillMutation ? { lastSkillMutation: current.lastSkillMutation } diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 52da50eb6bc..8e11142345f 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -524,7 +524,7 @@ export interface DaemonWorkspaceEventSignals { settingsVersion: number; skillsVersion: number; lastSkillMutation?: DaemonSkillToggleMutation; - lastSkillMutationsByCwd?: Record; + lastSkillMutationsByCwd?: Record; mcpVersion: number; extensionsVersion: number; artifactsVersion: number; From 6bc5e9d352d8ce4a6ab0b0c8dd500380ddc0e6ed Mon Sep 17 00:00:00 2001 From: YungSen Hsin Date: Thu, 20 Aug 2026 10:11:11 +0800 Subject: [PATCH 13/18] fix(web-shell): keep Skill composer source correct after toggles Skip the applied fast path while session skills are unknown, and drop leaked pending toggles when the workspace fallback is cleared. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 55 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 3 ++ 2 files changed, 58 insertions(+) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index e0c30336c2d..aebacb23f2c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -10854,6 +10854,14 @@ describe('App session callbacks', () => { ); rerender(); await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'review' })]), + ); mockConnection.workspaceCwd = '/tmp/project'; mockConnection.sessionId = 'session-1'; @@ -10989,6 +10997,53 @@ describe('App session callbacks', () => { ); }); + it('reloads workspace skills when an applied toggle arrives while session skills are unknown', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = undefined; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + + const { rerender } = renderApp(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + }); + emitSkillMutation( + 'applied-web-search-unknown-skills', + [{ name: 'web-search', enabled: false }], + 'applied', + ); + rerender(); + await flush(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect( + mockWorkspaceActions.loadSkillsStatus.mock.calls.length, + ).toBeGreaterThan(1); + }); + it('uses the workspace Skill snapshot while the session Skill list is unknown', async () => { mockConnection.commands = [ skillCommandFixture('web-search', 'Search the web'), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6a955f5d4f2..c68ae3dd1fa 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4607,6 +4607,7 @@ export function App({ ); if ( sessionId && + connection.skills !== undefined && priorPending.length === 0 && unhandled.every( (mutation) => @@ -4651,6 +4652,7 @@ export function App({ }, [ connected, connection.sessionId, + connection.skills, connection.workspaceCwd, reloadLoadedSkills, workspaceEventSignals?.lastSkillMutation, @@ -4663,6 +4665,7 @@ export function App({ fallbackWorkspaceCwdRef.current !== connection.workspaceCwd ) { handledSkillMutationKeysRef.current.clear(); + pendingSkillTogglesRef.current = []; fallbackWorkspaceCwdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; From b69b33db8ba9838c0c14e559addc520e22905f15 Mon Sep 17 00:00:00 2001 From: YungSen Hsin Date: Thu, 20 Aug 2026 20:54:04 +0800 Subject: [PATCH 14/18] fix(web-shell): keep skill-toggle refresh scoped to #9123 Drop multi-workspace mutation books, pending-toggle merging, and the nested skills mapper so Web Shell only consumes skill_toggle metadata and refreshes the composer. Co-authored-by: Cursor --- packages/web-shell/client/App.test.tsx | 672 ------------------ packages/web-shell/client/App.tsx | 119 +--- .../session/DaemonSessionProvider.test.tsx | 68 -- .../daemon/session/DaemonSessionProvider.tsx | 37 +- .../webui/src/daemon/session/mappers.test.ts | 22 - packages/webui/src/daemon/session/mappers.ts | 14 +- packages/webui/src/daemon/session/types.ts | 1 - 7 files changed, 28 insertions(+), 905 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 71e09d401c1..94d69bbc9f0 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -443,19 +443,6 @@ const { sessionsFailed: number; } | undefined, - lastSkillMutationsByCwd: undefined as - | Record< - string, - Array<{ - id: string; - kind: 'skill_toggle'; - skills: Array<{ name: string; enabled: boolean }>; - activation: 'applied' | 'deferred' | 'partial'; - sessionsRefreshed: number; - sessionsFailed: number; - }> - > - | undefined, }, latestSettingsState: null as { settings: DaemonSettingDescriptor[]; @@ -4659,23 +4646,10 @@ function emitSkillMutation( sessionsRefreshed: activation === 'applied' ? 1 : 0, sessionsFailed: activation === 'partial' ? 1 : 0, }; - const cwd = mockConnection.workspaceCwd; - const previousByCwd = testState.workspaceEventSignals.lastSkillMutationsByCwd; - const existing = cwd ? (previousByCwd?.[cwd] ?? []) : []; testState.workspaceEventSignals = { ...testState.workspaceEventSignals, skillsVersion: testState.workspaceEventSignals.skillsVersion + 1, lastSkillMutation: mutation, - ...(cwd - ? { - lastSkillMutationsByCwd: { - ...previousByCwd, - [cwd]: existing.some((entry) => entry.id === mutation.id) - ? existing - : [...existing, mutation], - }, - } - : {}), }; } @@ -4803,7 +4777,6 @@ beforeEach(() => { extensionsVersion: 0, skillsVersion: 0, lastSkillMutation: undefined, - lastSkillMutationsByCwd: undefined, }; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], @@ -10665,651 +10638,6 @@ describe('App session callbacks', () => { expect(onToast).toHaveBeenCalledWith('error', 'Skill snapshot unavailable'); }); - it('retries a cancelled partial Skill refresh after returning to the session', async () => { - mockConnection.commands = [ - { - name: 'web-search', - description: 'Search the web', - source: 'skill', - raw: { - name: 'web-search', - description: 'Search the web', - input: null, - _meta: { source: 'skill' }, - }, - }, - ]; - mockConnection.skills = ['web-search']; - const pendingReload = deferred<{ - skills: Array<{ name: string; description: string; status: string }>; - }>(); - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockReturnValue(pendingReload.promise); - - const { rerender } = renderApp(); - await flush(); - expect(testState.latestChatEditorProps?.skills).toEqual([ - { name: 'web-search', description: 'Search the web' }, - ]); - - testState.workspaceEventSignals = { - ...testState.workspaceEventSignals, - skillsVersion: 1, - lastSkillMutation: { - id: 'partial-web-search-mid-switch', - kind: 'skill_toggle', - skills: [{ name: 'web-search', enabled: false }], - activation: 'partial', - sessionsRefreshed: 0, - sessionsFailed: 1, - }, - }; - rerender(); - await flush(); - - mockConnection.sessionId = 'session-2'; - rerender(); - await flush(); - mockConnection.sessionId = 'session-1'; - rerender(); - await flush(); - - await act(async () => { - pendingReload.resolve({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - await pendingReload.promise; - }); - await flush(); - - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('keeps the workspace Skill fallback when an unrelated commands update races the refresh', async () => { - mockConnection.commands = [ - { - name: 'web-search', - description: 'Search the web', - source: 'skill', - raw: { - name: 'web-search', - description: 'Search the web', - input: null, - _meta: { source: 'skill' }, - }, - }, - ]; - mockConnection.skills = ['web-search']; - const pendingReload = deferred<{ - skills: Array<{ name: string; description: string; status: string }>; - }>(); - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockReturnValue(pendingReload.promise); - - const { rerender } = renderApp(); - await flush(); - expect(testState.latestChatEditorProps?.skills).toEqual([ - { name: 'web-search', description: 'Search the web' }, - ]); - - testState.workspaceEventSignals = { - ...testState.workspaceEventSignals, - skillsVersion: 1, - lastSkillMutation: { - id: 'partial-web-search-establish-race', - kind: 'skill_toggle', - skills: [{ name: 'web-search', enabled: false }], - activation: 'partial', - sessionsRefreshed: 0, - sessionsFailed: 1, - }, - }; - rerender(); - await flush(); - - mockConnection.commands = [ - { - name: 'web-search', - description: 'Search the web', - source: 'skill', - raw: { - name: 'web-search', - description: 'Search the web', - input: null, - _meta: { source: 'skill' }, - }, - }, - ]; - mockConnection.skills = ['web-search']; - rerender(); - await flush(); - - await act(async () => { - pendingReload.resolve({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - await pendingReload.promise; - }); - await flush(); - - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('keeps the workspace Skill fallback after an unrelated commands update', async () => { - mockConnection.commands = [ - { - name: 'web-search', - description: 'Search the web', - source: 'skill', - raw: { - name: 'web-search', - description: 'Search the web', - input: null, - _meta: { source: 'skill' }, - }, - }, - ]; - mockConnection.skills = ['web-search']; - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockResolvedValue({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - const { rerender } = renderApp(); - await flush(); - expect(testState.latestChatEditorProps?.skills).toHaveLength(1); - - testState.workspaceEventSignals = { - ...testState.workspaceEventSignals, - skillsVersion: 1, - lastSkillMutation: { - id: 'partial-web-search-clear-race', - kind: 'skill_toggle', - skills: [{ name: 'web-search', enabled: false }], - activation: 'partial', - sessionsRefreshed: 0, - sessionsFailed: 1, - }, - }; - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - - mockConnection.commands = [ - { - name: 'web-search', - description: 'Search the web', - source: 'skill', - raw: { - name: 'web-search', - description: 'Search the web', - input: null, - _meta: { source: 'skill' }, - }, - }, - ]; - mockConnection.skills = ['web-search']; - rerender(); - await flush(); - await flush(); - - expect(testState.latestChatEditorProps?.skills).toEqual([]); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('retries a cancelled partial Skill refresh after reconnecting', async () => { - mockConnection.commands = [ - { - name: 'web-search', - description: 'Search the web', - source: 'skill', - raw: { - name: 'web-search', - description: 'Search the web', - input: null, - _meta: { source: 'skill' }, - }, - }, - ]; - mockConnection.skills = ['web-search']; - const pendingReload = deferred<{ - skills: Array<{ name: string; description: string; status: string }>; - }>(); - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockReturnValue(pendingReload.promise); - - const { rerender } = renderApp(); - await flush(); - expect(testState.latestChatEditorProps?.skills).toEqual([ - { name: 'web-search', description: 'Search the web' }, - ]); - - testState.workspaceEventSignals = { - ...testState.workspaceEventSignals, - skillsVersion: 1, - lastSkillMutation: { - id: 'partial-web-search-reconnect', - kind: 'skill_toggle', - skills: [{ name: 'web-search', enabled: false }], - activation: 'partial', - sessionsRefreshed: 0, - sessionsFailed: 1, - }, - }; - rerender(); - await flush(); - - mockConnection.status = 'disconnected'; - rerender(); - await flush(); - mockConnection.status = 'connected'; - rerender(); - await flush(); - - await act(async () => { - pendingReload.resolve({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - await pendingReload.promise; - }); - await flush(); - - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('does not pin a later workspace to an earlier workspace Skill mutation', async () => { - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockResolvedValue({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - mockWorkspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ - workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), - workspaceSkills: - cwd === '/tmp/other' - ? vi.fn().mockResolvedValue({ - skills: [ - { - name: 'review', - description: 'Review changes', - status: 'ok', - }, - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - : mockWorkspaceActions.loadSkillsStatus, - workspaceGitHubPullRequests: vi.fn().mockResolvedValue({ - v: 1, - workspaceCwd: cwd, - available: true, - pullRequests: [], - }), - })); - - const { rerender } = renderApp(); - await flush(); - emitPartialSkillMutation('partial-web-search-workspace-a', [ - { name: 'web-search', enabled: false }, - ]); - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - - mockConnection.workspaceCwd = '/tmp/other'; - mockConnection.sessionId = 'session-2'; - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - rerender(); - await flush(); - await flush(); - - expect(testState.latestChatEditorProps?.skills).toEqual([ - { name: 'web-search', description: 'Search the web' }, - ]); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'review' })]), - ); - - mockConnection.workspaceCwd = '/tmp/project'; - mockConnection.sessionId = 'session-1'; - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('restores an earlier workspace Skill fallback after an intervening toggle', async () => { - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockResolvedValue({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - mockWorkspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ - workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), - workspaceSkills: - cwd === '/tmp/other' - ? vi.fn().mockResolvedValue({ - skills: [ - { - name: 'review', - description: 'Review changes', - status: 'ok', - }, - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - : mockWorkspaceActions.loadSkillsStatus, - workspaceGitHubPullRequests: vi.fn().mockResolvedValue({ - v: 1, - workspaceCwd: cwd, - available: true, - pullRequests: [], - }), - })); - - const { rerender } = renderApp(); - await flush(); - emitPartialSkillMutation('partial-web-search-workspace-a', [ - { name: 'web-search', enabled: false }, - ]); - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - - mockConnection.workspaceCwd = '/tmp/other'; - mockConnection.sessionId = 'session-2'; - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - rerender(); - await flush(); - emitSkillMutation( - 'applied-review-workspace-b', - [{ name: 'review', enabled: false }], - 'applied', - ); - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([ - { name: 'web-search', description: 'Search the web' }, - ]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'review' })]), - ); - - mockConnection.workspaceCwd = '/tmp/project'; - mockConnection.sessionId = 'session-1'; - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('keeps a batched partial Skill toggle when a later applied mutation arrives', async () => { - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockResolvedValue({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - - const { rerender } = renderApp(); - await flush(); - - const partialDisable = { - id: 'partial-web-search-batch', - kind: 'skill_toggle' as const, - skills: [{ name: 'web-search', enabled: false }], - activation: 'partial' as const, - sessionsRefreshed: 0, - sessionsFailed: 1, - }; - const appliedOther = { - id: 'applied-review-batch', - kind: 'skill_toggle' as const, - skills: [{ name: 'review', enabled: false }], - activation: 'applied' as const, - sessionsRefreshed: 1, - sessionsFailed: 0, - }; - testState.workspaceEventSignals = { - ...testState.workspaceEventSignals, - skillsVersion: testState.workspaceEventSignals.skillsVersion + 2, - lastSkillMutation: appliedOther, - lastSkillMutationsByCwd: { - '/tmp/project': [partialDisable, appliedOther], - }, - }; - rerender(); - await flush(); - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - - it('keeps an earlier partial Skill toggle when a later mutation is already reflected', async () => { - mockConnection.commands = [ - skillCommandFixture('web-search', 'Search the web'), - ]; - mockConnection.skills = ['web-search']; - const pendingReload = deferred<{ - skills: Array<{ name: string; description: string; status: string }>; - }>(); - mockWorkspaceActions.loadSkillsStatus - .mockResolvedValueOnce({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'ok', - }, - ], - }) - .mockReturnValue(pendingReload.promise); - - const { rerender } = renderApp(); - await flush(); - emitPartialSkillMutation('partial-web-search-first', [ - { name: 'web-search', enabled: false }, - ]); - rerender(); - await flush(); - - emitPartialSkillMutation('partial-review-already-absent', [ - { name: 'review', enabled: false }, - ]); - rerender(); - await flush(); - - await act(async () => { - pendingReload.resolve({ - skills: [ - { - name: 'web-search', - description: 'Search the web', - status: 'disabled', - }, - ], - }); - await pendingReload.promise; - }); - await flush(); - - await vi.waitFor(() => { - expect(testState.latestChatEditorProps?.skills).toEqual([]); - }); - expect(testState.latestChatEditorProps?.commands).not.toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), - ); - }); - it('reloads workspace skills when an applied toggle arrives while session skills are unknown', async () => { mockConnection.commands = [ skillCommandFixture('web-search', 'Search the web'), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a212a1e7a1d..a617b45275e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -41,7 +41,6 @@ import { import type { DaemonInputAnnotation, DaemonSessionAgentTaskStatus, - DaemonSkillToggleMutation, DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, @@ -512,34 +511,6 @@ function sessionSkillsReflectToggle( }); } -function skillMutationsForWorkspace( - signals: - | { - lastSkillMutation?: DaemonSkillToggleMutation; - lastSkillMutationsByCwd?: Record; - } - | undefined, - workspaceCwd?: string, -): DaemonSkillToggleMutation[] { - if (workspaceCwd && signals?.lastSkillMutationsByCwd) { - return signals.lastSkillMutationsByCwd[workspaceCwd] ?? []; - } - return signals?.lastSkillMutation ? [signals.lastSkillMutation] : []; -} - -function mergeSkillToggles( - current: ReadonlyArray<{ name: string; enabled: boolean }>, - incoming: ReadonlyArray<{ name: string; enabled: boolean }>, -): Array<{ name: string; enabled: boolean }> { - const byName = new Map( - current.map((skill) => [skill.name.toLowerCase(), skill] as const), - ); - for (const skill of incoming) { - byName.set(skill.name.toLowerCase(), skill); - } - return [...byName.values()]; -} - const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -4669,86 +4640,33 @@ export function App({ if (!connected) return; void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); - const handledSkillMutationKeysRef = useRef>(new Set()); - const skillMutationOriginByIdRef = useRef>( - new Map(), - ); - const pendingSkillTogglesRef = useRef< - Array<{ name: string; enabled: boolean }> - >([]); - const fallbackWorkspaceCwdRef = useRef(undefined); + const handledSkillMutationIdRef = useRef(undefined); useEffect(() => { if (!connected) return; + const mutation = workspaceEventSignals?.lastSkillMutation; + if (!mutation || handledSkillMutationIdRef.current === mutation.id) return; + handledSkillMutationIdRef.current = mutation.id; const sessionId = connection.sessionId; - const workspaceCwd = connection.workspaceCwd; - const mutations = skillMutationsForWorkspace( - workspaceEventSignals, - workspaceCwd, - ); - const unhandled: DaemonSkillToggleMutation[] = []; - for (const mutation of mutations) { - if (!skillMutationOriginByIdRef.current.has(mutation.id)) { - skillMutationOriginByIdRef.current.set(mutation.id, workspaceCwd); - } - if ( - skillMutationOriginByIdRef.current.get(mutation.id) !== workspaceCwd - ) { - continue; - } - const handleKey = `${mutation.id}::${sessionId ?? ''}::${workspaceCwd ?? ''}`; - if (handledSkillMutationKeysRef.current.has(handleKey)) continue; - unhandled.push(mutation); - } - if (unhandled.length === 0) return; - const markHandled = () => { - for (const mutation of unhandled) { - handledSkillMutationKeysRef.current.add( - `${mutation.id}::${sessionId ?? ''}::${workspaceCwd ?? ''}`, - ); - } - }; - const priorPending = pendingSkillTogglesRef.current; - pendingSkillTogglesRef.current = mergeSkillToggles( - priorPending, - unhandled.flatMap((mutation) => mutation.skills), - ); if ( sessionId && connection.skills !== undefined && - priorPending.length === 0 && - unhandled.every( - (mutation) => - mutation.activation === 'applied' && mutation.sessionsFailed === 0, - ) + mutation.activation === 'applied' && + mutation.sessionsFailed === 0 ) { - markHandled(); - pendingSkillTogglesRef.current = []; - fallbackWorkspaceCwdRef.current = undefined; setLoadedSkillsFallbackSessionId(undefined); return; } let cancelled = false; - void reloadLoadedSkills(workspaceCwd, true).then((loaded) => { - if (cancelled || !loaded) return; - if (!sessionId) { - markHandled(); - return; - } + void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { + if (cancelled || !loaded || !sessionId) return; const currentSnapshot = connectionSkillSnapshotRef.current; if ( currentSnapshot.sessionId === sessionId && - sessionSkillsReflectToggle( - currentSnapshot.skills, - pendingSkillTogglesRef.current, - ) + sessionSkillsReflectToggle(currentSnapshot.skills, mutation.skills) ) { - pendingSkillTogglesRef.current = []; - markHandled(); return; } - fallbackWorkspaceCwdRef.current = workspaceCwd; setLoadedSkillsFallbackSessionId(sessionId); - markHandled(); }); return () => { cancelled = true; @@ -4767,31 +4685,22 @@ export function App({ ]); useEffect(() => { if (!loadedSkillsFallbackSessionId) return; - if ( - connection.sessionId !== loadedSkillsFallbackSessionId || - fallbackWorkspaceCwdRef.current !== connection.workspaceCwd - ) { - handledSkillMutationKeysRef.current.clear(); - pendingSkillTogglesRef.current = []; - fallbackWorkspaceCwdRef.current = undefined; + if (connection.sessionId !== loadedSkillsFallbackSessionId) { setLoadedSkillsFallbackSessionId(undefined); return; } + const mutation = workspaceEventSignals?.lastSkillMutation; if ( - pendingSkillTogglesRef.current.length > 0 && - sessionSkillsReflectToggle( - connection.skills, - pendingSkillTogglesRef.current, - ) + mutation && + sessionSkillsReflectToggle(connection.skills, mutation.skills) ) { - pendingSkillTogglesRef.current = []; setLoadedSkillsFallbackSessionId(undefined); } }, [ connection.sessionId, connection.skills, - connection.workspaceCwd, loadedSkillsFallbackSessionId, + workspaceEventSignals?.lastSkillMutation, ]); const [modelDialogMode, setModelDialogMode] = diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 8a4468de78c..f8bc600a8fe 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -3233,74 +3233,6 @@ describe('DaemonSessionProvider', () => { settingsVersion: 1, skillsVersion: 1, lastSkillMutation: mutation, - lastSkillMutationsByCwd: { - '/mock-workspace': [mutation], - }, - }); - }); - - it('retains distinct skill toggle mutations that arrive together', async () => { - const mutationA = { - id: 'skill-toggle-A', - kind: 'skill_toggle' as const, - skills: [{ name: 'web-search', enabled: false }], - activation: 'partial' as const, - sessionsRefreshed: 0, - sessionsFailed: 1, - }; - const mutationB = { - id: 'skill-toggle-B', - kind: 'skill_toggle' as const, - skills: [{ name: 'review', enabled: false }], - activation: 'applied' as const, - sessionsRefreshed: 1, - sessionsFailed: 0, - }; - const session = createMockSession({ - events: async function* skillToggleBatchEvents() { - yield { - id: 27, - v: 1, - type: 'settings_changed', - data: { - key: 'skills.disabled', - scope: 'workspace', - value: ['web-search'], - mutation: mutationA, - }, - }; - yield { - id: 28, - v: 1, - type: 'settings_changed', - data: { - key: 'skills.disabled', - scope: 'workspace', - value: ['web-search', 'review'], - mutation: mutationB, - }, - }; - }, - }); - sdkMocks.sessions.push(session); - let signals: DaemonWorkspaceEventSignals | undefined; - - function Harness() { - signals = useDaemonWorkspaceEventSignals(); - return null; - } - - await renderWithProvider(, { autoConnect: true }); - await act(async () => { - await flushPromises(); - }); - - expect(signals).toMatchObject({ - skillsVersion: 2, - lastSkillMutation: mutationB, - lastSkillMutationsByCwd: { - '/mock-workspace': [mutationA, mutationB], - }, }); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index b52b68a09a5..6c2b5630f59 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -1770,7 +1770,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { bumpWorkspaceEventSignals( sideEffectEvents, setWorkspaceEventSignals, - activeSession.workspaceCwd, ); } if (replayExceededCapacity && historyHasMore) { @@ -2207,7 +2206,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { bumpWorkspaceEventSignals( uiEvents, setWorkspaceEventSignals, - activeSession.workspaceCwd, ); if (uiEvents.length > 0) { const hasGenerationSignal = hasActiveGenerationSignal(uiEvents); @@ -3975,15 +3973,14 @@ function getNumber( function bumpWorkspaceEventSignals( events: readonly DaemonUiEvent[], setSignals: Dispatch>, - workspaceCwd?: string, ): void { let memory = 0; let agents = 0; let tools = 0; let settings = 0; - const skillMutations: Array> = []; + let lastSkillMutation: + | DaemonWorkspaceEventSignals['lastSkillMutation'] + | undefined; const seenSkillMutationIds = new Set(); let mcp = 0; let extensions = 0; @@ -4009,7 +4006,7 @@ function bumpWorkspaceEventSignals( if (event.mutation?.kind === 'skill_toggle') { if (!seenSkillMutationIds.has(event.mutation.id)) { seenSkillMutationIds.add(event.mutation.id); - skillMutations.push(event.mutation); + lastSkillMutation = event.mutation; } } else { settings += 1; @@ -4052,8 +4049,6 @@ function bumpWorkspaceEventSignals( } } - const lastSkillMutation = skillMutations[skillMutations.length - 1]; - if ( memory + agents + @@ -4070,15 +4065,9 @@ function bumpWorkspaceEventSignals( return; setSignals((current) => { - const existing = - workspaceCwd && current.lastSkillMutationsByCwd - ? (current.lastSkillMutationsByCwd[workspaceCwd] ?? []) - : []; - const existingIds = new Set(existing.map((mutation) => mutation.id)); - const toAppend = skillMutations.filter( - (mutation) => !existingIds.has(mutation.id), - ); - const hasNewSkillMutation = toAppend.length > 0; + const hasNewSkillMutation = + lastSkillMutation !== undefined && + lastSkillMutation.id !== current.lastSkillMutation?.id; if ( memory + agents + @@ -4094,28 +4083,20 @@ function bumpWorkspaceEventSignals( ) { return current; } - const lastSkillMutationsByCwd = - hasNewSkillMutation && workspaceCwd - ? { - ...current.lastSkillMutationsByCwd, - [workspaceCwd]: [...existing, ...toAppend], - } - : current.lastSkillMutationsByCwd; return { memoryVersion: current.memoryVersion + memory, agentsVersion: current.agentsVersion + agents, toolsVersion: current.toolsVersion + tools, settingsVersion: current.settingsVersion + settings, - skillsVersion: current.skillsVersion + toAppend.length, + skillsVersion: current.skillsVersion + (hasNewSkillMutation ? 1 : 0), mcpVersion: current.mcpVersion + mcp, extensionsVersion: current.extensionsVersion + extensions, artifactsVersion: current.artifactsVersion + artifacts, - ...(lastSkillMutation + ...(hasNewSkillMutation ? { lastSkillMutation } : current.lastSkillMutation ? { lastSkillMutation: current.lastSkillMutation } : {}), - ...(lastSkillMutationsByCwd ? { lastSkillMutationsByCwd } : {}), ...(lastExtensionChange ? { lastExtensionChange } : {}), initVersion: current.initVersion + init, authVersion: current.authVersion + auth, diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index eeab2eaea5c..b69c1545bc9 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -845,28 +845,6 @@ describe('updateConnectionFromDaemonEvent', () => { expect(next.skills).toEqual(['review']); }); - it('reads availableSkills from nested _meta when the flat field is absent', () => { - const next = applyEvent( - { status: 'connected', workspaceCwd: '/workspace' }, - { - id: 1, - v: 1, - type: 'session_update', - data: { - update: { - sessionUpdate: 'available_commands_update', - availableCommands: [ - { name: 'review', description: 'Review a PR', input: null }, - ], - _meta: { availableSkills: ['review'] }, - }, - }, - } as DaemonEvent, - ); - - expect(next.skills).toEqual(['review']); - }); - it('clears stale commands when the update reports an empty list', () => { // The daemon snapshot is authoritative: a list that shrank to empty must // not leave the previous commands autocompleting. Keying on length would diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 4ed94ea3099..73463c02e25 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -766,15 +766,11 @@ function mapAvailableCommandsUpdate( }, ]; }); - const nestedSkills = getRecord(update['_meta'])?.['availableSkills']; - const rawSkills = Array.isArray(update['availableSkills']) - ? update['availableSkills'] - : Array.isArray(nestedSkills) - ? nestedSkills - : []; - const skills = rawSkills.filter( - (skill): skill is string => typeof skill === 'string', - ); + const skills = Array.isArray(update['availableSkills']) + ? update['availableSkills'].filter( + (skill): skill is string => typeof skill === 'string', + ) + : []; const skillCommands = skills.map((skill) => ({ name: skill, description: '', diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 8507410ec5d..da4c38d388d 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -548,7 +548,6 @@ export interface DaemonWorkspaceEventSignals { settingsVersion: number; skillsVersion: number; lastSkillMutation?: DaemonSkillToggleMutation; - lastSkillMutationsByCwd?: Record; mcpVersion: number; extensionsVersion: number; artifactsVersion: number; From ed72125a15c91f3719b0b0b9d0ee6a5043ab628d Mon Sep 17 00:00:00 2001 From: YungSen Hsin Date: Thu, 20 Aug 2026 21:38:17 +0800 Subject: [PATCH 15/18] fix(web-shell): drop unused skill-refresh eslint disable The scoped effect already lists its deps, so the leftover exhaustive-deps suppression failed lint:ci. Co-authored-by: Cursor --- packages/web-shell/client/App.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a617b45275e..9ac77e1f629 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4671,9 +4671,6 @@ export function App({ return () => { cancelled = true; }; - // Skill-specific fields only: other workspaceEventSignals versions must - // not cancel an in-flight /workspace/skills refresh. - // eslint-disable-next-line react-hooks/exhaustive-deps -- see above }, [ connected, connection.sessionId, From 2a7b2251da672ad2084d4dbd689499fbe090322f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Fri, 21 Aug 2026 03:26:30 +0800 Subject: [PATCH 16/18] fix(web-shell): revalidate partial skill mutations --- packages/web-shell/client/App.test.tsx | 97 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 57 +++++++++++++-- 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 94d69bbc9f0..e51991ffe69 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -10611,6 +10611,103 @@ describe('App session callbacks', () => { ]); }); + it('revalidates a partial Skill mutation only within its workspace', async () => { + const enabledStatus = { + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok' as const, + }, + ], + }; + const disabledStatus = { + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled' as const, + }, + ], + }; + const switchedSessionRefresh = deferred(); + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce(enabledStatus) + .mockResolvedValueOnce(disabledStatus) + .mockReturnValue(switchedSessionRefresh.promise); + const { rerender } = renderApp(); + await flush(); + + emitPartialSkillMutation('partial-web-search-session-switch', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + mockConnection.sessionId = 'session-2'; + rerender(); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(3); + }); + mockConnection.skills = ['web-search']; + rerender(); + await act(async () => { + switchedSessionRefresh.resolve(disabledStatus); + await switchedSessionRefresh.promise; + }); + await flush(); + + expect(testState.latestChatEditorProps?.skills).toEqual([]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(3); + + emitPartialSkillMutation('partial-web-search-session-switch', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await flush(); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(3); + + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] }); + mockConnection.sessionId = 'session-3'; + mockConnection.workspaceCwd = '/tmp/other-project'; + rerender(); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(4); + }); + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + + mockConnection.sessionId = 'session-2'; + mockConnection.workspaceCwd = '/tmp/project'; + rerender(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(6); + + mockConnection.status = 'connecting'; + mockConnection.sessionId = undefined; + rerender(); + await flush(); + mockConnection.status = 'connected'; + mockConnection.sessionId = 'session-2'; + rerender(); + await vi.waitFor(() => { + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(8); + }); + it('surfaces a failed deferred Skill snapshot refresh', async () => { mockConnection.sessionId = undefined; mockWorkspaceActions.loadSkillsStatus diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 9ac77e1f629..35ce225e05b 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4640,25 +4640,63 @@ export function App({ if (!connected) return; void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); - const handledSkillMutationIdRef = useRef(undefined); + const skillMutationHandlingRef = useRef< + | { + id: string; + sessionId: string | undefined; + workspaceCwd: string | undefined; + phase: 'idle' | 'processing' | 'handled'; + } + | undefined + >(undefined); useEffect(() => { - if (!connected) return; + if (!connected) { + if (skillMutationHandlingRef.current) { + skillMutationHandlingRef.current.phase = 'idle'; + } + return; + } const mutation = workspaceEventSignals?.lastSkillMutation; - if (!mutation || handledSkillMutationIdRef.current === mutation.id) return; - handledSkillMutationIdRef.current = mutation.id; + if (!mutation) return; + let handling = skillMutationHandlingRef.current; + if (!handling || handling.id !== mutation.id) { + handling = { + id: mutation.id, + sessionId: connection.sessionId, + workspaceCwd: connection.workspaceCwd, + phase: 'idle', + }; + skillMutationHandlingRef.current = handling; + } + if (handling.workspaceCwd !== connection.workspaceCwd) { + handling.phase = 'idle'; + return; + } + if ( + handling.sessionId === connection.sessionId && + handling.phase !== 'idle' + ) { + return; + } + handling.sessionId = connection.sessionId; + handling.phase = 'processing'; const sessionId = connection.sessionId; + const sessionSkills = connectionSkillSnapshotRef.current.skills; if ( sessionId && - connection.skills !== undefined && + sessionSkills !== undefined && mutation.activation === 'applied' && mutation.sessionsFailed === 0 ) { + handling.phase = 'handled'; setLoadedSkillsFallbackSessionId(undefined); return; } let cancelled = false; void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { - if (cancelled || !loaded || !sessionId) return; + if (cancelled || skillMutationHandlingRef.current !== handling) return; + handling.phase = 'handled'; + if (!loaded || !sessionId) return; const currentSnapshot = connectionSkillSnapshotRef.current; if ( currentSnapshot.sessionId === sessionId && @@ -4670,11 +4708,16 @@ export function App({ }); return () => { cancelled = true; + if ( + skillMutationHandlingRef.current === handling && + handling.phase === 'processing' + ) { + handling.phase = 'idle'; + } }; }, [ connected, connection.sessionId, - connection.skills, connection.workspaceCwd, reloadLoadedSkills, workspaceEventSignals?.lastSkillMutation, From 60fe1f0679b52799009ea30fcdf4581dd7368898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E8=83=A7?= Date: Fri, 21 Aug 2026 14:48:11 +0800 Subject: [PATCH 17/18] fix(web-shell): reconcile queued skill mutations --- packages/web-shell/client/App.test.tsx | 242 +++++++++++++++++- packages/web-shell/client/App.tsx | 168 +++++++----- .../session/DaemonSessionProvider.test.tsx | 94 ++++++- .../daemon/session/DaemonSessionProvider.tsx | 39 ++- .../webui/src/daemon/session/mappers.test.ts | 43 ++++ packages/webui/src/daemon/session/mappers.ts | 14 +- packages/webui/src/daemon/session/types.ts | 1 + 7 files changed, 508 insertions(+), 93 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8bcacb847ab..9200d9cbbc8 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -11,6 +11,7 @@ import { type DaemonSessionShellTaskStatus, type DaemonSessionStatsStatus, type DaemonSettingDescriptor, + type DaemonSkillToggleMutation, type DaemonWorkspaceGitStatus, type GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; @@ -434,15 +435,9 @@ const { artifactsVersion: 0, extensionsVersion: 0, skillsVersion: 0, - lastSkillMutation: undefined as - | { - id: string; - kind: 'skill_toggle'; - skills: Array<{ name: string; enabled: boolean }>; - activation: 'applied' | 'deferred' | 'partial'; - sessionsRefreshed: number; - sessionsFailed: number; - } + lastSkillMutation: undefined as DaemonSkillToggleMutation | undefined, + skillMutationsByCwd: undefined as + | Record | undefined, }, latestSettingsState: null as { @@ -4655,10 +4650,19 @@ function emitSkillMutation( sessionsRefreshed: activation === 'applied' ? 1 : 0, sessionsFailed: activation === 'partial' ? 1 : 0, }; + const cwd = mockConnection.workspaceCwd; + const previousByCwd = testState.workspaceEventSignals.skillMutationsByCwd; + const existing = previousByCwd?.[cwd] ?? []; testState.workspaceEventSignals = { ...testState.workspaceEventSignals, skillsVersion: testState.workspaceEventSignals.skillsVersion + 1, lastSkillMutation: mutation, + skillMutationsByCwd: { + ...previousByCwd, + [cwd]: existing.some((entry) => entry.id === mutation.id) + ? existing + : [...existing, mutation], + }, }; } @@ -4786,6 +4790,7 @@ beforeEach(() => { extensionsVersion: 0, skillsVersion: 0, lastSkillMutation: undefined, + skillMutationsByCwd: undefined, }; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], @@ -10747,6 +10752,225 @@ describe('App session callbacks', () => { expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(8); }); + it('keeps an in-flight partial Skill mutation when a later mutation is applied', async () => { + const pendingPartialRefresh = deferred<{ + skills: Array<{ + name: string; + description: string; + status: 'disabled'; + }>; + }>(); + const disabledStatus = { + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled' as const, + }, + ], + }; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockReturnValueOnce(pendingPartialRefresh.promise) + .mockResolvedValue(disabledStatus); + const { rerender } = renderApp(); + await flush(); + + emitPartialSkillMutation('partial-web-search-batch', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + }); + + emitSkillMutation( + 'applied-review-batch', + [{ name: 'review', enabled: false }], + 'applied', + ); + rerender(); + await flush(); + + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(3); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + + await act(async () => { + pendingPartialRefresh.resolve(disabledStatus); + await pendingPartialRefresh.promise; + }); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + + it('reconciles every Skill mutation delivered in one signal update', async () => { + const partialDisable: DaemonSkillToggleMutation = { + id: 'partial-web-search-single-update', + kind: 'skill_toggle', + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial', + sessionsRefreshed: 0, + sessionsFailed: 1, + }; + const appliedReview: DaemonSkillToggleMutation = { + id: 'applied-review-single-update', + kind: 'skill_toggle', + skills: [{ name: 'review', enabled: false }], + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok', + }, + ], + }) + .mockResolvedValue({ + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled', + }, + ], + }); + const { rerender } = renderApp(); + await flush(); + + testState.workspaceEventSignals = { + ...testState.workspaceEventSignals, + skillsVersion: 2, + lastSkillMutation: appliedReview, + skillMutationsByCwd: { + [mockConnection.workspaceCwd]: [partialDisable, appliedReview], + }, + }; + rerender(); + await flush(); + + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + + it('revalidates a cancelled mutation after another workspace mutates', async () => { + const pendingWorkspaceARefresh = deferred<{ + skills: Array<{ + name: string; + description: string; + status: 'disabled'; + }>; + }>(); + const enabledStatus = { + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'ok' as const, + }, + ], + }; + const disabledStatus = { + skills: [ + { + name: 'web-search', + description: 'Search the web', + status: 'disabled' as const, + }, + ], + }; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + mockWorkspaceActions.loadSkillsStatus + .mockResolvedValueOnce(enabledStatus) + .mockReturnValueOnce(pendingWorkspaceARefresh.promise) + .mockResolvedValue(disabledStatus); + const { rerender } = renderApp(); + await flush(); + + emitPartialSkillMutation('partial-web-search-workspace-a', [ + { name: 'web-search', enabled: false }, + ]); + rerender(); + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + }); + + mockConnection.workspaceCwd = '/tmp/other-project'; + mockConnection.sessionId = 'session-2'; + mockConnection.commands = []; + mockConnection.skills = []; + rerender(); + await flush(); + emitSkillMutation( + 'applied-review-workspace-b', + [{ name: 'review', enabled: false }], + 'applied', + ); + rerender(); + await flush(); + + const callsBeforeReturning = + mockWorkspaceActions.loadSkillsStatus.mock.calls.length; + mockConnection.workspaceCwd = '/tmp/project'; + mockConnection.sessionId = 'session-1'; + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + rerender(); + await flush(); + + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus.mock.calls.length).toBe( + callsBeforeReturning + 2, + ); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + + await act(async () => { + pendingWorkspaceARefresh.resolve(disabledStatus); + await pendingWorkspaceARefresh.promise; + }); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + it('surfaces a failed deferred Skill snapshot refresh', async () => { mockConnection.sessionId = undefined; mockWorkspaceActions.loadSkillsStatus diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 0cf77d5e17b..6f70147cd6a 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -41,6 +41,7 @@ import { import type { DaemonInputAnnotation, DaemonSessionAgentTaskStatus, + DaemonSkillToggleMutation, DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, DaemonSessionShellTaskStatus, @@ -514,6 +515,30 @@ function sessionSkillsReflectToggle( }); } +function skillMutationsForWorkspace( + lastSkillMutation: DaemonSkillToggleMutation | undefined, + skillMutationsByCwd: Record | undefined, + workspaceCwd?: string, +): readonly DaemonSkillToggleMutation[] { + if (skillMutationsByCwd) { + return workspaceCwd ? (skillMutationsByCwd[workspaceCwd] ?? []) : []; + } + return lastSkillMutation ? [lastSkillMutation] : []; +} + +function mergeSkillToggles( + current: ReadonlyArray<{ name: string; enabled: boolean }>, + incoming: ReadonlyArray<{ name: string; enabled: boolean }>, +): Array<{ name: string; enabled: boolean }> { + const byName = new Map( + current.map((skill) => [skill.name.toLowerCase(), skill] as const), + ); + for (const skill of incoming) { + byName.set(skill.name.toLowerCase(), skill); + } + return [...byName.values()]; +} + const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; @@ -4621,8 +4646,10 @@ export function App({ const workspaceEventSignals = useWorkspaceEventSignals(); const [loadedSkills, setLoadedSkills] = useState([]); const [loadedSkillsReady, setLoadedSkillsReady] = useState(false); - const [loadedSkillsFallbackSessionId, setLoadedSkillsFallbackSessionId] = - useState(); + const [loadedSkillsFallback, setLoadedSkillsFallback] = useState<{ + sessionId: string; + workspaceCwd: string | undefined; + }>(); const connectionSkillSnapshotRef = useRef({ sessionId: connection.sessionId, skills: connection.skills, @@ -4662,80 +4689,78 @@ export function App({ if (!connected) return; void reloadLoadedSkills(connection.workspaceCwd); }, [connected, connection.workspaceCwd, reloadLoadedSkills]); - const skillMutationHandlingRef = useRef< - | { - id: string; - sessionId: string | undefined; - workspaceCwd: string | undefined; - phase: 'idle' | 'processing' | 'handled'; - } - | undefined - >(undefined); + const handledSkillMutationKeysRef = useRef(new Set()); + const pendingSkillTogglesByContextRef = useRef( + new Map>(), + ); useEffect(() => { if (!connected) { - if (skillMutationHandlingRef.current) { - skillMutationHandlingRef.current.phase = 'idle'; - } + handledSkillMutationKeysRef.current.clear(); return; } - const mutation = workspaceEventSignals?.lastSkillMutation; - if (!mutation) return; - let handling = skillMutationHandlingRef.current; - if (!handling || handling.id !== mutation.id) { - handling = { - id: mutation.id, - sessionId: connection.sessionId, - workspaceCwd: connection.workspaceCwd, - phase: 'idle', - }; - skillMutationHandlingRef.current = handling; - } - if (handling.workspaceCwd !== connection.workspaceCwd) { - handling.phase = 'idle'; - return; - } - if ( - handling.sessionId === connection.sessionId && - handling.phase !== 'idle' - ) { - return; - } - handling.sessionId = connection.sessionId; - handling.phase = 'processing'; const sessionId = connection.sessionId; + const workspaceCwd = connection.workspaceCwd; + const contextKey = `${workspaceCwd ?? ''}\n${sessionId ?? ''}`; + const mutations = skillMutationsForWorkspace( + workspaceEventSignals?.lastSkillMutation, + workspaceEventSignals?.skillMutationsByCwd, + workspaceCwd, + ); + const mutationKeys = mutations.map( + (mutation) => `${contextKey}\n${mutation.id}`, + ); + const unhandled = mutations.filter( + (_, index) => + !handledSkillMutationKeysRef.current.has(mutationKeys[index]!), + ); + if (unhandled.length === 0) return; + const markHandled = () => { + for (const mutationKey of mutationKeys) { + handledSkillMutationKeysRef.current.add(mutationKey); + } + }; + const priorPending = + pendingSkillTogglesByContextRef.current.get(contextKey) ?? []; + const pendingToggles = mergeSkillToggles( + priorPending, + unhandled.flatMap((mutation) => mutation.skills), + ); const sessionSkills = connectionSkillSnapshotRef.current.skills; if ( sessionId && sessionSkills !== undefined && - mutation.activation === 'applied' && - mutation.sessionsFailed === 0 + priorPending.length === 0 && + unhandled.every( + (mutation) => + mutation.activation === 'applied' && mutation.sessionsFailed === 0, + ) ) { - handling.phase = 'handled'; - setLoadedSkillsFallbackSessionId(undefined); + markHandled(); + setLoadedSkillsFallback(undefined); return; } + pendingSkillTogglesByContextRef.current.set(contextKey, pendingToggles); let cancelled = false; - void reloadLoadedSkills(connection.workspaceCwd, true).then((loaded) => { - if (cancelled || skillMutationHandlingRef.current !== handling) return; - handling.phase = 'handled'; - if (!loaded || !sessionId) return; + void reloadLoadedSkills(workspaceCwd, true).then((loaded) => { + if (cancelled || !loaded) return; + markHandled(); + if (!sessionId) { + pendingSkillTogglesByContextRef.current.delete(contextKey); + return; + } const currentSnapshot = connectionSkillSnapshotRef.current; if ( currentSnapshot.sessionId === sessionId && - sessionSkillsReflectToggle(currentSnapshot.skills, mutation.skills) + sessionSkillsReflectToggle(currentSnapshot.skills, pendingToggles) ) { + pendingSkillTogglesByContextRef.current.delete(contextKey); + setLoadedSkillsFallback(undefined); return; } - setLoadedSkillsFallbackSessionId(sessionId); + setLoadedSkillsFallback({ sessionId, workspaceCwd }); }); return () => { cancelled = true; - if ( - skillMutationHandlingRef.current === handling && - handling.phase === 'processing' - ) { - handling.phase = 'idle'; - } }; }, [ connected, @@ -4743,26 +4768,38 @@ export function App({ connection.workspaceCwd, reloadLoadedSkills, workspaceEventSignals?.lastSkillMutation, - workspaceEventSignals?.skillsVersion, + workspaceEventSignals?.skillMutationsByCwd, ]); useEffect(() => { - if (!loadedSkillsFallbackSessionId) return; - if (connection.sessionId !== loadedSkillsFallbackSessionId) { - setLoadedSkillsFallbackSessionId(undefined); + if (!loadedSkillsFallback) return; + if ( + connection.sessionId !== loadedSkillsFallback.sessionId || + connection.workspaceCwd !== loadedSkillsFallback.workspaceCwd + ) { + const previousContextKey = `${loadedSkillsFallback.workspaceCwd ?? ''}\n${loadedSkillsFallback.sessionId}`; + for (const mutationKey of handledSkillMutationKeysRef.current) { + if (mutationKey.startsWith(`${previousContextKey}\n`)) { + handledSkillMutationKeysRef.current.delete(mutationKey); + } + } + setLoadedSkillsFallback(undefined); return; } - const mutation = workspaceEventSignals?.lastSkillMutation; + const contextKey = `${connection.workspaceCwd ?? ''}\n${connection.sessionId ?? ''}`; + const pendingToggles = + pendingSkillTogglesByContextRef.current.get(contextKey) ?? []; if ( - mutation && - sessionSkillsReflectToggle(connection.skills, mutation.skills) + pendingToggles.length > 0 && + sessionSkillsReflectToggle(connection.skills, pendingToggles) ) { - setLoadedSkillsFallbackSessionId(undefined); + pendingSkillTogglesByContextRef.current.delete(contextKey); + setLoadedSkillsFallback(undefined); } }, [ connection.sessionId, connection.skills, - loadedSkillsFallbackSessionId, - workspaceEventSignals?.lastSkillMutation, + connection.workspaceCwd, + loadedSkillsFallback, ]); const [modelDialogMode, setModelDialogMode] = @@ -11053,7 +11090,8 @@ export function App({ loadedSkillsReady && (!connection.sessionId || connection.skills === undefined || - loadedSkillsFallbackSessionId === connection.sessionId); + (loadedSkillsFallback?.sessionId === connection.sessionId && + loadedSkillsFallback.workspaceCwd === connection.workspaceCwd)); const composerSkills = useMemo( () => useWorkspaceSkillSnapshot diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 92eeeeb2fcd..ef626c33b90 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -3185,6 +3185,14 @@ describe('DaemonSessionProvider', () => { sessionsRefreshed: 1, sessionsFailed: 0, }; + const laterMutation = { + id: 'skill-toggle-2', + kind: 'skill_toggle' as const, + skills: [{ name: 'review', enabled: false }], + activation: 'partial' as const, + sessionsRefreshed: 0, + sessionsFailed: 1, + }; const session = createMockSession({ events: async function* skillToggleEvents() { yield { @@ -3219,6 +3227,17 @@ describe('DaemonSessionProvider', () => { value: 'Qwen Dark', }, }; + yield { + id: 30, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.disabled', + scope: 'workspace', + value: ['web-search', 'review'], + mutation: laterMutation, + }, + }; }, }); sdkMocks.sessions.push(session); @@ -3236,8 +3255,79 @@ describe('DaemonSessionProvider', () => { expect(signals).toMatchObject({ settingsVersion: 1, - skillsVersion: 1, - lastSkillMutation: mutation, + skillsVersion: 2, + lastSkillMutation: laterMutation, + skillMutationsByCwd: { + '/mock-workspace': [mutation, laterMutation], + }, + }); + }); + + it('retains every distinct skill mutation from one replay batch', async () => { + const partialMutation = { + id: 'replay-skill-toggle-1', + kind: 'skill_toggle' as const, + skills: [{ name: 'web-search', enabled: false }], + activation: 'partial' as const, + sessionsRefreshed: 0, + sessionsFailed: 1, + }; + const appliedMutation = { + id: 'replay-skill-toggle-2', + kind: 'skill_toggle' as const, + skills: [{ name: 'review', enabled: false }], + activation: 'applied' as const, + sessionsRefreshed: 1, + sessionsFailed: 0, + }; + const session = createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + id: 27, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.disabled', + scope: 'workspace', + value: ['web-search'], + mutation: partialMutation, + }, + }, + { + id: 28, + v: 1, + type: 'settings_changed', + data: { + key: 'skills.disabled', + scope: 'workspace', + value: ['web-search', 'review'], + mutation: appliedMutation, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + let signals: DaemonWorkspaceEventSignals | undefined; + + function Harness() { + signals = useDaemonWorkspaceEventSignals(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + + expect(signals).toMatchObject({ + skillsVersion: 2, + lastSkillMutation: appliedMutation, + skillMutationsByCwd: { + '/mock-workspace': [partialMutation, appliedMutation], + }, }); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 0c487f74b70..0259a6cecba 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2138,6 +2138,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { bumpWorkspaceEventSignals( sideEffectEvents, setWorkspaceEventSignals, + activeSession.workspaceCwd, ); } if (replayExceededCapacity) { @@ -2620,6 +2621,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { bumpWorkspaceEventSignals( uiEvents, setWorkspaceEventSignals, + activeSession.workspaceCwd, ); if (uiEvents.length > 0) { const hasGenerationSignal = hasActiveGenerationSignal(uiEvents); @@ -4456,14 +4458,15 @@ function getNumber( function bumpWorkspaceEventSignals( events: readonly DaemonUiEvent[], setSignals: Dispatch>, + workspaceCwd: string, ): void { let memory = 0; let agents = 0; let tools = 0; let settings = 0; - let lastSkillMutation: - | DaemonWorkspaceEventSignals['lastSkillMutation'] - | undefined; + const skillMutations: Array< + NonNullable + > = []; const seenSkillMutationIds = new Set(); let mcp = 0; let extensions = 0; @@ -4489,7 +4492,7 @@ function bumpWorkspaceEventSignals( if (event.mutation?.kind === 'skill_toggle') { if (!seenSkillMutationIds.has(event.mutation.id)) { seenSkillMutationIds.add(event.mutation.id); - lastSkillMutation = event.mutation; + skillMutations.push(event.mutation); } } else { settings += 1; @@ -4543,14 +4546,16 @@ function bumpWorkspaceEventSignals( init + auth === 0 && - !lastSkillMutation + skillMutations.length === 0 ) return; setSignals((current) => { - const hasNewSkillMutation = - lastSkillMutation !== undefined && - lastSkillMutation.id !== current.lastSkillMutation?.id; + const existing = current.skillMutationsByCwd?.[workspaceCwd] ?? []; + const existingIds = new Set(existing.map((mutation) => mutation.id)); + const newSkillMutations = skillMutations.filter( + (mutation) => !existingIds.has(mutation.id), + ); if ( memory + agents + @@ -4562,7 +4567,7 @@ function bumpWorkspaceEventSignals( init + auth === 0 && - !hasNewSkillMutation + newSkillMutations.length === 0 ) { return current; } @@ -4571,15 +4576,25 @@ function bumpWorkspaceEventSignals( agentsVersion: current.agentsVersion + agents, toolsVersion: current.toolsVersion + tools, settingsVersion: current.settingsVersion + settings, - skillsVersion: current.skillsVersion + (hasNewSkillMutation ? 1 : 0), + skillsVersion: current.skillsVersion + newSkillMutations.length, mcpVersion: current.mcpVersion + mcp, extensionsVersion: current.extensionsVersion + extensions, artifactsVersion: current.artifactsVersion + artifacts, - ...(hasNewSkillMutation - ? { lastSkillMutation } + ...(newSkillMutations.length > 0 + ? { lastSkillMutation: newSkillMutations.at(-1) } : current.lastSkillMutation ? { lastSkillMutation: current.lastSkillMutation } : {}), + ...(newSkillMutations.length > 0 + ? { + skillMutationsByCwd: { + ...current.skillMutationsByCwd, + [workspaceCwd]: [...existing, ...newSkillMutations], + }, + } + : current.skillMutationsByCwd + ? { skillMutationsByCwd: current.skillMutationsByCwd } + : {}), ...(lastExtensionChange ? { lastExtensionChange } : {}), initVersion: current.initVersion + init, authVersion: current.authVersion + auth, diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index b69c1545bc9..e153ac648d0 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -845,6 +845,49 @@ describe('updateConnectionFromDaemonEvent', () => { expect(next.skills).toEqual(['review']); }); + it('reads nested availableSkills from the daemon wire shape', () => { + const next = applyEvent( + { status: 'connected', workspaceCwd: '/workspace' }, + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'review', description: 'Review a PR', input: null }, + ], + _meta: { availableSkills: ['review'] }, + }, + }, + } as DaemonEvent, + ); + + expect(next.skills).toEqual(['review']); + }); + + it('prefers flat availableSkills when both wire shapes are present', () => { + const next = applyEvent( + { status: 'connected', workspaceCwd: '/workspace' }, + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + availableSkills: ['flat-skill'], + _meta: { availableSkills: ['nested-skill'] }, + }, + }, + } as DaemonEvent, + ); + + expect(next.skills).toEqual(['flat-skill']); + }); + it('clears stale commands when the update reports an empty list', () => { // The daemon snapshot is authoritative: a list that shrank to empty must // not leave the previous commands autocompleting. Keying on length would diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 73463c02e25..4ed94ea3099 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -766,11 +766,15 @@ function mapAvailableCommandsUpdate( }, ]; }); - const skills = Array.isArray(update['availableSkills']) - ? update['availableSkills'].filter( - (skill): skill is string => typeof skill === 'string', - ) - : []; + const nestedSkills = getRecord(update['_meta'])?.['availableSkills']; + const rawSkills = Array.isArray(update['availableSkills']) + ? update['availableSkills'] + : Array.isArray(nestedSkills) + ? nestedSkills + : []; + const skills = rawSkills.filter( + (skill): skill is string => typeof skill === 'string', + ); const skillCommands = skills.map((skill) => ({ name: skill, description: '', diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 483c87670f6..4c113698639 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -556,6 +556,7 @@ export interface DaemonWorkspaceEventSignals { settingsVersion: number; skillsVersion: number; lastSkillMutation?: DaemonSkillToggleMutation; + skillMutationsByCwd?: Record; mcpVersion: number; extensionsVersion: number; artifactsVersion: number; From e51199b06f4956169f7021a71bcb00165e81d9cf Mon Sep 17 00:00:00 2001 From: YungSen Hsin Date: Sat, 22 Aug 2026 20:19:48 +0800 Subject: [PATCH 18/18] fix(web-shell): reconcile zero-session skill refreshes --- packages/web-shell/client/App.test.tsx | 32 +++++++++++++++++++++++++- packages/web-shell/client/App.tsx | 4 +++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f7197909d78..085a00291ff 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -4652,13 +4652,14 @@ function emitSkillMutation( id: string, skills: Array<{ name: string; enabled: boolean }>, activation: 'applied' | 'deferred' | 'partial' = 'partial', + sessionsRefreshed = activation === 'applied' ? 1 : 0, ): void { const mutation = { id, kind: 'skill_toggle' as const, skills, activation, - sessionsRefreshed: activation === 'applied' ? 1 : 0, + sessionsRefreshed, sessionsFailed: activation === 'partial' ? 1 : 0, }; const cwd = mockConnection.workspaceCwd; @@ -10602,6 +10603,35 @@ describe('App session callbacks', () => { expect(mockSessionActions.reloadSession).not.toHaveBeenCalled(); }); + it('refreshes workspace Skills when an applied toggle refreshed no sessions', async () => { + mockConnection.commands = [ + skillCommandFixture('web-search', 'Search the web'), + ]; + mockConnection.skills = ['web-search']; + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'web-search', description: 'Search the web' }, + ]); + + emitSkillMutation( + 'applied-web-search-without-refreshed-session', + [{ name: 'web-search', enabled: false }], + 'applied', + 0, + ); + rerender(); + await flush(); + + await vi.waitFor(() => { + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + expect(testState.latestChatEditorProps?.skills).toEqual([]); + }); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web-search' })]), + ); + }); + it('refreshes session-less composer Skills once for a deferred mutation', async () => { mockConnection.sessionId = undefined; mockWorkspaceActions.loadSkillsStatus diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 30fc17235fa..5ad4da8484a 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -4743,7 +4743,9 @@ export function App({ priorPending.length === 0 && unhandled.every( (mutation) => - mutation.activation === 'applied' && mutation.sessionsFailed === 0, + mutation.activation === 'applied' && + mutation.sessionsFailed === 0 && + mutation.sessionsRefreshed > 0, ) ) { markHandled();