Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
33 changes: 29 additions & 4 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,17 +279,27 @@ export function isAhpAutomationRunChannel(uri: string): boolean {

const MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.hiddenFromTranscript';
const MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX = '<!-- vscode-hidden-from-transcript -->\n';
const MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.requestHiddenFromTranscript';
const MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX = '<!-- vscode-request-hidden-from-transcript -->\n';

function readMessageMeta(message: Message): { readonly hiddenFromTranscript: boolean } {
function readMessageMeta(message: Message): { readonly hiddenFromTranscript: boolean; readonly requestHiddenFromTranscript: boolean } {
const meta = message._meta;
const hiddenFromTranscript = meta?.[MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true
|| message.text.startsWith(MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX);
return {
hiddenFromTranscript: meta?.[MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true,
hiddenFromTranscript,
requestHiddenFromTranscript: meta?.[MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true
|| message.text.startsWith(MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX),
};
}

export function isMessageHiddenFromTranscript(message: Message): boolean {
return readMessageMeta(message).hiddenFromTranscript
|| message.text.startsWith(MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX);
return readMessageMeta(message).hiddenFromTranscript;
}

/** Whether only the message's request row is hidden while its response remains visible. */
export function isMessageRequestHiddenFromTranscript(message: Message): boolean {
return readMessageMeta(message).requestHiddenFromTranscript;
}

export function withMessageHiddenFromTranscript(message: Message, hidden: boolean | undefined): Message {
Expand All @@ -306,6 +316,21 @@ export function withMessageHiddenFromTranscript(message: Message, hidden: boolea
};
}

/** Marks only the message's request row as hidden while preserving its response. */
export function withMessageRequestHiddenFromTranscript(message: Message, hidden: boolean | undefined): Message {
if (!hidden || isMessageHiddenFromTranscript(message)) {
return message;
}
return {
...message,
text: message.text.startsWith(MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX) ? message.text : MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX + message.text,
_meta: {
...message._meta,
[MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY]: true,
},
};
}

/** Whole-turn token consumption attributed to a single model. */
export interface ITurnTokenTotal {
readonly model: string;
Expand Down
4 changes: 2 additions & 2 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f
import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js';
import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js';
import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js';
import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js';
import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js';
Expand Down Expand Up @@ -1305,7 +1305,7 @@ export class AgentService extends Disposable implements IAgentService {
type: ActionType.ChatTurnStarted,
turnId,
startedAt: new Date().toISOString(),
message: withMessageHiddenFromTranscript({ text: content, origin: { kind: MessageKind.SystemNotification } }, true),
message: withMessageRequestHiddenFromTranscript({ text: content, origin: { kind: MessageKind.SystemNotification } }, true),
});
this._stateManager.dispatchServerAction(channel, {
type: ActionType.ChatResponsePart,
Expand Down
8 changes: 5 additions & 3 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js';
import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js';
import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js';
import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js';
import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js';
import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js';
Expand Down Expand Up @@ -14654,7 +14654,8 @@ suite('AgentService (node dispatcher)', () => {
assert.deepStrictEqual({
// The turn exists only to carry the notice, so its own message
// stays out of the transcript.
hiddenMessage: isMessageHiddenFromTranscript(notice.message),
hiddenTurn: isMessageHiddenFromTranscript(notice.message),
hiddenRequest: isMessageRequestHiddenFromTranscript(notice.message),
origin: notice.message.origin.kind,
state: notice.state,
responseParts: notice.responseParts,
Expand All @@ -14665,7 +14666,8 @@ suite('AgentService (node dispatcher)', () => {
// so it only survives reload as a local turn.
persistedLocally: (await sessionDb.getLocalTurns()).map(record => ({ chatUri: record.chatUri, turnId: record.turnId })),
}, {
hiddenMessage: true,
hiddenTurn: false,
hiddenRequest: true,
origin: MessageKind.SystemNotification,
state: TurnState.Complete,
responseParts: [{
Expand Down
115 changes: 115 additions & 0 deletions src/vs/sessions/browser/sessionAgentMerge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Codicon } from '../../base/common/codicons.js';
import { structuralEquals } from '../../base/common/equals.js';
import { Event } from '../../base/common/event.js';
import { constObservable, derivedOpts, IObservable, observableFromEvent } from '../../base/common/observable.js';
import { themeColorFromId, ThemeIcon } from '../../base/common/themables.js';
import { AgentMergeConfiguration, AgentMergeSettingId, defaultAgentMergeConfiguration, isAgentMergeMergePullRequest, resolveAgentMergeConfiguration } from '../../platform/agentHost/common/agentMerge.js';
import { IConfigurationService } from '../../platform/configuration/common/configuration.js';
import { IAgentMergeClientState, isAgentHostProvider } from '../common/agentHostSessionsProvider.js';
import { ISessionsProvidersService } from '../services/sessions/browser/sessionsProvidersService.js';
import { ISession } from '../services/sessions/common/session.js';

const noAgentMergeConfiguration = constObservable<ISessionAgentMergeConfiguration | undefined>(undefined);
const agentMergeSessionStateBySession = new WeakMap<ISession, IObservable<IAgentMergeClientState | undefined>>();
const agentMergeConfigurationBySession = new WeakMap<ISession, IObservable<ISessionAgentMergeConfiguration | undefined>>();
const openPullRequestIcon = { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') };

/** Effective Agent Merge state used by client presentation. */
export interface ISessionAgentMergeConfiguration {
readonly enabled: boolean;
readonly actions: AgentMergeConfiguration;
}

/** Returns the Agent Merge state observable for a session. */
export function getSessionAgentMergeStateObservable(session: ISession, sessionsProvidersService: ISessionsProvidersService): IObservable<IAgentMergeClientState | undefined> {
const cached = agentMergeSessionStateBySession.get(session);
if (cached) {
return cached;
}
const provider = sessionsProvidersService.getProvider(session.providerId);
if (!provider || !isAgentHostProvider(provider)) {
return constObservable(undefined);
}
const observable = provider.getAgentMergeClientStateObservable(session.sessionId);
agentMergeSessionStateBySession.set(session, observable);
return observable;
}

/** Returns effective Agent Merge actions for a session. */
export function getSessionAgentMergeConfigurationObservable(session: ISession, sessionsProvidersService: ISessionsProvidersService, configurationService: IConfigurationService): IObservable<ISessionAgentMergeConfiguration | undefined> {
const cached = agentMergeConfigurationBySession.get(session);
if (cached) {
return cached;
}
const provider = sessionsProvidersService.getProvider(session.providerId);
if (!provider || !isAgentHostProvider(provider)) {
return noAgentMergeConfiguration;
}
const state = getSessionAgentMergeStateObservable(session, sessionsProvidersService);
const globalConfiguration = observableFromEvent(
Event.filter(configurationService.onDidChangeConfiguration, event => Object.values(AgentMergeSettingId).some(settingId => event.affectsConfiguration(settingId))),
() => getGlobalAgentMergeConfiguration(configurationService));
const observable = derivedOpts<ISessionAgentMergeConfiguration>({
owner: session,
equalsFn: structuralEquals,
}, reader => {
const sessionState = state.read(reader);
return {
enabled: sessionState?.enabled === true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI Review: Effective enablement should include the global chat.agentMerge.enabled gate. The controller stops this session's runtime when that gate is false but deliberately preserves the per-session bit, so this remains true and the icon/banner consumers hide blockers while nothing is monitoring them. Please combine the session bit with the global or host operational state and cover global disable/re-enable.

actions: resolveAgentMergeConfiguration(globalConfiguration.read(reader), sessionState?.overrides),
};
});
agentMergeConfigurationBySession.set(session, observable);
return observable;
}

/** Hides pull-request blockers that an enabled Agent Merge session owns. */
export function getAgentMergeAwarePullRequestIcon(icon: ThemeIcon, agentMerge: ISessionAgentMergeConfiguration | undefined, blockers?: { readonly hasFailingChecks?: boolean; readonly hasMergeConflicts?: boolean; readonly hasUnresolvedComments?: boolean }): ThemeIcon {
if (!agentMerge?.enabled) {
return icon;
}
if (icon.id === Codicon.gitPullRequestComment.id) {
return agentMerge.actions.addressReviews ? openPullRequestIcon : icon;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI Review: hasUnresolvedComments covers every unresolved thread, but Agent Merge only addresses threads containing maintainer or Copilot-reviewer feedback. A contributor or unrelated-bot thread is therefore turned into the normal open icon and removed from the banner even though the controller creates no repair turn. Please base suppression on the controller's author classifier, or keep unowned threads visible.

}
if (icon.id === Codicon.gitPullRequestError.id) {
const hasKnownBlocker = blockers?.hasFailingChecks === true || blockers?.hasMergeConflicts === true || blockers?.hasUnresolvedComments === true;
if (blockers && !hasKnownBlocker) {
return icon;
}
const handlesBlockers = blockers
? (!blockers.hasFailingChecks || agentMerge.actions.fixCI)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI Review: hasFailingChecks aggregates every check run, but Agent Merge subscribes to and repairs required checks only. With only an optional check failing, fixCI turns this into the normal open icon and the CI banner is also suppressed even though no repair turn is scheduled. Please carry requiredness into these decisions so optional failures remain visible.

&& (!blockers.hasMergeConflicts || agentMerge.actions.resolveConflicts)
&& (!blockers.hasUnresolvedComments || agentMerge.actions.addressReviews)
: agentMerge.actions.fixCI && agentMerge.actions.resolveConflicts && agentMerge.actions.addressReviews;
return handlesBlockers ? openPullRequestIcon : icon;
}
return icon;
}

/** Whether the pull-request icon represents blockers Agent Merge can own. */
export function isAgentMergePullRequestIcon(icon: ThemeIcon): boolean {
return icon.id === Codicon.gitPullRequestError.id || icon.id === Codicon.gitPullRequestComment.id;
}

/** Reads the effective global Agent Merge configuration. */
export function getGlobalAgentMergeConfiguration(configurationService: IConfigurationService): AgentMergeConfiguration {
const mergePullRequest = configurationService.getValue<unknown>(AgentMergeSettingId.MergePullRequest);
return {
addressReviews: configurationService.getValue<boolean>(AgentMergeSettingId.AddressReviews) ?? defaultAgentMergeConfiguration.addressReviews,
fixCI: configurationService.getValue<boolean>(AgentMergeSettingId.FixCI) ?? defaultAgentMergeConfiguration.fixCI,
resolveConflicts: configurationService.getValue<boolean>(AgentMergeSettingId.ResolveConflicts) ?? defaultAgentMergeConfiguration.resolveConflicts,
// Tolerate the retired boolean form until every profile has run its migration.
mergePullRequest: isAgentMergeMergePullRequest(mergePullRequest)
? mergePullRequest
: typeof mergePullRequest === 'boolean'
? (mergePullRequest ? 'always' : 'never')
: defaultAgentMergeConfiguration.mergePullRequest,
mergeMethod: configurationService.getValue<AgentMergeConfiguration['mergeMethod']>(AgentMergeSettingId.MergeMethod) ?? defaultAgentMergeConfiguration.mergeMethod,
replyAttribution: configurationService.getValue<boolean>(AgentMergeSettingId.ReplyAttribution) ?? defaultAgentMergeConfiguration.replyAttribution,
};
}
Loading
Loading