Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/skills/sessions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru

- **Default/main chat title persistence differs per provider — don't unify them**: For the **agent host**, the default (main) chat title is independent of the session title (`AgentHostSessionAdapter._defaultChatTitleOverride`, persisted on the host as `customChatTitle:<defaultChatUri>`); it must be seeded back on restore via `restoreSession`/`_ensureDefaultChat` (mirroring `_restorePeerChats`), or it reverts to the session title after a process restart / idle eviction. For the **local chat sessions provider** (`localChatSessionsProvider`), the primary chat and the session intentionally **share one `_title` observable**, so renaming the first/main chat updates the session title live — this is by design; do not "fix" it to be independent. Only additional (non-primary) local chats have their own title.

- **The agent picker only ever sees `SessionState.customizations`, so the client must publish its customizations when it *opens* a session, not only on the first turn**: for an agent-host session the picker list comes from `getEffectiveAgents(SessionState.customizations)` — the host's merged view of (1) host-configured, (2) session-discovered, and (3) **client-contributed** plugins. Client-installed VS Code plugins reach the host **only** via `session/activeClientSet` (push-only; there is no host pull API for them), so a session created in this process shows them (createSession carries `activeClient`) but a **reopened existing session** (or one created before the plugins were installed) shows none — the client never re-published. `AgentHostSessionHandler.provideChatSessionContent` deliberately does *not* register the active client on open (to avoid changing tool-dedup ownership for another client's in-flight turn). Fix: publish a **customizations-only** active-client entry (`tools: []`) on open via `_publishClientCustomizationsOnOpen` (no-op if already an active client so a real turn's tool contribution is never downgraded), and make the customization-sync autorun also cover the *resolve-after-open* ordering by publishing customizations-only for an opened **existing** (`!_isNewSessionResource`) session that isn't yet an active client. Tools stay deferred to the first turn (`_ensureActiveClientForMessage`). Do NOT read local prompt/agent files directly in the picker to "fix" this — that bypasses the host's enabled/expanded/dedup view.

- **`ISession.capabilities` must be observable, not a live plain getter**: capabilities can hydrate/change after a session first surfaces (e.g. an agent host whose root state arrives after the session's first `SessionState`). A plain getter cannot be tracked by the context-key autorun (`setActiveSessionContextKeys` reads it inside an `autorun`), so `supportsMultipleChats`/`sessionSupportsFork` would stay stale, and a multi-chat catalog processed while `supportsMultipleChats` was still `false` would stay collapsed to `[defaultChat]`. Expose `capabilities` as `IObservable<ISessionCapabilities>` (agent host derives it from `connection.rootState` via `observableFromEvent` + `derivedOpts` with `structuralEquals`; static providers use `constObservable`), have consumers read `.read(reader)`/`.get()`, and re-apply the chat catalog from the last `SessionState` in an `autorun` on capability change. Do **not** fix this by firing `_onDidChangeSessions` — the active-session context autorun tracks the session's own observables, not the provider's session-list event.

- **A managed/default editor tab must be re-ensured every sync, not opened once**: the per-session editor working-set restore (`baseSessionLayoutController` [B2] `_applyWorkingSet`) runs on session activation and is **not** docked-gated, so it reinstates the session's *saved* editor set and will close any tab not in it (e.g. a set persisted before a new tab existed). A controller that opens a default tab **once** (guarded by a `Set` of initialized sessions) therefore loses it permanently after the restore. Ensure managed tabs (the pinned Changes tab, the default File tab) **idempotently on every sync** (`group.editors.some(e => e instanceof X)` → open if absent), exactly like the Changes tab — never with an open-once `Set`. Also: `IEditorService.openEditor(input, group)` on a typed `EditorInput` binds `group` to the `options` param (overload is `(editor, options?, group?)`); pass `openEditor(input, undefined, group)`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp
import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
Expand Down Expand Up @@ -736,8 +736,27 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
const backendSession = this._resolveSessionUri(sessionResource);
const state = this._getSessionState(backendSession.toString());
const existing = state?.activeClients.find(c => c.clientId === clientId);
if (existing && !equals(existing.customizations ?? [], refs)) {
this._dispatchActiveClient(backendSession, [...refs]);
if (existing) {
if (!equals(existing.customizations ?? [], refs)) {
// Preserve the existing tool contribution: tools are synced
// by their own autorun above and, for a session that was
// only opened (not yet messaged), stay empty until the
// first turn.
this._dispatchAction(backendSession, {
type: ActionType.SessionActiveClientSet,
activeClient: { ...existing, customizations: [...refs] },
});
}
} else if (refs.length > 0 && !this._isNewSessionResource(sessionResource)) {
// An existing session was opened before this client's
// customizations finished resolving, so it isn't an active
// client yet. Publish customizations-only now (tools stay
// deferred to the first turn) so the picker still lists them.
// New sessions are skipped: they publish via `createSession`.
this._dispatchAction(backendSession, {
type: ActionType.SessionActiveClientSet,
activeClient: { clientId, tools: [], customizations: [...refs] },
Comment on lines +753 to +758

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tools should be readily accessible here via this._activeClientService.getClientTools(this._config.sessionType)

});
}
}
}));
Expand Down Expand Up @@ -1022,6 +1041,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
this._ensureDraftSyncSubscription(sessionResource, resolvedSession, chatURI);
}

// Surface this client's customizations (installed plugins / custom
// agents) on the session so the agent picker lists them when merely
// opening an existing session. Tools stay deferred to the first turn
// (see `_ensureActiveClientForMessage`) so viewing a session never
// changes tool ownership for another client's in-flight turn.
this._publishClientCustomizationsOnOpen(resolvedSession);

// Eagerly create the snapshot controller once the ChatModel for
// this session is available so that "Restore Checkpoint" works
// on historical turns. The model may already exist (in which
Expand Down Expand Up @@ -1412,15 +1438,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
}

/**
* Dispatches `session/activeClientSet` to add this connection as an
* active client for this session and publish the current customizations
* and client-provided tools. This client never removes itself.
* Publishes this client's customizations (installed plugins / custom
* agents) as an active-client entry with no tools, so the host expands
* them into the session's `customizations` and the agent picker reflects
* them when an existing session is opened. No-op if this client is already
* an active client (e.g. a turn was already sent) so an existing tool
* contribution is never downgraded. Tools are contributed later, on the
* first turn, by {@link _ensureActiveClientForMessage}. This client never
* removes itself.
*/
private _dispatchActiveClient(backendSession: URI, customizations: ClientPluginCustomization[]): void {
const current = this._getCurrentActiveClient();
private _publishClientCustomizationsOnOpen(backendSession: URI): void {
const clientId = this._config.connection.clientId;
const state = this._getSessionState(backendSession.toString());
if (state?.activeClients.some(c => c.clientId === clientId)) {
return;
}
const customizations = this._activeClientService.getCustomizations(this._config.sessionType).get();
if (customizations.length === 0) {
return;
}
this._dispatchAction(backendSession, {
type: ActionType.SessionActiveClientSet,
activeClient: { ...current, customizations },
activeClient: { clientId, tools: [], customizations: [...customizations] },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise here.

I feel like we can probably dedupe this method, and the two autoruns where we call getClientTools and getCustomizations

});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm
import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js';
import { isChatAction, isSessionAction, type ActionEnvelope, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { buildDefaultChatUri, buildSubagentChatUri, createChatState, createDefaultChatSummary, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, parseDefaultChatUri, type ChatState, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { buildDefaultChatUri, buildSubagentChatUri, createChatState, createDefaultChatSummary, CustomizationType, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, parseDefaultChatUri, type ChatState, type ClientPluginCustomization, type SessionActiveClient, type SessionState, type SessionSummary, type RootState, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { chatReducer, sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js';
import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js';
import { ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
Expand Down Expand Up @@ -424,6 +424,7 @@ suite('AgentHostClientTools', () => {
disposables: DisposableStore,
tools: IToolData[],
toolServiceOptions?: { requireConfirmation?: boolean; throwBeforeConfirmation?: Error },
activeClientCustomizations?: readonly ClientPluginCustomization[],
) {
const instantiationService = disposables.add(new TestInstantiationService());
const connection = new MockAgentHostConnection();
Expand Down Expand Up @@ -517,9 +518,23 @@ suite('AgentHostClientTools', () => {
});

// Use the real active-client service so the handler's tools autorun
// observes the mocked ILanguageModelToolsService tool sets.
const activeClientService = disposables.add(instantiationService.createInstance(AgentHostActiveClientService));
instantiationService.stub(IAgentHostActiveClientService, activeClientService);
// observes the mocked ILanguageModelToolsService tool sets. Tests that
// exercise the on-open customization publish override it with a mock
// that returns a fixed customizations set.
if (activeClientCustomizations) {
const customizationsObs = constObservable(activeClientCustomizations);
instantiationService.stub(IAgentHostActiveClientService, new class extends mock<IAgentHostActiveClientService>() {
override registerForAgent() { return { syncProvider: undefined!, dispose() { } }; }
override getActiveClient(_sessionType: string, clientId: string): SessionActiveClient {
return { clientId, tools: [], customizations: [...activeClientCustomizations] };
}
override getCustomizations() { return customizationsObs; }
override getClientTools() { return constObservable<readonly ToolDefinition[]>([]); }
}());
} else {
const activeClientService = disposables.add(instantiationService.createInstance(AgentHostActiveClientService));
instantiationService.stub(IAgentHostActiveClientService, activeClientService);
}

const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
Expand Down Expand Up @@ -632,6 +647,27 @@ suite('AgentHostClientTools', () => {
assert.strictEqual(runTestsDef.description, 'Runs unit tests');
});

test('publishes client customizations without tools when opening an existing session', async () => {
const customization: ClientPluginCustomization = {
type: CustomizationType.Plugin,
id: 'plugin-1',
uri: 'file:///plugins/inbox',
name: 'Inbox',
enabled: true,
};
const { handler, connection } = createHandlerWithMocks(disposables, [], undefined, [customization]);

await handler.provideChatSessionContent(URI.parse('agent-host-copilot:/session-1'), CancellationToken.None);
await timeout(0);

const entry = connection.dispatchedActions.find(e => e.action.type === ActionType.SessionActiveClientSet);
assert.ok(entry && entry.action.type === ActionType.SessionActiveClientSet, 'expected a SessionActiveClientSet dispatch on open');
assert.deepStrictEqual(
{ tools: entry.action.activeClient.tools, customizations: entry.action.activeClient.customizations },
{ tools: [], customizations: [customization] },
);
});

test('handles tools with when clauses via observeTools filtering', () => {
// The observeTools method already filters by `when` clauses.
// When a tool has a `when` clause that doesn't match, it won't
Expand Down
Loading