Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export type CopilotCLICommand = 'compact' | 'plan' | 'fleet' | 'remote';
export const copilotCLICommands: readonly CopilotCLICommand[] = ['compact', 'plan', 'fleet', 'remote'] as const;

export class CopilotCLIQuotaExceededError extends Error {
constructor(message: string) {
constructor(message: string, readonly code?: string) {
super(message);
this.name = 'CopilotCLIQuotaExceededError';
}
Expand Down Expand Up @@ -1174,6 +1174,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
const editTracker = new ExternalEditTracker();
let sdkRequestId: string | undefined;
let isQuotaError = false;
let quotaErrorCode: string | undefined;
const toolIdEditMap = new Map<string, Promise<string | undefined>>();
const remoteMode = isMissionControlCommandSource(input.source) ? this._mcState?.mcMode : undefined;
const effectivePermissionLevel = remoteMode ? (remoteMode === 'autopilot' ? 'autopilot' : undefined) : this._permissionLevel;
Expand Down Expand Up @@ -1564,6 +1565,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes

if (event.data.errorType === 'quota' || event.data.statusCode === 402) {
isQuotaError = true;
quotaErrorCode = event.data.errorCode;
} else {
requestStream?.markdown(l10n.t('\n\nError: ({0}) {1}', event.data.errorType, event.data.message));
}
Expand Down Expand Up @@ -1631,7 +1633,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
isUsageBasedBilling = copilotToken.tokenBasedBilling;
quotaResetDate = copilotToken.quotaInfo.quota_reset_date;
} catch { /* token unavailable */ }
throw new CopilotCLIQuotaExceededError(getQuotaMessageForPlan(plan, isUsageBasedBilling, quotaResetDate));
throw new CopilotCLIQuotaExceededError(getQuotaMessageForPlan(plan, isUsageBasedBilling, quotaResetDate), quotaErrorCode);
}
this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`);
const resolvedToolIdEditMap: Record<string, string> = {};
Expand Down Expand Up @@ -1674,7 +1676,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
isUsageBasedBilling = copilotToken.tokenBasedBilling;
quotaResetDate = copilotToken.quotaInfo.quota_reset_date;
} catch { /* token unavailable */ }
throw new CopilotCLIQuotaExceededError(getQuotaMessageForPlan(plan, isUsageBasedBilling, quotaResetDate));
throw new CopilotCLIQuotaExceededError(getQuotaMessageForPlan(plan, isUsageBasedBilling, quotaResetDate), quotaErrorCode);
}
this._status = ChatSessionStatus.Failed;
this._statusChange.fire(this._status);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
return {};
}
if (ex instanceof CopilotCLIQuotaExceededError) {
return { errorDetails: { message: ex.message, isQuotaExceeded: true } };
return { errorDetails: { message: ex.message, isQuotaExceeded: true, ...(ex.code ? { quotaExceededCode: ex.code } : {}) } };
}
throw ex;
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
if (ex instanceof CopilotCLIQuotaExceededError) {
notifySessionChange = false;
return { errorDetails: { message: ex.message, isQuotaExceeded: true } };
return { errorDetails: { message: ex.message, isQuotaExceeded: true, ...(ex.code ? { quotaExceededCode: ex.code } : {}) } };
}
throw ex;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,12 @@ export class InlineChatIntent implements IIntent {
return {
errorDetails: {
message: details.message,
responseIsFiltered: details.responseIsFiltered
responseIsFiltered: details.responseIsFiltered,
// Forward the structured quota signal so core builds the
// single, plan-aware quota message (rendered by the inline
// chat controller from `errorDetails.message`).
isQuotaExceeded: details.isQuotaExceeded,
quotaExceededCode: details.quotaExceededCode,
}
};
}
Expand Down
2 changes: 1 addition & 1 deletion extensions/copilot/src/platform/chat/common/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ function getErrorDetailsFromChatFetchErrorInner(fetchResult: ChatFetchError, cop
details = {
message: getQuotaHitMessage(fetchResult, copilotPlan, isUsageBasedBilling, quotaResetDate),
isQuotaExceeded: true,
...(fetchResult.capiError?.code && { code: fetchResult.capiError.code }),
...(fetchResult.capiError?.code && { code: fetchResult.capiError.code, quotaExceededCode: fetchResult.capiError.code }),
};
break;
case ChatFetchResponseType.BadRequest:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2129,6 +2129,7 @@ export class CopilotAgentSession extends Disposable {
errorType: e.data.errorType,
message: e.data.message,
stack: e.data.stack,
code: e.data.errorCode,
},
});
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderSe
import { AgentHostSnapshotController } from './agentHostSnapshotController.js';
import { toolDataToDefinition } from './agentHostToolUtils.js';
import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js';
import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js';
import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, errorInfoToChatErrorDetails, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js';
export { toolDataToDefinition };

// =============================================================================
Expand Down Expand Up @@ -111,6 +111,16 @@ interface IObserveTurnOptions {
* suppressed to preserve legacy behavior.
*/
readonly subAgentInvocationId?: string;
/**
* When set, a terminal turn error is NOT emitted into {@link sink} as a
* plain "Error: (...)" markdown part. Instead the caller is expected to
* surface it as structured {@link IChatAgentResult.errorDetails} (see
* {@link errorInfoToChatErrorDetails}). Used by the live invoke path so a
* `quota` error (e.g. an upstream 402) renders the rich quota widget
* rather than inline markdown. Reconnect / server-initiated paths leave
* this unset and keep the markdown fallback.
*/
readonly errorsAsResult?: boolean;
}

interface IStartServerRequestOptions {
Expand Down Expand Up @@ -835,8 +845,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC

const completedTurn = await this._handleTurn(resolvedSession, request, progress, cancellationToken);
const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn);
const errorDetails = completedTurn?.state === TurnState.Error && completedTurn.error
? errorInfoToChatErrorDetails(completedTurn.error)
: undefined;

return details ? { details } : {};
return {
...(details ? { details } : {}),
...(errorDetails ? { errorDetails } : {}),
};
}

/**
Expand Down Expand Up @@ -1248,6 +1264,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
turnId,
sink: progress,
cancellationToken,
errorsAsResult: true,
onTurnEnded: (lastTurn) => {
store.dispose();
this._clientDispatchedTurnIds.delete(turnId);
Expand Down Expand Up @@ -1482,7 +1499,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
if (!seenActive) {
return;
}
if (lastTurn?.state === TurnState.Error && lastTurn.error) {
if (lastTurn?.state === TurnState.Error && lastTurn.error && !opts.errorsAsResult) {
opts.sink([{ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${lastTurn.error.errorType}) ${lastTurn.error.message}`) }]);
}
finish(lastTurn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../
import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js';
import { URI } from '../../../../../../base/common/uri.js';
import { generateUuid } from '../../../../../../base/common/uuid.js';
import { MessageKind, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type ActiveTurn, type ICompletedToolCall, type Message, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { MessageKind, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type ActiveTurn, type ErrorInfo, type ICompletedToolCall, type Message, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js';
import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js';
import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/agentFeedbackAttachments.js';
import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { type ChatExternalEditKind, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js';
import { type ChatExternalEditKind, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js';
import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js';
import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js';
import { type IChatRequestVariableData } from '../../../common/model/chatModel.js';
Expand Down Expand Up @@ -134,6 +134,38 @@ export function usageInfoToChatUsage(usage: UsageInfo | undefined): IChatUsage |
};
}

/**
* Well-known agent host error categories ({@link ErrorInfo.errorType}). These
* mirror the upstream Copilot SDK `session.error` `errorType` categories that
* survive into the protocol's {@link ErrorInfo} (e.g. `authentication`,
* `authorization`, `quota`, `rate_limit`, `context_limit`, `query`). Only the
* ones we map to structured chat error flags are enumerated here.
*/
const enum AgentHostErrorType {
Quota = 'quota',
RateLimit = 'rate_limit',
}

/**
* Maps a protocol {@link ErrorInfo} from a failed turn into structured chat
* {@link IChatResponseErrorDetails}, so the chat UI can render the rich quota
* widget (`ChatQuotaExceededPart`) for `quota` errors (e.g. an upstream 402)
* instead of a plain "Error: (...)" markdown line. The same applies to
* rate-limit errors via {@link IChatResponseErrorDetails.isRateLimited}.
*
* The user-facing quota message is intentionally NOT built here: core
* substitutes a friendly, plan-aware message for any result flagged
* {@link IChatResponseErrorDetails.isQuotaExceeded} (see `chatServiceImpl`),
* so every provider's 402 renders identically.
*/
export function errorInfoToChatErrorDetails(error: ErrorInfo): IChatResponseErrorDetails {
return {
message: error.message,
...(error.errorType === AgentHostErrorType.Quota ? { isQuotaExceeded: true, ...(error.code ? { quotaExceededCode: error.code } : {}) } : {}),
...(error.errorType === AgentHostErrorType.RateLimit ? { isRateLimited: true } : {}),
};
}

/**
* Converts completed turns from the protocol state into session history items.
*
Expand Down
Loading