Skip to content
Merged
2 changes: 1 addition & 1 deletion extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4507,7 +4507,7 @@
},
"github.copilot.chat.agentHistorySummarizationInline": {
"type": "boolean",
"default": false,
"default": true,
"markdownDescription": "%github.copilot.config.agentHistorySummarizationInline%",
"tags": [
"advanced",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -778,8 +778,8 @@ class ConversationHistorySummarizer {
private async handleSummarizationResponse(response: ChatResponse, mode: SummaryMode, elapsedTime: number, promptTypes?: string): Promise<FetchSuccess<string>> {
if (response.type !== ChatFetchResponseType.Success) {
const outcome = response.type;
this.sendSummarizationTelemetry(outcome, response.requestId, this.props.endpoint.model, mode, elapsedTime, undefined, response.reason);
this.logInfo(`Summarization request failed. ${response.type} ${response.reason}`, mode);
this.sendSummarizationTelemetry(outcome, response.requestId, this.props.endpoint.model, mode, elapsedTime, undefined, response.reason ?? response.type);
this.logInfo(`Summarization request failed. ${response.type} ${response.reason ?? response.type}`, mode);
if (response.type === ChatFetchResponseType.Canceled) {
throw new CancellationError();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ export namespace ConfigKey {

export const InstantApplyShortModelName = defineAndMigrateExpSetting<string>('chat.advanced.instantApply.shortContextModelName', 'chat.instantApply.shortContextModelName', CHAT_MODEL.SHORT_INSTANT_APPLY);
export const InstantApplyShortContextLimit = defineAndMigrateExpSetting<number>('chat.advanced.instantApply.shortContextLimit', 'chat.instantApply.shortContextLimit', 8000);
export const AgentHistorySummarizationInline = defineAndMigrateExpSetting<boolean>('chat.advanced.agentHistorySummarizationInline', 'chat.agentHistorySummarizationInline', false);
export const AgentHistorySummarizationInline = defineAndMigrateExpSetting<boolean>('chat.advanced.agentHistorySummarizationInline', 'chat.agentHistorySummarizationInline', true);
export const PromptFileContext = defineAndMigrateExpSetting<boolean>('chat.advanced.promptFileContextProvider.enabled', 'chat.promptFileContextProvider.enabled', true);
export const DefaultToolsGrouped = defineAndMigrateExpSetting<boolean>('chat.advanced.tools.defaultToolsGrouped', 'chat.tools.defaultToolsGrouped', false);
export const Gpt5AlternativePatch = defineAndMigrateExpSetting<boolean>('chat.advanced.gpt5AlternativePatch', 'chat.gpt5AlternativePatch', false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,15 +397,14 @@ export function getVerbosityForModelSync(model: IChatEndpoint): 'low' | 'medium'
* - Claude Opus 4.5 (claude-opus-4-5-* or claude-opus-4.5-*)
* - Claude Opus 4.6 (claude-opus-4-6-* or claude-opus-4.6-*)
* - Claude Opus 4.7 (claude-opus-4-7-* or claude-opus-4.7-*)
* - OpenAI gpt-5.4 (gpt-5.4-*), but only when the `ResponsesApiToolSearchEnabled` setting is enabled
* - OpenAI gpt-5.4/gpt-5.5, but only when the `ResponsesApiToolSearchEnabled` setting is enabled
*/
export function modelSupportsToolSearch(modelId: string, configurationService?: IConfigurationService, experimentationService?: IExperimentationService): boolean {
const lower = modelId.toLowerCase();
if (isGpt54(lower)) {
const normalized = modelId.toLowerCase().replace(/\./g, '-');
if (isResponsesApiToolSearchModelId(normalized)) {
return !!configurationService && !!experimentationService && isResponsesApiToolSearchEnabled(modelId, configurationService, experimentationService);
}

const normalized = lower.replace(/\./g, '-');
return normalized.startsWith('claude-sonnet-4-5') ||
normalized.startsWith('claude-sonnet-4-6') ||
normalized.startsWith('claude-opus-4-5') ||
Expand All @@ -414,12 +413,18 @@ export function modelSupportsToolSearch(modelId: string, configurationService?:
isHiddenModelG(modelId);
}

function isResponsesApiToolSearchModelId(normalizedModelId: string): boolean {
return normalizedModelId.startsWith('gpt-5-4') || normalizedModelId.startsWith('gpt-5-5') || normalizedModelId.startsWith('gpt5-5');
}

export function isResponsesApiToolSearchEnabled(
endpoint: IChatEndpoint | string,
configurationService: IConfigurationService,
experimentationService: IExperimentationService,
): boolean {
return isGpt54(endpoint) && configurationService.getExperimentBasedConfig(ConfigKey.ResponsesApiToolSearchEnabled, experimentationService);
const modelId = typeof endpoint === 'string' ? endpoint : endpoint.model;
const normalized = modelId.toLowerCase().replace(/\./g, '-');
return isResponsesApiToolSearchModelId(normalized) && configurationService.getExperimentBasedConfig(ConfigKey.ResponsesApiToolSearchEnabled, experimentationService);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,19 @@ describe('modelSupportsToolSearch', () => {
expect(modelSupportsToolSearch('claude-3-opus')).toBe(false);
});

test('supports OpenAI gpt-5.4 models when the setting is enabled', () => {
test('supports OpenAI gpt-5.4 and gpt-5.5 models when the setting is enabled', () => {
const configurationService = {
getExperimentBasedConfig: (key: unknown) => key === ConfigKey.ResponsesApiToolSearchEnabled,
} as unknown as IConfigurationService;
const experimentationService = {} as IExperimentationService;

expect(modelSupportsToolSearch('gpt-5.4', configurationService, experimentationService)).toBe(true);
expect(modelSupportsToolSearch('gpt-5.4-preview', configurationService, experimentationService)).toBe(true);
expect(modelSupportsToolSearch('gpt-5.5', configurationService, experimentationService)).toBe(true);
expect(modelSupportsToolSearch('gpt-5.5-preview', configurationService, experimentationService)).toBe(true);
expect(modelSupportsToolSearch('gpt5.5-preview', configurationService, experimentationService)).toBe(true);
expect(modelSupportsToolSearch('gpt-5.4')).toBe(false);
expect(modelSupportsToolSearch('gpt-5.5')).toBe(false);
});

test('rejects other non-Claude models', () => {
Expand Down
12 changes: 12 additions & 0 deletions src/vs/base/common/htmlContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ export function escapeMarkdownSyntaxTokens(text: string): string {
return text.replace(/[\\`*_{}[\]()#+\-!~]/g, '\\$&'); // CodeQL [SM02383] Backslash is escaped in the character class
}

/**
* Escapes only the characters that would break out of markdown link text
* (`[label](url)`) syntax: `\` and `]`. Use this when the escaped string is
* displayed as the visible label of a link, since renderers that extract the
* link text without re-parsing markdown (e.g. the chat inline anchor / skill
* pill) would otherwise show full `escapeMarkdownSyntaxTokens` backslashes
* (`\-`, `\.`, ...) verbatim.
*/
export function escapeMarkdownLinkLabel(text: string): string {
return text.replace(/[\\\]]/g, '\\$&');
}

/**
* @see https://github.com/microsoft/vscode/issues/193746
*/
Expand Down
23 changes: 22 additions & 1 deletion src/vs/base/test/common/htmlContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { appendEscapedMarkdownInlineCode } from '../../common/htmlContent.js';
import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel } from '../../common/htmlContent.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js';

suite('htmlContent', () => {
Expand Down Expand Up @@ -37,4 +37,25 @@ suite('htmlContent', () => {
assert.strictEqual(appendEscapedMarkdownInlineCode('``'), '``` `` ```');
});
});

suite('escapeMarkdownLinkLabel', () => {
test('passes plain text through unchanged', () => {
assert.strictEqual(escapeMarkdownLinkLabel('hello'), 'hello');
assert.strictEqual(escapeMarkdownLinkLabel(''), '');
assert.strictEqual(escapeMarkdownLinkLabel('heap-snapshot-analysis'), 'heap-snapshot-analysis');
assert.strictEqual(escapeMarkdownLinkLabel('foo.bar_baz'), 'foo.bar_baz');
});

test('escapes only `\\` and `]`', () => {
assert.strictEqual(escapeMarkdownLinkLabel('a]b'), 'a\\]b');
assert.strictEqual(escapeMarkdownLinkLabel('a\\b'), 'a\\\\b');
assert.strictEqual(escapeMarkdownLinkLabel(']]'), '\\]\\]');
});

test('does not escape characters that are safe in link text', () => {
// these would be escaped by escapeMarkdownSyntaxTokens but must
// pass through here since they render literally inside `[...]`.
assert.strictEqual(escapeMarkdownLinkLabel('a*b_c#d-e.f!g~h+i(j)k{l}m'), 'a*b_c#d-e.f!g~h+i(j)k{l}m');
});
});
});
23 changes: 21 additions & 2 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCom
import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolDefinition } from './state/protocol/state.js';
import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from './state/sessionActions.js';
import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js';
import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionMeta, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js';

// IPC contract between the renderer and the agent host utility process.
// Defines all serializable event types, the IAgent provider interface,
Expand Down Expand Up @@ -87,6 +87,14 @@ export interface IAgentSessionMetadata {
readonly isRead?: boolean;
readonly isArchived?: boolean;
readonly diffs?: readonly FileEdit[];
/**
* Side-channel metadata mirroring {@link SessionState._meta}, propagated
* to clients via per-session state subscriptions.
* Producers SHOULD use namespaced keys; consumers MUST ignore unknown
* keys. Use the typed accessors in `sessionState.ts` (e.g.
* `readSessionGitState`) for well-known slots.
*/
readonly _meta?: SessionMeta;
}

export interface IAgentSessionProjectInfo {
Expand Down Expand Up @@ -340,6 +348,17 @@ export interface IAgentReasoningEvent extends IAgentProgressEventBase {
readonly content: string;
}

/**
* The set of events returned by {@link IAgent.getSessionMessages} when
* reconstructing a session's history. Reasoning is carried inline on
* {@link IAgentMessageEvent.reasoningText} rather than as a separate event.
*/
export type SessionHistoryEvent =
| IAgentMessageEvent
| IAgentToolStartEvent
| IAgentToolCompleteEvent
| IAgentSubagentStartedEvent;

/** A steering message was consumed (sent to the model). */
export interface IAgentSteeringConsumedEvent extends IAgentProgressEventBase {
readonly type: 'steering_consumed';
Expand Down Expand Up @@ -452,7 +471,7 @@ export interface IAgent {
setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void;

/** Retrieve all session events/messages for reconstruction. */
getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]>;
getSessionMessages(session: URI): Promise<SessionHistoryEvent[]>;

/** Dispose a session, freeing resources. */
disposeSession(session: URI): Promise<void>;
Expand Down
92 changes: 92 additions & 0 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,95 @@ export type ComponentToState = {
[StateComponents.Session]: SessionState;
[StateComponents.Terminal]: TerminalState;
};

// ---- SessionMeta accessors -------------------------------------------------

/**
* VS Code-side alias for the protocol's open `_meta` property bag on
* {@link SessionState}. Keys SHOULD be namespaced (e.g. `git`, `vscode.foo`)
* to avoid collisions; values MUST be JSON-serializable.
*/
export type SessionMeta = Record<string, unknown>;

/**
* Reserved key under {@link SessionMeta} for the well-known git-state
* payload. Value at this key, when present, MUST be shaped like
* {@link ISessionGitState}. This is a VS Code-specific convention layered
* on top of the protocol's generic `_meta` bag — the protocol itself does
* not know about git state.
*/
export const SESSION_META_GIT_KEY = 'git';

/**
* Git state of a session's working directory, carried under
* {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to
* drive source-control affordances (e.g. PR/merge buttons in the Agents
* app).
*
* All fields are optional — agents that do not track a particular field
* should omit it rather than send a placeholder, so clients can distinguish
* "unknown" from "known to be zero".
*/
export interface ISessionGitState {
/** Whether the working directory has a `github.com` git remote. */
readonly hasGitHubRemote?: boolean;
/** Current branch name. */
readonly branchName?: string;
/** Base branch the work targets (e.g. `main`). */
readonly baseBranchName?: string;
/** Upstream tracking branch (e.g. `origin/feature`). */
readonly upstreamBranchName?: string;
/** Number of commits the upstream branch has ahead of the local branch. */
readonly incomingChanges?: number;
/** Number of commits the local branch has ahead of the upstream branch. */
readonly outgoingChanges?: number;
/** Number of files with uncommitted changes. */
readonly uncommittedChanges?: number;
}

/**
* Reads the well-known git-state payload from {@link SessionMeta}, if
* present. Returns `undefined` when the meta bag is absent or the value at
* the git key is not a plain object (e.g. an array or a primitive).
* Individual fields with wrong types are silently dropped so partial state
* still propagates.
*/
export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitState | undefined {
const value = meta?.[SESSION_META_GIT_KEY];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const raw = value as Record<string, unknown>;
const result: {
hasGitHubRemote?: boolean;
branchName?: string;
baseBranchName?: string;
upstreamBranchName?: string;
incomingChanges?: number;
outgoingChanges?: number;
uncommittedChanges?: number;
} = {};
if (typeof raw['hasGitHubRemote'] === 'boolean') { result.hasGitHubRemote = raw['hasGitHubRemote']; }
if (typeof raw['branchName'] === 'string') { result.branchName = raw['branchName']; }
if (typeof raw['baseBranchName'] === 'string') { result.baseBranchName = raw['baseBranchName']; }
if (typeof raw['upstreamBranchName'] === 'string') { result.upstreamBranchName = raw['upstreamBranchName']; }
if (typeof raw['incomingChanges'] === 'number') { result.incomingChanges = raw['incomingChanges']; }
if (typeof raw['outgoingChanges'] === 'number') { result.outgoingChanges = raw['outgoingChanges']; }
if (typeof raw['uncommittedChanges'] === 'number') { result.uncommittedChanges = raw['uncommittedChanges']; }
return result;
}

/**
* Returns a new {@link SessionMeta} with the git-state payload set to
* `gitState`, or with the git slot removed if `gitState` is `undefined`.
* Returns `undefined` if the result would be empty.
*/
export function withSessionGitState(meta: SessionMeta | undefined, gitState: ISessionGitState | undefined): SessionMeta | undefined {
const next: { [key: string]: unknown } = { ...meta };
if (gitState !== undefined) {
next[SESSION_META_GIT_KEY] = gitState;
} else {
delete next[SESSION_META_GIT_KEY];
}
return Object.keys(next).length > 0 ? next : undefined;
}
Loading
Loading