Add chatSessions isReadOnly (#294255)

* PROTOTYPE: Add growth agent (https://github.com/microsoft/vscode-copilot-chat/pull/3460)

* support vscode.ChatSessionStatus.NeedsInput in chatSessions ext api

ref https://github.com/microsoft/vscode/issues/292430

* Add isReadOnly flag to chat sessions contributions

  Read-only session types (e.g., Growth) are passive/informational and
  should not be registered as agents, appear in session target pickers,
  or be delegation targets. Commands are still registered to support
  openSessionWithPrompt.

* Collapse isReadOnly and canDelegate branches in _enableContribution

  Both need agent and command registration; picker filtering handles
  keeping isReadOnly sessions out of the UI separately.

  the alternative (and probably ideal) UI is to 'grey out'/'disable' the
  chat input for isReadOnly sessions.  That way we don't have this
 problem at all of a non-functional chatInput

* fix description

* redundant doc

* update test
This commit is contained in:
Josh Spicer
2026-02-10 18:16:39 -06:00
committed by GitHub
parent c78a202c54
commit 90c1e41253
10 changed files with 72 additions and 8 deletions

View File

@@ -478,7 +478,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
return ChatSessionStatus.Completed;
case 2: // vscode.ChatSessionStatus.InProgress
return ChatSessionStatus.InProgress;
// Need to support NeedsInput status if we ever export it to the extension API
case 3: // vscode.ChatSessionStatus.NeedsInput
return ChatSessionStatus.NeedsInput;
default:
return undefined;
}

View File

@@ -3552,7 +3552,8 @@ export enum ChatLocation {
export enum ChatSessionStatus {
Failed = 0,
Completed = 1,
InProgress = 2
InProgress = 2,
NeedsInput = 3
}
export class ChatSessionChangedFile {

View File

@@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { observableValue } from '../../../../../base/common/observable.js';
import { IChatSessionTiming } from '../../common/chatService/chatService.js';
import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js';
import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js';
import { getChatSessionType } from '../../common/model/chatUri.js';
@@ -18,6 +19,7 @@ export enum AgentSessionProviders {
Cloud = 'copilot-cloud-agent',
Claude = 'claude-code',
Codex = 'openai-codex',
Growth = 'copilot-growth',
}
export function isBuiltInAgentSessionProvider(provider: string): boolean {
@@ -35,6 +37,7 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes
case AgentSessionProviders.Cloud:
case AgentSessionProviders.Claude:
case AgentSessionProviders.Codex:
case AgentSessionProviders.Growth:
return type;
default:
return undefined;
@@ -59,6 +62,8 @@ export function getAgentSessionProviderName(provider: AgentSessionProviders): st
return 'Claude';
case AgentSessionProviders.Codex:
return 'Codex';
case AgentSessionProviders.Growth:
return 'Growth';
}
}
@@ -74,6 +79,8 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th
return Codicon.openai;
case AgentSessionProviders.Claude:
return Codicon.claude;
case AgentSessionProviders.Growth:
return Codicon.lightbulb;
}
}
@@ -85,11 +92,16 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders
return true;
case AgentSessionProviders.Claude:
case AgentSessionProviders.Codex:
case AgentSessionProviders.Growth:
return false;
}
}
export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean {
export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean {
// Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets
if (contribution?.isReadOnly) {
return false;
}
switch (provider) {
case AgentSessionProviders.Local:
case AgentSessionProviders.Background:
@@ -97,6 +109,7 @@ export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean
return true;
case AgentSessionProviders.Claude:
case AgentSessionProviders.Codex:
case AgentSessionProviders.Growth:
return false;
}
}
@@ -113,6 +126,8 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide
return localize('chat.session.providerDescription.claude', "Delegate tasks to the Claude Agent SDK using the Claude models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace.");
case AgentSessionProviders.Codex:
return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view.");
case AgentSessionProviders.Growth:
return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features.");
}
}

View File

@@ -200,6 +200,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint<IChatSessionsEx
type: 'boolean',
default: false
},
isReadOnly: {
description: localize('chatSessionsExtPoint.isReadOnly', 'Whether this session type is for read-only agents that do not support interactive chat. This flag is incompatible with \'canDelegate\'.'),
type: 'boolean',
default: false
},
customAgentTarget: {
description: localize('chatSessionsExtPoint.customAgentTarget', 'When set, the chat session will show a filtered mode picker that prefers custom agents whose target property matches this value. Custom agents without a target property are still shown in all session types. This enables the use of standard agent/mode with contributed sessions.'),
type: 'string'
@@ -366,6 +371,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
displayName = localize('chat.session.inProgress.background', "Background Agent");
} else if (chatSessionType === AgentSessionProviders.Cloud) {
displayName = localize('chat.session.inProgress.cloud', "Cloud Agent");
} else if (chatSessionType === AgentSessionProviders.Growth) {
displayName = localize('chat.session.inProgress.growth', "Growth");
} else {
displayName = this._contributions.get(chatSessionType)?.contribution.displayName;
}
@@ -648,7 +655,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
private _enableContribution(contribution: IChatSessionsExtensionPoint, ext: IRelaxedExtensionDescription): void {
const disposableStore = new DisposableStore();
this._contributionDisposables.set(contribution.type, disposableStore);
if (contribution.canDelegate) {
if (contribution.isReadOnly || contribution.canDelegate) {
disposableStore.add(this._registerAgent(contribution, ext));
disposableStore.add(this._registerCommands(contribution));
}

View File

@@ -132,7 +132,7 @@ export class ChatSuggestNextWidget extends Disposable {
return false;
}
const provider = getAgentSessionProvider(c.type);
return provider !== undefined && getAgentCanContinueIn(provider);
return provider !== undefined && getAgentCanContinueIn(provider, c);
});
if (showContinueOn && availableContributions.length > 0) {

View File

@@ -54,7 +54,8 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt
return true; // Always show active session type
}
return getAgentCanContinueIn(type);
const contribution = this.chatSessionsService.getChatSessionContribution(type);
return getAgentCanContinueIn(type, contribution);
}
protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) {

View File

@@ -163,6 +163,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem {
const contributions = this.chatSessionsService.getAllChatSessionContributions();
for (const contribution of contributions) {
if (contribution.isReadOnly) {
continue; // Read-only sessions are not interactive and should not appear in session target picker
}
const agentSessionType = getAgentSessionProvider(contribution.type);
if (!agentSessionType) {
continue;

View File

@@ -85,6 +85,7 @@ export interface IChatSessionsExtensionPoint {
readonly capabilities?: IChatAgentAttachmentCapabilities;
readonly commands?: IChatSessionCommandContribution[];
readonly canDelegate?: boolean;
readonly isReadOnly?: boolean;
/**
* When set, the chat session will show a filtered mode picker with custom agents
* that have a matching `target` property. This enables contributed chat sessions

View File

@@ -22,7 +22,7 @@ import { MenuId } from '../../../../../../platform/actions/common/actions.js';
import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js';
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js';
import { AgentSessionProviders, getAgentCanContinueIn, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js';
suite('AgentSessions', () => {
@@ -1949,6 +1949,16 @@ suite('AgentSessions', () => {
assert.strictEqual(icon.id, Codicon.cloud.id);
});
test('should return correct name for Growth provider', () => {
const name = getAgentSessionProviderName(AgentSessionProviders.Growth);
assert.strictEqual(name, 'Growth');
});
test('should return correct icon for Growth provider', () => {
const icon = getAgentSessionProviderIcon(AgentSessionProviders.Growth);
assert.strictEqual(icon.id, Codicon.lightbulb.id);
});
test('should handle Local provider type in model', async () => {
return runWithFakedTimers({}, async () => {
const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables));
@@ -2087,6 +2097,25 @@ suite('AgentSessions', () => {
});
});
suite('AgentSessionsViewModel - getAgentCanContinueIn', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('should return false when contribution.isReadOnly is true', () => {
const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true });
assert.strictEqual(result, false);
});
test('should return true for Cloud when contribution is not read-only', () => {
const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false });
assert.strictEqual(result, true);
});
test('should return false for Growth provider', () => {
const result = getAgentCanContinueIn(AgentSessionProviders.Growth);
assert.strictEqual(result, false);
});
});
suite('AgentSessionsViewModel - Cancellation and Lifecycle', () => {
const disposables = new DisposableStore();
let mockChatSessionsService: MockChatSessionsService;

View File

@@ -23,7 +23,12 @@ declare module 'vscode' {
/**
* The chat session is currently in progress.
*/
InProgress = 2
InProgress = 2,
/**
* The chat session needs user input (e.g. an unresolved confirmation).
*/
NeedsInput = 3
}
export namespace chat {