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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1257,13 +1257,16 @@ suite('ProviderAutomationService', () => {
runs: [],
});
const { service, providerStore, storage } = createService(futureLedger);
const catalogueState = service.catalogueState.get();

await assert.rejects(service.waitForMigrationForTesting(), /cannot be migrated safely/);

assert.deepStrictEqual({
catalogueState,
providerAutomations: providerStore.automations.get(),
persisted: storage.get(AUTOMATION_STORAGE_KEY, StorageScope.APPLICATION),
}, {
catalogueState: 'error',
providerAutomations: [],
persisted: futureLedger,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide
};
bindConnection();
this._register(this._agentHostService.onAgentHostStart(bindConnection));
this._register(this._agentHostService.onAgentHostExit(() => {
connectionListeners.clear();
automations.clearConnection();
}));

// Eagerly populate the session cache once authentication has settled.
// Without this, the sidebar would only call `getSessions()` after some
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js';
import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js';
import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js';
import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js';
import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js';
import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js';
import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js';
Expand All @@ -38,6 +40,7 @@ import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, Resour
import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js';
import { IChatService, type ChatSendResult, type IChatModelReference, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatSessionsService, isIChatSessionFileChange2 } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js';
import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/constants.js';
import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js';
import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js';
Expand Down Expand Up @@ -88,7 +91,9 @@ class MockAgentHostService extends mock<IAgentHostService>() {
override get rootState(): IAgentSubscription<RootState> { return this._rootStateSubscription; }
private readonly _onAgentHostStart = new Emitter<void>();
override readonly onAgentHostStart = this._onAgentHostStart.event;
override readonly initializeResult = constObservable({
private readonly _onAgentHostExit = new Emitter<number>();
override readonly onAgentHostExit = this._onAgentHostExit.event;
override readonly initializeResult = observableValue<InitializeResult>(this, {
protocolVersion: '1',
serverSeq: 0,
snapshots: [],
Expand All @@ -97,6 +102,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {

override readonly clientId = 'test-local-client';
private readonly _sessions = new Map<string, IAgentSessionMetadata>();
public automationCatalog: AutomationState = { entries: [] };
public disposedSessions: URI[] = [];
public onDisposeSession: ((session: URI) => void) | undefined;
public failDisposeSessionFor: string | undefined;
Expand Down Expand Up @@ -275,7 +281,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {
override getSubscription<T>(_kind: StateComponents, resource: URI): IReference<IAgentSubscription<T>> {
const key = resource.toString();
if (isAhpAutomationCatalogChannel(key) && !this._sessionStateValues.has(key)) {
this._sessionStateValues.set(key, { entries: [] });
this._sessionStateValues.set(key, this.automationCatalog);
}
return this._getSubscription<T>(key);
}
Expand Down Expand Up @@ -381,6 +387,10 @@ class MockAgentHostService extends mock<IAgentHostService>() {
this._onAgentHostStart.fire();
}

fireAgentHostExit(): void {
this._onAgentHostExit.fire(0);
}

setRootStateError(): void {
const error = new Error('root state failed');
this._rootStateValue = error;
Expand All @@ -401,6 +411,7 @@ class MockAgentHostService extends mock<IAgentHostService>() {
this._onDidRootStateChange.dispose();
this._onDidRootStateError.dispose();
this._onAgentHostStart.dispose();
this._onAgentHostExit.dispose();
for (const emitter of this._sessionStateEmitters.values()) {
emitter.dispose();
}
Expand Down Expand Up @@ -672,6 +683,29 @@ suite('LocalAgentHostSessionsProvider', () => {

// ---- Provider identity -------

test('Automation catalogue state follows local Agent Host connection lifetime', () => {
agentHost.automationCatalog = { entries: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } };
agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), automations: { create: {} } }, undefined);
const provider = createProvider(disposables, agentHost, undefined, {
configurationService: new TestConfigurationService({ [CHAT_AUTOMATIONS_ENABLED_SETTING]: true }),
});
const initial = provider.automations.catalogueState.get();

agentHost.fireAgentHostExit();
const disconnected = provider.automations.catalogueState.get();
agentHost.fireAgentHostStart();

assert.deepStrictEqual({
initial,
disconnected,
reconnected: provider.automations.catalogueState.get(),
}, {
initial: 'ready',
disconnected: 'unavailable',
reconnected: 'ready',
});
});

test('has correct id, label, and sessionType from rootState agents', () => {
const provider = createProvider(disposables, agentHost);

Expand Down
84 changes: 73 additions & 11 deletions src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable } from '../../../../base/common/lifecycle.js';
import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { autorun, derived, observableValue } from '../../../../base/common/observable.js';
import { onUnexpectedError } from '../../../../base/common/errors.js';
import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js';
Expand All @@ -12,7 +12,9 @@ import { observableMemento, type ObservableMemento } from '../../../../platform/
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js';
import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js';
import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js';
import { ICustomViewService } from '../../../services/customView/browser/customViewService.js';
import { ISessionsWindowUsageService } from '../../../services/sessions/browser/sessionsWindowUsageService.js';
import { AUTOMATIONS_CUSTOM_VIEW_ID } from './automationsConstants.js';

export const AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY = 'sessions.automations.newBadgeSeen';
Expand All @@ -23,6 +25,8 @@ export type AutomationsNewBadgeStyle = 'accent' | 'soft' | 'outline';

const DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE: AutomationsNewBadgeStyle = 'outline';

type AutomationsNewBadgeStartupDecision = 'pending' | 'eligible' | 'suppressed';

const automationsNewBadgeSeenMemento = observableMemento<boolean>({
key: AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY,
defaultValue: false,
Expand All @@ -35,10 +39,21 @@ export class AutomationsNewBadgeState extends Disposable {

private readonly seen: ObservableMemento<boolean>;
private readonly resolvedStyle = observableValue<AutomationsNewBadgeStyle | undefined>(this, undefined);
private observingActiveView = false;
private readonly forcePreview = observableValue(this, false);
private readonly startupDecision = observableValue<AutomationsNewBadgeStartupDecision>(this, 'pending');
private readonly automationEvidenceObserver = this._register(new MutableDisposable());
private observersRegistered = false;
private initializationPromise: Promise<void> | undefined;
private styleRequest = 0;
readonly presentation = derived(this, reader => this.seen.read(reader) ? undefined : this.resolvedStyle.read(reader));
readonly presentation = derived(this, reader => {
if (this.seen.read(reader)) {
return undefined;
}
if (this.forcePreview.read(reader)) {
return this.resolvedStyle.read(reader);
}
return this.startupDecision.read(reader) === 'eligible' ? this.resolvedStyle.read(reader) : undefined;
});
readonly showNewBadge = derived(this, reader => this.presentation.read(reader) !== undefined);

constructor(
Expand All @@ -48,19 +63,43 @@ export class AutomationsNewBadgeState extends Disposable {
@IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@ILogService private readonly logService: ILogService,
@ISessionsWindowUsageService private readonly sessionsWindowUsageService: ISessionsWindowUsageService,
@ILifecycleService private readonly lifecycleService: ILifecycleService,
) {
super();
this.seen = this._register(automationsNewBadgeSeenMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, storageService));
}

initialize(): Promise<void> {
if (!this.observingActiveView) {
this.observingActiveView = true;
if (!this.observersRegistered) {
this.observersRegistered = true;
if (!this.seen.get()) {
const evidenceObserver = autorun(reader => {
if (this.forcePreview.read(reader)) {
return;
}
if (this.automationService.automations.read(reader).length > 0 || this.automationService.runs.read(reader).length > 0) {
this.markSeen();
}
});
this.automationEvidenceObserver.value = evidenceObserver;
if (this.seen.get()) {
this.automationEvidenceObserver.clear();
}
}
this._register(autorun(reader => {
if (this.customViewService.activeCustomView.read(reader)?.id === AUTOMATIONS_CUSTOM_VIEW_ID) {
this.markSeen();
}
}));
this._register(autorun(reader => {
if (this.forcePreview.read(reader) || this.seen.read(reader) || this.startupDecision.read(reader) !== 'eligible') {
return;
}
if (this.automationService.catalogueState.read(reader) !== 'ready') {
this.startupDecision.set('suppressed', undefined);
}
}));
}
if (!this.initializationPromise) {
this.initializationPromise = this.doInitialize();
Expand All @@ -77,26 +116,43 @@ export class AutomationsNewBadgeState extends Disposable {
}

async reset(): Promise<void> {
this.forcePreview.set(true, undefined);
this.seen.set(false, undefined);
this.storageService.remove(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION);
this.resolvedStyle.set(undefined, undefined);
await this.updateStyle();
}

private async doInitialize(): Promise<void> {
const hasPriorUse = this.seen.get()
|| this.automationService.automations.get().length > 0
|| this.automationService.runs.get().length > 0;
if (hasPriorUse) {
if (this._store.isDisposed || this.seen.get() || this.forcePreview.get()) {
return;
}

if (!this.sessionsWindowUsageService.hadPriorWindowOpen) {
this.startupDecision.set('suppressed', undefined);
return;
}

await this.lifecycleService.when(LifecyclePhase.Eventually);
if (this._store.isDisposed || this.seen.get() || this.forcePreview.get()) {
return;
}

if (this.automationService.catalogueState.get() !== 'ready') {
this.startupDecision.set('suppressed', undefined);
return;
}
if (this.automationService.automations.get().length > 0 || this.automationService.runs.get().length > 0) {
this.markSeen();
return;
}

this.startupDecision.set('eligible', undefined);
await this.updateStyle();
}

private async updateStyle(): Promise<void> {
if (this.seen.get()) {
if (!this.canResolveStyle()) {
return;
}

Expand All @@ -112,12 +168,16 @@ export class AutomationsNewBadgeState extends Disposable {
this.logService.warn(`[AutomationsNewBadgeState] Failed to resolve badge style treatment; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`, error);
}
}
if (request !== this.styleRequest || this.seen.get()) {
if (request !== this.styleRequest || !this.canResolveStyle()) {
return;
}
this.resolvedStyle.set(this.normalizeStyle(value), undefined);
}

private canResolveStyle(): boolean {
return !this._store.isDisposed && !this.seen.get() && (this.forcePreview.get() || this.startupDecision.get() === 'eligible');
}

private normalizeStyle(value: string | undefined): AutomationsNewBadgeStyle {
if (value === undefined || value === DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE) {
return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE;
Expand All @@ -130,8 +190,10 @@ export class AutomationsNewBadgeState extends Disposable {
}

private markSeen(): void {
this.forcePreview.set(false, undefined);
if (!this.seen.get()) {
this.seen.set(true, undefined);
}
this.automationEvidenceObserver.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import { ISession } from '../../../services/sessions/common/session.js';
import { getPullRequestStatusFromIcon, PullRequestStatus } from '../../github/common/types.js';
import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js';

/** Storage key for the cumulative number of times this client has been launched. */
const APP_LAUNCH_COUNT_KEY = 'agentSessions.telemetry.summary.appLaunchCount';
/** Storage key for the per-session lifecycle stats map (JSON encoded). Exported for tests. */
export const SESSIONS_KEY = 'agentSessions.telemetry.summary.sessions';
/** Storage key for the cumulative number of sessions started from the Agents window across all workspaces and providers. */
Expand Down Expand Up @@ -238,13 +236,13 @@ export class SessionsLifecycleTracker extends Disposable {
private readonly _appLaunchCount: number;
private readonly _stats: Map<string, IStoredSessionStats>;

constructor(private readonly _storageService: IStorageService) {
constructor(
private readonly _storageService: IStorageService,
appLaunchCount: number,
) {
super();

const previousAppLaunches = this._storageService.getNumber(APP_LAUNCH_COUNT_KEY, StorageScope.APPLICATION, 0);
this._appLaunchCount = previousAppLaunches + 1;
this._storageService.store(APP_LAUNCH_COUNT_KEY, this._appLaunchCount, StorageScope.APPLICATION, StorageTarget.MACHINE);

this._appLaunchCount = appLaunchCount;
this._stats = this._load();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ISendRequestOptions, ISessionsProvider } from '../../../services/sessio
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js';
import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js';
import { ISessionsWindowUsageService } from '../../../services/sessions/browser/sessionsWindowUsageService.js';
import { ISessionLifecycleSummary, SessionDoneReason, SessionsLifecycleTracker } from './sessionsLifecycleTracker.js';
import { ITypedCharactersEntry, SessionsTypedCharactersTracker } from './sessionsTypedCharactersTracker.js';

Expand Down Expand Up @@ -66,10 +67,11 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe
@ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService,
@ISessionsTasksService private readonly _sessionsTasksService: ISessionsTasksService,
@IModelService modelService: IModelService,
@ISessionsWindowUsageService sessionsWindowUsageService: ISessionsWindowUsageService,
) {
super();

this._lifecycleTracker = new SessionsLifecycleTracker(this._storageService);
this._lifecycleTracker = new SessionsLifecycleTracker(this._storageService, sessionsWindowUsageService.windowOpenCount);
// Registered after the lifecycle tracker is created but before it is
// registered: disposing flushes buffered typing, which needs a live
// lifecycle tracker to attribute it to.
Expand Down
Loading