diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index c64792442192cd..b1db249ad9ad35 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -120,7 +120,7 @@ const vscodeResourceIncludes = [ 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', 'out-build/vs/sessions/contrib/welcome/browser/media/*.svg', 'out-build/vs/sessions/contrib/welcome/browser/media/themePreviews/*.svg', - 'out-build/vs/sessions/prompts/*.prompt.md', + 'out-build/vs/sessions/prompts/*.md', 'out-build/vs/sessions/skills/**/SKILL.md', // Extensions diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 691f8cd516478d..f93234332bba75 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -760,6 +760,10 @@ "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/sessionComparison", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/sessionInputBanners", "project": "vscode-sessions" diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 34d22b5d162357..b9f8ce2d7d8b9f 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -114,6 +114,7 @@ export class Dialog extends Disposable { private readonly buttonsContainer: HTMLElement; private readonly messageDetailElement: HTMLElement; private readonly messageContainer: HTMLElement; + private readonly bodyContainer: HTMLElement | undefined; private readonly footerContainer: HTMLElement | undefined; private footerActionToFocus: HTMLAnchorElement | undefined; private readonly iconElement: HTMLElement; @@ -202,8 +203,10 @@ export class Dialog extends Disposable { } if (this.options.renderBody) { - const customBody = this.messageContainer.appendChild($('#monaco-dialog-message-body.dialog-message-body')); - this.options.renderBody(customBody); + this.bodyContainer = this.messageContainer.appendChild($('#monaco-dialog-message-body.dialog-message-body')); + this.options.renderBody(this.bodyContainer); + } else { + this.bodyContainer = undefined; } if (this.options.renderBody || this.options.detailElement) { @@ -409,7 +412,8 @@ export class Dialog extends Disposable { // Focus: Next / Previous const isArrowNavigation = evt.equals(KeyCode.RightArrow) || evt.equals(KeyCode.LeftArrow); const isEditableTarget = isHTMLElement(e.target) && (isEditableElement(e.target) || e.target.isContentEditable); - if (evt.equals(KeyCode.Tab) || evt.equals(KeyMod.Shift | KeyCode.Tab) || isArrowNavigation && !isEditableTarget) { + const handlesArrowNavigation = isHTMLElement(e.target) && !!e.target.closest('select, [role="combobox"], [role="listbox"], [role="radio"], [role="slider"], summary'); + if (evt.equals(KeyCode.Tab) || evt.equals(KeyMod.Shift | KeyCode.Tab) || isArrowNavigation && !isEditableTarget && !handlesArrowNavigation) { // Build a list of focusable elements in their visual order const focusableElements: { focus: () => void }[] = []; @@ -417,7 +421,7 @@ export class Dialog extends Disposable { if (this.messageContainer) { // eslint-disable-next-line no-restricted-syntax - const links = this.messageContainer.querySelectorAll('a'); + const links = this.messageDetailElement.querySelectorAll('a'); for (const link of links) { focusableElements.push(link); if (isActiveElement(link)) { @@ -426,6 +430,23 @@ export class Dialog extends Disposable { } } + if (this.bodyContainer) { + // eslint-disable-next-line no-restricted-syntax + const elements = this.bodyContainer.querySelectorAll('a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'); + for (const element of elements) { + if (element.tabIndex < 0 + || element.hasAttribute('disabled') + || element.getAttribute('aria-disabled') === 'true' + || element.getClientRects().length === 0) { + continue; + } + focusableElements.push(element); + if (isActiveElement(element)) { + focusedIndex = focusableElements.length - 1; + } + } + } + for (const input of this.inputs) { focusableElements.push(input); if (input.hasFocus()) { diff --git a/src/vs/base/browser/ui/selectBox/selectBox.ts b/src/vs/base/browser/ui/selectBox/selectBox.ts index b0bebe5c4f0bed..044471dfe08a24 100644 --- a/src/vs/base/browser/ui/selectBox/selectBox.ts +++ b/src/vs/base/browser/ui/selectBox/selectBox.ts @@ -40,6 +40,8 @@ export interface ISelectBoxOptions { ariaDescription?: string; minBottomMargin?: number; optionsAsChildren?: boolean; + /** Context views with higher layers are rendered higher in z-index order. */ + contextViewLayer?: number; /** Hide disabled options from the custom-drawn dropdown. */ hideDisabledOptions?: boolean; /** Show option descriptions in right-side hovers instead of the details pane. */ diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts index a9d87182919629..8fe7bd1b2be741 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts @@ -516,7 +516,8 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi onHide: () => { this.selectDropDownContainer.classList.remove('visible'); }, - anchorPosition: this._dropDownPosition + anchorPosition: this._dropDownPosition, + layer: this.selectBoxOptions.contextViewLayer, }, this.selectBoxOptions.optionsAsChildren ? this.container : undefined); // Hide so we can relay out @@ -530,7 +531,8 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi onHide: () => { this.selectDropDownContainer.classList.remove('visible'); }, - anchorPosition: this._dropDownPosition + anchorPosition: this._dropDownPosition, + layer: this.selectBoxOptions.contextViewLayer, }, this.selectBoxOptions.optionsAsChildren ? this.container : undefined); this._isVisible = true; diff --git a/src/vs/base/test/browser/ui/dialog/dialog.test.ts b/src/vs/base/test/browser/ui/dialog/dialog.test.ts index 5bd29321018bda..4ad6a6553e2376 100644 --- a/src/vs/base/test/browser/ui/dialog/dialog.test.ts +++ b/src/vs/base/test/browser/ui/dialog/dialog.test.ts @@ -74,6 +74,50 @@ suite('Dialog', () => { await result; }); + test('includes interactive custom body controls in keyboard navigation', async () => { + const container = append(document.body, $('.test-dialog-container')); + disposables.add(toDisposable(() => container.remove())); + let textarea!: HTMLTextAreaElement; + let select!: HTMLSelectElement; + let action!: Button; + const dialog = disposables.add(new Dialog(container, 'Message', ['Save', 'Cancel'], { + renderBody: body => { + textarea = append(body, $('textarea')); + select = append(body, $('select')); + action = disposables.add(new Button(body, unthemedButtonStyles)); + action.label = 'Add attempt'; + }, + buttonStyles: unthemedButtonStyles, + checkboxStyles: unthemedCheckboxStyles, + inputBoxStyles: unthemedInboxStyles, + dialogStyles: unthemedDialogStyles, + })); + const result = dialog.show(); + + const dispatchKey = (target: HTMLElement, key: string, keyCode: number) => { + target.focus(); + const event = new (getWindow(target).KeyboardEvent)('keydown', { key, keyCode, bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return { + activeElement: getWindow(target).document.activeElement, + defaultPrevented: event.defaultPrevented, + }; + }; + + assert.deepStrictEqual({ + textareaToSelect: dispatchKey(textarea, 'Tab', 9), + selectToAction: dispatchKey(select, 'Tab', 9), + selectArrow: dispatchKey(select, 'ArrowRight', 39), + }, { + textareaToSelect: { activeElement: select, defaultPrevented: true }, + selectToAction: { activeElement: action.element, defaultPrevented: true }, + selectArrow: { activeElement: select, defaultPrevented: false }, + }); + + dialog.dispose(); + await result; + }); + test('renders a plain string detail as text', async () => { const container = append(document.body, $('.test-dialog-container')); disposables.add(toDisposable(() => container.remove())); diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index 386a0325b961c1..f3bb109053ded3 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -93,6 +93,17 @@ Claude honors these standard resource variables for traces, logs, and metrics wh The host emits a zero-duration `vscode.agent_host.session` anchor and passes its W3C `traceparent`/`tracestate` to native runtimes. Copilot reads the context through `CopilotClientOptions.onGetTraceContext`, Claude receives it in its session subprocess environment, and Codex receives it on session-scoped JSON-RPC request envelopes. Provider-native traces can therefore share one trace id while retaining their provider conversation attributes. +Sessions created by Run Multiple Agents add bounded correlation attributes to this anchor: + +| Attribute | Description | +|---|---| +| `vscode.agent_host.comparison.id` | Random comparison identifier. | +| `vscode.agent_host.comparison.role` | `attempt`, `judge`, or `synthesis`. | +| `vscode.agent_host.comparison.attempt_index` | Zero-based attempt ordinal; present only for attempts. | +| `vscode.agent_host.comparison.attempt_count` | Number of implementation attempts in the comparison. | + +These attributes contain no prompt, title, path, model label, or tool content and do not require content capture. They are emitted only when Agent Host OTel is already enabled; comparisons do not enable or reconfigure OTel. + ## Session Title Metadata When content capture is enabled, the agent host emits a zero-duration `vscode.agent_host.session.title_changed` span whenever an authoritative Copilot, Claude, or Codex session title changes. This includes fallback, generated, refined, and manually renamed titles; assigning the same title again does not emit another span. Downstream consumers can use the latest span for a conversation to display its current title. diff --git a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts index 4fb1328b0d62ed..1bfd12844f88ae 100644 --- a/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts +++ b/src/vs/platform/agentHost/common/otel/agentHostOTelService.ts @@ -6,6 +6,7 @@ import type { TelemetryConfig } from '@github/copilot-sdk'; import type { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { IAgentSessionComparisonMetadata } from '../state/sessionState.js'; /** @@ -28,6 +29,10 @@ export const AgentHostSessionTitleSpanName = 'vscode.agent_host.session.title_ch export const AgentHostSessionTitleAttribute = 'vscode.agent_host.session.title'; export const AgentHostSessionUriAttribute = 'vscode.agent_host.session.uri'; +export const AgentHostComparisonIdAttribute = 'vscode.agent_host.comparison.id'; +export const AgentHostComparisonRoleAttribute = 'vscode.agent_host.comparison.role'; +export const AgentHostComparisonAttemptIndexAttribute = 'vscode.agent_host.comparison.attempt_index'; +export const AgentHostComparisonAttemptCountAttribute = 'vscode.agent_host.comparison.attempt_count'; export interface IAgentHostTraceContext { readonly traceId: string; @@ -66,6 +71,9 @@ export interface IAgentHostOTelService { /** Return a stable W3C parent for a provider session and emit its anchor span. */ getSessionTraceContext(conversationId: string, sessionUri: string): IAgentHostTraceContext | undefined; + /** Associates bounded comparison metadata with a session before its first provider call. */ + setSessionComparisonMetadata(sessionUri: string, comparison: IAgentSessionComparisonMetadata | undefined): void; + /** Release a permanent session's retained W3C context. Idle eviction must not call this. */ releaseSessionTraceContext(sessionUri: string): void; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 33768d01d4b446..4d91a5fddaee9b 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -2001,6 +2001,43 @@ export function withSessionCreationReference(meta: SessionSummaryMeta | undefine return { ...meta, [SESSION_META_CREATED_BY_SESSION_KEY]: creationReference }; } +export const SESSION_META_COMPARISON_KEY = 'agentHost/sessionComparison'; + +export type AgentSessionComparisonRole = 'attempt' | 'judge' | 'synthesis'; + +export interface IAgentSessionComparisonMetadata { + readonly id: string; + readonly role: AgentSessionComparisonRole; + readonly attemptIndex?: number; + readonly attemptCount: number; +} + +export function readSessionComparisonMetadata(meta: SessionSummaryMeta | undefined): IAgentSessionComparisonMetadata | undefined { + const value = meta?.[SESSION_META_COMPARISON_KEY]; + if (!value || typeof value !== 'object') { + return undefined; + } + const candidate = value as { [key: string]: unknown }; + if (typeof candidate.id !== 'string' || candidate.id.length === 0 || candidate.id.length > 128 + || (candidate.role !== 'attempt' && candidate.role !== 'judge' && candidate.role !== 'synthesis') + || !Number.isInteger(candidate.attemptCount) || (candidate.attemptCount as number) < 2 + || (candidate.attemptIndex !== undefined && (!Number.isInteger(candidate.attemptIndex) || (candidate.attemptIndex as number) < 0 || (candidate.attemptIndex as number) >= (candidate.attemptCount as number))) + || (candidate.role === 'attempt') !== (candidate.attemptIndex !== undefined) + ) { + return undefined; + } + return { + id: candidate.id, + role: candidate.role, + attemptIndex: candidate.attemptIndex as number | undefined, + attemptCount: candidate.attemptCount as number, + }; +} + +export function withSessionComparisonMetadata(meta: SessionSummaryMeta | undefined, comparison: IAgentSessionComparisonMetadata): SessionSummaryMeta { + return { ...meta, [SESSION_META_COMPARISON_KEY]: comparison }; +} + /** * Reserved key under {@link SessionSummaryMeta} marking a session as * workspace-less: a session with no workspace/folder binding (surfaced in the diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2fc4c7717346e5..d2f285ffe07039 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -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_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, 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, chatStorageUri, getErrorResponsePart, getSessionRelatedPullRequestUrls, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSessionStatusArchived, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, 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_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionComparisonMetadata, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, 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, chatStorageUri, getErrorResponsePart, getSessionRelatedPullRequestUrls, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSessionStatusArchived, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, 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'; @@ -90,6 +90,7 @@ import { AgentMergeController, type IAgentMergeControllerOptions } from './agent import { AgentMergeConfigKey, agentMergeRootConfigSchema, getNonMergeSessionConfigValues, readAgentMergeSessionState } from '../common/agentMerge.js'; import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../common/meta/agentSystemNotificationMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; @@ -621,6 +622,7 @@ export class AgentService extends Disposable implements IAgentService { @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, + @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, ) { super(); this._authService = core.authenticationService; @@ -2899,6 +2901,7 @@ export class AgentService extends Disposable implements IAgentService { if (config?.session) { this._cancelPendingSessionGc(config.session); this._sessionResidency.touch(config.session); + this._otelService.setSessionComparisonMetadata(config.session.toString(), readSessionComparisonMetadata(config._meta)); } // Capability gate: only a provider that advertises diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index e7d56874155bd4..30e3689d0723a7 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -478,6 +478,22 @@ function mapResult( outputTokens: message.usage.output_tokens, cacheReadTokens: message.usage.cache_read_input_tokens, ...(modelKey ? { model: modelKey } : {}), + ...(modelKey ? { + _meta: { + turnTokenTotals: [{ + model: modelKey, + inputTokens: message.usage.input_tokens, + cachedTokens: message.usage.cache_read_input_tokens, + outputTokens: message.usage.output_tokens, + }], + directTurnTokenTotals: [{ + model: modelKey, + inputTokens: message.usage.input_tokens, + cachedTokens: message.usage.cache_read_input_tokens, + outputTokens: message.usage.output_tokens, + }], + }, + } : {}), }, }, }); diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index 9785588ab526e4..65b063146d8e6c 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -504,6 +504,20 @@ export function mapTokenUsageUpdated(params: ThreadTokenUsageUpdatedNotification _meta: { reasoningOutputTokens: last.reasoningOutputTokens, modelContextWindow: params.tokenUsage.modelContextWindow, + ...(modelId ? { + turnTokenTotals: [{ + model: modelId, + inputTokens: last.inputTokens, + cachedTokens: last.cachedInputTokens, + outputTokens: last.outputTokens, + }], + directTurnTokenTotals: [{ + model: modelId, + inputTokens: last.inputTokens, + cachedTokens: last.cachedInputTokens, + outputTokens: last.outputTokens, + }], + } : {}), }, }, }]; diff --git a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts index cdb7377248e33a..653f8011831c03 100644 --- a/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts +++ b/src/vs/platform/agentHost/node/otel/agentHostOTelService.ts @@ -24,7 +24,8 @@ import { GenAiAttr } from '../../../otel/common/genAiAttributes.js'; import { ICompletedSpanData, SpanStatusCode } from '../../../otel/common/spanData.js'; import { OTelSqliteStore } from '../../../otel/node/sqlite/otelSqliteStore.js'; import { AgentHostOTelSpansDbSubPath } from '../../common/agentService.js'; -import { AgentHostOTelServiceName, AgentHostOTelServiceNamespace, AgentHostSessionSpanName, AgentHostSessionTitleAttribute, AgentHostSessionTitleSpanName, AgentHostSessionUriAttribute, IAgentHostNativeOTelConfig, IAgentHostOTelService, IAgentHostTraceContext } from '../../common/otel/agentHostOTelService.js'; +import { AgentHostComparisonAttemptCountAttribute, AgentHostComparisonAttemptIndexAttribute, AgentHostComparisonIdAttribute, AgentHostComparisonRoleAttribute, AgentHostOTelServiceName, AgentHostOTelServiceNamespace, AgentHostSessionSpanName, AgentHostSessionTitleAttribute, AgentHostSessionTitleSpanName, AgentHostSessionUriAttribute, IAgentHostNativeOTelConfig, IAgentHostOTelService, IAgentHostTraceContext } from '../../common/otel/agentHostOTelService.js'; +import { IAgentSessionComparisonMetadata } from '../../common/state/sessionState.js'; /** Sub-path under the user data directory where the span DB lives. */ const SPANS_DB_SUBPATH = AgentHostOTelSpansDbSubPath; @@ -229,6 +230,7 @@ export class AgentHostOTelService extends Disposable implements IAgentHostOTelSe private _startPromise: Promise | undefined; private _metadataExportQueue = Promise.resolve(); private readonly _sessionContexts = new Map(); + private readonly _sessionComparisons = new Map(); private _currentTraceContext: IAgentHostTraceContext | undefined; private _pendingFilteredCodexAuthSpans = 0; private _totalFilteredCodexAuthSpans = 0; @@ -306,6 +308,7 @@ export class AgentHostOTelService extends Disposable implements IAgentHostOTelSe const context: IAgentHostTraceContext = { traceId, spanId, traceparent: `00-${traceId}-${spanId}-01` }; this._sessionContexts.set(sessionUri, context); const now = Date.now(); + const comparison = this._sessionComparisons.get(sessionUri); this._queueSyntheticSpan({ name: AgentHostSessionSpanName, traceId, @@ -317,14 +320,29 @@ export class AgentHostOTelService extends Disposable implements IAgentHostOTelSe ...this._config.resourceAttributes, [GenAiAttr.CONVERSATION_ID]: conversationId, [AgentHostSessionUriAttribute]: sessionUri, + ...(comparison ? { + [AgentHostComparisonIdAttribute]: comparison.id, + [AgentHostComparisonRoleAttribute]: comparison.role, + [AgentHostComparisonAttemptCountAttribute]: comparison.attemptCount, + ...(comparison.attemptIndex !== undefined ? { [AgentHostComparisonAttemptIndexAttribute]: comparison.attemptIndex } : {}), + } : {}), }, events: [], }); return context; } + setSessionComparisonMetadata(sessionUri: string, comparison: IAgentSessionComparisonMetadata | undefined): void { + if (comparison) { + this._sessionComparisons.set(sessionUri, comparison); + } else { + this._sessionComparisons.delete(sessionUri); + } + } + releaseSessionTraceContext(sessionUri: string): void { this._sessionContexts.delete(sessionUri); + this._sessionComparisons.delete(sessionUri); } withTraceContext(context: IAgentHostTraceContext | undefined, fn: () => T): T { diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index d16a4d8e727058..367dfebfc03061 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -10,7 +10,7 @@ import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; -import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readUsageInfoMeta, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; +import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; import type { SessionModelInfo, SimpleMessageAttachment } from '../../common/state/protocol/state.js'; import { createAgentModelByokMeta, readAgentModelByokIdentifier } from '../../common/agentModelByokMeta.js'; import { createAgentModelSourceMeta, readAgentModelSourceId } from '../../common/agentModelSource.js'; @@ -36,6 +36,23 @@ suite('Agent host _meta readers', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('reads bounded session comparison metadata', () => { + assert.deepStrictEqual(readSessionComparisonMetadata(withSessionComparisonMetadata(undefined, { + id: 'comparison', + role: 'attempt', + attemptIndex: 1, + attemptCount: 3, + })), { + id: 'comparison', + role: 'attempt', + attemptIndex: 1, + attemptCount: 3, + }); + assert.strictEqual(readSessionComparisonMetadata({ + 'agentHost/sessionComparison': { id: 'comparison', role: 'attempt', attemptIndex: 3, attemptCount: 3 }, + }), undefined); + }); + suite('readToolCallMeta', () => { test('returns empty when no _meta', () => { assert.deepStrictEqual(readToolCallMeta(toolCall(undefined)), {}); diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 8e3247bdb60e1d..a8b976b060ca74 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -19,6 +19,7 @@ import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { IAgentEditAttributionService, NullAgentEditAttributionService } from '../../common/fileEditAttribution.js'; import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; import { IAgentService } from '../../common/agentService.js'; +import { IAgentHostOTelService, IAgentHostTraceContext } from '../../common/otel/agentHostOTelService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { IAgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; @@ -191,6 +192,19 @@ export function createTestAgentService( services.set(IAgentHostFileMonitorService, effectiveFileMonitorService); services.set(IAgentEditAttributionService, new NullAgentEditAttributionService()); services.set(IAgentHostWorktreeIsolation, worktreeIsolation.service); + services.set(IAgentHostOTelService, { + _serviceBrand: undefined, + getSdkTelemetryConfig: async () => undefined, + getNativeSdkTelemetryConfig: async () => undefined, + getSessionTraceContext: () => undefined, + setSessionComparisonMetadata: () => { }, + releaseSessionTraceContext: () => { }, + withTraceContext: (_context: IAgentHostTraceContext | undefined, fn: () => T): T => fn(), + getCurrentTraceContext: () => undefined, + getSpansDbPath: () => undefined, + emitSessionTitleChanged: () => { }, + flush: async () => { }, + }); const instantiationService = new InstantiationService(services, /*strict*/ true); const octoKitService = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostOctoKitService)); const effectiveCopilotApiService = instantiationService.invokeFunction(accessor => accessor.get(ICopilotApiService)); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index ce31d1b79d0c8f..bc0af72bab48b9 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -92,6 +92,7 @@ const noopOTelService: IAgentHostOTelService = { getSdkTelemetryConfig: async () => undefined, getNativeSdkTelemetryConfig: async () => undefined, getSessionTraceContext: () => undefined, + setSessionComparisonMetadata: () => { }, releaseSessionTraceContext: () => { }, withTraceContext: (_context: undefined, fn: () => T): T => fn(), getCurrentTraceContext: () => undefined, diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index ab6ba378367c80..a57b4cdb34cb06 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1067,6 +1067,7 @@ class RecordingOTelService implements IAgentHostOTelService { async getSdkTelemetryConfig(): Promise { return undefined; } async getNativeSdkTelemetryConfig(): Promise { return undefined; } getSessionTraceContext(): undefined { return undefined; } + setSessionComparisonMetadata(): void { } releaseSessionTraceContext(): void { } withTraceContext(_context: undefined, fn: () => T): T { return fn(); } getCurrentTraceContext(): undefined { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index affbe13bec0685..e9a099cf64484e 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -878,6 +878,10 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { outputTokens: 34, cacheReadTokens: 5, model: 'claude-test', + _meta: { + turnTokenTotals: [{ model: 'claude-test', inputTokens: 12, cachedTokens: 5, outputTokens: 34 }], + directTurnTokenTotals: [{ model: 'claude-test', inputTokens: 12, cachedTokens: 5, outputTokens: 34 }], + }, }, }, }, diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts index facf9d8004b219..5adf8ef9e869ef 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -259,7 +259,12 @@ suite('codexMapAppServerEvents', () => { outputTokens: 6, model: 'codex-model:openai:gpt-5.6-sol', cacheReadTokens: 4, - _meta: { reasoningOutputTokens: 2, modelContextWindow: 200000 }, + _meta: { + reasoningOutputTokens: 2, + modelContextWindow: 200000, + turnTokenTotals: [{ model: 'codex-model:openai:gpt-5.6-sol', inputTokens: 10, cachedTokens: 4, outputTokens: 6 }], + directTurnTokenTotals: [{ model: 'codex-model:openai:gpt-5.6-sol', inputTokens: 10, cachedTokens: 4, outputTokens: 6 }], + }, }, }]); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts index db5c65411b914e..19273a29eb7559 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts @@ -47,6 +47,7 @@ class RecordingOTelService implements IAgentHostOTelService { async getSdkTelemetryConfig(): Promise { return undefined; } async getNativeSdkTelemetryConfig(): Promise { return undefined; } getSessionTraceContext(): undefined { return undefined; } + setSessionComparisonMetadata(): void { } releaseSessionTraceContext(): void { } withTraceContext(_context: undefined, fn: () => T): T { return fn(); } getCurrentTraceContext(): undefined { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 814d291b46b7b6..262aedb2e87910 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -897,6 +897,7 @@ class MockAgentHostOTelService implements IAgentHostOTelService { } async getNativeSdkTelemetryConfig() { return undefined; } getSessionTraceContext() { return undefined; } + setSessionComparisonMetadata() { } releaseSessionTraceContext() { } withTraceContext(_context: undefined, fn: () => T): T { return fn(); } getCurrentTraceContext() { return undefined; } @@ -934,6 +935,7 @@ class RecordingReleaseOTelService implements IAgentHostOTelService { async getSdkTelemetryConfig() { return undefined; } async getNativeSdkTelemetryConfig() { return undefined; } getSessionTraceContext() { return undefined; } + setSessionComparisonMetadata() { } releaseSessionTraceContext(sessionUri: string): void { this.released.push(sessionUri); } diff --git a/src/vs/platform/agentHost/test/node/otel/agentHostOTelService.integrationTest.ts b/src/vs/platform/agentHost/test/node/otel/agentHostOTelService.integrationTest.ts index 629aa42bb1317f..d9b2a92b8fcbaf 100644 --- a/src/vs/platform/agentHost/test/node/otel/agentHostOTelService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/otel/agentHostOTelService.integrationTest.ts @@ -18,7 +18,7 @@ import { IOtlpExportTraceServiceRequest, OtlpSpanKind, } from '../../../../otel/node/otlp/otlpJsonTypes.js'; -import { AgentHostSessionTitleAttribute, AgentHostSessionTitleSpanName, AgentHostSessionUriAttribute, IAgentHostOTelService } from '../../../common/otel/agentHostOTelService.js'; +import { AgentHostComparisonAttemptCountAttribute, AgentHostComparisonAttemptIndexAttribute, AgentHostComparisonIdAttribute, AgentHostComparisonRoleAttribute, AgentHostSessionSpanName, AgentHostSessionTitleAttribute, AgentHostSessionTitleSpanName, AgentHostSessionUriAttribute, IAgentHostOTelService } from '../../../common/otel/agentHostOTelService.js'; import { AgentHostOTelService, normalizeAgentHostOtlpBody, readAgentHostOTelEnv } from '../../../node/otel/agentHostOTelService.js'; import { AgentHostOTelSpansDbSubPath } from '../../../common/agentService.js'; @@ -394,6 +394,53 @@ suite('platform/agentHost - AgentHostOTelService (integration)', () => { } }); + test('DB mode: adds bounded comparison metadata to the session anchor', async () => { + const saved = saveEnv(); + const tmp = await mkdtemp(join(tmpdir(), 'vscode-otel-svc-')); + const cleanup = () => rm(tmp, { recursive: true, force: true }).catch(() => undefined); + try { + process.env.COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED = 'true'; + const di = store.add(new TestInstantiationService()); + di.set(ILogService, new NullLogService()); + di.set(INativeEnvironmentService, makeEnvService(tmp)); + const svc = store.add(di.createInstance(AgentHostOTelService, undefined)); + + await svc.getSdkTelemetryConfig(); + svc.setSessionComparisonMetadata('claude:/attempt', { + id: 'comparison-id', + role: 'attempt', + attemptIndex: 1, + attemptCount: 3, + }); + svc.getSessionTraceContext('conversation', 'claude:/attempt'); + await svc.flush(); + + const dbPath = svc.getSpansDbPath(); + ok(dbPath); + const reader = new OTelSqliteStore(dbPath!.fsPath); + try { + const anchor = reader.getSpansByConversationId('conversation').find(span => span.name === AgentHostSessionSpanName); + ok(anchor); + deepStrictEqual({ + comparisonId: reader.getSpanAttribute(anchor.span_id, AgentHostComparisonIdAttribute), + role: reader.getSpanAttribute(anchor.span_id, AgentHostComparisonRoleAttribute), + attemptIndex: reader.getSpanAttribute(anchor.span_id, AgentHostComparisonAttemptIndexAttribute), + attemptCount: reader.getSpanAttribute(anchor.span_id, AgentHostComparisonAttemptCountAttribute), + }, { + comparisonId: 'comparison-id', + role: 'attempt', + attemptIndex: '1', + attemptCount: '3', + }); + } finally { + reader.close(); + } + } finally { + restoreEnv(saved); + await cleanup(); + } + }); + test('DB mode: emits session title metadata spans when content capture is enabled', async () => { const saved = saveEnv(); const tmp = await mkdtemp(join(tmpdir(), 'vscode-otel-svc-')); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index fa22b59568cf47..b4fde0c25c2c7a 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -31,7 +31,7 @@ The workbench omits the standard Activity Bar, Status Bar, and Banner. Part posi | Panel | Terminal and other panel views | | Custom View Grid | Full-surface contributed views that replace session content | -The Sessions Part contains its own horizontal grid. Its leaves are not workbench editor groups. +The Sessions Part contains its own session grid. Its leaves are not workbench editor groups. ## Grid behavior @@ -56,6 +56,8 @@ The Sessions Part renders that model. It does not create a second active-session Multiple visible sessions share the available Sessions Part width. Opening, closing, and reordering views operate through `ISessionsService`. +`ISessionsService.openSessionsInGrid` opens a set of existing sessions in a tiled arrangement without creating sessions or sending requests. The view service owns and restores the arrangement mode with the visible-session snapshot; the Sessions Part derives a roughly square row-and-column shape from the session count while retaining the live session views. Phone layouts stack the sessions in one column. Ordinary session opens retain the horizontal presentation, and opening a session outside the tiled set returns to that presentation. + ## Editor presentation The Agents Window supports two presentation families: diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md index 44a4798e00a87d..64da72b5f7c618 100644 --- a/src/vs/sessions/README.md +++ b/src/vs/sessions/README.md @@ -36,6 +36,7 @@ Do not turn those files into general Sessions guidance. | Session-aware layout capture and restoration | [LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md) | | Single-pane behavior scenarios | [SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md) | | Sessions sidebar list | [SESSIONS_LIST.md](SESSIONS_LIST.md) | +| Multi-harness implementation comparisons | [SESSION_COMPARISONS.md](SESSION_COMPARISONS.md) | | Phone layout and mobile components | [MOBILE.md](MOBILE.md) | | AI customizations | [AI_CUSTOMIZATIONS.md](AI_CUSTOMIZATIONS.md) | | Copilot customizations | [copilot-customizations-spec.md](copilot-customizations-spec.md) | diff --git a/src/vs/sessions/SESSION_COMPARISONS.md b/src/vs/sessions/SESSION_COMPARISONS.md new file mode 100644 index 00000000000000..f7fc5071602504 --- /dev/null +++ b/src/vs/sessions/SESSION_COMPARISONS.md @@ -0,0 +1,89 @@ +# Session comparison architecture + +> **Specification change gate:** Update this document only when comparison ownership, participant roles, persistence, or lifecycle invariants change. + +## Scope + +Session comparisons run the same task through multiple Sessions providers and preserve every implementation in an isolated worktree. The workflow is provider-neutral: comparison code uses `ISessionsManagementService`, while providers remain responsible for listing and resolving their own model identifiers, creating worktrees, and deleting sessions. + +## Ownership + +| Concern | Owner | +|---|---| +| Comparison records, participant lifecycle, and selection | `ISessionComparisonService` | +| Attempt and Judge harness/provider-local model selection | new-session composer | +| Session creation, model resolution, and worktree isolation | Sessions provider through `ISessionsManagementService` | +| Attempt evidence and user actions | Judge chat result and comparison parent grid | +| Bounded attempt manifest | `readAttemptComparison` tool | +| Targeted transcript follow-up | existing Agent Host `get_session_context` tool | +| Structured recommendation | visible grouped Judge session and `completeAttemptComparison` tool | + +Comparison records are persisted in profile storage. Session and chat resources remain provider-owned identities; the comparison record snapshots only the final content-free token summary needed to preserve attempt evidence after a reload. +The Sessions group service persists the comparison's session membership so the hierarchy survives window reloads. + +## Participant hierarchy + +Each comparison has one visible Sessions group containing all of its participants: + +- **Attempt:** one uniquely identified setup entry with one selected harness, one provider-local model selection, and one isolated worktree. Multiple attempts may use the same harness and model. +- **Judge:** uses the harness and model selected in comparison setup, starts after at least two successfully launched attempts reach a terminal state, and submits one structured verdict. +- **Synthesis:** optional new attempt using the recommended or selected attempt's harness. It never mutates an original attempt. + +The comparison service creates attempts directly and adds each launched participant to the ordinary Sessions group. The group displays the Judge and synthesis first, followed by attempts in their stable launch order; only attempts use connector decoration. It reconciles every participant back into that group as provider catalogs hydrate, so attempts, the Judge, and synthesis cannot fall back into separate workspace sections after a reload. It does not create a model-backed coordinator: orchestration is deterministic service behavior, and no model participant may create a second session tree. + +## Lifecycle invariants + +1. The prompt, attachments, workspace, branch, permission level, and Judge harness/model are frozen at launch. The prompt and attachments are shared across attempts, and each attempt independently selects a model advertised by its harness provider. +2. Every harness must support worktree configuration. Model identifiers remain provider-local and are never matched across providers by identifier or display name. +3. Attempts launch concurrently. One launch failure is recorded without deleting successful attempts. If fewer than two attempts launch, comparison setup fails and any successful sessions remain available outside the comparison group. Opening the comparison parent presents every available participant session in participant order in a tiled Sessions grid, including attempts, the Judge, and synthesis when they exist. +4. The Judge calls `readAttemptComparison` once to obtain the original task, successful participants, worktree locations, changed files, change summaries, and exact provider-owned transcript targets. Because terminal commands start in the Judge worktree, it explicitly changes to the manifest's exact attempt working directory for every command that inspects or validates that attempt. It reviews every attempt's diff, calls the existing `get_session_context` tool with those exact targets to inspect validation claims or other focused transcript evidence, and runs missing targeted validation when needed. It records whether each validation result came from the attempt report, a Judge run, unavailable evidence, or did not apply, then successfully calls `completeAttemptComparison`. A rejected invalid verdict may be corrected and retried, but a successful verdict is not resubmitted. It does not discover sessions, guess references, create sessions, or modify attempts. +5. After the Judge submits a verdict, its chat remains open and presents the winning attempt, supporting evidence, strong points from other attempts, and actions to focus the winner or start synthesis. Focusing the winner records a preference and opens its session without automatically opening its Changes editor; it does not apply changes to the user's working tree. +6. Judge recommendations are advisory. The Judge may describe semantic decision sections with the relevant files and each attempt's approach. Before synthesis, the user may persist a plan that chooses an attempt for each section or delegates that section to the synthesis agent. Synthesis starts only through an explicit user action, treats the plan as user requirements, and creates a new isolated grouped participant. Original attempts remain available. + +## Evidence + +The Judge result uses the persisted structured verdict and provider-neutral participant state. When an attempt becomes terminal, the comparison service persists aggregate input, cached-input, and output totals plus their per-model breakdown and completeness. Missing evidence is shown as unknown rather than inferred as successful or equal. + +`readAttemptComparison` is intentionally a bounded manifest rather than a second transcript API. Agent Host already owns transcript retrieval through `get_session_context`, including summary, digest, and full detail levels. For attempts owned by the same provider authority as the Judge, the manifest maps provider-neutral participant records to exact provider-owned targets accepted by that existing tool. Cross-provider or cross-host attempts remain comparable through their bounded change, worktree, and validation evidence, but do not advertise an unusable transcript target. Token usage is not included in the Judge manifest or prompts, so it remains informational and cannot silently become a ranking criterion. + +Both comparison tools are registered as ordinary workbench language-model tools and members of a hidden internal tool set. This follows the same client-tool publication path as other workbench-provided Agent Host tools: `AgentHostActiveClientService` publishes enabled tool-set members through `SessionActiveClient.tools`, and the owning VS Code client executes their implementations. Registering a tool without adding it to a tool set does not make it available to Agent Host sessions. + +## End-to-end flow + +```mermaid +flowchart TD + Composer[New-session composer] --> Setup[Compare agents setup
Prompt + N attempt agent/models + Judge agent/model] + Setup -->|Run Attempts| Service[SessionComparisonService
Create comparison record and Sessions group] + + subgraph Group[One comparison group] + direction TB + A1[Attempt 1 session
isolated worktree] + A2[Attempt 2 session
isolated worktree] + AN[Attempt N session
isolated worktree] + Judge[Judge session
selected agent + model] + Synthesis[Synthesis session
optional isolated worktree] + end + + Service -->|createAndSendNewChatRequest| A1 + Service -->|createAndSendNewChatRequest| A2 + Service -->|createAndSendNewChatRequest| AN + Service -->|Open comparison parent| Grid[Sessions grid
all available participants] + Grid -.-> A1 + Grid -.-> A2 + Grid -.-> AN + Grid -.-> Judge + Grid -.-> Synthesis + A1 --> Terminal{At least two launched attempts terminal} + A2 --> Terminal + AN --> Terminal + Terminal -->|createAndSendNewChatRequest| Judge + + Judge -->|1. readAttemptComparison comparisonId| Manifest[Bounded manifest
task + participant IDs + worktrees
changed files + change summaries + context targets] + Manifest --> Judge + Judge -.->|2. get_session_context exact target
only when more transcript evidence is needed| Context[Existing Agent Host transcript reader] + Context -.-> Judge + Judge -->|3. completeAttemptComparison successfully| Verdict[Persisted structured verdict] + Verdict -->|Render in Judge chat| Result[Judge result
winner + evidence + other strengths] + Result -->|Focus winner explicitly| A1 + Result -->|Synthesize explicitly| Synthesis +``` diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 9b4212828f099a..6d308f9b0472e8 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -255,6 +255,17 @@ gap: 4px; } +.session-header-bar .chat-composite-bar-title-actions .action-label.codicon-close { + opacity: 0; + pointer-events: none; +} + +.session-header-bar:hover .chat-composite-bar-title-actions .action-label.codicon-close, +.session-header-bar:focus-within .chat-composite-bar-title-actions .action-label.codicon-close { + opacity: 1; + pointer-events: auto; +} + /* Floating variant: a lightweight, absolutely positioned toolbar with no header chrome */ .chat-composite-bar.chat-composite-bar-toolbar-floating { position: absolute; diff --git a/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts index 6b5c95494350f4..9598cc215c1412 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts @@ -20,6 +20,10 @@ import { isPhoneLayout } from './mobileLayout.js'; */ export class MobileSessionsPart extends SessionsPart { + protected override getGridColumnCount(count: number): number { + return isPhoneLayout(this.layoutService) ? 1 : super.getGridColumnCount(count); + } + override updateStyles(): void { // Always run the desktop implementation first so inline styles are // set on tablet/desktop transitions. In phone mode we then clear @@ -50,7 +54,7 @@ export class MobileSessionsPart extends SessionsPart { // Full dimensions - no card margins or session-bar subtraction. const { contentSize } = this.layoutContents(width, height); - this._gridWidget?.layout(contentSize.width, contentSize.height, top, left); + this.layoutSessionGrid(contentSize.width, contentSize.height, top, left); Part.prototype.layout.call(this, width, height, top, left); } } diff --git a/src/vs/sessions/browser/parts/sessionGridLayout.ts b/src/vs/sessions/browser/parts/sessionGridLayout.ts new file mode 100644 index 00000000000000..2277dd40b4b9ae --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionGridLayout.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Direction, Grid, IView, Orientation, Sizing } from '../../../base/browser/ui/grid/grid.js'; + +export function getSessionGridColumns(count: number): number { + return Math.max(1, Math.ceil(Math.sqrt(count))); +} + +/** Rearranges existing views without disposing their chat widgets. */ +export function arrangeSessionGrid(grid: Grid, views: readonly T[], columns: number): void { + if (views.length === 0) { + return; + } + for (let i = views.length - 1; i > 0; i--) { + grid.removeView(views[i]); + } + grid.orientation = Orientation.VERTICAL; + for (let i = columns; i < views.length; i += columns) { + grid.addView(views[i], Sizing.Distribute, views[i - columns], Direction.Down); + } + for (let row = 0; row < views.length; row += columns) { + for (let i = row + 1; i < Math.min(row + columns, views.length); i++) { + grid.addView(views[i], Sizing.Distribute, views[i - 1], Direction.Right); + } + } + grid.distributeViewSizes(); +} diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 413b18caa54043..a07fefe378ec13 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -15,7 +15,7 @@ import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js' import { Direction, SerializableGrid, Sizing } from '../../../base/browser/ui/grid/grid.js'; import { Part } from '../../../workbench/browser/part.js'; import { ActiveSessionsContext, MultipleSessionsVisibleContext, SessionsFocusContext } from '../../common/contextkeys.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, isAncestorOfActiveElement, trackFocus } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, getActiveElement, isHTMLElement, isAncestor, isAncestorOfActiveElement, trackFocus } from '../../../base/browser/dom.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { SessionView } from './sessionView.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; @@ -31,6 +31,8 @@ import { IAgentWorkbenchLayoutService } from '../workbench.js'; import { applyAgentsPartCardStyles, getAgentsPartCardContentSize } from './agentsPartCard.js'; import { SessionsChatBackgroundRenderer } from '../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ISessionsChatBackgroundService } from '../../services/chatBackground/browser/chatBackgroundService.js'; +import { SessionGridLayout } from '../../services/sessions/browser/sessionsPartService.js'; +import { arrangeSessionGrid, getSessionGridColumns } from './sessionGridLayout.js'; interface IGridSlot { readonly view: SessionView; @@ -52,6 +54,8 @@ export class SessionsPart extends Part { /** Internal grid that hosts the part's session views. */ protected _gridWidget: SerializableGrid | undefined; + private _gridLayout: SessionGridLayout = 'columns'; + private _gridShape = ''; /** Lazily-created progress bar shown at the top of the content area. */ private _progressBar: ProgressBar | undefined; @@ -182,7 +186,7 @@ export class SessionsPart extends Part { * the number of visible sessions changes, and rebinds each slot to its * session by position via {@link SessionView.openSession}. */ - updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined): void { + updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined, layout: SessionGridLayout = 'columns'): void { if (!this._gridWidget) { return; } @@ -212,6 +216,8 @@ export class SessionsPart extends Part { slot.boundSessionId = session?.sessionId; slot.view.openSession(session, {}); } + this._gridLayout = layout; + this._arrangeGrid(); // Mark the active session's element for styling/focus indication. const activeId = active?.sessionId; @@ -234,6 +240,38 @@ export class SessionsPart extends Part { this._updateContextKeys(visible); } + private _arrangeGrid(): void { + if (!this._gridWidget) { + return; + } + const count = this._slots.length; + const columns = this._gridLayout === 'grid' ? this.getGridColumnCount(count) : count; + const shape = this._gridLayout === 'grid' ? `grid:${count}:${columns}` : 'columns'; + if (shape === this._gridShape) { + return; + } + if (shape === 'columns' && !this._gridShape) { + this._gridShape = shape; + return; + } + const focused = getActiveElement(); + const restoreFocus = isHTMLElement(focused) && isAncestor(focused, this._gridWidget.element); + const maximized = this._slots.find(slot => this._gridWidget!.isViewMaximized(slot.view)); + this._gridWidget.exitMaximizedView(); + arrangeSessionGrid(this._gridWidget, this._slots.map(slot => slot.view), columns); + this._gridShape = shape; + if (maximized) { + this._gridWidget.maximizeView(maximized.view); + } + if (restoreFocus) { + focused.focus(); + } + } + + protected getGridColumnCount(count: number): number { + return getSessionGridColumns(count); + } + private _updateContextKeys(visible: readonly (IActiveSession | undefined)[]): void { this._multipleSessionsVisibleKey.set(visible.length > 1); } @@ -319,7 +357,8 @@ export class SessionsPart extends Part { } const containerRect = this._gridWidget.element.getBoundingClientRect(); const viewRect = view.element.getBoundingClientRect(); - const isFullyVisible = viewRect.left >= containerRect.left - 1 && viewRect.right <= containerRect.right + 1; + const isFullyVisible = viewRect.left >= containerRect.left - 1 && viewRect.right <= containerRect.right + 1 + && viewRect.top >= containerRect.top - 1 && viewRect.bottom <= containerRect.bottom + 1; if (!isFullyVisible) { view.element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } @@ -431,13 +470,17 @@ export class SessionsPart extends Part { // Size the content area with the reduced dimensions. const { contentSize } = this.layoutContents(cardSize.width, cardSize.height); - // Layout the internal grid widget within the content area. - this._gridWidget?.layout(contentSize.width, contentSize.height, top, left); + this.layoutSessionGrid(contentSize.width, contentSize.height, top, left); // Store the full grid-allocated dimensions so that Part.relayout() works correctly. super.layout(width, height, top, left); } + protected layoutSessionGrid(width: number, height: number, top: number, left: number): void { + this._arrangeGrid(); + this._gridWidget?.layout(width, height, top, left); + } + override dispose(): void { for (const slot of this._slots) { slot.disposables.dispose(); diff --git a/src/vs/sessions/browser/parts/sessionsParts.ts b/src/vs/sessions/browser/parts/sessionsParts.ts index 54fe1f4da0c32c..8da72ac172ab37 100644 --- a/src/vs/sessions/browser/parts/sessionsParts.ts +++ b/src/vs/sessions/browser/parts/sessionsParts.ts @@ -14,7 +14,7 @@ import { SessionView } from './sessionView.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { IProgressIndicator } from '../../../platform/progress/common/progress.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { ISessionsPartService, IToggleMaximizeSessionEvent } from '../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsPartService, IToggleMaximizeSessionEvent, SessionGridLayout } from '../../services/sessions/browser/sessionsPartService.js'; /** * Owns the lifecycle of the {@link SessionsPart}. Selects the mobile vs. desktop @@ -50,8 +50,8 @@ export class SessionsParts extends Disposable implements ISessionsPartService { this._mainPart = this._register(instantiationService.createInstance(isPhoneLayout ? MobileSessionsPart : SessionsPart)); } - updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined): void { - this._mainPart.updateVisibleSessions(visible, active); + updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined, layout?: SessionGridLayout): void { + this._mainPart.updateVisibleSessions(visible, active, layout); } setContentVisible(visible: boolean): void { diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index 5d5b3d84455047..4711e5f82b146f 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -32,6 +32,75 @@ export function hashSessionIdForTelemetry(sessionId: string): string { return sha1.digest(); } +export type SessionComparisonAttemptTerminalStatus = 'completed' | 'error' | 'launchError'; +export type SessionComparisonUsageCompleteness = 'complete' | 'partial' | 'unavailable'; + +export interface ISessionComparisonAttemptCompletedTelemetry { + readonly comparisonId: string; + readonly agentSessionId?: string; + readonly attemptIndex: number; + readonly attemptCount: number; + readonly status: SessionComparisonAttemptTerminalStatus; + readonly elapsedMs?: number; + readonly inputTokenCount?: number; + readonly cachedInputTokenCount?: number; + readonly outputTokenCount?: number; + readonly usageCompleteness: SessionComparisonUsageCompleteness; +} + +type SessionComparisonAttemptCompletedEvent = ISessionComparisonAttemptCompletedTelemetry; + +type SessionComparisonAttemptCompletedClassification = { + owner: 'meganrogge'; + comment: 'Tracks terminal implementation attempts in Execute Parallel Agents, including aggregate token usage when available.'; + comparisonId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A hashed identifier used to correlate attempts from the same comparison.' }; + agentSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier, used to correlate with existing trusted model telemetry.' }; + attemptIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The zero-based ordinal of the attempt within the comparison.' }; + attemptCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of implementation attempts in the comparison.' }; + status: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the attempt completed, failed while running, or failed to launch.' }; + elapsedMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Elapsed attempt duration in milliseconds when a session was created.' }; + inputTokenCount?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Aggregate input token usage reported for the attempt.' }; + cachedInputTokenCount?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Aggregate cached input token usage reported for the attempt.' }; + outputTokenCount?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Aggregate output token usage reported for the attempt.' }; + usageCompleteness: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether token usage is complete, partial, or unavailable.' }; +}; + +export function logSessionComparisonAttemptCompleted(telemetryService: ITelemetryService, data: ISessionComparisonAttemptCompletedTelemetry): void { + telemetryService.publicLog2('agents/sessionComparisonAttemptCompleted', data); +} + +export interface ISessionComparisonAttemptJudgedTelemetry { + readonly comparisonId: string; + readonly agentSessionId?: string; + readonly attemptIndex: number; + readonly attemptCount: number; + readonly recommended: boolean; + readonly tests: string; + readonly build: string; + readonly lint: string; + readonly diagnostics: string; +} + +type SessionComparisonAttemptJudgedEvent = ISessionComparisonAttemptJudgedTelemetry; + +type SessionComparisonAttemptJudgedClassification = { + owner: 'meganrogge'; + comment: 'Relates Execute Parallel Agents attempts to the Judge recommendation and categorical validation outcome.'; + comparisonId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A hashed identifier used to correlate attempts from the same comparison.' }; + agentSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agent Host session identifier, used to correlate with attempt execution and trusted model telemetry.' }; + attemptIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The zero-based ordinal of the attempt within the comparison.' }; + attemptCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of implementation attempts in the comparison.' }; + recommended: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the Judge recommended this attempt.' }; + tests: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Judge-reported categorical test validation state.' }; + build: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Judge-reported categorical build validation state.' }; + lint: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Judge-reported categorical lint validation state.' }; + diagnostics: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Judge-reported categorical diagnostics validation state.' }; +}; + +export function logSessionComparisonAttemptJudged(telemetryService: ITelemetryService, data: ISessionComparisonAttemptJudgedTelemetry): void { + telemetryService.publicLog2('agents/sessionComparisonAttemptJudged', data); +} + // --- Titlebar button interactions --- export type SessionsInteractionButton = diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index fbde6ee1022fc0..f1e2b5f94050d0 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -51,7 +51,7 @@ import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SE import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { EditorAreaFocusContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; -import { NEW_SESSION_ACTION_ID, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; +import { COMPARE_AGENTS_ENABLED_SETTING, NEW_SESSION_ACTION_ID, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext, SessionsTitleBarNewSessionEnabledContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; @@ -410,6 +410,14 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, deprecationMessage: localize('chat.agentSessions.consolidatedRemoteWorkspaces.deprecated', "Deprecated. Use the unified workspace picker setting instead."), }, + [COMPARE_AGENTS_ENABLED_SETTING]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + description: localize('sessions.chat.compareAgents.enabled', "Controls whether the Execute Parallel Agents action is shown in eligible new-session agent pickers."), + tags: ['experimental'], + experiment: { mode: 'auto' }, + }, [AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING]: { type: 'boolean', default: true, diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 815b0407f98870..8216bd9f8657d9 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -54,6 +54,7 @@ import { ISessionOpenTelemetryService } from '../../../services/sessions/browser import { SessionArchiveNudge } from './sessionArchiveNudge.js'; import { SessionsChatBackgroundReplica } from '../../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ISessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { SessionComparisonResult } from './sessionComparisonResult.js'; const SESSION_CHAT_RESPONSE_INTERNAL_HORIZONTAL_PADDING = 12; @@ -178,6 +179,8 @@ export class ChatView extends AbstractChatView { /** Session banners (CI failures, created comments) shown above the chat input. */ private readonly _banners: SessionInputBanners; + /** Judge-only comparison result shown above the chat input. */ + private readonly _comparisonResult: SessionComparisonResult; /** Floating status pills (changes, preview, background activity) above the input. */ private readonly _chatPills: SessionChatInputToolbar; @@ -310,6 +313,11 @@ export class ChatView extends AbstractChatView { // Mount the session banners directly above the chat input. this._banners = this._register(instantiationService.createInstance(SessionInputBanners)); this._banners.setActive(this._isActive); + this._comparisonResult = this._register(scopedInstantiationService.createInstance( + SessionComparisonResult, + this._currentSessionObs, + () => this._layoutChatWidget(), + )); const archiveNudge = this._register(instantiationService.createInstance(SessionArchiveNudge, derived(this, reader => { if (!this._isVisibleObs.read(reader) || !this._isPrimaryObs.read(reader) || this.isLoadingTranscript.read(reader)) { @@ -712,12 +720,16 @@ export class ChatView extends AbstractChatView { const persistentContentContainer = this._widget.inputPart.persistentContentContainerElement; const pillsNode = this._chatPills.element; const bannersNode = this._banners.domNode; + const comparisonResultNode = this._comparisonResult.domNode; if (persistentContentContainer.firstChild !== pillsNode) { persistentContentContainer.insertBefore(pillsNode, persistentContentContainer.firstChild); } if (persistentContentContainer.nextSibling !== bannersNode) { inputPartElement.insertBefore(bannersNode, persistentContentContainer.nextSibling); } + if (bannersNode.nextSibling !== comparisonResultNode) { + inputPartElement.insertBefore(comparisonResultNode, bannersNode.nextSibling); + } } //#region Voice overlay diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index cfaf69f4e017a2..7d23dc54a0a800 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -416,6 +416,21 @@ transition: background-color 250ms ease, color 250ms ease; } +.sessions-chat-send-button.labeled { + width: auto; +} + +.sessions-chat-send-button.labeled .monaco-button { + width: auto; + min-width: 22px; + padding: 0 var(--vscode-spacing-size80); +} + +.action-widget .sessions-new-chat-picker-list .monaco-list-row.sessions-run-multiple-agents-action.has-toolbar .action-list-item-toolbar { + display: flex; + visibility: visible; +} + .sessions-chat-send-button .monaco-button.disabled { cursor: default; } diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionComparisonResult.css b/src/vs/sessions/contrib/chat/browser/media/sessionComparisonResult.css new file mode 100644 index 00000000000000..1092b0dcc3da6b --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/sessionComparisonResult.css @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-comparison-result { + margin: var(--vscode-spacing-size80) var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); +} + +.session-comparison-result-title { + margin: 0; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-result-explanation { + margin: var(--vscode-spacing-size80) 0 0; +} + +.session-comparison-result-subtitle { + margin: var(--vscode-spacing-size120) 0 var(--vscode-spacing-size60); + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-result-strengths { + width: 100%; + border-collapse: collapse; +} + +.session-comparison-result-strengths th, +.session-comparison-result-strengths td { + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + text-align: left; + vertical-align: top; +} + +.session-comparison-result-strengths th:first-child { + width: 30%; +} + +.session-comparison-result-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--vscode-spacing-size80); + margin-top: var(--vscode-spacing-size120); + padding-top: var(--vscode-spacing-size120); + border-top: var(--vscode-strokeThickness) solid var(--vscode-widget-border); +} + +.session-comparison-synthesis-plan { + margin-top: var(--vscode-spacing-size120); + padding-top: var(--vscode-spacing-size120); + border-top: var(--vscode-strokeThickness) solid var(--vscode-widget-border); +} + +.session-comparison-synthesis-plan-summary { + cursor: pointer; + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-synthesis-plan-description, +.session-comparison-synthesis-section-description { + margin: var(--vscode-spacing-size80) 0 0; +} + +.session-comparison-synthesis-section { + margin-top: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-sideBar-background); +} + +.session-comparison-synthesis-section-title { + margin: 0; + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-synthesis-section-select { + margin-top: var(--vscode-spacing-size120); +} + +.session-comparison-synthesis-section-select .monaco-select-box { + width: 100%; +} + +.session-comparison-synthesis-plan-actions { + display: flex; + justify-content: flex-end; + margin-top: var(--vscode-spacing-size120); +} diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionComparisonSetupDialog.css b/src/vs/sessions/contrib/chat/browser/media/sessionComparisonSetupDialog.css new file mode 100644 index 00000000000000..8b5a8d2f705625 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/sessionComparisonSetupDialog.css @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-comparison-setup-dialog { + width: min(560px, calc(100% - var(--vscode-spacing-size320))); +} + +.session-comparison-setup-dialog .dialog-message { + margin-bottom: var(--vscode-spacing-size80); +} + +.session-comparison-setup-dialog .dialog-message-text { + font-size: var(--vscode-fontSize-heading2); + font-weight: var(--vscode-fontWeight-semiBold); + letter-spacing: -0.01em; +} + +.monaco-dialog-box.session-comparison-setup-dialog .dialog-message-row .dialog-message-container { + overflow-y: hidden; +} + +.monaco-dialog-box.session-comparison-setup-dialog .dialog-message-body { + min-height: 0; + overflow: hidden; +} + +.session-comparison-setup-body { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + min-width: 0; + max-height: calc(90vh - 120px); +} + +.session-comparison-setup-validation, +.session-comparison-setup-usage { + color: var(--vscode-descriptionForeground); +} + +.session-comparison-setup-section-title { + margin: 0 0 var(--vscode-spacing-size60); + font-size: var(--vscode-fontSize-body1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-setup-prompt .monaco-inputbox { + width: 100%; + border-radius: var(--vscode-cornerRadius-small); +} + +.session-comparison-setup-prompt .monaco-inputbox textarea.input { + min-height: 52px; + line-height: 1.4; +} + +.session-comparison-setup-context-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--vscode-spacing-size40); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.session-comparison-setup-context-separator { + color: var(--vscode-descriptionForeground); +} + +.session-comparison-setup-context-value { + color: var(--vscode-foreground); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-setup-attempts { + display: flex; + flex-direction: column; + min-height: 0; +} + +.session-comparison-setup-rows-scroll { + flex: 1 1 auto; + min-height: 0; +} + +.session-comparison-setup-rows { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size60); + max-height: 220px; + overflow: hidden; + padding-right: var(--vscode-spacing-size60); +} + +.session-comparison-setup-row { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size60); + padding: var(--vscode-spacing-size80); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); +} + +.session-comparison-setup-row-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--vscode-spacing-size80); +} + +.session-comparison-setup-judge-description { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); +} + +.session-comparison-setup-row-header .monaco-button, +.session-comparison-setup-add.monaco-button { + width: auto; +} + +.session-comparison-setup-row-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--vscode-spacing-size80); +} + +.session-comparison-setup-field { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size40); + min-width: 0; +} + +.session-comparison-setup-field-label, +.session-comparison-setup-provider { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label1); +} + +.session-comparison-setup-label { + font-weight: var(--vscode-fontWeight-semiBold); +} + +.session-comparison-setup-select { + width: 100%; +} + +.session-comparison-setup-add { + align-self: flex-start; + margin-top: var(--vscode-spacing-size40); + border-color: transparent; + background-color: transparent; + color: var(--vscode-textLink-foreground); + padding-inline: var(--vscode-spacing-size40); +} + +.session-comparison-setup-add:hover { + border-color: transparent; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.session-comparison-setup-remove { + border-color: transparent; + background-color: transparent; + color: var(--vscode-descriptionForeground); + padding-inline: var(--vscode-spacing-size40); +} + +.session-comparison-setup-remove:hover { + border-color: transparent; + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.session-comparison-setup-evaluation { + border-top: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + padding-top: var(--vscode-spacing-size80); +} + +.session-comparison-setup-evaluation-summary { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + cursor: pointer; + color: var(--vscode-foreground); +} + +.session-comparison-setup-evaluation-summary:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-small); +} + +.session-comparison-setup-evaluation-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-regular); +} + +.session-comparison-setup-judge { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + padding-top: var(--vscode-spacing-size80); +} + +.session-comparison-setup-validation, +.session-comparison-setup-usage { + font-size: var(--vscode-fontSize-label1); +} + +.monaco-workbench.hc-black .session-comparison-setup-row, +.monaco-workbench.hc-light .session-comparison-setup-row { + border-color: var(--vscode-contrastBorder); +} diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index 47c812e6032210..3ce764930f5ef1 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -75,7 +75,8 @@ export class MobileSessionTypePicker extends SessionTypePicker { super._showPicker(anchor); return; } - if (this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked)) { + const additionalAction = this._getVisibleAdditionalAction(); + if (this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked) && !additionalAction) { return; } @@ -113,6 +114,16 @@ export class MobileSessionTypePicker extends SessionTypePicker { isFirstInGroup = false; } } + if (additionalAction) { + sheetItems.push({ + id: additionalAction.id, + label: additionalAction.label, + description: additionalAction.description, + icon: additionalAction.icon, + navigates: true, + sectionTitle: '', + }); + } const trigger = this._triggerElement; if (!trigger) { @@ -132,6 +143,11 @@ export class MobileSessionTypePicker extends SessionTypePicker { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); if (id !== undefined) { + const additionalAction = this._getVisibleAdditionalAction(); + if (additionalAction?.id === id) { + additionalAction.run(); + return; + } const [providerId, sessionTypeId] = id.split('\u0000'); if (providerId && sessionTypeId) { await this._selectSessionType({ providerId, sessionTypeId }); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 5eacb63ed0d1f3..210709e8c018fe 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -371,13 +371,15 @@ export interface INewChatInputSendRequest { * to add a bit of personality. One is picked per widget instance, avoiding * an immediate repeat of the previous pick. */ +export const NEW_SESSION_PROMPT_PLACEHOLDER = localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea"); + const RANDOM_PLACEHOLDERS = [ localize('sessionsChatInput.placeholder.whatAreYouBuilding', "What are you building?"), localize('sessionsChatInput.placeholder.whatWillYouShipToday', "What will you ship today?"), localize('sessionsChatInput.placeholder.describeWhatYouWantToBuild', "Describe what you want to build"), localize('sessionsChatInput.placeholder.whatsYourNextMilestone', "What's your next milestone?"), localize('sessionsChatInput.placeholder.whatAreYouTryingToAchieve', "What are you trying to achieve?"), - localize('sessionsChatInput.placeholder.pitchYourIdea', "Pitch your idea"), + NEW_SESSION_PROMPT_PLACEHOLDER, localize('sessionsChatInput.placeholder.whatsTheGoal', "What's the goal?"), localize('sessionsChatInput.placeholder.whatWillYouCreate', "What will you create?"), localize('sessionsChatInput.placeholder.whatFeatureAreYouDreamingUp', "What feature are you dreaming up?"), @@ -535,6 +537,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation renderRepositoryControls?: boolean; sessionTypePickerOptions?: ISessionTypePickerOptions; supportsBackground?: boolean; + sendButtonLabel?: IObservable; deferredNotificationsEnabled?: IObservable; petHostPreferred?: IObservable; getChatPetPlatformElements?: () => readonly HTMLElement[]; @@ -1281,6 +1284,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation ariaLabel: localize('send', "Send"), })); sendButton.icon = Codicon.arrowUpCompact; + if (this.options.sendButtonLabel) { + this._register(autorun(reader => { + const label = this.options.sendButtonLabel?.read(reader); + sendButton.label = label ?? ''; + sendButton.element.ariaLabel = label ?? localize('send', "Send"); + this._sendButtonContainer?.classList.toggle('labeled', !!label); + })); + } // Hold Alt while clicking Send to start the session in the background. this._register(sendButton.onDidClick(e => this._send(!!this.options.supportsBackground && !!(e as MouseEvent | KeyboardEvent | undefined)?.altKey))); } @@ -1913,6 +1924,15 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } prefillInput(text: string): void { + this.setInputValue(text); + this._editor?.focus(); + } + + getInputValue(): string { + return this._editor?.getModel()?.getValue() ?? ''; + } + + setInputValue(text: string): void { const editor = this._editor; const model = editor?.getModel(); if (editor && model) { @@ -1920,7 +1940,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const lastLine = model.getLineCount(); const maxColumn = model.getLineMaxColumn(lastLine); editor.setPosition({ lineNumber: lastLine, column: maxColumn }); - editor.focus(); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index c1b9def8e22c4b..0b5a51ccced596 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -11,17 +11,20 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { isCancellationError, onUnexpectedError } from '../../../../base/common/errors.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { SessionConfigKey } from '../../../../platform/agentHost/common/sessionConfigKeys.js'; import { localize } from '../../../../nls.js'; import { IActiveSession, ICreateNewSessionOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../services/sessions/common/session.js'; @@ -34,7 +37,7 @@ import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/a import { IWorkspacePickerNoWorkspaceOption, IWorkspacePickerTrigger, WorkspacePicker } from './sessionWorkspacePicker.js'; import { WebWorkspacePicker } from './webWorkspacePicker.js'; import { IPickedSessionType, IPreferredSessionType } from './sessionTypePicker.js'; -import { NewChatInputWidget } from './newChatInput.js'; +import { NEW_SESSION_PROMPT_PLACEHOLDER, NewChatInputWidget } from './newChatInput.js'; import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; @@ -57,8 +60,11 @@ import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTrac import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { Menus } from '../../../browser/menus.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../common/newChatContextIds.js'; -import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; +import { COMPARE_AGENTS_ENABLED_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ISessionComparisonAttemptConfiguration, ISessionComparisonHarness, ISessionComparisonService } from '../../../services/sessions/common/sessionComparison.js'; +import { OPEN_SESSION_COMPARISON_COMMAND_ID } from '../../sessionComparison/common/sessionComparison.js'; +import { SessionComparisonSetupDialog } from './sessionComparisonSetupDialog.js'; // #region --- New Chat Widget --- @@ -95,12 +101,16 @@ export class NewChatWidget extends Disposable { private readonly _isQuickChatComposer: IObservable; private readonly _isWorkspacePickerQuickChat: IObservable; private readonly _useConsolidatedRemoteWorkspaces: IObservable; + private readonly _compareAgentsEnabled: IObservable; /** Draft comments shared by every uncreated new-session composer. */ private readonly _feedbackItems: IObservable; /** In-flight background sends awaiting confirmation before their comments are cleared. */ private readonly _pendingBackgroundSends = this._register(new DisposableMap()); + private readonly _comparisonAttempts = observableValue(this, []); + private readonly _comparisonJudgeHarness = observableValue(this, undefined); + private readonly _comparisonSetupDialog = this._register(new MutableDisposable()); /** * Tracks whether the workspace picker is currently rendered (vs replaced by @@ -133,6 +143,8 @@ export class NewChatWidget extends Disposable { @IStorageService private readonly storageService: IStorageService, @INewSessionComposerService private readonly newSessionComposerService: INewSessionComposerService, @ICommandService private readonly commandService: ICommandService, + @ISessionComparisonService private readonly sessionComparisonService: ISessionComparisonService, + @INotificationService private readonly notificationService: INotificationService, ) { super(); this._workspacePickerVisibleKey = SessionWorkspacePickerVisibleContext.bindTo(contextKeyService); @@ -146,6 +158,7 @@ export class NewChatWidget extends Disposable { if (activeSession && activeSession.isCreated.read(reader)) { return prev; } + return activeSession; }); @@ -160,6 +173,11 @@ export class NewChatWidget extends Disposable { Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(UNIFIED_WORKSPACE_PICKER_SETTING)), () => this.configurationService.getValue(UNIFIED_WORKSPACE_PICKER_SETTING), ); + this._compareAgentsEnabled = observableFromEvent( + this, + Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(COMPARE_AGENTS_ENABLED_SETTING)), + () => this.configurationService.getValue(COMPARE_AGENTS_ENABLED_SETTING), + ); this._isWorkspacePickerQuickChat = derived(this, reader => { const session = this._session.read(reader); return this._useConsolidatedRemoteWorkspaces.read(reader) && !!session?.isQuickChat?.read(reader); @@ -235,6 +253,7 @@ export class NewChatWidget extends Disposable { this.storageService.onDidChangeValue(StorageScope.APPLICATION, TOTAL_SESSIONS_KEY, this._store), () => this._hasEnoughSessionsForFirstRunNotices(), ); + const comparisonDescription = localize('runMultipleAgents.description', "Compares results and lets you synthesize the best concepts"); const newChatInput = this.instantiationService.createInstance(NewChatInputWidget, { session: this._session, @@ -254,7 +273,7 @@ export class NewChatWidget extends Disposable { hasAdditionalSendContent: hasFeedback, loading, historyKey: constObservable(undefined), // no persisted history for the new-session view - placeholder: localize('newSessionPromptPlaceholder', "Pitch your idea"), + placeholder: NEW_SESSION_PROMPT_PLACEHOLDER, supportsBackground: true, deferredNotificationsEnabled, petHostPreferred: this.options.petHostPreferred, @@ -262,6 +281,18 @@ export class NewChatWidget extends Disposable { onDidChangeChatPetPlatform: this._workspacePicker.onDidChangeChatPetPlatform, sessionTypePickerOptions: { prepareSessionTypeSelection: pick => this._prepareSessionTypeSelection(pick), + additionalAction: { + id: 'sessions.runMultipleAgents', + label: localize('runMultipleAgents.label', "Execute Parallel Agents..."), + description: comparisonDescription, + icon: Codicon.layers, + isVisible: () => { + const session = this._session.get(); + const provider = session ? this.sessionsProvidersService.getProvider(session.providerId) : undefined; + return this._compareAgentsEnabled.get() && provider !== undefined && isAgentHostProvider(provider); + }, + run: () => void this._configureComparison(), + }, }, }); this._register(toDisposable(() => newChatInput.saveState())); @@ -531,6 +562,13 @@ export class NewChatWidget extends Disposable { this._register(autorun(reader => { const isQuickChat = this._isQuickChatComposer.read(reader); const isWorkspacePickerQuickChat = this._isWorkspacePickerQuickChat.read(reader); + this._compareAgentsEnabled.read(reader); + const session = this._session.read(reader); + session?.loading.read(reader); + const provider = session ? this.sessionsProvidersService.getProvider(session.providerId) : undefined; + if (session && provider && isAgentHostProvider(provider)) { + provider.isSessionConfigResolving(session.sessionId).read(reader); + } const useHeaderHost = isQuickChat && !isWorkspacePickerQuickChat; const target = useHeaderHost ? this._quickChatHeaderPickerHost : this._workspacePickerRow; if (!target) { @@ -882,6 +920,19 @@ export class NewChatWidget extends Disposable { }); } + private _getComparisonBranch(session = this._session.get()): string | undefined { + const provider = session ? this.sessionsProvidersService.getProvider(session.providerId) : undefined; + if (!session || !provider || !isAgentHostProvider(provider)) { + return undefined; + } + const branch = provider.getCreateSessionConfig(session.sessionId)?.[SessionConfigKey.Branch]; + if (typeof branch === 'string' && branch.trim()) { + return branch; + } + const workspace = session.workspace.get() ?? this._workspacePicker.selectedResolved?.workspace; + return workspace?.folders[0]?.gitRepository?.branchName?.trim() || undefined; + } + private _renderSessionTypePicker(container: HTMLElement, prependBeforeSiblings: boolean): void { this._newChatInput.sessionTypePicker.render(container, { className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', @@ -895,6 +946,70 @@ export class NewChatWidget extends Disposable { } } + private async _configureComparison(): Promise { + const workspace = this._workspacePicker.selectedFolderUri; + if (!workspace) { + this._workspacePicker.showPicker(); + return; + } + const session = this._session.get(); + const branch = session ? this._getComparisonBranch(session) : undefined; + if (!branch) { + this._workspacePicker.showPicker(); + return; + } + const currentType = session && this.sessionsManagementService.getSessionTypesForFolder(workspace).find(({ providerId, sessionType }) => + providerId === session.providerId && sessionType.id === session.sessionType); + const currentHarness = currentType ? { + providerId: currentType.providerId, + sessionTypeId: currentType.sessionType.id, + label: currentType.sessionType.label, + modelId: this._newChatInput.selectedModelState.get().currentModel?.identifier, + modelLabel: this._newChatInput.selectedModelState.get().currentModel?.metadata.name, + } : undefined; + const retainedAttempts = this._comparisonAttempts.get(); + const initialAttempts = retainedAttempts.length >= 2 + ? retainedAttempts + : currentHarness + ? [ + ...(retainedAttempts.length === 1 ? retainedAttempts : [{ id: generateUuid(), harness: currentHarness }]), + { id: generateUuid(), harness: currentHarness }, + ] + : retainedAttempts; + const initialJudgeHarness = this._comparisonJudgeHarness.get() ?? currentHarness ?? initialAttempts[0]?.harness; + if (!initialJudgeHarness) { + return; + } + const setupDialog = this._comparisonSetupDialog.value = this.instantiationService.createInstance(SessionComparisonSetupDialog); + let shouldRefocusInput = true; + try { + const result = await setupDialog.show({ + workspace, + workspaceLabel: this._workspacePicker.selectedResolved?.workspace.label ?? basename(workspace), + branch, + attachedContextCount: this._newChatInput.attachments.length, + prompt: this._newChatInput.getInputValue(), + setPrompt: prompt => this._newChatInput.setInputValue(prompt), + }, initialAttempts, initialJudgeHarness); + this._comparisonAttempts.set(result.attempts, undefined); + this._comparisonJudgeHarness.set(result.judgeHarness, undefined); + if (result.confirmed) { + if (await this._newChatInput.submit()) { + this._comparisonAttempts.set([], undefined); + this._comparisonJudgeHarness.set(undefined, undefined); + shouldRefocusInput = false; + } + } + } finally { + if (this._comparisonSetupDialog.value === setupDialog) { + this._comparisonSetupDialog.clear(); + } + } + if (shouldRefocusInput) { + this._newChatInput.focus(); + } + } + private _renderEmptyState(container: HTMLElement): IDisposable { this._workspacePickerVisibleKey.set(false); const emptyState = this.instantiationService.createInstance(NoAgentHostEmptyState); @@ -1001,6 +1116,84 @@ export class NewChatWidget extends Disposable { } } + if (this._comparisonAttempts.get().length > 0) { + const workspace = this._workspacePicker.selectedFolderUri; + if (!workspace) { + this._workspacePicker.showPicker(); + return false; + } + const permissionLevel = session.permissionLevel?.get(); + const branch = this._getComparisonBranch(session); + if (!branch) { + this.notificationService.error(localize('sessionComparison.gitRepositoryRequired', "Comparisons require a Git repository with at least one commit.")); + return false; + } + const availableTypes = this.sessionsManagementService.getSessionTypesForFolder(workspace); + const resolveHarness = (harness: ISessionComparisonHarness): ISessionComparisonHarness | undefined => { + const type = availableTypes.find(candidate => + candidate.providerId === harness.providerId && candidate.sessionType.id === harness.sessionTypeId && candidate.sessionType.supportsWorktreeConfiguration); + const resolution = type && harness.modelId + ? this.sessionsProvidersService.getProvider(type.providerId)?.getModelsSnapshotForCreation?.(workspace, type.sessionType.id, harness.modelId).desiredModelResolution + : undefined; + const resolvedModelId = resolution?.kind === 'available' ? resolution.model.identifier : undefined; + return type ? { + providerId: harness.providerId, + sessionTypeId: harness.sessionTypeId, + label: type.sessionType.label, + modelId: resolvedModelId, + modelLabel: resolution?.kind === 'available' ? resolution.model.metadata.name : undefined, + } : undefined; + }; + const attempts = this._comparisonAttempts.get().flatMap(attempt => { + const harness = resolveHarness(attempt.harness); + return harness ? [{ id: attempt.id, harness }] : []; + }); + if (attempts.length !== this._comparisonAttempts.get().length) { + this.notificationService.error(localize('sessionComparison.harnessUnavailable', "One or more selected agents no longer support this workspace or worktree isolation. Edit the comparison setup and choose another agent.")); + return false; + } + if (attempts.length < 2) { + this.notificationService.error(localize('sessionComparison.minimumAttempts', "Configure at least two attempts to start a comparison.")); + return false; + } + const unavailableModel = this._comparisonAttempts.get().find(attempt => + attempt.harness.modelId && !attempts.some(candidate => + candidate.id === attempt.id + && candidate.harness.modelId)); + if (unavailableModel) { + this.notificationService.error(localize('sessionComparison.modelUnavailable', "The selected model for {0} is no longer available. Edit the comparison setup and choose another model.", unavailableModel.harness.label)); + return false; + } + const selectedJudgeHarness = this._comparisonJudgeHarness.get(); + const judgeHarness = selectedJudgeHarness ? resolveHarness(selectedJudgeHarness) : undefined; + if (!selectedJudgeHarness || !judgeHarness) { + this.notificationService.error(localize('sessionComparison.judgeHarnessUnavailable', "The selected Judge agent no longer supports this workspace or worktree isolation. Edit the comparison setup and choose another agent.")); + return false; + } + if (selectedJudgeHarness.modelId && !judgeHarness.modelId) { + this.notificationService.error(localize('sessionComparison.judgeModelUnavailable', "The selected Judge model is no longer available. Edit the comparison setup and choose another model.")); + return false; + } + try { + this.sessionsService.unsetNewSession(); + const comparison = await this.sessionComparisonService.startComparison({ + workspace, + prompt: request, + attachedContext: requestContext.size > 0 ? [...requestContext.values()] : undefined, + attempts, + judgeHarness, + permissionLevel, + branch, + }); + await this.commandService.executeCommand(OPEN_SESSION_COMPARISON_COMMAND_ID, comparison.id); + return true; + } catch (error) { + this.logService.error('Failed to start session comparison:', error); + this.notificationService.error(error); + return false; + } + } + // Capture the composer's workspace selection before the send: a // background send consumes the in-flight new session and resets the // new-session view, so we re-seed a fresh pending session afterwards @@ -1133,6 +1326,10 @@ export class NewChatWidget extends Disposable { this._preferredDevContainerFolderUri = undefined; } const currentFolderUri = this._session.get()?.workspace.get()?.folders[0]?.root; + if (this._comparisonAttempts.get().length > 0 && (!folderUri || !currentFolderUri || !this.uriIdentityService.extUri.isEqual(currentFolderUri, folderUri))) { + this._comparisonAttempts.set([], undefined); + this._comparisonJudgeHarness.set(undefined, undefined); + } const refreshingPromptOptions = !!currentFolderUri && (!folderUri || !this.uriIdentityService.extUri.isEqual(currentFolderUri, folderUri)) && this._newChatInput.preparePromptOptionsRefresh(); diff --git a/src/vs/sessions/contrib/chat/browser/sessionComparisonResult.ts b/src/vs/sessions/contrib/chat/browser/sessionComparisonResult.ts new file mode 100644 index 00000000000000..f0eb25d0986d24 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionComparisonResult.ts @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionComparisonResult.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { defaultButtonStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { getSessionComparisonHarnessLabel, ISessionComparison, ISessionComparisonParticipant, ISessionComparisonService, ISessionComparisonSynthesisPlan, SessionComparisonParticipantRole } from '../../../services/sessions/common/sessionComparison.js'; + +export class SessionComparisonResult extends Disposable { + + readonly domNode = dom.$('.session-comparison-result'); + private readonly renderStore = this._register(new DisposableStore()); + private readonly titleId = `session-comparison-result-title-${generateUuid()}`; + private announcedComparisonId: string | undefined; + private renderedComparisonId: string | undefined; + private renderedVerdict: ISessionComparison['verdict']; + private renderedParticipants: ISessionComparison['participants'] | undefined; + + constructor( + currentSession: IObservable, + private readonly onDidChangeLayout: () => void, + @ISessionComparisonService private readonly comparisonService: ISessionComparisonService, + @ISessionsService private readonly sessionsService: ISessionsService, + @INotificationService private readonly notificationService: INotificationService, + @IContextViewService private readonly contextViewService: IContextViewService, + ) { + super(); + this.domNode.hidden = true; + this.domNode.setAttribute('role', 'region'); + this.domNode.setAttribute('aria-labelledby', this.titleId); + + this._register(autorun(reader => { + const session = currentSession.read(reader); + const comparison = session + ? this.comparisonService.comparisons.read(reader).find(candidate => isJudgeSession(candidate, session)) + : undefined; + if (comparison?.id === this.renderedComparisonId + && comparison?.verdict === this.renderedVerdict + && comparison?.participants === this.renderedParticipants) { + return; + } + this.render(comparison?.verdict ? comparison : undefined); + })); + } + + private render(comparison: ISessionComparison | undefined): void { + this.renderedComparisonId = comparison?.id; + this.renderedVerdict = comparison?.verdict; + this.renderedParticipants = comparison?.participants; + this.renderStore.clear(); + dom.clearNode(this.domNode); + const wasHidden = this.domNode.hidden; + this.domNode.hidden = !comparison; + if (!comparison?.verdict) { + if (!wasHidden) { + this.onDidChangeLayout(); + } + return; + } + + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + const winner = attempts.find(participant => participant.id === comparison.verdict?.recommendedParticipantId); + if (!winner) { + this.domNode.hidden = true; + return; + } + const winnerLabel = getSessionComparisonHarnessLabel(winner); + const title = dom.append(this.domNode, dom.$('h2.session-comparison-result-title')); + title.id = this.titleId; + title.textContent = + localize('sessionComparisonResult.winner', "{0} won", winnerLabel); + dom.append(this.domNode, dom.$('h3.session-comparison-result-subtitle')).textContent = + localize('sessionComparisonResult.whyWinner', "Why it won"); + dom.append(this.domNode, dom.$('p.session-comparison-result-explanation')).textContent = comparison.verdict.explanation; + + const otherAttempts = attempts.filter(attempt => attempt.id !== winner.id); + if (otherAttempts.length > 0) { + const strengthsTitle = dom.append(this.domNode, dom.$('h3.session-comparison-result-subtitle')); + strengthsTitle.id = `session-comparison-strengths-${generateUuid()}`; + strengthsTitle.textContent = + localize('sessionComparisonResult.otherStrengths', "Strong points from other attempts"); + const table = dom.append(this.domNode, dom.$('table.session-comparison-result-strengths')); + table.setAttribute('aria-labelledby', strengthsTitle.id); + const head = dom.append(table, dom.$('thead')); + const headerRow = dom.append(head, dom.$('tr')); + const attemptHeader = dom.append(headerRow, dom.$('th')); + attemptHeader.setAttribute('scope', 'col'); + attemptHeader.textContent = localize('sessionComparisonResult.attempt', "Attempt"); + const strengthsHeader = dom.append(headerRow, dom.$('th')); + strengthsHeader.setAttribute('scope', 'col'); + strengthsHeader.textContent = localize('sessionComparisonResult.strongPoints', "Strong points"); + const body = dom.append(table, dom.$('tbody')); + for (const attempt of otherAttempts) { + const verdict = comparison.verdict.attempts.find(candidate => candidate.participantId === attempt.id); + const strengths = verdict?.notableDifferences.length ? verdict.notableDifferences : verdict?.summary ? [verdict.summary] : []; + const row = dom.append(body, dom.$('tr')); + const label = getSessionComparisonHarnessLabel(attempt); + const attemptHeader = dom.append(row, dom.$('th')); + attemptHeader.setAttribute('scope', 'row'); + attemptHeader.textContent = label; + dom.append(row, dom.$('td')).textContent = strengths.length > 0 + ? strengths.join('; ') + : localize('sessionComparisonResult.noStrengths', "No distinct strong points reported"); + } + } + + const actions = dom.append(this.domNode, dom.$('.session-comparison-result-actions')); + actions.setAttribute('role', 'group'); + actions.setAttribute('aria-label', localize('sessionComparisonResult.actionsAriaLabel', "Comparison result actions")); + if (winner.sessionResource) { + const focusWinner = this.renderStore.add(new Button(actions, { + ...defaultButtonStyles, + secondary: true, + ariaLabel: localize('sessionComparisonResult.focusWinnerAriaLabel', "Focus winning session, {0}", winnerLabel), + })); + focusWinner.label = localize('sessionComparisonResult.focusWinner', "Focus Winning Session"); + this.renderStore.add(focusWinner.onDidClick(() => this.focusWinner(comparison, winner, focusWinner))); + } + + const synthesis = comparison.participants.find(participant => participant.role === SessionComparisonParticipantRole.Synthesis); + if (!synthesis && comparison.verdict.decisionSections?.length) { + this.renderSynthesisPlan(comparison, attempts); + } + const synthesize = this.renderStore.add(new Button(actions, { + ...defaultButtonStyles, + ariaLabel: synthesis + ? localize('sessionComparisonResult.synthesisStartedAriaLabel', "Synthesis has started") + : localize('sessionComparisonResult.synthesizeAriaLabel', "Synthesize using the Judge recommendation"), + })); + synthesize.label = synthesis + ? localize('sessionComparisonResult.synthesisStarted', "Synthesis Started") + : localize('sessionComparisonResult.synthesize', "Synthesize Recommended"); + synthesize.enabled = !synthesis; + if (!synthesis) { + this.renderStore.add(synthesize.onDidClick(() => this.synthesize(comparison, synthesize, undefined))); + } + + if (this.announcedComparisonId !== comparison.id) { + this.announcedComparisonId = comparison.id; + status(localize('sessionComparisonResult.ready', "{0} won. Comparison result ready.", winnerLabel)); + } + if (wasHidden) { + this.onDidChangeLayout(); + } + } + + private renderSynthesisPlan(comparison: ISessionComparison, attempts: readonly ISessionComparisonParticipant[]): void { + const decisionSections = comparison.verdict?.decisionSections ?? []; + const attemptLabels = new Map(attempts.map(attempt => [attempt.id, getSessionComparisonHarnessLabel(attempt)])); + const storedSelections = new Map(comparison.synthesisPlan?.selections.map(selection => [selection.sectionId, selection.participantId])); + const selections = new Map(); + const details = dom.append(this.domNode, dom.$('details.session-comparison-synthesis-plan')); + const summary = dom.append(details, dom.$('summary.session-comparison-synthesis-plan-summary')); + summary.textContent = localize('sessionComparisonResult.customizeSynthesis', "Customize Synthesis"); + this.renderStore.add(dom.addDisposableListener(details, 'toggle', this.onDidChangeLayout)); + dom.append(details, dom.$('p.session-comparison-synthesis-plan-description')).textContent = + localize('sessionComparisonResult.customizeSynthesisDescription', "Choose which attempt's approach the synthesis agent should follow for each implementation decision. The agent will reconcile dependencies and validate the combined result in a new worktree."); + + for (const section of decisionSections) { + const card = dom.append(details, dom.$('section.session-comparison-synthesis-section')); + dom.append(card, dom.$('h4.session-comparison-synthesis-section-title')).textContent = section.title; + dom.append(card, dom.$('p.session-comparison-synthesis-section-description')).textContent = section.description; + const selectContainer = dom.append(card, dom.$('.session-comparison-synthesis-section-select')); + const options = [ + { text: localize('sessionComparisonResult.synthesizerDecides', "Let Synthesizer Decide") }, + ...section.options.map(option => ({ + text: attemptLabels.get(option.participantId) ?? option.participantId, + detail: option.approach, + decoratorRight: option.participantId === section.recommendedParticipantId + ? localize('sessionComparisonResult.recommended', "Recommended") + : undefined, + })), + ]; + const storedParticipantId = storedSelections.has(section.id) + ? storedSelections.get(section.id) + : section.recommendedParticipantId; + const selectedIndex = Math.max(0, section.options.findIndex(option => option.participantId === storedParticipantId) + 1); + selections.set(section.id, selectedIndex === 0 ? undefined : section.options[selectedIndex - 1].participantId); + const select = this.renderStore.add(new SelectBox(options, selectedIndex, this.contextViewService, defaultSelectBoxStyles, { + ariaLabel: localize('sessionComparisonResult.sectionSelection', "Approach for {0}", section.title), + useCustomDrawn: true, + contextViewLayer: 1, + })); + select.render(selectContainer); + this.renderStore.add(select.onDidSelect(({ index }) => { + selections.set(section.id, index === 0 ? undefined : section.options[index - 1].participantId); + this.comparisonService.setSynthesisPlan(comparison.id, createSynthesisPlan(decisionSections, selections)); + })); + } + + const actions = dom.append(details, dom.$('.session-comparison-synthesis-plan-actions')); + const start = this.renderStore.add(new Button(actions, { + ...defaultButtonStyles, + ariaLabel: localize('sessionComparisonResult.startPlannedSynthesisAriaLabel', "Start synthesis with the selected approaches"), + })); + start.label = localize('sessionComparisonResult.startPlannedSynthesis', "Start Planned Synthesis"); + this.renderStore.add(start.onDidClick(() => this.synthesize(comparison, start, createSynthesisPlan(decisionSections, selections)))); + } + + private async focusWinner(comparison: ISessionComparison, winner: ISessionComparisonParticipant, button: Button): Promise { + if (!winner.sessionResource) { + return; + } + button.enabled = false; + try { + this.comparisonService.selectAttempt(comparison.id, winner.id); + await this.sessionsService.openSession(winner.sessionResource, { source: 'chat' }); + } catch (error) { + this.notificationService.error(error); + button.enabled = true; + } + } + + private async synthesize(comparison: ISessionComparison, button: Button, plan: ISessionComparisonSynthesisPlan | undefined): Promise { + button.enabled = false; + try { + this.comparisonService.setSynthesisPlan(comparison.id, plan); + await this.comparisonService.synthesize(comparison.id); + } catch (error) { + this.notificationService.error(error); + button.enabled = true; + } + } + +} + +function createSynthesisPlan( + sections: NonNullable['decisionSections'], + selections: ReadonlyMap, +): ISessionComparisonSynthesisPlan { + return { + selections: (sections ?? []).map(section => ({ + sectionId: section.id, + participantId: selections.get(section.id), + })), + }; +} + +function isJudgeSession(comparison: ISessionComparison, session: ISession): boolean { + return comparison.participants.some(participant => + participant.role === SessionComparisonParticipantRole.Judge + && !!participant.sessionResource + && isEqual(participant.sessionResource, session.resource), + ); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionComparisonSetupDialog.ts b/src/vs/sessions/contrib/chat/browser/sessionComparisonSetupDialog.ts new file mode 100644 index 00000000000000..125360f885f9fe --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionComparisonSetupDialog.ts @@ -0,0 +1,407 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionComparisonSetupDialog.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; +import { Button, IButton } from '../../../../base/browser/ui/button/button.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { localize } from '../../../../nls.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultDialogStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionComparisonAttemptConfiguration, ISessionComparisonHarness } from '../../../services/sessions/common/sessionComparison.js'; +import { NEW_SESSION_PROMPT_PLACEHOLDER } from './newChatInput.js'; + +export interface ISessionComparisonSetupContext { + readonly workspace: URI; + readonly workspaceLabel: string; + readonly branch?: string; + readonly attachedContextCount: number; + readonly prompt: string; + readonly setPrompt: (prompt: string) => void; +} + +export interface ISessionComparisonSetupResult { + readonly confirmed: boolean; + readonly attempts: readonly ISessionComparisonAttemptConfiguration[]; + readonly judgeHarness: ISessionComparisonHarness; +} + +function harnessKey(providerId: string, sessionTypeId: string): string { + return `${providerId}\0${sessionTypeId}`; +} + +export class SessionComparisonSetupDialog extends Disposable { + + private readonly activeDialog = this._register(new MutableDisposable()); + + constructor( + @IContextViewService private readonly contextViewService: IContextViewService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + } + + async show(context: ISessionComparisonSetupContext, initialAttempts: readonly ISessionComparisonAttemptConfiguration[], initialJudgeHarness: ISessionComparisonHarness): Promise { + const disposables = new DisposableStore(); + this.activeDialog.value = disposables; + const rowsDisposables = disposables.add(new DisposableStore()); + let attempts = [...initialAttempts]; + let body: HTMLElement | undefined; + let confirmButton: IButton | undefined; + let validationElement: HTMLElement | undefined; + let prompt = context.prompt; + let judgeHarness = initialJudgeHarness; + let evaluationExpanded = false; + let renderedEvaluation: HTMLDetailsElement | undefined; + + const getHarnesses = (): readonly ISessionComparisonHarness[] => this.sessionsManagementService.getSessionTypesForFolder(context.workspace) + .filter(({ sessionType }) => sessionType.supportsWorktreeConfiguration) + .map(({ providerId, sessionType }) => ({ + providerId, + sessionTypeId: sessionType.id, + label: sessionType.label, + })); + + const updateValidation = (): void => { + const count = attempts.length; + const hasPrompt = prompt.trim().length > 0; + const hasWorktreeBase = context.branch !== undefined; + const hasHarnesses = getHarnesses().length > 0; + if (confirmButton) { + confirmButton.enabled = count >= 2 && hasPrompt && hasWorktreeBase && hasHarnesses; + confirmButton.label = localize('sessionComparisonSetup.runAttemptCount', "Run {0} attempts", count); + } + if (validationElement) { + validationElement.hidden = count >= 2 && hasPrompt && hasWorktreeBase && hasHarnesses; + validationElement.textContent = !hasWorktreeBase + ? localize('sessionComparisonSetup.gitRepositoryRequired', "Comparisons require a Git repository with at least one commit.") + : !hasHarnesses + ? localize('sessionComparisonSetup.noAvailableAgents', "No agents that support worktree isolation are available.") + : count < 2 + ? localize('sessionComparisonSetup.minimumSelection', "Add at least two attempts.") + : hasPrompt ? '' : localize('sessionComparisonSetup.promptRequired', "Enter a prompt to run the attempts."); + } + }; + + const renderRows = (focusAttemptId?: string): void => { + if (!body) { + return; + } + evaluationExpanded = renderedEvaluation?.open ?? evaluationExpanded; + renderedEvaluation = undefined; + rowsDisposables.clear(); + dom.clearNode(body); + validationElement = undefined; + + const promptSection = dom.append(body, dom.$('.session-comparison-setup-prompt')); + const promptHeading = dom.append(promptSection, dom.$('h3.session-comparison-setup-section-title')); + promptHeading.id = `session-comparison-prompt-${generateUuid()}`; + promptHeading.textContent = localize('sessionComparisonSetup.prompt', "Prompt"); + promptSection.setAttribute('role', 'group'); + promptSection.setAttribute('aria-labelledby', promptHeading.id); + const promptInput = rowsDisposables.add(new InputBox(promptSection, this.contextViewService, { + ariaLabel: localize('sessionComparisonSetup.promptAriaLabel', "Prompt shared by every comparison attempt"), + placeholder: NEW_SESSION_PROMPT_PLACEHOLDER, + flexibleHeight: true, + flexibleMaxHeight: 100, + inputBoxStyles: defaultInputBoxStyles, + })); + promptInput.value = prompt; + rowsDisposables.add(promptInput.onDidChange(value => { + prompt = value; + context.setPrompt(value); + updateValidation(); + })); + const contextSummary = dom.append(body, dom.$('.session-comparison-setup-context-summary')); + dom.append(contextSummary, dom.$('span')).textContent = localize('sessionComparisonSetup.startingFrom', "Starting from"); + dom.append(contextSummary, dom.$('span.session-comparison-setup-context-value')).textContent = context.workspaceLabel; + if (context.branch !== undefined) { + const separator = dom.append(contextSummary, dom.$('span.session-comparison-setup-context-separator')); + separator.setAttribute('aria-hidden', 'true'); + separator.textContent = '·'; + dom.append(contextSummary, dom.$('span.session-comparison-setup-context-value')).textContent = context.branch; + } + if (context.attachedContextCount > 0) { + const separator = dom.append(contextSummary, dom.$('span.session-comparison-setup-context-separator')); + separator.setAttribute('aria-hidden', 'true'); + separator.textContent = '·'; + dom.append(contextSummary, dom.$('span')).textContent = + localize('sessionComparisonSetup.attachedContextCount', "{0} context items", context.attachedContextCount); + } + + const usage = dom.append(body, dom.$('.session-comparison-setup-usage')); + usage.textContent = localize('sessionComparisonSetup.usage', "Each attempt runs in an isolated worktree. Nothing is applied automatically."); + + const attemptsSection = dom.append(body, dom.$('.session-comparison-setup-attempts')); + const attemptsHeading = dom.append(attemptsSection, dom.$('h3.session-comparison-setup-section-title')); + attemptsHeading.id = `session-comparison-attempts-${generateUuid()}`; + attemptsHeading.textContent = + localize('sessionComparisonSetup.attempts', "Attempts"); + attemptsSection.setAttribute('role', 'group'); + attemptsSection.setAttribute('aria-labelledby', attemptsHeading.id); + const rows = dom.$('.session-comparison-setup-rows'); + const rowsScrollable = rowsDisposables.add(new DomScrollableElement(rows, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: true, + consumeMouseWheelIfScrollbarIsNeeded: true, + })); + rowsScrollable.getDomNode().classList.add('session-comparison-setup-rows-scroll'); + dom.append(attemptsSection, rowsScrollable.getDomNode()); + const harnesses = getHarnesses(); + if (harnesses.length === 0) { + const empty = dom.append(rows, dom.$('.session-comparison-setup-empty')); + empty.textContent = localize('sessionComparisonSetup.noHarnesses', "No agents that support worktree isolation are available."); + } + const showProviderLabels = new Set(harnesses.map(harness => harness.providerId)).size > 1; + + const renderHarnessControls = ( + container: HTMLElement, + selectedHarness: ISessionComparisonHarness, + agentAriaLabel: string, + modelAriaLabel: string, + unavailableAgentMessage: string, + unavailableModelMessage: string, + onChange: (harness: ISessionComparisonHarness) => void, + ): SelectBox | undefined => { + const harnessIndex = harnesses.findIndex(harness => + harnessKey(harness.providerId, harness.sessionTypeId) === harnessKey(selectedHarness.providerId, selectedHarness.sessionTypeId)); + const selectedHarnessIndex = Math.max(0, harnessIndex); + let harness = harnesses[selectedHarnessIndex]; + if (!harness) { + return undefined; + } + if (harnessIndex < 0) { + status(unavailableAgentMessage); + onChange(harness); + } else { + harness = selectedHarness; + } + const provider = this.sessionsProvidersService.getProvider(harness.providerId); + const models = provider?.getModelsSnapshotForCreation?.(context.workspace, harness.sessionTypeId).models ?? []; + if (harness.modelId && !models.some(model => model.identifier === harness.modelId)) { + harness = { ...harness, modelId: undefined, modelLabel: undefined }; + onChange(harness); + status(unavailableModelMessage); + } + + const agentField = dom.append(container, dom.$('.session-comparison-setup-field')); + dom.append(agentField, dom.$('span.session-comparison-setup-field-label')).textContent = + localize('sessionComparisonSetup.agent', "Agent"); + const agentSelect = rowsDisposables.add(new SelectBox( + harnesses.map(candidate => ({ + text: candidate.label, + detail: showProviderLabels ? (this.sessionsProvidersService.getProvider(candidate.providerId)?.label ?? candidate.providerId) : undefined, + })), + selectedHarnessIndex, + this.contextViewService, + defaultSelectBoxStyles, + { + ariaLabel: agentAriaLabel, + useCustomDrawn: true, + contextViewLayer: 1, + }, + )); + agentSelect.render(dom.append(agentField, dom.$('.session-comparison-setup-select'))); + if (showProviderLabels) { + dom.append(agentField, dom.$('span.session-comparison-setup-provider')).textContent = + provider?.label ?? harness.providerId; + } + rowsDisposables.add(agentSelect.onDidSelect(({ index }) => { + const selected = harnesses[index]; + if (selected) { + onChange(selected); + renderRows(); + } + })); + + const modelOptions = [ + { text: localize('sessionComparisonSetup.defaultModel', "Auto") }, + ...models.map(model => ({ text: model.metadata.name, detail: model.metadata.detail })), + ]; + const selectedModelIndex = harness.modelId + ? Math.max(0, models.findIndex(model => model.identifier === harness.modelId) + 1) + : 0; + const modelField = dom.append(container, dom.$('.session-comparison-setup-field')); + dom.append(modelField, dom.$('span.session-comparison-setup-field-label')).textContent = + localize('sessionComparisonSetup.model', "Model"); + const modelSelect = rowsDisposables.add(new SelectBox( + modelOptions, + selectedModelIndex, + this.contextViewService, + defaultSelectBoxStyles, + { + ariaLabel: modelAriaLabel, + useCustomDrawn: true, + contextViewLayer: 1, + }, + )); + modelSelect.render(dom.append(modelField, dom.$('.session-comparison-setup-select'))); + modelSelect.setEnabled(modelOptions.length > 1); + rowsDisposables.add(modelSelect.onDidSelect(({ index }) => { + const model = index === 0 ? undefined : models[index - 1]; + onChange({ + ...harness, + modelId: model?.identifier, + modelLabel: model?.metadata.name, + }); + })); + return agentSelect; + }; + + for (const [index, attempt] of attempts.entries()) { + const row = dom.append(rows, dom.$('.session-comparison-setup-row')); + row.dataset.attemptId = attempt.id; + const header = dom.append(row, dom.$('.session-comparison-setup-row-header')); + const attemptLabel = dom.append(header, dom.$('.session-comparison-setup-label')); + attemptLabel.id = `session-comparison-attempt-${attempt.id}`; + attemptLabel.textContent = + localize('sessionComparisonSetup.attempt', "Attempt {0}", index + 1); + row.setAttribute('role', 'group'); + row.setAttribute('aria-labelledby', attemptLabel.id); + if (attempts.length > 2) { + const removeButton = rowsDisposables.add(new Button(header, { + ...defaultButtonStyles, + secondary: true, + ariaLabel: localize('sessionComparisonSetup.removeAttemptAriaLabel', "Remove attempt {0}", index + 1), + })); + removeButton.element.classList.add('session-comparison-setup-remove'); + removeButton.label = localize('sessionComparisonSetup.removeAttempt', "Remove"); + rowsDisposables.add(removeButton.onDidClick(() => { + attempts = attempts.filter(candidate => candidate.id !== attempt.id); + renderRows(attempts[Math.min(index, attempts.length - 1)]?.id); + })); + } + + const controls = dom.append(row, dom.$('.session-comparison-setup-row-controls')); + const agentSelect = renderHarnessControls( + controls, + attempt.harness, + localize('sessionComparisonSetup.agentForAttempt', "Agent for attempt {0}", index + 1), + localize('sessionComparisonSetup.modelForAttempt', "Model for attempt {0}", index + 1), + localize('sessionComparisonSetup.agentReset', "The agent for attempt {0} is no longer available. The first available agent will be used.", index + 1), + localize('sessionComparisonSetup.modelReset', "The selected model for attempt {0} is no longer available. The agent default will be used.", index + 1), + harness => attempts[index] = { id: attempt.id, harness }, + ); + + if (focusAttemptId === attempt.id) { + agentSelect?.focus(); + } + } + + rowsScrollable.scanDomNode(); + + const addButton = rowsDisposables.add(new Button(attemptsSection, { + ...defaultButtonStyles, + secondary: true, + ariaLabel: localize('sessionComparisonSetup.addAttemptAriaLabel', "Add another comparison attempt"), + })); + addButton.element.classList.add('session-comparison-setup-add'); + addButton.label = localize('sessionComparisonSetup.addAttempt', "Add attempt"); + addButton.enabled = harnesses.length > 0; + rowsDisposables.add(addButton.onDidClick(() => { + const harness = attempts.at(-1)?.harness ?? harnesses[0]; + if (!harness) { + return; + } + const attempt = { id: generateUuid(), harness }; + attempts = [...attempts, attempt]; + renderRows(attempt.id); + })); + + const evaluation = dom.append(body, dom.$('details.session-comparison-setup-evaluation')) as HTMLDetailsElement; + renderedEvaluation = evaluation; + evaluation.open = evaluationExpanded; + const evaluationSummary = dom.append(evaluation, dom.$('summary.session-comparison-setup-evaluation-summary')); + dom.append(evaluationSummary, dom.$('span.session-comparison-setup-label')).textContent = + localize('sessionComparisonSetup.evaluation', "Evaluation"); + const judgeLabel = judgeHarness.modelLabel + ? localize('sessionComparisonSetup.judgeHarnessAndModel', "{0} · {1}", judgeHarness.label, judgeHarness.modelLabel) + : localize('sessionComparisonSetup.judgeHarnessAuto', "{0} · Auto", judgeHarness.label); + dom.append(evaluationSummary, dom.$('span.session-comparison-setup-evaluation-value')).textContent = judgeLabel; + const judgeRow = dom.append(evaluation, dom.$('.session-comparison-setup-judge')); + judgeRow.setAttribute('role', 'group'); + judgeRow.setAttribute('aria-label', localize('sessionComparisonSetup.judgeConfiguration', "Judge configuration")); + dom.append(judgeRow, dom.$('.session-comparison-setup-judge-description')).textContent = + localize('sessionComparisonSetup.judgeDescription', "Reviews the finished attempts and recommends a result."); + renderHarnessControls( + dom.append(judgeRow, dom.$('.session-comparison-setup-row-controls')), + judgeHarness, + localize('sessionComparisonSetup.agentForJudge', "Agent for the Judge"), + localize('sessionComparisonSetup.modelForJudge', "Model for the Judge"), + localize('sessionComparisonSetup.judgeAgentReset', "The Judge agent is no longer available. The first available agent will be used."), + localize('sessionComparisonSetup.judgeModelReset', "The selected Judge model is no longer available. The agent default will be used."), + harness => judgeHarness = harness, + ); + rowsDisposables.add(dom.addDisposableListener(evaluation, 'toggle', () => { + evaluationExpanded = evaluation.open; + })); + + validationElement = dom.append(body, dom.$('.session-comparison-setup-validation')); + validationElement.setAttribute('role', 'status'); + validationElement.setAttribute('aria-live', 'polite'); + updateValidation(); + }; + + try { + const dialog = disposables.add(new Dialog( + this.layoutService.activeContainer, + localize('sessionComparisonSetup.title', "Run and Compare Agents"), + [ + localize('sessionComparisonSetup.confirm', "Run {0} attempts", attempts.length), + localize('sessionComparisonSetup.cancel', "Cancel"), + ], + { + cancelId: 1, + type: 'none', + extraClasses: ['session-comparison-setup-dialog'], + isExternalFocusAllowed: target => !!target.closest('.monaco-select-box-dropdown-container'), + buttonStyles: defaultButtonStyles, + checkboxStyles: defaultCheckboxStyles, + inputBoxStyles: defaultInputBoxStyles, + dialogStyles: defaultDialogStyles, + buttonOptions: [{ + styleButton: button => { + confirmButton = button; + updateValidation(); + }, + }], + renderBody: container => { + body = container; + body.classList.add('session-comparison-setup-body'); + renderRows(); + }, + }, + )); + + for (const provider of this.sessionsProvidersService.getProviders()) { + disposables.add(provider.onDidChangeModels(() => renderRows())); + } + disposables.add(this.sessionsManagementService.onDidChangeSessionTypes(() => renderRows())); + + const result = await dialog.show(); + return { confirmed: result.button === 0, attempts, judgeHarness }; + } finally { + if (this.activeDialog.value === disposables) { + this.activeDialog.clear(); + } else { + disposables.dispose(); + } + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 4a9e75e972a74a..1c857467aa7730 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; +import { IAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -32,6 +33,7 @@ import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/b import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; import { SessionHarnessPickerVisibleContext } from '../../../common/contextkeys.js'; import { isAllowSignedOutWhenUsableEnabled } from '../../../browser/sessionsAuthGate.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; const STORAGE_KEY_LAST_SESSION_TYPE = 'sessions.userSelectedSessionType'; @@ -95,6 +97,16 @@ export interface ISessionTypePickerOptions { * `false` cancels the selection without changing the current type. */ readonly prepareSessionTypeSelection?: (pick: IPickedSessionType) => Promise; + /** Optional workflow action shown after the available session types. */ + readonly additionalAction?: { + readonly id: string; + readonly label: string; + readonly description: string; + readonly icon: ThemeIcon; + readonly infoAction?: IAction; + readonly isVisible: () => boolean; + readonly run: () => void; + }; } /** @@ -102,7 +114,8 @@ export interface ISessionTypePickerOptions { * provider id and the session type so we can dispatch creation through * the correct provider when the same type is offered by multiple providers. */ -interface ISessionTypePickerItem { +interface ISessionTypePickerSessionItem { + readonly kind: 'sessionType'; readonly providerId: string; readonly sessionTypeId: string; readonly label: string; @@ -116,6 +129,13 @@ interface ISessionTypePickerItem { readonly groupLabel?: string; } +interface ISessionTypePickerAdditionalActionItem { + readonly kind: 'additionalAction'; + readonly run: () => void; +} + +type ISessionTypePickerItem = ISessionTypePickerSessionItem | ISessionTypePickerAdditionalActionItem; + export class SessionTypePicker extends Disposable { /** @@ -167,7 +187,7 @@ export class SessionTypePicker extends Disposable { constructor( private readonly _session: IObservable, - private readonly _options: ISessionTypePickerOptions | undefined, + protected readonly _options: ISessionTypePickerOptions | undefined, @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @@ -478,7 +498,8 @@ export class SessionTypePicker extends Disposable { this._folderSessionTypes = folderTypes; this._updateModelTargetChatSessionType(); - if (folderTypes.length <= 1 && this._pickServedByFolder(this._picked)) { + const additionalAction = this._getVisibleAdditionalAction(); + if (folderTypes.length <= 1 && this._pickServedByFolder(this._picked) && !additionalAction) { return; } @@ -508,7 +529,6 @@ export class SessionTypePicker extends Disposable { } const hasDuplicateLabels = Array.from(labelCounts.values()).some(count => count > 1); const showSectionHeaders = groups.size > 1 && hasDuplicateLabels; - const groupedItems: IActionListItem[] = []; for (const [groupTitle, types] of groups) { if (showSectionHeaders) { @@ -533,6 +553,7 @@ export class SessionTypePicker extends Disposable { ); const unavailable = availability !== SessionTypeAvailability.Available; const item: ISessionTypePickerItem = { + kind: 'sessionType', providerId, sessionTypeId: sessionType.id, label: sessionType.label, @@ -545,7 +566,9 @@ export class SessionTypePicker extends Disposable { disabled: unavailable, ...(unavailable ? { description: getSessionTypeUnavailableDescription(availability), - hover: { content: getSessionTypeUnavailableHover(availability) }, + hover: { + content: getSessionTypeUnavailableHover(availability), + }, } : {}), group: { title: '', @@ -555,11 +578,33 @@ export class SessionTypePicker extends Disposable { }); } } + if (additionalAction) { + if (groupedItems.length > 0) { + groupedItems.push({ kind: ActionListItemKind.Separator, label: '' }); + } + groupedItems.push({ + kind: ActionListItemKind.Action, + label: additionalAction.label, + description: additionalAction.description, + ariaDescription: additionalAction.description, + group: { title: '', icon: additionalAction.icon }, + toolbarActions: additionalAction.infoAction ? [additionalAction.infoAction] : undefined, + className: 'sessions-run-multiple-agents-action', + item: { + kind: 'additionalAction', + run: additionalAction.run, + }, + }); + } const triggerElement = this._triggerElement; const delegate: IActionListDelegate = { onSelect: async item => { this.actionWidgetService.hide(); + if (item.kind === 'additionalAction') { + item.run(); + return; + } await this._selectSessionType(item); }, onHide: () => { @@ -578,13 +623,20 @@ export class SessionTypePicker extends Disposable { undefined, [], { - getAriaLabel: (element) => element.item?.groupLabel ? localize('sessionTypePicker.itemAriaLabel', "{0}, {1}", element.label ?? '', element.item.groupLabel) : (element.label ?? ''), + getAriaLabel: (element) => element.item?.kind === 'sessionType' && element.item.groupLabel + ? localize('sessionTypePicker.itemAriaLabel', "{0}, {1}", element.label ?? '', element.item.groupLabel) + : (element.label ?? ''), getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), }, { className: 'sessions-new-chat-picker-list', minWidth: 200 }, ); } + protected _getVisibleAdditionalAction(): NonNullable | undefined { + const action = this._options?.additionalAction; + return action?.isVisible() ? action : undefined; + } + protected async _selectSessionType(pick: IPickedSessionType): Promise { const visiblePickChanged = pick.providerId !== this._picked?.providerId || pick.sessionTypeId !== this._picked?.sessionTypeId; if (this._options?.prepareSessionTypeSelection) { @@ -717,7 +769,7 @@ export class SessionTypePicker extends Disposable { return; } - const disabled = this._folderSessionTypes.length === 1 && this._pickServedByFolder(this._picked); + const disabled = this._folderSessionTypes.length === 1 && this._pickServedByFolder(this._picked) && !this._getVisibleAdditionalAction(); this._triggerElement.classList.remove('hidden'); this._triggerElement.parentElement?.classList.toggle('disabled', disabled); this._triggerElement.tabIndex = disabled ? -1 : 0; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 49e4f59f982b3a..fc7429ea9a329f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -23,6 +23,7 @@ import { ChatSessionArchiveActionWording, getChatSessionArchiveActionWording } f import { SESSION_ARCHIVE_NUDGE_SETTING } from './sessionArchiveNudge.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; +import { COMPARE_AGENTS_ENABLED_SETTING } from '../common/constants.js'; export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 120; readonly name = 'sessionsChat'; @@ -39,6 +40,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.overview', "You are in the Agents window. The Agents window is a dedicated workspace for working with AI agents. It provides a chat interface, a changes view for reviewing agent-generated changes, a file explorer, and customization options.")); content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); content.push(getModePickerAccessibilityHelp()); + content.push(localize('sessionsChat.closePane', "When multiple session panes are visible, move focus to a pane header and activate Close to remove that pane from the grid. Closing a pane keeps the session and its worktree available in the Sessions list.")); content.push(localize('sessionsChat.inputPills', "When session metadata or active-turn status pills appear above the input, press Shift+Tab to reach them, use the Left and Right arrow keys to move between them, and press Enter or Space to activate one. Open the context menu{0} to choose which pills are shown. Pull Requests Options lets you show all pull requests or only open and draft ones, remembered across sessions. If every pull request is filtered out, use the toolbar context menu to show all again.", '')); content.push(localize('sessionsChat.removePullRequestArtifact', "For pull requests recorded as session artifacts, the pull request dropdown offers Remove Pull Request Artifact from Session on each row. Use Tab to reach its actions. When only one pull request is visible, use the pull request pill's context menu instead. Removal is immediate and only deletes the artifact record; it does not close the pull request or remove independent session associations.")); content.push(localize('sessionsChat.externalSessionFilter', "The Sessions list Filter menu includes an External submenu. Use it to choose whether external sessions from another application are shown for the last 24 hours, the last 7 days, always, or not at all.")); @@ -46,6 +48,9 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.delegatedMessage', "Messages sent by another session or chat show a source annotation above the message. Press Tab to focus the annotation, then press Enter or Space to open the source chat.")); content.push(localize('sessionsChat.createdBySession', "When a session was created by another session, focus it in the Sessions list and use the Show Hover command{0}. Move focus to the Created by link, then press Enter or Space to open the creator session.", '')); content.push(localize('sessionsChat.promptOptions', "When prompt options appear above the new-session input, use Tab and Shift+Tab to move between them, then press Enter or Space to insert one. You can select a different option while the input is empty, exactly matches the inserted prompt, or only has its editable placeholder removed; other edits disable the options without hiding them. Clearing the input also clears the selected option. Use the Close action to hide the options and return focus to the input.")); + if (accessor.get(IConfigurationService).getValue(COMPARE_AGENTS_ENABLED_SETTING)) { + content.push(localize('sessionsChat.compareAgents', "In the new-session input, open the agent picker and activate Execute Parallel Agents to open comparison setup with two attempts. Edit the shared prompt or either attempt's agent and model. Expand Evaluation to change the Judge. Use Add attempt for another run; Remove appears when more than two attempts exist. Activate Run attempts to submit, or press Escape to cancel. Open the comparison parent in the Sessions list to show every available attempt, the Judge, and synthesis in a resizable grid. After the Judge finishes, its chat shows the winning attempt, supporting evidence, and strong points from other attempts. Use Synthesize Recommended for the Judge's default plan, or expand Customize Synthesis, choose an attempt's approach for each decision section, and activate Start Planned Synthesis. The choices guide a new synthesis session and do not modify the original attempts.")); + } content.push(localize('sessionsChat.promptTemplatePlaceholder', "When the new-session prompt contains a highlighted task placeholder, place the caret inside it and replace it{0} to type your task.", ``)); content.push(localize('sessionsChat.feedbackComments', "When pull requests have failing checks or unreviewed comments, one banner appears above the input. If several pull requests need attention, use the Previous Banner and Next Banner buttons to move between them. A pull request with both failing checks and comments uses a split button: activate the main action to address both, or use its More Actions button to address only the checks or comments. In-product agent review comments appear as their own carousel item.")); if (accessor.get(IConfigurationService).getValue(SESSION_ARCHIVE_NUDGE_SETTING)) { diff --git a/src/vs/sessions/contrib/chat/common/constants.ts b/src/vs/sessions/contrib/chat/common/constants.ts index b933c86956a9d2..194b1da3ecec89 100644 --- a/src/vs/sessions/contrib/chat/common/constants.ts +++ b/src/vs/sessions/contrib/chat/common/constants.ts @@ -7,3 +7,4 @@ import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/con export const NEW_SESSION_ACTION_ID = 'workbench.action.sessions.newChat'; export const UNIFIED_WORKSPACE_PICKER_SETTING = ChatConfiguration.UnifiedWorkspacePicker; +export const COMPARE_AGENTS_ENABLED_SETTING = 'sessions.chat.compareAgents.enabled'; diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts index ea1da1e4ea2861..96efbddf5115bb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -49,6 +49,8 @@ const updateSendButtonState = Reflect.get(NewChatInputWidget.prototype, '_update const updateInitializationLoadingState = Reflect.get(NewChatInputWidget.prototype, '_updateInitializationLoadingState') as (this: IInitializationLoadingHarness, loading: boolean) => void; const setLoadingSpinnerVisible = Reflect.get(NewChatInputWidget.prototype, '_setLoadingSpinnerVisible') as (this: ILoadingSpinnerHarness, visible: boolean) => void; const setInputEditorFocused = Reflect.get(NewChatInputWidget.prototype, '_setInputEditorFocused') as (container: HTMLElement, focused: boolean) => void; +const getInputValue = Reflect.get(NewChatInputWidget.prototype, 'getInputValue') as (this: IInputValueHarness) => string; +const setInputValue = Reflect.get(NewChatInputWidget.prototype, 'setInputValue') as (this: IInputValueHarness, value: string) => void; const updateAttachmentRendering = Reflect.get(NewChatContextAttachments.prototype, '_updateRendering') as (this: IAttachmentRenderingHarness) => void; const getStaticContextPicks = Reflect.get(NewChatContextAttachments.prototype, '_getStaticPicks') as (contextActions: readonly { label: string; icon: ThemeIcon }[]) => readonly { label?: string; type?: string }[]; @@ -120,6 +122,18 @@ interface IUpdateSendButtonStateHarness { readonly _canSendRequest: { get(): boolean }; } +interface IInputValueHarness { + readonly _editor: { + getModel(): { + getValue(): string; + setValue(value: string): void; + getLineCount(): number; + getLineMaxColumn(lineNumber: number): number; + } | null; + setPosition(position: { lineNumber: number; column: number }): void; + }; +} + interface ILoadingSpinnerHarness { readonly _loadingSpinner: HTMLElement | undefined; readonly _sendButtonContainer: HTMLElement | undefined; @@ -183,6 +197,32 @@ class InputModelReferenceHarness implements IInputModelReferenceHarness, IDispos suite('NewChatInputWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('gets and sets the composer input without moving focus', () => { + let value = 'Initial prompt'; + let position: { lineNumber: number; column: number } | undefined; + const harness: IInputValueHarness = { + _editor: { + getModel: () => ({ + getValue: () => value, + setValue: newValue => value = newValue, + getLineCount: () => 2, + getLineMaxColumn: () => 8, + }), + setPosition: newPosition => position = newPosition, + }, + }; + + setInputValue.call(harness, 'Updated\nprompt'); + + assert.deepStrictEqual({ + value: getInputValue.call(harness), + position, + }, { + value: 'Updated\nprompt', + position: { lineNumber: 2, column: 8 }, + }); + }); + test('only keeps the input frame focused while editor text has focus', () => { const stack = document.createElement('div'); stack.classList.add('chat-input-stack'); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index f75de832850da9..febd2d8d8ce936 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -18,6 +18,7 @@ import { IRemoteAgentHostService } from '../../../../../platform/agentHost/commo import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; @@ -50,12 +51,14 @@ import { IRecentWorkspace, ISessionsRecentWorkspacesService } from '../../../../ import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISessionType, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE, SessionStatus, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; +import { ISessionComparisonService } from '../../../../services/sessions/common/sessionComparison.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; import { IAquariumService } from '../../../aquarium/browser/aquariumOverlay.js'; import { computeIssueIcon, computePullRequestIcon, GitHubIssueState, GitHubPullRequestState } from '../../../github/common/types.js'; import { NewChatView } from '../../browser/chatView.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; +import { COMPARE_AGENTS_ENABLED_SETTING } from '../../common/constants.js'; import { INewSessionComposerService, INewSessionPromptOption, NewSessionComposerService, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js'; import { INewChatVoiceTargetService, NewChatVoiceTargetService } from '../../browser/newChatVoice.js'; @@ -81,6 +84,8 @@ interface INewChatWidgetFixtureOptions { readonly withRemoteWorkspace?: boolean; readonly openWorkspacePicker?: boolean; readonly openGitHubContextPicker?: boolean; + readonly openComparisonSetup?: boolean; + readonly comparisonPrompt?: string; readonly withAttachedContext?: boolean; readonly withControlPickers?: boolean; readonly withAutoModel?: boolean; @@ -167,6 +172,8 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN withRemoteWorkspace = false, openWorkspacePicker = false, openGitHubContextPicker = false, + openComparisonSetup = false, + comparisonPrompt, withAttachedContext = false, withControlPickers = false, withAutoModel = false, @@ -221,6 +228,7 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN override readonly onHide = Event.None; }()); reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { + override readonly activeContainer = container; override readonly mainContainer = container; override readonly mainContainerDimension = { width, height }; override getContainer() { return container; } @@ -232,6 +240,9 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN return activeSession ? sessionTypes.map(sessionType => ({ providerId: provider.id, sessionType })) : []; } }()); + reg.defineInstance(ISessionComparisonService, new class extends mock() { + override readonly comparisons = constObservable([]); + }()); reg.defineInstance(ISessionsService, sessionsService); reg.defineInstance(ISessionsProvidersService, new class extends mock() { override readonly onDidChangeProviders = Event.None; @@ -344,9 +355,15 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN }()); }, }); + await instantiationService.get(IConfigurationService).updateValue(COMPARE_AGENTS_ENABLED_SETTING, true); container.style.width = `${width}px`; container.style.height = `${height}px`; + if (openComparisonSetup) { + container.style.position = 'relative'; + container.style.overflow = 'hidden'; + container.style.transform = 'translate3d(0, 0, 0)'; + } container.classList.add('monaco-workbench', 'agent-sessions-workbench'); container.classList.toggle('phone-layout', phoneLayout); @@ -376,6 +393,10 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); await nextFrame(); await nextFrame(); + for (let attempt = 0; attempt < 30 && !view.element.querySelector('.sessions-chat-session-type-picker'); attempt++) { + await nextFrame(); + } + assert(!!view.element.querySelector('.sessions-chat-session-type-picker')); if (phoneLayout && withAttachedContext) { const content = view.element.querySelector('.new-chat-widget-content'); assert(!!content); @@ -417,6 +438,20 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN await nextFrame(); await nextFrame(); view.element.querySelector('[aria-label="Attach a GitHub issue or pull request to the new session"]')?.click(); + } else if (openComparisonSetup) { + if (comparisonPrompt !== undefined) { + view.prefillInput(comparisonPrompt); + } + view.element.querySelector('.sessions-chat-session-type-picker .action-label')?.click(); + await nextFrame(); + await nextFrame(); + targetWindow.document.querySelector('.sessions-run-multiple-agents-action')?.click(); + await nextFrame(); + await nextFrame(); + const rows = container.querySelector('.session-comparison-setup-rows-scroll'); + if (rows) { + rows.scrollTop = 0; + } } if (promptOptions) { @@ -470,6 +505,12 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker. Pill and dropdown labels use the same body text size, and their leading icons use the same base icon size.'], render: context => renderNewChatWidget(context, { withWorkspace: true, openWorkspacePicker: true }), }), + NewSessionComparisonSetup: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: false }, + expectedVisualDescriptions: ['A focused, narrow Run and Compare Agents dialog opens with the current prompt, a compact “Starting from vscode · main” summary, and the note that each attempt runs in an isolated worktree and nothing is applied automatically. Two aligned attempt rows are visible by default with Agent and Model custom-drawn pickers. Remove actions are absent while only the required two attempts exist, Add attempt is a quiet inline action, Evaluation is collapsed with its selected Judge summarized, and the primary action reads Run 2 attempts.'], + render: context => renderNewChatWidget(context, { height: 760, withWorkspace: true, withAutoModel: true, openComparisonSetup: true, comparisonPrompt: 'Implement the issue and include focused tests.' }), + }), NewSessionGitHubContextPicker: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The Issue/PR pill has the active treatment after opening its picker.'], @@ -575,12 +616,14 @@ function createFixtureSessionTypes(): readonly ISessionType[] { label: 'Copilot', icon: Codicon.terminal, authRequirement: SessionTypeAuthRequirement.None, + supportsWorktreeConfiguration: true, }, { id: 'claude', label: 'Claude', icon: Codicon.sparkle, authRequirement: SessionTypeAuthRequirement.None, + supportsWorktreeConfiguration: true, }, ]; } @@ -648,6 +691,10 @@ function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: reado }; } + override getModelsSnapshotForCreation() { + return this.getModelsSnapshot(); + } + override getModelPickerOptions() { return { useGroupedModelPicker: true, @@ -790,6 +837,7 @@ function createFixtureActiveSession(workspace: ISessionWorkspace, sessionType: I override readonly isCreated = constObservable(false); override readonly loading = constObservable(false); override readonly workspace = constObservable(workspace); + override readonly branch = constObservable('main'); override readonly modelId = constObservable(undefined); override readonly activeChat = constObservable(activeChat); }(); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index b41c1a41042757..b4a4a96e52fbd3 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -14,7 +14,7 @@ import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ISession, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../../services/sessions/common/session.js'; +import { ISession, ISessionGitRepository, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISendRequestOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { IOpenNewSessionOptions, IOpenNewSessionResult } from '../../../../services/sessions/browser/sessionsService.js'; @@ -25,6 +25,8 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IWorkspacePickerNoWorkspaceOption, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; +import { ISessionComparisonAttemptConfiguration, IStartSessionComparisonOptions, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; +import { ISessionComparisonSetupContext, ISessionComparisonSetupResult, SessionComparisonSetupDialog } from '../../browser/sessionComparisonSetupDialog.js'; import { IWorkspaceSelectionSnapshot, WorkspaceSelectionOrigin } from '../../../../common/workspaceSelection.js'; import { ISelectWorkspaceOptions } from '../../../../browser/parts/chatView.js'; import { NewChatInputWidget } from '../../browser/newChatInput.js'; @@ -138,6 +140,8 @@ const handlePromptOptionsWorkspaceChange = Reflect.get(NewChatWidget.prototype, const syncWorkspacePickerFromSessionWorkspace = Reflect.get(NewChatWidget.prototype, '_syncWorkspacePickerFromSessionWorkspace') as (this: ISyncWorkspacePickerHarness, workspace: ISessionWorkspace | undefined) => void; const hasEnoughSessionsForFirstRunNotices = Reflect.get(NewChatWidget.prototype, '_hasEnoughSessionsForFirstRunNotices') as (this: ISessionCountHarness) => boolean; const send = Reflect.get(NewChatWidget.prototype, '_send') as (this: ISendHarness, query: string, attachedContext?: IChatRequestVariableEntry[], background?: boolean) => Promise; +const configureComparison = Reflect.get(NewChatWidget.prototype, '_configureComparison') as (this: IConfigureComparisonHarness) => Promise; +const getComparisonBranch = Reflect.get(NewChatWidget.prototype, '_getComparisonBranch') as (this: IGetComparisonBranchHarness, session?: ISession) => string | undefined; interface IPromptOptionsWorkspaceHarness { readonly uriIdentityService: { readonly extUri: typeof extUri }; @@ -160,6 +164,8 @@ interface ISendHarness { readonly newSessionComposerService: { notifyWillSendRequest(options: ISendRequestOptions, selection: IWorkspaceSelectionSnapshot | undefined): void }; readonly _session: IObservable; readonly _feedbackItems: IObservable; + readonly _comparisonAttempts: IObservable; + readonly _comparisonJudgeHarness?: IObservable; readonly _workspacePicker: { readonly selectedFolderUri: URI | undefined; readonly selectionSnapshot?: IWorkspaceSelectionSnapshot; @@ -168,11 +174,89 @@ interface ISendHarness { }; readonly _isQuickChatComposer: IObservable; readonly agentFeedbackService: { removeFeedback(resource: URI, id: string): void }; - readonly sessionsManagementService: { sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise }; + readonly sessionsManagementService: { + sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise; + getSessionTypesForFolder?(workspace: URI): readonly { readonly providerId: string; readonly sessionType: { readonly id: string; readonly label: string; readonly supportsWorktreeConfiguration?: boolean } }[]; + }; + readonly sessionsProvidersService?: { + getProvider(providerId: string): { + getModelsSnapshotForCreation(workspace: URI, sessionTypeId: string, desiredModelId?: string): { + readonly desiredModelResolution: { readonly kind: 'available'; readonly model: { readonly identifier: string; readonly metadata: { readonly name: string } } }; + }; + } | undefined; + }; + readonly sessionComparisonService?: { startComparison(options: IStartSessionComparisonOptions): Promise<{ readonly id: string; readonly participants: readonly { readonly role: SessionComparisonParticipantRole; readonly sessionResource?: URI }[] }> }; + readonly sessionsService?: { unsetNewSession(): void; openSession(resource: URI, options: { readonly source: 'chat' }): Promise }; + readonly commandService?: { executeCommand(commandId: string, ...args: unknown[]): Promise }; + readonly notificationService?: { error(error: unknown): void }; readonly logService: { error(message: string, ...args: unknown[]): void }; + _getComparisonBranch?(session: ISession): string | undefined; _getWorkspaceRoots(session: ISession): readonly URI[]; } +interface IConfigureComparisonHarness { + readonly _workspacePicker: { + readonly selectedFolderUri: URI | undefined; + readonly selectedResolved: { readonly workspace: { readonly label: string } } | undefined; + showPicker(): void; + }; + readonly _session: IObservable; + readonly _newChatInput: { + readonly attachments: readonly IChatRequestVariableEntry[]; + readonly selectedModelState: IObservable<{ readonly currentModel: undefined }>; + getInputValue(): string; + setInputValue(value: string): void; + submit(): Promise; + focus(): void; + }; + readonly _comparisonAttempts: { + get(): readonly ISessionComparisonAttemptConfiguration[]; + set(value: readonly ISessionComparisonAttemptConfiguration[], transaction: undefined): void; + }; + readonly _comparisonJudgeHarness: { + get(): ISessionComparisonAttemptConfiguration['harness'] | undefined; + set(value: ISessionComparisonAttemptConfiguration['harness'] | undefined, transaction: undefined): void; + }; + readonly _comparisonSetupDialog: { + value: IDisposable | undefined; + clear(): void; + }; + readonly sessionsManagementService: { + getSessionTypesForFolder(workspace: URI): readonly { + readonly providerId: string; + readonly sessionType: { + readonly id: string; + readonly label: string; + readonly supportsWorktreeConfiguration: boolean; + }; + }[]; + }; + readonly instantiationService: { + createInstance(ctor: typeof SessionComparisonSetupDialog): { + show( + context: ISessionComparisonSetupContext, + initialAttempts: readonly ISessionComparisonAttemptConfiguration[], + initialJudgeHarness: ISessionComparisonAttemptConfiguration['harness'], + ): Promise; + dispose(): void; + }; + }; + _getComparisonBranch(session: ISession | undefined): string | undefined; +} + +interface IGetComparisonBranchHarness { + readonly _session: IObservable; + readonly _workspacePicker: { + readonly selectedResolved: { readonly workspace: ISessionWorkspace } | undefined; + }; + readonly sessionsProvidersService: { + getProvider(providerId: string): { + readonly id: string; + getCreateSessionConfig(sessionId: string): Record | undefined; + } | undefined; + }; +} + interface IRenderSessionTypePickerHarness { readonly _newChatInput: { readonly sessionTypePicker: { @@ -256,7 +340,7 @@ function createHarness( suite('NewChatWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('workspace row hosts the workspace picker before the multiple-harness and context pickers', () => { + test('workspace row hosts the workspace picker before the harness and context pickers', () => { const container = document.createElement('div'); const harnessLabels = ['Copilot', 'Claude']; const workspaceTriggers: { readonly tooltip: string | undefined; readonly icon: string | undefined; readonly attachesContext: boolean | undefined }[] = []; @@ -951,6 +1035,7 @@ suite('NewChatWidget', () => { showPicker: () => { }, }, _isQuickChatComposer: constObservable(false), + _comparisonAttempts: constObservable([]), agentFeedbackService: { removeFeedback: () => { } }, newSessionComposerService: { notifyWillSendRequest: (options, selection) => { @@ -1015,6 +1100,7 @@ suite('NewChatWidget', () => { showPicker: () => pickerOpenCount++, }, _isQuickChatComposer: constObservable(false), + _comparisonAttempts: constObservable([]), agentFeedbackService: { removeFeedback: () => { } }, newSessionComposerService: { notifyWillSendRequest: () => { } }, sessionsManagementService: { @@ -1033,6 +1119,352 @@ suite('NewChatWidget', () => { }); }); + test('starts comparisons with uniquely identified repeated harness and model attempts', async () => { + const workspace = URI.file('/workspace'); + const session = upcastPartial({ + workspace: constObservable({ + uri: workspace, + label: 'workspace', + icon: Codicon.folder, + folders: [{ + root: workspace, + workingDirectory: workspace, + name: 'workspace', + description: undefined, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }), + branch: constObservable('main'), + }); + const configuredAttempts: readonly ISessionComparisonAttemptConfiguration[] = [ + { + id: 'first-run', + harness: { providerId: 'provider-one', sessionTypeId: 'type-one', label: 'One', modelId: 'provider-one/model', modelLabel: 'Model One' }, + }, + { + id: 'second-run', + harness: { providerId: 'provider-one', sessionTypeId: 'type-one', label: 'One', modelId: 'provider-one/model', modelLabel: 'Model One' }, + }, + ]; + let comparisonOptions: IStartSessionComparisonOptions | undefined; + + const result = await send.call({ + newSessionComposerService: { notifyWillSendRequest: () => { } }, + _session: constObservable(session), + _feedbackItems: constObservable([]), + _comparisonAttempts: constObservable(configuredAttempts), + _comparisonJudgeHarness: constObservable(configuredAttempts[0].harness), + _workspacePicker: { + selectedFolderUri: workspace, + clearAttachedContext: () => { }, + showPicker: () => { }, + }, + _isQuickChatComposer: constObservable(false), + agentFeedbackService: { removeFeedback: () => { } }, + sessionsManagementService: { + sendNewChatRequest: async () => { }, + getSessionTypesForFolder: () => [{ + providerId: 'provider-one', + sessionType: { + id: 'type-one', + label: 'One', + supportsWorktreeConfiguration: true, + }, + }], + }, + sessionsProvidersService: { + getProvider: providerId => ({ + getModelsSnapshotForCreation: (_workspace, _sessionTypeId, desiredModelId) => ({ + desiredModelResolution: { + kind: 'available', + model: { + identifier: desiredModelId ?? `${providerId}/default`, + metadata: { name: 'Model One' }, + }, + }, + }), + }), + }, + sessionComparisonService: { + startComparison: async options => { + comparisonOptions = options; + return { + id: 'comparison', + participants: [], + }; + }, + }, + sessionsService: { + unsetNewSession: () => { }, + openSession: async () => { }, + }, + commandService: { executeCommand: async () => undefined }, + notificationService: { error: () => { } }, + logService: { error: () => { } }, + _getComparisonBranch: () => 'main', + _getWorkspaceRoots: () => [workspace], + }, 'compare implementations'); + + assert.deepStrictEqual({ + result, + attempts: comparisonOptions?.attempts, + judgeHarness: comparisonOptions?.judgeHarness, + }, { + result: true, + attempts: configuredAttempts, + judgeHarness: configuredAttempts[0].harness, + }); + }); + + test('opens workspace selection when comparison isolation is unavailable', async () => { + let workspacePickerOpened = 0; + + await configureComparison.call(upcastPartial({ + _workspacePicker: { + selectedFolderUri: URI.file('/workspace'), + selectedResolved: { workspace: { label: 'workspace' } }, + showPicker: () => workspacePickerOpened++, + }, + _session: constObservable(upcastPartial({ providerId: 'provider', sessionType: 'agent' })), + _getComparisonBranch: () => undefined, + })); + + assert.strictEqual(workspacePickerOpened, 1); + }); + + test('uses the workspace branch while Agent Host creation config is unresolved', () => { + const workspace = upcastPartial({ + folders: [{ + root: URI.file('/workspace'), + workingDirectory: URI.file('/workspace'), + name: 'workspace', + description: undefined, + gitRepository: upcastPartial({ branchName: 'main' }), + }], + }); + const session = upcastPartial({ + sessionId: 'session', + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + workspace: constObservable(workspace), + }); + const harness: IGetComparisonBranchHarness = { + _session: constObservable(session), + _workspacePicker: { selectedResolved: { workspace } }, + sessionsProvidersService: { + getProvider: () => ({ + id: LOCAL_AGENT_HOST_PROVIDER_ID, + getCreateSessionConfig: () => undefined, + }), + }, + }; + + assert.strictEqual(getComparisonBranch.call(harness), 'main'); + }); + + test('opens a new comparison with two attempts from the composer selection', async () => { + const workspace = URI.file('/workspace'); + const harnessSelection = { providerId: 'provider', sessionTypeId: 'agent', label: 'Copilot', modelId: undefined, modelLabel: undefined }; + const attempts = observableValue(disposables, []); + const judgeHarness = observableValue(disposables, undefined); + let openedAttempts: readonly ISessionComparisonAttemptConfiguration[] = []; + let openedJudge: ISessionComparisonAttemptConfiguration['harness'] | undefined; + const dialogSlot: IConfigureComparisonHarness['_comparisonSetupDialog'] = { + value: undefined, + clear() { + this.value?.dispose(); + this.value = undefined; + }, + }; + const session = upcastPartial({ providerId: harnessSelection.providerId, sessionType: harnessSelection.sessionTypeId }); + + await configureComparison.call({ + _workspacePicker: { + selectedFolderUri: workspace, + selectedResolved: { workspace: { label: 'vscode' } }, + showPicker: () => { }, + }, + _session: constObservable(session), + _newChatInput: { + attachments: [], + selectedModelState: constObservable({ currentModel: undefined }), + getInputValue: () => 'Improve the picker', + setInputValue: () => { }, + submit: async () => false, + focus: () => { }, + }, + _comparisonAttempts: attempts, + _comparisonJudgeHarness: judgeHarness, + _comparisonSetupDialog: dialogSlot, + sessionsManagementService: { + getSessionTypesForFolder: () => [{ + providerId: harnessSelection.providerId, + sessionType: { id: harnessSelection.sessionTypeId, label: harnessSelection.label, supportsWorktreeConfiguration: true }, + }], + }, + instantiationService: { + createInstance: () => ({ + show: async (_context, initialAttempts, initialJudgeHarness) => { + openedAttempts = initialAttempts; + openedJudge = initialJudgeHarness; + return { confirmed: false, attempts: initialAttempts, judgeHarness: initialJudgeHarness }; + }, + dispose: () => { }, + }), + }, + _getComparisonBranch: () => 'main', + }); + + assert.deepStrictEqual({ + attemptCount: openedAttempts.length, + distinctIds: new Set(openedAttempts.map(attempt => attempt.id)).size, + harnesses: openedAttempts.map(attempt => attempt.harness), + judge: openedJudge, + }, { + attemptCount: 2, + distinctIds: 2, + harnesses: [harnessSelection, harnessSelection], + judge: harnessSelection, + }); + }); + + test('keeps comparison selections after dismiss and clears them after successful submit', async () => { + const workspace = URI.file('/workspace'); + const initialAttempt = { + id: 'initial', + harness: { providerId: 'provider', sessionTypeId: 'type', label: 'Agent', modelId: 'model-1', modelLabel: 'Model 1' }, + }; + const editedAttempts = [ + initialAttempt, + { + id: 'added', + harness: { providerId: 'provider', sessionTypeId: 'type', label: 'Agent', modelId: 'model-2', modelLabel: 'Model 2' }, + }, + ]; + const editedJudge = { providerId: 'provider', sessionTypeId: 'type', label: 'Agent', modelId: 'judge-model', modelLabel: 'Judge Model' }; + const attempts = observableValue(disposables, [initialAttempt]); + const judgeHarness = observableValue(disposables, initialAttempt.harness); + const openedWith: (readonly ISessionComparisonAttemptConfiguration[])[] = []; + const results: ISessionComparisonSetupResult[] = [ + { confirmed: false, attempts: editedAttempts, judgeHarness: editedJudge }, + { confirmed: true, attempts: editedAttempts, judgeHarness: editedJudge }, + ]; + let submitCount = 0; + const dialogSlot: IConfigureComparisonHarness['_comparisonSetupDialog'] = { + value: undefined, + clear() { + this.value?.dispose(); + this.value = undefined; + }, + }; + const harness: IConfigureComparisonHarness = { + _workspacePicker: { + selectedFolderUri: workspace, + selectedResolved: { workspace: { label: 'workspace' } }, + showPicker: () => { }, + }, + _session: constObservable(upcastPartial({ providerId: 'provider', sessionType: 'type' })), + _newChatInput: { + attachments: [], + selectedModelState: constObservable({ currentModel: undefined }), + getInputValue: () => 'Implement the feature', + setInputValue: () => { }, + submit: async () => { + submitCount++; + return true; + }, + focus: () => { }, + }, + _comparisonAttempts: attempts, + _comparisonJudgeHarness: judgeHarness, + _comparisonSetupDialog: dialogSlot, + sessionsManagementService: { getSessionTypesForFolder: () => [] }, + instantiationService: { + createInstance: () => ({ + show: async (_context, initialAttempts) => { + openedWith.push(initialAttempts); + return results.shift()!; + }, + dispose: () => { }, + }), + }, + _getComparisonBranch: () => 'main', + }; + + await configureComparison.call(harness); + assert.deepStrictEqual({ + firstOpen: openedWith[0], + draftAttempts: attempts.get(), + draftJudge: judgeHarness.get(), + submitCount, + }, { + firstOpen: [initialAttempt], + draftAttempts: editedAttempts, + draftJudge: editedJudge, + submitCount: 0, + }); + + await configureComparison.call(harness); + assert.deepStrictEqual({ + secondOpen: openedWith[1], + draftAttempts: attempts.get(), + draftJudge: judgeHarness.get(), + submitCount, + }, { + secondOpen: editedAttempts, + draftAttempts: [], + draftJudge: undefined, + submitCount: 1, + }); + }); + + test('rejects comparisons when isolated worktrees are unavailable', async () => { + const workspace = URI.file('/workspace'); + const session = upcastPartial({ + workspace: constObservable(undefined), + branch: constObservable(undefined), + }); + const attempts: readonly ISessionComparisonAttemptConfiguration[] = [ + { id: 'first-run', harness: { providerId: 'provider-one', sessionTypeId: 'type-one', label: 'One' } }, + { id: 'second-run', harness: { providerId: 'provider-one', sessionTypeId: 'type-one', label: 'One' } }, + ]; + const errors: unknown[] = []; + let startCount = 0; + + const result = await send.call({ + newSessionComposerService: { notifyWillSendRequest: () => { } }, + _session: constObservable(session), + _feedbackItems: constObservable([]), + _comparisonAttempts: constObservable(attempts), + _workspacePicker: { + selectedFolderUri: workspace, + clearAttachedContext: () => { }, + showPicker: () => { }, + }, + _isQuickChatComposer: constObservable(false), + agentFeedbackService: { removeFeedback: () => { } }, + sessionsManagementService: { + sendNewChatRequest: async () => { }, + }, + sessionComparisonService: { + startComparison: async () => { + startCount++; + return { id: 'comparison', participants: [] }; + }, + }, + notificationService: { error: error => errors.push(error) }, + logService: { error: () => { } }, + _getComparisonBranch: () => undefined, + _getWorkspaceRoots: () => [], + }, 'compare implementations'); + + assert.deepStrictEqual({ result, errors, startCount }, { + result: false, + errors: ['Comparisons require a Git repository with at least one commit.'], + startCount: 0, + }); + }); + for (const origin of [ WorkspaceSelectionOrigin.None, WorkspaceSelectionOrigin.CheckedWorkspace, WorkspaceSelectionOrigin.AgentsRecent, WorkspaceSelectionOrigin.VSCodeRecent, WorkspaceSelectionOrigin.VSCodeWorkspace, WorkspaceSelectionOrigin.ExistingSessions, WorkspaceSelectionOrigin.WindowContext, diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionComparisonResult.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionComparisonResult.test.ts new file mode 100644 index 00000000000000..a2ee4938a69844 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionComparisonResult.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 { timeout } from '../../../../../base/common/async.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { ISessionComparison, ISessionComparisonService, ISessionComparisonSynthesisPlan, SessionComparisonParticipantRole, SessionComparisonValidationState } from '../../../../services/sessions/common/sessionComparison.js'; +import { SessionComparisonResult } from '../../browser/sessionComparisonResult.js'; + +suite('Sessions - Comparison Result', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders only in the Judge and invokes winner and synthesis actions', async () => { + const attempt1Resource = URI.parse('test:///attempt-1'); + const attempt2Resource = URI.parse('test:///attempt-2'); + const judgeResource = URI.parse('test:///judge'); + const comparison: ISessionComparison = { + id: 'comparison', + groupId: 'group', + title: 'Compare', + createdAt: 0, + workspace: URI.file('/repo'), + prompt: 'Implement', + participants: [{ + id: 'attempt-1', + role: SessionComparisonParticipantRole.Attempt, + sessionResource: attempt1Resource, + harness: { providerId: 'test', sessionTypeId: 'test', label: 'Claude' }, + }, { + id: 'attempt-2', + role: SessionComparisonParticipantRole.Attempt, + sessionResource: attempt2Resource, + harness: { providerId: 'test', sessionTypeId: 'test', label: 'Codex' }, + }, { + id: 'judge', + role: SessionComparisonParticipantRole.Judge, + sessionResource: judgeResource, + harness: { providerId: 'test', sessionTypeId: 'test', label: 'Copilot' }, + }], + verdict: { + recommendedParticipantId: 'attempt-2', + explanation: 'Codex handled the edge case and passed the focused test.', + conflicts: [], + attempts: [{ + participantId: 'attempt-1', + summary: 'Added the core implementation.', + validation: { tests: SessionComparisonValidationState.Passed, build: SessionComparisonValidationState.Unknown, lint: SessionComparisonValidationState.Unknown, diagnostics: SessionComparisonValidationState.Unknown }, + unresolvedIssues: [], + notableDifferences: ['Clearer naming'], + }, { + participantId: 'attempt-2', + summary: 'Handled the edge case.', + validation: { tests: SessionComparisonValidationState.Passed, build: SessionComparisonValidationState.Passed, lint: SessionComparisonValidationState.Passed, diagnostics: SessionComparisonValidationState.Passed }, + unresolvedIssues: [], + notableDifferences: [], + }], + decisionSections: [{ + id: 'error-handling', + title: 'Error handling', + description: 'Choose how parse failures are represented.', + affectedFiles: ['src/parser.ts'], + options: [{ + participantId: 'attempt-1', + approach: 'Throw structured errors.', + }, { + participantId: 'attempt-2', + approach: 'Return typed diagnostics.', + }], + recommendedParticipantId: 'attempt-2', + }], + }, + }; + const comparisons = observableValue('comparisons', [comparison]); + const currentSession = observableValue('session', upcastPartial({ resource: judgeResource })); + let selected: string | undefined; + let opened: URI | undefined; + let synthesisPlan: ISessionComparisonSynthesisPlan | undefined; + let synthesized = 0; + let layouts = 0; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionComparisonService, new class extends mock() { + override comparisons = comparisons; + override selectAttempt(_comparisonId: string, participantId: string): void { + selected = participantId; + } + override setSynthesisPlan(_comparisonId: string, plan: ISessionComparisonSynthesisPlan | undefined): void { + synthesisPlan = plan; + } + override async synthesize(): Promise { + synthesized++; + } + }()); + instantiationService.stub(ISessionsService, new class extends mock() { + override async openSession(resource: URI): Promise { + opened = resource; + } + }()); + instantiationService.stub(INotificationService, new class extends mock() { }); + instantiationService.stub(IContextViewService, upcastPartial({})); + const result = store.add(instantiationService.createInstance(SessionComparisonResult, currentSession, () => layouts++)); + + const initialText = result.domNode.textContent ?? ''; + const buttons = result.domNode.querySelectorAll('.monaco-button'); + const plannedSynthesis = [...buttons].find(button => button.textContent === 'Start Planned Synthesis'); + const title = result.domNode.querySelector('.session-comparison-result-title'); + const strengthsTitle = result.domNode.querySelector('.session-comparison-result-subtitle:last-of-type'); + const table = result.domNode.querySelector('.session-comparison-result-strengths'); + const actions = result.domNode.querySelector('.session-comparison-result-actions'); + const synthesisPlanSummary = result.domNode.querySelector('.session-comparison-synthesis-plan-summary'); + const synthesisSelect = result.domNode.querySelector('[aria-label="Approach for Error handling"]'); + const accessibility = { + regionRole: result.domNode.getAttribute('role'), + regionLabelledBy: result.domNode.getAttribute('aria-labelledby'), + titleId: title?.id, + tableLabelledBy: table?.getAttribute('aria-labelledby'), + strengthsTitleId: strengthsTitle?.id, + actionsRole: actions?.getAttribute('role'), + actionsLabel: actions?.getAttribute('aria-label'), + buttonLabels: [...buttons].map(button => button.getAttribute('aria-label')), + }; + buttons[0].click(); + plannedSynthesis?.click(); + await timeout(0); + currentSession.set(upcastPartial({ resource: attempt1Resource }), undefined); + + assert.deepStrictEqual({ + content: { + winner: initialText.includes('Codex won'), + customize: initialText.includes('Customize Synthesis'), + section: initialText.includes('Error handling'), + approach: initialText.includes('Return typed diagnostics.'), + filesHidden: !initialText.includes('src/parser.ts'), + }, + selected, + opened: opened?.toString(), + synthesized, + synthesisPlan, + hiddenOutsideJudge: result.domNode.hidden, + layouts, + synthesisPlanSummary: synthesisPlanSummary?.textContent, + synthesisSelectLabel: synthesisSelect?.getAttribute('aria-label'), + synthesisSelect: { + hasAttemptNumber: synthesisSelect?.textContent?.includes('Attempt 2'), + hasHarness: synthesisSelect?.textContent?.includes('Codex'), + }, + accessibility, + }, { + content: { + winner: true, + customize: true, + section: true, + approach: false, + filesHidden: true, + }, + selected: 'attempt-2', + opened: attempt2Resource.toString(), + synthesized: 1, + synthesisPlan: { selections: [{ sectionId: 'error-handling', participantId: 'attempt-2' }] }, + hiddenOutsideJudge: true, + layouts: 2, + synthesisPlanSummary: 'Customize Synthesis', + synthesisSelectLabel: 'Approach for Error handling', + synthesisSelect: { + hasAttemptNumber: false, + hasHarness: true, + }, + accessibility: { + regionRole: 'region', + regionLabelledBy: title?.id, + titleId: title?.id, + tableLabelledBy: strengthsTitle?.id, + strengthsTitleId: strengthsTitle?.id, + actionsRole: 'group', + actionsLabel: 'Comparison result actions', + buttonLabels: [ + 'Focus winning session, Codex', + 'Synthesize using the Judge recommendation', + 'Start synthesis with the selected approaches', + ], + }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index 207b9f0f872478..5c96f1d17f9b31 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Action } from '../../../../../base/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -12,6 +13,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionListDelegate, IActionListItem, ActionListItemKind } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IChatInputNotificationService } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -332,6 +334,64 @@ suite('SessionTypePicker', () => { }); }); + test('shows an additional workflow action without changing the selected session type', () => { + management.setSessionTypes([sessionType('copilot', 'cloud', 'Copilot')]); + let shownItems: readonly IActionListItem[] = []; + let selectAdditionalAction: (() => void) | undefined; + const actionWidgetService = new class extends mock() { + override isVisible = false; + override hide(): void { } + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + shownItems = items; + const actionItem = items.find(item => item.item && (item.item as { kind?: string }).kind === 'additionalAction'); + selectAdditionalAction = actionItem?.item ? () => void delegate.onSelect(actionItem.item!) : undefined; + } + }; + let runCount = 0; + const infoAction = disposables.add(new Action('test.info', 'Info')); + const picker = createPicker(disposables, session, management, storage, { + additionalAction: { + id: 'test.runMultiple', + label: 'Execute Parallel Agents...', + description: 'Run isolated attempts, then compare them.', + icon: Codicon.diffMultiple, + infoAction, + isVisible: () => true, + run: () => runCount++, + }, + }, actionWidgetService); + session.set(createFakeSession('copilot', 'cloud', folder), undefined); + const container = document.createElement('div'); + picker.render(container); + const trigger = container.querySelector('.action-label'); + + picker.showPicker(); + selectAdditionalAction?.(); + + assert.deepStrictEqual({ + triggerDisabled: trigger?.getAttribute('aria-disabled'), + items: shownItems.map(item => ({ + kind: item.kind, + label: item.label, + icon: item.group?.icon?.id, + toolbarActions: item.toolbarActions?.map(action => action.id), + })), + runCount, + selected: picker.selectedPick, + stored: picker.getUserPickedSessionType(), + }, { + triggerDisabled: 'false', + items: [ + { kind: ActionListItemKind.Action, label: 'Copilot', icon: 'terminal', toolbarActions: undefined }, + { kind: ActionListItemKind.Separator, label: '', icon: undefined, toolbarActions: undefined }, + { kind: ActionListItemKind.Action, label: 'Execute Parallel Agents...', icon: 'diff-multiple', toolbarActions: ['test.info'] }, + ], + runCount: 1, + selected: { providerId: 'copilot', sessionTypeId: 'cloud' }, + stored: undefined, + }); + }); + test('re-selecting the default (first) session type clears the stored pick', () => { management.setSessionTypes([ sessionType('local-1', 'local', 'Local'), diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index 2198db6e956123..4c09f097ae4fee 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -14,6 +14,7 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { COMPARE_AGENTS_ENABLED_SETTING } from '../../common/constants.js'; import { SESSION_ARCHIVE_NUDGE_SETTING } from '../../browser/sessionArchiveNudge.js'; import { SessionsChatAccessibilityHelp } from '../../browser/sessionsChatAccessibilityHelp.js'; @@ -81,4 +82,29 @@ suite('SessionsChatAccessibilityHelp', () => { }, { controls: true, cleanupSettings: true, escape: true, focus: true, close: false, onboarding: true }); }); } + + test('describes Execute Parallel Agents only when enabled', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const configuration = new TestConfigurationService({ + [COMPARE_AGENTS_ENABLED_SETTING]: false, + }); + store.add(configuration.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configuration); + instantiationService.stub(ISessionsPartService, new class extends mock() { }()); + instantiationService.stub(ISessionsService, new class extends mock() { }()); + instantiationService.stub(IWorkbenchLayoutService, { mainContainer: mainWindow.document.createElement('div') }); + + const disabledProvider = store.add(new SessionsChatAccessibilityHelp().getProvider(instantiationService)); + const disabledContent = disabledProvider.provideContent(); + await configuration.setUserConfiguration(COMPARE_AGENTS_ENABLED_SETTING, true); + const enabledProvider = store.add(new SessionsChatAccessibilityHelp().getProvider(instantiationService)); + + assert.deepStrictEqual({ + disabled: disabledContent.includes('activate Execute Parallel Agents'), + enabled: enabledProvider.provideContent().includes('activate Execute Parallel Agents'), + }, { + disabled: false, + enabled: true, + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 57418663f30b77..1a0af6ae02dee6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,6 +29,7 @@ import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizati import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/annotationsUri.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; +import { buildOpenSessionLinkForChatResource } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_MODE_VALUES, omitAutomationSessionTemplateConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; @@ -3230,7 +3231,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement .filter(agent => this._shouldAdvertiseAgent(agent.provider)) .map((agent): ISessionType => ({ id: agent.provider, - supportsWorktreeConfiguration: agent.provider === CopilotCLISessionType.id, + supportsWorktreeConfiguration: true, authRequirement: resolveAgentAuthRequirement(agent), // The chat session contribution and language models for an agent-host // agent are registered under its resource scheme (`agent-host-`), @@ -3566,7 +3567,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement sessionType, workspace, false, - options?.metadata, + options?.createdBySession + ? withSessionCreationReference(options.metadata, { + session: options.createdBySession.session.toString(), + chat: options.createdBySession.chat?.toString(), + turnId: options.createdBySession.turnId, + }) + : options?.metadata, options?.automationConfiguration, ); } @@ -3595,7 +3602,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement sessionType, undefined, true, - options?.metadata, + options?.createdBySession + ? withSessionCreationReference(options.metadata, { + session: options.createdBySession.session.toString(), + chat: options.createdBySession.chat?.toString(), + turnId: options.createdBySession.turnId, + }) + : options?.metadata, options?.automationConfiguration, ); } @@ -4421,6 +4434,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement modelTarget: undefined, }; } + return this._getModelsSnapshotForTarget(resourceScheme, desiredModelId); + } + + getModelsSnapshotForCreation(_workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { + return this._getModelsSnapshotForTarget(this.resourceSchemeForProvider(sessionTypeId), desiredModelId); + } + + private _getModelsSnapshotForTarget(resourceScheme: string, desiredModelId?: string): ISessionModelsSnapshot { const allModels = getRegisteredLanguageModels(this._languageModelsService); const models = allModels.filter(model => { if (model.metadata.targetChatSessionType !== resourceScheme) { @@ -4600,6 +4621,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } + getSessionContextReference(chatResource: URI): string | undefined { + const backendResource = this.getBackendChatResource(chatResource); + return backendResource ? buildOpenSessionLinkForChatResource(backendResource) : undefined; + } + getWorkingDirectories(sessionId: string): readonly string[] { const sessionState = this._lastSessionStates.get(sessionId); return sessionState?.workingDirectories ?? []; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index eecb76a1045b4c..02fdd11df5e66e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -860,12 +860,12 @@ suite('LocalAgentHostSessionsProvider', () => { ]); const provider = createProvider(disposables, agentHost); assert.deepStrictEqual( - provider.sessionTypes.map(t => ({ id: t.id, icon: t.icon.id })), + provider.sessionTypes.map(t => ({ id: t.id, icon: t.icon.id, supportsWorktreeConfiguration: t.supportsWorktreeConfiguration })), [ - { id: 'copilotcli', icon: 'copilot' }, - { id: 'claude', icon: 'claude' }, - { id: 'openai', icon: 'openai' }, - { id: 'unknown-agent', icon: 'vm' }, + { id: 'copilotcli', icon: 'copilot', supportsWorktreeConfiguration: true }, + { id: 'claude', icon: 'claude', supportsWorktreeConfiguration: true }, + { id: 'openai', icon: 'openai', supportsWorktreeConfiguration: true }, + { id: 'unknown-agent', icon: 'vm', supportsWorktreeConfiguration: true }, ], ); }); @@ -1978,9 +1978,11 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ models: snapshot.models.map(model => model.identifier), modelTarget: snapshot.modelTarget, + creationModels: provider.getModelsSnapshotForCreation(URI.file('/workspace'), provider.sessionTypes[0].id).models.map(model => model.identifier), }, { models: ['matching'], modelTarget: 'agent-host-copilotcli', + creationModels: ['matching'], }); }); @@ -4833,6 +4835,25 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(agentHost.createSessionConfigs, []); }); + test('forwards programmatic parent session provenance to eager creation', async () => { + const provider = createProvider(disposables, agentHost); + provider.createNewSession(URI.file('/home/user/project'), provider.sessionTypes[0].id, { + metadata: { existing: 'value' }, + createdBySession: { + session: URI.parse('agent-host-copilotcli:/parent'), + chat: URI.parse('agent-host-chat:/parent/default'), + turnId: 'turn-1', + }, + }); + await timeout(0); + + assert.deepStrictEqual(agentHost.createSessionConfigs[0]?.metadata, withSessionCreationReference({ existing: 'value' }, { + session: 'agent-host-copilotcli:/parent', + chat: 'agent-host-chat:/parent/default', + turnId: 'turn-1', + })); + }); + test('Automation model options reach eager creation and the browser-executed first request', async () => { const modelId = 'agent-host-copilotcli:model'; const metadata: ILanguageModelChatMetadata = { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 9f41b1551cc31e..ea7aa42f48f610 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -27,7 +27,7 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { assertAutomationSessionTemplate, IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationModelConfiguration } from '../../../automations/browser/automationModelConfiguration.js'; -import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary } from '../../../../services/sessions/common/session.js'; +import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement, ISessionChangesSummary, ISessionCreationReference } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -78,6 +78,13 @@ const STORAGE_KEY_ISOLATION_MODE = 'sessions.isolationPicker.selectedMode'; /** Remembers the cloud sandbox choice across new sessions, like the isolation picker above. */ const STORAGE_KEY_USE_SANDBOX = 'sessions.cloudSandboxPicker.useSandbox'; +const STORAGE_KEY_CREATED_BY_SESSIONS = 'sessions.copilotChat.createdBySessions'; + +interface IStoredSessionCreationReference { + readonly session: string; + readonly chat?: string; + readonly turnId?: string; +} function getGitHubRepositoryId(repository: string): string | undefined { const match = /^(?:(?:https?|ssh|git):\/\/(?:git@)?github\.com\/|git@github\.com:)?(?[^/:\s]+)\/(?[^/\s]+?)(?:\.git)?\/?$/i.exec(repository); @@ -135,6 +142,7 @@ export interface ICopilotChatSession { readonly gitHubInfo: IObservable; /** Checkpoints associated with this session, if any. */ readonly checkpoints: IObservable; + readonly createdBySession?: IObservable; readonly permissionLevel: IObservable; setPermissionLevel(level: ChatPermissionLevel): void; @@ -335,11 +343,13 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { readonly target = AgentSessionProviders.Background; readonly selectedOptions = new Map(); + readonly createdBySession = observableValue(this, undefined); get selectedModelId(): string | undefined { return this._modelId; } get chatMode(): IChatMode | undefined { return this._mode; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + setCreatedBySession(reference: ISessionCreationReference | undefined): void { this.createdBySession.set(reference, undefined); } get cancellationToken(): CancellationToken { return this._lifetimeCts.token; } get gitRepository(): IGitRepository | undefined { return this._gitRepository; } get disabled(): boolean { @@ -694,6 +704,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession readonly onDidChangeOptionGroups: Event = this._onDidChangeOptionGroups.event; readonly selectedOptions = new Map(); + readonly createdBySession = observableValue(this, undefined); get project(): ISessionWorkspace | undefined { return this._project; } get selectedModelId(): string | undefined { return this._modelId; } @@ -710,6 +721,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession get chatMode(): IChatMode | undefined { return undefined; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + setCreatedBySession(reference: ISessionCreationReference | undefined): void { this.createdBySession.set(reference, undefined); } get disabled(): boolean { return !this._repoUri && !this.selectedOptions.has('repositories'); } @@ -1563,10 +1575,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IFileService private readonly fileService: IFileService, @IPathService private readonly pathService: IPathService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this._multiChatEnabled = this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; + this._loadCreatedBySessions(); this._register(runOnChange(this.agentHostEnablementService.enabled, () => { this._onDidChangeSessionTypes.fire(); @@ -1675,6 +1689,64 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // -- Session Lifecycle -- private readonly _newSessions = this._register(new DisposableMap()); + private readonly _createdBySessions = new Map>(); + private readonly _storedCreatedBySessions = new Map(); + + private _createdBySession(resource: URI): ISettableObservable { + const key = resource.toString(); + let createdBySession = this._createdBySessions.get(key); + if (!createdBySession) { + createdBySession = observableValue(this, this._storedCreatedBySessions.get(key)); + this._createdBySessions.set(key, createdBySession); + } + return createdBySession; + } + + private _setCreatedBySession(resource: URI, reference: ISessionCreationReference): void { + const key = resource.toString(); + this._createdBySession(resource).set(reference, undefined); + this._storedCreatedBySessions.set(key, reference); + this._saveCreatedBySessions(); + } + + private _deleteCreatedBySession(resource: URI): void { + const key = resource.toString(); + this._createdBySessions.delete(key); + if (this._storedCreatedBySessions.delete(key)) { + this._saveCreatedBySessions(); + } + } + + private _loadCreatedBySessions(): void { + const raw = this.storageService.get(STORAGE_KEY_CREATED_BY_SESSIONS, StorageScope.PROFILE); + if (!raw) { + return; + } + try { + const stored = JSON.parse(raw) as Record; + for (const [resource, reference] of Object.entries(stored)) { + this._storedCreatedBySessions.set(resource, { + session: URI.parse(reference.session), + chat: reference.chat ? URI.parse(reference.chat) : undefined, + turnId: reference.turnId, + }); + } + } catch (error) { + this.logService.error('[CopilotChatSessionsProvider] Failed to restore session creation references.', error); + } + } + + private _saveCreatedBySessions(): void { + const stored: Record = {}; + for (const [resource, reference] of this._storedCreatedBySessions) { + stored[resource] = { + session: reference.session.toString(), + chat: reference.chat?.toString(), + turnId: reference.turnId, + }; + } + this.storageService.store(STORAGE_KEY_CREATED_BY_SESSIONS, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); + } /** * Clear the tracked new session with the given session's id, but only if @@ -1740,6 +1812,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id, automationConfiguration); session.setPermissionLevel(this._defaultPermissionLevel()); } + session.setCreatedBySession(options?.createdBySession); this._newSessions.set(session.sessionId, session); try { this._applyAutomationSessionConfiguration(session, automationConfiguration); @@ -1894,6 +1967,25 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions }; } + getModelsSnapshotForCreation(_workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { + if (sessionTypeId === CopilotCloudSessionType.id) { + const group = this.chatSessionsService.getOptionGroupsForSessionType(AgentSessionProviders.Cloud)?.find(candidate => isModelOptionGroup(candidate)); + const models = group?.items.map((item): ILanguageModelChatMetadataAndIdentifier => this._toSyntheticModel(item)) ?? []; + return { + models, + desiredModelResolution: resolveModelIdentifier(models, desiredModelId, group !== undefined), + modelTarget: AgentSessionProviders.Cloud, + }; + } + const allModels = getRegisteredLanguageModels(this.languageModelsService); + const models = allModels.filter(model => model.metadata.targetChatSessionType === CopilotCLISessionType.id); + return { + models, + desiredModelResolution: resolveModelIdentifierFromLanguageModels(models, desiredModelId, this.languageModelsService, allModels), + modelTarget: CopilotCLISessionType.id, + }; + } + getModelPickerOptions(sessionId: string): ISessionModelPickerOptions { // A session type that requires an explicit model selection cannot fall // back to Auto. When it has no models, the picker shows a "No models @@ -2177,8 +2269,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return; } + const createdByResources = [...allChatIds].flatMap(chatId => { + const chat = this._findChatSession(chatId); + return chat ? [chat.resource] : []; + }); await this._deleteAgentSessions(agentSessions); - + for (const resource of createdByResources) { + this._deleteCreatedBySession(resource); + } this._sessionGroupCache.delete(sessionId); this._refreshSessionCache(); } @@ -2655,6 +2753,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._inFlightCommits.add(committedResource.toString()); try { + const createdBySession = session.createdBySession.get(); + if (createdBySession) { + this._setCreatedBySession(committedResource, createdBySession); + } // Wait for _refreshSessionCache to populate the committed adapter const committedChat = await this._waitForSessionInCache(committedResource, cts.token); this._sessionCache.delete(session.resource.toString()); @@ -3282,6 +3384,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const key = chatSession.resource.toString(); this._sessionCache.delete(key); + this._deleteCreatedBySession(chatSession.resource); this._invalidateGroupingCaches(); this._sessionGroupCache.delete(chatSession.sessionId); if (this._newSessions.has(chatSession.sessionId)) { @@ -3650,6 +3753,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions lastTurnEnd: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.lastTurnEnd.read(reader))), chats: chatsObs, mainChat, + createdBySession: primaryChat.createdBySession ?? this._createdBySession(primaryChat.resource), capabilities: constObservable({ supportsMultipleChats: primaryChat.sessionType === CopilotCLISessionType.id && this._isMultiChatEnabled(), supportsRename: this._sessionTypeSupportsRename(primaryChat.sessionType), @@ -3693,6 +3797,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions lastTurnEnd: chat.lastTurnEnd, chats: chatsObs, mainChat, + createdBySession: chat.createdBySession ?? this._createdBySession(chat.resource), capabilities: constObservable({ supportsMultipleChats: false, supportsRename: this._sessionTypeSupportsRename(chat.sessionType), diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 4937305eec2e3d..f459d313596652 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -41,7 +41,7 @@ import { ChatMode, CustomChatMode, IChatMode, IChatModes, IChatModeService } fro import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionChangesSummary, ISessionFileChange, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionChangesSummary, ISessionCreationReference, ISessionFileChange, ISessionWorkspace, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../../../services/sessions/common/session.js'; import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; @@ -190,6 +190,14 @@ interface IExecutedCommand { readonly args: readonly unknown[]; } +function serializeCreationReference(reference: ISessionCreationReference | undefined) { + return reference ? { + session: reference.session.toString(), + chat: reference.chat?.toString(), + turnId: reference.turnId, + } : undefined; +} + interface ICreateProviderOptions { readonly multiChatEnabled?: boolean; readonly consolidatedRemoteWorkspaces?: boolean; @@ -202,6 +210,7 @@ interface ICreateProviderOptions { readonly fileService?: IFileService; readonly pullRequestIconCache?: IPullRequestIconCache; readonly pathService?: IPathService; + readonly storageService?: IStorageService; } function createGitConfigFileService(repositoryRoot: URI, config: string | (() => string), onRead?: () => void): IFileService { @@ -316,7 +325,7 @@ function createProviderWithConfig( instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled, managedSandboxEnforced: constObservable(false) }); - instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); + instantiationService.stub(IStorageService, opts?.storageService ?? disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }), @@ -422,7 +431,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; chatModeService?: IChatModeService; languageModelsService?: Partial }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined; notifications?: string[]; chatModeService?: IChatModeService; languageModelsService?: Partial; storageService?: IStorageService }, ): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -431,7 +440,7 @@ function createProviderForSendTests( instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IConfigurationService, configService); - instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); + instantiationService.stub(IStorageService, opts?.storageService ?? disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }), @@ -1022,6 +1031,19 @@ suite('CopilotChatSessionsProvider', () => { ); }); + test('new Copilot CLI drafts expose their creating session', () => { + const provider = createProvider(disposables, model, { agentHostEnabled: false }); + const createdBySession = { + session: URI.parse('agent-host-copilotcli:/parent'), + chat: URI.parse('agent-host-chat:/parent/default'), + turnId: 'turn-1', + }; + + const session = provider.createNewSession(URI.file('/test/vscode'), CopilotCLISessionType.id, { createdBySession }); + + assert.deepStrictEqual(session.createdBySession?.get(), createdBySession); + }); + test('getSessionTypes offers Cloud for a local workspace with a GitHub remote', async () => { const folder = URI.file('/test/vscode'); const provider = createProvider(disposables, model, { @@ -1277,6 +1299,7 @@ suite('CopilotChatSessionsProvider', () => { const workspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repository' }); const session = provider.createNewSession(workspace, CopilotCloudSessionType.id); const beforeResolve = provider.getModelsSnapshot(session.sessionId, 'removed-cloud-model'); + const creationBeforeResolve = provider.getModelsSnapshotForCreation(workspace, CopilotCloudSessionType.id, 'removed-cloud-model'); modelsState.optionGroups = [{ id: 'models', @@ -1284,13 +1307,18 @@ suite('CopilotChatSessionsProvider', () => { items: [{ id: 'synthetic-cloud-model', name: 'Synthetic Cloud Model' }], }]; const afterResolve = provider.getModelsSnapshot(session.sessionId, 'removed-cloud-model'); + const creationAfterResolve = provider.getModelsSnapshotForCreation(workspace, CopilotCloudSessionType.id, 'removed-cloud-model'); assert.deepStrictEqual({ beforeResolve: { models: beforeResolve.models.map(model => model.identifier), desiredModelResolution: beforeResolve.desiredModelResolution, modelTarget: beforeResolve.modelTarget }, afterResolve: { models: afterResolve.models.map(model => model.identifier), desiredModelResolution: afterResolve.desiredModelResolution, modelTarget: afterResolve.modelTarget }, + creationBeforeResolve: creationBeforeResolve.desiredModelResolution, + creationAfterResolve: creationAfterResolve.models.map(model => model.identifier), }, { beforeResolve: { models: [], desiredModelResolution: { kind: 'pending', identifier: 'removed-cloud-model' }, modelTarget: AgentSessionProviders.Cloud }, afterResolve: { models: ['synthetic-cloud-model'], desiredModelResolution: { kind: 'unavailable', identifier: 'removed-cloud-model' }, modelTarget: AgentSessionProviders.Cloud }, + creationBeforeResolve: { kind: 'pending', identifier: 'removed-cloud-model' }, + creationAfterResolve: ['synthetic-cloud-model'], }); }); @@ -2936,7 +2964,7 @@ suite('CopilotChatSessionsProvider', () => { }); } - test('cloud session that commits a new resource resolves without timing out', async () => { + test('cloud session that commits a new resource resolves without timing out and restores provenance', async () => { // Regression: a cloud session commits a different resource mid-request // (untitled → /task/), so _sendFirstChat must wait for the committed // resource, not the untitled one, otherwise it times out and removes the session. @@ -2947,6 +2975,7 @@ suite('CopilotChatSessionsProvider', () => { const responseCompletePromise = new Promise(r => { resolveComplete = r; }); const responseCreatedPromise = new Promise(() => { /* never resolves */ }); + const storageService = disposables.add(new TestStorageService()); const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'sent' as const, data: { @@ -2954,10 +2983,16 @@ suite('CopilotChatSessionsProvider', () => { responseCreatedPromise, agent: new class extends mock() { }(), } as IChatSendRequestData, - }), { onDidCommitSession: onDidCommit.event }); + }), { onDidCommitSession: onDidCommit.event, storageService }); const workspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repo/HEAD' }); - const session = provider.createNewSession(workspace, CopilotCloudSessionType.id); + const createdBySession = { + session: URI.parse('agent-host-copilotcli:/parent'), + chat: URI.parse('agent-host-chat:/parent/default'), + turnId: 'turn-1', + }; + const session = provider.createNewSession(workspace, CopilotCloudSessionType.id, { createdBySession }); + assert.deepStrictEqual(session.createdBySession?.get(), createdBySession); const removals: string[] = []; disposables.add(provider.onDidChangeSessions(e => { @@ -2988,18 +3023,26 @@ suite('CopilotChatSessionsProvider', () => { } }; const commitLoop = fireCommitUntilSettled(); + let committedSession!: ISession; try { - await assert.doesNotReject(sendPromise); + committedSession = await sendPromise; } finally { sendSettled = true; await commitLoop; } - assert.ok( - !removals.includes(untitledResource.toString()), - `Cloud session should not be removed after committing. Removals seen: [${removals.join(', ')}]`, - ); + assert.deepStrictEqual({ + createdBySession: serializeCreationReference(committedSession.createdBySession?.get()), + untitledRemoved: removals.includes(untitledResource.toString()), + }, { + createdBySession: serializeCreationReference(createdBySession), + untitledRemoved: false, + }); + + const restoredProvider = createProviderForSendTests(disposables, model, async () => ({ kind: 'rejected', reason: 'Unexpected send' }), { storageService }); + const restoredSession = restoredProvider.getSessions().find(candidate => candidate.resource.toString() === committedResource.toString()); + assert.deepStrictEqual(serializeCreationReference(restoredSession?.createdBySession?.get()), serializeCreationReference(createdBySession)); }); suite('cloud sandbox send path', () => { // A browsed GitHub workspace root carries a ref (`///HEAD`), which is what diff --git a/src/vs/sessions/contrib/sessionComparison/browser/sessionComparison.contribution.ts b/src/vs/sessions/contrib/sessionComparison/browser/sessionComparison.contribution.ts new file mode 100644 index 00000000000000..1abd440f047c6a --- /dev/null +++ b/src/vs/sessions/contrib/sessionComparison/browser/sessionComparison.contribution.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../workbench/common/contributions.js'; +import { OPEN_SESSION_COMPARISON_COMMAND_ID } from '../common/sessionComparison.js'; +import { SessionComparisonToolContribution } from './sessionComparisonTool.js'; +import { ISessionComparisonViewService, SessionComparisonViewService } from './sessionComparisonViewService.js'; + +registerSingleton(ISessionComparisonViewService, SessionComparisonViewService, InstantiationType.Delayed); + +registerWorkbenchContribution2(SessionComparisonToolContribution.ID, SessionComparisonToolContribution, WorkbenchPhase.Eventually); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: OPEN_SESSION_COMPARISON_COMMAND_ID, + title: localize2('openSessionComparison', "Open Attempt Comparison"), + f1: false, + }); + } + + override async run(accessor: ServicesAccessor, comparisonId: string): Promise { + await accessor.get(ISessionComparisonViewService).open(comparisonId); + } +}); diff --git a/src/vs/sessions/contrib/sessionComparison/browser/sessionComparisonTool.ts b/src/vs/sessions/contrib/sessionComparison/browser/sessionComparisonTool.ts new file mode 100644 index 00000000000000..2d524051653b01 --- /dev/null +++ b/src/vs/sessions/contrib/sessionComparison/browser/sessionComparisonTool.ts @@ -0,0 +1,571 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { localize } from '../../../../nls.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionComparison, ISessionComparisonAttemptVerdict, ISessionComparisonService, ISessionComparisonVerdict, SessionComparisonParticipantRole, SessionComparisonValidationSource, SessionComparisonValidationState } from '../../../services/sessions/common/sessionComparison.js'; + +const CompleteSessionComparisonToolId = 'vscode_completeAttemptComparison'; +const ReadSessionComparisonToolId = 'vscode_readAttemptComparison'; + +interface ICompleteSessionComparisonInput { + readonly comparisonId: string; + readonly recommendedAttemptNumber: number; + readonly explanation: string; + readonly conflicts: readonly string[]; + readonly attempts: readonly ICompleteSessionComparisonAttemptInput[]; + readonly decisionSections: readonly ICompleteSessionComparisonDecisionSectionInput[]; +} + +interface ICompleteSessionComparisonAttemptInput extends Omit { + readonly attemptNumber: number; +} + +interface ICompleteSessionComparisonDecisionSectionInput { + readonly id: string; + readonly title: string; + readonly description: string; + readonly affectedFiles: readonly string[]; + readonly options: readonly { + readonly attemptNumber: number; + readonly approach: string; + }[]; + readonly recommendedAttemptNumber: number; +} + +interface IReadSessionComparisonInput { + readonly comparisonId: string; +} + +export class ReadSessionComparisonTool implements IToolImpl { + + constructor( + @ISessionComparisonService private readonly comparisonService: ISessionComparisonService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { } + + getToolData(): IToolData { + return { + id: ReadSessionComparisonToolId, + toolReferenceName: 'readAttemptComparison', + canBeReferencedInPrompt: true, + icon: Codicon.compareChanges, + displayName: localize('sessionComparison.readTool.displayName', "Read Attempt Comparison"), + userDescription: localize('sessionComparison.readTool.userDescription', "Read the attempts and evidence for an active comparison"), + modelDescription: 'Read the bounded manifest for an active implementation-attempt comparison. Use this when judging or synthesizing that comparison, before inspecting individual transcripts. It returns the original task, every attempt, changed-file evidence status, change summaries, authoritative worktree locations, exact targets for get_session_context, and any user-selected synthesis plan. Terminal commands start in the Judge or synthesis worktree, so explicitly cd to an attempt\'s listed workingDirectory in every command that inspects or validates it. Read implementation code only from the listed worktrees; transcripts are for rationale or validation evidence. A Judge must review every attempt diff and run missing targeted validation when needed. A synthesis agent must treat selected plan sections as user requirements. It does not return full transcripts or submit a verdict.', + source: ToolDataSource.Internal, + when: ContextKeyExpr.and(ChatContextKeys.enabled), + runsInWorkspace: false, + inputSchema: { + type: 'object', + properties: { + comparisonId: { + type: 'string', + description: 'The comparison ID supplied in the Judge or synthesis prompt.', + }, + }, + required: ['comparisonId'], + additionalProperties: false, + }, + }; + } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('sessionComparison.readTool.invocationMessage', "Reading attempt comparison"), + pastTenseMessage: localize('sessionComparison.readTool.pastTenseMessage', "Read attempt comparison"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const input = parseReadInput(invocation.parameters); + if (!input) { + return toolError('The comparison manifest input is invalid.'); + } + const comparison = this.comparisonService.getComparison(input.comparisonId); + if (!comparison) { + return toolError(`Comparison '${input.comparisonId}' does not exist.`); + } + if (!isInvokingParticipant(comparison, invocation, [SessionComparisonParticipantRole.Judge, SessionComparisonParticipantRole.Synthesis])) { + return toolError('Only the Judge or synthesis session for this comparison can read its manifest.'); + } + const invokingSession = invocation.context?.sessionResource + ? this.sessionsManagementService.getSession(invocation.context.sessionResource) + : undefined; + + const attemptParticipants = comparison.participants + .filter(participant => participant.role === SessionComparisonParticipantRole.Attempt && participant.sessionResource); + const attemptNumbers = new Map(attemptParticipants.map((participant, index) => [participant.id, index + 1])); + const attempts = attemptParticipants.map((participant, index) => { + const session = this.sessionsManagementService.getSession(participant.sessionResource!); + const changes = session?.changes.get() ?? []; + const changedFiles = changes.slice(0, 200).map(change => ({ + resource: (isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri).toString(), + insertions: change.insertions, + deletions: change.deletions, + })); + const workspace = session?.workspace.get(); + const changesSummary = session?.changesSummary?.get(); + const sessionContextTarget = session && invokingSession && invokingSession.providerId === session.providerId + ? this.sessionsManagementService.getSessionContextReference(session.mainChat.get().resource) + : undefined; + return { + attemptNumber: index + 1, + label: `Attempt ${index + 1}: ${participant.harness.label}${participant.harness.modelLabel ? ` · ${participant.harness.modelLabel}` : ''}`, + harness: { + agent: participant.harness.label, + model: participant.harness.modelLabel ?? 'Default', + }, + status: session?.status.get() ?? 'unavailable', + launchError: participant.launchError, + sessionContextTarget, + sessionContextUnavailableReason: session && !sessionContextTarget + ? 'Transcript follow-up is unavailable from this Judge host; use the manifest and worktree evidence.' + : undefined, + worktree: workspace ? { + workingDirectory: workspace.folders[0]?.workingDirectory.fsPath, + folders: workspace.folders.map(folder => folder.workingDirectory.fsPath), + } : undefined, + changesSummary, + changedFiles, + changedFilesStatus: changes.length > 0 ? 'available' : changesSummary?.files === 0 ? 'noChanges' : 'unavailable', + changedFilesTruncated: changes.length > changedFiles.length, + }; + }); + const synthesisPlan = comparison.synthesisPlan && comparison.verdict?.decisionSections ? { + sections: comparison.synthesisPlan.selections.flatMap(selection => { + const section = comparison.verdict!.decisionSections!.find(section => section.id === selection.sectionId); + if (!section) { + return []; + } + const option = selection.participantId + ? section.options.find(option => option.participantId === selection.participantId) + : undefined; + const attemptNumber = option ? attemptNumbers.get(option.participantId) : undefined; + return [{ + sectionId: section.id, + title: section.title, + description: section.description, + affectedFiles: section.affectedFiles, + selection: option && attemptNumber ? { + kind: 'attempt', + attemptNumber, + approach: option.approach, + } : { kind: 'synthesizer' }, + }]; + }), + } : undefined; + return toolResult(JSON.stringify({ + comparisonId: comparison.id, + originalTask: comparison.prompt, + baseBranch: comparison.branch, + attempts, + synthesisPlan, + next: 'Review every attempt diff in its authoritative worktree. Terminal commands start in this Judge or synthesis worktree, not an attempt worktree: explicitly cd to the exact attempt worktree.workingDirectory in every command that inspects or validates it. When changedFilesStatus is unavailable, read the Git diff from that worktree instead. Use get_session_context with an exact attempt sessionContextTarget only for rationale, validation claims, or other non-code evidence; never recover implementation code or paths from a transcript. Run missing targeted validation when needed, record whether each result came from the attempt report or the Judge run, and use notApplicable for both validation state and source when a category genuinely does not apply. Submit verdict references using the manifest attemptNumber values; do not copy participant or session UUIDs. If synthesisPlan is present, treat every selected section as an explicit user requirement and resolve dependencies coherently rather than copying hunks mechanically. Do not modify any attempt, inspect another checkout, discover sessions, guess references, or create sessions.', + })); + } +} + +export class CompleteSessionComparisonTool implements IToolImpl { + + constructor( + @ISessionComparisonService private readonly comparisonService: ISessionComparisonService, + ) { } + + getToolData(): IToolData { + return { + id: CompleteSessionComparisonToolId, + toolReferenceName: 'completeAttemptComparison', + canBeReferencedInPrompt: true, + icon: Codicon.compareChanges, + displayName: localize('sessionComparison.tool.displayName', "Complete Attempt Comparison"), + userDescription: localize('sessionComparison.tool.userDescription', "Submit the judge's structured attempt comparison"), + modelDescription: 'Submit the final structured verdict for an active implementation-attempt comparison. Use this after reviewing every referenced attempt diff and running any missing targeted validation needed for a reliable recommendation. Reference attempts only by the attemptNumber values returned by readAttemptComparison; do not use participant or session UUIDs. Record whether each validation result came from the attempt report, a Judge run, was unavailable, or was not applicable. Use notApplicable for both validation state and source when a category genuinely does not apply. Identify semantic decision sections when attempts take meaningfully different approaches, including affected files and one concise option per relevant attemptNumber. This persists an advisory verdict; synthesis only starts through an explicit user action. If invalid input is rejected, correct the reported fields and retry; do not submit again after success.', + source: ToolDataSource.Internal, + when: ContextKeyExpr.and(ChatContextKeys.enabled), + runsInWorkspace: false, + inputSchema: { + type: 'object', + properties: { + comparisonId: { + type: 'string', + description: 'The comparison ID supplied in the judge prompt.', + }, + recommendedAttemptNumber: { + type: 'integer', + minimum: 1, + description: 'The attemptNumber of the strongest attempt from readAttemptComparison.', + }, + explanation: { + type: 'string', + description: 'A concise explanation of why the winning attempt is strongest, citing specific code and validation evidence.', + }, + conflicts: { + type: 'array', + description: 'Cross-attempt conflicts or incompatible design choices that synthesis must resolve.', + items: { type: 'string' }, + }, + attempts: { + type: 'array', + description: 'One structured verdict for each attempt.', + items: { + type: 'object', + properties: { + attemptNumber: { + type: 'integer', + minimum: 1, + description: 'The attemptNumber from readAttemptComparison.', + }, + summary: { type: 'string' }, + validation: { + type: 'object', + properties: { + tests: validationStateSchema(), + build: validationStateSchema(), + lint: validationStateSchema(), + diagnostics: validationStateSchema(), + }, + required: ['tests', 'build', 'lint', 'diagnostics'], + additionalProperties: false, + }, + validationSource: { + type: 'object', + properties: { + tests: validationSourceSchema(), + build: validationSourceSchema(), + lint: validationSourceSchema(), + diagnostics: validationSourceSchema(), + }, + required: ['tests', 'build', 'lint', 'diagnostics'], + additionalProperties: false, + }, + unresolvedIssues: { type: 'array', items: { type: 'string' } }, + notableDifferences: { + type: 'array', + description: 'The strongest reusable points from this attempt, especially when it is not recommended.', + items: { type: 'string' }, + }, + }, + required: ['attemptNumber', 'summary', 'validation', 'validationSource', 'unresolvedIssues', 'notableDifferences'], + additionalProperties: false, + }, + }, + decisionSections: { + type: 'array', + description: 'Semantic implementation decisions the user may customize before synthesis. Return an empty array when there are no meaningful cross-attempt choices.', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'A stable identifier unique within this verdict.' }, + title: { type: 'string', description: 'A short user-facing name for the decision.' }, + description: { type: 'string', description: 'What this decision controls and why the approaches differ.' }, + affectedFiles: { type: 'array', items: { type: 'string' } }, + options: { + type: 'array', + items: { + type: 'object', + properties: { + attemptNumber: { type: 'integer', minimum: 1 }, + approach: { type: 'string', description: 'A concise description of this attempt\'s approach.' }, + }, + required: ['attemptNumber', 'approach'], + additionalProperties: false, + }, + }, + recommendedAttemptNumber: { type: 'integer', minimum: 1 }, + }, + required: ['id', 'title', 'description', 'affectedFiles', 'options', 'recommendedAttemptNumber'], + additionalProperties: false, + }, + }, + }, + required: ['comparisonId', 'recommendedAttemptNumber', 'explanation', 'conflicts', 'attempts', 'decisionSections'], + additionalProperties: false, + }, + }; + } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + invocationMessage: localize('sessionComparison.tool.invocationMessage', "Submitting attempt comparison"), + pastTenseMessage: localize('sessionComparison.tool.pastTenseMessage', "Submitted attempt comparison"), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const input = parseInput(invocation.parameters); + if (!input) { + return toolError('The comparison verdict input is invalid. Every attempt requires tests, build, lint, and diagnostics values in both validation and validationSource. Use notApplicable for both values when a category does not apply.'); + } + const comparison = this.comparisonService.getComparison(input.comparisonId); + if (!comparison) { + return toolError(`Comparison '${input.comparisonId}' does not exist.`); + } + if (!isInvokingParticipant(comparison, invocation, [SessionComparisonParticipantRole.Judge])) { + return toolError('Only the judge session for this comparison can submit its verdict.'); + } + const attemptParticipants = comparison.participants + .filter(participant => participant.role === SessionComparisonParticipantRole.Attempt && participant.sessionResource); + if (!Number.isInteger(input.recommendedAttemptNumber) + || input.recommendedAttemptNumber < 1 + || input.recommendedAttemptNumber > attemptParticipants.length + || input.attempts.length !== attemptParticipants.length + || input.attempts.some(attempt => !Number.isInteger(attempt.attemptNumber) || attempt.attemptNumber < 1 || attempt.attemptNumber > attemptParticipants.length) + || new Set(input.attempts.map(attempt => attempt.attemptNumber)).size !== input.attempts.length) { + return toolError('The verdict must recommend an attemptNumber and include exactly one finding for every attemptNumber returned by readAttemptComparison.'); + } + if (input.attempts.some(attempt => validationKinds.some(kind => + (attempt.validation[kind] === SessionComparisonValidationState.NotApplicable) + !== (attempt.validationSource?.[kind] === SessionComparisonValidationSource.NotApplicable)))) { + return toolError('A notApplicable validation result must use notApplicable as its validation source, and vice versa.'); + } + const decisionSectionIds = new Set(); + if (input.decisionSections.some(section => { + const optionNumbers = new Set(section.options.map(option => option.attemptNumber)); + const invalid = decisionSectionIds.has(section.id) + || !optionNumbers.has(section.recommendedAttemptNumber) + || optionNumbers.size !== section.options.length + || section.options.some(option => !Number.isInteger(option.attemptNumber) || option.attemptNumber < 1 || option.attemptNumber > attemptParticipants.length); + decisionSectionIds.add(section.id); + return invalid; + })) { + return toolError('Every synthesis decision section must have a unique ID and reference known attemptNumber values.'); + } + + const verdict: ISessionComparisonVerdict = { + recommendedParticipantId: attemptParticipants[input.recommendedAttemptNumber - 1].id, + explanation: input.explanation, + conflicts: input.conflicts, + attempts: input.attempts.map(attempt => { + const { attemptNumber, ...finding } = attempt; + return { + participantId: attemptParticipants[attemptNumber - 1].id, + ...finding, + }; + }), + decisionSections: input.decisionSections.map(section => ({ + id: section.id, + title: section.title, + description: section.description, + affectedFiles: section.affectedFiles, + options: section.options.map(option => ({ + participantId: attemptParticipants[option.attemptNumber - 1].id, + approach: option.approach, + })), + recommendedParticipantId: attemptParticipants[section.recommendedAttemptNumber - 1].id, + })), + }; + this.comparisonService.submitVerdict(input.comparisonId, verdict); + const result = toolResult(JSON.stringify({ status: 'submitted', comparisonId: input.comparisonId })); + result.toolResultMessage = localize('sessionComparison.tool.result', "Submitted attempt comparison"); + return result; + } +} + +export class SessionComparisonToolContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.sessionComparisonTool'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + const toolSet = this._register(toolsService.createToolSet( + ToolDataSource.Internal, + 'vscode_sessionComparison', + 'sessionComparison', + { + icon: Codicon.compareChanges, + description: localize('sessionComparison.toolSet.description', "Compare implementation attempts"), + hiddenInToolsPicker: true, + }, + )); + const readTool = instantiationService.createInstance(ReadSessionComparisonTool); + const readToolData = readTool.getToolData(); + this._register(toolsService.registerTool(readToolData, readTool)); + this._register(toolSet.addTool(readToolData)); + const tool = instantiationService.createInstance(CompleteSessionComparisonTool); + const toolData = tool.getToolData(); + this._register(toolsService.registerTool(toolData, tool)); + this._register(toolSet.addTool(toolData)); + } +} + +function parseReadInput(value: unknown): IReadSessionComparisonInput | undefined { + return isRecord(value) && typeof value.comparisonId === 'string' + ? { comparisonId: value.comparisonId } + : undefined; +} + +function isInvokingParticipant(comparison: ISessionComparison, invocation: IToolInvocation, roles: readonly SessionComparisonParticipantRole[]): boolean { + const invokingSession = invocation.context?.sessionResource; + return !!invokingSession && comparison.participants.some(participant => + roles.includes(participant.role) + && !!participant.sessionResource + && isEqual(invokingSession, participant.sessionResource)); +} + +function parseInput(value: unknown): ICompleteSessionComparisonInput | undefined { + if (!isRecord(value) + || typeof value.comparisonId !== 'string' + || typeof value.recommendedAttemptNumber !== 'number' + || typeof value.explanation !== 'string' + || !isStringArray(value.conflicts) + || !Array.isArray(value.attempts) + || !Array.isArray(value.decisionSections)) { + return undefined; + } + const attempts: ICompleteSessionComparisonAttemptInput[] = []; + for (const attempt of value.attempts) { + if (!isRecord(attempt) + || typeof attempt.attemptNumber !== 'number' + || typeof attempt.summary !== 'string' + || !isRecord(attempt.validation) + || !isValidationState(attempt.validation.tests) + || !isValidationState(attempt.validation.build) + || !isValidationState(attempt.validation.lint) + || !isValidationState(attempt.validation.diagnostics) + || !isRecord(attempt.validationSource) + || !isValidationSource(attempt.validationSource.tests) + || !isValidationSource(attempt.validationSource.build) + || !isValidationSource(attempt.validationSource.lint) + || !isValidationSource(attempt.validationSource.diagnostics) + || !isStringArray(attempt.unresolvedIssues) + || !isStringArray(attempt.notableDifferences)) { + return undefined; + } + attempts.push({ + attemptNumber: attempt.attemptNumber, + summary: attempt.summary, + validation: { + tests: attempt.validation.tests, + build: attempt.validation.build, + lint: attempt.validation.lint, + diagnostics: attempt.validation.diagnostics, + }, + validationSource: { + tests: attempt.validationSource.tests, + build: attempt.validationSource.build, + lint: attempt.validationSource.lint, + diagnostics: attempt.validationSource.diagnostics, + }, + unresolvedIssues: attempt.unresolvedIssues, + notableDifferences: attempt.notableDifferences, + }); + } + const decisionSections: ICompleteSessionComparisonDecisionSectionInput[] = []; + for (const section of value.decisionSections) { + if (!isRecord(section) + || typeof section.id !== 'string' + || typeof section.title !== 'string' + || typeof section.description !== 'string' + || !isStringArray(section.affectedFiles) + || !Array.isArray(section.options) + || typeof section.recommendedAttemptNumber !== 'number') { + return undefined; + } + const options: { attemptNumber: number; approach: string }[] = []; + for (const option of section.options) { + if (!isRecord(option) + || typeof option.attemptNumber !== 'number' + || typeof option.approach !== 'string') { + return undefined; + } + options.push({ attemptNumber: option.attemptNumber, approach: option.approach }); + } + decisionSections.push({ + id: section.id, + title: section.title, + description: section.description, + affectedFiles: section.affectedFiles, + options, + recommendedAttemptNumber: section.recommendedAttemptNumber, + }); + } + return { + comparisonId: value.comparisonId, + recommendedAttemptNumber: value.recommendedAttemptNumber, + explanation: value.explanation, + conflicts: value.conflicts, + attempts, + decisionSections, + }; +} + +function validationStateSchema(): IJSONSchema { + return { + type: 'string', + description: 'Use passed or failed for a known result, notRun when applicable validation was not run, notApplicable when the category does not apply, or unknown when the result cannot be determined.', + enum: [ + SessionComparisonValidationState.Passed, + SessionComparisonValidationState.Failed, + SessionComparisonValidationState.NotRun, + SessionComparisonValidationState.NotApplicable, + SessionComparisonValidationState.Unknown, + ], + }; +} + +function validationSourceSchema(): IJSONSchema { + return { + type: 'string', + description: 'Use attemptReport, judgeRun, unavailable, or notApplicable. notApplicable must be paired with a notApplicable validation result.', + enum: [ + SessionComparisonValidationSource.AttemptReport, + SessionComparisonValidationSource.JudgeRun, + SessionComparisonValidationSource.NotApplicable, + SessionComparisonValidationSource.Unavailable, + ], + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === 'string'); +} + +function isValidationState(value: unknown): value is SessionComparisonValidationState { + return value === SessionComparisonValidationState.Passed + || value === SessionComparisonValidationState.Failed + || value === SessionComparisonValidationState.NotRun + || value === SessionComparisonValidationState.NotApplicable + || value === SessionComparisonValidationState.Unknown; +} + +function isValidationSource(value: unknown): value is SessionComparisonValidationSource { + return value === SessionComparisonValidationSource.AttemptReport + || value === SessionComparisonValidationSource.JudgeRun + || value === SessionComparisonValidationSource.NotApplicable + || value === SessionComparisonValidationSource.Unavailable; +} + +const validationKinds = ['tests', 'build', 'lint', 'diagnostics'] as const; + +function toolResult(value: string): IToolResult { + return { content: [{ kind: 'text', value }] }; +} + +function toolError(message: string): IToolResult { + return { + content: [{ kind: 'text', value: message }], + toolResultError: message, + toolResultMessage: localize('sessionComparison.tool.error', "Attempt comparison submission failed"), + }; +} diff --git a/src/vs/sessions/contrib/sessionComparison/browser/sessionComparisonViewService.ts b/src/vs/sessions/contrib/sessionComparison/browser/sessionComparisonViewService.ts new file mode 100644 index 00000000000000..2b9e241187827f --- /dev/null +++ b/src/vs/sessions/contrib/sessionComparison/browser/sessionComparisonViewService.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { getSessionComparisonParticipantsInDisplayOrder, ISessionComparisonService } from '../../../services/sessions/common/sessionComparison.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export const ISessionComparisonViewService = createDecorator('sessionComparisonViewService'); + +export interface ISessionComparisonViewService { + readonly _serviceBrand: undefined; + open(comparisonId: string): Promise; +} + +export class SessionComparisonViewService implements ISessionComparisonViewService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionComparisonService private readonly comparisonService: ISessionComparisonService, + @ISessionsManagementService private readonly managementService: ISessionsManagementService, + @ISessionsService private readonly sessionsService: ISessionsService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + ) { } + + async open(comparisonId: string): Promise { + const comparison = this.comparisonService.getComparison(comparisonId); + if (!comparison) { + throw new Error(localize('sessionComparison.missing', "This comparison is no longer available.")); + } + const sessions = getSessionComparisonParticipantsInDisplayOrder(comparison.participants).flatMap(participant => { + if (!participant.sessionResource) { + return []; + } + const session = this.managementService.getSession(participant.sessionResource); + return session ? [session] : []; + }); + if (!sessions.length) { + throw new Error(localize('sessionComparison.noParticipants', "No comparison sessions are available to open.")); + } + await this.sessionsService.openSessionsInGrid(sessions); + this.layoutService.setPartHidden(true, Parts.EDITOR_PART); + } +} diff --git a/src/vs/sessions/contrib/sessionComparison/common/sessionComparison.ts b/src/vs/sessions/contrib/sessionComparison/common/sessionComparison.ts new file mode 100644 index 00000000000000..69dd82b44286e5 --- /dev/null +++ b/src/vs/sessions/contrib/sessionComparison/common/sessionComparison.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const OPEN_SESSION_COMPARISON_COMMAND_ID = 'sessions.openComparison'; diff --git a/src/vs/sessions/contrib/sessionComparison/test/browser/sessionComparisonTool.test.ts b/src/vs/sessions/contrib/sessionComparison/test/browser/sessionComparisonTool.test.ts new file mode 100644 index 00000000000000..2bf39b7503b6a5 --- /dev/null +++ b/src/vs/sessions/contrib/sessionComparison/test/browser/sessionComparisonTool.test.ts @@ -0,0 +1,447 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IToolResult, ToolProgress } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; +import { IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionComparison, ISessionComparisonService, ISessionComparisonVerdict, SessionComparisonParticipantRole, SessionComparisonValidationSource, SessionComparisonValidationState } from '../../../../services/sessions/common/sessionComparison.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { CompleteSessionComparisonTool, ReadSessionComparisonTool } from '../../browser/sessionComparisonTool.js'; + +const progress: ToolProgress = { report: () => { } }; +const attemptResource = URI.parse('test:/attempt'); +const attemptChatResource = URI.parse('test-chat:/attempt'); +const judgeResource = URI.parse('test:/judge'); +const synthesisResource = URI.parse('test:/synthesis'); + +suite('SessionComparisonTool', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('describes the manifest-first evidence flow', () => { + const tool = new ReadSessionComparisonTool( + upcastPartial({}), + upcastPartial({}), + ); + + assert.deepStrictEqual({ + referenceName: tool.getToolData().toolReferenceName, + description: tool.getToolData().modelDescription, + }, { + referenceName: 'readAttemptComparison', + description: 'Read the bounded manifest for an active implementation-attempt comparison. Use this when judging or synthesizing that comparison, before inspecting individual transcripts. It returns the original task, every attempt, changed-file evidence status, change summaries, authoritative worktree locations, exact targets for get_session_context, and any user-selected synthesis plan. Terminal commands start in the Judge or synthesis worktree, so explicitly cd to an attempt\'s listed workingDirectory in every command that inspects or validates it. Read implementation code only from the listed worktrees; transcripts are for rationale or validation evidence. A Judge must review every attempt diff and run missing targeted validation when needed. A synthesis agent must treat selected plan sections as user requirements. It does not return full transcripts or submit a verdict.', + }); + }); + + test('requires semantic decision sections in the completed verdict', () => { + const tool = new CompleteSessionComparisonTool(upcastPartial({})); + const data = tool.getToolData(); + + assert.deepStrictEqual({ + description: data.modelDescription.includes('semantic decision sections'), + required: data.inputSchema?.required?.includes('decisionSections'), + }, { + description: true, + required: true, + }); + }); + + test('returns bounded evidence and exact session context targets to the Judge', async () => { + const comparison = stubComparison(); + const session = stubAttemptSession(); + const workingDirectory = URI.file('/workspace').fsPath; + const tool = new ReadSessionComparisonTool( + upcastPartial({ + getComparison: id => id === comparison.id ? comparison : undefined, + }), + upcastPartial({ + getSession: resource => resource.toString() === attemptResource.toString() + ? session + : resource.toString() === judgeResource.toString() + ? upcastPartial({ providerId: 'provider' }) + : undefined, + getSessionContextReference: resource => resource.toString() === attemptChatResource.toString() + ? 'agent-host-session://copilot/attempt' + : undefined, + }), + ); + + const result = await invoke(tool, { comparisonId: comparison.id }, judgeResource); + + assert.deepStrictEqual(JSON.parse(getText(result)), { + comparisonId: 'comparison', + originalTask: 'Implement the feature', + baseBranch: 'main', + attempts: [{ + attemptNumber: 1, + label: 'Attempt 1: Copilot · Claude', + harness: { agent: 'Copilot', model: 'Claude' }, + status: SessionStatus.Completed, + sessionContextTarget: 'agent-host-session://copilot/attempt', + worktree: { + workingDirectory, + folders: [workingDirectory], + }, + changesSummary: { files: 1, additions: 3, deletions: 1 }, + changedFiles: [{ + resource: 'file:///workspace/src/example.ts', + insertions: 3, + deletions: 1, + }], + changedFilesStatus: 'available', + changedFilesTruncated: false, + }], + next: 'Review every attempt diff in its authoritative worktree. Terminal commands start in this Judge or synthesis worktree, not an attempt worktree: explicitly cd to the exact attempt worktree.workingDirectory in every command that inspects or validates it. When changedFilesStatus is unavailable, read the Git diff from that worktree instead. Use get_session_context with an exact attempt sessionContextTarget only for rationale, validation claims, or other non-code evidence; never recover implementation code or paths from a transcript. Run missing targeted validation when needed, record whether each result came from the attempt report or the Judge run, and use notApplicable for both validation state and source when a category genuinely does not apply. Submit verdict references using the manifest attemptNumber values; do not copy participant or session UUIDs. If synthesisPlan is present, treat every selected section as an explicit user requirement and resolve dependencies coherently rather than copying hunks mechanically. Do not modify any attempt, inspect another checkout, discover sessions, guess references, or create sessions.', + }); + }); + + test('returns the user synthesis plan to the synthesis participant', async () => { + const base = stubComparison(); + const comparison = { + ...base, + verdict: { + recommendedParticipantId: 'attempt', + explanation: 'Use the attempt.', + conflicts: [], + attempts: [], + decisionSections: [decisionSection()], + }, + synthesisPlan: { + selections: [{ sectionId: 'error-handling', participantId: 'attempt' }], + }, + participants: [...base.participants, { + id: 'synthesis', + role: SessionComparisonParticipantRole.Synthesis, + harness: { providerId: 'provider', sessionTypeId: 'copilot', label: 'Copilot' }, + sessionResource: synthesisResource, + }], + } satisfies ISessionComparison; + const tool = new ReadSessionComparisonTool( + upcastPartial({ getComparison: () => comparison }), + upcastPartial({ + getSession: resource => resource.toString() === attemptResource.toString() + ? stubAttemptSession() + : resource.toString() === synthesisResource.toString() + ? upcastPartial({ providerId: 'provider' }) + : undefined, + getSessionContextReference: () => undefined, + }), + ); + + const result = await invoke(tool, { comparisonId: comparison.id }, synthesisResource); + + assert.deepStrictEqual(JSON.parse(getText(result)).synthesisPlan, { + sections: [{ + sectionId: 'error-handling', + title: 'Error handling', + description: 'Choose how parse failures are represented.', + affectedFiles: ['src/parser.ts'], + selection: { + kind: 'attempt', + attemptNumber: 1, + approach: 'Return typed diagnostics.', + }, + }], + }); + }); + + test('accepts validation categories that do not apply', async () => { + const comparison = stubComparison(); + let submitted: ISessionComparisonVerdict | undefined; + const tool = new CompleteSessionComparisonTool(upcastPartial({ + getComparison: () => comparison, + submitVerdict: (_comparisonId, verdict) => submitted = verdict, + })); + + const result = await invoke(tool, { + comparisonId: comparison.id, + recommendedAttemptNumber: 1, + explanation: 'No implementation changes were needed.', + conflicts: [], + attempts: [{ + attemptNumber: 1, + summary: 'Completed the requested inspection without changing code.', + validation: { + tests: SessionComparisonValidationState.NotApplicable, + build: SessionComparisonValidationState.NotApplicable, + lint: SessionComparisonValidationState.NotApplicable, + diagnostics: SessionComparisonValidationState.NotApplicable, + }, + validationSource: { + tests: SessionComparisonValidationSource.NotApplicable, + build: SessionComparisonValidationSource.NotApplicable, + lint: SessionComparisonValidationSource.NotApplicable, + diagnostics: SessionComparisonValidationSource.NotApplicable, + }, + unresolvedIssues: [], + notableDifferences: [], + }], + decisionSections: [], + }, judgeResource); + + assert.deepStrictEqual({ + result: JSON.parse(getText(result)), + recommendedParticipantId: submitted?.recommendedParticipantId, + attemptParticipantId: submitted?.attempts[0].participantId, + validation: submitted?.attempts[0].validation, + validationSource: submitted?.attempts[0].validationSource, + }, { + result: { status: 'submitted', comparisonId: 'comparison' }, + recommendedParticipantId: 'attempt', + attemptParticipantId: 'attempt', + validation: { + tests: SessionComparisonValidationState.NotApplicable, + build: SessionComparisonValidationState.NotApplicable, + lint: SessionComparisonValidationState.NotApplicable, + diagnostics: SessionComparisonValidationState.NotApplicable, + }, + validationSource: { + tests: SessionComparisonValidationSource.NotApplicable, + build: SessionComparisonValidationSource.NotApplicable, + lint: SessionComparisonValidationSource.NotApplicable, + diagnostics: SessionComparisonValidationSource.NotApplicable, + }, + }); + }); + + test('explains inconsistent not applicable validation provenance', async () => { + const comparison = stubComparison(); + const tool = new CompleteSessionComparisonTool(upcastPartial({ + getComparison: () => comparison, + })); + + const result = await invoke(tool, { + comparisonId: comparison.id, + recommendedAttemptNumber: 1, + explanation: 'No implementation changes were needed.', + conflicts: [], + attempts: [{ + attemptNumber: 1, + summary: 'Completed the requested inspection without changing code.', + validation: { + tests: SessionComparisonValidationState.NotApplicable, + build: SessionComparisonValidationState.NotApplicable, + lint: SessionComparisonValidationState.NotApplicable, + diagnostics: SessionComparisonValidationState.NotApplicable, + }, + validationSource: { + tests: SessionComparisonValidationSource.Unavailable, + build: SessionComparisonValidationSource.NotApplicable, + lint: SessionComparisonValidationSource.NotApplicable, + diagnostics: SessionComparisonValidationSource.NotApplicable, + }, + unresolvedIssues: [], + notableDifferences: [], + }], + decisionSections: [], + }, judgeResource); + + assert.strictEqual(getText(result), 'A notApplicable validation result must use notApplicable as its validation source, and vice versa.'); + }); + + test('records automatic review evidence provenance in the verdict', async () => { + const comparison = stubComparison(); + let submitted: ISessionComparisonVerdict | undefined; + const tool = new CompleteSessionComparisonTool(upcastPartial({ + getComparison: () => comparison, + submitVerdict: (_comparisonId, verdict) => submitted = verdict, + })); + const result = await tool.invoke({ + callId: 'call', + toolId: 'tool', + parameters: { + comparisonId: comparison.id, + recommendedAttemptNumber: 1, + explanation: 'The implementation is correct and focused.', + conflicts: [], + attempts: [{ + attemptNumber: 1, + summary: 'Focused implementation with passing tests.', + validation: { + tests: SessionComparisonValidationState.Passed, + build: SessionComparisonValidationState.Passed, + lint: SessionComparisonValidationState.Unknown, + diagnostics: SessionComparisonValidationState.Passed, + }, + validationSource: { + tests: SessionComparisonValidationSource.JudgeRun, + build: SessionComparisonValidationSource.AttemptReport, + lint: SessionComparisonValidationSource.Unavailable, + diagnostics: SessionComparisonValidationSource.JudgeRun, + }, + unresolvedIssues: [], + notableDifferences: ['Smallest diff'], + }], + decisionSections: [decisionSectionInput()], + }, + context: { sessionResource: judgeResource }, + }, async () => 0, progress, CancellationToken.None); + + assert.deepStrictEqual({ + result: JSON.parse(getText(result)), + validationSource: submitted?.attempts[0].validationSource, + decisionSections: submitted?.decisionSections, + }, { + result: { status: 'submitted', comparisonId: 'comparison' }, + validationSource: { + tests: SessionComparisonValidationSource.JudgeRun, + build: SessionComparisonValidationSource.AttemptReport, + lint: SessionComparisonValidationSource.Unavailable, + diagnostics: SessionComparisonValidationSource.JudgeRun, + }, + decisionSections: [decisionSection()], + }); + }); + + test('rejects unrelated sessions', async () => { + const comparison = stubComparison(); + const tool = new ReadSessionComparisonTool( + upcastPartial({ getComparison: () => comparison }), + upcastPartial({}), + ); + + const result = await invoke(tool, { comparisonId: comparison.id }, URI.parse('test:/unrelated')); + + assert.strictEqual(getText(result), 'Only the Judge or synthesis session for this comparison can read its manifest.'); + }); + + test('rejects decision sections that reference an unknown attempt number', async () => { + const comparison = stubComparison(); + const tool = new CompleteSessionComparisonTool(upcastPartial({ + getComparison: () => comparison, + })); + const result = await invoke(tool, { + comparisonId: comparison.id, + recommendedAttemptNumber: 1, + explanation: 'Attempt is strongest.', + conflicts: [], + attempts: [{ + attemptNumber: 1, + summary: 'Summary', + validation: { + tests: SessionComparisonValidationState.Passed, + build: SessionComparisonValidationState.Passed, + lint: SessionComparisonValidationState.Passed, + diagnostics: SessionComparisonValidationState.Passed, + }, + validationSource: { + tests: SessionComparisonValidationSource.JudgeRun, + build: SessionComparisonValidationSource.JudgeRun, + lint: SessionComparisonValidationSource.JudgeRun, + diagnostics: SessionComparisonValidationSource.JudgeRun, + }, + unresolvedIssues: [], + notableDifferences: [], + }], + decisionSections: [{ + ...decisionSectionInput(), + options: [{ attemptNumber: 2, approach: 'Unknown' }], + recommendedAttemptNumber: 2, + }], + }, judgeResource); + + assert.strictEqual(getText(result), 'Every synthesis decision section must have a unique ID and reference known attemptNumber values.'); + }); +}); + +function decisionSectionInput() { + return { + id: 'error-handling', + title: 'Error handling', + description: 'Choose how parse failures are represented.', + affectedFiles: ['src/parser.ts'], + options: [{ attemptNumber: 1, approach: 'Return typed diagnostics.' }], + recommendedAttemptNumber: 1, + }; +} + +function decisionSection() { + return { + id: 'error-handling', + title: 'Error handling', + description: 'Choose how parse failures are represented.', + affectedFiles: ['src/parser.ts'], + options: [{ participantId: 'attempt', approach: 'Return typed diagnostics.' }], + recommendedParticipantId: 'attempt', + }; +} + +function stubComparison(): ISessionComparison { + return { + id: 'comparison', + groupId: 'group', + title: 'Comparison', + createdAt: 1, + workspace: URI.file('/workspace'), + prompt: 'Implement the feature', + branch: 'main', + judgeHarness: { providerId: 'provider', sessionTypeId: 'copilot', label: 'Copilot' }, + participants: [{ + id: 'attempt', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'provider', sessionTypeId: 'copilot', label: 'Copilot', modelLabel: 'Claude' }, + sessionResource: attemptResource, + usage: { + inputTokens: 30, + cachedTokens: 12, + outputTokens: 8, + models: [{ model: 'Claude', inputTokens: 30, cachedTokens: 12, outputTokens: 8 }], + isComplete: true, + }, + }, { + id: 'judge', + role: SessionComparisonParticipantRole.Judge, + harness: { providerId: 'provider', sessionTypeId: 'copilot', label: 'Copilot' }, + sessionResource: judgeResource, + }], + }; +} + +function stubAttemptSession(): ISession { + const chat = upcastPartial({ + resource: attemptChatResource, + }); + return upcastPartial({ + resource: attemptResource, + providerId: 'provider', + status: constObservable(SessionStatus.Completed), + mainChat: constObservable(chat), + workspace: constObservable(upcastPartial({ + folders: [{ + root: URI.file('/source-workspace'), + workingDirectory: URI.file('/workspace'), + name: 'workspace', + description: undefined, + }], + })), + changesSummary: constObservable({ files: 1, additions: 3, deletions: 1 }), + changes: constObservable([{ + uri: URI.file('/workspace/src/example.ts'), + insertions: 3, + deletions: 1, + }]), + }); +} + +async function invoke(tool: ReadSessionComparisonTool | CompleteSessionComparisonTool, parameters: Record, sessionResource: URI): Promise { + return tool.invoke({ + callId: 'call', + toolId: 'tool', + parameters, + context: { sessionResource }, + }, async () => 0, progress, CancellationToken.None); +} + +function getText(result: IToolResult): string { + const part = result.content[0]; + if (!part || part.kind !== 'text') { + assert.fail('Expected a text tool result.'); + } + return part.value; +} diff --git a/src/vs/sessions/contrib/sessionComparison/test/browser/sessionComparisonViewService.test.ts b/src/vs/sessions/contrib/sessionComparison/test/browser/sessionComparisonViewService.test.ts new file mode 100644 index 00000000000000..7c9654b6b4b1c2 --- /dev/null +++ b/src/vs/sessions/contrib/sessionComparison/test/browser/sessionComparisonViewService.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionComparison, ISessionComparisonService, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { SessionComparisonViewService } from '../../browser/sessionComparisonViewService.js'; + +suite('Session comparison chat grid', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup() { + const roles = [ + SessionComparisonParticipantRole.Attempt, + SessionComparisonParticipantRole.Attempt, + SessionComparisonParticipantRole.Judge, + SessionComparisonParticipantRole.Synthesis, + ]; + const sessions = roles.map((role, index) => upcastPartial({ + sessionId: `${role}-${index}`, + resource: URI.parse(`test:///${role}-${index}`), + })); + const comparison: ISessionComparison = { + id: 'comparison', + groupId: 'group', + title: 'Compare', + prompt: 'Implement', + createdAt: 0, + workspace: URI.file('/repo'), + participants: sessions.map((session, index) => ({ + id: session.sessionId, + role: roles[index], + sessionResource: session.resource, + harness: { providerId: 'test', sessionTypeId: 'test', label: session.sessionId }, + })), + }; + const comparisons = observableValue('comparisons', [comparison]); + const opened: string[][] = []; + const hiddenParts: Array<{ hidden: boolean; part: Parts }> = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionComparisonService, new class extends mock() { + override comparisons = comparisons; + override getComparison(id: string) { return comparisons.get().find(comparison => comparison.id === id); } + }()); + instantiationService.stub(ISessionsManagementService, new class extends mock() { + override getSession(resource: URI): IActiveSession | undefined { + return sessions.find(session => session.resource.toString() === resource.toString()); + } + }()); + instantiationService.stub(ISessionsService, new class extends mock() { + override async openSessionsInGrid(targets: readonly IActiveSession[]): Promise { + opened.push(targets.map(session => session.sessionId)); + } + }()); + instantiationService.stub(IWorkbenchLayoutService, new class extends mock() { + override setPartHidden(hidden: boolean, part: Parts): void { + hiddenParts.push({ hidden, part }); + } + }()); + const service = instantiationService.createInstance(SessionComparisonViewService); + return { service, comparisons, opened, hiddenParts }; + } + + test('opens Judge, synthesis, and attempts in display order', async () => { + const fixture = setup(); + await fixture.service.open('comparison'); + assert.deepStrictEqual({ + opened: fixture.opened, + hiddenParts: fixture.hiddenParts, + }, { + opened: [[ + 'judge-2', + 'synthesis-3', + 'attempt-0', + 'attempt-1', + ]], + hiddenParts: [{ hidden: true, part: Parts.EDITOR_PART }], + }); + }); + + test('skips participants without an available session', async () => { + const fixture = setup(); + fixture.comparisons.set([{ + ...fixture.comparisons.get()[0], + participants: fixture.comparisons.get()[0].participants.map((participant, index) => index === 1 ? { + ...participant, + sessionResource: undefined, + launchError: 'Failed to start', + } : participant), + }], undefined); + await fixture.service.open('comparison'); + assert.deepStrictEqual({ + opened: fixture.opened, + hiddenParts: fixture.hiddenParts, + }, { + opened: [[ + 'judge-2', + 'synthesis-3', + 'attempt-0', + ]], + hiddenParts: [{ hidden: true, part: Parts.EDITOR_PART }], + }); + }); + + test('reports when no participant session is available', async () => { + const fixture = setup(); + fixture.comparisons.set([{ + ...fixture.comparisons.get()[0], + participants: fixture.comparisons.get()[0].participants.map(participant => ({ + ...participant, + sessionResource: undefined, + })), + }], undefined); + await assert.rejects(() => fixture.service.open('comparison'), /No comparison sessions are available/); + assert.deepStrictEqual({ opened: fixture.opened, hiddenParts: fixture.hiddenParts }, { opened: [], hiddenParts: [] }); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 286e1314f9654f..07c5babfca0e16 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -281,6 +281,76 @@ } } + &[data-session-group-connector] .session-icon { + > .codicon, + > .monaco-pixel-spinner { + display: none; + } + + &::before { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--vscode-descriptionForeground); + font-family: var(--monaco-monospace-font); + font-size: var(--vscode-fontSize-body1); + line-height: 16px; + text-align: center; + } + } + + &[data-session-group-connector='first'] .session-icon::before { + content: '┌'; + } + + &[data-session-group-connector='middle'] .session-icon::before { + content: '├'; + } + + &[data-session-group-connector='last'] .session-icon::before { + content: '└'; + } + + &.session-comparison-participant[data-session-group-connector] .session-icon { + align-self: stretch; + width: var(--vscode-spacing-size160); + + &::before, + &::after { + position: absolute; + content: ''; + } + + &::before { + top: var(--vscode-spacing-size80); + left: calc(var(--vscode-spacing-size80) - var(--vscode-strokeThickness)); + width: calc(var(--vscode-spacing-size80) + var(--vscode-strokeThickness)); + height: 0; + border-top: var(--vscode-strokeThickness) solid var(--vscode-descriptionForeground); + } + + &::after { + left: calc(var(--vscode-spacing-size80) - var(--vscode-strokeThickness)); + width: 0; + border-left: var(--vscode-strokeThickness) solid var(--vscode-descriptionForeground); + } + } + + &.session-comparison-participant[data-session-group-connector='first'] .session-icon::after { + top: var(--vscode-spacing-size80); + bottom: calc(-1 * var(--vscode-spacing-size20)); + } + + &.session-comparison-participant[data-session-group-connector='middle'] .session-icon::after { + top: calc(-1 * var(--vscode-spacing-size20)); + bottom: calc(-1 * var(--vscode-spacing-size20)); + } + + &.session-comparison-participant[data-session-group-connector='last'] .session-icon::after { + top: calc(-1 * var(--vscode-spacing-size20)); + bottom: calc(100% - var(--vscode-spacing-size80)); + } + .session-main { padding-left: 6px; } @@ -296,6 +366,49 @@ padding-bottom: 4px; } + .session-comparison-attempt-status { + display: none; + flex-shrink: 0; + align-items: center; + gap: var(--vscode-spacing-size40); + margin-left: var(--vscode-spacing-size80); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-regular); + + &.visible { + display: flex; + } + + .session-comparison-attempt-status-icon { + font-size: var(--vscode-codiconFontSize-compact); + } + } + + &.session-comparison-attempt { + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size60) var(--vscode-spacing-size60) var(--vscode-spacing-size120); + + .session-icon { + line-height: 16px; + } + + .session-title-row { + line-height: 16px; + padding-bottom: 0; + } + + .session-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .session-details-row { + display: none; + } + } + .session-details-row { gap: 4px; font-size: var(--vscode-fontSize-label2, 11px); @@ -942,6 +1055,31 @@ /* Session groups render like sections, with an inline name editor. */ .session-group { + .session-group-labels { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + line-height: normal; + } + + .session-group-description { + display: none; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + font-weight: var(--vscode-fontWeight-regular); + line-height: 14px; + + &.visible { + display: block; + } + } + .session-group-input { flex: 1 1 auto; min-width: 0; @@ -960,6 +1098,38 @@ } } +.session-group.session-comparison-group { + padding-top: var(--vscode-spacing-size40); + padding-bottom: var(--vscode-spacing-size40); + color: var(--vscode-foreground); + + .session-section-label { + display: block; + color: var(--vscode-strongForeground); + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 16px; + } + + .session-section-chevron.collapsible { + order: 4; + display: flex; + align-items: center; + margin-right: 0; + margin-left: var(--vscode-spacing-size40); + } + + .session-section-toolbar { + order: 3; + } +} + +.monaco-list-row:hover .session-group.session-comparison-group .session-section-chevron.collapsible + .session-section-icon, +.sessions-list-control:not(.session-section-focus-from-pointer) .monaco-list:focus-within .monaco-list-row.focused .session-group.session-comparison-group .session-section-chevron.collapsible + .session-section-icon, +.monaco-list-row .session-group.session-comparison-group.dropdown-active .session-section-chevron.collapsible + .session-section-icon { + display: flex; +} + .session-group.session-group-editing .session-group-input { display: block; } @@ -1031,6 +1201,12 @@ var(--vscode-strongForeground) 100% ); } + + .monaco-list-row:not(.selected) .session-item.session-comparison-attempt.in-progress .session-title { + background: none; + -webkit-text-fill-color: currentColor; + animation: none; + } } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 140bdc8896d421..2430ada7e39e74 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -34,7 +34,7 @@ import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workben import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionFocusedChatIsRenameTargetContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionHasSideChatsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionFocusedChatIsRenameTargetContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionHasSideChatsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -1714,6 +1714,11 @@ registerAction2(class CloseSessionAction extends Action2 { title: localize2('chatCompositeBar.close', "Close"), icon: Codicon.close, menu: [{ + id: Menus.SessionBarToolbar, + when: ContextKeyExpr.and(MultipleSessionsVisibleContext, IsPhoneLayoutContext.negate()), + group: 'navigation', + order: 20, + }, { id: Menus.SessionBarToolbar, when: ContextKeyExpr.or(SessionIsCreatedContext, MultipleSessionsVisibleContext), group: 'secondary/4_pin', diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 9144cd5bbca9c2..6143e777d254bd 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -61,6 +61,7 @@ import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { ISessionsManagementService, IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { getSessionComparisonHarnessLabel, getSessionComparisonParticipantsInDisplayOrder, ISessionComparison, ISessionComparisonService, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsListModelService, SessionSortMode } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { ISessionGroup, ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; @@ -68,6 +69,7 @@ import { ISessionSectionOrderService } from '../../../../services/sessions/brows import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; +import { OPEN_SESSION_COMPARISON_COMMAND_ID } from '../../../sessionComparison/common/sessionComparison.js'; import { markOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; // ============================================================================= // TEMPORARY (tracked by https://github.com/microsoft/vscode/issues/320480) @@ -173,6 +175,11 @@ export interface ISessionGroupItem { readonly sessions: ISession[]; readonly isEmpty: boolean; readonly editing: boolean; + readonly comparison?: { + readonly id: string; + readonly title: string; + readonly summary: (reader?: IReader) => string; + }; } export interface ISessionShowMore { @@ -202,6 +209,7 @@ export class SessionChatItem { export type ISessionChatItem = SessionChatItem; export type SessionListItem = ISession | SessionChatItem | ISessionSection | ISessionGroupItem | ISessionShowMore | ISessionPlaceholder; +type SessionGroupConnectorPosition = 'first' | 'middle' | 'last'; function isSessionChatItem(item: SessionListItem): item is ISessionChatItem { return item instanceof SessionChatItem; @@ -290,6 +298,7 @@ const DEFAULT_APPROVAL_ROW_MAX_LINES = 3; class SessionsTreeDelegate implements IListVirtualDelegate { private static readonly ITEM_HEIGHT = 54; + private static readonly ITEM_HEIGHT_COMPARISON_ATTEMPT = 28; private static readonly ITEM_HEIGHT_COMPACT = 28; /** Quick-chat rows are single-line — see the `.session-item.quick-chat` rules in `sessionsList.css`. */ private static readonly ITEM_HEIGHT_QUICK_CHAT = 28; @@ -312,6 +321,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { */ private static readonly ITEM_HEIGHT_PHONE = 76; private static readonly SECTION_HEIGHT = 26; + private static readonly COMPARISON_SECTION_HEIGHT = 44; private static readonly SHOW_MORE_HEIGHT = 26; private static readonly PLACEHOLDER_HEIGHT = 26; @@ -330,6 +340,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { */ private readonly _aggregateChatApprovals = false, private readonly _useInsetRowSpacing = false, + private readonly _isComparisonAttempt: (session: ISession) => boolean = () => false, ) { } private withInsetRowSpacing(height: number): number { @@ -351,6 +362,9 @@ class SessionsTreeDelegate implements IListVirtualDelegate { } return this.withInsetRowSpacing(chatHeight); } + if (isSessionGroupItem(element) && element.comparison) { + return SessionsTreeDelegate.COMPARISON_SECTION_HEIGHT; + } if (isSessionSection(element) || isSessionGroupItem(element)) { return SessionsTreeDelegate.SECTION_HEIGHT; } @@ -364,6 +378,8 @@ class SessionsTreeDelegate implements IListVirtualDelegate { let height: number; if (this._isPhone()) { height = SessionsTreeDelegate.ITEM_HEIGHT_PHONE; + } else if (this._isComparisonAttempt(element as ISession)) { + height = SessionsTreeDelegate.ITEM_HEIGHT_COMPARISON_ATTEMPT; } else if (this._useCompactQuickChatRows && isQuickChatSession(element as ISession)) { height = SessionsTreeDelegate.ITEM_HEIGHT_QUICK_CHAT; } else if (this._isCompact()) { @@ -690,6 +706,9 @@ interface ISessionItemTemplate { readonly titleToolbar: MenuWorkbenchToolBar | undefined; readonly renderedSession: ISettableObservable; readonly pendingVoiceIndicator: HTMLElement; + readonly comparisonAttemptStatus: HTMLElement; + readonly comparisonAttemptStatusIcon: HTMLElement; + readonly comparisonAttemptStatusLabel: HTMLElement; readonly detailsRow: HTMLElement; readonly approvalRow: HTMLElement; readonly approvalLabel: HTMLElement; @@ -765,7 +784,7 @@ class SessionItemRenderer implements ITreeRenderer SessionsGrouping; isPinned: (session: ISession) => boolean; isRenderedInCustomGroup?: (session: ISession) => boolean; visibleSessions: IObservable; getMultiSelectedSessions: (session: ISession) => ISession[]; showHover: boolean; useCompactQuickChatRows: boolean; compact: () => boolean; approvalRowMaxLines: number; aggregateChatApprovals: boolean; toolbarMenuId: MenuId | undefined; handleToolbarAction?: (action: IAction, session: ISession) => boolean | Promise; onDidRequestRename?: (session: ISession) => void; activeGuideSessionIds?: IObservable>; + grouping: () => SessionsGrouping; isPinned: (session: ISession) => boolean; isRenderedInCustomGroup?: (session: ISession) => boolean; getGroupConnectorPosition?: (session: ISession) => SessionGroupConnectorPosition | undefined; getComparisonAttemptLabel?: (session: ISession) => string | undefined; isComparisonParticipant?: (session: ISession) => boolean; shouldShowComparisonAttemptStatus?: (session: ISession, reader: IReader) => boolean; visibleSessions: IObservable; getMultiSelectedSessions: (session: ISession) => ISession[]; showHover: boolean; useCompactQuickChatRows: boolean; compact: () => boolean; approvalRowMaxLines: number; aggregateChatApprovals: boolean; toolbarMenuId: MenuId | undefined; handleToolbarAction?: (action: IAction, session: ISession) => boolean | Promise; onDidRequestRename?: (session: ISession) => void; activeGuideSessionIds?: IObservable>; /** Whether status presentation derives from the main chat instead of the aggregate session. */ deriveStatusFromMainChat?: boolean; archiveOnboardingSession?: IObservable; @@ -835,6 +854,10 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { @@ -921,6 +944,15 @@ class SessionItemRenderer implements ITreeRenderer { @@ -1039,11 +1071,32 @@ class SessionItemRenderer implements ITreeRenderer { - const titleText = element.title.read(reader); + const titleText = comparisonAttemptLabel ?? element.title.read(reader); template.title.set(titleText, matches); })); @@ -1061,7 +1114,7 @@ class SessionItemRenderer implements ITreeRenderer { @@ -1655,7 +1711,9 @@ class SessionGroupRenderer implements ITreeRenderer, _index: number, template: ISessionGroupTemplate): void { @@ -1679,9 +1737,33 @@ class SessionGroupRenderer implements ITreeRenderer { + template.description.textContent = element.comparison?.summary(reader) ?? ''; + })); + } else { + template.description.textContent = ''; + } + template.description.classList.toggle('visible', isComparison); this.updateChevron(template, node.collapsible, node.collapsed); - renderSessionHeaderIcon(template, element.sessions, Codicon.folderLibrary, this.showUnreadInCollapsedSections, this.sessionsWithFailingCI, this.instantiationService); + if (isComparison) { + for (const eventType of ['pointerdown', 'pointerup', 'click', 'dblclick'] as const) { + template.elementDisposables.add(DOM.addDisposableListener(template.chevron, eventType, event => { + event.preventDefault(); + event.stopPropagation(); + if (eventType === 'click') { + this.delegate.toggleCollapsed(element); + } + })); + } + template.elementDisposables.add(Gesture.ignoreTarget(template.chevron)); + } + renderSessionHeaderIcon(template, element.sessions, isComparison ? Codicon.layers : Codicon.folderLibrary, this.showUnreadInCollapsedSections, this.sessionsWithFailingCI, this.instantiationService); SessionGroupHasVisibleSessionsContext.bindTo(template.contextKeyService).set(element.sessions.length > 0); SessionGroupIsEmptyContext.bindTo(template.contextKeyService).set(element.isEmpty); @@ -1855,6 +1937,7 @@ interface ISessionsAccessibilityProviderOptions { readonly grouping: () => SessionsGrouping; readonly isPinned: (session: ISession) => boolean; readonly isRenderedInCustomGroup?: (session: ISession) => boolean; + readonly getComparisonAttemptLabel?: (session: ISession) => string | undefined; readonly includeQuickChatInAriaLabel?: boolean; readonly automationNewBadgeVisible?: IObservable; readonly showUnreadInCollapsedSections?: IObservable; @@ -1884,7 +1967,9 @@ class SessionsAccessibilityProvider { )); } if (isSessionGroupItem(element)) { - return this.getSectionAriaLabel(element.group.name, element.sessions); + return element.comparison + ? derived(this, reader => localize('comparisonGroupAria', "{0}, {1}", element.comparison!.title, element.comparison!.summary(reader))) + : this.getSectionAriaLabel(element.group.name, element.sessions); } if (isSessionSection(element)) { if (element.id === AUTOMATIONS_SECTION_ID) { @@ -1926,7 +2011,7 @@ class SessionsAccessibilityProvider { : element.label; } return derived(this, reader => { - const title = element.title.read(reader); + const title = this.options?.getComparisonAttemptLabel?.(element) ?? element.title.read(reader); const updated = fromNow(element.updatedAt.read(reader), true); let label: string; if (this.options?.includeQuickChatInAriaLabel && element.isQuickChat?.read(reader)) { @@ -2568,6 +2653,9 @@ export class SessionsList extends Disposable implements ISessionsList { */ private readonly sessionGroupLimit = observableValue(this, SessionsList.DEFAULT_SESSION_GROUP_LIMIT); private readonly expandedSessionGroups = new Set(); + private readonly renderedGroupConnectorPositions = new Map(); + private readonly renderedComparisonAttemptLabels = new Map(); + private readonly renderedComparisonParticipantIds = new Set(); private expandedMoreFolders = false; private openWindowSourceFolder: URI | undefined; private hasFindPattern = false; @@ -2610,6 +2698,7 @@ export class SessionsList extends Disposable implements ISessionsList { @ICustomViewService private readonly customViewService: ICustomViewService, @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, @ISessionGroupsService private readonly _sessionGroupsService: ISessionGroupsService, + @ISessionComparisonService private readonly sessionComparisonService: ISessionComparisonService, @ISessionSectionOrderService private readonly _sessionSectionOrderService: ISessionSectionOrderService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IInstantiationService instantiationService: IInstantiationService, @@ -2690,6 +2779,10 @@ export class SessionsList extends Disposable implements ISessionsList { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s), isRenderedInCustomGroup: s => this.isRenderedInCustomGroup(s), + getGroupConnectorPosition: s => this.renderedGroupConnectorPositions.get(s.sessionId), + getComparisonAttemptLabel: s => this.renderedComparisonAttemptLabels.get(s.sessionId), + isComparisonParticipant: s => this.renderedComparisonParticipantIds.has(s.sessionId), + shouldShowComparisonAttemptStatus: (s, reader) => this.shouldShowComparisonAttemptStatus(s, reader), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s), showHover: true, @@ -2757,6 +2850,7 @@ export class SessionsList extends Disposable implements ISessionsList { commitEdit: (group, name) => this.commitGroupEdit(group, name), cancelEdit: group => this.cancelGroupEdit(group), select: selectHeader, + toggleCollapsed: element => this.tree.toggleCollapsed(element), }, showUnreadInCollapsedSections, sessionsWithFailingCI, instantiationService, contextKeyService); this._groupRenderer = groupRenderer; @@ -2773,6 +2867,7 @@ export class SessionsList extends Disposable implements ISessionsList { true /* useCompactQuickChatRows */, false /* aggregateChatApprovals */, true /* useInsetRowSpacing */, + session => this.renderedComparisonAttemptLabels.has(session.sessionId), ); this._delegate = delegate; @@ -2794,6 +2889,7 @@ export class SessionsList extends Disposable implements ISessionsList { grouping: this.options.grouping, isPinned: session => this.isSessionPinned(session), isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session), + getComparisonAttemptLabel: session => this.renderedComparisonAttemptLabels.get(session.sessionId), deriveStatusFromMainChat: true, automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, showUnreadInCollapsedSections, @@ -2854,7 +2950,7 @@ export class SessionsList extends Disposable implements ISessionsList { }, horizontalScrolling: false, multipleSelectionSupport: true, - expandOnlyOnTwistieClick: element => isSessionItem(element), + expandOnlyOnTwistieClick: element => isSessionItem(element) || (isSessionGroupItem(element) && element.comparison !== undefined), findWidgetEnabled: true, defaultFindMode: TreeFindMode.Filter, findWidgetContainer: this.options.findWidgetContainer, @@ -2868,7 +2964,7 @@ export class SessionsList extends Disposable implements ISessionsList { keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (element: SessionListItem) => { if (isSessionGroupItem(element)) { - return element.group.name; + return element.comparison?.title ?? element.group.name; } if (isSessionSection(element)) { return element.label; @@ -2929,6 +3025,11 @@ export class SessionsList extends Disposable implements ISessionsList { this.hoveredGuideSessionId.set(undefined, undefined); } })); + this._register(this.tree.onMouseClick(e => { + if (e.element && isSessionGroupItem(e.element) && e.element.comparison && e.browserEvent.button === 0) { + this.commandService.executeCommand(OPEN_SESSION_COMPARISON_COMMAND_ID, e.element.comparison.id); + } + })); this._register(this.tree.onDidChangeSelection(() => { this.selectedGuideSessionIds.set(guideOwnerSessionIds(this.tree.getSelection()), undefined); })); @@ -2983,6 +3084,12 @@ export class SessionsList extends Disposable implements ISessionsList { this.commandService.executeCommand('sessionsView.manageAutomations'); return; } + if (isSessionGroupItem(element) && element.comparison) { + if (!DOM.isMouseEvent(e.browserEvent) || e.browserEvent.button !== 0) { + this.commandService.executeCommand(OPEN_SESSION_COMPARISON_COMMAND_ID, element.comparison.id); + } + return; + } if (!isSessionSection(element) && !isSessionGroupItem(element)) { // Gate the open on workspace trust before any side effect (mark-read, // activation, folder mount). A refused open leaves the current @@ -3267,14 +3374,40 @@ export class SessionsList extends Disposable implements ISessionsList { // service (defaulting to newest-first), independent of their members' // recency, and is shared across both grouping modes. const groupItemsById = new Map(); + const comparisonsByGroupId = new Map(this.sessionComparisonService.comparisons.get().map(comparison => [comparison.groupId, comparison])); + this.renderedGroupConnectorPositions.clear(); + this.renderedComparisonAttemptLabels.clear(); + this.renderedComparisonParticipantIds.clear(); for (const group of this._sessionGroupsService.getGroups()) { const members = groupedMembers.get(group.id) ?? []; - const sortedMembers = sortSessions(members, sorting, sortKeyForGrouping); + const comparison = comparisonsByGroupId.get(group.id); + const sortedMembers = comparison + ? sortComparisonGroupMembers(comparison, members, sorting, sortKeyForGrouping) + : sortSessions(members, sorting, sortKeyForGrouping); + if (comparison) { + for (const member of members.filter(candidate => comparison.participants.some(participant => participant.sessionResource && isEqual(participant.sessionResource, candidate.resource)))) { + this.renderedComparisonParticipantIds.add(member.sessionId); + } + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + for (const participant of attempts) { + const session = participant.sessionResource + ? members.find(member => isEqual(member.resource, participant.sessionResource)) + : undefined; + if (session) { + this.renderedComparisonAttemptLabels.set(session.sessionId, getSessionComparisonHarnessLabel(participant)); + } + } + } groupItemsById.set(group.id, { group, sessions: sortedMembers, isEmpty: this._sessionGroupsService.getSessionIdsInGroup(group.id).length === 0, editing: group.id === this._editingGroupId, + comparison: comparison ? { + id: comparison.id, + title: comparison.title, + summary: reader => getComparisonGroupSummary(comparison, this.sessions, reader), + } : undefined, }); } const defaultGroupIds = [...groupItemsById.values()] @@ -3438,6 +3571,18 @@ export class SessionsList extends Disposable implements ISessionsList { } }] : renderSessionChildren(groupItem.sessions, sectionId, groupItem.group.name, !this.hasFindPattern && this.workspaceGroupCapped); + const visibleGroupSessions = groupChildren.map(child => child.element).filter(isSessionItem); + const connectorSessions = groupItem.comparison + ? visibleGroupSessions.filter(session => this.renderedComparisonAttemptLabels.has(session.sessionId)) + : visibleGroupSessions; + if (connectorSessions.length > 1) { + for (let index = 0; index < connectorSessions.length; index++) { + this.renderedGroupConnectorPositions.set( + connectorSessions[index].sessionId, + index === 0 ? 'first' : index === connectorSessions.length - 1 ? 'last' : 'middle', + ); + } + } return { element: groupItem, collapsible: true, @@ -4218,6 +4363,28 @@ export class SessionsList extends Disposable implements ISessionsList { return this.getRenderedSessionGroup(session) !== undefined; } + private shouldShowComparisonAttemptStatus(session: ISession, reader: IReader): boolean { + const comparison = this.sessionComparisonService.getComparisonForSession(session.resource); + if (!comparison) { + return false; + } + if (getSessionRowStatus(session, reader, false) === SessionStatus.InProgress) { + return true; + } + const statuses = comparison.participants + .filter(participant => participant.role === SessionComparisonParticipantRole.Attempt) + .map(participant => { + if (participant.launchError) { + return SessionStatus.Error; + } + const attemptSession = participant.sessionResource + ? this.sessions.find(candidate => isEqual(candidate.resource, participant.sessionResource)) + : undefined; + return attemptSession?.status.read(reader); + }); + return statuses.includes(SessionStatus.Error) || new Set(statuses).size > 1; + } + /** Whether any registered provider can create quick chats (gates the always-visible "Chats" section). */ private _someProviderSupportsQuickChats(): boolean { return this._sessionsProvidersService.getProviders().some(p => !!p.supportsQuickChats); @@ -4513,6 +4680,77 @@ export function sortSessions(sessions: ISession[], sorting: SessionsSorting, get return [...sessions].sort((a, b) => key(b, sorting) - key(a, sorting)); } +function sortComparisonGroupMembers(comparison: ISessionComparison, sessions: ISession[], sorting: SessionsSorting, getSortKey: (session: ISession, sorting: SessionsSorting) => number): ISession[] { + const participants = getSessionComparisonParticipantsInDisplayOrder(comparison.participants); + const participantOrder = (session: ISession) => participants.findIndex(participant => participant.sessionResource && isEqual(participant.sessionResource, session.resource)); + return sortSessions(sessions, sorting, getSortKey).sort((a, b) => { + const aIndex = participantOrder(a); + const bIndex = participantOrder(b); + if (aIndex < 0) { + return bIndex < 0 ? 0 : 1; + } + if (bIndex < 0) { + return -1; + } + return aIndex - bIndex; + }); +} + +function getComparisonGroupSummary(comparison: ISessionComparison, sessions: readonly ISession[], reader?: IReader): string { + if (comparison.verdict) { + return localize('comparisonGroup.reviewReady', "Comparison · Review ready"); + } + const judge = comparison.participants.find(participant => participant.role === SessionComparisonParticipantRole.Judge); + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + const statuses = attempts.map(participant => { + if (participant.launchError) { + return SessionStatus.Error; + } + return participant.sessionResource + ? reader + ? sessions.find(session => isEqual(session.resource, participant.sessionResource))?.status.read(reader) + : sessions.find(session => isEqual(session.resource, participant.sessionResource))?.status.get() + : undefined; + }); + const needsInput = statuses.filter(status => status === SessionStatus.NeedsInput).length; + const working = statuses.filter(status => status === SessionStatus.InProgress).length; + const finished = statuses.filter(status => status === SessionStatus.Completed || status === SessionStatus.Error).length; + if (needsInput > 0) { + return needsInput === 1 + ? localize('comparisonGroup.oneAttemptNeedsInput', "Comparison · 1 attempt needs input") + : localize('comparisonGroup.attemptsNeedInput', "Comparison · {0} attempts need input", needsInput); + } + if (working > 0) { + return working === 1 + ? localize('comparisonGroup.oneAttemptWorking', "Comparison · 1 attempt working") + : localize('comparisonGroup.attemptsWorking', "Comparison · {0} attempts working", working); + } + if (judge?.launchError) { + return localize('comparisonGroup.reviewFailed', "Comparison · Review failed"); + } + if (judge?.sessionResource) { + const judgeStatus = reader + ? sessions.find(session => isEqual(session.resource, judge.sessionResource))?.status.read(reader) + : sessions.find(session => isEqual(session.resource, judge.sessionResource))?.status.get(); + if (judgeStatus === SessionStatus.NeedsInput) { + return localize('comparisonGroup.judgeNeedsInput', "Comparison · Judge needs input"); + } + if (judgeStatus === SessionStatus.Untitled || judgeStatus === SessionStatus.InProgress) { + return localize('comparisonGroup.reviewing', "Comparison · Reviewing attempts"); + } + if (judgeStatus === SessionStatus.Error) { + return localize('comparisonGroup.reviewFailed', "Comparison · Review failed"); + } + if (judgeStatus === SessionStatus.Completed) { + return localize('comparisonGroup.reviewIncomplete', "Comparison · Review incomplete"); + } + } + if (finished === attempts.length) { + return localize('comparisonGroup.attemptsFinished', "Comparison · {0} attempts finished", attempts.length); + } + return localize('comparisonGroup.attempts', "Comparison · {0} attempts", attempts.length); +} + export interface ISessionLimitResult { readonly sessions: readonly ISession[]; readonly showMore: ISessionShowMore | undefined; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 655dbcafee00e3..44c6a09dec94fb 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -28,14 +28,15 @@ import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSection import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { localize } from '../../../../../nls.js'; import { SessionsList, SessionsGrouping, SessionsSorting } from './sessionsList.js'; -import { SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionComparisonService } from '../../../../services/sessions/common/sessionComparison.js'; import { AICustomizationShortcutsWidget } from '../aiCustomizationShortcutsWidget.js'; import { AgentHostShortcutsWidget } from '../agentHostShortcutsWidget.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { agentsBackground } from '../../../../common/theme.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; -import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { PANEL_SECTION_BORDER } from '../../../../../workbench/common/theme.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -45,6 +46,7 @@ import { MobileSessionFilterChips } from '../../../../browser/parts/mobile/mobil import { IMobileSortGroupSheetItem, showMobileSortGroupSheet } from '../../../../browser/parts/mobile/mobileSortGroupSheet.js'; import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; const $ = DOM.$; export const SessionsViewId = 'sessions.workbench.view.sessionsView'; @@ -135,8 +137,9 @@ export class SessionsView extends ViewPane { @IHoverService hoverService: IHoverService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, + @ISessionComparisonService private readonly sessionComparisonService: ISessionComparisonService, @IHostService private readonly hostService: IHostService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IAgentWorkbenchLayoutService private readonly layoutService: IAgentWorkbenchLayoutService, @IStorageService private readonly storageService: IStorageService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -166,6 +169,16 @@ export class SessionsView extends ViewPane { this.workspaceGroupCappedContextKey = IsWorkspaceGroupCappedContext.bindTo(contextKeyService); } + private _handleSessionOpened(session: ISession): void { + const comparison = this.sessionComparisonService.getComparisonForSession(session.resource); + if (comparison) { + this.layoutService.hideSidePane(); + } + if (isWeb && isPhoneLayout(this.layoutService)) { + this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); + } + } + protected override renderBody(parent: HTMLElement): void { super.renderBody(parent); @@ -227,16 +240,12 @@ export class SessionsView extends ViewPane { compact: () => this.currentCompact && !isPhoneLayout(this.layoutService), findWidgetContainer, onSessionOpen: (resource, preserveFocus, sideBySide) => { - const onOpened = () => { - if (isWeb && isPhoneLayout(this.layoutService)) { - this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); - } - }; const session = this.sessionsManagementService.getSession(resource); if (!session) { onUnexpectedError(new Error(`Unable to open session because '${resource.toString()}' is not available`)); return; } + const onOpened = () => this._handleSessionOpened(session); if (sideBySide) { // Alt-click: open the session to the right of the last visible session in the grid. this.sessionsService.openSessionToSide(session, { preserveFocus, source: 'sessionsList', restoreOnlySideOrToolChat: true }).then(onOpened).catch(onUnexpectedError); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index 93be0e42f113fe..040cfd47ee25e3 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -84,6 +84,7 @@ suite('Sessions - Actions', () => { assert.deepStrictEqual(actions, [ { id: 'sessions.chatCompositeBar.togglePin', group: 'navigation' }, + { id: 'sessions.chatCompositeBar.close', group: 'navigation' }, { id: 'sessions.sessionHeader.rename', group: 'secondary/1_session' }, { id: 'sessions.chatCompositeBar.addChat', group: 'secondary/3_newChat' }, { id: 'sessions.chatCompositeBar.togglePin', group: 'secondary/4_pin' }, @@ -141,6 +142,28 @@ suite('Sessions - Actions', () => { }]); }); + test('shows Close in every multi-pane desktop header', () => { + const closeItems = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .filter(item => item.command.id === 'sessions.chatCompositeBar.close') + .sort((a, b) => (a.group ?? '').localeCompare(b.group ?? '')) + .map(item => ({ + group: item.group, + order: item.order, + when: item.when?.serialize(), + })); + + assert.deepStrictEqual(closeItems, [{ + group: 'navigation', + order: 20, + when: 'multipleSessionsVisible && !sessionsIsPhoneLayout', + }, { + group: 'secondary/4_pin', + order: 30, + when: 'multipleSessionsVisible || sessionIsCreated', + }]); + }); + test('keeps the Command Palette delete action explicit', () => { const deleteChat = MenuRegistry.getCommand('sessions.chatCompositeBar.deleteChat'); @@ -227,6 +250,7 @@ suite('Sessions - Actions', () => { assert.deepStrictEqual(actions, [ { title: 'Pin', group: 'navigation' }, { title: 'Pin', group: 'secondary/4_pin' }, + { title: 'Close', group: 'navigation' }, { title: 'Maximize', group: 'secondary/4_pin' }, { title: 'Close', group: 'secondary/4_pin' }, ]); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index a2fbcc3ef938db..5f666af2b49a45 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -48,6 +48,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { ISessionComparison, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionItemToolbarMenuId, SessionSectionRenderer, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; @@ -57,6 +58,7 @@ import '../../browser/views/sessionsViewActions.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; +import { OPEN_SESSION_COMPARISON_COMMAND_ID } from '../../../sessionComparison/common/sessionComparison.js'; import { BlockedSessionReason, BlockedSessions } from '../../../blockedSessions/browser/blockedSessions.js'; function createSession(id: string, opts: { @@ -1807,15 +1809,26 @@ suite('Sessions - SessionsList', () => { return { list, container }; } - function rowSnapshot(container: HTMLElement): { title: string; badge: string | undefined; ariaLabel: string | null; details: string }[] { + function rowSnapshot(container: HTMLElement): { title: string; badge: string | undefined; ariaLabel: string | null; details: string; groupConnector: string | undefined }[] { return [...container.querySelectorAll('.session-item')].map(item => ({ title: item.querySelector('.session-title')?.textContent ?? '', badge: item.querySelector('.session-badge')?.textContent ?? undefined, ariaLabel: item.closest('.monaco-list-row')?.getAttribute('aria-label') ?? null, details: item.querySelector('.session-details-row')?.textContent ?? '', + groupConnector: item.dataset.sessionGroupConnector, })); } + test('custom-group rows show connected first, middle, and last indicators', () => { + const first = createTestSession('First', { workspaceLabel: 'vscode' }).session; + const middle = createTestSession('Middle', { workspaceLabel: 'vscode' }).session; + const last = createTestSession('Last', { workspaceLabel: 'vscode' }).session; + const memberships = new Map([first, middle, last].map(session => [session.sessionId, group.id])); + const { container } = renderList([first, middle, last], SessionsGrouping.Workspace, { memberships }); + + assert.deepStrictEqual(rowSnapshot(container).map(row => row.groupConnector), ['first', 'middle', 'last']); + }); + test('workspace grouping shows a badge only under a custom group', () => { const grouped = createTestSession('Grouped', { workspaceLabel: 'vscode' }).session; const ordinary = createTestSession('Ordinary', { workspaceLabel: 'vscode' }).session; @@ -1916,6 +1929,182 @@ suite('Sessions - SessionsList', () => { }); }); + suite('comparison groups', () => { + const group: ISessionGroup = { id: 'comparison-group', name: 'Compare: Improve the picker', createdAt: 1 }; + + function renderComparison(verdict?: ISessionComparison['verdict']) { + const attempt1 = createTestSession('Stored attempt one', { resourceId: 'attempt-1', status: SessionStatus.InProgress }); + const attempt2 = createTestSession('Stored attempt two', { resourceId: 'attempt-2', status: SessionStatus.InProgress }); + const judge = createTestSession('Judge', { resourceId: 'judge', status: SessionStatus.InProgress }); + const synthesis = createTestSession('Synthesis', { resourceId: 'synthesis', status: SessionStatus.InProgress }); + const comparison: ISessionComparison = { + id: 'comparison-1', + groupId: group.id, + title: 'Improve the picker', + createdAt: 1, + workspace: URI.parse('file:///workspace'), + prompt: 'Improve the picker', + verdict, + participants: [ + { + id: 'participant-1', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'test', sessionTypeId: 'copilot', label: 'Copilot', modelLabel: 'Claude Opus 5' }, + sessionResource: attempt1.session.resource, + }, + { + id: 'participant-2', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'test', sessionTypeId: 'codex', label: 'Codex', modelLabel: 'GPT-5' }, + sessionResource: attempt2.session.resource, + }, + { + id: 'judge', + role: SessionComparisonParticipantRole.Judge, + harness: { providerId: 'test', sessionTypeId: 'copilot', label: 'Copilot', modelLabel: 'Claude Opus 5' }, + sessionResource: judge.session.resource, + }, + { + id: 'synthesis', + role: SessionComparisonParticipantRole.Synthesis, + harness: { providerId: 'test', sessionTypeId: 'copilot', label: 'Copilot', modelLabel: 'Claude Opus 5' }, + sessionResource: synthesis.session.resource, + }, + ], + }; + const sessions = [attempt2.session, synthesis.session, judge.session, attempt1.session]; + const memberships = new Map(sessions.map(session => [session.sessionId, group.id])); + const harness = createListHarness(disposables, sessions, { groups: [group], memberships, comparisons: [comparison] }); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Workspace, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(400, 400); + return { attempt1, attempt2, judge, synthesis, container, harness }; + } + + test('renders Judge and synthesis before connected compact attempts', () => { + const { attempt1, attempt2, container } = renderComparison(); + const parent = container.querySelector('.session-comparison-group'); + const attempts = [...container.querySelectorAll('.session-comparison-attempt')]; + const participants = [...container.querySelectorAll('.session-comparison-participant')]; + const independentParticipants = participants.filter(participant => !participant.classList.contains('session-comparison-attempt')); + const judge = independentParticipants.find(participant => participant.querySelector('.session-title')?.textContent === 'Judge'); + assert.ok(parent && judge); + + assert.deepStrictEqual({ + parent: { + title: parent.querySelector('.session-section-label')?.textContent, + summary: parent.querySelector('.session-group-description')?.textContent, + layersIcon: parent.querySelector('.session-section-icon')?.classList.contains('codicon-layers'), + ariaLabel: parent.closest('.monaco-list-row')?.getAttribute('aria-label'), + }, + order: participants.map(participant => participant.querySelector('.session-title')?.textContent), + attempts: attempts.map(attempt => ({ + title: attempt.querySelector('.session-title')?.textContent, + ariaLabel: attempt.closest('.monaco-list-row')?.getAttribute('aria-label'), + status: attempt.querySelector('.session-comparison-attempt-status.visible')?.textContent, + hasSpinner: attempt.querySelector('.session-comparison-attempt-status-icon')?.classList.contains('codicon-modifier-spin'), + details: attempt.querySelector('.session-details-row')?.textContent, + height: attempt.closest('.monaco-list-row')?.style.height, + connectorVisibility: mainWindow.getComputedStyle(attempt.querySelector('.session-icon')!).visibility, + })), + judge: { + title: judge.querySelector('.session-title')?.textContent, + inProgress: judge.classList.contains('in-progress'), + hasProgressIndicator: judge.querySelector('.session-icon')?.childElementCount === 1, + connector: judge.getAttribute('data-session-group-connector'), + }, + independentParticipantConnectors: independentParticipants.map(participant => participant.getAttribute('data-session-group-connector')), + }, { + parent: { + title: 'Improve the picker', + summary: 'Comparison · 2 attempts working', + layersIcon: true, + ariaLabel: 'Improve the picker, Comparison · 2 attempts working', + }, + order: ['Judge', 'Synthesis', 'Copilot · Claude Opus 5', 'Codex · GPT-5'], + attempts: [ + { title: 'Copilot · Claude Opus 5', ariaLabel: 'Copilot · Claude Opus 5, updated now, State: In Progress', status: '', hasSpinner: true, details: '', height: '30px', connectorVisibility: 'visible' }, + { title: 'Codex · GPT-5', ariaLabel: 'Codex · GPT-5, updated now, State: In Progress', status: '', hasSpinner: true, details: '', height: '30px', connectorVisibility: 'visible' }, + ], + judge: { title: 'Judge', inProgress: true, hasProgressIndicator: true, connector: null }, + independentParticipantConnectors: [null, null], + }); + + attempt1.status.set(SessionStatus.Completed, undefined); + assert.deepStrictEqual({ + statuses: attempts.map(attempt => attempt.querySelector('.session-comparison-attempt-status.visible')?.textContent), + ariaLabels: attempts.map(attempt => attempt.closest('.monaco-list-row')?.getAttribute('aria-label')), + }, { + statuses: [undefined, ''], + ariaLabels: [ + 'Copilot · Claude Opus 5, updated now, State: Completed, in Workspace', + 'Codex · GPT-5, updated now, State: In Progress', + ], + }); + attempt2.status.set(SessionStatus.Completed, undefined); + assert.deepStrictEqual({ + summary: parent.querySelector('.session-group-description')?.textContent, + statuses: attempts.map(attempt => attempt.querySelector('.session-comparison-attempt-status.visible')?.textContent), + }, { + summary: 'Comparison · Reviewing attempts', + statuses: [undefined, undefined], + }); + }); + + test('opens from the parent and reserves disclosure for the chevron', () => { + const { container, harness } = renderComparison(); + const parent = container.querySelector('.session-comparison-group'); + const parentRow = parent?.closest('.monaco-list-row'); + const chevron = parent?.querySelector('.session-section-chevron'); + assert.ok(parent && parentRow && chevron); + const expandedBefore = parentRow.getAttribute('aria-expanded'); + + parent.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + parent.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + assert.deepStrictEqual({ + command: harness.commandService.calls.at(-1), + expanded: parentRow.getAttribute('aria-expanded'), + }, { + command: { commandId: OPEN_SESSION_COMPARISON_COMMAND_ID, args: ['comparison-1'] }, + expanded: expandedBefore, + }); + + harness.commandService.calls.length = 0; + chevron.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + assert.deepStrictEqual({ + commands: harness.commandService.calls, + expanded: parentRow.getAttribute('aria-expanded'), + summary: parent.querySelector('.session-group-description')?.textContent, + }, { + commands: [], + expanded: expandedBefore === 'true' ? 'false' : 'true', + summary: 'Comparison · 2 attempts working', + }); + }); + + test('marks a judged comparison as ready to review', () => { + const { container } = renderComparison({ + recommendedParticipantId: 'participant-1', + explanation: 'Attempt 1 is the strongest.', + conflicts: [], + attempts: [], + }); + const parent = container.querySelector('.session-comparison-group'); + + assert.deepStrictEqual({ + summary: parent?.querySelector('.session-group-description')?.textContent, + ariaLabel: parent?.closest('.monaco-list-row')?.getAttribute('aria-label'), + }, { + summary: 'Comparison · Review ready', + ariaLabel: 'Improve the picker, Comparison · Review ready', + }); + }); + }); + suite('empty group filter', () => { test('hides empty custom and default groups and persists the filter', () => { const emptyGroup: ISessionGroup = { id: 'empty', name: 'Empty Group', createdAt: 2 }; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index f4e556502ab1b7..324bfa7bdbb5cf 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -27,6 +27,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { ISessionsWindowUsageService } from '../../../../services/sessions/browser/sessionsWindowUsageService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IChat, ISession, ISessionCapabilities, ISessionChangesSummary, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionComparison, ISessionComparisonService } from '../../../../services/sessions/common/sessionComparison.js'; import { IDeleteChatOptions } from '../../../../services/sessions/common/sessionsProvider.js'; const ITestAgentSessionsService = createDecorator('agentSessions'); @@ -181,6 +182,7 @@ export interface IListHarnessOptions { readonly groups?: readonly ISessionGroup[]; readonly memberships?: ReadonlyMap; readonly pinnedSessionIds?: ReadonlySet; + readonly comparisons?: readonly ISessionComparison[]; } type ConfigureListHarness = (instantiationService: TestInstantiationService) => void; @@ -195,6 +197,7 @@ export function createListHarness(disposables: Pick, ses const groups = options.groups ?? []; const memberships = options.memberships ?? new Map(); const pinnedSessionIds = options.pinnedSessionIds ?? new Set(); + const comparisons = options.comparisons ?? []; const sortChanges: ISortChangeRecord[] = []; instantiationService.stub(ISessionsManagementService, managementService); @@ -229,6 +232,13 @@ export function createListHarness(disposables: Pick, ses return [...memberships].filter(([, memberGroupId]) => memberGroupId === groupId).map(([sessionId]) => sessionId); } }); + instantiationService.stub(ISessionComparisonService, new class extends mock() { + override readonly comparisons = constObservable(comparisons); + override getComparison(comparisonId: string) { return comparisons.find(comparison => comparison.id === comparisonId); } + override getComparisonForSession(resource: URI) { + return comparisons.find(comparison => comparison.participants.some(participant => participant.sessionResource?.toString() === resource.toString())); + } + }); instantiationService.stub(ISessionSectionOrderService, new class extends mock() { override readonly onDidChange = Event.None; override resolveOrder(ids: readonly string[]) { return [...ids]; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts index 22d811c3d8fba9..3b6040a8ed1982 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts @@ -8,9 +8,13 @@ import { mainWindow } from '../../../../../base/browser/window.js'; import { SplitView, Sizing } from '../../../../../base/browser/ui/splitview/splitview.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { Workbench } from '../../../../browser/workbench.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { ISessionComparison, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { SessionsView } from '../../browser/views/sessionsView.js'; import '../../browser/media/sessionsViewPane.css'; @@ -23,6 +27,10 @@ const registerEditorTabHeightClass = Reflect.get(Workbench.prototype, 'registerE }; _register(disposable: T): T; }) => void; +const handleSessionOpened = Reflect.get(SessionsView.prototype, '_handleSessionOpened') as (this: { + readonly sessionComparisonService: { getComparisonForSession(resource: URI): ISessionComparison | undefined }; + readonly layoutService: { hideSidePane(): void; mainContainer: HTMLElement; setPartHidden(hidden: boolean, part: string): void }; +}, session: ISession) => void; suite('Sessions - SessionsViewPane', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -158,4 +166,47 @@ suite('Sessions - SessionsViewPane', () => { workbench.remove(); } }); + + test('hides session details when a comparison participant is opened', () => { + const attempt = upcastPartial({ resource: URI.parse('test:/attempt') }); + const judge = upcastPartial({ resource: URI.parse('test:/judge') }); + const comparison: ISessionComparison = { + id: 'comparison', + groupId: 'group', + title: 'Compare', + createdAt: 1, + workspace: URI.file('/workspace'), + prompt: 'Implement', + participants: [ + { + id: 'attempt', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'test', sessionTypeId: 'test', label: 'Test' }, + sessionResource: attempt.resource, + }, + { + id: 'judge', + role: SessionComparisonParticipantRole.Judge, + harness: { providerId: 'test', sessionTypeId: 'test', label: 'Test' }, + sessionResource: judge.resource, + }, + ], + }; + let hideSidePaneCalls = 0; + const host = { + sessionComparisonService: { + getComparisonForSession: () => comparison, + }, + layoutService: { + hideSidePane: () => hideSidePaneCalls++, + mainContainer: mainWindow.document.createElement('div'), + setPartHidden: () => { }, + }, + }; + + handleSessionOpened.call(host, attempt); + handleSessionOpened.call(host, judge); + + assert.strictEqual(hideSidePaneCalls, 2); + }); }); diff --git a/src/vs/sessions/prompts/judge.md b/src/vs/sessions/prompts/judge.md new file mode 100644 index 00000000000000..e4ceb70fd28d95 --- /dev/null +++ b/src/vs/sessions/prompts/judge.md @@ -0,0 +1,13 @@ +# Judge implementation attempts + +Judge implementation comparison `{{comparisonId}}`. + +1. Call `#readAttemptComparison` exactly once with this comparison ID. +2. Review every attempt's code changes and validation evidence. Terminal commands start in the Judge worktree, not an attempt worktree, so explicitly `cd` to the exact `worktree.workingDirectory` from the manifest in every command that inspects or validates an attempt. +3. Run missing targeted tests, build, lint, or diagnostics when needed to make a reliable recommendation. +4. Record whether each validation result came from the attempt report, your own Judge run, or unavailable evidence. When a validation category genuinely does not apply, use `notApplicable` for both its result and source. +5. Explain why the winning attempt is strongest using specific code and validation evidence. For every other attempt, record its strongest reusable points in `notableDifferences`. +6. Identify semantic `decisionSections` where attempts make meaningfully different implementation choices. Give each section a stable ID, short title, description, affected repository-relative files, one concise option per relevant `attemptNumber`, and a recommended `attemptNumber`. Return an empty array when there are no meaningful choices. Do not use raw line numbers as section identity. +7. Do not modify, merge, apply, or delete any attempt. +8. Call `#completeAttemptComparison` with the recommendation and supporting evidence. Refer to attempts only by the `attemptNumber` values returned by `#readAttemptComparison`; do not copy participant or session UUIDs. If it rejects invalid input, correct the reported fields and retry; do not submit again after success. +9. After the tool returns, respond concisely with the winning attempt, specific code and validation evidence for why it won, and the strongest reusable points from every other attempt. diff --git a/src/vs/sessions/services/sessions/browser/sessionComparisonService.ts b/src/vs/sessions/services/sessions/browser/sessionComparisonService.ts new file mode 100644 index 00000000000000..7b10e0244429b0 --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionComparisonService.ts @@ -0,0 +1,609 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { withSessionComparisonMetadata } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { localize } from '../../../../nls.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { aggregateChatUsage, IChatUsageSummary } from '../../../../workbench/contrib/chat/common/chatUsage.js'; +import { SessionStatus } from '../common/session.js'; +import { ISessionGroupsService } from './sessionGroupsService.js'; +import { ISessionsManagementService } from '../common/sessionsManagement.js'; +import { getSessionComparisonHarnessLabel, ISessionComparison, ISessionComparisonHarness, ISessionComparisonParticipant, ISessionComparisonService, ISessionComparisonSynthesisPlan, ISessionComparisonVerdict, IStartSessionComparisonOptions, SessionComparisonParticipantRole } from '../common/sessionComparison.js'; +import { hashSessionIdForTelemetry, logSessionComparisonAttemptCompleted, logSessionComparisonAttemptJudged } from '../../../common/sessionsTelemetry.js'; + +const JUDGE_PROMPT_URI = FileAccess.asFileUri('vs/sessions/prompts/judge.md'); +const JUDGE_COMPARISON_ID_PLACEHOLDER = '{{comparisonId}}'; + +interface IStoredSessionComparisonParticipant extends Omit { + readonly sessionResource?: string; +} + +interface IStoredSessionComparison extends Omit { + readonly workspace: string; + readonly participants: readonly IStoredSessionComparisonParticipant[]; +} + +export class SessionComparisonService extends Disposable implements ISessionComparisonService { + declare readonly _serviceBrand: undefined; + + private static readonly STORAGE_KEY = 'sessions.comparisons'; + private static readonly TELEMETRY_STORAGE_KEY = 'sessions.comparisonTelemetry'; + + private readonly _comparisons = observableValue(this, []); + readonly comparisons = this._comparisons; + private readonly _judgeStarting = new Set(); + private readonly _synthesisStarting = new Set(); + private readonly _migratingAttemptTitles = new Set(); + private readonly _migratedAttemptTitles = new Set(); + private readonly _reportedExecutionTelemetry = new Set(); + private readonly _reportedOutcomeTelemetry = new Set(); + private _judgePromptTemplate: Promise | undefined; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionGroupsService private readonly sessionGroupsService: ISessionGroupsService, + @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, + @IChatService private readonly chatService: IChatService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IFileService private readonly fileService: IFileService, + ) { + super(); + this._loadTelemetryState(); + const comparisons = this._load(); + this._comparisons.set(comparisons, undefined); + this._ensureComparisonGroupMembership(comparisons); + this._migrateLegacyAttemptTitles(comparisons); + this._register(this.sessionsManagementService.onDidChangeSessions(() => { + const comparisons = this._comparisons.get(); + this._ensureComparisonGroupMembership(comparisons); + this._migrateLegacyAttemptTitles(comparisons); + this._checkComparisons(); + })); + this._checkComparisons(); + } + + async startComparison(options: IStartSessionComparisonOptions, token: CancellationToken = CancellationToken.None): Promise { + if (options.attempts.length < 2) { + throw new Error('A session comparison requires at least two attempts.'); + } + if (new Set(options.attempts.map(attempt => attempt.id)).size !== options.attempts.length) { + throw new Error('Session comparison attempt identifiers must be unique.'); + } + + const id = generateUuid(); + const title = comparisonTitle(options.prompt); + const group = this.sessionGroupsService.createGroup(title); + let comparison: ISessionComparison = { + id, + groupId: group.id, + title, + createdAt: Date.now(), + workspace: options.workspace, + prompt: options.prompt, + branch: options.branch, + permissionLevel: options.permissionLevel, + judgeHarness: options.judgeHarness, + participants: [], + }; + this._addComparison(comparison); + + const attemptPromises = options.attempts.map(async (attempt, index): Promise => { + const harness = attempt.harness; + const participant = { + id: attempt.id, + role: SessionComparisonParticipantRole.Attempt, + harness, + } satisfies ISessionComparisonParticipant; + try { + const session = await this.sessionsManagementService.createAndSendNewChatRequest(options.workspace, { + query: options.prompt, + attachedContext: options.attachedContext ? [...options.attachedContext] : undefined, + title: getSessionComparisonHarnessLabel(participant), + background: true, + }, this._createOptions(harness, options, id, index), token); + return { + ...participant, + sessionResource: session?.resource, + ...(!session ? { launchError: localize('sessionComparison.launchUnavailable', "The session did not start.") } : {}), + } satisfies ISessionComparisonParticipant; + } catch (error) { + return { + ...participant, + launchError: isCancellationError(error) + ? localize('sessionComparison.launchCancelled', "The attempt was cancelled before it started.") + : error instanceof Error ? error.message : String(error), + } satisfies ISessionComparisonParticipant; + } + }); + + const attempts = await Promise.all(attemptPromises); + comparison = { ...comparison, participants: attempts }; + this._ensureComparisonGroupMembership([comparison]); + this._replaceComparison(comparison); + this._checkComparison(comparison); + const successfulAttemptCount = attempts.filter(participant => participant.sessionResource).length; + if (successfulAttemptCount < 2) { + this._removeComparison(comparison.id); + this.sessionGroupsService.deleteGroup(comparison.groupId); + const launchFailures = attempts + .filter(participant => participant.launchError) + .map(participant => `${getSessionComparisonHarnessLabel(participant)}: ${participant.launchError}`) + .join('; '); + throw new Error(localize( + 'sessionComparison.insufficientSuccessfulAttempts', + "Only {0} of {1} comparison attempts started. At least two must start successfully. Failed attempts: {2}", + successfulAttemptCount, + attempts.length, + launchFailures + )); + } + return comparison; + } + + getComparison(comparisonId: string): ISessionComparison | undefined { + return this._comparisons.get().find(comparison => comparison.id === comparisonId); + } + + getComparisonForSession(resource: URI): ISessionComparison | undefined { + return this._comparisons.get().find(comparison => + comparison.participants.some(participant => participant.sessionResource && isEqual(participant.sessionResource, resource))); + } + + selectAttempt(comparisonId: string, participantId: string): void { + const comparison = this._requireComparison(comparisonId); + const participant = comparison.participants.find(candidate => + candidate.id === participantId && candidate.role === SessionComparisonParticipantRole.Attempt && candidate.sessionResource); + if (!participant) { + throw new Error(`Comparison attempt '${participantId}' was not found.`); + } + this._replaceComparison({ ...comparison, selectedParticipantId: participantId }); + } + + submitVerdict(comparisonId: string, verdict: ISessionComparisonVerdict): void { + const comparison = this._requireComparison(comparisonId); + const attemptIds = new Set(comparison.participants + .filter(participant => participant.role === SessionComparisonParticipantRole.Attempt && participant.sessionResource) + .map(participant => participant.id)); + if (!attemptIds.has(verdict.recommendedParticipantId)) { + throw new Error(`Recommended comparison attempt '${verdict.recommendedParticipantId}' was not found.`); + } + if (verdict.attempts.some(attempt => !attemptIds.has(attempt.participantId))) { + throw new Error('The comparison verdict contains an unknown attempt.'); + } + const sectionIds = new Set(); + for (const section of verdict.decisionSections ?? []) { + const optionIds = new Set(section.options.map(option => option.participantId)); + if (sectionIds.has(section.id) + || !attemptIds.has(section.recommendedParticipantId) + || !optionIds.has(section.recommendedParticipantId) + || optionIds.size !== section.options.length + || section.options.some(option => !attemptIds.has(option.participantId))) { + throw new Error('The comparison verdict contains an invalid synthesis decision section.'); + } + sectionIds.add(section.id); + } + this._replaceComparison({ ...comparison, verdict, synthesisPlan: undefined }); + this._reportOutcomeTelemetry(comparison, verdict); + } + + setSynthesisPlan(comparisonId: string, plan: ISessionComparisonSynthesisPlan | undefined): void { + const comparison = this._requireComparison(comparisonId); + if (!plan) { + this._replaceComparison({ ...comparison, synthesisPlan: undefined }); + return; + } + const sections = new Map((comparison.verdict?.decisionSections ?? []).map(section => [section.id, section])); + const selectedSectionIds = new Set(); + for (const selection of plan.selections) { + const section = sections.get(selection.sectionId); + if (!section + || selectedSectionIds.has(selection.sectionId) + || selection.participantId !== undefined && !section.options.some(option => option.participantId === selection.participantId)) { + throw new Error('The synthesis plan contains an invalid section selection.'); + } + selectedSectionIds.add(selection.sectionId); + } + this._replaceComparison({ ...comparison, synthesisPlan: plan }); + } + + async synthesize(comparisonId: string): Promise { + if (this._synthesisStarting.has(comparisonId)) { + return; + } + const comparison = this._requireComparison(comparisonId); + if (comparison.participants.some(participant => participant.role === SessionComparisonParticipantRole.Synthesis)) { + return; + } + const recommendedId = comparison.selectedParticipantId ?? comparison.verdict?.recommendedParticipantId; + const recommended = comparison.participants.find(participant => + participant.id === recommendedId && participant.role === SessionComparisonParticipantRole.Attempt && participant.sessionResource); + if (!recommended) { + throw new Error('A selected or recommended attempt is required before synthesis.'); + } + + this._synthesisStarting.add(comparisonId); + try { + const session = await this.sessionsManagementService.createAndSendNewChatRequest(comparison.workspace, { + query: localize('sessionComparison.synthesisPrompt', "Synthesize the strongest parts of comparison {0} into a new implementation. First call #readAttemptComparison exactly once with that comparison ID. Read implementation code only from the authoritative worktrees in its manifest. If changedFilesStatus is unavailable, read the Git diff from that worktree. If the manifest includes a synthesisPlan, treat every selected section as an explicit user requirement and resolve cross-section dependencies coherently instead of copying hunks mechanically. Call get_session_context only with an exact sessionContextTarget returned by the manifest and only for rationale or validation evidence; never recover implementation code or paths from a transcript. Do not inspect another checkout, discover sessions, or guess references. Preserve correct behavior, resolve the Judge's reported conflicts, and run the relevant validation.\n\nJudge recommendation:\n{1}", comparison.id, comparison.verdict?.explanation ?? localize('sessionComparison.noJudgeExplanation', "No Judge explanation is available; use the selected attempt as the base.")), + title: localize('sessionComparison.synthesisTitle', "Synthesis: {0}", comparison.title), + background: true, + }, { + providerId: recommended.harness.providerId, + sessionTypeId: recommended.harness.sessionTypeId, + modelId: recommended.harness.modelId, + permissionLevel: comparison.permissionLevel, + isolationMode: 'worktree', + branch: comparison.branch, + metadata: withSessionComparisonMetadata(undefined, { + id: comparison.id, + role: 'synthesis', + attemptCount: comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt).length, + }), + }); + if (session) { + this.sessionGroupsService.addToGroup(session.sessionId, comparison.groupId); + } + const synthesis: ISessionComparisonParticipant = { + id: generateUuid(), + role: SessionComparisonParticipantRole.Synthesis, + harness: recommended.harness, + sessionResource: session?.resource, + ...(!session ? { launchError: localize('sessionComparison.synthesisUnavailable', "The synthesis session did not start.") } : {}), + }; + this._replaceComparison({ + ...this._requireComparison(comparisonId), + participants: [...this._requireComparison(comparisonId).participants, synthesis], + }); + } finally { + this._synthesisStarting.delete(comparisonId); + } + } + + private _createOptions(harness: ISessionComparisonHarness, options: IStartSessionComparisonOptions, comparisonId: string, attemptIndex: number) { + return { + providerId: harness.providerId, + sessionTypeId: harness.sessionTypeId, + modelId: harness.modelId, + permissionLevel: options.permissionLevel, + isolationMode: 'worktree', + branch: options.branch, + metadata: withSessionComparisonMetadata(undefined, { + id: comparisonId, + role: 'attempt', + attemptIndex, + attemptCount: options.attempts.length, + }), + }; + } + + private _requireComparison(comparisonId: string): ISessionComparison { + const comparison = this.getComparison(comparisonId); + if (!comparison) { + throw new Error(`Session comparison '${comparisonId}' was not found.`); + } + return comparison; + } + + private _checkComparisons(): void { + for (const comparison of this._comparisons.get()) { + this._checkComparison(comparison); + } + } + + private _checkComparison(comparison: ISessionComparison): void { + comparison = this._snapshotTerminalAttemptUsage(comparison); + this._reportTerminalAttemptTelemetry(comparison); + if (this._judgeStarting.has(comparison.id) + || comparison.participants.some(participant => participant.role === SessionComparisonParticipantRole.Judge)) { + return; + } + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + const successful = attempts.filter(participant => participant.sessionResource); + if (successful.length < 2 || attempts.some(participant => { + if (participant.launchError) { + return false; + } + const session = participant.sessionResource ? this.sessionsManagementService.getSession(participant.sessionResource) : undefined; + const status = session?.status.get(); + return status !== SessionStatus.Completed && status !== SessionStatus.Error; + })) { + return; + } + this._judgeStarting.add(comparison.id); + void this._startJudge(comparison).catch(error => { + this.logService.error('[SessionComparisonService] Failed to start the comparison judge.', error); + const current = this.getComparison(comparison.id); + if (current && !current.participants.some(participant => participant.role === SessionComparisonParticipantRole.Judge)) { + const harness = this._getJudgeHarness(current); + if (harness) { + this._replaceComparison({ + ...current, + participants: [...current.participants, { + id: generateUuid(), + role: SessionComparisonParticipantRole.Judge, + harness, + launchError: error instanceof Error ? error.message : String(error), + }], + }); + } + } + }).finally(() => this._judgeStarting.delete(comparison.id)); + } + + private _snapshotTerminalAttemptUsage(comparison: ISessionComparison): ISessionComparison { + let changed = false; + const participants = comparison.participants.map(participant => { + if (participant.role !== SessionComparisonParticipantRole.Attempt || participant.usage || !participant.sessionResource) { + return participant; + } + const session = this.sessionsManagementService.getSession(participant.sessionResource); + const status = session?.status.get(); + if (!session || (status !== SessionStatus.Completed && status !== SessionStatus.Error)) { + return participant; + } + const usage = this._getSessionUsage(session.mainChat.get().resource); + if (!usage) { + return participant; + } + changed = true; + return { ...participant, usage }; + }); + if (!changed) { + return comparison; + } + const updated = { ...comparison, participants }; + this._replaceComparison(updated); + return updated; + } + + private _getSessionUsage(chatResource: URI): IChatUsageSummary | undefined { + const model = this.chatService.getSession(chatResource); + return aggregateChatUsage(model?.getRequests().map(request => request.response?.usage) ?? []); + } + + private _reportTerminalAttemptTelemetry(comparison: ISessionComparison): void { + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + let changed = false; + for (const [attemptIndex, participant] of attempts.entries()) { + const key = `${comparison.id}/${participant.id}`; + if (this._reportedExecutionTelemetry.has(key)) { + continue; + } + const session = participant.sessionResource ? this.sessionsManagementService.getSession(participant.sessionResource) : undefined; + const status = session?.status.get(); + if (!participant.launchError && status !== SessionStatus.Completed && status !== SessionStatus.Error) { + continue; + } + logSessionComparisonAttemptCompleted(this.telemetryService, { + comparisonId: hashSessionIdForTelemetry(comparison.id), + agentSessionId: session?.sessionId, + attemptIndex, + attemptCount: attempts.length, + status: participant.launchError ? 'launchError' : status === SessionStatus.Completed ? 'completed' : 'error', + elapsedMs: session ? Math.max(0, session.updatedAt.get().getTime() - session.createdAt.getTime()) : undefined, + inputTokenCount: participant.usage?.inputTokens, + cachedInputTokenCount: participant.usage?.cachedTokens, + outputTokenCount: participant.usage?.outputTokens, + usageCompleteness: participant.usage ? participant.usage.isComplete ? 'complete' : 'partial' : 'unavailable', + }); + this._reportedExecutionTelemetry.add(key); + changed = true; + } + if (changed) { + this._saveTelemetryState(); + } + } + + private _reportOutcomeTelemetry(comparison: ISessionComparison, verdict: ISessionComparisonVerdict): void { + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + let changed = false; + for (const [attemptIndex, participant] of attempts.entries()) { + const attemptVerdict = verdict.attempts.find(candidate => candidate.participantId === participant.id); + const key = `${comparison.id}/${participant.id}`; + if (!attemptVerdict || this._reportedOutcomeTelemetry.has(key)) { + continue; + } + const session = participant.sessionResource ? this.sessionsManagementService.getSession(participant.sessionResource) : undefined; + logSessionComparisonAttemptJudged(this.telemetryService, { + comparisonId: hashSessionIdForTelemetry(comparison.id), + agentSessionId: session?.sessionId, + attemptIndex, + attemptCount: attempts.length, + recommended: verdict.recommendedParticipantId === participant.id, + tests: attemptVerdict.validation.tests, + build: attemptVerdict.validation.build, + lint: attemptVerdict.validation.lint, + diagnostics: attemptVerdict.validation.diagnostics, + }); + this._reportedOutcomeTelemetry.add(key); + changed = true; + } + if (changed) { + this._saveTelemetryState(); + } + } + + private async _startJudge(comparison: ISessionComparison): Promise { + const harness = this._getJudgeHarness(comparison); + if (!harness) { + throw new Error('No successful comparison attempt is available to run the Judge.'); + } + const query = await this._getJudgePrompt(comparison.id); + const session = await this.sessionsManagementService.createAndSendNewChatRequest(comparison.workspace, { + query, + title: localize('sessionComparison.judgeTitle', "Judge: {0}", comparison.title), + background: true, + }, { + providerId: harness.providerId, + sessionTypeId: harness.sessionTypeId, + modelId: harness.modelId, + permissionLevel: comparison.permissionLevel, + isolationMode: 'worktree', + branch: comparison.branch, + metadata: withSessionComparisonMetadata(undefined, { + id: comparison.id, + role: 'judge', + attemptCount: comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt).length, + }), + }); + if (session) { + this.sessionGroupsService.addToGroup(session.sessionId, comparison.groupId); + } + const judge: ISessionComparisonParticipant = { + id: generateUuid(), + role: SessionComparisonParticipantRole.Judge, + harness, + sessionResource: session?.resource, + ...(!session ? { launchError: localize('sessionComparison.judgeUnavailable', "The judge session did not start.") } : {}), + }; + const current = this._requireComparison(comparison.id); + this._replaceComparison({ ...current, participants: [...current.participants, judge] }); + } + + private async _getJudgePrompt(comparisonId: string): Promise { + this._judgePromptTemplate ??= this.fileService.readFile(JUDGE_PROMPT_URI).then(content => content.value.toString()); + const template = await this._judgePromptTemplate; + if (!template.includes(JUDGE_COMPARISON_ID_PLACEHOLDER)) { + throw new Error(`The Judge prompt is missing the ${JUDGE_COMPARISON_ID_PLACEHOLDER} placeholder.`); + } + return template.replaceAll(JUDGE_COMPARISON_ID_PLACEHOLDER, comparisonId); + } + + private _getJudgeHarness(comparison: ISessionComparison): ISessionComparisonHarness | undefined { + return comparison.judgeHarness + ?? comparison.participants.find(participant => + participant.role === SessionComparisonParticipantRole.Coordinator)?.harness + ?? comparison.participants.find(participant => + participant.role === SessionComparisonParticipantRole.Attempt && participant.sessionResource)?.harness; + } + + private _ensureComparisonGroupMembership(comparisons: readonly ISessionComparison[]): void { + for (const comparison of comparisons) { + const sessionIds = comparison.participants + .map(participant => participant.sessionResource ? this.sessionsManagementService.getSession(participant.sessionResource)?.sessionId : undefined) + .filter(sessionId => sessionId !== undefined); + this.sessionGroupsService.addToGroup(sessionIds, comparison.groupId); + } + } + + private _migrateLegacyAttemptTitles(comparisons: readonly ISessionComparison[]): void { + for (const comparison of comparisons) { + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + for (const [index, participant] of attempts.entries()) { + const session = participant.sessionResource ? this.sessionsManagementService.getSession(participant.sessionResource) : undefined; + if (!session || this._migratingAttemptTitles.has(session.sessionId) || this._migratedAttemptTitles.has(session.sessionId)) { + continue; + } + const title = getSessionComparisonHarnessLabel(participant); + const legacyTitle = localize('sessionComparison.legacyAttemptTitle', "Attempt {0}: {1}", index + 1, title); + if (session.title.get() !== legacyTitle) { + continue; + } + this._migratingAttemptTitles.add(session.sessionId); + void this.sessionsManagementService.renameSession(session, title).then(() => { + this._migratedAttemptTitles.add(session.sessionId); + }, error => { + this.logService.warn('[SessionComparisonService] Failed to migrate an attempt title.', error); + }).finally(() => { + this._migratingAttemptTitles.delete(session.sessionId); + }); + } + } + } + + private _addComparison(comparison: ISessionComparison): void { + this._comparisons.set([...this._comparisons.get(), comparison], undefined); + this._save(); + } + + private _replaceComparison(comparison: ISessionComparison): void { + this._comparisons.set(this._comparisons.get().map(candidate => candidate.id === comparison.id ? comparison : candidate), undefined); + this._save(); + } + + private _removeComparison(comparisonId: string): void { + this._comparisons.set(this._comparisons.get().filter(comparison => comparison.id !== comparisonId), undefined); + this._save(); + } + + private _load(): readonly ISessionComparison[] { + const raw = this.storageService.get(SessionComparisonService.STORAGE_KEY, StorageScope.PROFILE); + if (!raw) { + return []; + } + try { + const stored = JSON.parse(raw) as readonly IStoredSessionComparison[]; + return stored.map(comparison => ({ + ...comparison, + workspace: URI.parse(comparison.workspace), + participants: comparison.participants.map(participant => ({ + ...participant, + sessionResource: participant.sessionResource ? URI.parse(participant.sessionResource) : undefined, + })), + })); + } catch (error) { + this.logService.error('[SessionComparisonService] Failed to restore comparisons.', error); + return []; + } + } + + private _save(): void { + const stored: readonly IStoredSessionComparison[] = this._comparisons.get().map(comparison => ({ + ...comparison, + workspace: comparison.workspace.toString(), + participants: comparison.participants.map(participant => ({ + ...participant, + sessionResource: participant.sessionResource?.toString(), + })), + })); + this.storageService.store(SessionComparisonService.STORAGE_KEY, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + private _loadTelemetryState(): void { + const raw = this.storageService.get(SessionComparisonService.TELEMETRY_STORAGE_KEY, StorageScope.PROFILE); + if (!raw) { + return; + } + try { + const stored = JSON.parse(raw) as { readonly execution?: readonly string[]; readonly outcome?: readonly string[] }; + for (const key of stored.execution ?? []) { + this._reportedExecutionTelemetry.add(key); + } + for (const key of stored.outcome ?? []) { + this._reportedOutcomeTelemetry.add(key); + } + } catch (error) { + this.logService.error('[SessionComparisonService] Failed to restore comparison telemetry state.', error); + } + } + + private _saveTelemetryState(): void { + this.storageService.store(SessionComparisonService.TELEMETRY_STORAGE_KEY, JSON.stringify({ + execution: [...this._reportedExecutionTelemetry], + outcome: [...this._reportedOutcomeTelemetry], + }), StorageScope.PROFILE, StorageTarget.MACHINE); + } +} + +function comparisonTitle(prompt: string): string { + const firstLine = prompt.trim().split(/\r?\n/, 1)[0]; + return firstLine.length > 60 ? `${firstLine.slice(0, 57)}...` : firstLine; +} + +registerSingleton(ISessionComparisonService, SessionComparisonService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index fdde71a2518811..13bb7cef0778d3 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -297,6 +297,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return undefined; } + getSessionContextReference(resource: URI): string | undefined { + const ownedChat = this.getSessionForChatResource(resource); + return ownedChat ? this._getProvider(ownedChat.session)?.getSessionContextReference?.(ownedChat.chat.resource) : undefined; + } + getAllSessionTypes(): ISessionType[] { return [...this._sessionTypes]; } @@ -616,6 +621,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa : options?.automationConfiguration; return { metadata: options?.metadata, + ...(options?.createdBySession ? { createdBySession: options.createdBySession } : {}), ...(automationConfiguration ? { automationConfiguration } : {}), }; } diff --git a/src/vs/sessions/services/sessions/browser/sessionsPartService.ts b/src/vs/sessions/services/sessions/browser/sessionsPartService.ts index f81f1a94b1d38c..6b0f4174deaef2 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsPartService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsPartService.ts @@ -11,6 +11,8 @@ import { Event } from '../../../../base/common/event.js'; export const ISessionsPartService = createDecorator('sessionsPartService'); +export type SessionGridLayout = 'columns' | 'grid'; + /** * Payload for {@link ISessionsPartService.onDidToggleMaximizeSession}. */ @@ -29,7 +31,7 @@ export interface ISessionsPartService { * visible sessions or active session change. The part is a passive renderer: * it does not observe the model itself. */ - updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined): void; + updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined, layout?: SessionGridLayout): void; /** * Controls whether mounted session views may render independently of the part's grid visibility. diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 2c74ace3d464d7..65f96ab252edc4 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -9,7 +9,7 @@ import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; -import { IObservable, autorun, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, autorun, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -27,7 +27,7 @@ import { SessionsNavigation } from './sessionNavigation.js'; import { SessionsRecencyHistory } from './sessionsRecencyHistory.js'; import { VisibleSessions } from './visibleSessions.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ISessionsPartService } from './sessionsPartService.js'; +import { ISessionsPartService, SessionGridLayout } from './sessionsPartService.js'; import { ICustomViewService } from '../../customView/browser/customViewService.js'; import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { setActiveSessionContextKeys } from '../common/sessionContextKeys.js'; @@ -137,6 +137,7 @@ interface ISessionState { visibleOrder?: number; /** Whether the session was pinned (sticky) in the grid at save time. */ isSticky?: boolean; + gridLayout?: SessionGridLayout; } /** @@ -154,6 +155,15 @@ interface ISessionState { export interface ISessionsService { readonly _serviceBrand: undefined; + /** Opens existing sessions together in a tiled grid, without creating or sending requests. */ + openSessionsInGrid(sessions: readonly ISession[]): Promise; + + /** Current presentation of the visible Sessions Part leaves. */ + readonly sessionGridLayout: IObservable; + + /** Returns the existing visible sessions to their ordinary horizontal presentation. */ + resetSessionGridLayout(): void; + /** * Observable for the currently active session as {@link IActiveSession}, * or `undefined` for the new-session (empty) slot. @@ -357,6 +367,8 @@ export class SessionsService extends Disposable implements ISessionsService { /** The canonical active session — the visible active slot. */ readonly activeSession: IObservable; private readonly _initialRestoreComplete = observableValue(this, false); + private readonly _gridLayout = observableValue(this, 'columns'); + readonly sessionGridLayout: IObservable = this._gridLayout; readonly initialRestoreComplete: IObservable = this._initialRestoreComplete; private readonly _navigationRequest = observableValue(this, undefined); readonly navigationRequest: IObservable = this._navigationRequest; @@ -515,7 +527,7 @@ export class SessionsService extends Disposable implements ISessionsService { const visible = this.visibleSessions.read(reader); const active = this._visibility.activeSession.read(reader); const preserveFocus = this._visibility.activePreserveFocus.read(reader); - this.sessionsPartService.updateVisibleSessions(visible, active); + this.sessionsPartService.updateVisibleSessions(visible, active, this._gridLayout.read(reader)); // Move keyboard focus into the active session whenever it changes // (e.g. after opening, switching to, or restoring a session) so the @@ -770,7 +782,14 @@ export class SessionsService extends Disposable implements ISessionsService { * canonical active session is updated reactively by the mirror autorun. */ private _activate(session: ISession | undefined, preserveFocus?: boolean): IActiveSession | undefined { - return this._visibility.setActive(session, preserveFocus); + let active: IActiveSession | undefined; + transaction(tx => { + if (!this.visibleSessions.get().some(visible => visible?.sessionId === session?.sessionId)) { + this._gridLayout.set('columns', tx); + } + active = this._visibility.setActive(session, preserveFocus); + }); + return active; } openChat(session: ISession, chatUri: URI, options?: IOpenSessionOptions): Promise { @@ -913,6 +932,47 @@ export class SessionsService extends Disposable implements ISessionsService { return this._openSession(sessionResource, options, 'explicit'); } + async openSessionsInGrid(sessions: readonly ISession[]): Promise { + if (sessions.length === 0) { + throw new Error(localize('sessions.emptyGrid', "No sessions are available to open.")); + } + this._cancelRestore(); + this._beginNavigation('explicit'); + const token = this._startOpenSession(); + const resolved = new ResourceMap(); + for (const session of sessions) { + const target = await this._resolveSessionForOpen(session, undefined); + if (token.isCancellationRequested) { + return; + } + if (resolved.has(target.session.resource)) { + continue; + } + if (!await this.canOpenSession(target.session) || token.isCancellationRequested) { + return; + } + await this.sessionsProvidersService.getProvider(target.session.providerId)?.prepareSessionForOpen?.(target.session, 'open'); + if (token.isCancellationRequested) { + return; + } + resolved.set(target.session.resource, target.session); + } + this._snapshotVisibleSessionStates(); + const slots = [...resolved.values()].map(session => ({ + session, + sticky: this._visibility.getSlot(session.sessionId)?.sticky ?? false, + })); + const activeIndex = slots.findIndex(slot => slot.session.sessionId === this.activeSession.get()?.sessionId); + transaction(tx => { + this._gridLayout.set('grid', tx); + this._visibility.restoreGrid(slots, Math.max(0, activeIndex)); + }); + } + + resetSessionGridLayout(): void { + this._gridLayout.set('columns', undefined); + } + private async _openSession(sessionResource: URI, options: IOpenSessionOptions | undefined, intent: SessionNavigationIntent): Promise { this.logService.trace(`[SessionsView] openSession requested uri=${sessionResource.toString()}`); // Claim the open before resolving: resolution can take seconds for a legacy @@ -1451,6 +1511,7 @@ export class SessionsService extends Disposable implements ISessionsService { visibleOrder: index, isSticky: session.sticky.get(), isActive: session.sessionId === activeId, + gridLayout: this._gridLayout.get(), }; this._sessionStates.set(session.resource, state); entries.push(state); @@ -1675,7 +1736,10 @@ export class SessionsService extends Disposable implements ISessionsService { } slots.push({ session: session ?? undefined, sticky: target.isSticky }); } - this._visibility.restoreGrid(slots, activeSlotIndex); + transaction(tx => { + this._gridLayout.set(persisted.some(state => state.gridLayout === 'grid') ? 'grid' : 'columns', tx); + this._visibility.restoreGrid(slots, activeSlotIndex); + }); if (token.isCancellationRequested) { return; diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 22441639a1ba3f..1d5414c3f301eb 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -255,6 +255,8 @@ export class VisibleSession extends Disposable implements IActiveSession { get artifacts() { return this._session.artifacts; } get modelId() { return this._activeChatModelId; } get mode() { return this._activeChatMode; } + get permissionLevel() { return this._session.permissionLevel; } + get branch() { return this._session.branch; } get loading() { return this._session.loading; } get isNewSessionRequestInProgress() { return this._session.isNewSessionRequestInProgress; } get isArchived() { return this._session.isArchived; } @@ -307,6 +309,8 @@ class ResourceOverrideSession implements ISession { get artifacts() { return this._session.artifacts; } get modelId() { return this._session.modelId; } get mode() { return this._session.mode; } + get permissionLevel() { return this._session.permissionLevel; } + get branch() { return this._session.branch; } get loading() { return this._session.loading; } get isNewSessionRequestInProgress() { return this._session.isNewSessionRequestInProgress; } get isArchived() { return this._session.isArchived; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 6bb99d2934fa87..bb31514df32acf 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -785,6 +785,10 @@ export interface ISession { /** Currently selected model identifier. */ readonly modelId: IObservable; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; + /** Provider-owned permission level selected while configuring a new session. */ + readonly permissionLevel?: IObservable; + /** Provider-owned branch selected while configuring a new session. */ + readonly branch?: IObservable; /** Whether the session is still initializing (e.g., resolving git repository). */ readonly loading: IObservable; /** Whether the first request lifecycle is in progress. Used to present a still-untitled draft as active during preparation. Absent means `false`. */ diff --git a/src/vs/sessions/services/sessions/common/sessionComparison.ts b/src/vs/sessions/services/sessions/common/sessionComparison.ts new file mode 100644 index 00000000000000..ddb02132c3f4be --- /dev/null +++ b/src/vs/sessions/services/sessions/common/sessionComparison.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { localize } from '../../../../nls.js'; +import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatUsageSummary } from '../../../../workbench/contrib/chat/common/chatUsage.js'; + +export const enum SessionComparisonParticipantRole { + Coordinator = 'coordinator', + Attempt = 'attempt', + Judge = 'judge', + Synthesis = 'synthesis', +} + +export const enum SessionComparisonValidationState { + Passed = 'passed', + Failed = 'failed', + NotRun = 'notRun', + NotApplicable = 'notApplicable', + Unknown = 'unknown', +} + +export const enum SessionComparisonValidationSource { + AttemptReport = 'attemptReport', + JudgeRun = 'judgeRun', + NotApplicable = 'notApplicable', + Unavailable = 'unavailable', +} + +export interface ISessionComparisonHarness { + readonly providerId: string; + readonly sessionTypeId: string; + readonly label: string; + readonly modelId?: string; + readonly modelLabel?: string; +} + +export interface ISessionComparisonAttemptConfiguration { + readonly id: string; + readonly harness: ISessionComparisonHarness; +} + +export interface ISessionComparisonParticipant { + readonly id: string; + readonly role: SessionComparisonParticipantRole; + readonly harness: ISessionComparisonHarness; + readonly sessionResource?: URI; + readonly launchError?: string; + readonly usage?: IChatUsageSummary; +} + +export interface ISessionComparisonAttemptVerdict { + readonly participantId: string; + readonly summary: string; + readonly validation: { + readonly tests: SessionComparisonValidationState; + readonly build: SessionComparisonValidationState; + readonly lint: SessionComparisonValidationState; + readonly diagnostics: SessionComparisonValidationState; + }; + readonly validationSource?: { + readonly tests: SessionComparisonValidationSource; + readonly build: SessionComparisonValidationSource; + readonly lint: SessionComparisonValidationSource; + readonly diagnostics: SessionComparisonValidationSource; + }; + readonly unresolvedIssues: readonly string[]; + readonly notableDifferences: readonly string[]; +} + +export interface ISessionComparisonDecisionOption { + readonly participantId: string; + readonly approach: string; +} + +export interface ISessionComparisonDecisionSection { + readonly id: string; + readonly title: string; + readonly description: string; + readonly affectedFiles: readonly string[]; + readonly options: readonly ISessionComparisonDecisionOption[]; + readonly recommendedParticipantId: string; +} + +export interface ISessionComparisonVerdict { + readonly recommendedParticipantId: string; + readonly explanation: string; + readonly conflicts: readonly string[]; + readonly attempts: readonly ISessionComparisonAttemptVerdict[]; + readonly decisionSections?: readonly ISessionComparisonDecisionSection[]; +} + +export interface ISessionComparisonSynthesisSelection { + readonly sectionId: string; + /** Undefined means the synthesis agent should decide for this section. */ + readonly participantId?: string; +} + +export interface ISessionComparisonSynthesisPlan { + readonly selections: readonly ISessionComparisonSynthesisSelection[]; +} + +export interface ISessionComparison { + readonly id: string; + readonly groupId: string; + readonly title: string; + readonly createdAt: number; + readonly workspace: URI; + readonly prompt: string; + readonly branch?: string; + readonly permissionLevel?: string; + readonly judgeHarness?: ISessionComparisonHarness; + readonly participants: readonly ISessionComparisonParticipant[]; + readonly selectedParticipantId?: string; + readonly verdict?: ISessionComparisonVerdict; + readonly synthesisPlan?: ISessionComparisonSynthesisPlan; +} + +export interface IStartSessionComparisonOptions { + readonly workspace: URI; + readonly prompt: string; + readonly attachedContext?: readonly IChatRequestVariableEntry[]; + readonly attempts: readonly ISessionComparisonAttemptConfiguration[]; + readonly judgeHarness: ISessionComparisonHarness; + readonly permissionLevel?: string; + readonly branch?: string; +} + +export interface ISessionComparisonService { + readonly _serviceBrand: undefined; + readonly comparisons: IObservable; + + startComparison(options: IStartSessionComparisonOptions, token?: CancellationToken): Promise; + getComparison(comparisonId: string): ISessionComparison | undefined; + getComparisonForSession(resource: URI): ISessionComparison | undefined; + selectAttempt(comparisonId: string, participantId: string): void; + submitVerdict(comparisonId: string, verdict: ISessionComparisonVerdict): void; + setSynthesisPlan(comparisonId: string, plan: ISessionComparisonSynthesisPlan | undefined): void; + synthesize(comparisonId: string): Promise; +} + +export const ISessionComparisonService = createDecorator('sessionComparisonService'); + +export function getSessionComparisonHarnessLabel(participant: ISessionComparisonParticipant): string { + return participant.harness.modelLabel + ? localize('sessionComparison.harnessAndModel', "{0} · {1}", participant.harness.label, participant.harness.modelLabel) + : participant.harness.label; +} + +export function getSessionComparisonParticipantsInDisplayOrder(participants: readonly ISessionComparisonParticipant[]): readonly ISessionComparisonParticipant[] { + const rolePriority = (role: SessionComparisonParticipantRole): number => { + switch (role) { + case SessionComparisonParticipantRole.Judge: + return 0; + case SessionComparisonParticipantRole.Synthesis: + return 1; + case SessionComparisonParticipantRole.Attempt: + return 2; + default: + return 3; + } + }; + return participants + .map((participant, index) => ({ participant, index })) + .sort((a, b) => rolePriority(a.participant.role) - rolePriority(b.participant.role) || a.index - b.index) + .map(({ participant }) => participant); +} diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index d79f30ba002f3b..f57ab73a7d1410 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; +import { IChat, ISession, ISessionCreationReference, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; /** Raised when unattended session creation targets a workspace that requires trust. */ @@ -75,6 +75,8 @@ export interface ICreateNewSessionOptions { readonly sessionTypeId?: string; /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; + /** Session that created this session, when it should be presented as a child. */ + readonly createdBySession?: ISessionCreationReference; /** * Optional model identifier to apply to the new session via * {@link ISessionsProvider.setModel}. If the provider throws, the @@ -261,6 +263,12 @@ export interface ISessionsManagementService { */ getSessionForChatResource(resource: URI): { session: ISession; chat: IChat } | undefined; + /** + * Returns an opaque provider-owned target for reading a chat through the + * provider's session-context tool. + */ + getSessionContextReference(resource: URI): string | undefined; + /** * Get all session types from all registered providers, deduplicated by * {@link ISessionType.id} (first provider wins). Use diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 72f3db9fe9d960..ad7f01b0102cec 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -13,7 +13,7 @@ import { ILanguageModelChatMetadataAndIdentifier, type IModelConfigurationAccess import { ModelIdentifierResolution } from '../../../../workbench/contrib/chat/common/modelSelection.js'; import { IAutomationDescriptor, IAutomationRun, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationStore } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; -import { ChatModelSource, IChat, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection } from './session.js'; +import { ChatModelSource, IChat, ISession, ISessionCreationReference, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection } from './session.js'; /** * Event fired when sessions change within a provider. @@ -53,6 +53,8 @@ export interface ISendRequestOptions { export interface ISessionsProviderCreateSessionOptions { /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; + /** Session that created this session, when it should be presented as a child. */ + readonly createdBySession?: ISessionCreationReference; /** Complete Automation state for providers that also own compatibility projections. */ readonly automationConfiguration?: IAutomationSessionConfiguration; } @@ -204,6 +206,13 @@ export interface ISessionsProvider { * List of all sessions currently known to the provider. Consumers should not cache this list, but should listen to `onDidChangeSessions` and update their cached list accordingly. */ getSessions(): ISession[]; + + /** + * Returns an opaque target that the provider's session-context tool accepts + * for the given chat, or `undefined` when transcript access is unavailable. + */ + getSessionContextReference?(chatResource: URI): string | undefined; + /** * Event that fires when sessions are added, removed, or changed. Consumers should update their session lists and any related UI when this occurs. */ @@ -366,6 +375,12 @@ export interface ISessionsProvider { */ getModelsSnapshot(sessionId: string, desiredModelId?: string): ISessionModelsSnapshot; + /** + * Get selectable models before creating a session. + * Providers apply the same availability, visibility, and identifier-resolution rules as {@link getModelsSnapshot}. + */ + getModelsSnapshotForCreation?(workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot; + /** * Get the presentation options for the sessions-core model picker for the * given session. The provider — not the core picker — decides how its models diff --git a/src/vs/sessions/services/sessions/test/browser/sessionComparisonService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionComparisonService.test.ts new file mode 100644 index 00000000000000..6ace3b52370997 --- /dev/null +++ b/src/vs/sessions/services/sessions/test/browser/sessionComparisonService.test.ts @@ -0,0 +1,709 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { hasKey } from '../../../../../base/common/types.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IChatService, IChatUsage } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { ChatInteractivity, ISession, SessionStatus } from '../../common/session.js'; +import { ISessionComparisonVerdict, SessionComparisonParticipantRole, SessionComparisonValidationState } from '../../common/sessionComparison.js'; +import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService, NewSessionRequestOptions } from '../../common/sessionsManagement.js'; +import { ISessionChangeEvent } from '../../common/sessionsProvider.js'; +import { ISessionGroup, ISessionGroupsService } from '../../browser/sessionGroupsService.js'; +import { SessionComparisonService } from '../../browser/sessionComparisonService.js'; + +suite('SessionComparisonService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createServices(storageService = disposables.add(new InMemoryStorageService()), telemetryService = new RecordingTelemetryService()) { + const sessionsManagementService = disposables.add(new TestSessionsManagementService()); + const chatService = new TestChatService(); + const fileService = new TestJudgePromptFileService(); + const groupsService = new class extends mock() { + readonly groupedSessionIds: string[] = []; + readonly deletedGroupIds: string[] = []; + override readonly onDidChange = Event.None; + override createGroup(name: string): ISessionGroup { return { id: 'group', name, createdAt: 1 }; } + override deleteGroup(groupId: string): void { this.deletedGroupIds.push(groupId); } + override addToGroup(sessionIdOrIds: string | Iterable): void { + this.groupedSessionIds.push(...(typeof sessionIdOrIds === 'string' ? [sessionIdOrIds] : sessionIdOrIds)); + } + }(); + const service = disposables.add(new SessionComparisonService( + sessionsManagementService, + groupsService, + storageService, + new NullLogService(), + chatService, + telemetryService, + fileService, + )); + return { service, sessionsManagementService, groupsService, storageService, chatService, telemetryService, fileService }; + } + + test('rejects and removes comparisons with fewer than two launched attempts', async () => { + const { service, sessionsManagementService, groupsService, storageService } = createServices(); + const deferredAttempt = new DeferredPromise(); + sessionsManagementService.enqueuePromise(deferredAttempt.p); + sessionsManagementService.enqueueError(new Error('provider unavailable')); + + const comparisonPromise = service.startComparison(startOptions()); + await timeout(0); + assert.strictEqual(sessionsManagementService.createCalls.length, 2); + deferredAttempt.complete(stubSession('attempt-one')); + + await assert.rejects(comparisonPromise, /Only 1 of 2 comparison attempts started.*Two: provider unavailable/); + assert.deepStrictEqual({ + comparisons: service.comparisons.get(), + deletedGroupIds: groupsService.deletedGroupIds, + storedComparisons: JSON.parse(storageService.get('sessions.comparisons', StorageScope.PROFILE) ?? '[]'), + }, { + comparisons: [], + deletedGroupIds: ['group'], + storedComparisons: [], + }); + }); + + test('creates only the requested attempt sessions before judging', async () => { + const { service, sessionsManagementService, groupsService } = createServices(); + sessionsManagementService.enqueue(stubSession('attempt-one')); + sessionsManagementService.enqueue(stubSession('attempt-two')); + + const options = startOptions(); + const comparison = await service.startComparison({ + ...options, + attempts: options.attempts.map((attempt, index) => ({ + ...attempt, + harness: { + ...attempt.harness, + modelLabel: `Model ${index + 1}`, + }, + })), + }); + + assert.deepStrictEqual({ + requests: sessionsManagementService.createCalls.map(call => ({ + query: call.options.query, + title: call.options.title, + })), + roles: comparison.participants.map(participant => participant.role), + groupedSessionIds: groupsService.groupedSessionIds, + }, { + requests: [ + { query: 'Implement the feature', title: 'One · Model 1' }, + { query: 'Implement the feature', title: 'Two · Model 2' }, + ], + roles: [ + SessionComparisonParticipantRole.Attempt, + SessionComparisonParticipantRole.Attempt, + ], + groupedSessionIds: ['attempt-one', 'attempt-two'], + }); + }); + + test('starts Judge only after successful attempts are terminal', async () => { + const { service, sessionsManagementService, fileService } = createServices(); + const firstStatus = observableValue('firstStatus', SessionStatus.InProgress); + const secondStatus = observableValue('secondStatus', SessionStatus.InProgress); + sessionsManagementService.enqueue(stubSession('attempt-one', firstStatus)); + sessionsManagementService.enqueue(stubSession('attempt-two', secondStatus)); + sessionsManagementService.enqueue(stubSession('judge')); + + const comparison = await service.startComparison({ ...startOptions(), permissionLevel: 'allowedTools' }); + firstStatus.set(SessionStatus.Completed, undefined); + sessionsManagementService.fireChange(); + await timeout(0); + assert.strictEqual(sessionsManagementService.createCalls.length, 2); + + secondStatus.set(SessionStatus.Error, undefined); + sessionsManagementService.fireChange(); + await timeout(0); + assert.deepStrictEqual({ + createCalls: sessionsManagementService.createCalls.length, + judgeResource: service.getComparison(comparison.id)?.participants.find(participant => participant.role === SessionComparisonParticipantRole.Judge)?.sessionResource?.toString(), + judgeHarness: sessionsManagementService.createCalls[2].createOptions, + judgePrompt: sessionsManagementService.createCalls[2].options.query, + readJudgePromptResource: fileService.lastReadResource?.path.endsWith('/vs/sessions/prompts/judge.md'), + }, { + createCalls: 3, + judgeResource: 'test:/judge', + judgeHarness: { + providerId: 'judge-provider', + sessionTypeId: 'judge-type', + modelId: 'judge-model', + permissionLevel: 'allowedTools', + isolationMode: 'worktree', + branch: undefined, + metadata: { + 'agentHost/sessionComparison': { + id: comparison.id, + role: 'judge', + attemptCount: 2, + }, + }, + }, + judgePrompt: getTestJudgePrompt(comparison.id), + readJudgePromptResource: true, + }); + }); + + test('snapshots whole-turn usage before starting the Judge', async () => { + const { service, sessionsManagementService, chatService, storageService } = createServices(); + const firstStatus = observableValue('firstStatus', SessionStatus.InProgress); + const secondStatus = observableValue('secondStatus', SessionStatus.InProgress); + sessionsManagementService.enqueue(stubSession('attempt-one', firstStatus)); + sessionsManagementService.enqueue(stubSession('attempt-two', secondStatus)); + sessionsManagementService.enqueue(stubSession('judge')); + chatService.setUsage(URI.parse('test-chat:/attempt-one'), [{ + kind: 'usage', + promptTokens: 10, + completionTokens: 2, + modelTotals: [{ model: 'Claude', inputTokens: 30, cachedTokens: 12, outputTokens: 8 }], + }]); + + const comparison = await service.startComparison(startOptions()); + firstStatus.set(SessionStatus.Completed, undefined); + secondStatus.set(SessionStatus.Error, undefined); + sessionsManagementService.fireChange(); + await timeout(0); + + const stored = JSON.parse(storageService.get('sessions.comparisons', StorageScope.PROFILE) ?? '[]'); + const expectedUsage = { + inputTokens: 30, + cachedTokens: 12, + outputTokens: 8, + models: [{ model: 'Claude', inputTokens: 30, cachedTokens: 12, outputTokens: 8 }], + isComplete: true, + }; + assert.deepStrictEqual({ + live: service.getComparison(comparison.id)?.participants.find(participant => participant.id === 'attempt-one')?.usage, + stored: stored[0].participants.find((participant: { id: string }) => participant.id === 'attempt-one')?.usage, + }, { + live: expectedUsage, + stored: expectedUsage, + }); + }); + + test('reports terminal usage and Judge outcomes once per attempt', async () => { + const { service, sessionsManagementService, chatService, telemetryService } = createServices(); + const firstStatus = observableValue('firstStatus', SessionStatus.InProgress); + const secondStatus = observableValue('secondStatus', SessionStatus.InProgress); + sessionsManagementService.enqueue(stubSession('attempt-one', firstStatus)); + sessionsManagementService.enqueue(stubSession('attempt-two', secondStatus)); + sessionsManagementService.enqueue(stubSession('judge')); + chatService.setUsage(URI.parse('test-chat:/attempt-one'), [{ + kind: 'usage', + promptTokens: 10, + completionTokens: 2, + modelTotals: [{ model: 'Claude', inputTokens: 30, cachedTokens: 12, outputTokens: 8 }], + }]); + + const comparison = await service.startComparison(startOptions()); + firstStatus.set(SessionStatus.Completed, undefined); + secondStatus.set(SessionStatus.Error, undefined); + sessionsManagementService.fireChange(); + await timeout(0); + const comparisonVerdict = verdict('attempt-two', ['attempt-one', 'attempt-two']); + service.submitVerdict(comparison.id, comparisonVerdict); + service.submitVerdict(comparison.id, comparisonVerdict); + + assert.deepStrictEqual(telemetryService.events.map(event => ({ + name: event.name, + attemptIndex: event.data.attemptIndex, + status: event.data.status, + recommended: event.data.recommended, + inputTokenCount: event.data.inputTokenCount, + })), [ + { name: 'agents/sessionComparisonAttemptCompleted', attemptIndex: 0, status: 'completed', recommended: undefined, inputTokenCount: 30 }, + { name: 'agents/sessionComparisonAttemptCompleted', attemptIndex: 1, status: 'error', recommended: undefined, inputTokenCount: undefined }, + { name: 'agents/sessionComparisonAttemptJudged', attemptIndex: 0, status: undefined, recommended: false, inputTokenCount: undefined }, + { name: 'agents/sessionComparisonAttemptJudged', attemptIndex: 1, status: undefined, recommended: true, inputTokenCount: undefined }, + ]); + }); + + test('passes provider-local models to their harnesses', async () => { + const { service, sessionsManagementService } = createServices(); + sessionsManagementService.enqueue(stubSession('attempt-one')); + sessionsManagementService.enqueue(stubSession('attempt-two')); + + await service.startComparison({ ...startOptions(), permissionLevel: 'allowedTools' }); + + assert.deepStrictEqual(sessionsManagementService.createCalls.map(call => ({ + providerId: call.createOptions?.providerId, + sessionTypeId: call.createOptions?.sessionTypeId, + modelId: call.createOptions?.modelId, + permissionLevel: call.createOptions?.permissionLevel, + comparison: call.createOptions?.metadata?.['agentHost/sessionComparison'], + })), [ + { + providerId: 'provider-one', + sessionTypeId: 'type-one', + modelId: 'model-one', + permissionLevel: 'allowedTools', + comparison: { id: service.comparisons.get()[0].id, role: 'attempt', attemptIndex: 0, attemptCount: 2 }, + }, + { + providerId: 'provider-two', + sessionTypeId: 'type-two', + modelId: 'model-two', + permissionLevel: 'allowedTools', + comparison: { id: service.comparisons.get()[0].id, role: 'attempt', attemptIndex: 1, attemptCount: 2 }, + }, + ]); + }); + + test('preserves unique attempt identifiers for repeated harness and model configurations', async () => { + const { service, sessionsManagementService } = createServices(); + sessionsManagementService.enqueue(stubSession('attempt-one')); + sessionsManagementService.enqueue(stubSession('attempt-two')); + const harness = { providerId: 'provider', sessionTypeId: 'type', label: 'Agent', modelId: 'model' }; + + const comparison = await service.startComparison({ + workspace: URI.file('/workspace'), + prompt: 'Implement the feature', + judgeHarness: harness, + attempts: [ + { id: 'first-run', harness }, + { id: 'second-run', harness }, + ], + }); + + assert.deepStrictEqual(comparison.participants + .filter(participant => participant.role === SessionComparisonParticipantRole.Attempt) + .map(participant => ({ id: participant.id, harness: participant.harness })), [ + { id: 'first-run', harness }, + { id: 'second-run', harness }, + ]); + }); + + test('restores persisted URI fields', () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('sessions.comparisons', JSON.stringify([{ + id: 'comparison', + groupId: 'group', + title: 'Comparison', + createdAt: 1, + workspace: 'file:///workspace', + prompt: 'Implement', + participants: [{ + id: 'attempt', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'provider', sessionTypeId: 'type', label: 'Harness' }, + sessionResource: 'test:/attempt', + }], + }]), StorageScope.PROFILE, StorageTarget.MACHINE); + + const { service } = createServices(storageService); + const comparison = service.getComparison('comparison'); + assert.deepStrictEqual({ + workspace: comparison?.workspace.toString(), + session: comparison?.participants[0].sessionResource?.toString(), + }, { + workspace: 'file:///workspace', + session: 'test:/attempt', + }); + }); + + test('uses the persisted permission level for Judge and synthesis sessions after reload', async () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('sessions.comparisons', JSON.stringify([{ + id: 'comparison', + groupId: 'comparison-group', + title: 'Comparison', + createdAt: 1, + workspace: 'file:///workspace', + prompt: 'Implement', + permissionLevel: 'allowedTools', + judgeHarness: { providerId: 'judge-provider', sessionTypeId: 'judge-type', label: 'Judge', modelId: 'judge-model' }, + participants: [{ + id: 'attempt-one', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'provider-one', sessionTypeId: 'type-one', label: 'One', modelId: 'model-one' }, + sessionResource: 'test:/attempt-one', + }, { + id: 'attempt-two', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'provider-two', sessionTypeId: 'type-two', label: 'Two', modelId: 'model-two' }, + sessionResource: 'test:/attempt-two', + }], + }]), StorageScope.PROFILE, StorageTarget.MACHINE); + const { service, sessionsManagementService } = createServices(storageService); + sessionsManagementService.addSession(stubSession('attempt-one', observableValue('firstStatus', SessionStatus.Completed))); + sessionsManagementService.addSession(stubSession('attempt-two', observableValue('secondStatus', SessionStatus.Completed))); + sessionsManagementService.enqueue(stubSession('judge')); + + sessionsManagementService.fireChange(); + await timeout(0); + service.submitVerdict('comparison', verdict('attempt-two', ['attempt-one', 'attempt-two'])); + sessionsManagementService.enqueue(stubSession('synthesis')); + await service.synthesize('comparison'); + + assert.deepStrictEqual(sessionsManagementService.createCalls.map(call => ({ + providerId: call.createOptions?.providerId, + permissionLevel: call.createOptions?.permissionLevel, + })), [ + { providerId: 'judge-provider', permissionLevel: 'allowedTools' }, + { providerId: 'provider-two', permissionLevel: 'allowedTools' }, + ]); + }); + + test('restores every comparison participant to its comparison group', () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('sessions.comparisons', JSON.stringify([{ + id: 'comparison', + groupId: 'comparison-group', + title: 'Comparison', + createdAt: 1, + workspace: 'file:///workspace', + prompt: 'Implement', + participants: [{ + id: 'attempt', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'provider', sessionTypeId: 'type', label: 'Harness' }, + sessionResource: 'test:/attempt', + }, { + id: 'judge', + role: SessionComparisonParticipantRole.Judge, + harness: { providerId: 'provider', sessionTypeId: 'type', label: 'Harness' }, + sessionResource: 'test:/judge', + }], + }]), StorageScope.PROFILE, StorageTarget.MACHINE); + const { sessionsManagementService, groupsService } = createServices(storageService); + sessionsManagementService.addSession(stubSession('attempt')); + sessionsManagementService.addSession(stubSession('judge')); + + sessionsManagementService.fireChange(); + + assert.deepStrictEqual(groupsService.groupedSessionIds, ['attempt', 'judge']); + }); + + test('removes attempt numbers from untouched legacy session titles', async () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('sessions.comparisons', JSON.stringify([{ + id: 'comparison', + groupId: 'comparison-group', + title: 'Comparison', + createdAt: 1, + workspace: 'file:///workspace', + prompt: 'Implement', + participants: [{ + id: 'attempt', + role: SessionComparisonParticipantRole.Attempt, + harness: { providerId: 'provider', sessionTypeId: 'type', label: 'Copilot', modelLabel: 'Claude Opus 5' }, + sessionResource: 'test:/attempt', + }], + }]), StorageScope.PROFILE, StorageTarget.MACHINE); + const { sessionsManagementService } = createServices(storageService); + sessionsManagementService.addSession({ + ...stubSession('attempt'), + title: constObservable('Attempt 1: Copilot · Claude Opus 5'), + }); + sessionsManagementService.fireChange(); + await timeout(0); + + assert.deepStrictEqual(sessionsManagementService.renameCalls, [{ + sessionId: 'attempt', + title: 'Copilot · Claude Opus 5', + }]); + }); + + test('synthesizes only after an explicit request with the recommended harness', async () => { + const { service, sessionsManagementService } = createServices(); + sessionsManagementService.enqueue(stubSession('attempt-one')); + sessionsManagementService.enqueue(stubSession('attempt-two')); + sessionsManagementService.enqueue(stubSession('synthesis')); + + const comparison = await service.startComparison(startOptions()); + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + service.submitVerdict(comparison.id, { + ...verdict(attempts[1].id, attempts.map(attempt => attempt.id)), + decisionSections: [{ + id: 'error-handling', + title: 'Error handling', + description: 'Choose the error representation.', + affectedFiles: ['src/parser.ts'], + options: attempts.map(attempt => ({ participantId: attempt.id, approach: `Use ${attempt.harness.label}` })), + recommendedParticipantId: attempts[1].id, + }], + }); + service.setSynthesisPlan(comparison.id, { + selections: [{ sectionId: 'error-handling', participantId: attempts[0].id }], + }); + assert.strictEqual(sessionsManagementService.createCalls.length, 2); + await service.synthesize(comparison.id); + + const current = service.getComparison(comparison.id); + assert.deepStrictEqual({ + synthesisResource: current?.participants.find(participant => participant.role === SessionComparisonParticipantRole.Synthesis)?.sessionResource?.toString(), + providerId: sessionsManagementService.createCalls[2].createOptions?.providerId, + sessionTypeId: sessionsManagementService.createCalls[2].createOptions?.sessionTypeId, + modelId: sessionsManagementService.createCalls[2].createOptions?.modelId, + prompt: sessionsManagementService.createCalls[2].options.query, + plan: current?.synthesisPlan, + }, { + synthesisResource: 'test:/synthesis', + providerId: 'provider-two', + sessionTypeId: 'type-two', + modelId: 'model-two', + prompt: `Synthesize the strongest parts of comparison ${comparison.id} into a new implementation. First call #readAttemptComparison exactly once with that comparison ID. Read implementation code only from the authoritative worktrees in its manifest. If changedFilesStatus is unavailable, read the Git diff from that worktree. If the manifest includes a synthesisPlan, treat every selected section as an explicit user requirement and resolve cross-section dependencies coherently instead of copying hunks mechanically. Call get_session_context only with an exact sessionContextTarget returned by the manifest and only for rationale or validation evidence; never recover implementation code or paths from a transcript. Do not inspect another checkout, discover sessions, or guess references. Preserve correct behavior, resolve the Judge's reported conflicts, and run the relevant validation.\n\nJudge recommendation:\nAttempt two is stronger.`, + plan: { + selections: [{ sectionId: 'error-handling', participantId: attempts[0].id }], + }, + }); + }); + + test('persists synthesis selections and rejects unknown sections or approaches', async () => { + const { service, sessionsManagementService, storageService } = createServices(); + sessionsManagementService.enqueue(stubSession('attempt-one')); + sessionsManagementService.enqueue(stubSession('attempt-two')); + const comparison = await service.startComparison(startOptions()); + const attempts = comparison.participants.filter(participant => participant.role === SessionComparisonParticipantRole.Attempt); + service.submitVerdict(comparison.id, { + ...verdict(attempts[1].id, attempts.map(attempt => attempt.id)), + decisionSections: [{ + id: 'tests', + title: 'Test strategy', + description: 'Choose the preferred coverage structure.', + affectedFiles: ['test/parser.test.ts'], + options: attempts.map(attempt => ({ participantId: attempt.id, approach: attempt.harness.label })), + recommendedParticipantId: attempts[1].id, + }], + }); + service.setSynthesisPlan(comparison.id, { + selections: [{ sectionId: 'tests', participantId: attempts[0].id }], + }); + const stored = JSON.parse(storageService.get('sessions.comparisons', StorageScope.PROFILE) ?? '[]'); + const restored = createServices(storageService).service.getComparison(comparison.id)?.synthesisPlan; + let unknownSection: string | undefined; + let unknownAttempt: string | undefined; + try { + service.setSynthesisPlan(comparison.id, { selections: [{ sectionId: 'missing' }] }); + } catch (error) { + unknownSection = error instanceof Error ? error.message : String(error); + } + try { + service.setSynthesisPlan(comparison.id, { selections: [{ sectionId: 'tests', participantId: 'missing' }] }); + } catch (error) { + unknownAttempt = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + live: service.getComparison(comparison.id)?.synthesisPlan, + stored: stored[0].synthesisPlan, + restored, + unknownSection, + unknownAttempt, + }, { + live: { selections: [{ sectionId: 'tests', participantId: attempts[0].id }] }, + stored: { selections: [{ sectionId: 'tests', participantId: attempts[0].id }] }, + restored: { selections: [{ sectionId: 'tests', participantId: attempts[0].id }] }, + unknownSection: 'The synthesis plan contains an invalid section selection.', + unknownAttempt: 'The synthesis plan contains an invalid section selection.', + }); + }); + +}); + +const TEST_JUDGE_PROMPT_TEMPLATE = 'Follow the Judge instructions for comparison {{comparisonId}}.'; + +function getTestJudgePrompt(comparisonId: string): string { + return TEST_JUDGE_PROMPT_TEMPLATE.replace('{{comparisonId}}', comparisonId); +} + +class TestJudgePromptFileService extends mock() { + lastReadResource: URI | undefined; + + override async readFile(resource: URI): Promise { + this.lastReadResource = resource; + return { + resource, + name: 'judge.md', + mtime: 0, + ctime: 0, + etag: '', + size: TEST_JUDGE_PROMPT_TEMPLATE.length, + readonly: true, + locked: false, + executable: false, + value: VSBuffer.fromString(TEST_JUDGE_PROMPT_TEMPLATE), + }; + } +} + +class TestSessionsManagementService extends mock() implements IDisposable { + private readonly _onDidChangeSessions = new Emitter(); + override readonly onDidChangeSessions = this._onDidChangeSessions.event; + private readonly _results: Array<() => Promise> = []; + private readonly _sessions = new Map(); + readonly createCalls: Array<{ folderUri: URI; options: ISendRequestOptions; createOptions?: ICreateNewSessionOptions; token?: CancellationToken }> = []; + readonly renameCalls: Array<{ sessionId: string; title: string }> = []; + + enqueue(session: ISession): void { + this.enqueuePromise(Promise.resolve(session)); + } + + enqueuePromise(result: Promise): void { + this._results.push(async () => result); + } + + enqueueError(error: Error): void { + this._results.push(async () => { throw error; }); + } + + override async createAndSendNewChatRequest(folderUri: URI, options: NewSessionRequestOptions, createOptions?: ICreateNewSessionOptions, token?: CancellationToken): Promise { + if (hasKey(options, { kind: true })) { + throw new Error('Session comparisons must send an immediate request.'); + } + this.createCalls.push({ folderUri, options, createOptions, token }); + const result = await this._results.shift()?.(); + if (result) { + this._sessions.set(result.resource.toString(), result); + } + return result; + } + + override getSession(resource: URI): ISession | undefined { + return this._sessions.get(resource.toString()); + } + + addSession(session: ISession): void { + this._sessions.set(session.resource.toString(), session); + } + + override async renameSession(session: ISession, title: string): Promise { + this.renameCalls.push({ sessionId: session.sessionId, title }); + } + + fireChange(): void { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [] }); + } + + dispose(): void { + this._onDidChangeSessions.dispose(); + } +} + +class TestChatService extends mock() { + private readonly _usages = new Map(); + + setUsage(resource: URI, usages: readonly IChatUsage[]): void { + this._usages.set(resource.toString(), usages); + } + + override getSession(resource: URI): IChatModel | undefined { + const usages = this._usages.get(resource.toString()); + return usages ? upcastPartial({ + getRequests: () => usages.map(usage => upcastPartial({ + response: upcastPartial({ usage }), + })), + }) : undefined; + } +} + +class RecordingTelemetryService extends NullTelemetryServiceShape { + readonly events: Array<{ name: string; data: Record }> = []; + + override publicLog2(eventName?: string, data?: Record): void { + if (eventName) { + this.events.push({ name: eventName, data: data ?? {} }); + } + } +} + +function stubSession(sessionId: string, status = observableValue(`${sessionId}Status`, SessionStatus.InProgress)): ISession { + const chat = { + resource: URI.parse(`test-chat:/${sessionId}`), + createdAt: new Date(), + title: constObservable(sessionId), + updatedAt: constObservable(new Date()), + status, + changes: constObservable([]), + checkpoints: constObservable(undefined), + modelId: constObservable(undefined), + modelSource: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + interactivity: constObservable(ChatInteractivity.Full), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + return { + sessionId, + resource: URI.parse(`test:/${sessionId}`), + providerId: 'provider', + sessionType: 'type', + icon: Codicon.vm, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable(sessionId), + updatedAt: constObservable(new Date()), + status, + changesets: constObservable([]), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + chats: constObservable([chat]), + mainChat: constObservable(chat), + capabilities: constObservable({ supportsMultipleChats: false }), + }; +} + +function startOptions() { + return { + workspace: URI.file('/workspace'), + prompt: 'Implement the feature', + judgeHarness: { providerId: 'judge-provider', sessionTypeId: 'judge-type', label: 'Judge', modelId: 'judge-model' }, + attempts: [ + { + id: 'attempt-one', + harness: { providerId: 'provider-one', sessionTypeId: 'type-one', label: 'One', modelId: 'model-one' }, + }, + { + id: 'attempt-two', + harness: { providerId: 'provider-two', sessionTypeId: 'type-two', label: 'Two', modelId: 'model-two' }, + }, + ], + }; +} + +function verdict(recommendedParticipantId: string, participantIds: readonly string[]): ISessionComparisonVerdict { + return { + recommendedParticipantId, + explanation: 'Attempt two is stronger.', + conflicts: [], + attempts: participantIds.map(participantId => ({ + participantId, + summary: 'Summary', + validation: { + tests: SessionComparisonValidationState.Passed, + build: SessionComparisonValidationState.Passed, + lint: SessionComparisonValidationState.Passed, + diagnostics: SessionComparisonValidationState.Passed, + }, + unresolvedIssues: [], + notableDifferences: [], + })), + }; +} diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index e9842a4fcde681..a46a2785b5494c 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -177,6 +177,10 @@ class MockSessionStore implements ISessionsManagementService { return undefined; } + getSessionContextReference(_resource: URI): string | undefined { + return undefined; + } + getAllSessionTypes(): ISessionType[] { return []; } getAllProviderSessionTypes(): IProviderSessionType[] { return []; } getSessionTypesForFolder(_folderUri: URI): IProviderSessionType[] { return []; } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 71456b30073f94..c950d25d7ca886 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -39,10 +39,10 @@ import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbenc import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ISessionChangeEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; -import { ISessionsManagementService, ICreateNewSessionOptions, inheritableSessionTarget, ISendRequestSentEvent, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; +import { IActiveSession, ISessionsManagementService, ICreateNewSessionOptions, inheritableSessionTarget, ISendRequestSentEvent, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; import { SessionsService } from '../../browser/sessionsService.js'; import { ISessionOpenTelemetryService, SessionOpenTelemetryService } from '../../browser/sessionOpenTelemetryService.js'; -import { ISessionsPartService } from '../../browser/sessionsPartService.js'; +import { ISessionsPartService, SessionGridLayout } from '../../browser/sessionsPartService.js'; import { AbstractCustomView } from '../../../customView/browser/customView.js'; import { CustomViewService, ICustomViewService } from '../../../customView/browser/customViewService.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; @@ -231,15 +231,17 @@ function createSessionsManagementService( workspaceTrustManagementService = new TestWorkspaceTrustManagementService(), workspaceTrustRequestService?: IWorkspaceTrustRequestService, configurationService: IConfigurationService = new TestConfigurationService(), -): { service: ISessionsManagementService; view: SessionsService; chatWidgetService: TestChatWidgetService; chatService: TestChatService; contextKeyService: MockContextKeyService; customViewService: ICustomViewService } { +): { service: ISessionsManagementService; view: SessionsService; chatWidgetService: TestChatWidgetService; chatService: TestChatService; contextKeyService: MockContextKeyService; customViewService: ICustomViewService; instantiationService: TestInstantiationService; storage: InMemoryStorageService; partService: TestSessionsPartService } { const instantiationService = disposables.add(new TestInstantiationService()); const chatWidgetService = new TestChatWidgetService(); const chatService = disposables.add(new TestChatService()); const providers = Array.isArray(provider) ? provider : [provider]; const contextKeyService = disposables.add(new MockContextKeyService()); const customViewService = disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService()))); + const storage = disposables.add(new InMemoryStorageService()); + const partService = new TestSessionsPartService(); - instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(IStorageService, storage); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IContextKeyService, contextKeyService); @@ -257,8 +259,8 @@ function createSessionsManagementService( } const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); - const view = createView(instantiationService, service, disposables, customViewService); - return { service, view, chatWidgetService, chatService, contextKeyService, customViewService }; + const view = createView(instantiationService, service, disposables, customViewService, partService); + return { service, view, chatWidgetService, chatService, contextKeyService, customViewService, instantiationService, storage, partService }; } /** @@ -266,9 +268,12 @@ function createSessionsManagementService( * exercise the view/model behaviour, so the calls are no-ops. */ class TestSessionsPartService extends mock() { + readonly updates: { ids: (string | undefined)[]; layout: SessionGridLayout | undefined }[] = []; override readonly onDidFocusSession = Event.None; override readonly onDidToggleMaximizeSession = Event.None; - override updateVisibleSessions(): void { } + override updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], _active: IActiveSession | undefined, layout?: SessionGridLayout): void { + this.updates.push({ ids: visible.map(session => session?.sessionId), layout }); + } override focusSession(): void { } } @@ -297,9 +302,10 @@ function createView( service: ISessionsManagementService, disposables: ReturnType, customViewService: ICustomViewService = disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService()))), + partService = new TestSessionsPartService(), ): SessionsService { instantiationService.stub(ISessionsManagementService, service); - instantiationService.stub(ISessionsPartService, new TestSessionsPartService()); + instantiationService.stub(ISessionsPartService, partService); instantiationService.stub(ICustomViewService, customViewService); instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(ISessionOpenTelemetryService, disposables.add(new SessionOpenTelemetryService(NullTelemetryService))); @@ -1484,6 +1490,75 @@ suite('SessionsManagementService', () => { }); }); + test('openSessionsInGrid atomically opens only the requested sessions and restores the tiled mode', async () => { + const sessions = ['a', 'b', 'c', 'd', 'unrelated'].map(sessionId => stubSession({ sessionId, providerId: 'test', status: constObservable(SessionStatus.Completed) })); + const prepared: string[] = []; + const provider = new class extends TestSessionsProvider { + override getSessions() { return sessions; } + override async prepareSessionForOpen(session: ISession): Promise { prepared.push(session.sessionId); } + }(sessions[0]); + const fixture = createSessionsManagementService(sessions[0], disposables, provider); + await fixture.view.openSession(sessions[4].resource); + fixture.partService.updates.length = 0; + prepared.length = 0; + await fixture.view.openSessionsInGrid([...sessions.slice(0, 4), sessions[0]]); + const initialUpdates = [...fixture.partService.updates]; + await fixture.storage.flush(); + fixture.view.dispose(); + const restoredParts = new TestSessionsPartService(); + const restored = createView(fixture.instantiationService, fixture.service, disposables, fixture.customViewService, restoredParts); + await restored.restoreVisibleSessions(); + const restoredLayout = restoredParts.updates.at(-1); + await restored.openSession(sessions[4].resource); + assert.deepStrictEqual({ + initialUpdates, + prepared: prepared.slice(0, 4), + restoredLayout, + ordinaryLayout: restoredParts.updates.at(-1)?.layout, + }, { + initialUpdates: [{ ids: ['a', 'b', 'c', 'd'], layout: 'grid' }], + prepared: ['a', 'b', 'c', 'd'], + restoredLayout: { ids: ['a', 'b', 'c', 'd'], layout: 'grid' }, + ordinaryLayout: 'columns', + }); + }); + + test('openSessionsInGrid preserves the current layout when preparing an attempt fails', async () => { + const sessions = ['a', 'b'].map(sessionId => stubSession({ sessionId, providerId: 'test' })); + const provider = new class extends TestSessionsProvider { + override getSessions() { return sessions; } + override async prepareSessionForOpen(session: ISession): Promise { + if (session === sessions[1]) { throw new Error('Provider disconnected'); } + } + }(sessions[0]); + const { view } = createSessionsManagementService(sessions[0], disposables, provider); + await view.openSession(sessions[0].resource); + await assert.rejects(view.openSessionsInGrid(sessions), /Provider disconnected/); + assert.deepStrictEqual(view.visibleSessions.get().map(session => session?.sessionId), ['a']); + }); + + test('openSessionsInGrid does not supersede a newer explicit navigation', async () => { + const sessions = ['a', 'b', 'c'].map(sessionId => stubSession({ sessionId, providerId: 'test' })); + const started = new DeferredPromise(); + const pending = new DeferredPromise(); + const provider = new class extends TestSessionsProvider { + override getSessions() { return sessions; } + override async prepareSessionForOpen(session: ISession): Promise { + if (session === sessions[1]) { + started.complete(); + await pending.p; + } + } + }(sessions[0]); + const { view } = createSessionsManagementService(sessions[0], disposables, provider); + const opening = view.openSessionsInGrid(sessions.slice(0, 2)); + await started.p; + await view.openSession(sessions[2].resource); + pending.complete(); + await opening; + assert.deepStrictEqual(view.visibleSessions.get().map(session => session?.sessionId), ['c']); + }); + test('restoreVisibleSessions prepares only the active session', async () => { const session = stubSession({ sessionId: 'remote', diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 5e17a99cfb3993..8312870aedf175 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -469,6 +469,7 @@ import './contrib/chat/browser/chat.contribution.js'; import './contrib/chat/browser/btwSlashCommand.contribution.js'; import './contrib/chat/browser/requestOriginProvider.contribution.js'; import './contrib/chat/browser/sideChatProvider.contribution.js'; +import './contrib/sessionComparison/browser/sessionComparison.contribution.js'; import './contrib/providers/agentHost/browser/exportDebugLogsAction.js'; import './contrib/providers/agentHost/browser/agentHostSessionConfigPicker.js'; import './contrib/providers/agentHost/browser/agentHostForkActions.js'; @@ -477,6 +478,7 @@ import './contrib/providers/copilotChatSessions/browser/copilotChatSessions.cont import './contrib/sessions/browser/sessions.contribution.js'; import './services/sessions/browser/sessionsListModelService.js'; import './services/sessions/browser/sessionGroupsService.js'; +import './services/sessions/browser/sessionComparisonService.js'; import './services/sessions/browser/sessionSectionOrderService.js'; import './services/agentHostFilter/browser/agentHostFilterService.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; diff --git a/src/vs/sessions/test/browser/mobileSessionsPart.test.ts b/src/vs/sessions/test/browser/mobileSessionsPart.test.ts index 228fdd18911d72..8a769e20af2d89 100644 --- a/src/vs/sessions/test/browser/mobileSessionsPart.test.ts +++ b/src/vs/sessions/test/browser/mobileSessionsPart.test.ts @@ -30,10 +30,8 @@ suite('Sessions - Mobile Sessions Part', () => { contentSize: { width: width - 2, height: height - 4 }, }; }, - _gridWidget: { - layout: (width: number, height: number, top: number, left: number) => { - gridLayoutArgs = [width, height, top, left]; - }, + layoutSessionGrid: (width: number, height: number, top: number, left: number) => { + gridLayoutArgs = [width, height, top, left]; }, }; diff --git a/src/vs/sessions/test/browser/sessionGridLayout.test.ts b/src/vs/sessions/test/browser/sessionGridLayout.test.ts new file mode 100644 index 00000000000000..8f321ac83596db --- /dev/null +++ b/src/vs/sessions/test/browser/sessionGridLayout.test.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Direction, Grid, Sizing } from '../../../base/browser/ui/grid/grid.js'; +import { TestView } from '../../../base/test/browser/ui/grid/util.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { arrangeSessionGrid, getSessionGridColumns } from '../../browser/parts/sessionGridLayout.js'; + +suite('Sessions - Tiled Chat Grid', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('chooses tmux-style columns from the session count', () => { + assert.deepStrictEqual([ + getSessionGridColumns(1), + getSessionGridColumns(2), + getSessionGridColumns(3), + getSessionGridColumns(4), + getSessionGridColumns(5), + getSessionGridColumns(6), + getSessionGridColumns(9), + getSessionGridColumns(10), + getSessionGridColumns(16), + ], [1, 2, 2, 2, 3, 3, 3, 4, 4]); + }); + + for (const count of [2, 3, 4, 5, 6, 9]) { + test(`tiles ${count} existing chats without losing their view identity`, () => { + const views = Array.from({ length: count }, () => store.add(new TestView(100, Infinity, 100, Infinity))); + const grid = store.add(new Grid(views[0])); + grid.layout(1200, 800); + for (let i = 1; i < count; i++) { + grid.addView(views[i], Sizing.Distribute, views[i - 1], Direction.Right); + } + const columns = getSessionGridColumns(count); + arrangeSessionGrid(grid, views, columns); + grid.layout(1200, 800); + const rows = Math.ceil(count / columns); + const rowHeight = Math.floor(800 / rows); + assert.deepStrictEqual(views.map((view, index) => ({ + width: grid.getViewSize(view).width, + height: grid.getViewSize(view).height, + right: index % columns < columns - 1 && index + 1 < count + ? grid.getNeighborViews(view, Direction.Right).includes(views[index + 1]) + : undefined, + below: index + columns < count + ? grid.getNeighborViews(view, Direction.Down).includes(views[index + columns]) + : undefined, + })), views.map((_, index) => ({ + width: 1200 / Math.min(columns, count - Math.floor(index / columns) * columns), + height: Math.floor(index / columns) === rows - 1 ? 800 - rowHeight * (rows - 1) : rowHeight, + right: index % columns < columns - 1 && index + 1 < count ? true : undefined, + below: index + columns < count ? true : undefined, + }))); + + arrangeSessionGrid(grid, views, count); + grid.layout(1200, 800); + const columnWidth = Math.floor(1200 / count); + assert.deepStrictEqual(views.map(view => grid.getViewSize(view)), views.map((_, index) => ({ + width: index === count - 1 ? 1200 - columnWidth * (count - 1) : columnWidth, + height: 800, + }))); + }); + } +}); diff --git a/src/vs/workbench/contrib/chat/common/chatUsage.ts b/src/vs/workbench/contrib/chat/common/chatUsage.ts new file mode 100644 index 00000000000000..f078713dc75967 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatUsage.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatUsage } from './chatService/chatService.js'; + +export interface IChatUsageModelSummary { + readonly model: string; + readonly inputTokens: number; + readonly cachedTokens: number; + readonly outputTokens: number; +} + +export interface IChatUsageSummary { + readonly inputTokens: number; + readonly cachedTokens?: number; + readonly outputTokens: number; + readonly models: readonly IChatUsageModelSummary[]; + readonly isComplete: boolean; +} + +export function aggregateChatUsage(usages: readonly (IChatUsage | undefined)[]): IChatUsageSummary | undefined { + const models = new Map(); + let inputTokens = 0; + let cachedTokens = 0; + let outputTokens = 0; + let hasUsage = false; + let isComplete = true; + + for (const usage of usages) { + if (!usage) { + continue; + } + const modelTotals = usage.modelTotals?.filter(isValidModelTotal); + if (modelTotals?.length) { + hasUsage = true; + for (const total of modelTotals) { + inputTokens += total.inputTokens; + cachedTokens += total.cachedTokens; + outputTokens += total.outputTokens; + const current = models.get(total.model); + models.set(total.model, { + model: total.model, + inputTokens: (current?.inputTokens ?? 0) + total.inputTokens, + cachedTokens: (current?.cachedTokens ?? 0) + total.cachedTokens, + outputTokens: (current?.outputTokens ?? 0) + total.outputTokens, + }); + } + continue; + } + + if (isTokenCount(usage.promptTokens) && isTokenCount(usage.completionTokens)) { + hasUsage = true; + isComplete = false; + inputTokens += usage.promptTokens; + outputTokens += usage.completionTokens; + } + } + + return hasUsage ? { + inputTokens, + ...(isComplete ? { cachedTokens } : {}), + outputTokens, + models: [...models.values()], + isComplete, + } : undefined; +} + +function isValidModelTotal(total: IChatUsageModelSummary): boolean { + return !!total.model + && isTokenCount(total.inputTokens) + && isTokenCount(total.cachedTokens) + && isTokenCount(total.outputTokens); +} + +function isTokenCount(value: number): boolean { + return Number.isFinite(value) && value >= 0; +} diff --git a/src/vs/workbench/contrib/chat/test/common/chatUsage.test.ts b/src/vs/workbench/contrib/chat/test/common/chatUsage.test.ts new file mode 100644 index 00000000000000..b4544efda3eb87 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/chatUsage.test.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IChatUsage } from '../../common/chatService/chatService.js'; +import { aggregateChatUsage } from '../../common/chatUsage.js'; + +suite('Chat usage aggregation', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('aggregates whole-turn totals by model', () => { + const summary = aggregateChatUsage([ + usage([ + { model: 'Claude', inputTokens: 10, cachedTokens: 4, outputTokens: 2 }, + { model: 'GPT', inputTokens: 5, cachedTokens: 1, outputTokens: 3 }, + ]), + usage([ + { model: 'Claude', inputTokens: 20, cachedTokens: 8, outputTokens: 6 }, + ]), + ]); + + assert.deepStrictEqual(summary, { + inputTokens: 35, + cachedTokens: 13, + outputTokens: 11, + models: [ + { model: 'Claude', inputTokens: 30, cachedTokens: 12, outputTokens: 8 }, + { model: 'GPT', inputTokens: 5, cachedTokens: 1, outputTokens: 3 }, + ], + isComplete: true, + }); + }); + + test('marks response-level fallback as partial', () => { + assert.deepStrictEqual(aggregateChatUsage([ + { kind: 'usage', promptTokens: 10, completionTokens: 2 }, + undefined, + { kind: 'usage', promptTokens: 20, completionTokens: 4 }, + ]), { + inputTokens: 30, + outputTokens: 6, + models: [], + isComplete: false, + }); + }); + + test('ignores invalid totals and returns undefined without usable usage', () => { + assert.strictEqual(aggregateChatUsage([ + undefined, + { kind: 'usage', promptTokens: -1, completionTokens: 2 }, + { + kind: 'usage', + promptTokens: Number.NaN, + completionTokens: 1, + modelTotals: [{ model: 'Claude', inputTokens: Number.NaN, cachedTokens: 0, outputTokens: 2 }], + }, + ]), undefined); + }); +}); + +function usage(modelTotals: NonNullable): IChatUsage { + return { + kind: 'usage', + promptTokens: 1, + completionTokens: 1, + modelTotals, + }; +} diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index da4db9a8478559..863aa752e16709 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -286,6 +286,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I }()); reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } + override getChatSessionContribution() { return undefined; } override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 8c227163986bf0..6c3dc813ae4d88 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -38,6 +38,8 @@ import { IAgentHostFilterService } from '../../../../../sessions/services/agentH // eslint-disable-next-line local/code-import-patterns import { ISessionGroup, ISessionGroupsService } from '../../../../../sessions/services/sessions/browser/sessionGroupsService.js'; // eslint-disable-next-line local/code-import-patterns +import { ISessionComparisonService } from '../../../../../sessions/services/sessions/common/sessionComparison.js'; +// eslint-disable-next-line local/code-import-patterns import { ISessionSectionOrderService } from '../../../../../sessions/services/sessions/browser/sessionSectionOrderService.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionsListModelService, SessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js'; @@ -337,6 +339,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender override readonly visibleSessions: IObservable = constObservable([]); override readonly activeSession: IObservable = constObservable(undefined); }()); + reg.defineInstance(ISessionComparisonService, new class extends mock() { + override readonly comparisons = constObservable([]); + }()); reg.defineInstance(ISessionsListModelService, new class extends mock() { override readonly onDidChange = Event.None; override isSessionPinned(): boolean { return false; }