From b32087ab8a9851fda12cbd09aed01396c80001d8 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 28 Aug 2026 15:26:31 -0700 Subject: [PATCH 1/3] agent host: update to AHP 0.9.0 Updates VS Code to the AHP 0.9.0 protocol and Rust crates. - Renames the automation catalog state and adopts the entries collection. - Treats normalized automation channel URIs as equivalent on both ends. - Updates generated protocol types and handwritten automation consumers. - Updates the Rust AHP dependencies and new request fields. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/Cargo.lock | 9 +- cli/Cargo.toml | 4 +- cli/src/commands/agent.rs | 1 + cli/src/commands/agent_discovery.rs | 1 + cli/src/commands/agent_ps.rs | 2 + .../common/state/agentSubscription.ts | 14 ++- .../common/state/protocol/.ahp-version | 2 +- .../protocol/channels-automation-run/state.ts | 4 +- .../protocol/channels-automation/actions.ts | 18 ++-- .../protocol/channels-automation/commands.ts | 14 +-- .../protocol/channels-automation/reducer.ts | 22 ++--- .../protocol/channels-automation/state.ts | 19 ++-- .../state/protocol/channels-session/state.ts | 4 +- .../common/state/protocol/common/commands.ts | 10 +-- .../common/state/protocol/common/state.ts | 4 +- .../common/state/protocol/version/registry.ts | 6 +- .../agentHost/common/state/sessionState.ts | 29 +++--- .../node/agentHostAutomationService.ts | 86 +++++++++--------- .../agentHost/node/agentHostStateManager.ts | 8 +- .../platform/agentHost/node/agentService.ts | 4 +- .../agentHost/node/protocolServerHandler.ts | 5 +- .../test/common/agentSubscription.test.ts | 44 +++++---- .../agentHostProtocolClient.test.ts | 8 +- .../node/agentHostAutomationService.test.ts | 82 ++++++++--------- .../test/node/agentHostStorageService.test.ts | 2 +- .../test/node/protocolServerHandler.test.ts | 25 ++++-- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../browser/agentHostAutomationStore.ts | 90 +++++++++---------- .../browser/agentHostAutomationStore.test.ts | 38 ++++---- .../localAgentHostSessionsProvider.test.ts | 10 +-- .../remoteAgentHostSessionsProvider.test.ts | 14 +-- 31 files changed, 306 insertions(+), 275 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 7ae418cd29163a..6028796f7b8a5c 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -19,11 +19,12 @@ dependencies = [ [[package]] name = "ahp" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0e38f1cb5959958300e43d648624a142becea4fe498ebc62ab11d2bdcdd971" +checksum = "e78bcc1da853fe667b53c71c29d81917081511bf2a4bf299a0020fbacc971b98" dependencies = [ "ahp-types", + "jiff", "serde", "serde_json", "thiserror 2.0.18", @@ -33,9 +34,9 @@ dependencies = [ [[package]] name = "ahp-types" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f698acd3312d94d9f4ce9f2d6c2d3fa778bb088548a288d81c7384c71fe7c23d" +checksum = "cc0674a9fabc13c97d309f1df5041a1e0811fc244200846f61b154660d3209da" dependencies = [ "serde", "serde_json", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 7fe5f28010af95..2fa952a91636bc 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -56,8 +56,8 @@ console = "0.15.7" bytes = "1.11.1" tar = "0.4.46" local-ip-address = "0.6" -ahp = "0.7.0" -ahp-types = "0.7.0" +ahp = "0.9.0" +ahp-types = "0.9.0" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/src/commands/agent.rs b/cli/src/commands/agent.rs index 93d0da513f38e3..da13465a3fe5a4 100644 --- a/cli/src/commands/agent.rs +++ b/cli/src/commands/agent.rs @@ -366,6 +366,7 @@ async fn authenticate_from_error( resource: resource.resource.clone(), token: credential.access_token().to_string(), scopes: None, + meta: None, }, ) .await diff --git a/cli/src/commands/agent_discovery.rs b/cli/src/commands/agent_discovery.rs index c40b2f133e21bc..a82010f4c55c6a 100644 --- a/cli/src/commands/agent_discovery.rs +++ b/cli/src/commands/agent_discovery.rs @@ -139,6 +139,7 @@ async fn probe_host( channel: ROOT_RESOURCE_URI.to_string(), limit: None, cursor: None, + meta: None, }, ) .await; diff --git a/cli/src/commands/agent_ps.rs b/cli/src/commands/agent_ps.rs index 58a73adfd6eb52..5ffcba1561c5eb 100644 --- a/cli/src/commands/agent_ps.rs +++ b/cli/src/commands/agent_ps.rs @@ -123,6 +123,7 @@ async fn list_sessions( channel: ROOT_RESOURCE_URI.to_string(), limit: None, cursor, + meta: None, }, ) .await?; @@ -412,6 +413,7 @@ mod tests { modified_at: modified_at.to_string(), project: None, working_directories: None, + origin: None, changes: None, annotations: None, meta: None, diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 0ae471a3f56620..bdd3576868c84a 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -13,7 +13,7 @@ import { ActionEnvelope, ActionType, type AutomationAction, type AutomationRunAc import { automationReducer, automationRunReducer, changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js'; import { terminalReducer } from './protocol/reducers.js'; import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as IProtocolChatAction, TerminalAction } from './protocol/action-origin.generated.js'; -import type { AnnotationsState, AutomationCatalogState, AutomationRunState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; +import type { AnnotationsState, AutomationRunState, AutomationState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; import type { IStateSnapshot } from './sessionProtocol.js'; import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isAhpRootChannel, ROOT_STATE_URI, StateComponents } from './sessionState.js'; import { normalizeLegacyChatStateErrors } from './legacyProtocolCompatibility.js'; @@ -577,13 +577,13 @@ export class TerminalStateSubscription extends BaseAgentSubscription { +export class AutomationCatalogSubscription extends BaseAgentSubscription { constructor(clientId: string, log: (msg: string) => void) { super(clientId, log); } - protected override _applyReducer(state: AutomationCatalogState, action: StateAction): AutomationCatalogState { + protected override _applyReducer(state: AutomationState, action: StateAction): AutomationState { return automationReducer(state, action as AutomationAction, this._log); } @@ -1254,6 +1254,14 @@ export function isActionEnvelopeRelevantToSubscriptionUris(envelope: ActionEnvel } return false; } + if (isAhpAutomationCatalogChannel(envelope.channel)) { + for (const uri of subscribedUris) { + if (isAhpAutomationCatalogChannel(uri)) { + return true; + } + } + return false; + } for (const uri of subscribedUris) { if (uri === envelope.channel) { return true; diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 1519ab5e092315..6110301d110ece 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -a0bc67f8 +60706330 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts index c3ca0a976ee52e..0b3a920c067e1d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import type { ErrorInfo, URI, UsageInfo } from '../common/state.js'; -import type { AutomationEventTrigger, AutomationMisfirePolicy, AutomationScheduleTrigger, AutomationState } from '../channels-automation/state.js'; +import type { AutomationEventTrigger, AutomationMisfirePolicy, AutomationScheduleTrigger, AutomationEntry } from '../channels-automation/state.js'; import type { RunAutomationParams } from '../channels-automation/commands.js'; import type { SessionState } from '../channels-session/state.js'; @@ -227,7 +227,7 @@ export interface AutomationRunSummary { export interface AutomationRunState { /** URI of this automation-run channel. */ resource: URI; - /** Owning `ahp-automation:` URI matching {@link AutomationState.resource}. */ + /** Owning `ahp-automation:` URI matching {@link AutomationEntry.resource}. */ automation: URI; /** Immutable provenance describing how this run was created. */ origin: AutomationRunOrigin; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts index 9dbafdb743c147..f4e2a71346cf7e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts @@ -9,7 +9,7 @@ import { ActionType } from '../common/actions.js'; import type { Message } from '../channels-chat/state.js'; import type { URI } from '../common/state.js'; -import type { AutomationCatalogState, AutomationDefinition, AutomationOperation, AutomationSessionTemplate, AutomationState, AutomationTrigger } from './state.js'; +import type { AutomationDefinition, AutomationEntry, AutomationOperation, AutomationSessionTemplate, AutomationState, AutomationTrigger } from './state.js'; /** * Partial replacement of editable {@link AutomationDefinition} fields. @@ -60,9 +60,9 @@ export interface AutomationDefinitionPatch { */ export interface AutomationCreateRequestedAction { type: ActionType.AutomationCreateRequested; - /** Client-chosen `ahp-automation:` URI that becomes {@link AutomationState.resource}. */ + /** Client-chosen `ahp-automation:` URI that becomes {@link AutomationEntry.resource}. */ resource: URI; - /** Complete initial {@link AutomationState.definition}. */ + /** Complete initial {@link AutomationEntry.definition}. */ definition: AutomationDefinition; } @@ -87,7 +87,7 @@ export interface AutomationCreateRequestedAction { */ export interface AutomationUpdateRequestedAction { type: ActionType.AutomationUpdateRequested; - /** Target {@link AutomationState.resource}. */ + /** Target {@link AutomationEntry.resource}. */ resource: URI; /** Editable {@link AutomationDefinition} fields to replace. */ changes: AutomationDefinitionPatch; @@ -95,9 +95,9 @@ export interface AutomationUpdateRequestedAction { /** * Add or replace one full automation state in - * {@link AutomationCatalogState.automations}. + * {@link AutomationState.entries}. * - * Existing entries are matched by {@link AutomationState.resource} and + * Existing entries are matched by {@link AutomationEntry.resource} and * replaced in place. A previously unseen resource is appended. * * @category Automation Actions @@ -106,11 +106,11 @@ export interface AutomationUpdateRequestedAction { export interface AutomationSetAction { type: ActionType.AutomationSet; /** Full new or replacement automation state. */ - automation: AutomationState; + automation: AutomationEntry; } /** - * Remove one automation from {@link AutomationCatalogState.automations}. + * Remove one automation from {@link AutomationState.entries}. * * Clients may dispatch this action only while the target advertises * {@link AutomationOperation.Remove}. The host revalidates that operation @@ -125,6 +125,6 @@ export interface AutomationSetAction { */ export interface AutomationRemovedAction { type: ActionType.AutomationRemoved; - /** {@link AutomationState.resource} to remove. */ + /** {@link AutomationEntry.resource} to remove. */ resource: URI; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts index e7aec7c2fbc284..780382e53ffddd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts @@ -11,7 +11,7 @@ import type { URI } from '../common/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; import type { AgentInfo } from '../channels-root/state.js'; import type { AutomationSetAction } from './actions.js'; -import type { AutomationDefinition, AutomationSessionTemplate, AutomationState, AutomationTriggerDefinition } from './state.js'; +import type { AutomationDefinition, AutomationEntry, AutomationSessionTemplate, AutomationTriggerDefinition } from './state.js'; /** * Discover event-trigger types available for a prospective session template. @@ -63,8 +63,8 @@ export interface ListAutomationTriggerDefinitionsResult { */ export interface RunAutomationParams extends BaseParams { /** Manual runs are scoped to the catalogue channel. */ - channel: 'ahp-automations://catalog'; - /** Target {@link AutomationState.resource}. */ + channel: 'ahp-automations://'; + /** Target {@link AutomationEntry.resource}. */ automation: URI; /** * Durable client-generated idempotency key. Retrying with the same key and @@ -89,7 +89,7 @@ export interface RunAutomationResult { * * The response only acknowledges the request. The updated full state arrives * through {@link AutomationSetAction | `automation/set`} on the - * `ahp-automations://catalog` channel, keeping all catalogue subscribers synchronized + * `ahp-automations://` channel, keeping all catalogue subscribers synchronized * through the normal action stream. * * @category Commands @@ -100,11 +100,11 @@ export interface RunAutomationResult { */ export interface FetchAutomationRunsParams extends BaseParams { /** Run-history loading is scoped to the catalogue channel. */ - channel: 'ahp-automations://catalog'; - /** Target {@link AutomationState.resource}. */ + channel: 'ahp-automations://'; + /** Target {@link AutomationEntry.resource}. */ automation: URI; /** - * Cursor previously received as {@link AutomationState.runsNextCursor}. + * Cursor previously received as {@link AutomationEntry.runsNextCursor}. * Omit to request the first page not already included by the snapshot. */ cursor?: string; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts index a02efe823b1e45..5372b8c458bd07 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts @@ -9,36 +9,36 @@ import type { AutomationAction } from '../action-origin.generated.js'; import { ActionType } from '../common/actions.js'; import { softAssertNever } from '../common/reducer-helpers.js'; -import type { AutomationCatalogState } from './state.js'; +import type { AutomationState } from './state.js'; /** Pure reducer for automation catalogue state. */ -export function automationReducer(state: AutomationCatalogState, action: AutomationAction, log?: (msg: string) => void): AutomationCatalogState { +export function automationReducer(state: AutomationState, action: AutomationAction, log?: (msg: string) => void): AutomationState { switch (action.type) { case ActionType.AutomationCreateRequested: case ActionType.AutomationUpdateRequested: return state; case ActionType.AutomationSet: { - const idx = state.automations.findIndex(automation => automation.resource === action.automation.resource); + const idx = state.entries.findIndex(automation => automation.resource === action.automation.resource); if (idx < 0) { return { ...state, - automations: [...state.automations, action.automation], + entries: [...state.entries, action.automation], }; } - const automations = state.automations.slice(); - automations[idx] = action.automation; - return { ...state, automations }; + const entries = state.entries.slice(); + entries[idx] = action.automation; + return { ...state, entries }; } case ActionType.AutomationRemoved: { - const idx = state.automations.findIndex(automation => automation.resource === action.resource); + const idx = state.entries.findIndex(automation => automation.resource === action.resource); if (idx < 0) { return state; } - const automations = state.automations.slice(); - automations.splice(idx, 1); - return { ...state, automations }; + const entries = state.entries.slice(); + entries.splice(idx, 1); + return { ...state, entries }; } default: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts index 95f2455f81e20d..67b86d3eb84800 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts @@ -19,7 +19,7 @@ import type { FetchAutomationRunsParams, ListAutomationTriggerDefinitionsParams, /** * Operations the host currently permits for an automation. * - * The list on {@link AutomationState.operations} is authoritative and may + * The list on {@link AutomationEntry.operations} is authoritative and may * change over time. Clients MUST NOT infer permission from capabilities alone: * capabilities describe what the host implementation can support, while * operations describe what is allowed for this particular automation now. @@ -255,7 +255,7 @@ export interface AutomationSessionTemplate { * A definition combines the initial automation message, the session template * used for each run, and zero or more automatic triggers. Run history, * timestamps, and currently allowed operations live on - * {@link AutomationState} rather than in the definition. + * {@link AutomationEntry} rather than in the definition. * * @category Automation State */ @@ -284,8 +284,7 @@ export interface AutomationDefinition { } /** - * Authoritative state of one automation in the - * {@link AutomationCatalogState.automations} catalogue. + * Authoritative state of one automation in {@link AutomationState.entries}. * * The host owns trigger evaluation, run claims, run retention, and operation * availability. Clients render this state and submit actions or commands; they @@ -293,7 +292,7 @@ export interface AutomationDefinition { * * @category Automation State */ -export interface AutomationState { +export interface AutomationEntry { /** Stable `ahp-automation:/` resource identifier. */ resource: URI; /** Current durable definition. */ @@ -303,7 +302,7 @@ export interface AutomationState { /** * Newest-first retained run summaries. This is a bounded window; use * {@link FetchAutomationRunsParams | fetchAutomationRuns} when - * {@link AutomationState.runsNextCursor} is present. + * {@link AutomationEntry.runsNextCursor} is present. */ runs: AutomationRunSummary[]; /** Opaque cursor passed as {@link FetchAutomationRunsParams.cursor} for the next older run-history page. */ @@ -319,7 +318,7 @@ export interface AutomationState { } /** - * Authoritative automation catalogue exposed on the `ahp-automations://catalog` + * Authoritative automation catalogue exposed on the `ahp-automations://` * channel. * * A subscription snapshot contains every automation visible to the client. @@ -329,9 +328,9 @@ export interface AutomationState { * * @category Automation State */ -export interface AutomationCatalogState { - /** Full automation states keyed by {@link AutomationState.resource}. */ - automations: AutomationState[]; +export interface AutomationState { + /** Full automation entries keyed by {@link AutomationEntry.resource}. */ + entries: AutomationEntry[]; /** Opaque host-defined catalogue metadata. */ _meta?: Record; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 4a15a7e80dee75..445f33494ce5b3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -10,7 +10,7 @@ import type { Changeset } from '../channels-changeset/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallRunningState, ToolCallAuthRequiredState } from '../channels-chat/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; -import type { AutomationState } from '../channels-automation/state.js'; +import type { AutomationEntry } from '../channels-automation/state.js'; import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, TextRange, URI } from '../common/state.js'; // ─── Session State ─────────────────────────────────────────────────────────── @@ -74,7 +74,7 @@ export const enum SessionOriginKind { */ export interface AutomationSessionOrigin { kind: SessionOriginKind.Automation; - /** Owning {@link AutomationState.resource}. */ + /** Owning {@link AutomationEntry.resource}. */ automation: URI; /** Owning {@link AutomationRunState.resource}. */ run: URI; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index 05000eb38ec5dc..5ac6b4778a5217 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -10,7 +10,7 @@ import type { URI, Snapshot } from './state.js'; import type { ActionEnvelope, StateAction } from './actions.js'; import type { AutomationRunCancelRequestedAction } from '../channels-automation-run/actions.js'; import type { AutomationCreateRequestedAction } from '../channels-automation/actions.js'; -import type { AutomationSchedule, AutomationScheduleTrigger, AutomationCatalogState, AutomationState } from '../channels-automation/state.js'; +import type { AutomationSchedule, AutomationScheduleTrigger, AutomationEntry, AutomationState } from '../channels-automation/state.js'; import type { TelemetryCapabilities } from '../channels-otlp/state.js'; // ─── BaseParams ────────────────────────────────────────────────────────────── @@ -281,7 +281,7 @@ export interface InitializeResult { telemetry?: TelemetryCapabilities; /** * Host-owned automation support. Presence means clients may subscribe to - * `ahp-automations://catalog` for {@link AutomationCatalogState}; absence means the + * `ahp-automations://` for {@link AutomationState}; absence means the * host does not expose an automation catalogue or automation commands. * * @see {@link /guide/automations | Automations Guide} @@ -292,12 +292,12 @@ export interface InitializeResult { /** * Automation features supported by this host authority. * - * The presence of this object advertises the baseline `ahp-automations://catalog` + * The presence of this object advertises the baseline `ahp-automations://` * catalogue. Optional fields describe additional host features and * restrictions. * * Capabilities describe implementation support. - * {@link AutomationState.operations} remains authoritative for which + * {@link AutomationEntry.operations} remains authoritative for which * definition mutations are currently allowed on a particular automation. * * @category Commands @@ -313,7 +313,7 @@ export interface AutomationCapabilities { */ runCancellation?: AutomationRunCancellationCapability; /** - * Maximum terminal entries retained in {@link AutomationState.runs}. Active + * Maximum terminal entries retained in {@link AutomationEntry.runs}. Active * runs are not counted toward the limit. Absence means the retention limit is * implementation-defined. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index d8b8db833146c5..14df0f4718f832 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -13,7 +13,7 @@ import type { ChangesetState } from '../channels-changeset/state.js'; import type { ResourceWatchState } from '../channels-resource-watch/state.js'; import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; -import type { AutomationCatalogState } from '../channels-automation/state.js'; +import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -334,7 +334,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationCatalogState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index cda0b8fa5b32fe..86a7dfeb64226a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '1.0.0'; +export const PROTOCOL_VERSION = '0.9.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,7 +35,7 @@ export const PROTOCOL_VERSION = '1.0.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '1.0.0', + '0.9.0', '0.8.0', '0.7.0', '0.6.0', @@ -126,7 +126,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnComplete]: '0.4.0', [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', - [ActionType.ChatTurnResume]: '1.0.0', + [ActionType.ChatTurnResume]: '0.9.0', [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index b9c01605ce92e2..31dcd0d3863773 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -37,7 +37,7 @@ import { type PendingMessage, type Turn, type AnnotationsState, - type AutomationCatalogState, + type AutomationState, type AutomationRunState, type URI as ProtocolURI, type RootState, @@ -76,7 +76,7 @@ export { type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, type ErrorResponsePart, type ResponsePart, type RootState, type RuleCustomization, type SessionActiveClient, - type AutomationCatalogState, type AutomationRunState, + type AutomationState, type AutomationRunState, type SessionConfigState, type SessionModelInfo, type SessionState, @@ -249,18 +249,23 @@ export interface UsageInfoMeta { /** * Singleton channel containing the host-owned automation catalogue. - * - * The `catalog` authority is appended so the URI round-trips through - * `.toString()`. Without an authority, `ahp-automations://` serializes back to - * `ahp-automations:` and no longer matches. Comparing catalogue channels as - * URIs everywhere (ResourceMap/isEqual) is the intended followup. See - * https://github.com/microsoft/vscode/pull/331796#discussion_r3857160917. */ -export const AUTOMATION_CATALOG_URI = 'ahp-automations://catalog'; +export const AHP_AUTOMATIONS_SCHEME = 'ahp-automations'; +export const AUTOMATION_CATALOG_URI = `${AHP_AUTOMATIONS_SCHEME}://`; -/** Returns whether `uri` identifies the singleton automation catalogue channel. */ +/** + * Returns whether `uri` identifies the singleton automation catalogue channel, + * including forms normalized by the workbench {@link ResourceURI} class. + */ export function isAhpAutomationCatalogChannel(uri: string): boolean { - return uri === AUTOMATION_CATALOG_URI; + if (uri === AUTOMATION_CATALOG_URI) { + return true; + } + try { + return ResourceURI.parse(uri).scheme === AHP_AUTOMATIONS_SCHEME; + } catch { + return false; + } } /** Returns whether `uri` identifies one automation-run channel. */ @@ -1068,7 +1073,7 @@ export type ComponentToState = { [StateComponents.Terminal]: TerminalState; [StateComponents.Changeset]: ChangesetState; [StateComponents.Annotations]: AnnotationsState; - [StateComponents.AutomationCatalog]: AutomationCatalogState; + [StateComponents.AutomationCatalog]: AutomationState; [StateComponents.AutomationRun]: AutomationRunState; }; diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts index 18bd7a7ae275b3..0be268c21a7519 100644 --- a/src/vs/platform/agentHost/node/agentHostAutomationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -13,11 +13,11 @@ import { localize } from '../../../nls.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { ActionType, type ActionEnvelope, type AutomationCreateRequestedAction, type AutomationRemovedAction, type AutomationRunCancelRequestedAction, type AutomationRunLifecycleChangedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunSessionSetAction, type AutomationUpdateRequestedAction } from '../common/state/sessionActions.js'; -import { AUTOMATION_CATALOG_URI, isDefaultChatUri, parseRequiredSessionUriFromChatUri, type AutomationCatalogState, type Message } from '../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, isDefaultChatUri, parseRequiredSessionUriFromChatUri, type AutomationState, type Message } from '../common/state/sessionState.js'; import { automationReducer } from '../common/state/sessionReducers.js'; import type { AutomationCapabilities } from '../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; -import { AutomationMisfirePolicy, AutomationOperation, AutomationTriggerKind, type AutomationDefinition, type AutomationSessionTemplate, type AutomationState } from '../common/state/protocol/channels-automation/state.js'; +import { AutomationMisfirePolicy, AutomationOperation, AutomationTriggerKind, type AutomationDefinition, type AutomationEntry, type AutomationSessionTemplate } from '../common/state/protocol/channels-automation/state.js'; import { AutomationRunOriginKind, AutomationRunStatus, type AutomationRunLifecycle, type AutomationRunOrigin, type AutomationRunState, type AutomationRunSummary } from '../common/state/protocol/channels-automation-run/state.js'; import { MessageKind } from '../common/state/protocol/channels-chat/state.js'; import { IAgentHostStateManager, type AgentHostStateManager } from './agentHostStateManager.js'; @@ -40,7 +40,7 @@ interface IStoredManualRunRequest { interface IStoredAutomations { readonly version?: 1; - readonly catalog: AutomationCatalogState; + readonly catalog: AutomationState; readonly runs?: readonly AutomationRunState[]; readonly manualRunRequests?: readonly IStoredManualRunRequest[]; readonly migration?: { @@ -82,7 +82,7 @@ export interface IAgentHostAutomationService { export class AgentHostAutomationService extends Disposable implements IAgentHostAutomationService { declare readonly _serviceBrand: undefined; - private _catalog: AutomationCatalogState | undefined; + private _catalog: AutomationState | undefined; private _migrationCompletedAt: string | undefined; private _runs = new Map(); private _manualRunRequests = new Map(); @@ -103,7 +103,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._runs = new Map(stored?.runs?.map(run => [run.resource, run])); this._catalog = stored?.catalog ? { ...stored.catalog, - automations: stored.catalog.automations.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), + entries: stored.catalog.entries.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), ...(this._migrationCompletedAt ? { _meta: { ...stored.catalog._meta, [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } } : {}), } : undefined; this._manualRunRequests = new Map(stored?.manualRunRequests?.map(request => [request.requestId, request])); @@ -144,8 +144,8 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost if (this._migrationCompletedAt !== undefined) { return; } - const missing = (expectedResources ?? catalog.automations.map(automation => automation.resource)) - .filter(resource => !catalog.automations.some(automation => automation.resource === resource)); + const missing = (expectedResources ?? catalog.entries.map(automation => automation.resource)) + .filter(resource => !catalog.entries.some(automation => automation.resource === resource)); if (missing.length > 0) { throw new Error(`Automation migration is incomplete; ${missing.length} expected automation resources are missing.`); } @@ -155,12 +155,12 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost // fails to preserve the pre-migration invariants. const priorCompletedAt = this._migrationCompletedAt; this._migrationCompletedAt = completedAt; - let migratedCatalog: AutomationCatalogState; + let migratedCatalog: AutomationState; try { migratedCatalog = { ...catalog, _meta: { ...catalog._meta, [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true }, - automations: catalog.automations.map(automation => ({ + entries: catalog.entries.map(automation => ({ ...automation, operations: this._migrationOperationsForItem(automation), })), @@ -172,16 +172,16 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost } this._catalog = migratedCatalog; this._stateManager.setAutomationCatalogState(migratedCatalog); - for (const automation of migratedCatalog.automations) { + for (const automation of migratedCatalog.entries) { this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); } - this._logService.info(`[AgentHostAutomationService] Automation migration completed: discovered=${expectedResources?.length ?? migratedCatalog.automations.length}, automations=${migratedCatalog.automations.length}, runs=${this._runs.size}.`); + this._logService.info(`[AgentHostAutomationService] Automation migration completed: discovered=${expectedResources?.length ?? migratedCatalog.entries.length}, automations=${migratedCatalog.entries.length}, runs=${this._runs.size}.`); this._recoverRuns(); this._scheduleNext(); }); } - private _migrationOperationsForItem(automation: AutomationState): AutomationOperation[] { + private _migrationOperationsForItem(automation: AutomationEntry): AutomationOperation[] { if (!this._canGrantRun(automation.definition)) { // Pending imports or disabled automations must not receive Run or // Remove: the browser scheduler still owns the legacy row until the @@ -202,9 +202,9 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost async handleConfigurationChanged(): Promise { return this._enqueueMutation(async () => { const catalog = this._requireCatalog(); - const nextCatalog: AutomationCatalogState = { + const nextCatalog: AutomationState = { ...catalog, - automations: catalog.automations.map(automation => ({ + entries: catalog.entries.map(automation => ({ ...automation, operations: this._canGrantRun(automation.definition) ? withOperation(automation.operations, AutomationOperation.Run) @@ -215,7 +215,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost if (!equals(nextCatalog, catalog)) { await this._persist(nextCatalog, this._runs, this._manualRunRequests); this._catalog = nextCatalog; - for (const automation of nextCatalog.automations) { + for (const automation of nextCatalog.entries) { this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); } } @@ -245,7 +245,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._validateAutomationResource(action.resource); const definition = action.definition; this._validateDefinition(definition); - const existing = catalog.automations.find(automation => automation.resource === action.resource); + const existing = catalog.entries.find(automation => automation.resource === action.resource); if (existing && equals(existing.definition, definition)) { this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation: existing }); return; @@ -281,13 +281,13 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost private async _handleUpdate(action: AutomationUpdateRequestedAction): Promise { const catalog = this._requireCatalog(); - const existing = catalog.automations.find(automation => automation.resource === action.resource); + const existing = catalog.entries.find(automation => automation.resource === action.resource); if (!existing) { throw new Error(`Automation not found: ${action.resource}`); } this._requireOperation(existing, AutomationOperation.Update); - let automation: AutomationState = { + let automation: AutomationEntry = { ...existing, definition: { ...existing.definition, @@ -330,7 +330,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost private async _handleRemove(action: AutomationRemovedAction): Promise { const catalog = this._requireCatalog(); - const existing = catalog.automations.find(automation => automation.resource === action.resource); + const existing = catalog.entries.find(automation => automation.resource === action.resource); if (!existing) { return; } @@ -364,7 +364,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost private async _fetchAutomationRuns(params: FetchAutomationRunsParams): Promise { const catalog = this._requireAvailableCatalog(); - const automation = catalog.automations.find(candidate => candidate.resource === params.automation); + const automation = catalog.entries.find(candidate => candidate.resource === params.automation); if (!automation) { throw new Error(`Automation not found: ${params.automation}`); } @@ -414,7 +414,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost } const stored = this._storageService.get(STORAGE_KEY); if (stored === undefined) { - return { catalog: { automations: [] } }; + return { catalog: { entries: [] } }; } if (!isStoredAutomations(stored)) { this._logService.error('[AgentHostAutomationService] Automation storage is invalid; automation execution remains unavailable until it is recovered.'); @@ -424,7 +424,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost } private async _persist( - catalog: AutomationCatalogState, + catalog: AutomationState, runs: ReadonlyMap, manualRunRequests: ReadonlyMap, migrationCompletedAt = this._migrationCompletedAt, @@ -438,14 +438,14 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost }); } - private _requireCatalog(): AutomationCatalogState { + private _requireCatalog(): AutomationState { if (!this._catalog) { throw new Error('Automation storage is unavailable and must be recovered before automations can run.'); } return this._catalog; } - private _requireAvailableCatalog(): AutomationCatalogState { + private _requireAvailableCatalog(): AutomationState { const catalog = this._requireCatalog(); if (this._migrationCompletedAt === undefined) { throw new Error('Automation migration must complete before automations can be accessed or run.'); @@ -460,7 +460,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost return this._stateManager.rootState.config?.values[AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY] === true; } - private _withInitialScheduleState(automation: AutomationState, now: Date): AutomationState { + private _withInitialScheduleState(automation: AutomationEntry, now: Date): AutomationEntry { const cursors: Record = {}; if (automation.definition.enabled) { for (const trigger of automation.definition.triggers) { @@ -481,7 +481,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost if (!this._migrationCompletedAt || !this._catalog || !this._isAutomationsEnabled()) { return; } - const timestamps = this._catalog.automations + const timestamps = this._catalog.entries .filter(automation => automation.definition.enabled && automation.operations.includes(AutomationOperation.Run) && automation.nextRunAt @@ -513,10 +513,10 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost const createdAt = now.toISOString(); let nextCatalog = catalog; const nextRuns = new Map(this._runs); - const changed = new Map(); + const changed = new Map(); const claimed: { run: AutomationRunState; definition: AutomationDefinition }[] = []; - for (const current of catalog.automations) { + for (const current of catalog.entries) { if (!current.definition.enabled) { continue; } @@ -563,7 +563,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost } cursors[trigger.id] = scheduledFor.toISOString(); } - const nextAutomation: AutomationState = { + const nextAutomation: AutomationEntry = { ...automation, nextRunAt: earliestCursor(cursors), _meta: withScheduleCursors(automation._meta, cursors), @@ -610,7 +610,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost if (run.lifecycle.status !== AutomationRunStatus.Pending) { continue; } - const automation = this._catalog?.automations.find(candidate => candidate.resource === run.automation); + const automation = this._catalog?.entries.find(candidate => candidate.resource === run.automation); if (automation && automation.operations.includes(AutomationOperation.Run) && this._execution.isSessionTemplateAvailable(automation.definition.session)) { @@ -636,7 +636,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost return { run: previousRun }; } - const automation = catalog.automations.find(candidate => candidate.resource === params.automation); + const automation = catalog.entries.find(candidate => candidate.resource === params.automation); if (!automation) { throw new Error(`Automation not found: ${params.automation}`); } @@ -852,8 +852,8 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost } } - private _catalogWithRun(catalog: AutomationCatalogState, run: AutomationRunState): AutomationCatalogState { - const existing = catalog.automations.find(automation => automation.resource === run.automation); + private _catalogWithRun(catalog: AutomationState, run: AutomationRunState): AutomationState { + const existing = catalog.entries.find(automation => automation.resource === run.automation); if (!existing) { throw new Error(`Automation not found for run: ${run.automation}`); } @@ -863,8 +863,8 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost return automationReducer(catalog, { type: ActionType.AutomationSet, automation }, this._log); } - private _publishAutomation(catalog: AutomationCatalogState, resource: string): void { - const automation = catalog.automations.find(candidate => candidate.resource === resource); + private _publishAutomation(catalog: AutomationState, resource: string): void { + const automation = catalog.entries.find(candidate => candidate.resource === resource); if (automation) { this._stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { type: ActionType.AutomationSet, automation }); } @@ -896,7 +896,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost } } - private _requireOperation(automation: AutomationState, operation: AutomationOperation): void { + private _requireOperation(automation: AutomationEntry, operation: AutomationOperation): void { if (!automation.operations.includes(operation)) { throw new Error(`Automation operation '${operation}' is not available: ${automation.resource}`); } @@ -936,12 +936,12 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost private readonly _log = (message: string) => this._logService.warn(`[AgentHostAutomationService] ${message}`); } -function isAutomationCatalogState(value: unknown): value is AutomationCatalogState { +function isAutomationState(value: unknown): value is AutomationState { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; } - const automations = (value as Record)['automations']; - return Array.isArray(automations) && automations.every(isAutomationState); + const entries = (value as Record)['entries']; + return Array.isArray(entries) && entries.every(isAutomationEntry); } function isStoredAutomations(value: unknown): value is IStoredAutomations { @@ -950,13 +950,13 @@ function isStoredAutomations(value: unknown): value is IStoredAutomations { } const stored = value as Record; return (stored['version'] === undefined || stored['version'] === 1) - && isAutomationCatalogState(stored['catalog']) + && isAutomationState(stored['catalog']) && (stored['runs'] === undefined || Array.isArray(stored['runs']) && stored['runs'].every(isAutomationRunState)) && (stored['manualRunRequests'] === undefined || Array.isArray(stored['manualRunRequests']) && stored['manualRunRequests'].every(isStoredManualRunRequest)) && (stored['migration'] === undefined || isCompletedMigration(stored['migration'])); } -function isAutomationState(value: unknown): value is AutomationState { +function isAutomationEntry(value: unknown): value is AutomationEntry { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; } @@ -1013,7 +1013,7 @@ function toRunSummary(run: AutomationRunState): AutomationRunSummary { }; } -function withRunSummary(automation: AutomationState, allRuns: ReadonlyMap): AutomationState { +function withRunSummary(automation: AutomationEntry, allRuns: ReadonlyMap): AutomationEntry { const terminalLimit = Math.max(RUN_HISTORY_PAGE_SIZE, automation.runs.filter(candidate => isTerminalLifecycle(candidate.lifecycle)).length); const window = withRunWindow(automation, allRuns, terminalLimit); const runs = window.runs; @@ -1026,7 +1026,7 @@ function withRunSummary(automation: AutomationState, allRuns: ReadonlyMap, terminalLimit: number): AutomationState { +function withRunWindow(automation: AutomationEntry, allRuns: ReadonlyMap, terminalLimit: number): AutomationEntry { const summaries = [...allRuns.values()] .filter(run => run.automation === automation.resource) .map(toRunSummary) diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index e6720c8cd2f025..a000fabbf8c5ad 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -13,7 +13,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationCatalogState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -257,7 +257,7 @@ export class AgentHostStateManager extends Disposable { * client-dispatchable and lazily create their state on first write. */ private readonly _annotations = new Map(); - private _automationCatalog: AutomationCatalogState | undefined; + private _automationCatalog: AutomationState | undefined; private readonly _automationRuns = new Map(); /** @@ -761,11 +761,11 @@ export class AgentHostStateManager extends Disposable { } /** Installs the durable automation catalogue before accepting subscriptions. */ - setAutomationCatalogState(state: AutomationCatalogState): void { + setAutomationCatalogState(state: AutomationState): void { this._automationCatalog = state; } - getAutomationCatalogState(): AutomationCatalogState | undefined { + getAutomationCatalogState(): AutomationState | undefined { return this._automationCatalog; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e1fcc51e24ee51..0dc37a1ffe1686 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 { AUTOMATION_CATALOG_URI, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, 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'; @@ -4380,7 +4380,7 @@ export class AgentService extends Disposable implements IAgentService { } if (this._isAutomationAction(action)) { const origin = { clientId, clientSeq }; - if (channel !== AUTOMATION_CATALOG_URI) { + if (!isAhpAutomationCatalogChannel(channel)) { this._stateManager.rejectClientAction(channel, action, origin, 'Automation actions require the automation catalogue channel.'); return; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 16d9b2a0a51d6c..3bfa94f8ad6f0c 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -49,7 +49,7 @@ import { type SubscribeResult, type ListSessionsResult, } from '../common/state/sessionProtocol.js'; -import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; @@ -1993,9 +1993,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if ((sub?.kind === ChannelKind.State || sub?.kind === ChannelKind.ResourceWatch) && sub.active) { return true; } - if (!isAhpRootChannel(envelope.channel)) { - return false; - } return isActionEnvelopeRelevantToSubscriptionUris(envelope, this._stateAndResourceWatchUris(client)); } diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index c7b4b5b9fa4f86..706885b378e4da 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ActionType, type ActionEnvelope, type ClientChangesetAction } from '../../common/state/sessionActions.js'; -import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, ChangesetStatus, MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type AutomationCatalogState, type AutomationRunState, type ChangesetState, type ErrorInfo, type RootState, type SessionState, type SessionSummary, type TerminalState, type Turn } from '../../common/state/protocol/state.js'; +import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, ChangesetStatus, MessageKind, ResponsePartKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type AutomationRunState, type AutomationState, type ChangesetState, type ErrorInfo, type RootState, type SessionState, type SessionSummary, type TerminalState, type Turn } from '../../common/state/protocol/state.js'; import { AUTOMATION_CATALOG_URI, buildDefaultChatUri, createChatState, createDefaultChatSummary, getTurnError, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; import { AgentSubscriptionManager, AutomationCatalogSubscription, AutomationRunSubscription, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; import { normalizeLegacyActionEnvelope, readLegacyTurnError } from '../../common/state/legacyProtocolCompatibility.js'; @@ -87,8 +87,8 @@ const changesetUri = `${sessionUri}/changeset/session`; const automationUri = 'ahp-automation:/test-automation'; const automationRunUri = 'ahp-automation-run:/test-run'; -function makeAutomationCatalogState(): AutomationCatalogState { - return { automations: [] }; +function makeAutomationCatalogState(): AutomationState { + return { entries: [] }; } function makeAutomationRunState(): AutomationRunState { @@ -121,7 +121,7 @@ suite('Automation subscriptions', () => { definition, }, 1, { clientId: 'c1', clientSeq: 1 }, undefined, AUTOMATION_CATALOG_URI)); - const requested = subscription.value as AutomationCatalogState; + const requested = subscription.value as AutomationState; subscription.receiveEnvelope(makeEnvelope({ type: ActionType.AutomationSet, automation: { @@ -135,8 +135,8 @@ suite('Automation subscriptions', () => { }, 2, undefined, undefined, AUTOMATION_CATALOG_URI)); assert.deepStrictEqual({ - requested: requested.automations, - authoritative: (subscription.value as AutomationCatalogState).automations.map(automation => automation.resource), + requested: requested.entries, + authoritative: (subscription.value as AutomationState).entries.map(automation => automation.resource), }, { requested: [], authoritative: [automationUri], @@ -178,7 +178,7 @@ suite('Automation subscriptions', () => { triggers: [], }; subscription.handleSnapshot({ - automations: [{ + entries: [{ resource: automationUri, definition, runs: [], @@ -194,7 +194,7 @@ suite('Automation subscriptions', () => { }, 1, { clientId: 'c1', clientSeq: 1 }, 'Automation has an active run.', AUTOMATION_CATALOG_URI)); assert.deepStrictEqual( - (subscription.value as AutomationCatalogState).automations.map(automation => automation.resource), + (subscription.value as AutomationState).entries.map(automation => automation.resource), [automationUri], ); }); @@ -852,7 +852,7 @@ suite('AgentSubscriptionManager', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState | ChangesetState | AnnotationsState | AutomationCatalogState; fromSeq: number }> = async resource => { + function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState | ChangesetState | AnnotationsState | AutomationState; fromSeq: number }> = async resource => { const key = resource.toString(); subscribedResources.push(key); if (key.endsWith('/annotations')) { @@ -972,10 +972,15 @@ suite('AgentSubscriptionManager', () => { makeEnvelope({ type: ActionType.SessionTitleChanged, title: 'Yep' }, 3), ['ahp-root:', sessionUri], ), + automationVariant: isActionEnvelopeRelevantToSubscriptionUris( + makeEnvelope({ type: ActionType.AutomationRemoved, resource: automationUri }, 4, undefined, undefined, AUTOMATION_CATALOG_URI), + [URI.parse(AUTOMATION_CATALOG_URI).toString()], + ), }, { rootVariant: true, rootOnlyGetsSession: false, exactSession: true, + automationVariant: true, }); }); @@ -1003,12 +1008,13 @@ suite('AgentSubscriptionManager', () => { ref.dispose(); }); - test('uses the round-trippable automation catalogue URI', async () => { + test('uses the normalized automation catalogue URI internally', async () => { + const normalizedCatalogUri = URI.parse(AUTOMATION_CATALOG_URI).toString(); const mgr = createManager(async resource => { subscribedResources.push(resource.toString()); - return { resource: resource.toString(), state: { automations: [] }, fromSeq: 0 }; + return { resource: resource.toString(), state: { entries: [] }, fromSeq: 0 }; }); - const ref = mgr.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'AutomationHolder'); + const ref = mgr.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'AutomationHolder'); await Event.toPromise(ref.object.onDidChange); assert.deepStrictEqual({ @@ -1016,13 +1022,13 @@ suite('AgentSubscriptionManager', () => { resources: mgr.currentSubscriptionUris().map(resource => resource.toString()), activeResource: mgr.getActiveSubscriptions()[0].resource.toString(), }, { - subscribedResources: [AUTOMATION_CATALOG_URI], - resources: [AUTOMATION_CATALOG_URI], - activeResource: AUTOMATION_CATALOG_URI, + subscribedResources: [normalizedCatalogUri], + resources: [normalizedCatalogUri], + activeResource: normalizedCatalogUri, }); ref.dispose(); - assert.deepStrictEqual(unsubscribedResources, [AUTOMATION_CATALOG_URI]); + assert.deepStrictEqual(unsubscribedResources, [normalizedCatalogUri]); }); test('dispatchOptimistic applies to matching session subscription', async () => { @@ -1272,10 +1278,10 @@ suite('AgentSubscriptionManager', () => { test('markSubscriptionsMissing handles the Automation catalogue URI', async () => { const mgr = createManager(async resource => ({ resource: resource.toString(), - state: { automations: [] }, + state: { entries: [] }, fromSeq: 0, })); - const ref = mgr.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'test'); + const ref = mgr.getSubscription(StateComponents.AutomationCatalog, URI.parse(AUTOMATION_CATALOG_URI), 'test'); await Event.toPromise(ref.object.onDidChange); mgr.markSubscriptionsMissing([URI.parse(AUTOMATION_CATALOG_URI)]); @@ -1285,7 +1291,7 @@ suite('AgentSubscriptionManager', () => { resource: mgr.getActiveSubscriptions()[0].resource.toString(), }, { valueIsError: true, - resource: AUTOMATION_CATALOG_URI, + resource: URI.parse(AUTOMATION_CATALOG_URI).toString(), }); ref.dispose(); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 602190a6bf14df..ab7cb0865d2cf7 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -2510,7 +2510,7 @@ suite('AgentHostProtocolClient', () => { const initialSubscribe = await waitForRequest(transports[0], 'subscribe'); transports[0].fireMessage({ jsonrpc: '2.0', id: initialSubscribe.id, - result: { snapshot: { resource: AUTOMATION_CATALOG_URI, state: { automations: [] }, fromSeq: 5 } }, + result: { snapshot: { resource: AUTOMATION_CATALOG_URI, state: { entries: [] }, fromSeq: 5 } }, }); await flushMicrotasks(); @@ -2541,10 +2541,12 @@ suite('AgentHostProtocolClient', () => { await flushMicrotasks(); assert.deepStrictEqual({ - channel: (restoredSubscribe.params as { channel: string }).channel, + initialChannel: (initialSubscribe.params as { channel: string }).channel, + restoredChannel: (restoredSubscribe.params as { channel: string }).channel, valueIsError: catalogRef.object.value instanceof Error, }, { - channel: AUTOMATION_CATALOG_URI, + initialChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), + restoredChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), valueIsError: true, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts index b7bad282195667..2d29c0814ac0cd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -107,14 +107,14 @@ suite('AgentHostAutomationService', () => { }, { writeAttempts: 3, capabilities: { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: 50 }, - catalog: { automations: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } }, + catalog: { entries: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } }, }); }); test('a future host automation storage version disables the capability without rewriting data', async () => { storageService.set('automations', { version: 2, - catalog: { automations: [] }, + catalog: { entries: [] }, }); await storageService.whenIdle(); const service = createService(); @@ -138,13 +138,13 @@ suite('AgentHostAutomationService', () => { await assert.rejects(service.handleCreate(createAction()), /storage unavailable/); assert.deepStrictEqual(stateManager.getAutomationCatalogState(), { - automations: [], + entries: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true }, }); await service.handleCreate(createAction()); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations.map(automation => ({ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries.map(automation => ({ resource: automation.resource, operations: automation.operations, })), [{ @@ -162,21 +162,21 @@ suite('AgentHostAutomationService', () => { /1 expected automation resources are missing/, ); await assert.rejects(service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'blocked-request', }), /migration must complete/); assert.deepStrictEqual({ capabilities: service.capabilities, - operations: stateManager.getAutomationCatalogState()?.automations[0].operations, + operations: stateManager.getAutomationCatalogState()?.entries[0].operations, }, { capabilities: { create: {}, schedules: {}, runCancellation: {}, runHistoryLimit: 50 }, operations: [AutomationOperation.Update, AutomationOperation.Remove], }); await service.completeMigration(['ahp-automation:/review-changes']); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run, @@ -193,11 +193,11 @@ suite('AgentHostAutomationService', () => { await service.handleConfigurationChanged(); await assert.rejects(service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'disabled-request', }), /Automations are disabled/); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, AutomationOperation.Remove, ]); @@ -207,7 +207,7 @@ suite('AgentHostAutomationService', () => { config: { [AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY]: true }, }); await service.handleConfigurationChanged(); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run, @@ -247,7 +247,7 @@ suite('AgentHostAutomationService', () => { await enableAndCreate(service); const params = { - channel: 'ahp-automations://catalog' as const, + channel: 'ahp-automations://' as const, automation: 'ahp-automation:/review-changes', requestId: 'manual-request', }; @@ -265,7 +265,7 @@ suite('AgentHostAutomationService', () => { status: running?.lifecycle.status, sessions: running?.sessions, primarySession: running?.primarySession, - catalogRuns: stateManager.getAutomationCatalogState()?.automations[0].runs.length, + catalogRuns: stateManager.getAutomationCatalogState()?.entries[0].runs.length, startedMessageKind, }, { first: second, @@ -296,7 +296,7 @@ suite('AgentHostAutomationService', () => { assert.deepStrictEqual({ run: stateManager.getAutomationRunState(first.resource)?.lifecycle.status, - summary: stateManager.getAutomationCatalogState()?.automations[0].runs[0].lifecycle.status, + summary: stateManager.getAutomationCatalogState()?.entries[0].runs[0].lifecycle.status, }, { run: AutomationRunStatus.Completed, summary: AutomationRunStatus.Completed, @@ -315,14 +315,14 @@ suite('AgentHostAutomationService', () => { writeFailures = 1; await assert.rejects(service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'failed-request', }), /storage unavailable/); assert.deepStrictEqual({ createCalls, - runs: stateManager.getAutomationCatalogState()?.automations[0].runs, + runs: stateManager.getAutomationCatalogState()?.entries[0].runs, }, { createCalls: 0, runs: [], @@ -355,7 +355,7 @@ suite('AgentHostAutomationService', () => { await enableAndCreate(service); const result = await service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'deferred-request', }); @@ -412,7 +412,7 @@ suite('AgentHostAutomationService', () => { )); const result = await service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'hung-request', }); @@ -423,7 +423,7 @@ suite('AgentHostAutomationService', () => { assert.deepStrictEqual({ status: run?.lifecycle.status, error: run?.lifecycle.status === AutomationRunStatus.Failed ? run.lifecycle.error.message : undefined, - removeAvailable: stateManager.getAutomationCatalogState()?.automations[0].operations.includes(AutomationOperation.Remove), + removeAvailable: stateManager.getAutomationCatalogState()?.entries[0].operations.includes(AutomationOperation.Remove), }, { status: AutomationRunStatus.Failed, error: 'Automation run timed out.', @@ -461,7 +461,7 @@ suite('AgentHostAutomationService', () => { }); await enableAndCreate(service); const result = await service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'cancel-request', }); @@ -514,7 +514,7 @@ suite('AgentHostAutomationService', () => { }); await enableAndCreate(service); const result = await service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/review-changes', requestId: 'cancel-failure', }); @@ -540,7 +540,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - automations: [{ + entries: [{ resource: automationResource, definition: scheduledDefinition, nextRunAt: scheduledFor, @@ -584,7 +584,7 @@ suite('AgentHostAutomationService', () => { }); await started.p; - const automation = stateManager.getAutomationCatalogState()?.automations[0]; + const automation = stateManager.getAutomationCatalogState()?.entries[0]; const run = automation?.runs[0]; assert.deepStrictEqual({ origin: run?.origin, @@ -630,7 +630,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - automations: [{ + entries: [{ resource: automationResource, definition: multiTriggerDefinition, nextRunAt: firstScheduledFor, @@ -672,7 +672,7 @@ suite('AgentHostAutomationService', () => { }); await started.p; - const automation = stateManager.getAutomationCatalogState()?.automations[0]; + const automation = stateManager.getAutomationCatalogState()?.entries[0]; const cursors = automation?._meta?.['vscode.scheduleCursors'] as Record | undefined; assert.deepStrictEqual({ runsClaimed: automation?.runs.length, @@ -711,7 +711,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - automations: [{ + entries: [{ resource: automationResource, definition: multiTriggerDefinition, nextRunAt: stale, @@ -754,7 +754,7 @@ suite('AgentHostAutomationService', () => { }); await started.p; - const automation = stateManager.getAutomationCatalogState()?.automations[0]; + const automation = stateManager.getAutomationCatalogState()?.entries[0]; assert.deepStrictEqual({ runsClaimed: automation?.runs.length, claimedTriggerId: automation?.runs[0]?.origin.kind === AutomationRunOriginKind.Trigger ? automation.runs[0].origin.triggerId : undefined, @@ -783,7 +783,7 @@ suite('AgentHostAutomationService', () => { }); storageService.set('automations', { catalog: { - automations: [{ + entries: [{ resource: automationResource, definition: definition(), runs: runs.map(run => ({ @@ -806,22 +806,22 @@ suite('AgentHostAutomationService', () => { const service = createService(); assert.deepStrictEqual({ - count: stateManager.getAutomationCatalogState()?.automations[0].runs.length, - cursor: stateManager.getAutomationCatalogState()?.automations[0].runsNextCursor, + count: stateManager.getAutomationCatalogState()?.entries[0].runs.length, + cursor: stateManager.getAutomationCatalogState()?.entries[0].runsNextCursor, }, { count: 50, cursor: '50', }); await service.fetchAutomationRuns({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: automationResource, cursor: '50', }); assert.deepStrictEqual({ - count: stateManager.getAutomationCatalogState()?.automations[0].runs.length, - cursor: stateManager.getAutomationCatalogState()?.automations[0].runsNextCursor, + count: stateManager.getAutomationCatalogState()?.entries[0].runs.length, + cursor: stateManager.getAutomationCatalogState()?.entries[0].runsNextCursor, }, { count: 51, cursor: undefined, @@ -841,11 +841,11 @@ suite('AgentHostAutomationService', () => { }); await assert.rejects(service.runAutomation({ - channel: 'ahp-automations://catalog', + channel: 'ahp-automations://', automation: 'ahp-automation:/pending-import', requestId: 'pending-request', }), /not available/i); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, ]); }); @@ -867,7 +867,7 @@ suite('AgentHostAutomationService', () => { changes: { _meta: {} }, }); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, AutomationOperation.Remove, AutomationOperation.Run, @@ -888,7 +888,7 @@ suite('AgentHostAutomationService', () => { changes: { _meta: { [AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY]: true } }, }); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, ]); }); @@ -911,7 +911,7 @@ suite('AgentHostAutomationService', () => { await service.completeMigration(); - const automations = stateManager.getAutomationCatalogState()?.automations ?? []; + const automations = stateManager.getAutomationCatalogState()?.entries ?? []; const byResource = new Map(automations.map(automation => [automation.resource, automation.operations])); assert.deepStrictEqual({ pending: byResource.get('ahp-automation:/pending-import'), @@ -944,7 +944,7 @@ suite('AgentHostAutomationService', () => { }); await service.handleConfigurationChanged(); - assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.automations[0].operations, [ + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries[0].operations, [ AutomationOperation.Update, ]); }); @@ -964,7 +964,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - automations: [{ + entries: [{ resource: 'ahp-automation:/pending-scheduled', definition: scheduledDefinition, nextRunAt: scheduledFor, @@ -995,7 +995,7 @@ suite('AgentHostAutomationService', () => { }); await service.completeMigration(); - const automation = stateManager.getAutomationCatalogState()?.automations[0]; + const automation = stateManager.getAutomationCatalogState()?.entries[0]; assert.deepStrictEqual({ createCalls, operations: automation?.operations, @@ -1022,7 +1022,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - automations: [{ + entries: [{ resource: 'ahp-automation:/pending-import', definition: scheduledDefinition, runs: [pendingRun], diff --git a/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts b/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts index 072b0ac75cab5b..365a22314d8741 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts @@ -92,7 +92,7 @@ suite('AgentHostStorageService', () => { try { const service = disposables.add(new AgentHostStorageService(URI.file(path), new NullLogService())); - assert.throws(() => service.set('automations', { catalog: { automations: [] } }), /persisted data could not be loaded/); + assert.throws(() => service.set('automations', { catalog: { entries: [] } }), /persisted data could not be loaded/); await assert.rejects(service.whenIdle(), /persisted data could not be loaded/); assert.deepStrictEqual({ hasLoadError: service.loadError instanceof Error, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 968ca55af8d090..0511df6e9c71e3 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -607,18 +607,19 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString()); }); - test('automation catalogue subscription and run command preserve canonical channels', async () => { - stateManager.setAutomationCatalogState({ automations: [] }); + test('automation catalogue accepts URI-equivalent channels', async () => { + const normalizedCatalogUri = URI.parse(AUTOMATION_CATALOG_URI).toString(); + stateManager.setAutomationCatalogState({ entries: [] }); agentService.automationCapabilities = { create: {}, schedules: {}, runCancellation: {} }; agentService.automationRunResult = { resource: 'ahp-automation-run:/run-1' }; const transport = connectClient('automation-client'); const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'subscribe', { channel: AUTOMATION_CATALOG_URI })); + transport.simulateMessage(request(2, 'subscribe', { channel: normalizedCatalogUri })); const subscription = await responsePromise; const runResponsePromise = waitForResponse(transport, 3); transport.simulateMessage(request(3, 'runAutomation', { - channel: AUTOMATION_CATALOG_URI, + channel: normalizedCatalogUri, automation: 'ahp-automation:/automation-1', requestId: 'request-1', })); @@ -631,21 +632,29 @@ suite('ProtocolServerHandler', () => { response: hasKey(response, { result: true }) ? response.result : undefined, }, { snapshot: { - resource: AUTOMATION_CATALOG_URI, - state: { automations: [] }, + resource: normalizedCatalogUri, + state: { entries: [] }, fromSeq: stateManager.serverSeq, }, requests: [{ - channel: AUTOMATION_CATALOG_URI, + channel: normalizedCatalogUri, automation: 'ahp-automation:/automation-1', requestId: 'request-1', }], response: { resource: 'ahp-automation-run:/run-1' }, }); + + transport.sent.length = 0; + stateManager.dispatchServerAction(AUTOMATION_CATALOG_URI, { + type: ActionType.AutomationRemoved, + resource: 'ahp-automation:/automation-1', + }); + const action = findNotifications(transport.sent, 'action')[0]?.params as ActionEnvelope | undefined; + assert.strictEqual(action?.channel, AUTOMATION_CATALOG_URI); }); test('automation catalogue subscription rejects an inactive client before adding it', async () => { - stateManager.setAutomationCatalogState({ automations: [] }); + stateManager.setAutomationCatalogState({ entries: [] }); let subscriberAdded = false; agentService.addSubscriber = () => subscriberAdded = true; const target = handler as unknown as { diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 62ede1e843e54f..0899eb25b8d6b0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -41,7 +41,7 @@ The contribution also registers the content and working-directory adapters neede ## Automations -Agent Host providers expose Automations through the singleton `ahp-automations://catalog` catalogue when the negotiated host capabilities include `automations`. `AgentHostAutomationStore` projects that authoritative AHP state onto the Sessions automation model; it does not persist definitions or execute a fallback scheduler. `ReconnectableAgentHostAutomationStore` keeps that projection stable across local and remote connection changes and falls back to the legacy store only while the feature is disabled, the host lacks the capability, or migration has not completed. +Agent Host providers expose Automations through the singleton `ahp-automations://` catalogue when the negotiated host capabilities include `automations`. `AgentHostAutomationStore` projects that authoritative AHP state onto the Sessions automation model; it does not persist definitions or execute a fallback scheduler. `ReconnectableAgentHostAutomationStore` keeps that projection stable across local and remote connection changes and falls back to the legacy store only while the feature is disabled, the host lacks the capability, or migration has not completed. Migration imports each legacy definition with canonical `automation/createRequested` actions and waits for authoritative `automation/set` state. Imported definitions identify their initial prompt with `MessageKind.Automation`, preserving automation provenance instead of representing host-triggered execution as a user message. Editor-qualified language-model identifiers are converted to provider-native `ModelSelection.id` values at the AHP boundary while VS Code projection metadata preserves the editor identifier. The host withholds the per-automation `run` operation and rejects execution until every expected resource is present and the durable completion marker is written. Import retries are idempotent and concurrent edits are reconciled before source removal. Failures before a verified item transfer leave its legacy authority intact; failures after transfer retain the durable host definition and archived history for retry. Historical legacy runs are copied to an atomic, read-only local archive before guarded ledger removal because AHP deliberately has no run-history import command. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 69c79ef99c660f..610dabf6bfffef 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -17,8 +17,8 @@ import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationCatalogState, type AutomationDefinition, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationDefinition, type AutomationEntry, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AUTOMATION_CATALOG_URI, isAhpAutomationCatalogChannel, ROOT_STATE_URI, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -44,7 +44,7 @@ export type IAgentHostAutomationConnection = Pick>; + ): IReference>; }; interface ISerializedArchivedRun extends Omit { @@ -68,8 +68,8 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro readonly preservesImportedRunHistory = true; - private readonly _catalogReference: IReference>; - private readonly _catalog: IAgentSubscription; + private readonly _catalogReference: IReference>; + private readonly _catalog: IAgentSubscription; private readonly _catalogChanged; private readonly _ready = observableValue(this, false); private readonly _runsForCache = new Map>(); @@ -113,8 +113,8 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro const catalog = this._catalog.value; if (catalog && !(catalog instanceof Error) && (isAgentHostAutomationCatalogMigrated(catalog) - || catalog.automations.some(automation => automation.operations.includes(AutomationOperation.Run))) - && !catalog.automations.some(automation => isAgentHostLegacyAutomationImportPending(automation.definition)) + || catalog.entries.some(automation => automation.operations.includes(AutomationOperation.Run))) + && !catalog.entries.some(automation => isAgentHostLegacyAutomationImportPending(automation.definition)) && (!this._legacySource || this._legacySource.automations.read(reader).length === 0) && !this._migrationPromise && !this._ready.read(reader)) { @@ -146,15 +146,15 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro getAutomation(id: string): IAutomationDescriptor | undefined { return this._ready.get() - ? this._projectAutomation(this._findAutomationState(id)) - : this._legacySource?.getAutomation(id) ?? this._projectAutomation(this._findAutomationState(id)); + ? this._projectAutomation(this._findAutomationEntry(id)) + : this._legacySource?.getAutomation(id) ?? this._projectAutomation(this._findAutomationEntry(id)); } isSchedulingOwnedByHost(automationId: string): boolean { if (!this._ready.get()) { return false; } - const state = this._findAutomationState(automationId); + const state = this._findAutomationEntry(automationId); return state !== undefined && !isAgentHostLegacyAutomationImportPending(state.definition) && state.operations.includes(AutomationOperation.Run); @@ -238,12 +238,12 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._requireOperation(id, AutomationOperation.Remove); mutationGuard?.(); const resource = automationResource(id); - if (!this._findAutomationState(id)) { + if (!this._findAutomationEntry(id)) { return; } await this._dispatchAndWait( { type: ActionType.AutomationRemoved, resource }, - catalog => !catalog.automations.some(automation => automation.resource === resource), + catalog => !catalog.entries.some(automation => automation.resource === resource), ); this._runsForCache.delete(id); } @@ -253,7 +253,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } private async _importAutomationSnapshot(snapshot: IAutomation, importPending: boolean): Promise { - const existing = this._findAutomationState(snapshot.automation.id); + const existing = this._findAutomationEntry(snapshot.automation.id); if (existing) { const current = this._requireProjectedAutomation(existing); const expected = this._canonicalDescriptor(snapshot.automation, existing); @@ -282,7 +282,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } async upsertAutomationSnapshot(snapshot: IAutomation): Promise { - if (this._findAutomationState(snapshot.automation.id)) { + if (this._findAutomationEntry(snapshot.automation.id)) { await this._replaceDescriptor(snapshot.automation, true, true); } else { await this._createDescriptor(snapshot.automation, true, true); @@ -291,7 +291,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } async removeAutomationSnapshotIfUnchanged(expected: IAutomation): Promise { - const current = this._findAutomationState(expected.automation.id); + const current = this._findAutomationEntry(expected.automation.id); if (!current) { return { kind: 'missing' }; } @@ -305,7 +305,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } async acknowledgeAutomationSnapshotImported(snapshot: IAutomation): Promise { - const current = this._findAutomationState(snapshot.automation.id); + const current = this._findAutomationEntry(snapshot.automation.id); if (!current || !isAgentHostLegacyAutomationImportPending(current.definition)) { return; } @@ -329,10 +329,10 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro automation: automationResource(automationId), requestId: generateUuid(), }); - const catalog = await this._waitForCatalog(state => state.automations.some(automation => automation.runs.some(run => + const catalog = await this._waitForCatalog(state => state.entries.some(automation => automation.runs.some(run => run.resource === result.resource && (run.primarySession !== undefined || isTerminalRun(run)) ))); - const run = catalog.automations.flatMap(automation => automation.runs).find(candidate => candidate.resource === result.resource); + const run = catalog.entries.flatMap(automation => automation.runs).find(candidate => candidate.resource === result.resource); if (!run) { throw new Error(`Automation run did not appear in the authoritative catalogue: ${result.resource}`); } @@ -342,7 +342,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro run: projectedRun, externalDispatch: { sessionResource: projectedRun.sessionResource, - whenCompleted: this._waitForCatalog(state => state.automations.some(automation => automation.runs.some(candidate => + whenCompleted: this._waitForCatalog(state => state.entries.some(automation => automation.runs.some(candidate => candidate.resource === result.resource && isTerminalRun(candidate) )), undefined, null).then(() => undefined), ...(this._connection.initializeResult.get()?.automations?.runCancellation ? { @@ -530,7 +530,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } private async _clearImportPending(automationId: string): Promise { - const current = this._findAutomationState(automationId); + const current = this._findAutomationEntry(automationId); if (!current || !isAgentHostLegacyAutomationImportPending(current.definition)) { return; } @@ -547,7 +547,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return; } const failures: Error[] = []; - const pending = catalog.automations.filter(automation => isAgentHostLegacyAutomationImportPending(automation.definition)); + const pending = catalog.entries.filter(automation => isAgentHostLegacyAutomationImportPending(automation.definition)); for (const automation of pending) { if (this._store.isDisposed) { throw new CancellationError(); @@ -584,7 +584,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro if (!catalog || catalog instanceof Error) { return []; } - return catalog.automations + return catalog.entries .map(automation => this._projectAutomation(automation)) .filter((automation): automation is IAutomationDescriptor => automation !== undefined) .sort((first, second) => second.createdAt.localeCompare(first.createdAt)); @@ -596,7 +596,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro if (!catalog || catalog instanceof Error) { return []; } - return catalog.automations + return catalog.entries .flatMap(automation => automation.runs) .map(run => this._projectRun(run)) .sort((first, second) => second.startedAt.localeCompare(first.startedAt)); @@ -604,11 +604,11 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro // Projects one Agent Host Automation's run summaries into editor-facing runs. private _projectRunsFor(resource: string): IAutomationRun[] { - return this._findAutomationStateByResource(resource)?.runs.map(run => this._projectRun(run)) ?? []; + return this._findAutomationEntryByResource(resource)?.runs.map(run => this._projectRun(run)) ?? []; } // Projects Agent Host Automation state into the editor-facing Automation model. - private _projectAutomation(state: AutomationState | undefined): IAutomationDescriptor | undefined { + private _projectAutomation(state: AutomationEntry | undefined): IAutomationDescriptor | undefined { if (!state) { return undefined; } @@ -683,14 +683,14 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro }; } - private _findAutomationState(id: string): AutomationState | undefined { - return this._findAutomationStateByResource(automationResource(id)); + private _findAutomationEntry(id: string): AutomationEntry | undefined { + return this._findAutomationEntryByResource(automationResource(id)); } - private _findAutomationStateByResource(resource: string): AutomationState | undefined { + private _findAutomationEntryByResource(resource: string): AutomationEntry | undefined { const catalog = this._catalog.value; return catalog && !(catalog instanceof Error) - ? catalog.automations.find(automation => automation.resource === resource) + ? catalog.entries.find(automation => automation.resource === resource) : undefined; } @@ -706,7 +706,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro if (!this._ready.get() && this._legacySource?.getAutomation(id)) { return true; } - return this._findAutomationState(id)?.operations.includes(operation) === true; + return this._findAutomationEntry(id)?.operations.includes(operation) === true; } private _requireOperation(id: string, operation: AutomationOperation): void { @@ -715,7 +715,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } } - private _requireProjectedAutomation(state: AutomationState): IAutomationDescriptor { + private _requireProjectedAutomation(state: AutomationEntry): IAutomationDescriptor { const automation = this._projectAutomation(state); if (!automation) { throw new Error(`Automation cannot be represented by the compatibility view: ${state.resource}`); @@ -723,12 +723,12 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return automation; } - private async _createDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { + private async _createDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { const resource = automationResource(descriptor.id); const definition = this._definitionFromDescriptor(descriptor, undefined, imported, importPending); const state = await this._dispatchAndWait( { type: ActionType.AutomationCreateRequested, resource, definition }, - catalog => catalog.automations.some(automation => automation.resource === resource), + catalog => catalog.entries.some(automation => automation.resource === resource), ); if (!state) { throw new Error(`Automation create completed without authoritative state: ${resource}`); @@ -736,9 +736,9 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return state; } - private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { + private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { const resource = automationResource(descriptor.id); - const current = this._findAutomationState(descriptor.id); + const current = this._findAutomationEntry(descriptor.id); if (!current) { throw new Error(`Automation does not exist: ${descriptor.id}`); } @@ -758,7 +758,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro }, }, catalog => { - const state = catalog.automations.find(automation => automation.resource === resource); + const state = catalog.entries.find(automation => automation.resource === resource); const projected = this._projectAutomation(state); if (projected === undefined || serializeAutomationEditableState(projected) !== serializeAutomationEditableState(expected)) { @@ -848,7 +848,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return prefix && !modelId.startsWith(prefix) ? `${prefix}${modelId}` : modelId; } - private _canonicalDescriptor(descriptor: IAutomationDescriptor, state: AutomationState): IAutomationDescriptor { + private _canonicalDescriptor(descriptor: IAutomationDescriptor, state: AutomationEntry): IAutomationDescriptor { const definition = this._definitionFromDescriptor(descriptor, state.definition); return this._requireProjectedAutomation({ ...state, definition }); } @@ -878,21 +878,21 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro private async _dispatchAndWait( action: Parameters[1] & { readonly resource: string }, - predicate: (catalog: AutomationCatalogState) => boolean, - ): Promise { + predicate: (catalog: AutomationState) => boolean, + ): Promise { await this._waitForCatalog(() => true); const result = this._waitForCatalog(predicate, action); this._connection.dispatch(AUTOMATION_CATALOG_URI, action); const catalog = await result; - const state = catalog.automations.find(automation => automation.resource === action.resource); + const state = catalog.entries.find(automation => automation.resource === action.resource); return state; } private _waitForCatalog( - predicate: (catalog: AutomationCatalogState) => boolean, + predicate: (catalog: AutomationState) => boolean, action?: { readonly type: ActionType; readonly resource: string }, timeoutMs: number | null = MUTATION_TIMEOUT_MS, - ): Promise { + ): Promise { if (this._store.isDisposed) { return Promise.reject(new CancellationError()); } @@ -903,7 +903,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro if (current && predicate(current)) { return Promise.resolve(current); } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const store = new DisposableStore(); const waitId = ++this._pendingWaitIds; let settled = false; @@ -914,7 +914,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro reject(new CancellationError()); } })); - const finish = (result: AutomationCatalogState | Error) => { + const finish = (result: AutomationState | Error) => { if (settled) { return; } @@ -940,7 +940,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } if (action) { store.add(this._connection.onDidAction(envelope => { - if (envelope.channel === AUTOMATION_CATALOG_URI + if (isAhpAutomationCatalogChannel(envelope.channel) && envelope.rejectionReason && envelope.action.type === action.type && hasKey(envelope.action, { resource: true }) diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 247d4d7cc39ed1..40a3a6aac78238 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -17,7 +17,7 @@ import type { IAgentConnection } from '../../../../../../platform/agentHost/comm import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationCatalogState, type AutomationState, type RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationEntry, type AutomationState, type RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -37,9 +37,9 @@ class TestAutomationConnection { private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; - private readonly _onDidCatalogChange = new Emitter(); + private readonly _onDidCatalogChange = new Emitter(); private readonly _onDidRootChange = new Emitter(); - private _catalog: AutomationCatalogState = { automations: [] }; + private _catalog: AutomationState = { entries: [] }; private _root: RootState; private _serverSeq = 0; private _migrationComplete: boolean; @@ -86,7 +86,7 @@ class TestAutomationConnection { kind: StateComponents.AutomationCatalog, resource: URI, _owner: string, - ): IReference> { + ): IReference> { assert.strictEqual(kind, StateComponents.AutomationCatalog); this.subscribedChannel = resource.toString(); const connection = this; @@ -122,7 +122,7 @@ class TestAutomationConnection { createdAt: timestamp, modifiedAt: timestamp, }; - this._catalog = { automations: [...this._catalog.automations, automation] }; + this._catalog = { entries: [...this._catalog.entries, automation] }; this._onDidCatalogChange.fire(this._catalog); this._onDidAction.fire({ channel: AUTOMATION_CATALOG_URI, @@ -134,7 +134,7 @@ class TestAutomationConnection { if (this.updateError) { throw this.updateError; } - const current = this._catalog.automations.find(automation => automation.resource === action.resource); + const current = this._catalog.entries.find(automation => automation.resource === action.resource); if (!current) { throw new Error(`Missing Automation: ${action.resource}`); } @@ -151,7 +151,7 @@ class TestAutomationConnection { modifiedAt: new Date().toISOString(), }; this._catalog = { - automations: this._catalog.automations.map(candidate => candidate.resource === automation.resource ? automation : candidate), + entries: this._catalog.entries.map(candidate => candidate.resource === automation.resource ? automation : candidate), }; this._onDidCatalogChange.fire(this._catalog); this._onDidAction.fire({ @@ -163,14 +163,14 @@ class TestAutomationConnection { } else if (action.type === ActionType.AutomationRemoved) { this._catalog = { ...this._catalog, - automations: this._catalog.automations.filter(automation => automation.resource !== action.resource), + entries: this._catalog.entries.filter(automation => automation.resource !== action.resource), }; this._onDidCatalogChange.fire(this._catalog); } else if (action.type === ActionType.RootConfigChanged && action.config[AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY]) { this._migrationComplete = true; this._catalog = { ...this._catalog, - automations: this._catalog.automations.map(automation => ({ + entries: this._catalog.entries.map(automation => ({ ...automation, operations: automation.definition._meta?.[AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY] ? automation.operations.filter(op => op !== AutomationOperation.Run && op !== AutomationOperation.Remove) @@ -197,7 +197,7 @@ class TestAutomationConnection { } async runAutomation(params: { readonly automation: string }) { - const automation = this._catalog.automations.find(candidate => candidate.resource === params.automation); + const automation = this._catalog.entries.find(candidate => candidate.resource === params.automation); if (!automation) { throw new Error(`Missing Automation: ${params.automation}`); } @@ -216,30 +216,30 @@ class TestAutomationConnection { }; this._catalog = { ...this._catalog, - automations: this._catalog.automations.map(candidate => candidate.resource === updated.resource ? updated : candidate), + entries: this._catalog.entries.map(candidate => candidate.resource === updated.resource ? updated : candidate), }; this._onDidCatalogChange.fire(this._catalog); return { resource }; } setOperations(resource: string, operations: AutomationOperation[]): void { - const current = this._catalog.automations.find(automation => automation.resource === resource); + const current = this._catalog.entries.find(automation => automation.resource === resource); if (!current) { throw new Error(`Missing Automation: ${resource}`); } const automation = { ...current, operations }; this._catalog = { ...this._catalog, - automations: this._catalog.automations.map(candidate => candidate.resource === resource ? automation : candidate), + entries: this._catalog.entries.map(candidate => candidate.resource === resource ? automation : candidate), }; this._onDidCatalogChange.fire(this._catalog); } - setAutomation(automation: AutomationState): void { + setAutomation(automation: AutomationEntry): void { this._catalog = { ...this._catalog, - automations: [ - ...this._catalog.automations.filter(candidate => candidate.resource !== automation.resource), + entries: [ + ...this._catalog.entries.filter(candidate => candidate.resource !== automation.resource), automation, ], }; @@ -250,7 +250,7 @@ class TestAutomationConnection { const timestamp = new Date().toISOString(); this._catalog = { ...this._catalog, - automations: this._catalog.automations.map(automation => ({ + entries: this._catalog.entries.map(automation => ({ ...automation, runs: automation.runs.map(run => run.resource === resource ? { ...run, @@ -388,7 +388,7 @@ suite('AgentHostAutomationStore', () => { enabled: automation.enabled, }, }, { - subscribedChannel: AUTOMATION_CATALOG_URI, + subscribedChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), dispatchChannel: AUTOMATION_CATALOG_URI, definitionMeta: undefined, triggerExpression: '30 9 * * *', @@ -1333,7 +1333,7 @@ suite('AgentHostAutomationStore', () => { subscriptions: connection.subscribedChannel, completionRequests: connection.dispatched.filter(entry => entry.channel === ROOT_STATE_URI).length, }, { - subscriptions: AUTOMATION_CATALOG_URI, + subscriptions: URI.parse(AUTOMATION_CATALOG_URI).toString(), completionRequests: 1, }); }); 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 a49074ff94e3cd..3d0ef5b1937eaa 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 @@ -20,8 +20,8 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSes import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.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 AutomationCatalogState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, 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'; +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, type SessionSummary } 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'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -65,7 +65,7 @@ import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessi const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; -type SubscriptionState = SessionState | ChangesetState | ChatState | AutomationCatalogState; +type SubscriptionState = SessionState | ChangesetState | ChatState | AutomationState; class MockAgentHostService extends mock() { declare readonly _serviceBrand: undefined; @@ -256,8 +256,8 @@ class MockAgentHostService extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); - if (key === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(key)) { - this._sessionStateValues.set(key, { automations: [] }); + if (isAhpAutomationCatalogChannel(key) && !this._sessionStateValues.has(key)) { + this._sessionStateValues.set(key, { entries: [] }); } return this._getSubscription(key); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 7bff9ee7361fad..1b1c667a4b8577 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -17,9 +17,9 @@ import { AgentSession, type IAgentSessionMetadata } from '../../../../../../plat import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { MessageKind, SessionLifecycle, type AgentInfo, type AutomationCatalogState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { MessageKind, SessionLifecycle, type AgentInfo, type AutomationState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AUTOMATION_CATALOG_URI, buildDefaultChatUri, SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildDefaultChatUri, isAhpAutomationCatalogChannel, SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -130,9 +130,9 @@ class MockAgentConnection extends mock() { // ---- Session-state subscriptions --------------------------------------- - private readonly _sessionStateEmitters = new Map>(); + private readonly _sessionStateEmitters = new Map>(); private readonly _sessionStateErrorEmitters = new Map>(); - private readonly _sessionStateValues = new Map(); + private readonly _sessionStateValues = new Map(); public sessionSubscribeCounts = new Map(); public sessionUnsubscribeCounts = new Map(); /** @@ -143,8 +143,8 @@ class MockAgentConnection extends mock() { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const key = resource.toString(); - if (key === AUTOMATION_CATALOG_URI && !this._sessionStateValues.has(key)) { - this._sessionStateValues.set(key, { automations: [] }); + if (isAhpAutomationCatalogChannel(key) && !this._sessionStateValues.has(key)) { + this._sessionStateValues.set(key, { entries: [] }); } return this._getSubscription(key); } @@ -153,7 +153,7 @@ class MockAgentConnection extends mock() { this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1); let emitter = this._sessionStateEmitters.get(key); if (!emitter) { - emitter = new Emitter(); + emitter = new Emitter(); this._sessionStateEmitters.set(key, emitter); } let errorEmitter = this._sessionStateErrorEmitters.get(key); From 00db71ecdce6538a66bb3bc673f104db796002b5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 28 Aug 2026 15:49:11 -0700 Subject: [PATCH 2/3] agent host: preserve AHP compatibility Keeps the AHP 0.9 update compatible with supported hosts and stored data. - Excludes AHP 0.8 from client negotiation because its automation wire shape differs. - Migrates version 1 automation catalogs from automations to entries. - Writes migrated automation storage with version 2. - Adds regression coverage for protocol negotiation and storage migration. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 8 ++- .../node/agentHostAutomationService.ts | 65 +++++++++++++++---- .../agentHostProtocolClient.test.ts | 5 +- .../node/agentHostAutomationService.test.ts | 37 ++++++++++- 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 169c1511b12a0d..b0094f99aae6f4 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -52,6 +52,8 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; +// AHP 0.9 changed the automation catalog wire shape, so VS Code cannot safely negotiate 0.8. +const CLIENT_SUPPORTED_PROTOCOL_VERSIONS = SUPPORTED_PROTOCOL_VERSIONS.filter(version => version !== '0.8.0'); /** Initial delay before the first transport-level reconnect attempt. */ const RECONNECT_INITIAL_DELAY_MS = 1_000; @@ -459,10 +461,10 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect const result = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, - // Advertise every version this client can negotiate, most-preferred first, so an + // Advertise every compatible version, most-preferred first, so an // older host (a cloud sandbox running a 0.5.x `copilotd`) can negotiate down // instead of rejecting the connection. A current host still picks the newest. - protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + protocolVersions: [...CLIENT_SUPPORTED_PROTOCOL_VERSIONS], clientId: this._clientId, clientInfo: this._clientInfo, _meta: this._clientMeta(), @@ -730,7 +732,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._logService.info(`[RemoteAgentHostProtocol] Server forgot client ${this._clientId}; initializing a fresh connection.`); const initializeResult = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, - protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + protocolVersions: [...CLIENT_SUPPORTED_PROTOCOL_VERSIONS], clientId: this._clientId, clientInfo: this._clientInfo, _meta: this._clientMeta(), diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts index 0be268c21a7519..7545efc4867dfd 100644 --- a/src/vs/platform/agentHost/node/agentHostAutomationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -39,7 +39,7 @@ interface IStoredManualRunRequest { } interface IStoredAutomations { - readonly version?: 1; + readonly version: 2; readonly catalog: AutomationState; readonly runs?: readonly AutomationRunState[]; readonly manualRunRequests?: readonly IStoredManualRunRequest[]; @@ -412,15 +412,16 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._logService.error('[AgentHostAutomationService] Agent Host storage failed to load; automation state and execution remain unavailable.'); return undefined; } - const stored = this._storageService.get(STORAGE_KEY); + const stored = this._storageService.get(STORAGE_KEY); if (stored === undefined) { - return { catalog: { entries: [] } }; + return { version: 2, catalog: { entries: [] } }; } - if (!isStoredAutomations(stored)) { + const normalized = normalizeStoredAutomations(stored); + if (!normalized) { this._logService.error('[AgentHostAutomationService] Automation storage is invalid; automation execution remains unavailable until it is recovered.'); return undefined; } - return stored; + return normalized; } private async _persist( @@ -430,7 +431,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost migrationCompletedAt = this._migrationCompletedAt, ): Promise { await this._storageService.setAndFlush(STORAGE_KEY, { - version: 1, + version: 2, catalog, runs: [...runs.values()], manualRunRequests: [...manualRunRequests.values()], @@ -944,16 +945,54 @@ function isAutomationState(value: unknown): value is AutomationState { return Array.isArray(entries) && entries.every(isAutomationEntry); } -function isStoredAutomations(value: unknown): value is IStoredAutomations { +function normalizeStoredAutomations(value: unknown): IStoredAutomations | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; + return undefined; } const stored = value as Record; - return (stored['version'] === undefined || stored['version'] === 1) - && isAutomationState(stored['catalog']) - && (stored['runs'] === undefined || Array.isArray(stored['runs']) && stored['runs'].every(isAutomationRunState)) - && (stored['manualRunRequests'] === undefined || Array.isArray(stored['manualRunRequests']) && stored['manualRunRequests'].every(isStoredManualRunRequest)) - && (stored['migration'] === undefined || isCompletedMigration(stored['migration'])); + const version = stored['version']; + const catalog = version === 2 + ? isAutomationState(stored['catalog']) ? stored['catalog'] : undefined + : version === undefined || version === 1 + ? normalizeLegacyAutomationState(stored['catalog']) + : undefined; + const runs = stored['runs']; + const manualRunRequests = stored['manualRunRequests']; + const migration = stored['migration']; + if (!catalog + || runs !== undefined && (!Array.isArray(runs) || !runs.every(isAutomationRunState)) + || manualRunRequests !== undefined && (!Array.isArray(manualRunRequests) || !manualRunRequests.every(isStoredManualRunRequest)) + || migration !== undefined && !isCompletedMigration(migration)) { + return undefined; + } + return { + version: 2, + catalog, + ...(runs !== undefined ? { runs } : {}), + ...(manualRunRequests !== undefined ? { manualRunRequests } : {}), + ...(migration !== undefined ? { migration } : {}), + }; +} + +function normalizeLegacyAutomationState(value: unknown): AutomationState | undefined { + if (isAutomationState(value)) { + return value; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const catalog = value as Record; + const automations = catalog['automations']; + const meta = catalog['_meta']; + if (!Array.isArray(automations) + || !automations.every(isAutomationEntry) + || meta !== undefined && (!meta || typeof meta !== 'object' || Array.isArray(meta))) { + return undefined; + } + return { + entries: automations, + ...(meta !== undefined ? { _meta: meta as Record } : {}), + }; } function isAutomationEntry(value: unknown): value is AutomationEntry { diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index ab7cb0865d2cf7..828d6a8734c4ed 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -1014,9 +1014,9 @@ suite('AgentHostProtocolClient', () => { clientInfo: params.clientInfo, _meta: params._meta, }, { - // Every negotiable version is offered so an older host can negotiate down, + // Every compatible version is offered so an older host can negotiate down, // newest first so a current host still picks it. - protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + protocolVersions: SUPPORTED_PROTOCOL_VERSIONS.filter(version => version !== '0.8.0'), clientId: 'renderer-client-id', clientInfo, _meta: { @@ -1027,6 +1027,7 @@ suite('AgentHostProtocolClient', () => { }, }); assert.strictEqual(params.protocolVersions[0], PROTOCOL_VERSION); + assert.ok(!params.protocolVersions.includes('0.8.0')); // Reply with a successful handshake so `connect()` resolves and the // test can finish cleanly. diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts index 2d29c0814ac0cd..382a52dc14c864 100644 --- a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -113,7 +113,7 @@ suite('AgentHostAutomationService', () => { test('a future host automation storage version disables the capability without rewriting data', async () => { storageService.set('automations', { - version: 2, + version: 3, catalog: { entries: [] }, }); await storageService.whenIdle(); @@ -127,7 +127,40 @@ suite('AgentHostAutomationService', () => { }, { isAvailable: false, capabilities: undefined, - storedVersion: 2, + storedVersion: 3, + }); + }); + + test('version 1 automation storage is migrated from automations to entries', async () => { + const resource = 'ahp-automation:/review-changes'; + storageService.set('automations', { + version: 1, + catalog: { + automations: [{ + resource, + definition: definition(), + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove], + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + }], + }, + }); + await storageService.whenIdle(); + const service = createService(); + + assert.deepStrictEqual(stateManager.getAutomationCatalogState()?.entries.map(entry => entry.resource), [resource]); + + await service.completeMigration([resource]); + const stored = storageService.get<{ version: number; catalog: { entries?: unknown[]; automations?: unknown[] } }>('automations'); + assert.deepStrictEqual({ + version: stored?.version, + entryCount: stored?.catalog.entries?.length, + hasAutomations: Object.hasOwn(stored?.catalog ?? {}, 'automations'), + }, { + version: 2, + entryCount: 1, + hasAutomations: false, }); }); From af897bcd3b285fa006f55d209f752dd1878285c0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 28 Aug 2026 15:56:50 -0700 Subject: [PATCH 3/3] agent host: keep automation storage at version 1 Preserves the existing durable automation format while adopting the AHP 0.9 runtime model. - Keeps stored catalogs in the version 1 automations collection. - Maps stored automations to protocol entries when the service loads. - Maps protocol entries back to automations before persistence. - Updates storage fixtures to verify the stable format. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostAutomationService.ts | 97 +++++++------------ .../node/agentHostAutomationService.test.ts | 30 +++--- 2 files changed, 52 insertions(+), 75 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts index 7545efc4867dfd..6e98e27f32ebff 100644 --- a/src/vs/platform/agentHost/node/agentHostAutomationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -38,9 +38,14 @@ interface IStoredManualRunRequest { readonly run: string; } +interface IStoredAutomationCatalog { + readonly automations: readonly AutomationEntry[]; + readonly _meta?: Record; +} + interface IStoredAutomations { - readonly version: 2; - readonly catalog: AutomationState; + readonly version?: 1; + readonly catalog: IStoredAutomationCatalog; readonly runs?: readonly AutomationRunState[]; readonly manualRunRequests?: readonly IStoredManualRunRequest[]; readonly migration?: { @@ -102,9 +107,13 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._migrationCompletedAt = stored?.migration?.completedAt; this._runs = new Map(stored?.runs?.map(run => [run.resource, run])); this._catalog = stored?.catalog ? { - ...stored.catalog, - entries: stored.catalog.entries.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), - ...(this._migrationCompletedAt ? { _meta: { ...stored.catalog._meta, [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } } : {}), + entries: stored.catalog.automations.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), + ...(stored.catalog._meta || this._migrationCompletedAt ? { + _meta: { + ...stored.catalog._meta, + ...(this._migrationCompletedAt ? { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } : {}), + }, + } : {}), } : undefined; this._manualRunRequests = new Map(stored?.manualRunRequests?.map(request => [request.requestId, request])); if (this._catalog) { @@ -412,16 +421,15 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._logService.error('[AgentHostAutomationService] Agent Host storage failed to load; automation state and execution remain unavailable.'); return undefined; } - const stored = this._storageService.get(STORAGE_KEY); + const stored = this._storageService.get(STORAGE_KEY); if (stored === undefined) { - return { version: 2, catalog: { entries: [] } }; + return { catalog: { automations: [] } }; } - const normalized = normalizeStoredAutomations(stored); - if (!normalized) { + if (!isStoredAutomations(stored)) { this._logService.error('[AgentHostAutomationService] Automation storage is invalid; automation execution remains unavailable until it is recovered.'); return undefined; } - return normalized; + return stored; } private async _persist( @@ -431,8 +439,11 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost migrationCompletedAt = this._migrationCompletedAt, ): Promise { await this._storageService.setAndFlush(STORAGE_KEY, { - version: 2, - catalog, + version: 1, + catalog: { + automations: catalog.entries, + ...(catalog._meta ? { _meta: catalog._meta } : {}), + }, runs: [...runs.values()], manualRunRequests: [...manualRunRequests.values()], ...(migrationCompletedAt ? { migration: { status: 'complete', completedAt: migrationCompletedAt } } : {}), @@ -937,62 +948,28 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost private readonly _log = (message: string) => this._logService.warn(`[AgentHostAutomationService] ${message}`); } -function isAutomationState(value: unknown): value is AutomationState { +function isStoredAutomationCatalog(value: unknown): value is IStoredAutomationCatalog { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; } - const entries = (value as Record)['entries']; - return Array.isArray(entries) && entries.every(isAutomationEntry); + const catalog = value as Record; + const automations = catalog['automations']; + const meta = catalog['_meta']; + return Array.isArray(automations) + && automations.every(isAutomationEntry) + && (meta === undefined || !!meta && typeof meta === 'object' && !Array.isArray(meta)); } -function normalizeStoredAutomations(value: unknown): IStoredAutomations | undefined { +function isStoredAutomations(value: unknown): value is IStoredAutomations { if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; + return false; } const stored = value as Record; - const version = stored['version']; - const catalog = version === 2 - ? isAutomationState(stored['catalog']) ? stored['catalog'] : undefined - : version === undefined || version === 1 - ? normalizeLegacyAutomationState(stored['catalog']) - : undefined; - const runs = stored['runs']; - const manualRunRequests = stored['manualRunRequests']; - const migration = stored['migration']; - if (!catalog - || runs !== undefined && (!Array.isArray(runs) || !runs.every(isAutomationRunState)) - || manualRunRequests !== undefined && (!Array.isArray(manualRunRequests) || !manualRunRequests.every(isStoredManualRunRequest)) - || migration !== undefined && !isCompletedMigration(migration)) { - return undefined; - } - return { - version: 2, - catalog, - ...(runs !== undefined ? { runs } : {}), - ...(manualRunRequests !== undefined ? { manualRunRequests } : {}), - ...(migration !== undefined ? { migration } : {}), - }; -} - -function normalizeLegacyAutomationState(value: unknown): AutomationState | undefined { - if (isAutomationState(value)) { - return value; - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - const catalog = value as Record; - const automations = catalog['automations']; - const meta = catalog['_meta']; - if (!Array.isArray(automations) - || !automations.every(isAutomationEntry) - || meta !== undefined && (!meta || typeof meta !== 'object' || Array.isArray(meta))) { - return undefined; - } - return { - entries: automations, - ...(meta !== undefined ? { _meta: meta as Record } : {}), - }; + return (stored['version'] === undefined || stored['version'] === 1) + && isStoredAutomationCatalog(stored['catalog']) + && (stored['runs'] === undefined || Array.isArray(stored['runs']) && stored['runs'].every(isAutomationRunState)) + && (stored['manualRunRequests'] === undefined || Array.isArray(stored['manualRunRequests']) && stored['manualRunRequests'].every(isStoredManualRunRequest)) + && (stored['migration'] === undefined || isCompletedMigration(stored['migration'])); } function isAutomationEntry(value: unknown): value is AutomationEntry { diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts index 382a52dc14c864..49033b78af4cea 100644 --- a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -113,8 +113,8 @@ suite('AgentHostAutomationService', () => { test('a future host automation storage version disables the capability without rewriting data', async () => { storageService.set('automations', { - version: 3, - catalog: { entries: [] }, + version: 2, + catalog: { automations: [] }, }); await storageService.whenIdle(); const service = createService(); @@ -127,11 +127,11 @@ suite('AgentHostAutomationService', () => { }, { isAvailable: false, capabilities: undefined, - storedVersion: 3, + storedVersion: 2, }); }); - test('version 1 automation storage is migrated from automations to entries', async () => { + test('version 1 automation storage maps automations to protocol entries', async () => { const resource = 'ahp-automation:/review-changes'; storageService.set('automations', { version: 1, @@ -155,12 +155,12 @@ suite('AgentHostAutomationService', () => { const stored = storageService.get<{ version: number; catalog: { entries?: unknown[]; automations?: unknown[] } }>('automations'); assert.deepStrictEqual({ version: stored?.version, - entryCount: stored?.catalog.entries?.length, - hasAutomations: Object.hasOwn(stored?.catalog ?? {}, 'automations'), + automationCount: stored?.catalog.automations?.length, + hasEntries: Object.hasOwn(stored?.catalog ?? {}, 'entries'), }, { - version: 2, - entryCount: 1, - hasAutomations: false, + version: 1, + automationCount: 1, + hasEntries: false, }); }); @@ -573,7 +573,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - entries: [{ + automations: [{ resource: automationResource, definition: scheduledDefinition, nextRunAt: scheduledFor, @@ -663,7 +663,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - entries: [{ + automations: [{ resource: automationResource, definition: multiTriggerDefinition, nextRunAt: firstScheduledFor, @@ -744,7 +744,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - entries: [{ + automations: [{ resource: automationResource, definition: multiTriggerDefinition, nextRunAt: stale, @@ -816,7 +816,7 @@ suite('AgentHostAutomationService', () => { }); storageService.set('automations', { catalog: { - entries: [{ + automations: [{ resource: automationResource, definition: definition(), runs: runs.map(run => ({ @@ -997,7 +997,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - entries: [{ + automations: [{ resource: 'ahp-automation:/pending-scheduled', definition: scheduledDefinition, nextRunAt: scheduledFor, @@ -1055,7 +1055,7 @@ suite('AgentHostAutomationService', () => { }; storageService.set('automations', { catalog: { - entries: [{ + automations: [{ resource: 'ahp-automation:/pending-import', definition: scheduledDefinition, runs: [pendingRun],