mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-28 18:12:45 -05:00
Add Agent Host session configuration flow
(Written by Copilot)
This commit is contained in:
@@ -12,7 +12,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js';
|
||||
import { IProtectedResourceMetadata } from './state/protocol/state.js';
|
||||
import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js';
|
||||
import type { IAgentSubscription } from './state/agentSubscription.js';
|
||||
import type { ICreateTerminalParams } from './state/protocol/commands.js';
|
||||
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js';
|
||||
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
|
||||
import { AttachmentType, ComponentToState, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type PolicyState, type StringOrMarkdown, SessionInputResponseKind } from './state/sessionState.js';
|
||||
|
||||
@@ -99,10 +99,22 @@ export interface IAgentCreateSessionConfig {
|
||||
readonly model?: string;
|
||||
readonly session?: URI;
|
||||
readonly workingDirectory?: URI;
|
||||
readonly config?: Record<string, string>;
|
||||
/** Fork from an existing session at a specific turn index. */
|
||||
readonly fork?: { readonly session: URI; readonly turnIndex: number };
|
||||
}
|
||||
|
||||
export interface IAgentResolveSessionConfigParams {
|
||||
readonly provider?: AgentProvider;
|
||||
readonly workingDirectory?: URI;
|
||||
readonly config?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessionConfigParams {
|
||||
readonly property: string;
|
||||
readonly query?: string;
|
||||
}
|
||||
|
||||
/** Serializable attachment passed alongside a message to the agent host. */
|
||||
export interface IAgentAttachment {
|
||||
readonly type: AttachmentType;
|
||||
@@ -335,6 +347,12 @@ export interface IAgent {
|
||||
/** Create a new session. Returns server-owned session metadata. */
|
||||
createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult>;
|
||||
|
||||
/** Resolve the dynamic configuration schema for creating a session. */
|
||||
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
|
||||
|
||||
/** Return dynamic completions for a session configuration property. */
|
||||
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
|
||||
|
||||
/** Send a user message into an existing session. */
|
||||
sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void>;
|
||||
|
||||
@@ -452,6 +470,12 @@ export interface IAgentService {
|
||||
/** Create a new session. Returns the session URI. */
|
||||
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
|
||||
|
||||
/** Resolve the dynamic configuration schema for creating a session. */
|
||||
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
|
||||
|
||||
/** Return dynamic completions for a session configuration property. */
|
||||
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
|
||||
|
||||
/** Dispose a session in the agent host, freeing SDK resources. */
|
||||
disposeSession(session: URI): Promise<void>;
|
||||
|
||||
@@ -557,6 +581,8 @@ export interface IAgentConnection {
|
||||
authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult>;
|
||||
listSessions(): Promise<IAgentSessionMetadata[]>;
|
||||
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
|
||||
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
|
||||
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
|
||||
disposeSession(session: URI): Promise<void>;
|
||||
|
||||
// ---- Terminal lifecycle -------------------------------------------------
|
||||
|
||||
@@ -1 +1 @@
|
||||
1f72258
|
||||
cd9331d
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// Generated from types/actions.ts — do not edit
|
||||
// Run `npm run generate` to regenerate.
|
||||
|
||||
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';
|
||||
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ISessionConfigChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';
|
||||
|
||||
|
||||
// ─── Root vs Session vs Terminal Action Unions ───────────────────────────────
|
||||
@@ -57,6 +57,7 @@ export type ISessionAction =
|
||||
| ISessionIsReadChangedAction
|
||||
| ISessionIsDoneChangedAction
|
||||
| ISessionDiffsChangedAction
|
||||
| ISessionConfigChangedAction
|
||||
;
|
||||
|
||||
/** Union of session actions that clients may dispatch. */
|
||||
@@ -79,6 +80,7 @@ export type IClientSessionAction =
|
||||
| ISessionTruncatedAction
|
||||
| ISessionIsReadChangedAction
|
||||
| ISessionIsDoneChangedAction
|
||||
| ISessionConfigChangedAction
|
||||
;
|
||||
|
||||
/** Union of session actions that only the server may produce. */
|
||||
@@ -173,6 +175,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
|
||||
[ActionType.SessionIsReadChanged]: true,
|
||||
[ActionType.SessionIsDoneChanged]: true,
|
||||
[ActionType.SessionDiffsChanged]: false,
|
||||
[ActionType.SessionConfigChanged]: true,
|
||||
[ActionType.TerminalData]: false,
|
||||
[ActionType.TerminalInput]: true,
|
||||
[ActionType.TerminalResized]: true,
|
||||
|
||||
@@ -53,6 +53,7 @@ export const enum ActionType {
|
||||
SessionIsReadChanged = 'session/isReadChanged',
|
||||
SessionIsDoneChanged = 'session/isDoneChanged',
|
||||
SessionDiffsChanged = 'session/diffsChanged',
|
||||
SessionConfigChanged = 'session/configChanged',
|
||||
RootTerminalsChanged = 'root/terminalsChanged',
|
||||
TerminalData = 'terminal/data',
|
||||
TerminalInput = 'terminal/input',
|
||||
@@ -663,6 +664,27 @@ export interface ISessionCustomizationToggledAction {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ─── Config Actions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Client changed a mutable config value mid-session.
|
||||
*
|
||||
* Only properties with `sessionMutable: true` in the config schema may be
|
||||
* changed. The server validates and broadcasts the action; the reducer merges
|
||||
* the new values into `state.config.values`.
|
||||
*
|
||||
* @category Session Actions
|
||||
* @version 1
|
||||
* @clientDispatchable
|
||||
*/
|
||||
export interface ISessionConfigChangedAction {
|
||||
type: ActionType.SessionConfigChanged;
|
||||
/** Session URI */
|
||||
session: URI;
|
||||
/** Updated config values (merged into existing config) */
|
||||
config: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── Truncation ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1006,6 +1028,7 @@ export type IStateAction =
|
||||
| ISessionIsReadChangedAction
|
||||
| ISessionIsDoneChangedAction
|
||||
| ISessionDiffsChangedAction
|
||||
| ISessionConfigChangedAction
|
||||
| ITerminalDataAction
|
||||
| ITerminalInputAction
|
||||
| ITerminalResizedAction
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
// allow-any-unicode-comment-file
|
||||
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
|
||||
|
||||
import type { URI, ISnapshot, ISessionSummary, ITurn, ITerminalClaim } from './state.js';
|
||||
import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, ITurn, ITerminalClaim } from './state.js';
|
||||
import type { IActionEnvelope, IStateAction } from './actions.js';
|
||||
|
||||
export type { ISessionConfigPropertySchema, ISessionConfigSchema } from './state.js';
|
||||
|
||||
// ─── initialize ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -191,6 +193,11 @@ export interface ICreateSessionParams {
|
||||
* from the source session up to and including the specified turn's response.
|
||||
*/
|
||||
fork?: ISessionForkSource;
|
||||
/**
|
||||
* Agent-specific configuration values collected via `resolveSessionConfig`.
|
||||
* Keys and values correspond to the schema returned by the server.
|
||||
*/
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── disposeSession ──────────────────────────────────────────────────────────
|
||||
@@ -687,3 +694,152 @@ export interface IAuthenticateParams {
|
||||
*/
|
||||
export interface IAuthenticateResult {
|
||||
}
|
||||
|
||||
// ─── resolveSessionConfig ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Iteratively resolves the session configuration schema. The client sends the
|
||||
* current partial session config and any user-filled metadata values. The server
|
||||
* returns a property schema describing what additional metadata is needed,
|
||||
* contextual to the current selections.
|
||||
*
|
||||
* The client calls this command whenever the user changes a significant input
|
||||
* (e.g. picks a working directory, toggles a property). Each response returns
|
||||
* the full current property set (not a delta). When `ready` is `true`, the
|
||||
* client may call `createSession` with the accumulated config.
|
||||
*
|
||||
* @category Commands
|
||||
* @method resolveSessionConfig
|
||||
* @direction Client → Server
|
||||
* @messageType Request
|
||||
* @version 1
|
||||
* @example
|
||||
* ```jsonc
|
||||
* // Step 1: Client picks a working directory
|
||||
* // Client → Server
|
||||
* { "jsonrpc": "2.0", "id": 5, "method": "resolveSessionConfig",
|
||||
* "params": { "workingDirectory": "file:///home/user/my-project" } }
|
||||
*
|
||||
* // Server → Client (git repo detected, offers worktree option)
|
||||
* { "jsonrpc": "2.0", "id": 5, "result": {
|
||||
* "ready": true,
|
||||
* "schema": {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "target": { "type": "string", "title": "Target", "enum": ["workspace", "worktree"] }
|
||||
* }
|
||||
* },
|
||||
* "values": {}
|
||||
* }}
|
||||
*
|
||||
* // Step 2: User enables worktree
|
||||
* // Client → Server
|
||||
* { "jsonrpc": "2.0", "id": 6, "method": "resolveSessionConfig",
|
||||
* "params": { "workingDirectory": "file:///home/user/my-project",
|
||||
* "config": { "target": "worktree" } } }
|
||||
*
|
||||
* // Server → Client (now requires branch selection)
|
||||
* { "jsonrpc": "2.0", "id": 6, "result": {
|
||||
* "ready": false,
|
||||
* "schema": {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "target": { "type": "string", "title": "Target", "enum": ["workspace", "worktree"] },
|
||||
* "baseBranch": { "type": "string", "title": "Base Branch",
|
||||
* "enum": ["main", "develop"],
|
||||
* "enumLabels": ["main", "develop"] }
|
||||
* },
|
||||
* "required": ["baseBranch"]
|
||||
* },
|
||||
* "values": { "target": "worktree" }
|
||||
* }}
|
||||
* ```
|
||||
*/
|
||||
export interface IResolveSessionConfigParams {
|
||||
/** Agent provider ID */
|
||||
provider?: string;
|
||||
/** Working directory for the session */
|
||||
workingDirectory?: URI;
|
||||
/** Current user-filled configuration values */
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the `resolveSessionConfig` command.
|
||||
*/
|
||||
export interface IResolveSessionConfigResult {
|
||||
/** True when all required configuration is satisfied and `createSession` can be called */
|
||||
ready: boolean;
|
||||
/** JSON Schema describing available configuration properties given the current context */
|
||||
schema: ISessionConfigSchema;
|
||||
/** Current configuration values (echoed back with server-resolved defaults applied) */
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── sessionConfigCompletions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single value item returned by `sessionConfigCompletions`.
|
||||
*
|
||||
* @category Commands
|
||||
*/
|
||||
export interface ISessionConfigValueItem {
|
||||
/** The value to store in config */
|
||||
value: string;
|
||||
/** Human-readable display label */
|
||||
label: string;
|
||||
/** Optional secondary description */
|
||||
description?: string;
|
||||
/** Optional icon identifier */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the server for allowed values of a dynamic session config property.
|
||||
*
|
||||
* Used when a property in the schema returned by `resolveSessionConfig` has
|
||||
* `enumDynamic: true`. The client sends a search query and receives matching
|
||||
* values with display metadata.
|
||||
*
|
||||
* @category Commands
|
||||
* @method sessionConfigCompletions
|
||||
* @direction Client → Server
|
||||
* @messageType Request
|
||||
* @version 1
|
||||
* @example
|
||||
* ```jsonc
|
||||
* // Client → Server (user types "ma" in branch picker)
|
||||
* { "jsonrpc": "2.0", "id": 7, "method": "sessionConfigCompletions",
|
||||
* "params": { "workingDirectory": "file:///home/user/my-project",
|
||||
* "config": { "target": "worktree" },
|
||||
* "property": "baseBranch", "query": "ma" } }
|
||||
*
|
||||
* // Server → Client
|
||||
* { "jsonrpc": "2.0", "id": 7, "result": {
|
||||
* "items": [
|
||||
* { "value": "main", "label": "main", "icon": "git-branch" },
|
||||
* { "value": "main-v2", "label": "main-v2", "icon": "git-branch" }
|
||||
* ]
|
||||
* }}
|
||||
* ```
|
||||
*/
|
||||
export interface ISessionConfigCompletionsParams {
|
||||
/** Agent provider ID */
|
||||
provider?: string;
|
||||
/** Working directory for the session */
|
||||
workingDirectory?: URI;
|
||||
/** Current user-filled configuration values (provides context for the query) */
|
||||
config?: Record<string, string>;
|
||||
/** Property id from the schema to query values for */
|
||||
property: string;
|
||||
/** Search filter text (empty or omitted returns default/recent values) */
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the `sessionConfigCompletions` command.
|
||||
*/
|
||||
export interface ISessionConfigCompletionsResult {
|
||||
/** Matching value items */
|
||||
items: ISessionConfigValueItem[];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// allow-any-unicode-comment-file
|
||||
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
|
||||
|
||||
import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, ICreateTerminalParams, IDisposeTerminalParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js';
|
||||
import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, ICreateTerminalParams, IDisposeTerminalParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult, IResolveSessionConfigParams, IResolveSessionConfigResult, ISessionConfigCompletionsParams, ISessionConfigCompletionsResult } from './commands.js';
|
||||
|
||||
import type { IActionEnvelope } from './actions.js';
|
||||
import type { IProtocolNotification } from './notifications.js';
|
||||
@@ -73,6 +73,8 @@ export interface ICommandMap {
|
||||
'resourceMove': { params: IResourceMoveParams; result: IResourceMoveResult };
|
||||
'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult };
|
||||
'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult };
|
||||
'resolveSessionConfig': { params: IResolveSessionConfigParams; result: IResolveSessionConfigResult };
|
||||
'sessionConfigCompletions': { params: ISessionConfigCompletionsParams; result: ISessionConfigCompletionsResult };
|
||||
}
|
||||
|
||||
// ─── Notification Maps ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -541,6 +541,22 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
|
||||
summary: { ...state.summary, diffs: action.diffs },
|
||||
};
|
||||
|
||||
case ActionType.SessionConfigChanged:
|
||||
if (!state.config) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
values: { ...state.config.values, ...action.config },
|
||||
},
|
||||
summary: {
|
||||
...state.summary,
|
||||
modifiedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
case ActionType.SessionServerToolsChanged:
|
||||
return { ...state, serverTools: action.tools };
|
||||
|
||||
|
||||
@@ -294,6 +294,8 @@ export interface ISessionState {
|
||||
queuedMessages?: IPendingMessage[];
|
||||
/** Requests for user input that are currently blocking or informing session progress */
|
||||
inputRequests?: ISessionInputRequest[];
|
||||
/** Session configuration schema and current values */
|
||||
config?: ISessionConfigState;
|
||||
/**
|
||||
* Server-provided customizations active in this session.
|
||||
*
|
||||
@@ -378,6 +380,76 @@ export interface ISessionSummary {
|
||||
diffs?: ISessionFileDiff[];
|
||||
}
|
||||
|
||||
// ─── Session Config Types ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A JSON Schema-compatible string enum property descriptor with display extensions.
|
||||
*
|
||||
* Standard JSON Schema fields (`type`, `title`, `description`, `default`,
|
||||
* `enum`) allow validators to process the schema. Display extensions
|
||||
* (`enumLabels`, `enumDescriptions`, `enumIcons`) are parallel arrays that provide UI metadata for each `enum` value.
|
||||
*
|
||||
* @category Session Config Types
|
||||
*/
|
||||
export interface ISessionConfigPropertySchema {
|
||||
/** JSON Schema: property type. Only string enum properties are currently supported. */
|
||||
type: 'string';
|
||||
/** JSON Schema: human-readable label for the property */
|
||||
title: string;
|
||||
/** JSON Schema: description / tooltip */
|
||||
description?: string;
|
||||
/** JSON Schema: default value */
|
||||
default?: string;
|
||||
/** JSON Schema: allowed values */
|
||||
enum: string[];
|
||||
/** Display extension: human-readable label per enum value (parallel array) */
|
||||
enumLabels?: string[];
|
||||
/** Display extension: description per enum value (parallel array) */
|
||||
enumDescriptions?: string[];
|
||||
/** Display extension: icon identifier per enum value (parallel array) */
|
||||
enumIcons?: string[];
|
||||
/**
|
||||
* Display extension: when `true`, the full set of allowed values is too large
|
||||
* to enumerate statically. The client SHOULD use `sessionConfigCompletions`
|
||||
* to fetch matching values based on user input. Any values in `enum` are
|
||||
* seed/recent values for initial display.
|
||||
*/
|
||||
enumDynamic?: boolean;
|
||||
/** JSON Schema: when `true`, the property is displayed but cannot be modified by the user */
|
||||
readOnly?: boolean;
|
||||
/** When `true`, the user may change this property after session creation */
|
||||
sessionMutable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON Schema object describing available session configuration metadata.
|
||||
*
|
||||
* @category Session Config Types
|
||||
*/
|
||||
export interface ISessionConfigSchema {
|
||||
/** JSON Schema: always `'object'` */
|
||||
type: 'object';
|
||||
/** JSON Schema: property descriptors keyed by property id */
|
||||
properties: Record<string, ISessionConfigPropertySchema>;
|
||||
/** JSON Schema: list of required property ids */
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Live session configuration metadata.
|
||||
*
|
||||
* The schema describes the available configuration properties and the values
|
||||
* contain the current value for each resolved property.
|
||||
*
|
||||
* @category Session Config Types
|
||||
*/
|
||||
export interface ISessionConfigState {
|
||||
/** JSON Schema describing available configuration properties */
|
||||
schema: ISessionConfigSchema;
|
||||
/** Current configuration values */
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── Session Input Types ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe
|
||||
[ActionType.SessionIsReadChanged]: 1,
|
||||
[ActionType.SessionIsDoneChanged]: 1,
|
||||
[ActionType.SessionDiffsChanged]: 1,
|
||||
[ActionType.SessionConfigChanged]: 1,
|
||||
[ActionType.RootTerminalsChanged]: 1,
|
||||
[ActionType.TerminalData]: 1,
|
||||
[ActionType.TerminalInput]: 1,
|
||||
|
||||
@@ -43,6 +43,7 @@ export {
|
||||
type IResponsePart,
|
||||
type IRootState,
|
||||
type ISessionActiveClient,
|
||||
type ISessionConfigState,
|
||||
type ISessionFileDiff,
|
||||
type ISessionModelInfo,
|
||||
type ISessionState,
|
||||
|
||||
@@ -13,9 +13,9 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'
|
||||
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
|
||||
import { IConfigurationService } from '../../configuration/common/configuration.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
|
||||
import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
|
||||
import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js';
|
||||
import type { ICreateTerminalParams } from '../common/state/protocol/commands.js';
|
||||
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../common/state/sessionActions.js';
|
||||
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { StateComponents, ROOT_STATE_URI, type IRootState } from '../common/state/sessionState.js';
|
||||
@@ -113,6 +113,12 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
|
||||
createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
|
||||
return this._proxy.createSession(config);
|
||||
}
|
||||
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
return this._proxy.resolveSessionConfig(params);
|
||||
}
|
||||
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
return this._proxy.sessionConfigCompletions(params);
|
||||
}
|
||||
disposeSession(session: URI): Promise<void> {
|
||||
return this._proxy.disposeSession(session);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { URI } from '../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../base/common/uuid.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js';
|
||||
import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
|
||||
import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
|
||||
import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js';
|
||||
import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js';
|
||||
import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js';
|
||||
@@ -26,7 +26,7 @@ import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
|
||||
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js';
|
||||
import { AhpErrorCodes } from '../common/state/protocol/errors.js';
|
||||
import { ContentEncoding, type ICreateTerminalParams } from '../common/state/protocol/commands.js';
|
||||
import { ContentEncoding, type ICreateTerminalParams, type IResolveSessionConfigResult, type ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
|
||||
|
||||
/**
|
||||
@@ -187,10 +187,29 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
provider,
|
||||
model: config?.model,
|
||||
workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined,
|
||||
config: config?.config,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
return this._sendRequest('resolveSessionConfig', {
|
||||
provider: params.provider,
|
||||
workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
|
||||
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
return this._sendRequest('sessionConfigCompletions', {
|
||||
provider: params.provider,
|
||||
workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined,
|
||||
config: params.config,
|
||||
property: params.property,
|
||||
query: params.query,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with the remote agent host using a specific scheme.
|
||||
*/
|
||||
|
||||
93
src/vs/platform/agentHost/node/agentHostGitService.ts
Normal file
93
src/vs/platform/agentHost/node/agentHostGitService.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as cp from 'child_process';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
|
||||
export const IAgentHostGitService = createDecorator<IAgentHostGitService>('agentHostGitService');
|
||||
|
||||
export interface IAgentHostGitService {
|
||||
readonly _serviceBrand: undefined;
|
||||
isInsideWorkTree(workingDirectory: URI): Promise<boolean>;
|
||||
getCurrentBranch(workingDirectory: URI): Promise<string | undefined>;
|
||||
getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]>;
|
||||
getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined>;
|
||||
getWorktreeRoots(workingDirectory: URI): Promise<URI[]>;
|
||||
addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void>;
|
||||
removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void>;
|
||||
}
|
||||
|
||||
export class AgentHostGitService implements IAgentHostGitService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
async isInsideWorkTree(workingDirectory: URI): Promise<boolean> {
|
||||
return (await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']))?.trim() === 'true';
|
||||
}
|
||||
|
||||
async getCurrentBranch(workingDirectory: URI): Promise<string | undefined> {
|
||||
return (await this._runGit(workingDirectory, ['branch', '--show-current']))?.trim()
|
||||
|| (await this._runGit(workingDirectory, ['rev-parse', '--short', 'HEAD']))?.trim()
|
||||
|| undefined;
|
||||
}
|
||||
|
||||
async getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]> {
|
||||
const args = ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate'];
|
||||
if (options?.limit && !options.query) {
|
||||
args.push(`--count=${options.limit}`);
|
||||
}
|
||||
args.push('refs/heads');
|
||||
|
||||
const output = await this._runGit(workingDirectory, args);
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
const branches = output.split(/\r?\n/g).map(line => line.trim()).filter(branch => branch.length > 0);
|
||||
const normalizedQuery = options?.query?.toLowerCase();
|
||||
const filtered = normalizedQuery
|
||||
? branches.filter(branch => branch.toLowerCase().includes(normalizedQuery))
|
||||
: branches;
|
||||
return options?.limit ? filtered.slice(0, options.limit) : filtered;
|
||||
}
|
||||
|
||||
async getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined> {
|
||||
const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim();
|
||||
return repositoryRootPath ? URI.file(repositoryRootPath) : undefined;
|
||||
}
|
||||
|
||||
async getWorktreeRoots(workingDirectory: URI): Promise<URI[]> {
|
||||
const output = await this._runGit(workingDirectory, ['worktree', 'list', '--porcelain']);
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
return output.split(/\r?\n/g)
|
||||
.filter(line => line.startsWith('worktree '))
|
||||
.map(line => URI.file(line.substring('worktree '.length)));
|
||||
}
|
||||
|
||||
async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise<void> {
|
||||
await this._runGit(repositoryRoot, ['worktree', 'add', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true });
|
||||
}
|
||||
|
||||
async removeWorktree(repositoryRoot: URI, worktree: URI): Promise<void> {
|
||||
await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true });
|
||||
}
|
||||
|
||||
private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean }): Promise<string | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, timeout: options?.timeout ?? 5000 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (options?.throwOnError) {
|
||||
reject(new Error(stderr || error.message));
|
||||
return;
|
||||
}
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFile
|
||||
import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js';
|
||||
import { IAgentPluginManager } from '../common/agentPluginManager.js';
|
||||
import { AgentPluginManager } from './agentPluginManager.js';
|
||||
import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js';
|
||||
|
||||
// Entry point for the agent host utility process.
|
||||
// Sets up IPC, logging, and registers agent providers (Copilot).
|
||||
@@ -86,12 +87,12 @@ function startAgentHost(): void {
|
||||
diServices.set(IFileService, fileService);
|
||||
diServices.set(ISessionDataService, sessionDataService);
|
||||
diServices.set(IAgentPluginManager, pluginManager);
|
||||
|
||||
const diffComputeService = disposables.add(new NodeWorkerDiffComputeService(logService));
|
||||
diServices.set(IDiffComputeService, diffComputeService);
|
||||
|
||||
diServices.set(IAgentHostTerminalManager, agentService.terminalManager);
|
||||
const instantiationService = new InstantiationService(diServices);
|
||||
diServices.set(IAgentHostGitService, instantiationService.createInstance(AgentHostGitService));
|
||||
agentService.registerProvider(instantiationService.createInstance(CopilotAgent));
|
||||
} catch (err) {
|
||||
logService.error('Failed to create AgentService', err);
|
||||
|
||||
@@ -47,6 +47,7 @@ import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js';
|
||||
import { resolveServerUrls } from './serverUrls.js';
|
||||
import { AgentPluginManager } from './agentPluginManager.js';
|
||||
import { IAgentPluginManager } from '../common/agentPluginManager.js';
|
||||
import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js';
|
||||
|
||||
/** Log to stderr so messages appear in the terminal alongside the process. */
|
||||
function log(msg: string): void {
|
||||
@@ -174,6 +175,7 @@ async function main(): Promise<void> {
|
||||
diServices.set(IDiffComputeService, disposables.add(new NodeWorkerDiffComputeService(logService)));
|
||||
diServices.set(IAgentHostTerminalManager, agentService.terminalManager);
|
||||
const instantiationService = new InstantiationService(diServices);
|
||||
diServices.set(IAgentHostGitService, instantiationService.createInstance(AgentHostGitService));
|
||||
const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent));
|
||||
agentService.registerProvider(copilotAgent);
|
||||
log('CopilotAgent registered');
|
||||
|
||||
@@ -11,12 +11,12 @@ import { URI } from '../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../base/common/uuid.js';
|
||||
import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
|
||||
import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
|
||||
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||
import { ActionType, IActionEnvelope, INotification, ISessionAction, ITerminalAction, isSessionAction } from '../common/state/sessionActions.js';
|
||||
import type { ICreateTerminalParams } from '../common/state/protocol/commands.js';
|
||||
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type IResponsePart, type ISessionFileDiff, type ISessionSummary, type IToolCallCompletedState, type IToolResultSubagentContent, type ITurn } from '../common/state/sessionState.js';
|
||||
import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type IResponsePart, type ISessionConfigState, type ISessionFileDiff, type ISessionSummary, type IToolCallCompletedState, type IToolResultSubagentContent, type ITurn } from '../common/state/sessionState.js';
|
||||
import { AgentSideEffects } from './agentSideEffects.js';
|
||||
import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js';
|
||||
import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js';
|
||||
@@ -212,6 +212,8 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._sessionToProvider.set(session.toString(), provider.id);
|
||||
this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);
|
||||
|
||||
const sessionConfig = await this._resolveCreatedSessionConfig(provider, config);
|
||||
|
||||
// When forking, populate the new session's protocol state with
|
||||
// the source session's turns so the client sees the forked history.
|
||||
if (config?.fork) {
|
||||
@@ -233,6 +235,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
workingDirectory: config.workingDirectory?.toString(),
|
||||
};
|
||||
const state = this._stateManager.createSession(summary);
|
||||
state.config = sessionConfig;
|
||||
state.turns = sourceTurns;
|
||||
} else {
|
||||
// Create empty state for new sessions
|
||||
@@ -246,13 +249,49 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
|
||||
workingDirectory: config?.workingDirectory?.toString(),
|
||||
};
|
||||
this._stateManager.createSession(summary);
|
||||
const state = this._stateManager.createSession(summary);
|
||||
state.config = sessionConfig;
|
||||
}
|
||||
this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() });
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise<ISessionConfigState | undefined> {
|
||||
if (!config?.config && !config?.workingDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const resolved = await provider.resolveSessionConfig({
|
||||
provider: provider.id,
|
||||
workingDirectory: config.workingDirectory,
|
||||
config: config.config,
|
||||
});
|
||||
return { schema: resolved.schema, values: resolved.values };
|
||||
} catch (error) {
|
||||
this._logService.error(`[AgentService] Failed to resolve created session config for provider ${provider.id}`, error);
|
||||
return config.config ? { schema: { type: 'object', properties: {} }, values: config.config } : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
const providerId = params.provider ?? this._defaultProvider;
|
||||
const provider = providerId ? this._providers.get(providerId) : undefined;
|
||||
if (!provider) {
|
||||
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
|
||||
}
|
||||
return provider.resolveSessionConfig(params);
|
||||
}
|
||||
|
||||
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
const providerId = params.provider ?? this._defaultProvider;
|
||||
const provider = providerId ? this._providers.get(providerId) : undefined;
|
||||
if (!provider) {
|
||||
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
|
||||
}
|
||||
return provider.sessionConfigCompletions(params);
|
||||
}
|
||||
|
||||
async disposeSession(session: URI): Promise<void> {
|
||||
this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`);
|
||||
const provider = this._findProviderForSession(session);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CopilotClient } from '@github/copilot-sdk';
|
||||
import * as fs from 'fs/promises';
|
||||
import { rgPath } from '@vscode/ripgrep';
|
||||
import { Limiter, SequencerByKey } from '../../../../base/common/async.js';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
@@ -16,8 +17,10 @@ import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginP
|
||||
import { IFileService } from '../../../files/common/files.js';
|
||||
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../log/common/log.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
|
||||
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
||||
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
||||
import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js';
|
||||
import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js';
|
||||
@@ -25,15 +28,22 @@ import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toS
|
||||
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||
import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js';
|
||||
import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
|
||||
import { IAgentHostGitService } from '../agentHostGitService.js';
|
||||
import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
|
||||
import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
|
||||
import { createShellTools, ShellManager } from './copilotShellTools.js';
|
||||
|
||||
interface ICreatedWorktree {
|
||||
readonly repositoryRoot: URI;
|
||||
readonly worktree: URI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent provider backed by the Copilot SDK {@link CopilotClient}.
|
||||
*/
|
||||
export class CopilotAgent extends Disposable implements IAgent {
|
||||
readonly id = 'copilot' as const;
|
||||
private static readonly _BRANCH_COMPLETION_LIMIT = 25;
|
||||
|
||||
private readonly _onDidSessionProgress = this._register(new Emitter<IAgentProgressEvent>());
|
||||
readonly onDidSessionProgress = this._onDidSessionProgress.event;
|
||||
@@ -42,7 +52,9 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
private _clientStarting: Promise<CopilotClient> | undefined;
|
||||
private _githubToken: string | undefined;
|
||||
private readonly _sessions = this._register(new DisposableMap<string, CopilotAgentSession>());
|
||||
private readonly _createdWorktrees = new Map<string, ICreatedWorktree>();
|
||||
private readonly _sessionSequencer = new SequencerByKey<string>();
|
||||
private _shutdownPromise: Promise<void> | undefined;
|
||||
private readonly _plugins: PluginController;
|
||||
|
||||
constructor(
|
||||
@@ -50,6 +62,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||||
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
|
||||
@IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager,
|
||||
) {
|
||||
super();
|
||||
@@ -232,7 +245,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
const agentSession = await this._resumeSession(newSessionId);
|
||||
const session = agentSession.sessionUri;
|
||||
this._logService.info(`[Copilot] Forked session created: ${session.toString()}`);
|
||||
const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath });
|
||||
const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath }, this._gitService);
|
||||
this._storeSessionMetadata(session, undefined, config.workingDirectory, project, true);
|
||||
return { session, ...(project ? { project } : {}) };
|
||||
});
|
||||
@@ -242,31 +255,95 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
const sessionUri = AgentSession.uri(this.id, sessionId);
|
||||
const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri);
|
||||
const sessionConfig = this._buildSessionConfig(parsedPlugins, shellManager);
|
||||
const workingDirectory = await this._resolveSessionWorkingDirectory(config, sessionId);
|
||||
|
||||
const factory: SessionWrapperFactory = async callbacks => {
|
||||
const raw = await client.createSession({
|
||||
model: config?.model,
|
||||
sessionId,
|
||||
streaming: true,
|
||||
workingDirectory: config?.workingDirectory?.fsPath,
|
||||
workingDirectory: workingDirectory?.fsPath,
|
||||
...await sessionConfig(callbacks),
|
||||
});
|
||||
return new CopilotSessionWrapper(raw);
|
||||
};
|
||||
|
||||
const agentSession = this._createAgentSession(factory, config?.workingDirectory, sessionId, shellManager);
|
||||
this._plugins.setAppliedPlugins(agentSession, parsedPlugins);
|
||||
await agentSession.initializeSession();
|
||||
|
||||
let agentSession: CopilotAgentSession;
|
||||
try {
|
||||
agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager);
|
||||
this._plugins.setAppliedPlugins(agentSession, parsedPlugins);
|
||||
await agentSession.initializeSession();
|
||||
} catch (error) {
|
||||
await this._removeCreatedWorktree(sessionId);
|
||||
throw error;
|
||||
}
|
||||
const session = agentSession.sessionUri;
|
||||
this._logService.info(`[Copilot] Session created: ${session.toString()}`);
|
||||
const project = await projectFromCopilotContext({ cwd: config?.workingDirectory?.fsPath });
|
||||
const project = await projectFromCopilotContext({ cwd: workingDirectory?.fsPath }, this._gitService);
|
||||
// Persist model, working directory, and project so we can recreate the
|
||||
// session if the SDK loses it and avoid rediscovering git metadata.
|
||||
this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory, project, true);
|
||||
this._storeSessionMetadata(agentSession.sessionUri, config?.model, workingDirectory, project, true);
|
||||
return { session, ...(project ? { project } : {}) };
|
||||
}
|
||||
|
||||
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined;
|
||||
const targetValue = params.config?.target === 'folder' || params.config?.target === 'worktree'
|
||||
? params.config.target
|
||||
: gitInfo ? 'worktree' : 'folder';
|
||||
|
||||
const values: Record<string, string> = { target: targetValue };
|
||||
if (gitInfo) {
|
||||
values.branch = typeof params.config?.branch === 'string' && targetValue === 'worktree'
|
||||
? params.config.branch
|
||||
: gitInfo.currentBranch;
|
||||
}
|
||||
|
||||
const properties: IResolveSessionConfigResult['schema']['properties'] = {
|
||||
target: {
|
||||
type: 'string',
|
||||
title: localize('agentHost.sessionConfig.target', "Target"),
|
||||
description: localize('agentHost.sessionConfig.targetDescription', "Where the agent should make changes"),
|
||||
enum: gitInfo ? ['folder', 'worktree'] : ['folder'],
|
||||
enumLabels: gitInfo ? [localize('agentHost.sessionConfig.target.folder', "Folder"), localize('agentHost.sessionConfig.target.worktree', "Worktree")] : [localize('agentHost.sessionConfig.target.folder', "Folder")],
|
||||
enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.target.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.target.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.target.folderDescription', "Work directly in the folder")],
|
||||
enumIcons: gitInfo ? ['folder', 'worktree'] : ['folder'],
|
||||
default: gitInfo ? 'worktree' : 'folder',
|
||||
readOnly: !gitInfo,
|
||||
},
|
||||
};
|
||||
|
||||
if (gitInfo) {
|
||||
const branchReadOnly = targetValue === 'folder';
|
||||
properties.branch = {
|
||||
type: 'string',
|
||||
title: localize('agentHost.sessionConfig.branch', "Branch"),
|
||||
description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"),
|
||||
enum: branchReadOnly ? [gitInfo.currentBranch] : gitInfo.recentBranches,
|
||||
enumLabels: branchReadOnly ? [gitInfo.currentBranch] : gitInfo.recentBranches,
|
||||
enumIcons: branchReadOnly ? ['git-branch'] : gitInfo.recentBranches.map(() => 'git-branch'),
|
||||
default: gitInfo.currentBranch,
|
||||
enumDynamic: !branchReadOnly,
|
||||
readOnly: branchReadOnly,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: Object.values(values).every(value => value !== ''),
|
||||
schema: { type: 'object', properties },
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
if (params.property !== 'branch' || !params.workingDirectory) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const branches = await this._getBranches(params.workingDirectory, params.query);
|
||||
return { items: branches.map(branch => ({ value: branch, label: branch, icon: 'git-branch' })) };
|
||||
}
|
||||
|
||||
async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise<ISyncedCustomization[]> {
|
||||
return this._plugins.sync(clientId, customizations, progress);
|
||||
}
|
||||
@@ -323,7 +400,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
async disposeSession(session: URI): Promise<void> {
|
||||
const sessionId = AgentSession.id(session);
|
||||
await this._sessionSequencer.queue(sessionId, async () => {
|
||||
this._sessions.deleteAndDispose(sessionId);
|
||||
await this._destroyAndDisposeSession(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,10 +460,16 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this._logService.info('[Copilot] Shutting down...');
|
||||
this._sessions.clearAndDisposeAll();
|
||||
await this._client?.stop();
|
||||
this._client = undefined;
|
||||
this._shutdownPromise ??= (async () => {
|
||||
this._logService.info('[Copilot] Shutting down...');
|
||||
const sessionIds = new Set([...this._sessions.keys(), ...this._createdWorktrees.keys()]);
|
||||
for (const sessionId of sessionIds) {
|
||||
await this._sessionSequencer.queue(sessionId, () => this._destroyAndDisposeSession(sessionId));
|
||||
}
|
||||
await this._client?.stop();
|
||||
this._client = undefined;
|
||||
})();
|
||||
return this._shutdownPromise;
|
||||
}
|
||||
|
||||
respondToPermissionRequest(requestId: string, approved: boolean): void {
|
||||
@@ -436,6 +519,19 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
return agentSession;
|
||||
}
|
||||
|
||||
private async _destroyAndDisposeSession(sessionId: string): Promise<void> {
|
||||
const entry = this._sessions.get(sessionId);
|
||||
if (entry) {
|
||||
try {
|
||||
await entry.destroySession();
|
||||
} catch (error) {
|
||||
this._logService.warn(`[Copilot:${sessionId}] Failed to destroy session before cleanup: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
this._sessions.deleteAndDispose(sessionId);
|
||||
await this._removeCreatedWorktree(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the common session configuration (plugins + shell tools) shared
|
||||
* by both {@link createSession} and {@link _resumeSession}.
|
||||
@@ -506,6 +602,55 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
return agentSession;
|
||||
}
|
||||
|
||||
private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; recentBranches: string[] } | undefined> {
|
||||
if (!await this._gitService.isInsideWorkTree(workingDirectory)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentBranch = await this._gitService.getCurrentBranch(workingDirectory) ?? 'HEAD';
|
||||
const recentBranches = await this._getBranches(workingDirectory);
|
||||
return { currentBranch, recentBranches: this._prependUnique(currentBranch, recentBranches) };
|
||||
}
|
||||
|
||||
private async _getBranches(workingDirectory: URI, query?: string): Promise<string[]> {
|
||||
return this._gitService.getBranches(workingDirectory, { query, limit: CopilotAgent._BRANCH_COMPLETION_LIMIT });
|
||||
}
|
||||
|
||||
private _prependUnique(value: string, values: readonly string[]): string[] {
|
||||
return [value, ...values.filter(candidate => candidate !== value)];
|
||||
}
|
||||
|
||||
private async _resolveSessionWorkingDirectory(config: IAgentCreateSessionConfig | undefined, sessionId: string): Promise<URI | undefined> {
|
||||
if (config?.config?.target !== 'worktree' || !config.workingDirectory || typeof config.config.branch !== 'string') {
|
||||
return config?.workingDirectory;
|
||||
}
|
||||
|
||||
const repositoryRoot = await this._gitService.getRepositoryRoot(config.workingDirectory);
|
||||
if (!repositoryRoot) {
|
||||
return config.workingDirectory;
|
||||
}
|
||||
|
||||
const worktreesRoot = URI.joinPath(repositoryRoot, '..', '.copilot-worktrees');
|
||||
const worktree = URI.joinPath(worktreesRoot, sessionId);
|
||||
await fs.mkdir(worktreesRoot.fsPath, { recursive: true });
|
||||
await this._gitService.addWorktree(repositoryRoot, worktree, `copilot/${sessionId}`, config.config.branch);
|
||||
this._createdWorktrees.set(sessionId, { repositoryRoot, worktree });
|
||||
return worktree;
|
||||
}
|
||||
|
||||
private async _removeCreatedWorktree(sessionId: string): Promise<void> {
|
||||
const worktree = this._createdWorktrees.get(sessionId);
|
||||
if (!worktree) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._gitService.removeWorktree(worktree.repositoryRoot, worktree.worktree);
|
||||
this._createdWorktrees.delete(sessionId);
|
||||
} catch (error) {
|
||||
this._logService.warn(`[Copilot:${sessionId}] Failed to remove worktree '${worktree.worktree.fsPath}': ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- session metadata persistence --------------------------------------
|
||||
|
||||
private static readonly _META_MODEL = 'copilot.model';
|
||||
@@ -588,7 +733,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
|
||||
let project = projectByContext.get(key);
|
||||
if (!project) {
|
||||
project = limiter.queue(() => projectFromCopilotContext(context));
|
||||
project = limiter.queue(() => projectFromCopilotContext(context, this._gitService));
|
||||
projectByContext.set(key, project);
|
||||
}
|
||||
return project;
|
||||
@@ -608,8 +753,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._client?.stop().catch(() => { /* best-effort */ });
|
||||
super.dispose();
|
||||
this.shutdown().catch(() => { /* best-effort */ }).finally(() => super.dispose());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as cp from 'child_process';
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
import { basename } from '../../../../base/common/path.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import type { IAgentSessionProjectInfo } from '../../common/agentService.js';
|
||||
import type { IAgentHostGitService } from '../agentHostGitService.js';
|
||||
|
||||
export interface ICopilotSessionContext {
|
||||
readonly cwd?: string;
|
||||
@@ -15,49 +15,20 @@ export interface ICopilotSessionContext {
|
||||
readonly repository?: string;
|
||||
}
|
||||
|
||||
function execGit(cwd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
cp.execFile('git', args, { cwd, encoding: 'utf8' }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveGitProject(workingDirectory: URI | undefined): Promise<IAgentSessionProjectInfo | undefined> {
|
||||
export async function resolveGitProject(workingDirectory: URI | undefined, gitService: IAgentHostGitService): Promise<IAgentSessionProjectInfo | undefined> {
|
||||
if (!workingDirectory || workingDirectory.scheme !== Schemas.file) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cwd = workingDirectory.fsPath;
|
||||
try {
|
||||
if ((await execGit(cwd, ['rev-parse', '--is-inside-work-tree'])) !== 'true') {
|
||||
return undefined;
|
||||
}
|
||||
} catch {
|
||||
if (!await gitService.isInsideWorkTree(workingDirectory)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let projectPath: string | undefined;
|
||||
try {
|
||||
const worktreeList = await execGit(cwd, ['worktree', 'list', '--porcelain']);
|
||||
projectPath = worktreeList.split(/\r?\n/).find(line => line.startsWith('worktree '))?.substring('worktree '.length);
|
||||
} catch {
|
||||
// Fall back to the current worktree root below.
|
||||
const uri = (await gitService.getWorktreeRoots(workingDirectory))[0]
|
||||
?? await gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!uri) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
try {
|
||||
projectPath = await execGit(cwd, ['rev-parse', '--show-toplevel']);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const uri = URI.file(projectPath);
|
||||
return { uri, displayName: basename(uri.fsPath) || uri.toString() };
|
||||
}
|
||||
|
||||
@@ -68,13 +39,13 @@ export function projectFromRepository(repository: string): IAgentSessionProjectI
|
||||
return { uri, displayName };
|
||||
}
|
||||
|
||||
export async function projectFromCopilotContext(context: ICopilotSessionContext | undefined): Promise<IAgentSessionProjectInfo | undefined> {
|
||||
export async function projectFromCopilotContext(context: ICopilotSessionContext | undefined, gitService: IAgentHostGitService): Promise<IAgentSessionProjectInfo | undefined> {
|
||||
const workingDirectory = typeof context?.cwd === 'string'
|
||||
? URI.file(context.cwd)
|
||||
: typeof context?.gitRoot === 'string'
|
||||
? URI.file(context.gitRoot)
|
||||
: undefined;
|
||||
const gitProject = await resolveGitProject(workingDirectory);
|
||||
const gitProject = await resolveGitProject(workingDirectory, gitService);
|
||||
if (gitProject) {
|
||||
return gitProject;
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ export class ProtocolServerHandler extends Disposable {
|
||||
workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined,
|
||||
session: URI.parse(params.session),
|
||||
fork,
|
||||
config: params.config,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ProtocolError) {
|
||||
@@ -392,6 +393,22 @@ export class ProtocolServerHandler extends Disposable {
|
||||
}));
|
||||
return { items };
|
||||
},
|
||||
resolveSessionConfig: async (_client, params) => {
|
||||
return this._agentService.resolveSessionConfig({
|
||||
provider: params.provider,
|
||||
workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined,
|
||||
config: params.config,
|
||||
});
|
||||
},
|
||||
sessionConfigCompletions: async (_client, params) => {
|
||||
return this._agentService.sessionConfigCompletions({
|
||||
provider: params.provider,
|
||||
workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined,
|
||||
config: params.config,
|
||||
property: params.property,
|
||||
query: params.query,
|
||||
});
|
||||
},
|
||||
fetchTurns: async (_client, params) => {
|
||||
const state = this._stateManager.getSessionState(params.session);
|
||||
if (!state) {
|
||||
|
||||
@@ -220,6 +220,15 @@ suite('AgentService (node dispatcher)', () => {
|
||||
assert.strictEqual(sessions.length, 1);
|
||||
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
|
||||
});
|
||||
|
||||
test('createSession stores live session config', async () => {
|
||||
service.registerProvider(copilotAgent);
|
||||
|
||||
const config = { target: 'worktree', branch: 'feature/config' };
|
||||
const session = await service.createSession({ provider: 'copilot', config });
|
||||
|
||||
assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- authenticate ---------------------------------------------------
|
||||
|
||||
@@ -4,95 +4,74 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import * as cp from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from '../../../../base/common/path.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { Promises } from '../../../../base/node/pfs.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { getRandomTestPath } from '../../../../base/test/node/testUtils.js';
|
||||
import type { IAgentHostGitService } from '../../node/agentHostGitService.js';
|
||||
import { projectFromCopilotContext, projectFromRepository, resolveGitProject } from '../../node/copilot/copilotGitProject.js';
|
||||
|
||||
function execGit(cwd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
cp.execFile('git', args, { cwd, encoding: 'utf8' }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(stderr || error.message));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
class TestAgentHostGitService implements IAgentHostGitService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
insideWorkTree = true;
|
||||
repositoryRoot: URI | undefined;
|
||||
worktreeRoots: URI[] = [];
|
||||
|
||||
async isInsideWorkTree(): Promise<boolean> { return this.insideWorkTree; }
|
||||
async getCurrentBranch(): Promise<string | undefined> { return undefined; }
|
||||
async getBranches(): Promise<string[]> { return []; }
|
||||
async getRepositoryRoot(): Promise<URI | undefined> { return this.repositoryRoot; }
|
||||
async getWorktreeRoots(): Promise<URI[]> { return this.worktreeRoots; }
|
||||
async addWorktree(): Promise<void> { }
|
||||
async removeWorktree(): Promise<void> { }
|
||||
}
|
||||
|
||||
suite('Copilot Git Project', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
let testDir: string;
|
||||
let gitService: TestAgentHostGitService;
|
||||
|
||||
setup(async () => {
|
||||
testDir = getRandomTestPath(tmpdir(), 'vsctests', 'copilot-git-project');
|
||||
await fs.promises.mkdir(testDir, { recursive: true });
|
||||
setup(() => {
|
||||
gitService = new TestAgentHostGitService();
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
await Promises.rm(testDir);
|
||||
});
|
||||
|
||||
async function createRepository(name: string): Promise<string> {
|
||||
const repositoryPath = join(testDir, name);
|
||||
await fs.promises.mkdir(repositoryPath, { recursive: true });
|
||||
await execGit(repositoryPath, ['init']);
|
||||
await execGit(repositoryPath, ['config', 'user.email', 'test@example.com']);
|
||||
await execGit(repositoryPath, ['config', 'user.name', 'Test User']);
|
||||
await fs.promises.writeFile(join(repositoryPath, 'README.md'), '# Test\n');
|
||||
await execGit(repositoryPath, ['add', 'README.md']);
|
||||
await execGit(repositoryPath, ['commit', '-m', 'initial']);
|
||||
return repositoryPath;
|
||||
}
|
||||
|
||||
test('resolves a repository project from a worktree working directory', async () => {
|
||||
const repositoryPath = await createRepository('source-repo');
|
||||
const canonicalRepositoryPath = await fs.promises.realpath(repositoryPath);
|
||||
const worktreePath = join(testDir, 'worktree-checkout');
|
||||
await execGit(repositoryPath, ['worktree', 'add', worktreePath]);
|
||||
gitService.worktreeRoots = [URI.file('/workspace/source-repo')];
|
||||
|
||||
const project = await resolveGitProject(URI.file(worktreePath));
|
||||
const project = await resolveGitProject(URI.file('/workspace/worktree-checkout'), gitService);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
uri: project?.uri.toString(),
|
||||
displayName: project?.displayName,
|
||||
}, {
|
||||
uri: URI.file(canonicalRepositoryPath).toString(),
|
||||
uri: URI.file('/workspace/source-repo').toString(),
|
||||
displayName: 'source-repo',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves the repository itself for a normal git working directory', async () => {
|
||||
const repositoryPath = await createRepository('normal-repo');
|
||||
const canonicalRepositoryPath = await fs.promises.realpath(repositoryPath);
|
||||
gitService.repositoryRoot = URI.file('/workspace/normal-repo');
|
||||
|
||||
const project = await resolveGitProject(URI.file(repositoryPath));
|
||||
const project = await resolveGitProject(URI.file('/workspace/normal-repo'), gitService);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
uri: project?.uri.toString(),
|
||||
displayName: project?.displayName,
|
||||
}, {
|
||||
uri: URI.file(canonicalRepositoryPath).toString(),
|
||||
uri: URI.file('/workspace/normal-repo').toString(),
|
||||
displayName: 'normal-repo',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns undefined outside a git working tree', async () => {
|
||||
const folder = join(testDir, 'plain-folder');
|
||||
await fs.promises.mkdir(folder);
|
||||
gitService.insideWorkTree = false;
|
||||
|
||||
assert.strictEqual(await resolveGitProject(URI.file(folder)), undefined);
|
||||
assert.strictEqual(await resolveGitProject(URI.file('/workspace/plain-folder'), gitService), undefined);
|
||||
});
|
||||
|
||||
test('falls back to repository context when no git project is available', async () => {
|
||||
const project = await projectFromCopilotContext({ repository: 'microsoft/vscode' });
|
||||
gitService.insideWorkTree = false;
|
||||
|
||||
const project = await projectFromCopilotContext({ repository: 'microsoft/vscode' }, gitService);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
uri: project?.uri.toString(),
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Emitter } from '../../../../base/common/event.js';
|
||||
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { type ISyncedCustomization } from '../../common/agentPluginManager.js';
|
||||
import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js';
|
||||
import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js';
|
||||
import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
|
||||
import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js';
|
||||
|
||||
/** Well-known auto-generated title used by the 'with-title' prompt. */
|
||||
@@ -78,6 +79,14 @@ export class MockAgent implements IAgent {
|
||||
return { session, project: mockProject(this.id) };
|
||||
}
|
||||
|
||||
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
return { ready: true, schema: { type: 'object', properties: {} }, values: params.config ?? {} };
|
||||
}
|
||||
|
||||
async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
async sendMessage(session: URI, prompt: string): Promise<void> {
|
||||
this.sendMessageCalls.push({ session, prompt });
|
||||
}
|
||||
@@ -200,7 +209,13 @@ export class ScriptedMockAgent implements IAgent {
|
||||
}
|
||||
|
||||
async listSessions(): Promise<IAgentSessionMetadata[]> {
|
||||
return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), project: mockProject(this.id), summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined }));
|
||||
return [...this._sessions.values()].map(s => ({
|
||||
session: s,
|
||||
startTime: Date.now(),
|
||||
modifiedTime: Date.now(),
|
||||
project: mockProject(this.id),
|
||||
summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async createSession(_config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
|
||||
@@ -210,6 +225,48 @@ export class ScriptedMockAgent implements IAgent {
|
||||
return { session, project: mockProject(this.id) };
|
||||
}
|
||||
|
||||
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
const target = params.config?.target === 'folder' || params.config?.target === 'worktree' ? params.config.target : 'worktree';
|
||||
const branch = target === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main';
|
||||
return {
|
||||
ready: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
title: 'Target',
|
||||
description: 'Where the mock agent should make changes',
|
||||
enum: ['folder', 'worktree'],
|
||||
enumLabels: ['Folder', 'Worktree'],
|
||||
default: 'worktree',
|
||||
},
|
||||
branch: {
|
||||
type: 'string',
|
||||
title: 'Branch',
|
||||
description: 'Base branch to work from',
|
||||
enum: target === 'folder' ? ['main'] : ['main', 'feature/config', 'release'],
|
||||
enumLabels: target === 'folder' ? ['main'] : ['main', 'feature/config', 'release'],
|
||||
enumIcons: target === 'folder' ? ['git-branch'] : ['git-branch', 'git-branch', 'git-branch'],
|
||||
default: 'main',
|
||||
enumDynamic: target === 'worktree',
|
||||
readOnly: target === 'folder',
|
||||
},
|
||||
},
|
||||
},
|
||||
values: { target, branch },
|
||||
};
|
||||
}
|
||||
|
||||
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
if (params.property !== 'branch') {
|
||||
return { items: [] };
|
||||
}
|
||||
const query = params.query?.toLowerCase() ?? '';
|
||||
const branches = ['main', 'feature/config', 'release'].filter(branch => branch.toLowerCase().includes(query));
|
||||
return { items: branches.map(branch => ({ value: branch, label: branch, icon: 'git-branch' })) };
|
||||
}
|
||||
|
||||
async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[]): Promise<void> {
|
||||
switch (prompt) {
|
||||
case 'hello':
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';
|
||||
import { IServerHandle, startServer, TestProtocolClient } from './testHelpers.js';
|
||||
|
||||
suite('Agent Host Server', function () {
|
||||
|
||||
let server: IServerHandle;
|
||||
|
||||
suiteSetup(async function () {
|
||||
this.timeout(15_000);
|
||||
server = await startServer({ quiet: false });
|
||||
});
|
||||
|
||||
suiteTeardown(function () {
|
||||
server.process.kill();
|
||||
});
|
||||
|
||||
test('starts with production agent services registered', async function () {
|
||||
this.timeout(10_000);
|
||||
|
||||
const client = new TestProtocolClient(server.port);
|
||||
try {
|
||||
await client.connect();
|
||||
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-agent-host-server-services' });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult, ISubscribeResult } from '../../../common/state/protocol/commands.js';
|
||||
import { ActionType, type ISessionAddedNotification } from '../../../common/state/sessionActions.js';
|
||||
import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';
|
||||
import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';
|
||||
import type { ISessionState } from '../../../common/state/sessionState.js';
|
||||
import {
|
||||
getActionEnvelope,
|
||||
isActionNotification,
|
||||
IServerHandle,
|
||||
nextSessionUri,
|
||||
startServer,
|
||||
TestProtocolClient,
|
||||
} from './testHelpers.js';
|
||||
|
||||
suite('Protocol WebSocket - Session Config', function () {
|
||||
|
||||
let server: IServerHandle;
|
||||
let client: TestProtocolClient;
|
||||
|
||||
suiteSetup(async function () {
|
||||
this.timeout(15_000);
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
suiteTeardown(function () {
|
||||
server.process.kill();
|
||||
});
|
||||
|
||||
setup(async function () {
|
||||
this.timeout(10_000);
|
||||
client = new TestProtocolClient(server.port);
|
||||
await client.connect();
|
||||
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-session-config' });
|
||||
});
|
||||
|
||||
teardown(function () {
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('resolveSessionConfig returns schema and re-resolves dependent read-only state', async function () {
|
||||
this.timeout(10_000);
|
||||
|
||||
const workingDirectory = URI.file('/mock/workspace').toString();
|
||||
const initial = await client.call<IResolveSessionConfigResult>('resolveSessionConfig', {
|
||||
provider: 'mock',
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({ ready: initial.ready, values: initial.values }, {
|
||||
ready: true,
|
||||
values: { target: 'worktree', branch: 'main' },
|
||||
});
|
||||
assert.deepStrictEqual(Object.keys(initial.schema.properties), ['target', 'branch']);
|
||||
assert.strictEqual(initial.schema.properties.branch.enumDynamic, true);
|
||||
assert.strictEqual(initial.schema.properties.branch.readOnly, false);
|
||||
|
||||
const folder = await client.call<IResolveSessionConfigResult>('resolveSessionConfig', {
|
||||
provider: 'mock',
|
||||
workingDirectory,
|
||||
config: { target: 'folder', branch: 'feature/config' },
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({ ready: folder.ready, values: folder.values }, {
|
||||
ready: true,
|
||||
values: { target: 'folder', branch: 'main' },
|
||||
});
|
||||
assert.strictEqual(folder.schema.properties.branch.enumDynamic, false);
|
||||
assert.strictEqual(folder.schema.properties.branch.readOnly, true);
|
||||
});
|
||||
|
||||
test('sessionConfigCompletions returns dynamic branch matches', async function () {
|
||||
this.timeout(10_000);
|
||||
|
||||
const result = await client.call<ISessionConfigCompletionsResult>('sessionConfigCompletions', {
|
||||
provider: 'mock',
|
||||
workingDirectory: URI.file('/mock/workspace').toString(),
|
||||
config: { target: 'worktree' },
|
||||
property: 'branch',
|
||||
query: 'feat',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
items: [{ value: 'feature/config', label: 'feature/config', icon: 'git-branch' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('createSession stores config schema and values on session state', async function () {
|
||||
this.timeout(10_000);
|
||||
|
||||
const config = { target: 'worktree', branch: 'feature/config' };
|
||||
await client.call('createSession', {
|
||||
session: nextSessionUri(),
|
||||
provider: 'mock',
|
||||
workingDirectory: URI.file('/mock/workspace').toString(),
|
||||
config,
|
||||
});
|
||||
|
||||
const notif = await client.waitForNotification(n =>
|
||||
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
|
||||
);
|
||||
const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification;
|
||||
assert.strictEqual(Object.hasOwn(notification.summary, 'config'), false);
|
||||
|
||||
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: notification.summary.resource });
|
||||
const state = snapshot.snapshot.state as ISessionState;
|
||||
assert.deepStrictEqual(state.config?.values, config);
|
||||
assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}), ['target', 'branch']);
|
||||
});
|
||||
|
||||
test('session/configChanged merges config values into session state', async function () {
|
||||
this.timeout(10_000);
|
||||
|
||||
await client.call('createSession', {
|
||||
session: nextSessionUri(),
|
||||
provider: 'mock',
|
||||
config: { target: 'folder', branch: 'main' },
|
||||
});
|
||||
|
||||
const notif = await client.waitForNotification(n =>
|
||||
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
|
||||
);
|
||||
const session = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource;
|
||||
await client.call<ISubscribeResult>('subscribe', { resource: session });
|
||||
client.clearReceived();
|
||||
|
||||
client.notify('dispatchAction', {
|
||||
clientSeq: 1,
|
||||
action: {
|
||||
type: ActionType.SessionConfigChanged,
|
||||
session,
|
||||
config: { branch: 'release' },
|
||||
},
|
||||
});
|
||||
|
||||
const configChanged = await client.waitForNotification(n => isActionNotification(n, ActionType.SessionConfigChanged));
|
||||
assert.strictEqual(getActionEnvelope(configChanged).action.type, ActionType.SessionConfigChanged);
|
||||
|
||||
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: session });
|
||||
const state = snapshot.snapshot.state as ISessionState;
|
||||
assert.deepStrictEqual(state.config?.values, { target: 'folder', branch: 'release' });
|
||||
});
|
||||
});
|
||||
@@ -176,10 +176,14 @@ export interface IServerHandle {
|
||||
port: number;
|
||||
}
|
||||
|
||||
export async function startServer(): Promise<IServerHandle> {
|
||||
export async function startServer(options?: { readonly quiet?: boolean }): Promise<IServerHandle> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url));
|
||||
const child = fork(serverPath, ['--enable-mock-agent', '--quiet', '--port', '0', '--without-connection-token'], {
|
||||
const args = ['--enable-mock-agent', '--port', '0', '--without-connection-token'];
|
||||
if (options?.quiet ?? true) {
|
||||
args.push('--quiet');
|
||||
}
|
||||
const child = fork(serverPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import type { IAgentCreateSessionConfig, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js';
|
||||
import { IListSessionsResult, IResourceReadResult } from '../../common/state/protocol/commands.js';
|
||||
import type { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js';
|
||||
import { IListSessionsResult, IResourceReadResult, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js';
|
||||
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
|
||||
import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IResourceListResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js';
|
||||
@@ -104,6 +104,9 @@ class MockAgentService implements IAgentService {
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> { return { ready: true, schema: { type: 'object', properties: {} }, values: {} }; }
|
||||
async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> { return { items: [] }; }
|
||||
async disposeSession(_session: URI): Promise<void> { }
|
||||
async listSessions(): Promise<IAgentSessionMetadata[]> { return this.listedSessions; }
|
||||
async subscribe(resource: URI): Promise<IStateSnapshot> {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/agentHostSessionConfigPicker.css';
|
||||
import * as dom from '../../../../base/browser/dom.js';
|
||||
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
|
||||
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
|
||||
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
|
||||
import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
|
||||
import { Delayer } from '../../../../base/common/async.js';
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { autorun } from '../../../../base/common/observable.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { localize, localize2 } from '../../../../nls.js';
|
||||
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
|
||||
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import type { ISessionConfigPropertySchema, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import { IQuickInputService, type IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
|
||||
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
|
||||
import { Menus } from '../../../browser/menus.js';
|
||||
import { ActiveSessionProviderIdContext } from '../../../common/contextkeys.js';
|
||||
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
|
||||
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
|
||||
import type { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
|
||||
|
||||
const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/);
|
||||
const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host');
|
||||
|
||||
registerAction2(class extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'sessions.agentHost.sessionConfigPicker',
|
||||
title: localize2('agentHostSessionConfigPicker', "Session Configuration"),
|
||||
f1: false,
|
||||
menu: [{
|
||||
id: Menus.NewSessionRepositoryConfig,
|
||||
group: 'navigation',
|
||||
order: 3,
|
||||
when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost),
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
override async run(): Promise<void> { }
|
||||
});
|
||||
|
||||
interface IConfigPickerItem {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables: DisposableStore, onOpen: () => void): HTMLElement {
|
||||
const trigger = dom.append(slot, disabled ? dom.$('span.action-label') : dom.$('a.action-label'));
|
||||
if (disabled) {
|
||||
trigger.setAttribute('aria-readonly', 'true');
|
||||
} else {
|
||||
trigger.role = 'button';
|
||||
trigger.tabIndex = 0;
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
disposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, e => {
|
||||
dom.EventHelper.stop(e, true);
|
||||
onOpen();
|
||||
}));
|
||||
disposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
dom.EventHelper.stop(e, true);
|
||||
onOpen();
|
||||
}
|
||||
}));
|
||||
}
|
||||
slot.classList.toggle('disabled', disabled);
|
||||
|
||||
return trigger;
|
||||
}
|
||||
|
||||
class AgentHostSessionConfigPicker extends Disposable {
|
||||
|
||||
private readonly _renderDisposables = this._register(new DisposableStore());
|
||||
private readonly _providerListeners = this._register(new DisposableMap<string>());
|
||||
private _container: HTMLElement | undefined;
|
||||
|
||||
constructor(
|
||||
@IActionWidgetService private readonly _actionWidgetService: IActionWidgetService,
|
||||
@IQuickInputService private readonly _quickInputService: IQuickInputService,
|
||||
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
|
||||
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(autorun(reader => {
|
||||
const session = this._sessionsManagementService.activeSession.read(reader);
|
||||
if (session) {
|
||||
session.loading.read(reader);
|
||||
}
|
||||
this._renderConfigPickers();
|
||||
}));
|
||||
|
||||
this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
|
||||
for (const provider of e.removed) {
|
||||
this._providerListeners.deleteAndDispose(provider.id);
|
||||
}
|
||||
this._watchProviders(e.added);
|
||||
this._renderConfigPickers();
|
||||
}));
|
||||
this._watchProviders(this._sessionsProvidersService.getProviders());
|
||||
}
|
||||
|
||||
private _watchProviders(providers: readonly ISessionsProvider[]): void {
|
||||
for (const provider of providers) {
|
||||
if (!provider.onDidChangeSessionConfig || this._providerListeners.has(provider.id)) {
|
||||
continue;
|
||||
}
|
||||
this._providerListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._renderConfigPickers()));
|
||||
}
|
||||
}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this._container = dom.append(container, dom.$('.sessions-chat-agent-host-config'));
|
||||
this._renderConfigPickers();
|
||||
}
|
||||
|
||||
private _renderConfigPickers(): void {
|
||||
if (!this._container) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderDisposables.clear();
|
||||
dom.clearNode(this._container);
|
||||
|
||||
const session = this._sessionsManagementService.activeSession.get();
|
||||
const provider = session ? this._getProvider(session.providerId) : undefined;
|
||||
const resolvedConfig = session && provider?.getSessionConfig?.(session.sessionId);
|
||||
if (!session || !provider || !resolvedConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) {
|
||||
const value = resolvedConfig.values[property] ?? schema.default;
|
||||
const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot'));
|
||||
const trigger = renderPickerTrigger(slot, !!schema.readOnly, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger));
|
||||
this._renderTrigger(trigger, schema, value);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTrigger(trigger: HTMLElement, schema: ISessionConfigPropertySchema, value: string | undefined): void {
|
||||
dom.clearNode(trigger);
|
||||
const icon = this._getIcon(schema, value);
|
||||
if (icon) {
|
||||
dom.append(trigger, renderIcon(ThemeIcon.fromId(icon)));
|
||||
}
|
||||
const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label'));
|
||||
const label = this._getLabel(schema, value);
|
||||
labelSpan.textContent = label;
|
||||
trigger.setAttribute('aria-label', schema.readOnly
|
||||
? localize('agentHostSessionConfig.triggerAriaReadOnly', "{0}: {1}, Read-Only", schema.title, label)
|
||||
: localize('agentHostSessionConfig.triggerAria', "{0}: {1}", schema.title, label));
|
||||
if (!schema.readOnly) {
|
||||
dom.append(trigger, renderIcon(Codicon.chevronDown));
|
||||
}
|
||||
}
|
||||
|
||||
private async _showPicker(provider: ISessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): Promise<void> {
|
||||
if (schema.readOnly || this._actionWidgetService.isVisible) {
|
||||
return;
|
||||
}
|
||||
if (schema.enumDynamic) {
|
||||
this._showDynamicPicker(provider, sessionId, property, schema, trigger);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await this._getItems(provider, sessionId, property, schema);
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = provider.getSessionConfig?.(sessionId)?.values[property];
|
||||
const actionItems: IActionListItem<IConfigPickerItem>[] = items.map(item => ({
|
||||
kind: ActionListItemKind.Action,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
group: { title: '', icon: item.icon ? ThemeIcon.fromId(item.icon) : undefined },
|
||||
item: { ...item, label: item.value === currentValue ? `${item.label} ${localize('selected', "(Selected)")}` : item.label },
|
||||
}));
|
||||
|
||||
const delegate: IActionListDelegate<IConfigPickerItem> = {
|
||||
onSelect: item => {
|
||||
this._actionWidgetService.hide();
|
||||
provider.setSessionConfigValue?.(sessionId, property, item.value).catch(() => { /* best-effort */ });
|
||||
},
|
||||
onHide: () => trigger.focus(),
|
||||
};
|
||||
|
||||
this._actionWidgetService.show<IConfigPickerItem>(
|
||||
`agentHostSessionConfig.${property}`,
|
||||
false,
|
||||
actionItems,
|
||||
delegate,
|
||||
trigger,
|
||||
undefined,
|
||||
[],
|
||||
{
|
||||
getAriaLabel: item => item.label ?? '',
|
||||
getWidgetAriaLabel: () => localize('agentHostSessionConfig.ariaLabel', "{0} Picker", schema.title),
|
||||
},
|
||||
actionItems.length > 10 ? { showFilter: true, filterPlaceholder: localize('agentHostSessionConfig.filter', "Filter options...") } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private _showDynamicPicker(provider: ISessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema, trigger: HTMLElement): void {
|
||||
if (!provider.getSessionConfigCompletions) {
|
||||
return;
|
||||
}
|
||||
|
||||
interface IDynamicQuickPickItem extends IQuickPickItem {
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
const quickPick = this._quickInputService.createQuickPick<IDynamicQuickPickItem>();
|
||||
quickPick.placeholder = schema.description ?? localize('agentHostSessionConfig.dynamicPlaceholder', "Search options...");
|
||||
quickPick.ariaLabel = localize('agentHostSessionConfig.dynamicAriaLabel', "{0} Picker", schema.title);
|
||||
quickPick.busy = true;
|
||||
let request = 0;
|
||||
const delayer = new Delayer<void>(200);
|
||||
|
||||
const updateItems = async (query?: string) => {
|
||||
const requestId = ++request;
|
||||
quickPick.busy = true;
|
||||
try {
|
||||
const items = await provider.getSessionConfigCompletions!(sessionId, property, query);
|
||||
if (requestId !== request) {
|
||||
return;
|
||||
}
|
||||
quickPick.items = items.map(item => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
iconClass: item.icon ? ThemeIcon.asClassName(ThemeIcon.fromId(item.icon)) : undefined,
|
||||
}));
|
||||
} finally {
|
||||
if (requestId === request) {
|
||||
quickPick.busy = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const disposables = new DisposableStore();
|
||||
disposables.add(delayer);
|
||||
disposables.add(quickPick.onDidChangeValue(value => {
|
||||
quickPick.busy = true;
|
||||
delayer.trigger(() => updateItems(value)).catch(() => { /* best-effort */ });
|
||||
}));
|
||||
disposables.add(quickPick.onDidAccept(() => {
|
||||
const item = quickPick.selectedItems[0] ?? quickPick.activeItems[0];
|
||||
if (item) {
|
||||
provider.setSessionConfigValue?.(sessionId, property, item.value).catch(() => { /* best-effort */ });
|
||||
}
|
||||
quickPick.hide();
|
||||
}));
|
||||
disposables.add(quickPick.onDidHide(() => {
|
||||
disposables.dispose();
|
||||
quickPick.dispose();
|
||||
trigger.focus();
|
||||
}));
|
||||
|
||||
updateItems().catch(() => { quickPick.busy = false; });
|
||||
quickPick.show();
|
||||
}
|
||||
|
||||
private async _getItems(provider: ISessionsProvider, sessionId: string, property: string, schema: ISessionConfigPropertySchema): Promise<readonly IConfigPickerItem[]> {
|
||||
const dynamicItems = schema.enumDynamic && provider.getSessionConfigCompletions
|
||||
? await provider.getSessionConfigCompletions(sessionId, property)
|
||||
: undefined;
|
||||
if (dynamicItems?.length) {
|
||||
return dynamicItems.map(item => this._fromCompletionItem(item));
|
||||
}
|
||||
|
||||
return (schema.enum ?? []).map((value, index) => ({
|
||||
value,
|
||||
label: schema.enumLabels?.[index] ?? value,
|
||||
description: schema.enumDescriptions?.[index],
|
||||
icon: schema.enumIcons?.[index],
|
||||
}));
|
||||
}
|
||||
|
||||
private _fromCompletionItem(item: ISessionConfigValueItem): IConfigPickerItem {
|
||||
return {
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
};
|
||||
}
|
||||
|
||||
private _getLabel(schema: ISessionConfigPropertySchema, value: string | undefined): string {
|
||||
if (typeof value === 'string') {
|
||||
const index = schema.enum?.indexOf(value) ?? -1;
|
||||
return index >= 0 ? schema.enumLabels?.[index] ?? value : value;
|
||||
}
|
||||
return schema.title;
|
||||
}
|
||||
|
||||
private _getIcon(schema: ISessionConfigPropertySchema, value: string | undefined): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const index = schema.enum?.indexOf(value) ?? -1;
|
||||
return index >= 0 ? schema.enumIcons?.[index] : undefined;
|
||||
}
|
||||
|
||||
private _getProvider(providerId: string): ISessionsProvider | undefined {
|
||||
return this._sessionsProvidersService.getProvider(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
class PickerActionViewItem extends BaseActionViewItem {
|
||||
constructor(private readonly _picker: AgentHostSessionConfigPicker) {
|
||||
super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } });
|
||||
}
|
||||
|
||||
override render(container: HTMLElement): void {
|
||||
this._picker.render(container);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._picker.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class AgentHostSessionConfigPickerContribution extends Disposable implements IWorkbenchContribution {
|
||||
static readonly ID = 'sessions.contrib.agentHostSessionConfigPicker';
|
||||
|
||||
constructor(
|
||||
@IActionViewItemService actionViewItemService: IActionViewItemService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
) {
|
||||
super();
|
||||
this._register(actionViewItemService.register(
|
||||
Menus.NewSessionRepositoryConfig,
|
||||
'sessions.agentHost.sessionConfigPicker',
|
||||
() => new PickerActionViewItem(instantiationService.createInstance(AgentHostSessionConfigPicker)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkbenchContribution2(AgentHostSessionConfigPickerContribution.ID, AgentHostSessionConfigPickerContribution, WorkbenchPhase.AfterRestored);
|
||||
@@ -0,0 +1,15 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.sessions-chat-agent-host-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sessions-chat-agent-host-config:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import * as dom from '../../../../base/browser/dom.js';
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
|
||||
import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { Disposable, DisposableMap, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { autorun } from '../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { Button } from '../../../../base/browser/ui/button/button.js';
|
||||
@@ -39,6 +39,7 @@ import { localize } from '../../../../nls.js';
|
||||
import * as aria from '../../../../base/browser/ui/aria/aria.js';
|
||||
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
|
||||
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
|
||||
import type { ISession } from '../../../services/sessions/common/session.js';
|
||||
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
|
||||
import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js';
|
||||
import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js';
|
||||
@@ -81,6 +82,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
|
||||
|
||||
private readonly _workspacePicker: WorkspacePicker;
|
||||
private readonly _sessionTypePicker: SessionTypePicker;
|
||||
private readonly _sessionConfigListeners = this._register(new DisposableMap<string>());
|
||||
|
||||
// IHistoryNavigationWidget
|
||||
private readonly _onDidFocus = this._register(new Emitter<void>());
|
||||
@@ -157,6 +159,14 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
|
||||
this._loadingSpinner?.classList.toggle('visible', isLoading);
|
||||
this._updateSendButtonState();
|
||||
}));
|
||||
this._register(this.sessionsProvidersService.onDidChangeProviders(e => {
|
||||
for (const provider of e.removed) {
|
||||
this._sessionConfigListeners.deleteAndDispose(provider.id);
|
||||
}
|
||||
this._watchSessionConfigProviders();
|
||||
this._updateSendButtonState();
|
||||
}));
|
||||
this._watchSessionConfigProviders();
|
||||
this._register(this._contextAttachments.onDidChangeContext(() => {
|
||||
this._updateDraftState();
|
||||
this._focusEditor();
|
||||
@@ -550,7 +560,25 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
|
||||
const session = this.sessionsManagementService.activeSession.get();
|
||||
const hasActiveSession = !!session;
|
||||
const isLoading = session?.loading.get() ?? false;
|
||||
this._sendButton.enabled = !this._sending && hasText && hasActiveSession && !isLoading;
|
||||
const isConfigReady = session ? this._isSessionConfigReady(session) : false;
|
||||
this._sendButton.enabled = !this._sending && hasText && hasActiveSession && !isLoading && isConfigReady;
|
||||
}
|
||||
|
||||
private _watchSessionConfigProviders(): void {
|
||||
for (const provider of this.sessionsProvidersService.getProviders()) {
|
||||
if (!provider.onDidChangeSessionConfig || this._sessionConfigListeners.has(provider.id)) {
|
||||
continue;
|
||||
}
|
||||
this._sessionConfigListeners.set(provider.id, provider.onDidChangeSessionConfig(() => this._updateSendButtonState()));
|
||||
}
|
||||
}
|
||||
|
||||
private _isSessionConfigReady(session: ISession): boolean {
|
||||
const provider = this.sessionsProvidersService.getProvider(session.providerId);
|
||||
if (!provider?.getSessionConfig) {
|
||||
return true;
|
||||
}
|
||||
return provider.getSessionConfig(session.sessionId)?.ready ?? true;
|
||||
}
|
||||
|
||||
private async _send(): Promise<void> {
|
||||
@@ -565,6 +593,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSession = this.sessionsManagementService.activeSession.get();
|
||||
if (!activeSession || !this._isSessionConfigReady(activeSession)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for slash commands first
|
||||
if (this._slashCommandHandler?.tryExecuteSlashCommand(query)) {
|
||||
this._editor.getModel()?.setValue('');
|
||||
|
||||
@@ -17,6 +17,7 @@ import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';
|
||||
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
|
||||
import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
|
||||
@@ -198,6 +199,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
|
||||
private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>());
|
||||
readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;
|
||||
private readonly _onDidChangeSessionConfig = this._register(new Emitter<string>());
|
||||
readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event;
|
||||
|
||||
/** Cache of adapted sessions, keyed by raw session ID. */
|
||||
private readonly _sessionCache = new Map<string, LocalSessionAdapter>();
|
||||
@@ -206,6 +209,9 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
private _selectedModelId: string | undefined;
|
||||
private _currentNewSession: ISession | undefined;
|
||||
private _currentNewSessionStatus: ISettableObservable<SessionStatus> | undefined;
|
||||
private readonly _newSessionWorkspaces = new Map<string, URI>();
|
||||
private readonly _newSessionConfigs = new Map<string, IResolveSessionConfigResult>();
|
||||
private readonly _newSessionConfigRequests = new Map<string, number>();
|
||||
|
||||
private _cacheInitialized = false;
|
||||
|
||||
@@ -291,6 +297,9 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
throw new Error('Workspace has no repository URI');
|
||||
}
|
||||
|
||||
if (this._currentNewSession) {
|
||||
this._clearNewSessionConfig(this._currentNewSession.sessionId);
|
||||
}
|
||||
this._currentNewSession = undefined;
|
||||
this._selectedModelId = undefined;
|
||||
|
||||
@@ -337,9 +346,49 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
};
|
||||
this._currentNewSession = session;
|
||||
this._currentNewSessionStatus = status;
|
||||
this._newSessionWorkspaces.set(session.sessionId, workspaceUri);
|
||||
this._resolveSessionConfig(session.sessionId, workspaceUri, undefined);
|
||||
return session;
|
||||
}
|
||||
|
||||
getSessionConfig(sessionId: string): IResolveSessionConfigResult | undefined {
|
||||
return this._newSessionConfigs.get(sessionId);
|
||||
}
|
||||
|
||||
async setSessionConfigValue(sessionId: string, property: string, value: string): Promise<void> {
|
||||
const workingDirectory = this._newSessionWorkspaces.get(sessionId);
|
||||
if (!workingDirectory) {
|
||||
return;
|
||||
}
|
||||
const current = this._newSessionConfigs.get(sessionId)?.values ?? {};
|
||||
this._newSessionConfigs.set(sessionId, { ready: false, schema: { type: 'object', properties: {} }, values: { ...current, [property]: value } });
|
||||
this._onDidChangeSessionConfig.fire(sessionId);
|
||||
await this._resolveSessionConfig(sessionId, workingDirectory, { ...current, [property]: value });
|
||||
}
|
||||
|
||||
async getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise<readonly ISessionConfigValueItem[]> {
|
||||
const workingDirectory = this._newSessionWorkspaces.get(sessionId);
|
||||
if (!workingDirectory) {
|
||||
return [];
|
||||
}
|
||||
const result = await this._agentHostService.sessionConfigCompletions({
|
||||
provider: DEFAULT_AGENT_PROVIDER,
|
||||
workingDirectory,
|
||||
config: this._newSessionConfigs.get(sessionId)?.values,
|
||||
property,
|
||||
query,
|
||||
});
|
||||
return result.items;
|
||||
}
|
||||
|
||||
getCreateSessionConfig(sessionId: string): Record<string, string> | undefined {
|
||||
return this._newSessionConfigs.get(sessionId)?.values;
|
||||
}
|
||||
|
||||
clearSessionConfig(sessionId: string): void {
|
||||
this._clearNewSessionConfig(sessionId);
|
||||
}
|
||||
|
||||
setSessionType(_sessionId: string, _type: ISessionType): ISession {
|
||||
throw new Error('Local agent host sessions do not support changing session type');
|
||||
}
|
||||
@@ -404,6 +453,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
|
||||
if (cached && rawId) {
|
||||
cached.isRead.set(read, undefined);
|
||||
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
|
||||
const action = { type: ActionType.SessionIsReadChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isRead: read };
|
||||
this._agentHostService.dispatch(action);
|
||||
}
|
||||
@@ -433,6 +483,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
},
|
||||
agentIdSilent: contribution?.type,
|
||||
attachedContext,
|
||||
agentHostSessionConfig: this.getCreateSessionConfig(chatId),
|
||||
};
|
||||
|
||||
// Open chat widget — getOrCreateChatSession will wait for the session
|
||||
@@ -475,6 +526,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
const committedSession = await this._waitForNewSession(existingKeys);
|
||||
if (committedSession) {
|
||||
this._currentNewSession = undefined;
|
||||
this._clearNewSessionConfig(chatId);
|
||||
this._onDidReplaceSession.fire({ from: newSession, to: committedSession });
|
||||
return committedSession;
|
||||
}
|
||||
@@ -485,9 +537,58 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
|
||||
}
|
||||
|
||||
this._currentNewSession = undefined;
|
||||
this._clearNewSessionConfig(chatId);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
private async _resolveSessionConfig(sessionId: string, workingDirectory: URI, config: Record<string, string> | undefined): Promise<void> {
|
||||
const request = (this._newSessionConfigRequests.get(sessionId) ?? 0) + 1;
|
||||
this._newSessionConfigRequests.set(sessionId, request);
|
||||
try {
|
||||
const result = await this._agentHostService.resolveSessionConfig({
|
||||
provider: DEFAULT_AGENT_PROVIDER,
|
||||
workingDirectory,
|
||||
config,
|
||||
});
|
||||
if (this._newSessionConfigRequests.get(sessionId) !== request) {
|
||||
return;
|
||||
}
|
||||
this._newSessionConfigs.set(sessionId, result);
|
||||
} catch {
|
||||
if (this._newSessionConfigRequests.get(sessionId) !== request) {
|
||||
return;
|
||||
}
|
||||
this._newSessionConfigs.set(sessionId, this._createSessionConfigUnavailableResult());
|
||||
}
|
||||
this._onDidChangeSessionConfig.fire(sessionId);
|
||||
}
|
||||
|
||||
private _createSessionConfigUnavailableResult(): IResolveSessionConfigResult {
|
||||
return {
|
||||
ready: false,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
unavailable: {
|
||||
type: 'string',
|
||||
title: localize('sessionConfigUnavailable', "Configuration"),
|
||||
description: localize('sessionConfigUnavailableDescription', "Session configuration is unavailable. Select the workspace again to retry."),
|
||||
enum: ['unavailable'],
|
||||
enumLabels: [localize('sessionConfigUnavailableValue', "Unavailable")],
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
values: { unavailable: 'unavailable' },
|
||||
};
|
||||
}
|
||||
|
||||
private _clearNewSessionConfig(sessionId: string): void {
|
||||
this._newSessionWorkspaces.delete(sessionId);
|
||||
this._newSessionConfigs.delete(sessionId);
|
||||
this._newSessionConfigRequests.delete(sessionId);
|
||||
}
|
||||
|
||||
// -- Private: Session Cache --
|
||||
|
||||
private _ensureSessionCache(): void {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../
|
||||
import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';
|
||||
import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
|
||||
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { INotificationService } from '../../../../platform/notification/common/notification.js';
|
||||
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
|
||||
@@ -32,6 +33,8 @@ import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '..
|
||||
import { ISessionChangeEvent, ISendRequestOptions, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
|
||||
import { ISession, IChat, IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, CopilotCLISessionType, ISessionType } from '../../../services/sessions/common/session.js';
|
||||
|
||||
const DEFAULT_AGENT_PROVIDER = 'copilot';
|
||||
|
||||
function toLocalProjectUri(uri: URI, connectionAuthority: string): URI {
|
||||
return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri;
|
||||
}
|
||||
@@ -123,7 +126,7 @@ class RemoteSessionAdapter implements IChatData {
|
||||
private readonly _providerLabel: string,
|
||||
) {
|
||||
const rawId = AgentSession.id(metadata.session);
|
||||
this.agentProvider = AgentSession.provider(metadata.session) ?? 'copilot';
|
||||
this.agentProvider = AgentSession.provider(metadata.session) ?? DEFAULT_AGENT_PROVIDER;
|
||||
this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` });
|
||||
this.id = `${providerId}:${this.resource.toString()}`;
|
||||
this.providerId = providerId;
|
||||
@@ -197,6 +200,8 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
|
||||
private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>());
|
||||
readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;
|
||||
private readonly _onDidChangeSessionConfig = this._register(new Emitter<string>());
|
||||
readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event;
|
||||
|
||||
readonly browseActions: readonly ISessionWorkspaceBrowseAction[];
|
||||
|
||||
@@ -215,6 +220,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
private _selectedModelId: string | undefined;
|
||||
/** Settable status for the current new session, kept to avoid unsafe cast from IObservable. */
|
||||
private _currentNewSessionStatus: ISettableObservable<SessionStatus> | undefined;
|
||||
private readonly _newSessionWorkspaces = new Map<string, URI>();
|
||||
private readonly _newSessionConfigs = new Map<string, IResolveSessionConfigResult>();
|
||||
private readonly _newSessionAgentProviders = new Map<string, string>();
|
||||
private readonly _newSessionConfigRequests = new Map<string, number>();
|
||||
|
||||
private _connection: IAgentConnection | undefined;
|
||||
private _defaultDirectory: string | undefined;
|
||||
@@ -385,10 +394,14 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
}
|
||||
|
||||
// Reset draft state from any prior unsent session
|
||||
if (this._currentNewSession) {
|
||||
this._clearNewSessionConfig(this._currentNewSession.id);
|
||||
}
|
||||
this._currentNewSession = undefined;
|
||||
this._selectedModelId = undefined;
|
||||
|
||||
const resource = URI.from({ scheme: this._sessionTypeForProvider('copilot'), path: `/untitled-${generateUuid()}` });
|
||||
const agentProvider = this._getDefaultAgentProvider();
|
||||
const resource = URI.from({ scheme: this._sessionTypeForProvider(agentProvider), path: `/untitled-${generateUuid()}` });
|
||||
const status = observableValue<SessionStatus>(this, SessionStatus.Untitled);
|
||||
const session: IChatData = {
|
||||
id: `${this.id}:${resource.toString()}`,
|
||||
@@ -413,9 +426,50 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
};
|
||||
this._currentNewSession = session;
|
||||
this._currentNewSessionStatus = status;
|
||||
this._newSessionWorkspaces.set(session.id, workspaceUri);
|
||||
this._newSessionAgentProviders.set(session.id, agentProvider);
|
||||
this._resolveSessionConfig(session.id, agentProvider, workspaceUri, undefined);
|
||||
return this._chatToSession(session);
|
||||
}
|
||||
|
||||
getSessionConfig(sessionId: string): IResolveSessionConfigResult | undefined {
|
||||
return this._newSessionConfigs.get(sessionId);
|
||||
}
|
||||
|
||||
async setSessionConfigValue(sessionId: string, property: string, value: string): Promise<void> {
|
||||
const workingDirectory = this._newSessionWorkspaces.get(sessionId);
|
||||
if (!workingDirectory) {
|
||||
return;
|
||||
}
|
||||
const current = this._newSessionConfigs.get(sessionId)?.values ?? {};
|
||||
this._newSessionConfigs.set(sessionId, { ready: false, schema: { type: 'object', properties: {} }, values: { ...current, [property]: value } });
|
||||
this._onDidChangeSessionConfig.fire(sessionId);
|
||||
await this._resolveSessionConfig(sessionId, this._getAgentProviderForSession(sessionId), workingDirectory, { ...current, [property]: value });
|
||||
}
|
||||
|
||||
async getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise<readonly ISessionConfigValueItem[]> {
|
||||
const workingDirectory = this._newSessionWorkspaces.get(sessionId);
|
||||
if (!workingDirectory || !this._connection) {
|
||||
return [];
|
||||
}
|
||||
const result = await this._connection.sessionConfigCompletions({
|
||||
provider: this._getAgentProviderForSession(sessionId),
|
||||
workingDirectory,
|
||||
config: this._newSessionConfigs.get(sessionId)?.values,
|
||||
property,
|
||||
query,
|
||||
});
|
||||
return result.items;
|
||||
}
|
||||
|
||||
getCreateSessionConfig(sessionId: string): Record<string, string> | undefined {
|
||||
return this._newSessionConfigs.get(sessionId)?.values;
|
||||
}
|
||||
|
||||
clearSessionConfig(sessionId: string): void {
|
||||
this._clearNewSessionConfig(sessionId);
|
||||
}
|
||||
|
||||
setSessionType(_sessionId: string, _type: ISessionType): ISession {
|
||||
throw new Error('Remote agent host sessions do not support changing session type');
|
||||
}
|
||||
@@ -503,7 +557,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
|
||||
const { query, attachedContext } = options;
|
||||
|
||||
const contribution = this._chatSessionsService.getChatSessionContribution(this._sessionTypeForProvider('copilot'));
|
||||
const contribution = this._chatSessionsService.getChatSessionContribution(this._sessionTypeForProvider(this._getAgentProviderForSession(chatId)));
|
||||
|
||||
const sendOptions: IChatSendRequestOptions = {
|
||||
location: ChatAgentLocation.Chat,
|
||||
@@ -518,6 +572,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
},
|
||||
agentIdSilent: contribution?.type,
|
||||
attachedContext,
|
||||
agentHostSessionConfig: this.getCreateSessionConfig(chatId),
|
||||
};
|
||||
|
||||
// Open chat widget
|
||||
@@ -569,6 +624,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
const committedSession = await this._waitForNewSession(existingKeys);
|
||||
if (committedSession) {
|
||||
this._currentNewSession = undefined;
|
||||
this._clearNewSessionConfig(chatId);
|
||||
this._onDidReplaceSession.fire({ from: newSession, to: committedSession });
|
||||
return committedSession;
|
||||
}
|
||||
@@ -580,9 +636,62 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
|
||||
// Fallback: keep the temp session visible
|
||||
this._currentNewSession = undefined;
|
||||
this._clearNewSessionConfig(chatId);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
private async _resolveSessionConfig(sessionId: string, agentProvider: string, workingDirectory: URI, config: Record<string, string> | undefined): Promise<void> {
|
||||
if (!this._connection) {
|
||||
return;
|
||||
}
|
||||
const request = (this._newSessionConfigRequests.get(sessionId) ?? 0) + 1;
|
||||
this._newSessionConfigRequests.set(sessionId, request);
|
||||
try {
|
||||
const result = await this._connection.resolveSessionConfig({
|
||||
provider: agentProvider,
|
||||
workingDirectory,
|
||||
config,
|
||||
});
|
||||
if (this._newSessionConfigRequests.get(sessionId) !== request) {
|
||||
return;
|
||||
}
|
||||
this._newSessionConfigs.set(sessionId, result);
|
||||
} catch {
|
||||
if (this._newSessionConfigRequests.get(sessionId) !== request) {
|
||||
return;
|
||||
}
|
||||
this._newSessionConfigs.set(sessionId, this._createSessionConfigUnavailableResult());
|
||||
}
|
||||
this._onDidChangeSessionConfig.fire(sessionId);
|
||||
}
|
||||
|
||||
private _createSessionConfigUnavailableResult(): IResolveSessionConfigResult {
|
||||
return {
|
||||
ready: false,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
unavailable: {
|
||||
type: 'string',
|
||||
title: localize('sessionConfigUnavailable', "Configuration"),
|
||||
description: localize('sessionConfigUnavailableDescription', "Session configuration is unavailable. Select the workspace again to retry."),
|
||||
enum: ['unavailable'],
|
||||
enumLabels: [localize('sessionConfigUnavailableValue', "Unavailable")],
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
values: { unavailable: 'unavailable' },
|
||||
};
|
||||
}
|
||||
|
||||
private _clearNewSessionConfig(sessionId: string): void {
|
||||
this._newSessionWorkspaces.delete(sessionId);
|
||||
this._newSessionConfigs.delete(sessionId);
|
||||
this._newSessionAgentProviders.delete(sessionId);
|
||||
this._newSessionConfigRequests.delete(sessionId);
|
||||
}
|
||||
|
||||
// -- Private: Session Cache --
|
||||
|
||||
private _cacheInitialized = false;
|
||||
@@ -608,7 +717,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
|
||||
for (const meta of sessions) {
|
||||
const rawId = AgentSession.id(meta.session);
|
||||
const provider = AgentSession.provider(meta.session) ?? 'copilot';
|
||||
const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_PROVIDER;
|
||||
currentKeys.add(rawId);
|
||||
|
||||
const existing = this._sessionCache.get(rawId);
|
||||
@@ -681,7 +790,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = AgentSession.provider(sessionUri) ?? 'copilot';
|
||||
const provider = AgentSession.provider(sessionUri) ?? DEFAULT_AGENT_PROVIDER;
|
||||
const workingDir = typeof summary.workingDirectory === 'string'
|
||||
? toAgentHostUri(URI.parse(summary.workingDirectory), this._connectionAuthority)
|
||||
: undefined;
|
||||
@@ -750,6 +859,17 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
|
||||
return `remote-${this._connectionAuthority}-${provider}`;
|
||||
}
|
||||
|
||||
private _getDefaultAgentProvider(): string {
|
||||
const rootState = this._connection?.rootState?.value;
|
||||
return rootState && !(rootState instanceof Error)
|
||||
? rootState.agents[0]?.provider ?? DEFAULT_AGENT_PROVIDER
|
||||
: DEFAULT_AGENT_PROVIDER;
|
||||
}
|
||||
|
||||
private _getAgentProviderForSession(sessionId: string): string {
|
||||
return this._newSessionAgentProviders.get(sessionId) ?? DEFAULT_AGENT_PROVIDER;
|
||||
}
|
||||
|
||||
// -- Private: Browse --
|
||||
|
||||
private async _browseForFolder(): Promise<ISessionWorkspace | undefined> {
|
||||
|
||||
@@ -321,9 +321,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
|
||||
}
|
||||
|
||||
private setActiveSession(session: ISession | undefined): void {
|
||||
if (this._activeSession.get()?.sessionId === session?.sessionId) {
|
||||
const previousSession = this._activeSession.get();
|
||||
if (previousSession?.sessionId === session?.sessionId) {
|
||||
return;
|
||||
}
|
||||
if (previousSession?.status.get() === SessionStatus.Untitled) {
|
||||
this._getProvider(previousSession)?.clearSessionConfig?.(previousSession.sessionId);
|
||||
}
|
||||
|
||||
// Update context keys from session data
|
||||
this._activeSessionProviderId.set(session?.providerId ?? '');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IObservable } from '../../../../base/common/observable.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
|
||||
import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
|
||||
import { ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction } from './session.js';
|
||||
|
||||
@@ -112,6 +113,21 @@ export interface ISessionsProvider {
|
||||
/** Mark a session as read or unread. */
|
||||
setRead(sessionId: string, read: boolean): void;
|
||||
|
||||
// -- Dynamic Session Config --
|
||||
|
||||
/** Optional. Fires when dynamic configuration for a new session changes. */
|
||||
readonly onDidChangeSessionConfig?: Event<string>;
|
||||
/** Optional. Returns the last resolved dynamic configuration for a new session. */
|
||||
getSessionConfig?(sessionId: string): IResolveSessionConfigResult | undefined;
|
||||
/** Optional. Sets one dynamic configuration property and re-resolves the schema. */
|
||||
setSessionConfigValue?(sessionId: string, property: string, value: string): Promise<void>;
|
||||
/** Optional. Returns dynamic completions for a configuration property. */
|
||||
getSessionConfigCompletions?(sessionId: string, property: string, query?: string): Promise<readonly ISessionConfigValueItem[]>;
|
||||
/** Optional. Returns the resolved config that should be sent to createSession. */
|
||||
getCreateSessionConfig?(sessionId: string): Record<string, string> | undefined;
|
||||
/** Optional. Clears dynamic configuration state for an abandoned new session. */
|
||||
clearSessionConfig?(sessionId: string): void;
|
||||
|
||||
// -- Send --
|
||||
/** Send a request, creating a new chat in the session. */
|
||||
sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise<ISession>;
|
||||
|
||||
@@ -459,6 +459,7 @@ import './browser/layoutActions.js';
|
||||
import './contrib/accountMenu/browser/account.contribution.js';
|
||||
import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js';
|
||||
import './contrib/chat/browser/chat.contribution.js';
|
||||
import './contrib/chat/browser/agentHostSessionConfigPicker.js';
|
||||
import './contrib/chat/browser/customizationsDebugLog.contribution.js';
|
||||
import './contrib/copilotChatSessions/browser/copilotChatSessions.contribution.js';
|
||||
import './contrib/sessions/browser/sessions.contribution.js';
|
||||
|
||||
@@ -417,7 +417,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
async (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => {
|
||||
// todo@connor4312, I think IChatSession.requestHandler is actually
|
||||
// dead code and I don't believe this is ever called.
|
||||
const backendSession = resolvedSession ?? await this._createAndSubscribe(sessionResource, request.userSelectedModelId);
|
||||
const backendSession = resolvedSession ?? await this._createAndSubscribe(sessionResource, request.userSelectedModelId, undefined, request.agentHostSessionConfig);
|
||||
if (!resolvedSession) {
|
||||
resolvedSession = backendSession;
|
||||
this._sessionToBackend.set(sessionResource, backendSession);
|
||||
@@ -1796,7 +1796,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
}
|
||||
|
||||
/** Creates a new backend session and subscribes to its state. */
|
||||
private async _createAndSubscribe(sessionResource: URI, modelId?: string, fork?: { session: URI; turnIndex: number }): Promise<URI> {
|
||||
private async _createAndSubscribe(sessionResource: URI, modelId?: string, fork?: { session: URI; turnIndex: number }, sessionConfig?: Record<string, string>): Promise<URI> {
|
||||
const rawModelId = this._extractRawModelId(modelId);
|
||||
const resourceKey = sessionResource.path.substring(1);
|
||||
const workingDirectory = this._config.resolveWorkingDirectory?.(resourceKey)
|
||||
@@ -1824,6 +1824,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
provider: this._config.provider,
|
||||
workingDirectory,
|
||||
fork,
|
||||
config: sessionConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
// If authentication is required (e.g. token expired), try interactive auth and retry once
|
||||
@@ -1836,6 +1837,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
provider: this._config.provider,
|
||||
workingDirectory,
|
||||
fork,
|
||||
config: sessionConfig,
|
||||
});
|
||||
} else {
|
||||
throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again."));
|
||||
|
||||
@@ -7,11 +7,11 @@ import { Emitter, Event } from '../../../../../../base/common/event.js';
|
||||
import { Disposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js';
|
||||
import { URI, UriComponents } from '../../../../../../base/common/uri.js';
|
||||
import { Registry } from '../../../../../../platform/registry/common/platform.js';
|
||||
import { IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js';
|
||||
import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js';
|
||||
import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js';
|
||||
import { StateComponents, type ComponentToState, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js';
|
||||
import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import type { ICreateTerminalParams } from '../../../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
|
||||
import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js';
|
||||
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
|
||||
@@ -131,6 +131,14 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti
|
||||
return this._logCall('createSession', config, () => this._inner.createSession(config));
|
||||
}
|
||||
|
||||
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> {
|
||||
return this._logCall('resolveSessionConfig', params, () => this._inner.resolveSessionConfig(params));
|
||||
}
|
||||
|
||||
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> {
|
||||
return this._logCall('sessionConfigCompletions', params, () => this._inner.sessionConfigCompletions(params));
|
||||
}
|
||||
|
||||
async disposeSession(session: URI): Promise<void> {
|
||||
return this._logCall('disposeSession', session, () => this._inner.disposeSession(session));
|
||||
}
|
||||
|
||||
@@ -1391,6 +1391,7 @@ export interface IChatSendRequestOptions {
|
||||
rejectedConfirmationData?: any[];
|
||||
attachedContext?: IChatRequestVariableEntry[];
|
||||
resolvedVariables?: IChatRequestVariableEntry[];
|
||||
agentHostSessionConfig?: Record<string, string>;
|
||||
|
||||
/** The target agent ID can be specified with this property instead of using @ in 'message' */
|
||||
agentId?: string;
|
||||
|
||||
@@ -1210,6 +1210,7 @@ export class ChatService extends Disposable implements IChatService {
|
||||
locationData: thisRequest.locationData,
|
||||
acceptedConfirmationData: options?.acceptedConfirmationData,
|
||||
rejectedConfirmationData: options?.rejectedConfirmationData,
|
||||
agentHostSessionConfig: options?.agentHostSessionConfig,
|
||||
userSelectedModelId: options?.userSelectedModelId,
|
||||
modelConfiguration: options?.userSelectedModelId ? this.languageModelsService.getModelConfiguration(options.userSelectedModelId) : undefined,
|
||||
userSelectedTools: options?.userSelectedTools?.get(),
|
||||
|
||||
@@ -148,6 +148,7 @@ export interface IChatAgentRequest {
|
||||
locationData?: Revived<IChatLocationData>;
|
||||
acceptedConfirmationData?: unknown[];
|
||||
rejectedConfirmationData?: unknown[];
|
||||
agentHostSessionConfig?: Record<string, string>;
|
||||
userSelectedModelId?: string;
|
||||
modelConfiguration?: IStringDictionary<unknown>;
|
||||
userSelectedTools?: UserSelectedTools;
|
||||
|
||||
@@ -335,7 +335,7 @@ function createContribution(disposables: DisposableStore) {
|
||||
return { contribution, listController, sessionHandler, agentHostService, chatAgentService };
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string }> = {}): IChatAgentRequest {
|
||||
function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; agentHostSessionConfig: Record<string, string> }> = {}): IChatAgentRequest {
|
||||
return upcastPartial<IChatAgentRequest>({
|
||||
sessionResource: overrides.sessionResource ?? URI.from({ scheme: 'untitled', path: '/chat-1' }),
|
||||
requestId: 'req-1',
|
||||
@@ -344,6 +344,7 @@ function makeRequest(overrides: Partial<{ message: string; sessionResource: URI;
|
||||
variables: overrides.variables ?? { variables: [] },
|
||||
location: ChatAgentLocation.Chat,
|
||||
userSelectedModelId: overrides.userSelectedModelId,
|
||||
agentHostSessionConfig: overrides.agentHostSessionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,6 +370,7 @@ async function startTurn(
|
||||
sessionResource: URI;
|
||||
variables: IChatAgentRequest['variables'];
|
||||
userSelectedModelId: string;
|
||||
agentHostSessionConfig: Record<string, string>;
|
||||
cancellationToken: CancellationToken;
|
||||
}>,
|
||||
) {
|
||||
@@ -389,6 +391,7 @@ async function startTurn(
|
||||
sessionResource,
|
||||
variables: overrides?.variables,
|
||||
userSelectedModelId: overrides?.userSelectedModelId,
|
||||
agentHostSessionConfig: overrides?.agentHostSessionConfig,
|
||||
}),
|
||||
(parts) => collected.push(parts),
|
||||
[],
|
||||
@@ -1605,6 +1608,27 @@ suite('AgentHostChatContribution', () => {
|
||||
assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectory?.toString(), URI.file('/custom/working/dir').toString());
|
||||
}));
|
||||
|
||||
test('handler forwards request session config to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { instantiationService, agentHostService } = createTestServices(disposables);
|
||||
|
||||
const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
|
||||
provider: 'copilot' as const,
|
||||
agentId: 'config-test',
|
||||
sessionType: 'config-test',
|
||||
fullName: 'Test',
|
||||
description: 'test',
|
||||
connection: agentHostService,
|
||||
connectionAuthority: 'local',
|
||||
}));
|
||||
|
||||
const config = { target: 'worktree', isolation: 'new' };
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, disposables, { agentHostSessionConfig: config });
|
||||
fire({ type: 'session/turnComplete', session, turnId } as ISessionAction);
|
||||
await turnPromise;
|
||||
|
||||
assert.deepStrictEqual(agentHostService.createSessionCalls[0].config, config);
|
||||
}));
|
||||
|
||||
test('handler passes vscode-agent-host URI as-is to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { instantiationService, agentHostService } = createTestServices(disposables);
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Emitter, Event } from '../../../../../base/common/event.js';
|
||||
import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js';
|
||||
import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js';
|
||||
import { ActionType, IStateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js';
|
||||
import { IRootState, TerminalClaimKind, type ITerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js';
|
||||
import type { ICreateTerminalParams } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
|
||||
import type { IActionEnvelope, ISessionAction, ITerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js';
|
||||
|
||||
@@ -70,6 +70,8 @@ class MockAgentConnection implements IAgentConnection {
|
||||
async authenticate(_params: IAuthenticateParams): Promise<IAuthenticateResult> { return { authenticated: true }; }
|
||||
async listSessions(): Promise<IAgentSessionMetadata[]> { return []; }
|
||||
async createSession(_config?: IAgentCreateSessionConfig): Promise<URI> { return URI.parse('copilot:///test'); }
|
||||
async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult> { return { ready: true, schema: { type: 'object', properties: {} }, values: {} }; }
|
||||
async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult> { return { items: [] }; }
|
||||
async disposeSession(_session: URI): Promise<void> { }
|
||||
async shutdown(): Promise<void> { }
|
||||
async resourceList(_uri: URI): Promise<IResourceListResult> { return { entries: [] }; }
|
||||
|
||||
Reference in New Issue
Block a user