Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6291b53
feat(daemon): attach skill-toggle mutation metadata to settings_changed
samuelhsin Aug 13, 2026
c127037
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 13, 2026
123f747
fix(review): fit skill-toggle mutation metadata in the SDK bundle budget
samuelhsin Aug 13, 2026
d69bce1
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 13, 2026
7c1c0f6
test(daemon): pin skill-toggle mutation event count and parser edges
samuelhsin Aug 13, 2026
9da7603
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 13, 2026
98ea100
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 14, 2026
5df1746
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 14, 2026
f2063a5
Merge remote-tracking branch 'upstream/main' into codex/fix-9051-cr
samuelhsin Aug 14, 2026
b698a87
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 14, 2026
00924b8
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 15, 2026
42a825b
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 15, 2026
07ce8eb
fix(sdk): raise daemon browser bundle budget for skill-toggle metadata
samuelhsin Aug 15, 2026
b1f752b
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 15, 2026
503e1ff
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
wenshao Aug 16, 2026
806d758
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 16, 2026
3c7bdb7
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 16, 2026
fe0b74f
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 16, 2026
fc147d3
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 16, 2026
3579db6
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 16, 2026
3c46058
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 17, 2026
c131b3f
Merge branch 'main' into feat/9024-skill-toggle-mutation-metadata
samuelhsin Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/developers/daemon/09-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` and `value`; `scope` and Skill-toggle `mutation` are optional. |
Comment thread
samuelhsin marked this conversation as resolved.
Resolved
| `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`. 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
`"workspace_memory_remember"`, `"workspace_memory_forget"`, or
Expand Down Expand Up @@ -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<string, DaemonPermissionPartialVoteData>` - 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2768,7 +2768,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`

Expand Down Expand Up @@ -2815,7 +2815,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`

Expand Down
165 changes: 122 additions & 43 deletions packages/cli/src/serve/workspace-service/__tests__/facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import type {
InvokeWorkspaceCommandFn,
QueryWorkspaceStatusFn,
WorkspaceRequestContext,
WorkspaceSkillToggleActivation,
} from '../types.js';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -167,6 +168,34 @@ function makeCtx(
};
}

function skillToggleSettingsChanged(args: {
key: 'skills.disabled' | 'skills.enabled';
value: unknown;
skills: Array<{ name: string; enabled: boolean }>;
activation: WorkspaceSkillToggleActivation;
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<T>(fn: () => Promise<T>): Promise<T> {
return withIsolatedWorkspace(() => fn());
}
Expand Down Expand Up @@ -1647,15 +1676,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 () => {
Expand Down Expand Up @@ -1750,18 +1780,20 @@ 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 () => {
const publishWorkspaceEvent = vi.fn();
const svc = createDaemonWorkspaceService(
makeDeps({
queryWorkspaceStatus: statusQuery(),
Expand All @@ -1773,6 +1805,7 @@ describe('createDaemonWorkspaceService', () => {
sessionsRefreshed: 1,
sessionsFailed: 1,
}),
publishWorkspaceEvent,
isChannelLive: () => true,
}),
);
Expand All @@ -1784,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 () => {
Expand Down Expand Up @@ -2193,15 +2236,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 () => {
Expand Down Expand Up @@ -2459,15 +2506,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 () => {
Expand Down Expand Up @@ -2499,27 +2547,38 @@ 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 () => {
const publishWorkspaceEvent = vi.fn();
const failedSessions = createDaemonWorkspaceService(
makeDeps({
queryWorkspaceStatus: vi.fn().mockResolvedValue({
Expand All @@ -2536,6 +2595,7 @@ describe('createDaemonWorkspaceService', () => {
sessionsRefreshed: 1,
sessionsFailed: 1,
}),
publishWorkspaceEvent,
isChannelLive: () => true,
}),
);
Expand All @@ -2546,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({
Expand Down Expand Up @@ -2727,7 +2797,16 @@ describe('createDaemonWorkspaceService', () => {
],
});
expect(invokeWorkspaceCommand).toHaveBeenCalledOnce();
expect(publishWorkspaceEvent).toHaveBeenCalledOnce();
expect(publishWorkspaceEvent).toHaveBeenCalledWith(
skillToggleSettingsChanged({
key: 'skills.disabled',
value: ['deploy'],
Comment thread
samuelhsin marked this conversation as resolved.
Resolved
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 () => {
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/serve/workspace-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -88,6 +89,7 @@ import type {
WorkspaceSkillBatchToggleResult,
WorkspaceSkillToggleError,
WorkspaceSkillToggleResult,
WorkspaceSkillToggleActivation,
PersistDisabledSkillsBatchResult,
Comment thread
samuelhsin marked this conversation as resolved.
Resolved
WorkspaceSkillInstallRequest,
WorkspaceSkillMutationResult,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -908,13 +929,20 @@ export function createDaemonWorkspaceService(
persisted.disabled.length > 0 ? persisted.disabled : undefined,
},
];
const mutation = createSkillToggleMutation({
skills: [{ name: skill.name, enabled }],
activation,
Comment thread
samuelhsin marked this conversation as resolved.
Resolved
sessionsRefreshed,
sessionsFailed,
});
for (const change of settingsChanges) {
publishWorkspaceEvent({
type: 'settings_changed',
data: {
key: change.key,
value: change.value,
scope: 'workspace',
mutation,
},
originatorClientId: ctx.originatorClientId,
});
Expand Down Expand Up @@ -1080,10 +1108,21 @@ export function createDaemonWorkspaceService(
invalidateWorkspaceSkillsSnapshot();
}
assertActiveGeneration();
const mutation = createSkillToggleMutation({
skills: results
.filter((result) => result.changed)
Comment thread
samuelhsin marked this conversation as resolved.
Resolved
.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,
});
}
Expand Down
Loading
Loading