diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index d5d5177cc68df..efa5bfaf3a79b 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -14,7 +14,7 @@ import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } import type { IAgentSubscription } from './state/agentSubscription.js'; import type { ICreateTerminalParams } 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, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type IToolCallResult, type PolicyState, type StringOrMarkdown } from './state/sessionState.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'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -42,6 +42,7 @@ export interface IAgentSessionMetadata { readonly startTime: number; readonly modifiedTime: number; readonly summary?: string; + readonly status?: SessionStatus; readonly workingDirectory?: URI; readonly isRead?: boolean; readonly isDone?: boolean; @@ -245,6 +246,12 @@ export interface IAgentSteeringConsumedEvent extends IAgentProgressEventBase { readonly id: string; } +/** The agent's ask_user tool is requesting user input. */ +export interface IAgentUserInputRequestEvent extends IAgentProgressEventBase { + readonly type: 'user_input_request'; + readonly request: ISessionInputRequest; +} + export type IAgentProgressEvent = | IAgentDeltaEvent | IAgentMessageEvent @@ -256,7 +263,8 @@ export type IAgentProgressEvent = | IAgentErrorEvent | IAgentUsageEvent | IAgentReasoningEvent - | IAgentSteeringConsumedEvent; + | IAgentSteeringConsumedEvent + | IAgentUserInputRequestEvent; // ---- Session URI helpers ---------------------------------------------------- @@ -334,6 +342,9 @@ export interface IAgent { /** Respond to a pending permission request from the SDK. */ respondToPermissionRequest(requestId: string, approved: boolean): void; + /** Respond to a pending user input request from the SDK's ask_user tool. */ + respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void; + /** Return the descriptor for this agent. */ getDescriptor(): IAgentDescriptor; @@ -512,6 +523,7 @@ export interface IAgentConnection { // ---- State subscriptions ------------------------------------------------ readonly rootState: IAgentSubscription; getSubscription(kind: T, resource: URI): IReference>; + getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; // ---- Action dispatch ---------------------------------------------------- dispatch(action: ISessionAction | ITerminalAction): void; diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 82f78b357ec2d..2f17e6086d605 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -5,6 +5,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { URI } from '../../../../base/common/uri.js'; import { IActionEnvelope, ISessionAction, IStateAction, isSessionAction } from './sessionActions.js'; import { rootReducer, sessionReducer } from './sessionReducers.js'; @@ -350,7 +351,7 @@ export class TerminalStateSubscription extends BaseAgentSubscription; refCount: number }>(); + private readonly _subscriptions = new ResourceMap<{ sub: BaseAgentSubscription; refCount: number }>(); private readonly _rootState: RootStateSubscription; private readonly _clientId: string; private readonly _seqAllocator: () => number; @@ -387,43 +388,52 @@ export class AgentSubscriptionManager extends Disposable { this._rootState.handleSnapshot(state, fromSeq); } + /** + * Returns an existing subscription without affecting its refcount. + * Returns `undefined` if no subscription is active for the given resource. + */ + getSubscriptionUnmanaged(resource: URI): IAgentSubscription | undefined { + const entry = this._subscriptions.get(resource); + return entry?.sub as unknown as IAgentSubscription | undefined; + } + /** * Get or create a refcounted subscription to any resource. Disposing * the returned reference decrements the refcount; when it reaches zero * the subscription is torn down and the server is notified. */ getSubscription(kind: StateComponents, resource: URI): IReference> { - const key = resource.toString(); - const existing = this._subscriptions.get(key); + const existing = this._subscriptions.get(resource); if (existing) { existing.refCount++; return { object: existing.sub, - dispose: () => this._releaseSubscription(key), + dispose: () => this._releaseSubscription(resource), }; } // Create new subscription based on caller-specified kind + const key = resource.toString(); const sub = this._createSubscription(kind, key); const entry = { sub, refCount: 1 }; - this._subscriptions.set(key, entry); + this._subscriptions.set(resource, entry); // Kick off server subscription asynchronously. // Capture the entry reference so we can validate it hasn't been // replaced by a new subscription for the same key (race guard). this._subscribe(resource).then(snapshot => { - if (this._subscriptions.get(key) === entry) { + if (this._subscriptions.get(resource) === entry) { sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); } }).catch(err => { - if (this._subscriptions.get(key) === entry) { + if (this._subscriptions.get(resource) === entry) { sub.setError(err instanceof Error ? err : new Error(String(err))); } }); return { object: sub, - dispose: () => this._releaseSubscription(key), + dispose: () => this._releaseSubscription(resource), }; } @@ -445,8 +455,7 @@ export class AgentSubscriptionManager extends Disposable { */ dispatchOptimistic(action: ISessionAction | ITerminalAction): number { if (isSessionAction(action)) { - const key = action.session.toString(); - const entry = this._subscriptions.get(key); + const entry = this._subscriptions.get(URI.parse(action.session)); if (entry && entry.sub instanceof SessionStateSubscription) { return entry.sub.applyOptimistic(action); } @@ -466,15 +475,15 @@ export class AgentSubscriptionManager extends Disposable { } } - private _releaseSubscription(key: string): void { - const entry = this._subscriptions.get(key); + private _releaseSubscription(resource: URI): void { + const entry = this._subscriptions.get(resource); if (!entry) { return; } entry.refCount--; if (entry.refCount <= 0) { - this._subscriptions.delete(key); - try { this._unsubscribe(URI.parse(key)); } catch { /* best-effort */ } + this._subscriptions.delete(resource); + try { this._unsubscribe(resource); } catch { /* best-effort */ } if (entry.sub instanceof SessionStateSubscription) { entry.sub.clearPending(); } @@ -483,8 +492,8 @@ export class AgentSubscriptionManager extends Disposable { } override dispose(): void { - for (const [key, entry] of this._subscriptions) { - try { this._unsubscribe(URI.parse(key)); } catch { /* best-effort */ } + for (const [resource, entry] of this._subscriptions) { + try { this._unsubscribe(resource); } catch { /* best-effort */ } entry.sub.dispose(); } this._subscriptions.clear(); diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index eecc73338ee9f..54af453b08734 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -eea38f5 +069f338 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 7774d2ad35469..5e7c60b9f1e6e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -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 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 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 ─────────────────────────────── @@ -48,6 +48,9 @@ export type ISessionAction = | ISessionPendingMessageSetAction | ISessionPendingMessageRemovedAction | ISessionQueuedMessagesReorderedAction + | ISessionInputRequestedAction + | ISessionInputAnswerChangedAction + | ISessionInputCompletedAction | ISessionCustomizationsChangedAction | ISessionCustomizationToggledAction | ISessionTruncatedAction @@ -70,6 +73,8 @@ export type IClientSessionAction = | ISessionPendingMessageSetAction | ISessionPendingMessageRemovedAction | ISessionQueuedMessagesReorderedAction + | ISessionInputAnswerChangedAction + | ISessionInputCompletedAction | ISessionCustomizationToggledAction | ISessionTruncatedAction | ISessionIsReadChangedAction @@ -91,6 +96,7 @@ export type IServerSessionAction = | ISessionUsageAction | ISessionReasoningAction | ISessionServerToolsChangedAction + | ISessionInputRequestedAction | ISessionCustomizationsChangedAction | ISessionDiffsChangedAction ; @@ -158,6 +164,9 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo [ActionType.SessionPendingMessageSet]: true, [ActionType.SessionPendingMessageRemoved]: true, [ActionType.SessionQueuedMessagesReordered]: true, + [ActionType.SessionInputRequested]: false, + [ActionType.SessionInputAnswerChanged]: true, + [ActionType.SessionInputCompleted]: true, [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionTruncated]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 6f2c213a7de50..3962f26adf832 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type ISessionFileDiff, type ITerminalInfo, type ITerminalClaim } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type ISessionFileDiff, type ISessionInputAnswer, type ISessionInputRequest, type ITerminalInfo, type ITerminalClaim, type SessionInputResponseKind } from './state.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -44,6 +44,9 @@ export const enum ActionType { SessionPendingMessageSet = 'session/pendingMessageSet', SessionPendingMessageRemoved = 'session/pendingMessageRemoved', SessionQueuedMessagesReordered = 'session/queuedMessagesReordered', + SessionInputRequested = 'session/inputRequested', + SessionInputAnswerChanged = 'session/inputAnswerChanged', + SessionInputCompleted = 'session/inputCompleted', SessionCustomizationsChanged = 'session/customizationsChanged', SessionCustomizationToggled = 'session/customizationToggled', SessionTruncated = 'session/truncated', @@ -754,6 +757,69 @@ export interface ISessionQueuedMessagesReorderedAction { order: string[]; } +// ─── Session Input Actions ────────────────────────────────────────────────── + +/** + * A session requested input from the user. + * + * Full-request upsert semantics: the `request` replaces any existing request + * with the same `id`, or is appended if it is new. Answer drafts are preserved + * unless `request.answers` is provided. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionInputRequestedAction { + type: ActionType.SessionInputRequested; + /** Session URI */ + session: URI; + /** Input request to create or replace */ + request: ISessionInputRequest; +} + +/** + * A client updated, submitted, skipped, or removed a single in-progress answer. + * + * Dispatching with `answer: undefined` removes that question's answer draft. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionInputAnswerChangedAction { + type: ActionType.SessionInputAnswerChanged; + /** Session URI */ + session: URI; + /** Input request identifier */ + requestId: string; + /** Question identifier within the input request */ + questionId: string; + /** Updated answer, or `undefined` to clear an answer draft */ + answer?: ISessionInputAnswer; +} + +/** + * A client accepted, declined, or cancelled a session input request. + * + * If accepted, the server uses `answers` (when provided) plus the request's + * synced answer state to resume the blocked operation. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionInputCompletedAction { + type: ActionType.SessionInputCompleted; + /** Session URI */ + session: URI; + /** Input request identifier */ + requestId: string; + /** Completion outcome */ + response: SessionInputResponseKind; + /** Optional final answer replacement, keyed by question ID */ + answers?: Record; +} + // ─── Terminal Actions ──────────────────────────────────────────────────────── /** @@ -931,6 +997,9 @@ export type IStateAction = | ISessionPendingMessageSetAction | ISessionPendingMessageRemovedAction | ISessionQueuedMessagesReorderedAction + | ISessionInputRequestedAction + | ISessionInputAnswerChangedAction + | ISessionInputCompletedAction | ISessionCustomizationsChangedAction | ISessionCustomizationToggledAction | ISessionTruncatedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index b4d7e5052c8a7..840940d1e7f72 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -50,7 +50,7 @@ export const enum NotificationType { * "resource": "copilot:/", * "provider": "copilot", * "title": "New Session", - * "status": "idle", + * "status": 1, * "createdAt": 1710000000000, * "modifiedAt": 1710000000000 * } diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index f02156f4f9116..d2530a3250f1f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from './actions.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionState, type ITerminalState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionInputRequest, type ISessionState, type ITerminalState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js'; import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction, type ITerminalAction, type IClientTerminalAction } from './action-origin.generated.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -36,6 +36,45 @@ function tcBase(tc: IToolCallState) { }; } +/** Returns `true` if the active turn has any tool call awaiting user confirmation. */ +function hasPendingToolCallConfirmation(state: ISessionState): boolean { + if (!state.activeTurn) { + return false; + } + return state.activeTurn.responseParts.some(part => + part.kind === ResponsePartKind.ToolCall + && (part.toolCall.status === ToolCallStatus.PendingConfirmation + || part.toolCall.status === ToolCallStatus.PendingResultConfirmation), + ); +} + +/** Derives the summary status from live session work. */ +function summaryStatus(state: ISessionState, terminalStatus?: SessionStatus.Error): SessionStatus { + if (terminalStatus) { + return terminalStatus; + } + if ((state.inputRequests?.length ?? 0) > 0 || hasPendingToolCallConfirmation(state)) { + return SessionStatus.InputNeeded; + } + if (state.activeTurn) { + return SessionStatus.InProgress; + } + return SessionStatus.Idle; +} + +/** + * Returns a state with `summary.status` recomputed. Use this after reducers + * that change data which feeds into {@link summaryStatus} (e.g. tool call + * lifecycle transitions that may enter or leave a pending-confirmation state). + */ +function refreshSummaryStatus(state: ISessionState): ISessionState { + const status = summaryStatus(state); + if (status === state.summary.status) { + return state; + } + return { ...state, summary: { ...state.summary, status } }; +} + /** * Ends the active turn, finalizing it into a completed turn record. * @@ -46,7 +85,7 @@ function endTurn( state: ISessionState, turnId: string, turnState: TurnState, - summaryStatus: SessionStatus, + terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, ): ISessionState { if (!state.activeTurn || state.activeTurn.id !== turnId) { @@ -84,14 +123,33 @@ function endTurn( error, }; - return { + const next: ISessionState = { ...state, turns: [...state.turns, turn], activeTurn: undefined, - summary: { ...state.summary, status: summaryStatus, modifiedAt: Date.now() }, + summary: { ...state.summary, modifiedAt: Date.now() }, + }; + delete next.inputRequests; + return { + ...next, + summary: { ...next.summary, status: summaryStatus(next, terminalStatus) }, }; } +function upsertInputRequest(state: ISessionState, request: ISessionInputRequest): ISessionState { + const existing = state.inputRequests ?? []; + const idx = existing.findIndex(r => r.id === request.id); + const inputRequests = [...existing]; + if (idx >= 0) { + const answers = request.answers ?? inputRequests[idx].answers; + inputRequests[idx] = { ...request, answers }; + } else { + inputRequests.push(request); + } + const next = { ...state, inputRequests }; + return { ...next, summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now(), isRead: false } }; +} + /** * Immutably updates the tool call inside a `ToolCall` response part in the * active turn's `responseParts` array. Returns `state` unchanged if the @@ -221,7 +279,6 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log case ActionType.SessionTurnStarted: { let next: ISessionState = { ...state, - summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now(), isRead: false }, activeTurn: { id: action.turnId, userMessage: action.userMessage, @@ -229,6 +286,10 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log usage: undefined, }, }; + next = { + ...next, + summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now(), isRead: false }, + }; // If this turn was auto-started from a pending message, remove it if (action.queuedMessageId) { @@ -265,10 +326,10 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log }; case ActionType.SessionTurnComplete: - return endTurn(state, action.turnId, TurnState.Complete, SessionStatus.Idle); + return endTurn(state, action.turnId, TurnState.Complete); case ActionType.SessionTurnCancelled: - return endTurn(state, action.turnId, TurnState.Cancelled, SessionStatus.Idle); + return endTurn(state, action.turnId, TurnState.Cancelled); case ActionType.SessionError: return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); @@ -313,7 +374,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log }); case ActionType.SessionToolCallReady: - return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.Streaming && tc.status !== ToolCallStatus.Running) { return tc; } @@ -334,10 +395,10 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log toolInput: action.toolInput, confirmationTitle: action.confirmationTitle, }; - }); + })); case ActionType.SessionToolCallConfirmed: - return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.PendingConfirmation) { return tc; } @@ -360,10 +421,10 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log reasonMessage: action.reasonMessage, userSuggestion: action.userSuggestion, }; - }); + })); case ActionType.SessionToolCallComplete: - return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation) { return tc; } @@ -389,10 +450,10 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log confirmed, ...action.result, }; - }); + })); case ActionType.SessionToolCallResultConfirmed: - return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + return refreshSummaryStatus(updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { if (tc.status !== ToolCallStatus.PendingResultConfirmation) { return tc; } @@ -418,7 +479,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log toolInput: tc.toolInput, reason: ToolCallCancellationReason.ResultDenied, }; - }); + })); case ActionType.SessionToolCallContentChanged: return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { @@ -530,11 +591,66 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log } turns = state.turns.slice(0, idx + 1); } - return { + const next: ISessionState = { ...state, turns, activeTurn: undefined, - summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() }, + summary: { ...state.summary, modifiedAt: Date.now() }, + }; + delete next.inputRequests; + return { + ...next, + summary: { ...next.summary, status: summaryStatus(next) }, + }; + } + + // ── Session Input Requests ───────────────────────────────────────────── + + case ActionType.SessionInputRequested: + return upsertInputRequest(state, action.request); + + case ActionType.SessionInputAnswerChanged: { + const existing = state.inputRequests; + const idx = existing?.findIndex(request => request.id === action.requestId) ?? -1; + if (!existing || idx < 0) { + return state; + } + const request = existing[idx]; + const answers = { ...(request.answers ?? {}) }; + if (action.answer === undefined) { + delete answers[action.questionId]; + } else { + answers[action.questionId] = action.answer; + } + const updated = [...existing]; + updated[idx] = { + ...request, + answers: Object.keys(answers).length > 0 ? answers : undefined, + }; + return { + ...state, + inputRequests: updated, + summary: { ...state.summary, modifiedAt: Date.now() }, + }; + } + + case ActionType.SessionInputCompleted: { + const existing = state.inputRequests; + if (!existing?.some(request => request.id === action.requestId)) { + return state; + } + const inputRequests = existing.filter(request => request.id !== action.requestId); + const next: ISessionState = { + ...state, + }; + if (inputRequests.length > 0) { + next.inputRequests = inputRequests; + } else { + delete next.inputRequests; + } + return { + ...next, + summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now() }, }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 6f82323f8a0d4..9f2cbcbdb2480 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -251,14 +251,19 @@ export const enum SessionLifecycle { } /** - * Current session status. + * Bitset of summary-level session status flags. + * + * Use bitwise checks instead of equality for non-terminal activity. For example, + * `status & SessionStatus.InProgress` matches both ordinary in-progress turns + * and turns that are paused waiting for input. * * @category Session State */ export const enum SessionStatus { - Idle = 'idle', - InProgress = 'in-progress', - Error = 'error', + Idle = 1, + Error = 1 << 1, + InProgress = 1 << 3, + InputNeeded = (1 << 3) | (1 << 4), } /** @@ -287,6 +292,8 @@ export interface ISessionState { steeringMessage?: IPendingMessage; /** Messages to send automatically as new turns after the current turn finishes */ queuedMessages?: IPendingMessage[]; + /** Requests for user input that are currently blocking or informing session progress */ + inputRequests?: ISessionInputRequest[]; /** * Server-provided customizations active in this session. * @@ -357,6 +364,231 @@ export interface ISessionSummary { diffs?: ISessionFileDiff[]; } +// ─── Session Input Types ──────────────────────────────────────────────────── + +/** + * How a client completed an input request. + * + * @category Session Input Types + */ +export const enum SessionInputResponseKind { + Accept = 'accept', + Decline = 'decline', + Cancel = 'cancel', +} + +/** + * Question/input control kind. + * + * @category Session Input Types + */ +export const enum SessionInputQuestionKind { + Text = 'text', + Number = 'number', + Integer = 'integer', + Boolean = 'boolean', + SingleSelect = 'single-select', + MultiSelect = 'multi-select', +} + +/** + * A choice in a select-style question. + * + * @category Session Input Types + */ +export interface ISessionInputOption { + /** Stable option identifier; for MCP enum values this is the enum string */ + id: string; + /** Display label */ + label: string; + /** Optional secondary text */ + description?: string; + /** Whether this option is the recommended/default choice */ + recommended?: boolean; +} + +interface ISessionInputQuestionBase { + /** Stable question identifier used as the key in `answers` */ + id: string; + /** Short display title */ + title?: string; + /** Prompt shown to the user */ + message: string; + /** Whether the user must answer this question to accept the request */ + required?: boolean; +} + +/** Text question within a session input request. */ +export interface ISessionInputTextQuestion extends ISessionInputQuestionBase { + kind: SessionInputQuestionKind.Text; + /** Format hint for text questions, such as `email`, `uri`, `date`, or `date-time` */ + format?: string; + /** Minimum string length */ + min?: number; + /** Maximum string length */ + max?: number; + /** Default text */ + defaultValue?: string; +} + +/** Numeric question within a session input request. */ +export interface ISessionInputNumberQuestion extends ISessionInputQuestionBase { + kind: SessionInputQuestionKind.Number | SessionInputQuestionKind.Integer; + /** Minimum value */ + min?: number; + /** Maximum value */ + max?: number; + /** Default numeric value */ + defaultValue?: number; +} + +/** Boolean question within a session input request. */ +export interface ISessionInputBooleanQuestion extends ISessionInputQuestionBase { + kind: SessionInputQuestionKind.Boolean; + /** Default boolean value */ + defaultValue?: boolean; +} + +/** Single-select question within a session input request. */ +export interface ISessionInputSingleSelectQuestion extends ISessionInputQuestionBase { + kind: SessionInputQuestionKind.SingleSelect; + /** Options the user may select from */ + options: ISessionInputOption[]; + /** Whether the user may enter text instead of selecting an option */ + allowFreeformInput?: boolean; +} + +/** Multi-select question within a session input request. */ +export interface ISessionInputMultiSelectQuestion extends ISessionInputQuestionBase { + kind: SessionInputQuestionKind.MultiSelect; + /** Options the user may select from */ + options: ISessionInputOption[]; + /** Whether the user may enter text in addition to selecting options */ + allowFreeformInput?: boolean; + /** Minimum selected item count */ + min?: number; + /** Maximum selected item count */ + max?: number; +} + +/** + * One question within a session input request. + * + * @category Session Input Types + */ +export type ISessionInputQuestion = ISessionInputTextQuestion + | ISessionInputNumberQuestion + | ISessionInputBooleanQuestion + | ISessionInputSingleSelectQuestion + | ISessionInputMultiSelectQuestion; + +/** + * A live request for user input. + * + * The server creates or replaces requests with `session/inputRequested`. + * Clients sync drafts with `session/inputAnswerChanged` and complete requests + * with `session/inputCompleted`. + * + * @category Session Input Types + */ +export interface ISessionInputRequest { + /** Stable request identifier */ + id: string; + /** Display message for the request as a whole */ + message: string; + /** URL the user should review or open, for URL-style elicitations */ + url?: URI; + /** Ordered questions to ask the user */ + questions?: ISessionInputQuestion[]; + /** Current draft or submitted answers, keyed by question ID */ + answers?: Record; +} + +/** + * Answer value kind. + * + * @category Session Input Types + */ +export const enum SessionInputAnswerValueKind { + Text = 'text', + Number = 'number', + Boolean = 'boolean', + Selected = 'selected', + SelectedMany = 'selected-many', +} + +/** + * Value captured for one answer. + * + * @category Session Input Types + */ +export interface ISessionInputTextAnswerValue { + kind: SessionInputAnswerValueKind.Text; + value: string; +} + +export interface ISessionInputNumberAnswerValue { + kind: SessionInputAnswerValueKind.Number; + value: number; +} + +export interface ISessionInputBooleanAnswerValue { + kind: SessionInputAnswerValueKind.Boolean; + value: boolean; +} + +export interface ISessionInputSelectedAnswerValue { + kind: SessionInputAnswerValueKind.Selected; + value: string; + /** Free-form text entered instead of selecting an option */ + freeformValues?: string[]; +} + +export interface ISessionInputSelectedManyAnswerValue { + kind: SessionInputAnswerValueKind.SelectedMany; + value: string[]; + /** Free-form text entered in addition to selected options */ + freeformValues?: string[]; +} + +export type ISessionInputAnswerValue = ISessionInputTextAnswerValue + | ISessionInputNumberAnswerValue + | ISessionInputBooleanAnswerValue + | ISessionInputSelectedAnswerValue + | ISessionInputSelectedManyAnswerValue; + +export interface ISessionInputAnswered { + /** Answer state */ + state: SessionInputAnswerState.Draft | SessionInputAnswerState.Submitted; + /** Answer value */ + value: ISessionInputAnswerValue; +} + +export interface ISessionInputSkipped { + /** Answer state */ + state: SessionInputAnswerState.Skipped; + /** Free-form reason or value captured while skipping, if any */ + freeformValues?: string[]; +} + +/** + * Answer lifecycle state. + * + * @category Session Input Types + */ +export const enum SessionInputAnswerState { + Draft = 'draft', + Submitted = 'submitted', + Skipped = 'skipped', +} + +/** + * Draft, submitted, or skipped answer for one question. + * + * @category Session Input Types + */ +export type ISessionInputAnswer = ISessionInputAnswered | ISessionInputSkipped; + // ─── Turn Types ────────────────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 6a4fa5b1b0046..849ea2790f745 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -51,6 +51,9 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe [ActionType.SessionPendingMessageSet]: 1, [ActionType.SessionPendingMessageRemoved]: 1, [ActionType.SessionQueuedMessagesReordered]: 1, + [ActionType.SessionInputRequested]: 1, + [ActionType.SessionInputAnswerChanged]: 1, + [ActionType.SessionInputCompleted]: 1, [ActionType.SessionCustomizationsChanged]: 1, [ActionType.SessionCustomizationToggled]: 1, [ActionType.SessionTruncated]: 1, diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index 397d1585bd1fa..22c1ba51e55a5 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -48,6 +48,8 @@ export { type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, + type ISessionInputRequestedAction, + type ISessionInputCompletedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type IStateAction, diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 00a3cd4b6170a..781a806dcbe33 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -69,11 +69,19 @@ export { type IPendingMessage, type StringOrMarkdown, type URI, + type ISessionInputRequest, + type ISessionInputQuestion, + type ISessionInputAnswer, + type ISessionInputOption, AttachmentType, CustomizationStatus, PendingMessageKind, PolicyState, ResponsePartKind, + SessionInputAnswerState, + SessionInputAnswerValueKind, + SessionInputQuestionKind, + SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 653b0176d252a..04be1bd8996b6 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -147,6 +147,10 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { return this._subscriptionManager.getSubscription(kind, resource); } + getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { + return this._subscriptionManager.getSubscriptionUnmanaged(resource); + } + dispatch(action: ISessionAction | ITerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this.clientId, seq); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index 41d8603ba92ce..6f439b7f02270 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -144,6 +144,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._subscriptionManager.getSubscription(kind, resource); } + getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { + return this._subscriptionManager.getSubscriptionUnmanaged(resource); + } + dispatch(action: ISessionAction | ITerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this._clientId, seq); @@ -232,6 +236,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC startTime: s.createdAt, modifiedTime: s.modifiedAt, summary: s.title, + status: s.status, workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, isRead: s.isRead, isDone: s.isDone, diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 9a06daa0597c1..8dcf9d6b4ea59 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -13,12 +13,14 @@ import type { IAgentTitleChangedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, - IAgentUsageEvent + IAgentUsageEvent, + IAgentUserInputRequestEvent } from '../common/agentService.js'; import { ActionType, type ISessionAction, type ISessionErrorAction, + type ISessionInputRequestedAction, type ITitleChangedAction, type IToolCallCompleteAction, type IToolCallReadyAction, @@ -229,6 +231,15 @@ export class AgentEventMapper { }; } + case 'user_input_request': { + const e = event as IAgentUserInputRequestEvent; + return { + type: ActionType.SessionInputRequested, + session, + request: e.request, + } satisfies ISessionInputRequestedAction; + } + default: return undefined; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c82ef94f82cc1..509a5c7958913 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -154,8 +154,17 @@ export class AgentService extends Disposable implements IAgentService { return s; })); - this._logService.trace(`[AgentService] listSessions returned ${result.length} sessions`); - return result; + // Overlay live session status from the state manager + const withStatus = result.map(s => { + const liveState = this._stateManager.getSessionState(s.session.toString()); + if (liveState) { + return { ...s, status: liveState.summary.status }; + } + return s; + }); + + this._logService.trace(`[AgentService] listSessions returned ${withStatus.length} sessions`); + return withStatus; } async createSession(config?: IAgentCreateSessionConfig): Promise { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a4c2aaa228e94..5d2b28702e78e 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -307,6 +307,11 @@ export class AgentSideEffects extends Disposable { } break; } + case ActionType.SessionInputCompleted: { + const agent = this._options.getAgent(action.session); + agent?.respondToUserInputRequest(action.requestId, action.response, action.answers); + break; + } case ActionType.SessionTurnCancelled: { const agent = this._options.getAgent(action.session); agent?.abortSession(URI.parse(action.session)).catch(err => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 31e5543afb4db..6500897455410 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { CustomizationStatus, ICustomizationRef, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; +import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js'; import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -230,6 +230,7 @@ export class CopilotAgent extends Disposable implements IAgent { streaming: true, workingDirectory: config?.workingDirectory?.fsPath, onPermissionRequest: callbacks.onPermissionRequest, + onUserInputRequest: callbacks.onUserInputRequest, hooks: toSdkHooks(parsedPlugins.flatMap(p => p.hooks), callbacks.hooks), mcpServers: toSdkMcpServers(parsedPlugins.flatMap(p => p.mcpServers)), customAgents, @@ -381,6 +382,14 @@ export class CopilotAgent extends Disposable implements IAgent { } } + respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void { + for (const [, session] of this._sessions) { + if (session.respondToUserInputRequest(requestId, response, answers)) { + return; + } + } + } + /** * Returns true if this provider owns the given session ID. */ @@ -420,6 +429,7 @@ export class CopilotAgent extends Disposable implements IAgent { const customAgents = await toSdkCustomAgents(parsedPlugins.flatMap(p => p.agents), this._fileService); return { onPermissionRequest: callbacks.onPermissionRequest, + onUserInputRequest: callbacks.onUserInputRequest, hooks: toSdkHooks(parsedPlugins.flatMap(p => p.hooks), callbacks.hooks), mcpServers: toSdkMcpServers(parsedPlugins.flatMap(p => p.mcpServers)), customAgents, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 13e6f52799b9d..86bcbc6ac79ef 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -9,12 +9,13 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { localize } from '../../../../nls.js'; import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; -import { ToolResultContentType, type IPendingMessage, type IToolResultContent } from '../../common/state/sessionState.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type IPendingMessage, type IToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; @@ -31,12 +32,26 @@ import { mapSessionEvents } from './mapSessionEvents.js'; */ export type SessionWrapperFactory = (callbacks: { readonly onPermissionRequest: (request: PermissionRequest) => Promise; + readonly onUserInputRequest: (request: IUserInputRequest, invocation: { sessionId: string }) => Promise; readonly hooks: { readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; }; }) => Promise; +/** Matches the SDK's `UserInputRequest` which is not re-exported from the package entry point. */ +interface IUserInputRequest { + question: string; + choices?: string[]; + allowFreeform?: boolean; +} + +/** Matches the SDK's `UserInputResponse` which is not re-exported from the package entry point. */ +interface IUserInputResponse { + answer: string; + wasFreeform: boolean; +} + function tryStringify(value: unknown): string | undefined { try { return JSON.stringify(value); @@ -110,6 +125,8 @@ export class CopilotAgentSession extends Disposable { private readonly _activeToolCalls = new Map | undefined }>(); /** Pending permission requests awaiting a renderer-side decision. */ private readonly _pendingPermissions = new Map>(); + /** Pending user input requests awaiting a renderer-side answer. */ + private readonly _pendingUserInputs = new Map }>; questionId: string }>(); /** File edit tracker for this session. */ private readonly _editTracker: FileEditTracker; /** Session database reference. */ @@ -142,6 +159,7 @@ export class CopilotAgentSession extends Disposable { this._editTracker = this._instantiationService.createInstance(FileEditTracker, sessionUri.toString(), this._databaseRef.object); this._register(toDisposable(() => this._denyPendingPermissions())); + this._register(toDisposable(() => this._cancelPendingUserInputs())); } /** @@ -152,6 +170,7 @@ export class CopilotAgentSession extends Disposable { async initializeSession(): Promise { this._wrapper = this._register(await this._wrapperFactory({ onPermissionRequest: request => this.handlePermissionRequest(request), + onUserInputRequest: (request, invocation) => this.handleUserInputRequest(request, invocation), hooks: { onPreToolUse: async input => { if (isEditTool(input.toolName)) { @@ -309,6 +328,86 @@ export class CopilotAgentSession extends Disposable { return false; } + // ---- user input handling ------------------------------------------------ + + /** + * Handles a user input request from the SDK (ask_user tool) by firing a + * `user_input_request` progress event and waiting for the renderer to + * respond via {@link respondToUserInputRequest}. + */ + async handleUserInputRequest( + request: IUserInputRequest, + _invocation: { sessionId: string }, + ): Promise { + const requestId = generateUuid(); + const questionId = generateUuid(); + this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${request.question.substring(0, 100)}"`); + + const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record }>(); + this._pendingUserInputs.set(requestId, { deferred, questionId }); + + // Build the protocol ISessionInputRequest from the SDK's simple format + const inputRequest: ISessionInputRequest = { + id: requestId, + message: request.question, + questions: [request.choices && request.choices.length > 0 + ? { + kind: SessionInputQuestionKind.SingleSelect, + id: questionId, + message: request.question, + required: true, + options: request.choices.map(c => ({ id: c, label: c })), + allowFreeformInput: request.allowFreeform ?? true, + } + : { + kind: SessionInputQuestionKind.Text, + id: questionId, + message: request.question, + required: true, + }, + ], + }; + + this._onDidSessionProgress.fire({ + session: this.sessionUri, + type: 'user_input_request', + request: inputRequest, + }); + + const result = await deferred.p; + this._logService.info(`[Copilot:${this.sessionId}] User input response: requestId=${requestId}, response=${result.response}`); + + if (result.response !== SessionInputResponseKind.Accept || !result.answers) { + return { answer: '', wasFreeform: true }; + } + + // Extract the answer for our single question + const answer = result.answers[questionId]; + if (!answer || answer.state === SessionInputAnswerState.Skipped) { + return { answer: '', wasFreeform: true }; + } + + const { value: val } = answer; + if (val.kind === SessionInputAnswerValueKind.Text) { + return { answer: val.value, wasFreeform: true }; + } else if (val.kind === SessionInputAnswerValueKind.Selected) { + const wasFreeform = !request.choices?.includes(val.value); + return { answer: val.value, wasFreeform }; + } + + return { answer: '', wasFreeform: true }; + } + + respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): boolean { + const pending = this._pendingUserInputs.get(requestId); + if (pending) { + this._pendingUserInputs.delete(requestId); + pending.deferred.complete({ response, answers }); + return true; + } + return false; + } + // ---- event wiring ------------------------------------------------------- private _subscribeToEvents(): void { @@ -590,4 +689,11 @@ export class CopilotAgentSession extends Disposable { } this._pendingPermissions.clear(); } + + private _cancelPendingUserInputs(): void { + for (const [, pending] of this._pendingUserInputs) { + pending.deferred.complete({ response: SessionInputResponseKind.Cancel }); + } + this._pendingUserInputs.clear(); + } } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 34f8de15e1bf6..2726b5230fa1f 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -382,7 +382,7 @@ export class ProtocolServerHandler extends Disposable { resource: s.session.toString(), provider: AgentSession.provider(s.session) ?? 'copilot', title: s.summary ?? 'Session', - status: SessionStatus.Idle, + status: s.status ?? SessionStatus.Idle, createdAt: s.startTime, modifiedAt: s.modifiedTime, workingDirectory: s.workingDirectory?.toString(), diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 31abf881c1549..d91734aa75685 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -619,4 +619,31 @@ suite('AgentSubscriptionManager', () => { ref1.dispose(); ref2.dispose(); }); + + test('getSubscriptionUnmanaged returns undefined when no subscription exists', () => { + const mgr = createManager(); + const result = mgr.getSubscriptionUnmanaged(URI.parse('copilot:/nonexistent')); + assert.strictEqual(result, undefined); + }); + + test('getSubscriptionUnmanaged returns existing subscription without affecting refcount', async () => { + const mgr = createManager(); + const uri = URI.parse(sessionUri); + + // Create a subscription via getSubscription + const ref = mgr.getSubscription(StateComponents.Session, uri); + await new Promise(r => setTimeout(r, 0)); + + // Get it unmanaged + const unmanaged = mgr.getSubscriptionUnmanaged(uri); + assert.ok(unmanaged); + assert.strictEqual(unmanaged, ref.object); + + // Dispose the ref — subscription should be released (refcount was 1) + ref.dispose(); + + // Now unmanaged should return undefined since it was released + const after = mgr.getSubscriptionUnmanaged(uri); + assert.strictEqual(after, undefined); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index 8e092d8cec4ed..2c63114fc4571 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -16,6 +16,7 @@ import type { IAgentToolCompleteEvent, IAgentToolStartEvent, IAgentUsageEvent, + IAgentUserInputRequestEvent, } from '../../common/agentService.js'; import type { IDeltaAction, @@ -23,6 +24,7 @@ import type { IResponsePartAction, ISessionAction, ISessionErrorAction, + ISessionInputRequestedAction, ITitleChangedAction, IToolCallCompleteAction, IToolCallReadyAction, @@ -30,7 +32,7 @@ import type { ITurnCompleteAction, IUsageAction, } from '../../common/state/sessionActions.js'; -import { ToolResultContentType, type IMarkdownResponsePart, type IReasoningResponsePart } from '../../common/state/sessionState.js'; +import { SessionInputQuestionKind, ToolResultContentType, type IMarkdownResponsePart, type IReasoningResponsePart, type ISessionInputRequest } from '../../common/state/sessionState.js'; import { AgentEventMapper } from '../../node/agentEventMapper.js'; /** Helper: flatten the result of mapProgressEventToActions into an array. */ @@ -312,4 +314,29 @@ suite('AgentEventMapper', () => { const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); assert.strictEqual(result, undefined); }); + + test('user_input_request event maps to session/inputRequested action', () => { + const request: ISessionInputRequest = { + id: 'req-1', + message: 'What is your name?', + questions: [{ + kind: SessionInputQuestionKind.Text, + id: 'q-1', + message: 'What is your name?', + required: true, + }], + }; + const event: IAgentUserInputRequestEvent = { + session, + type: 'user_input_request', + request, + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + const action = actions[0] as ISessionInputRequestedAction; + assert.strictEqual(action.type, 'session/inputRequested'); + assert.strictEqual(action.session, session.toString()); + assert.strictEqual(action.request, request); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b728d22027479..99e528a23f984 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -11,9 +11,10 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService, ILogService } from '../../../log/common/log.js'; import { IFileService } from '../../../files/common/files.js'; -import { AgentSession, IAgentProgressEvent } from '../../common/agentService.js'; +import { AgentSession, IAgentProgressEvent, IAgentUserInputRequestEvent } from '../../common/agentService.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind } from '../../common/state/sessionState.js'; import { CopilotAgentSession, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; @@ -342,4 +343,113 @@ suite('CopilotAgentSession', () => { } }); }); + + // ---- user input handling ---- + + suite('user input handling', () => { + + function assertUserInputEvent(event: IAgentProgressEvent): asserts event is IAgentUserInputRequestEvent { + assert.strictEqual(event.type, 'user_input_request'); + } + + test('handleUserInputRequest fires user_input_request progress event', async () => { + const { session, progressEvents } = await createAgentSession(disposables); + + // Start the request (don't await — it blocks waiting for response) + const resultPromise = session.handleUserInputRequest( + { question: 'What is your name?' }, + { sessionId: 'test-session-1' } + ); + + // Verify progress event was fired + assert.strictEqual(progressEvents.length, 1); + const event = progressEvents[0]; + assertUserInputEvent(event); + assert.strictEqual(event.request.message, 'What is your name?'); + const requestId = event.request.id; + assert.ok(event.request.questions); + const questionId = event.request.questions[0].id; + + // Respond to unblock the promise + session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { + [questionId]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: 'Alice' } + } + }); + + const result = await resultPromise; + assert.strictEqual(result.answer, 'Alice'); + assert.strictEqual(result.wasFreeform, true); + }); + + test('handleUserInputRequest with choices generates SingleSelect question', async () => { + const { session, progressEvents } = await createAgentSession(disposables); + + const resultPromise = session.handleUserInputRequest( + { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, + { sessionId: 'test-session-1' } + ); + + assert.strictEqual(progressEvents.length, 1); + const event = progressEvents[0]; + assertUserInputEvent(event); + assert.ok(event.request.questions); + assert.strictEqual(event.request.questions.length, 1); + assert.strictEqual(event.request.questions[0].kind, SessionInputQuestionKind.SingleSelect); + if (event.request.questions[0].kind === SessionInputQuestionKind.SingleSelect) { + assert.strictEqual(event.request.questions[0].options.length, 3); + assert.strictEqual(event.request.questions[0].options[0].label, 'red'); + } + + // Respond with a selected choice + const questions = event.request.questions; + session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Accept, { + [questions[0].id]: { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'blue' } + } + }); + + const result = await resultPromise; + assert.strictEqual(result.answer, 'blue'); + assert.strictEqual(result.wasFreeform, false); + }); + + test('handleUserInputRequest returns empty answer on cancel', async () => { + const { session, progressEvents } = await createAgentSession(disposables); + + const resultPromise = session.handleUserInputRequest( + { question: 'Cancel me' }, + { sessionId: 'test-session-1' } + ); + + const event = progressEvents[0]; + assertUserInputEvent(event); + session.respondToUserInputRequest(event.request.id, SessionInputResponseKind.Cancel); + + const result = await resultPromise; + assert.strictEqual(result.answer, ''); + assert.strictEqual(result.wasFreeform, true); + }); + + test('respondToUserInputRequest returns false for unknown id', async () => { + const { session } = await createAgentSession(disposables); + assert.strictEqual(session.respondToUserInputRequest('unknown-id', SessionInputResponseKind.Accept), false); + }); + + test('pending user inputs are cancelled on dispose', async () => { + const { session } = await createAgentSession(disposables); + + const resultPromise = session.handleUserInputRequest( + { question: 'Will be cancelled' }, + { sessionId: 'test-session-1' } + ); + + session.dispose(); + const result = await resultPromise; + assert.strictEqual(result.answer, ''); + assert.strictEqual(result.wasFreeform, true); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index e4cd1568dc0ca..f7c1af2637511 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -10,7 +10,7 @@ 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 IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; -import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; +import { CustomizationStatus, SessionInputResponseKind, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult } from '../../common/state/sessionState.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ export const MOCK_AUTO_TITLE = 'Automatically generated title'; @@ -99,6 +99,10 @@ export class MockAgent implements IAgent { this.respondToPermissionCalls.push({ requestId, approved }); } + respondToUserInputRequest(_requestId: string, _response: SessionInputResponseKind, _answers?: Record): void { + // no-op in mock + } + async changeModel(session: URI, model: string): Promise { this.changeModelCalls.push({ session, model }); } @@ -437,6 +441,10 @@ export class ScriptedMockAgent implements IAgent { } } + respondToUserInputRequest(_requestId: string, _response: SessionInputResponseKind, _answers?: Record): void { + // no-op in mock + } + async authenticate(_resource: string, _token: string): Promise { return true; } diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts new file mode 100644 index 0000000000000..3368b870f3504 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { sessionReducer } from '../../common/state/protocol/reducers.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type ISessionState } from '../../common/state/sessionState.js'; + +function makeSession(): ISessionState { + return { + summary: { + resource: 'copilot:/test', + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }, + lifecycle: SessionLifecycle.Ready, + turns: [], + }; +} + +function withActiveTurnAndToolCall(state: ISessionState): ISessionState { + state = sessionReducer(state, { + type: ActionType.SessionTurnStarted, + session: 'copilot:/test', + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + state = sessionReducer(state, { + type: ActionType.SessionToolCallStart, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + toolName: 'readFile', + displayName: 'Read File', + }); + return state; +} + +suite('sessionReducer – summaryStatus with tool call confirmations and input requests', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('SessionStatus is InputNeeded when a tool call is PendingConfirmation', () => { + let state = withActiveTurnAndToolCall(makeSession()); + + // Transition to PendingConfirmation (no `confirmed` field) + state = sessionReducer(state, { + type: ActionType.SessionToolCallReady, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + invocationMessage: 'Read file?', + toolInput: '/foo.ts', + }); + + assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); + }); + + test('SessionStatus is InputNeeded when a tool call is PendingResultConfirmation', () => { + let state = withActiveTurnAndToolCall(makeSession()); + + // Transition to Running first + state = sessionReducer(state, { + type: ActionType.SessionToolCallReady, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + invocationMessage: 'Read file', + toolInput: '/foo.ts', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + // Then complete with requiresResultConfirmation + state = sessionReducer(state, { + type: ActionType.SessionToolCallComplete, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + requiresResultConfirmation: true, + result: { + success: true, + pastTenseMessage: 'Read file', + }, + }); + + assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); + }); + + test('SessionStatus transitions from InputNeeded to InProgress when tool call is confirmed', () => { + let state = withActiveTurnAndToolCall(makeSession()); + + // Transition to PendingConfirmation + state = sessionReducer(state, { + type: ActionType.SessionToolCallReady, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + invocationMessage: 'Read file?', + toolInput: '/foo.ts', + }); + assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); + + // Confirm it + state = sessionReducer(state, { + type: ActionType.SessionToolCallConfirmed, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }); + + assert.strictEqual(state.summary.status, SessionStatus.InProgress); + }); + + test('SessionStatus is InputNeeded with inputRequests', () => { + let state = withActiveTurnAndToolCall(makeSession()); + + state = sessionReducer(state, { + type: ActionType.SessionInputRequested, + session: 'copilot:/test', + request: { + id: 'req-1', + message: 'What is your name?', + questions: [{ + kind: SessionInputQuestionKind.Text, + id: 'q-1', + message: 'What is your name?', + required: true, + }], + }, + }); + + assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); + }); + + test('SessionStatus transitions from InputNeeded to InProgress after SessionInputCompleted', () => { + let state = withActiveTurnAndToolCall(makeSession()); + + // Add an input request + state = sessionReducer(state, { + type: ActionType.SessionInputRequested, + session: 'copilot:/test', + request: { + id: 'req-1', + message: 'What is your name?', + questions: [{ + kind: SessionInputQuestionKind.Text, + id: 'q-1', + message: 'What is your name?', + required: true, + }], + }, + }); + assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); + + // Complete the input request + state = sessionReducer(state, { + type: ActionType.SessionInputCompleted, + session: 'copilot:/test', + requestId: 'req-1', + response: SessionInputResponseKind.Accept, + answers: { 'q-1': { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Text, value: 'Alice' } } }, + }); + + assert.strictEqual(state.summary.status, SessionStatus.InProgress); + }); + + test('Tool call transition to PendingConfirmation updates summary status to InputNeeded', () => { + let state = withActiveTurnAndToolCall(makeSession()); + + // After SessionToolCallStart, status should be InProgress (tool is Streaming) + assert.strictEqual(state.summary.status, SessionStatus.InProgress); + + // Transition to PendingConfirmation via SessionToolCallReady (no confirmed) + state = sessionReducer(state, { + type: ActionType.SessionToolCallReady, + session: 'copilot:/test', + turnId: 'turn-1', + toolCallId: 'tc-1', + invocationMessage: 'Read file?', + toolInput: '/foo.ts', + }); + + assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 24295a2685eed..038d99deebea2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -20,17 +20,18 @@ import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/co import { ICustomizationRef, type IProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ISessionTurnStartedAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { AttachmentType, getToolFileEdits, PendingMessageKind, ResponsePartKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMessageAttachment, type IRootState, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, getToolFileEdits, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMessageAttachment, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { ChatRequestQueueKind, IChatProgress, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatSingleSelectAnswer } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; @@ -44,6 +45,54 @@ import { activeTurnToProgress, finalizeToolInvocation, toolCallStateToInvocation // turnCancelled) back to the server. // ============================================================================= +/** + * Converts carousel answers (IChatQuestionAnswers) to protocol + * ISessionInputAnswer records, handling text, single-select, + * and multi-select answer shapes. + */ +export function convertCarouselAnswers(raw: IChatQuestionAnswers): Record { + const answers: Record = {}; + for (const [qId, answer] of Object.entries(raw)) { + if (typeof answer === 'string') { + answers[qId] = { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: answer }, + }; + } else if (answer && typeof answer === 'object') { + const multi = answer as IChatMultiSelectAnswer; + const single = answer as IChatSingleSelectAnswer; + if (Array.isArray(multi.selectedValues)) { + // Multi-select answer + answers[qId] = { + state: SessionInputAnswerState.Submitted, + value: { + kind: SessionInputAnswerValueKind.SelectedMany, + value: multi.selectedValues, + freeformValues: multi.freeformValue ? [multi.freeformValue] : undefined, + }, + }; + } else if (single.selectedValue) { + // Single-select answer + answers[qId] = { + state: SessionInputAnswerState.Submitted, + value: { + kind: SessionInputAnswerValueKind.Selected, + value: single.selectedValue, + freeformValues: single.freeformValue ? [single.freeformValue] : undefined, + }, + }; + } else if (single.freeformValue) { + // Freeform-only answer (no selection) + answers[qId] = { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: single.freeformValue }, + }; + } + } + } + return answers; +} + // ============================================================================= // Chat session // ============================================================================= @@ -844,6 +893,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Track live ChatToolInvocation objects for this turn const activeToolInvocations = new Map(); + // Track live input request carousels to cancel if they disappear from state + const activeInputRequests = new Map(); + // Track last-emitted content lengths per response part to compute deltas const lastEmittedLengths = new Map(); @@ -968,6 +1020,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + // Process input requests (ask_user tool elicitations) + this._syncInputRequests(activeInputRequests, sessionState.inputRequests, session, cancellationToken, progress); + // If the turn is no longer active, emit any error and finish. if (!isActive) { const lastTurn = sessionState.turns.find((t: ITurn) => t.id === turnId); @@ -1034,6 +1089,142 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } + // ---- Input request handling --------------------------------------------- + + /** + * Syncs the set of active input request carousels against the current + * session state. Cancels carousels whose requests disappeared and creates + * new carousels for newly appeared requests. + */ + private _syncInputRequests( + active: Map, + inputRequests: readonly ISessionInputRequest[] | undefined, + session: URI, + token: CancellationToken, + progress: (items: IChatProgress[]) => void, + ): void { + const currentIds = new Set(inputRequests?.map(r => r.id)); + for (const [id, carousel] of active) { + if (!currentIds.has(id)) { + carousel.completion.complete({ answers: undefined }); + active.delete(id); + } + } + if (inputRequests) { + for (const inputReq of inputRequests) { + if (!active.has(inputReq.id)) { + active.set(inputReq.id, this._handleInputRequest(inputReq, session, token, progress)); + } + } + } + } + + /** + * Creates a question carousel for a session input request and dispatches + * the `SessionInputCompleted` action when the user answers or cancels. + */ + private _handleInputRequest( + inputReq: ISessionInputRequest, + session: URI, + cancellationToken: CancellationToken, + progress: (items: IChatProgress[]) => void, + ): ChatQuestionCarouselData { + const questions: IChatQuestion[] = (inputReq.questions ?? []).map((q): IChatQuestion => { + switch (q.kind) { + case SessionInputQuestionKind.SingleSelect: + return { + id: q.id, + type: 'singleSelect', + title: q.title ?? q.message, + description: q.title !== undefined ? q.message : undefined, + required: q.required, + allowFreeformInput: q.allowFreeformInput ?? true, + options: q.options.map(o => ({ id: o.id, label: o.label, value: o.id })), + }; + case SessionInputQuestionKind.MultiSelect: + return { + id: q.id, + type: 'multiSelect', + title: q.title ?? q.message, + description: q.title !== undefined ? q.message : undefined, + required: q.required, + allowFreeformInput: q.allowFreeformInput ?? true, + options: q.options.map(o => ({ id: o.id, label: o.label, value: o.id })), + }; + case SessionInputQuestionKind.Text: + return { + id: q.id, + type: 'text', + title: q.title ?? q.message, + description: q.title !== undefined ? q.message : undefined, + required: q.required, + defaultValue: q.defaultValue, + }; + default: + return { + id: q.id, + type: 'text', + title: q.title ?? q.message, + description: q.title !== undefined ? q.message : undefined, + required: q.required, + }; + } + }); + + if (questions.length === 0) { + // Fallback for input requests with no structured questions — + // create a single text question from the message. + questions.push({ + id: 'answer', + type: 'text', + title: inputReq.message, + required: true, + }); + } + + const carousel = new ChatQuestionCarouselData( + questions, + /* allowSkip */ true, + /* resolveId */ undefined, + /* data */ undefined, + /* isUsed */ undefined, + /* message */ new MarkdownString(inputReq.message), + ); + + progress([carousel]); + + if (cancellationToken.isCancellationRequested) { + carousel.completion.complete({ answers: undefined }); + } else { + const tokenListener = cancellationToken.onCancellationRequested(() => { + carousel.completion.complete({ answers: undefined }); + }); + carousel.completion.p.finally(() => tokenListener.dispose()); + } + + carousel.completion.p.then(result => { + if (!result.answers) { + this._config.connection.dispatch({ + type: ActionType.SessionInputCompleted, + session: session.toString(), + requestId: inputReq.id, + response: SessionInputResponseKind.Cancel, + }); + } else { + const answers = convertCarouselAnswers(result.answers); + this._config.connection.dispatch({ + type: ActionType.SessionInputCompleted, + session: session.toString(), + requestId: inputReq.id, + response: SessionInputResponseKind.Accept, + answers, + }); + } + }); + + return carousel; + } + // ---- Reconnection to active turn ---------------------------------------- /** @@ -1098,6 +1289,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + // Track live input request carousels for reconnection + const activeInputRequests = new Map(); + const appendProgress = (parts: IChatProgress[]) => chatSession.appendProgress(parts); + + // Restore any pending input requests from the initial state + this._syncInputRequests(activeInputRequests, currentState?.inputRequests, backendSession, cts.token, appendProgress); + // Process state changes from the protocol layer. const processStateChange = (sessionState: ISessionState) => { const activeTurn = sessionState.activeTurn; @@ -1176,6 +1374,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + // Process input requests + this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, cts.token, appendProgress); + // If the turn is no longer active, emit any error and finish. if (!isActive) { const lastTurn = sessionState.turns.find(t => t.id === turnId); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index bc6b05bfebbd7..307c81f1f634d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { IProductService } from '../../../../../../platform/product/common/productService.js'; -import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { ISessionFileDiff } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionStatus, StateComponents, type ISessionFileDiff, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ChatSessionStatus, IChatSessionFileChange2, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; @@ -26,6 +26,19 @@ function mapDiffsToChanges(diffs: readonly ISessionFileDiff[] | readonly { reado })); } +function mapSessionStatus(status: SessionStatus | undefined): ChatSessionStatus { + if (status === SessionStatus.InputNeeded) { + return ChatSessionStatus.NeedsInput; + } + if (status !== undefined && (status & SessionStatus.InProgress)) { + return ChatSessionStatus.InProgress; + } + if (status === SessionStatus.Error) { + return ChatSessionStatus.Failed; + } + return ChatSessionStatus.Completed; +} + /** * Provides session list items for the chat sessions sidebar by querying * active sessions from an agent host connection. Listens to protocol @@ -40,6 +53,8 @@ export class AgentHostSessionListController extends Disposable implements IChatS readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; private _items: IChatSessionItem[] = []; + /** Last-seen summary per session (by identity) to avoid redundant updates. */ + private readonly _lastSummary = new Map(); constructor( private readonly _sessionType: string, @@ -56,20 +71,14 @@ export class AgentHostSessionListController extends Disposable implements IChatS if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { const rawId = AgentSession.id(n.summary.resource); const workingDir = typeof n.summary.workingDirectory === 'string' ? URI.parse(n.summary.workingDirectory) : undefined; - const item: IChatSessionItem = { - resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), - label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`, - description: this._description, - iconPath: getAgentHostIcon(this._productService), - status: ChatSessionStatus.Completed, - metadata: this._buildMetadata(workingDir), - timing: { - created: n.summary.createdAt, - lastRequestStarted: n.summary.modifiedAt, - lastRequestEnded: n.summary.modifiedAt, - }, - changes: mapDiffsToChanges(n.summary.diffs, this._connectionAuthority), - }; + const item = this._makeItem(rawId, { + title: n.summary.title, + status: n.summary.status, + workingDirectory: workingDir, + createdAt: n.summary.createdAt, + modifiedAt: n.summary.modifiedAt, + diffs: n.summary.diffs, + }); this._items.push(item); this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); } else if (n.type === 'notify/sessionRemoved' && AgentSession.provider(n.session) === this._provider) { @@ -77,17 +86,45 @@ export class AgentHostSessionListController extends Disposable implements IChatS const idx = this._items.findIndex(item => item.resource.path === `/${removedId}`); if (idx >= 0) { const [removed] = this._items.splice(idx, 1); + this._lastSummary.delete(removedId); this._onDidChangeChatSessionItems.fire({ removed: [removed.resource] }); } } })); - // Refresh on turnComplete and diffsChanged actions for metadata updates + // Update items from live session state when actions arrive this._register(this._connection.onDidAction(e => { - if ((e.action.type === 'session/turnComplete' || e.action.type === 'session/diffsChanged') && isSessionAction(e.action) && AgentSession.provider(e.action.session) === this._provider) { - const cts = new CancellationTokenSource(); - this.refresh(cts.token).finally(() => cts.dispose()); + if (!isSessionAction(e.action)) { + return; + } + if (AgentSession.provider(e.action.session) !== this._provider) { + return; + } + + + // Peek at the subscription — if nothing is subscribed, skip + const state = this._connection.getSubscriptionUnmanaged(StateComponents.Session, URI.parse(e.action.session))?.value; + if (!state || state instanceof Error) { + return; + } + + const rawId = AgentSession.id(e.action.session); + + // Object identity check — the reducer produces new summary + // objects only when fields change. + if (this._lastSummary.get(rawId) === state.summary) { + return; } + this._lastSummary.set(rawId, state.summary); + + const item = this._makeItemFromSummary(rawId, state.summary, state.summary.diffs); + const idx = this._items.findIndex(i => i.resource.path === `/${rawId}`); + if (idx >= 0) { + this._items[idx] = item; + } else { + this._items.unshift(item); + } + this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); })); } @@ -99,20 +136,13 @@ export class AgentHostSessionListController extends Disposable implements IChatS try { const sessions = await this._connection.listSessions(); const filtered = sessions.filter(s => AgentSession.provider(s.session) === this._provider); - const rawId = (s: typeof filtered[0]) => AgentSession.id(s.session); - this._items = filtered.map(s => ({ - resource: URI.from({ scheme: this._sessionType, path: `/${rawId(s)}` }), - label: s.summary ?? `Session ${rawId(s).substring(0, 8)}`, - description: this._description, - iconPath: getAgentHostIcon(this._productService), - status: ChatSessionStatus.Completed, - metadata: this._buildMetadata(s.workingDirectory), - timing: { - created: s.startTime, - lastRequestStarted: s.modifiedTime, - lastRequestEnded: s.modifiedTime, - }, - changes: mapDiffsToChanges(s.diffs, this._connectionAuthority), + this._items = filtered.map(s => this._makeItem(AgentSession.id(s.session), { + title: s.summary, + status: s.status, + workingDirectory: s.workingDirectory, + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + diffs: s.diffs, })); } catch { this._items = []; @@ -120,6 +150,42 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } + private _makeItemFromSummary(rawId: string, summary: ISessionSummary, diffs: ISessionFileDiff[] | undefined): IChatSessionItem { + const workingDir = typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : summary.workingDirectory; + return this._makeItem(rawId, { + title: summary.title, + status: summary.status, + workingDirectory: workingDir, + createdAt: summary.createdAt, + modifiedAt: summary.modifiedAt, + diffs, + }); + } + + private _makeItem(rawId: string, opts: { + title?: string; + status?: SessionStatus; + workingDirectory?: URI; + createdAt: number; + modifiedAt: number; + diffs?: readonly ISessionFileDiff[] | readonly { readonly uri: string; readonly added?: number; readonly removed?: number }[]; + }): IChatSessionItem { + return { + resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), + label: opts.title ?? `Session ${rawId.substring(0, 8)}`, + description: this._description, + iconPath: getAgentHostIcon(this._productService), + status: mapSessionStatus(opts.status), + metadata: this._buildMetadata(opts.workingDirectory), + timing: { + created: opts.createdAt, + lastRequestStarted: opts.modifiedAt, + lastRequestEnded: opts.modifiedAt, + }, + changes: mapDiffsToChanges(opts.diffs, this._connectionAuthority), + }; + } + private _buildMetadata(workingDirectory?: URI): { readonly [key: string]: unknown } | undefined { if (!this._description) { return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 1b525b734c081..9c8f30ccc1db2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -151,6 +151,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.getSubscription(kind, resource); } + getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined { + return this._inner.getSubscriptionUnmanaged(kind, resource); + } + dispatch(action: ISessionAction | ITerminalAction): void { this._log('>>', 'dispatch', action); this._inner.dispatch(action); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 2a517daac018d..7573b3ba34c40 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -187,6 +187,20 @@ class MockAgentHostService extends mock() { }, }; } + override getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { + const entry = this._liveSubscriptions.get(resource.toString()); + if (!entry) { + return undefined; + } + const self = this; + return { + get value() { return self._liveSubscriptions.get(resource.toString())?.state as unknown as T; }, + get verifiedValue() { return self._liveSubscriptions.get(resource.toString())?.state as unknown as T; }, + onDidChange: entry.emitter.event as unknown as Event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } satisfies IAgentSubscription; + } override dispatch(action: ISessionAction | ITerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); // Apply state-management actions optimistically so state-dependent diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/convertCarouselAnswers.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/convertCarouselAnswers.test.ts new file mode 100644 index 0000000000000..4cf838d5fbb67 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/convertCarouselAnswers.test.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { convertCarouselAnswers } from '../../../browser/agentSessions/agentHost/agentHostSessionHandler.js'; + +suite('convertCarouselAnswers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('converts string answer to text', () => { + const result = convertCarouselAnswers({ 'q1': 'hello' }); + assert.deepStrictEqual(result, { + 'q1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: 'hello' } + } + }); + }); + + test('converts single-select answer', () => { + const result = convertCarouselAnswers({ 'q1': { selectedValue: 'opt-1' } }); + assert.deepStrictEqual(result, { + 'q1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'opt-1', freeformValues: undefined } + } + }); + }); + + test('converts single-select answer with freeform', () => { + const result = convertCarouselAnswers({ 'q1': { selectedValue: 'opt-1', freeformValue: 'custom' } }); + assert.deepStrictEqual(result, { + 'q1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Selected, value: 'opt-1', freeformValues: ['custom'] } + } + }); + }); + + test('converts multi-select answer', () => { + const result = convertCarouselAnswers({ 'q1': { selectedValues: ['a', 'b'] } }); + assert.deepStrictEqual(result, { + 'q1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.SelectedMany, value: ['a', 'b'], freeformValues: undefined } + } + }); + }); + + test('converts multi-select answer with freeform', () => { + const result = convertCarouselAnswers({ 'q1': { selectedValues: ['a'], freeformValue: 'extra' } }); + assert.deepStrictEqual(result, { + 'q1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.SelectedMany, value: ['a'], freeformValues: ['extra'] } + } + }); + }); + + test('converts freeform-only answer', () => { + const result = convertCarouselAnswers({ 'q1': { freeformValue: 'something' } }); + assert.deepStrictEqual(result, { + 'q1': { + state: SessionInputAnswerState.Submitted, + value: { kind: SessionInputAnswerValueKind.Text, value: 'something' } + } + }); + }); + + test('handles multiple questions', () => { + const result = convertCarouselAnswers({ + 'q1': 'text', + 'q2': { selectedValue: 'opt' }, + 'q3': { selectedValues: ['a'] }, + }); + assert.strictEqual(Object.keys(result).length, 3); + assert.strictEqual(result['q1'].state, SessionInputAnswerState.Submitted); + assert.strictEqual(result['q2'].state, SessionInputAnswerState.Submitted); + assert.strictEqual(result['q3'].state, SessionInputAnswerState.Submitted); + }); + + test('skips empty object answers', () => { + const result = convertCarouselAnswers({ 'q1': {} as Record }); + assert.strictEqual(Object.keys(result).length, 0); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 28f1f4084062a..2f189a9c9a7c8 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -110,6 +110,9 @@ class MockAgentConnection implements IAgentConnection { dispose: () => { listener.dispose(); onDidChange.dispose(); onWillApplyAction.dispose(); onDidApplyAction.dispose(); }, }; } + getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { + return undefined; + } dispatch(action: ISessionAction | ITerminalAction): void { this.dispatchedActions.push(action); }