From 624a664dc68c4fceec6dab5d8f775aab9f35bb26 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 2 Jul 2026 10:37:58 +0800 Subject: [PATCH 1/2] fix(web-shell): show skill slash commands (e.g. /review) before first prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since session creation is deferred until the first prompt (#6066), the deferred connect path reported 'connected' but only fetched workspace providers — it never populated the slash-command list. Before sending a message the composer therefore fell back to the hardcoded local command list, which omits skills, so '/rev' would not autocomplete '/review'. Fetch the session-less /workspace/skills status alongside providers in the deferred connect path and seed connection.commands/skills from it, so skill-backed slash commands autocomplete immediately. The full session-scoped supported-commands snapshot (which also carries custom, MCP-prompt and workflow commands) still replaces this once the first prompt creates a session. --- .../session/DaemonSessionProvider.test.tsx | 49 +++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 38 ++++++++-- .../webui/src/daemon/session/mappers.test.ts | 71 ++++++++++++++++++- packages/webui/src/daemon/session/mappers.ts | 38 ++++++++++ 4 files changed, 189 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 797792fa164..6c3e1a5ce3a 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -209,6 +209,7 @@ const sdkMocks = vi.hoisted(() => { sessions, capabilities, workspaceProviders, + workspaceSkills, MockDaemonClient, MockDaemonSessionClient, workspaceMcpTools, @@ -423,6 +424,54 @@ describe('DaemonSessionProvider', () => { expect(connection).not.toHaveProperty('sessionId'); }); + it('populates skill slash commands during deferred connect (before first prompt)', async () => { + sdkMocks.workspaceProviders.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + providers: [], + }); + sdkMocks.workspaceSkills.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review a GitHub pull request', + level: 'bundled', + modelInvocable: true, + }, + ], + }); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).not.toHaveBeenCalled(); + expect(connection?.status).toBe('connected'); + expect(connection).not.toHaveProperty('sessionId'); + expect(connection?.skills).toEqual(['review']); + expect(connection?.commands).toEqual([ + expect.objectContaining({ + name: 'review', + description: 'Review a GitHub pull request', + }), + ]); + }); + it('warns when deferred workspace providers fail', async () => { const error = new Error('providers unavailable'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 29f4da53726..0c935cc6985 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -46,6 +46,7 @@ import { mapProviderStatus, mapSessionContextModels, mapSupportedCommands, + mapWorkspaceSkills, updateConnectionFromDaemonEvent, } from './mappers.js'; import { @@ -421,20 +422,41 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { !reconnectSessionId && !shouldCreateFreshSession ) { - const providerResult = await Promise.allSettled([ + // Fetch skills alongside providers so skill-backed slash + // commands (e.g. /review) can autocomplete before the first + // prompt. Both are session-less workspace queries; the + // session-scoped supported-commands snapshot (which also carries + // custom/MCP/workflow commands) still lands once the first prompt + // creates a session. + const [providerResult, skillsResult] = await Promise.allSettled([ client.workspaceProviders(), + client.workspaceSkills(), ]); - if (providerResult[0].status === 'rejected') { + if (providerResult.status === 'rejected') { console.warn( '[DaemonSessionProvider] workspaceProviders failed in deferred connect:', - providerResult[0].reason, + providerResult.reason, + ); + } + if (skillsResult.status === 'rejected') { + console.warn( + '[DaemonSessionProvider] workspaceSkills failed in deferred connect:', + skillsResult.reason, ); } const providers = - providerResult[0].status === 'fulfilled' - ? providerResult[0].value + providerResult.status === 'fulfilled' + ? providerResult.value : undefined; const providerModelStatus = mapProviderStatus(providers); + const { + commands: deferredSkillCommands, + skills: deferredSkills, + } = mapWorkspaceSkills( + skillsResult.status === 'fulfilled' + ? skillsResult.value + : undefined, + ); setConnection((current) => ({ ...current, status: 'connected', @@ -445,6 +467,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { contextWindow: providerModelStatus.contextWindow, providers, capabilities: caps, + ...(deferredSkillCommands.length > 0 + ? { commands: deferredSkillCommands } + : {}), + ...(deferredSkills.length > 0 + ? { skills: deferredSkills } + : {}), })); return; } diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index 158604357bf..332e19127cc 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -5,8 +5,15 @@ */ import { describe, expect, it } from 'vitest'; -import type { DaemonEvent } from '@qwen-code/sdk/daemon'; -import { getReplayTokenCount, getReplayTokenUsage } from './mappers.js'; +import type { + DaemonEvent, + DaemonWorkspaceSkillsStatus, +} from '@qwen-code/sdk/daemon'; +import { + getReplayTokenCount, + getReplayTokenUsage, + mapWorkspaceSkills, +} from './mappers.js'; function usageEvent( id: number, @@ -142,3 +149,63 @@ describe('getReplayTokenCount', () => { ).toBe(500); }); }); + +describe('mapWorkspaceSkills', () => { + it('returns empty commands and skills for undefined status', () => { + expect(mapWorkspaceSkills(undefined)).toEqual({ commands: [], skills: [] }); + }); + + it('maps workspace skills into skill slash commands', () => { + const status: DaemonWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review a GitHub pull request', + level: 'bundled', + modelInvocable: true, + argumentHint: '', + }, + { + kind: 'skill', + status: 'ok', + name: 'deep-research', + description: '', + level: 'bundled', + modelInvocable: true, + }, + ], + }; + + const result = mapWorkspaceSkills(status); + + expect(result.skills).toEqual(['review', 'deep-research']); + expect(result.commands).toEqual([ + { + name: 'review', + description: 'Review a GitHub pull request', + argumentHint: '', + raw: { + name: 'review', + description: 'Review a GitHub pull request', + input: { hint: '' }, + _meta: { source: 'skill' }, + }, + }, + { + name: 'deep-research', + description: '', + raw: { + name: 'deep-research', + description: '', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]); + }); +}); diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index b5379d5302e..042198ebb20 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -11,6 +11,7 @@ import type { DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, DaemonWorkspaceProvidersStatus, + DaemonWorkspaceSkillsStatus, } from '@qwen-code/sdk/daemon'; import type { DaemonCommandInfo, @@ -161,6 +162,43 @@ export function mapSupportedCommands( }; } +/** + * Maps the session-less `/workspace/skills` status into slash-command entries. + * + * Session creation is deferred until the first prompt, so before any session + * exists the only way to populate skill-backed slash commands (e.g. `/review`) + * is this workspace-level status, which the daemon answers from `Config`'s + * SkillManager without a live session. The shape mirrors the skills portion of + * {@link mapSupportedCommands} so the deferred bootstrap and the post-attach + * snapshot stay consistent — except workspace status carries real descriptions + * and argument hints, which we surface here. + */ +export function mapWorkspaceSkills( + status: DaemonWorkspaceSkillsStatus | undefined, +): { + commands: DaemonCommandInfo[]; + skills: string[]; +} { + if (!status) return { commands: [], skills: [] }; + + const commands = status.skills.map((skill) => ({ + name: skill.name, + description: skill.description || '', + ...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}), + raw: { + name: skill.name, + description: skill.description || '', + input: skill.argumentHint ? { hint: skill.argumentHint } : null, + _meta: { source: 'skill' }, + } satisfies DaemonAvailableCommand, + })); + + return { + commands, + skills: status.skills.map((skill) => skill.name), + }; +} + export function mergeCommands( ...groups: DaemonCommandInfo[][] ): DaemonCommandInfo[] { From 4286fc22e2c0aca383b3297ecc6f3d37ca742d1a Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 2 Jul 2026 11:47:31 +0800 Subject: [PATCH 2/2] test(web-shell): cover deferred workspace skills fetch failure Add a parallel test to the deferred-connect warn coverage: when client.workspaceSkills() rejects, the connection still reports 'connected' (skills are non-blocking) and the failure is logged via console.warn, mirroring the existing workspaceProviders-failure test. Addresses review feedback on #6153. --- .../session/DaemonSessionProvider.test.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 6c3e1a5ce3a..b069470af14 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -499,6 +499,35 @@ describe('DaemonSessionProvider', () => { ); }); + it('warns when deferred workspace skills fail', async () => { + const error = new Error('skills unavailable'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + sdkMocks.workspaceSkills.mockRejectedValueOnce(error); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: undefined, + }); + + // Skills failing must not block the deferred connect: providers still + // resolve and the connection reports connected, just without skill commands. + expect(connection).toMatchObject({ + status: 'connected', + workspaceCwd: '/mock-workspace', + }); + expect(connection).not.toHaveProperty('commands'); + expect(warn).toHaveBeenCalledWith( + '[DaemonSessionProvider] workspaceSkills failed in deferred connect:', + error, + ); + }); + it('preserves a concurrently created session during deferred connect', async () => { const providers = createDeferred(); sdkMocks.workspaceProviders.mockReturnValueOnce(providers.promise);