feat(web-shell): refresh composer skills incrementally after toggles (#9131)

* 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 <[email protected]>

* 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 <[email protected]>

* test(daemon): pin skill-toggle mutation event count and parser edges

Co-authored-by: Cursor <[email protected]>

* feat(web-shell): refresh skills incrementally after toggles

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* fix(web-shell): revalidate partial skill mutations

* fix(web-shell): reconcile queued skill mutations

* fix(web-shell): reconcile zero-session skill refreshes

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: YungSen Hsin <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-08-23 04:13:51 +00:00
1 parent cf3e8ad7c0
commit b6901ee0fd
8 files changed
+1329 -35

No files matched your search

@@ -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.
+779 -5
View File
@@ -11,6 +11,7 @@ import {
type DaemonSessionShellTaskStatus,
type DaemonSessionStatsStatus,
type DaemonSettingDescriptor,
type DaemonSkillToggleMutation,
type DaemonWorkspaceGitStatus,
type GoalSnapshotV2,
} from '@qwen-code/sdk/daemon';
@@ -38,7 +39,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;
@@ -435,6 +436,15 @@ const {
onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void;
} | null,
settings: [] as DaemonSettingDescriptor[],
workspaceEventSignals: {
artifactsVersion: 0,
extensionsVersion: 0,
skillsVersion: 0,
lastSkillMutation: undefined as DaemonSkillToggleMutation | undefined,
skillMutationsByCwd: undefined as
| Record<string, DaemonSkillToggleMutation[]>
| undefined,
},
latestSettingsState: null as {
settings: DaemonSettingDescriptor[];
} | null,
@@ -543,10 +553,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => {
useWorkspace: () => mockWorkspace,
useWorkspaceActions: () => mockWorkspaceActions,
useMcp: () => mockMcp,
useWorkspaceEventSignals: () => ({
artifactsVersion: 0,
extensionsVersion: 0,
}),
useWorkspaceEventSignals: () => testState.workspaceEventSignals,
};
});
@@ -4640,6 +4647,57 @@ function deferred<T>(): {
return { promise, resolve, reject };
}
function skillCommandFixture(name: string, description: string) {
return {
name,
description,
source: 'skill',
raw: {
name,
description,
input: null,
_meta: { source: 'skill' },
},
};
}
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,
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],
},
};
}
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 }>
@@ -4752,6 +4810,13 @@ beforeEach(() => {
// hydration window (goalState still unknown) set it back to undefined.
mockConnection.goalState = { v: 2, activity: 'idle', goal: null };
testState.ownerVersion = 0;
testState.workspaceEventSignals = {
artifactsVersion: 0,
extensionsVersion: 0,
skillsVersion: 0,
lastSkillMutation: undefined,
skillMutationsByCwd: undefined,
};
mockWorkspace.capabilities = {
workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }],
};
@@ -10366,6 +10431,7 @@ describe('App session callbacks', () => {
});
it('filters disabled skills from the web-shell skills list', async () => {
mockConnection.sessionId = undefined;
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({
skills: [
{
@@ -10390,6 +10456,7 @@ describe('App session callbacks', () => {
});
it('reloads skills when starting a new session', async () => {
mockConnection.sessionId = undefined;
mockConnection.commands = [
{
name: 'review',
@@ -10433,6 +10500,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' }],
});
@@ -10470,6 +10538,712 @@ 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 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
.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('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<typeof disabledStatus>();
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('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
.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('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'),
];
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'),
];
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) => {
+229 -10
View File
@@ -41,6 +41,7 @@ import {
import type {
DaemonInputAnnotation,
DaemonSessionAgentTaskStatus,
DaemonSkillToggleMutation,
DaemonTranscriptBlock,
DaemonSessionMonitorTaskStatus,
DaemonSessionShellTaskStatus,
@@ -477,6 +478,70 @@ 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));
}
function sessionSkillsReflectToggle(
sessionSkills: readonly string[] | undefined,
mutationSkills: ReadonlyArray<{ name: string; enabled: boolean }>,
): boolean {
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 skillMutationsForWorkspace(
lastSkillMutation: DaemonSkillToggleMutation | undefined,
skillMutationsByCwd: Record<string, DaemonSkillToggleMutation[]> | 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';
@@ -4592,9 +4657,21 @@ export function App({
const workspaceEventSignals = useWorkspaceEventSignals();
const [loadedSkills, setLoadedSkills] = useState<SkillInfo[]>([]);
const [loadedSkillsReady, setLoadedSkillsReady] = useState(false);
const [loadedSkillsFallback, setLoadedSkillsFallback] = useState<{
sessionId: string;
workspaceCwd: string | undefined;
}>();
const connectionSkillSnapshotRef = useRef({
sessionId: connection.sessionId,
skills: connection.skills,
});
connectionSkillSnapshotRef.current = {
sessionId: connection.sessionId,
skills: connection.skills,
};
const loadedSkillsRequestRef = useRef(0);
const reloadLoadedSkills = useCallback(
async (workspaceCwd?: string) => {
async (workspaceCwd?: string, notifyOnError = false) => {
const request = ++loadedSkillsRequestRef.current;
try {
const status =
@@ -4606,16 +4683,137 @@ 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 handledSkillMutationKeysRef = useRef(new Set<string>());
const pendingSkillTogglesByContextRef = useRef(
new Map<string, Array<{ name: string; enabled: boolean }>>(),
);
useEffect(() => {
if (!connected) {
handledSkillMutationKeysRef.current.clear();
return;
}
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 &&
priorPending.length === 0 &&
unhandled.every(
(mutation) =>
mutation.activation === 'applied' &&
mutation.sessionsFailed === 0 &&
mutation.sessionsRefreshed > 0,
)
) {
markHandled();
setLoadedSkillsFallback(undefined);
return;
}
pendingSkillTogglesByContextRef.current.set(contextKey, pendingToggles);
let cancelled = false;
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, pendingToggles)
) {
pendingSkillTogglesByContextRef.current.delete(contextKey);
setLoadedSkillsFallback(undefined);
return;
}
setLoadedSkillsFallback({ sessionId, workspaceCwd });
});
return () => {
cancelled = true;
};
}, [
connected,
connection.sessionId,
connection.workspaceCwd,
reloadLoadedSkills,
workspaceEventSignals?.lastSkillMutation,
workspaceEventSignals?.skillMutationsByCwd,
]);
useEffect(() => {
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 contextKey = `${connection.workspaceCwd ?? ''}\n${connection.sessionId ?? ''}`;
const pendingToggles =
pendingSkillTogglesByContextRef.current.get(contextKey) ?? [];
if (
pendingToggles.length > 0 &&
sessionSkillsReflectToggle(connection.skills, pendingToggles)
) {
pendingSkillTogglesByContextRef.current.delete(contextKey);
setLoadedSkillsFallback(undefined);
}
}, [
connection.sessionId,
connection.skills,
connection.workspaceCwd,
loadedSkillsFallback,
]);
const [modelDialogMode, setModelDialogMode] =
useState<ModelDialogMode | null>(null);
@@ -10890,18 +11088,39 @@ export function App({
}
}, [modelDialogMode, showFallbacksDialog, showAuthDialog]);
const useWorkspaceSkillSnapshot =
loadedSkillsReady &&
(!connection.sessionId ||
connection.skills === undefined ||
(loadedSkillsFallback?.sessionId === connection.sessionId &&
loadedSkillsFallback.workspaceCwd === connection.workspaceCwd));
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,
@@ -10935,9 +11154,9 @@ export function App({
connection.skills,
hiddenCommands,
loadedSkills,
loadedSkillsReady,
sideTasksAvailable,
t,
useWorkspaceSkillSnapshot,
]);
const welcomeHeaderProps = useMemo(
@@ -12835,7 +13054,7 @@ export function App({
unknownPromptAdmission?.payloadAvailable === true
}
commands={commands}
skills={loadedSkills}
skills={composerSkills}
slashCommandCategoryOrder={slashCommandCategoryOrder}
builtinAtProviders={builtinAtProviders}
atProviders={atProviders}
@@ -12976,7 +13195,7 @@ export function App({
label: getModelDisplayName(m.label || m.id),
contextWindow: m.contextWindow,
}))}
skills={loadedSkills}
skills={composerSkills}
onSelectMode={handleSetMode}
onSelectModel={handleModelSelect}
/>
@@ -13004,7 +13223,7 @@ export function App({
label: getModelDisplayName(m.label || m.id),
contextWindow: m.contextWindow,
}))}
skills={loadedSkills}
skills={composerSkills}
onSelectMode={handleSetMode}
onSelectModel={handleModelSelect}
/>
@@ -3176,6 +3176,161 @@ 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 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 {
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',
},
};
yield {
id: 30,
v: 1,
type: 'settings_changed',
data: {
key: 'skills.disabled',
scope: 'workspace',
value: ['web-search', 'review'],
mutation: laterMutation,
},
};
},
});
sdkMocks.sessions.push(session);
let signals: DaemonWorkspaceEventSignals | undefined;
function Harness() {
signals = useDaemonWorkspaceEventSignals();
return null;
}
await renderWithProvider(<Harness />, { autoConnect: true });
await act(async () => {
await flushPromises();
});
expect(signals).toMatchObject({
settingsVersion: 1,
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(<Harness />, { autoConnect: true });
await act(async () => {
await flushPromises();
});
expect(signals).toMatchObject({
skillsVersion: 2,
lastSkillMutation: appliedMutation,
skillMutationsByCwd: {
'/mock-workspace': [partialMutation, appliedMutation],
},
});
});
it('logs settings reloads without inserting daemon debug blocks', async () => {
const debug = vi
.spyOn(console, 'debug')
@@ -677,6 +677,7 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
agentsVersion: 0,
toolsVersion: 0,
settingsVersion: 0,
skillsVersion: 0,
mcpVersion: 0,
extensionsVersion: 0,
artifactsVersion: 0,
@@ -2137,6 +2138,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
bumpWorkspaceEventSignals(
sideEffectEvents,
setWorkspaceEventSignals,
activeSession.workspaceCwd,
);
}
if (replayExceededCapacity) {
@@ -2616,7 +2618,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) =>
@@ -4452,11 +4458,16 @@ function getNumber(
function bumpWorkspaceEventSignals(
events: readonly DaemonUiEvent[],
setSignals: Dispatch<SetStateAction<DaemonWorkspaceEventSignals>>,
workspaceCwd: string,
): void {
let memory = 0;
let agents = 0;
let tools = 0;
let settings = 0;
const skillMutations: Array<
NonNullable<DaemonWorkspaceEventSignals['lastSkillMutation']>
> = [];
const seenSkillMutationIds = new Set<string>();
let mcp = 0;
let extensions = 0;
let artifacts = 0;
@@ -4478,7 +4489,14 @@ function bumpWorkspaceEventSignals(
tools += 1;
break;
case 'workspace.settings.changed':
settings += 1;
if (event.mutation?.kind === 'skill_toggle') {
if (!seenSkillMutationIds.has(event.mutation.id)) {
seenSkillMutationIds.add(event.mutation.id);
skillMutations.push(event.mutation);
}
} else {
settings += 1;
}
break;
case 'workspace.mcp.budget_warning':
case 'workspace.mcp.child_refused':
@@ -4527,22 +4545,61 @@ function bumpWorkspaceEventSignals(
artifacts +
init +
auth ===
0
0 &&
skillMutations.length === 0
)
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 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 +
tools +
settings +
mcp +
extensions +
artifacts +
init +
auth ===
0 &&
newSkillMutations.length === 0
) {
return current;
}
return {
memoryVersion: current.memoryVersion + memory,
agentsVersion: current.agentsVersion + agents,
toolsVersion: current.toolsVersion + tools,
settingsVersion: current.settingsVersion + settings,
skillsVersion: current.skillsVersion + newSkillMutations.length,
mcpVersion: current.mcpVersion + mcp,
extensionsVersion: current.extensionsVersion + extensions,
artifactsVersion: current.artifactsVersion + artifacts,
...(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,
};
});
}
function isTerminalSessionHttpError(error: unknown): boolean {
@@ -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
+9 -5
View File
@@ -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: '',
@@ -34,6 +34,7 @@ import type {
DaemonSessionTasksStatus,
DaemonSessionStatsStatus,
DaemonSessionArtifactsEnvelope,
DaemonSkillToggleMutation,
DaemonShellCommandResult,
DaemonTranscriptBlock,
DaemonTranscriptStore,
@@ -553,6 +554,9 @@ export interface DaemonWorkspaceEventSignals {
agentsVersion: number;
toolsVersion: number;
settingsVersion: number;
skillsVersion: number;
lastSkillMutation?: DaemonSkillToggleMutation;
skillMutationsByCwd?: Record<string, DaemonSkillToggleMutation[]>;
mcpVersion: number;
extensionsVersion: number;
artifactsVersion: number;