diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index 23c6f04134d833..6847b6a545047d 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -12,7 +12,7 @@ import { isCustomizationEnabled } from '../common/customizationEnablement.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; import { MessageAttachmentKind } from '../common/state/protocol/state.js'; import { toSkillCompletionAttachmentMeta } from '../common/meta/agentCompletionAttachmentMeta.js'; -import { buildDefaultChatUri, CustomizationType, DirectoryCustomization, PluginCustomization, SkillCustomization, type Customization } from '../common/state/sessionState.js'; +import { buildDefaultChatUri, CustomizationType, DirectoryCustomization, isAhpChatChannel, parseRequiredSessionUriFromChatUri, PluginCustomization, SkillCustomization, type Customization } from '../common/state/sessionState.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; import { extractWhitespaceDelimitedSlashToken, matchesSlashCompletion } from './agentHostSlashCompletion.js'; @@ -36,6 +36,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge * snapshot for the session yet. */ private readonly _getHostCustomizations: (session: URI | string) => readonly Customization[] | undefined = () => undefined, + private readonly _refreshSessionCustomizations?: (agent: IAgent, session: URI) => Promise, ) { super(); } @@ -46,7 +47,8 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge return []; } - const sessionUri = typeof params.channel === 'string' ? URI.parse(params.channel) : params.channel; + const channel = params.channel; + const sessionUri = URI.parse(isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel); const agent = this._getAgent(sessionUri); if (!agent) { return []; @@ -88,7 +90,13 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge private async _getCandidates(agent: IAgent, session: URI): Promise { const chat = URI.parse(buildDefaultChatUri(session)); - const customizations = await agent.getChatCustomizations(chat, { configurationResource: session, resource: session }, this._getHostCustomizations(session)); + let customizations: readonly Customization[]; + if (this._refreshSessionCustomizations) { + await this._refreshSessionCustomizations(agent, session); + customizations = this._getHostCustomizations(session) ?? []; + } else { + customizations = await agent.getChatCustomizations(chat, { configurationResource: session, resource: session }, this._getHostCustomizations(session)); + } const result: SlashCommmandCandidate[] = []; for (const c of customizations) { if (c.type === CustomizationType.McpServer || (c.type === CustomizationType.Plugin ? !isCustomizationEnabled(c) : !c.enabled) || !c.children) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e31e50b2c90982..3eeb49f4f53f43 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1128,6 +1128,7 @@ export class AgentService extends Disposable implements IAgentService { const provider = this._register(new AgentHostSkillCompletionProvider( session => this._providerService.getProviderForSession(session), session => this._hostCustomizations(URI.isUri(session) ? session : URI.parse(session)), + (agent, session) => this._sideEffects.refreshSessionCustomizations(agent, session.toString()), )); this._register(this._completions.registerProvider(provider)); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index e1773893947b0c..5856b0a2a05f90 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -471,7 +471,7 @@ export class AgentSideEffects extends Disposable { // Agent progress received during the fetch is newer than this snapshot. if (current !== currentBeforeFetch) { if (supersededRetries < MAX_SUPERSEDED_CUSTOMIZATION_PUBLISH_RETRIES) { - this._publishSessionCustomizationsSoon(agent, session, supersededRetries + 1); + await this._publishSessionCustomizations(agent, session, supersededRetries + 1); } return; } @@ -486,16 +486,25 @@ export class AgentSideEffects extends Disposable { } private _publishSessionCustomizationsSoon(agent: IAgent, session: ProtocolURI, supersededRetries = 0): void { + void this.refreshSessionCustomizations(agent, session, supersededRetries).catch(() => { }); + } + + /** + * Resolves and publishes the session's effective customizations in serialization order. + */ + refreshSessionCustomizations(agent: IAgent, session: ProtocolURI, supersededRetries = 0): Promise { const previous = this._pendingSessionCustomizationPublishes.get(session) ?? Promise.resolve(); - const publish = previous.then(() => this._publishSessionCustomizations(agent, session, supersededRetries)).catch(err => { + const publish = previous.then(() => this._publishSessionCustomizations(agent, session, supersededRetries)); + const tracked = publish.then(() => undefined, err => { this._logService.error('[AgentSideEffects] getChatCustomizations failed', err); }); - this._pendingSessionCustomizationPublishes.set(session, publish); - void publish.finally(() => { - if (this._pendingSessionCustomizationPublishes.get(session) === publish) { + this._pendingSessionCustomizationPublishes.set(session, tracked); + void tracked.finally(() => { + if (this._pendingSessionCustomizationPublishes.get(session) === tracked) { this._pendingSessionCustomizationPublishes.delete(session); } }); + return publish; } private _publishPendingCustomizationEnablementRefreshes(): void { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 51b6b71d55ac2f..4e65655a9ccb36 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,8 +41,9 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type PluginCustomization, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; +import { CompletionItemKind } from '../../common/state/protocol/commands.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; @@ -543,6 +544,40 @@ suite('AgentService (node dispatcher)', () => { }]); }); + test('publishes a newly discovered skill before returning its completion', async () => { + registerTestAgentProvider(service, copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const customization: PluginCustomization = { + type: CustomizationType.Plugin, + id: customizationId('file:///dummy'), + uri: 'file:///dummy', + name: 'dummy', + children: [{ + type: CustomizationType.Skill, + id: customizationId('file:///dummy/skills/print-hello-world/SKILL.md'), + uri: 'file:///dummy/skills/print-hello-world/SKILL.md', + name: 'print-hello-world', + description: 'Calculate the sum of 3+3 and output it.', + }], + }; + copilotAgent.customizations = [customization]; + + const result = await service.completions({ + kind: CompletionItemKind.UserMessage, + channel: buildDefaultChatUri(session), + text: '/print', + offset: '/print'.length, + }); + + assert.deepStrictEqual({ + items: result.items.map(item => item.insertText), + published: getStateManager(service).getSessionState(session.toString())?.customizations, + }, { + items: ['/dummy:print-hello-world '], + published: [customization], + }); + }); + // ---- Provider registration ------------------------------------------ suite('registerProvider', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 44dd8400a14e61..7e7917d041c06c 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -1065,6 +1065,38 @@ suite('AgentSideEffects', () => { { session: sessionUri.toString(), customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }] }] }, ]); }); + + test('awaits a refresh retry superseded by a direct customization update', async () => { + setupSession(); + const updatedPlugin: PluginCustomization = { ...plugin, name: 'Updated Plugin' }; + let fetchCount = 0; + let signalFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { signalFetchStarted = resolve; }); + let releaseFetch!: () => void; + agent.getSessionCustomizations = async () => { + fetchCount++; + if (fetchCount === 1) { + signalFetchStarted(); + await new Promise(resolve => { releaseFetch = resolve; }); + return [plugin]; + } + return [updatedPlugin]; + }; + + const refresh = sideEffects.refreshSessionCustomizations(agent, sessionUri.toString()); + await fetchStarted; + stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [plugin] }); + releaseFetch(); + await refresh; + + assert.deepStrictEqual({ + fetchCount, + customizations: stateManager.getSessionState(sessionUri.toString())?.customizations, + }, { + fetchCount: 2, + customizations: [updatedPlugin], + }); + }); }); suite('handleAction — session/turnStarted', () => {