From 5f16d81bb7045b66d7bc768d244feab75002d943 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 2 Oct 2026 15:55:20 -0700 Subject: [PATCH 1/2] release: AHP 1.0.0 Advertise the 1.0.0 and 0.9.0 compatibility baselines and provide host-side highest compatible SemVer negotiation across all SDKs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 27 +- clients/dotnet/CHANGELOG.md | 485 ++---------------- clients/dotnet/VERSION | 2 +- clients/dotnet/release-metadata.json | 11 +- .../Generated/Commands.generated.cs | 4 +- .../Generated/Version.generated.cs | 61 ++- .../AgentHostProtocol.AotSmoke/Program.cs | 4 +- .../AgentHostProtocol.Tests/ClientTests.cs | 13 +- .../HostStreamDropTagTests.cs | 2 +- .../AgentHostProtocol.Tests/HostsTests.cs | 4 +- .../MultiHostClientTests.cs | 8 +- .../AgentHostProtocol.Tests/TelemetryTests.cs | 2 +- .../TypesRoundTripFixtures.cs | 2 +- .../VersionNegotiationTests.cs | 36 ++ .../interop/session-host.ts | 26 +- clients/go/CHANGELOG.md | 29 ++ clients/go/VERSION | 2 +- clients/go/ahp/version_negotiation_test.go | 44 ++ clients/go/ahptypes/ahptypes_test.go | 4 +- clients/go/ahptypes/commands.generated.go | 4 +- clients/go/ahptypes/roundtrip_fixture_test.go | 5 +- clients/go/ahptypes/version.generated.go | 83 ++- clients/go/release-metadata.json | 11 +- clients/kotlin/CHANGELOG.md | 29 ++ clients/kotlin/gradle.properties | 2 +- clients/kotlin/release-metadata.json | 11 +- .../generated/Commands.generated.kt | 4 +- .../generated/Version.generated.kt | 49 +- .../VersionNegotiationTest.kt | 32 ++ clients/rust/CHANGELOG.md | 33 ++ clients/rust/Cargo.lock | 6 +- clients/rust/Cargo.toml | 6 +- clients/rust/crates/ahp-types/src/commands.rs | 4 +- clients/rust/crates/ahp-types/src/lib.rs | 2 +- clients/rust/crates/ahp-types/src/version.rs | 51 +- .../ahp-types/tests/roundtrip_corpus.rs | 6 +- .../ahp-types/tests/version_negotiation.rs | 31 ++ clients/rust/release-metadata.json | 11 +- .../Generated/Commands.generated.swift | 4 +- .../Generated/Version.generated.swift | 48 +- .../TypesRoundTripFixtureTests.swift | 6 +- .../VersionNegotiationTests.swift | 26 + clients/swift/CHANGELOG.md | 29 ++ clients/swift/VERSION | 2 +- clients/swift/release-metadata.json | 11 +- clients/typescript/CHANGELOG.md | 30 ++ clients/typescript/package-lock.json | 4 +- clients/typescript/package.json | 2 +- clients/typescript/release-metadata.json | 11 +- .../typescript/test/types-round-trip.test.ts | 6 +- ...0826-typescript-managed-subscriptions.json | 5 - ...dotnet-typescript-session-conformance.json | 5 - .../20260901-authenticate-token-lifetime.json | 4 - .../20260907-terminal-full-output.json | 4 - docs/.changes/20260914-typed-file-edits.json | 4 - ...20260915-session-summary-chat-catalog.json | 4 - .../20260920-chat-archive-action.json | 4 - .../20260921-per-chat-changesets.json | 4 - ...0260922-automation-disable-conditions.json | 4 - ...20260922-changeset-recomputing-status.json | 4 - .../20260923-config-array-cardinality.json | 5 - ...0260923-dotnet-discriminator-defaults.json | 6 - ...60923-dotnet-open-enum-forward-compat.json | 6 - ...et-union-discriminator-forward-compat.json | 6 - ...60923-mcp-server-background-requested.json | 4 - .../20260924-automation-customizations.json | 4 - docs/.changes/20260924-chat-canvases.json | 4 - docs/.changes/20260924-chat-move.json | 4 - .../20260924-rust-client-plugin-type.json | 5 - .../20260925-dotnet-chat-move-unknown.json | 5 - .../20260929-chat-background-work.json | 4 - .../20260929-chat-changes-summary.json | 4 - ...0260929-session-chat-summary-archived.json | 4 - .../20261001-chat-read-state-action.json | 4 - ...20261001-session-chat-summary-changes.json | 4 - .../20261002-dotnet-strong-name-signing.json | 5 - docs/specification/lifecycle.md | 2 +- docs/specification/versioning.md | 10 +- schema/commands.schema.json | 2 +- schema/errors.schema.json | 2 +- scripts/generate-csharp.ts | 52 +- scripts/generate-go.ts | 60 ++- scripts/generate-kotlin.ts | 43 +- scripts/generate-rust.ts | 37 +- scripts/generate-swift.ts | 42 +- scripts/read-protocol-versions.ts | 11 +- types/common/commands.ts | 4 +- types/test-cases/version-negotiation.json | 24 + types/version/registry.test.ts | 20 +- types/version/registry.ts | 46 +- 90 files changed, 1039 insertions(+), 757 deletions(-) create mode 100644 clients/dotnet/tests/AgentHostProtocol.Tests/VersionNegotiationTests.cs create mode 100644 clients/go/ahp/version_negotiation_test.go create mode 100644 clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/VersionNegotiationTest.kt create mode 100644 clients/rust/crates/ahp-types/tests/version_negotiation.rs create mode 100644 clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/VersionNegotiationTests.swift delete mode 100644 docs/.changes/20260826-typescript-managed-subscriptions.json delete mode 100644 docs/.changes/20260827-dotnet-typescript-session-conformance.json delete mode 100644 docs/.changes/20260901-authenticate-token-lifetime.json delete mode 100644 docs/.changes/20260907-terminal-full-output.json delete mode 100644 docs/.changes/20260914-typed-file-edits.json delete mode 100644 docs/.changes/20260915-session-summary-chat-catalog.json delete mode 100644 docs/.changes/20260920-chat-archive-action.json delete mode 100644 docs/.changes/20260921-per-chat-changesets.json delete mode 100644 docs/.changes/20260922-automation-disable-conditions.json delete mode 100644 docs/.changes/20260922-changeset-recomputing-status.json delete mode 100644 docs/.changes/20260923-config-array-cardinality.json delete mode 100644 docs/.changes/20260923-dotnet-discriminator-defaults.json delete mode 100644 docs/.changes/20260923-dotnet-open-enum-forward-compat.json delete mode 100644 docs/.changes/20260923-dotnet-union-discriminator-forward-compat.json delete mode 100644 docs/.changes/20260923-mcp-server-background-requested.json delete mode 100644 docs/.changes/20260924-automation-customizations.json delete mode 100644 docs/.changes/20260924-chat-canvases.json delete mode 100644 docs/.changes/20260924-chat-move.json delete mode 100644 docs/.changes/20260924-rust-client-plugin-type.json delete mode 100644 docs/.changes/20260925-dotnet-chat-move-unknown.json delete mode 100644 docs/.changes/20260929-chat-background-work.json delete mode 100644 docs/.changes/20260929-chat-changes-summary.json delete mode 100644 docs/.changes/20260929-session-chat-summary-archived.json delete mode 100644 docs/.changes/20261001-chat-read-state-action.json delete mode 100644 docs/.changes/20261001-session-chat-summary-changes.json delete mode 100644 docs/.changes/20261002-dotnet-strong-name-signing.json create mode 100644 types/test-cases/version-negotiation.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e66229a0..ce2012008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,13 +23,34 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). -## [0.10.0] — Unreleased +## [1.0.0] — 2026-10-02 -Spec version: `0.10.0` +Spec version: `1.0.0` ### Added -- Chat-owned canvas references and experimental per-canvas channels for live presentation state and source URLs. +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. + +### Changed + +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. ## [0.9.0] — 2026-08-28 diff --git a/clients/dotnet/CHANGELOG.md b/clients/dotnet/CHANGELOG.md index 3752108c7..80f4ac939 100644 --- a/clients/dotnet/CHANGELOG.md +++ b/clients/dotnet/CHANGELOG.md @@ -12,470 +12,43 @@ this release negotiates. ## [Unreleased] -### Added +## [1.0.0] — 2026-10-02 -- **Multiroot working directories** (upstream - microsoft/agent-host-protocol#337): the session-level `WorkingDirectories` set - (`SessionState`, `SessionSummary`) and each chat's subset of it (`ChatState`, - `ChatSummary`), plus the `MultipleWorkingDirectoriesCapability` - (`ImmutablePrimary`, which pins `WorkingDirectories[0]` as a fixed primary - root) that gates the whole feature. Four new reducer paths implement - membership semantics keyed by the - directory URI: `session/workingDirectorySet` / - `session/workingDirectoryRemoved` and `chat/workingDirectorySet` / - `chat/workingDirectoryRemoved` each append when absent (creating the set) and - no-op when already present — and, on removal, no-op when the set is absent or - does not contain the directory. All four are idempotent, matching the canonical - TypeScript reducer; an emptied set stays present as an empty list, and a chat - removal never touches the owning session's set. -- **Side chats** (upstream microsoft/agent-host-protocol#334): the - `ChatSource` union (`ForkChatSource` / `SideChatSource`, discriminated on - `Kind` via the new `ChatSourceKind` enum) carried by `CreateChatParams.Source`, - the `sideChat` `ChatOrigin` variant (`ChatOriginSideChat`, with a stable - top-level `TurnId` and an optional immutable `SideChatSelection` snapshot), and - the `chat` message attachment (`MessageChatAttachment`, referencing a bounded - transcript from another chat by `Resource` + `EndTurn`). Replaces the former - `ChatForkSource`, which upstream renamed to `ForkChatSource`. -- **Pre-registered OAuth client on MCP auth requirements** (upstream - microsoft/agent-host-protocol#346): the optional `OauthClient` field on the - shared `McpAuthRequirement` (and thus `McpServerAuthRequiredState` and, via its - `Auth` challenge, `ToolCallAuthRequiredState`), carrying the new `McpOAuthClient` - type (`ClientId`, optional `ClientSecret`). When present, a client uses these - credentials instead of dynamic client registration; an absent `ClientSecret` - marks a public client that uses a secretless flow such as authorization code - with PKCE. Additive and wire-compatible. -- **Protocol-level `ping`** (upstream microsoft/agent-host-protocol#344): - `IAhpClient.PingAsync` sends the connection-level `ping` command — scoped to the - root channel, carrying no payload in either direction — and completes when the - server responds, verifying the connection is alive and keeping idle-timeout - intermediaries from closing it. Distinct from the transport-level WebSocket - keep-alive frames exposed via `IKeepAliveTransport`. -- **MCP tool call authentication** (upstream microsoft/agent-host-protocol#336): - the new `auth-required` tool call status and its `ToolCallAuthRequiredState`, - reached when the MCP server backing an in-flight call challenges for auth - mid-execution (typically `insufficientScope` step-up). `chat/toolCallAuthRequired` - moves a `running` call to `auth-required` carrying the `McpAuthRequirement` - challenge, and `chat/toolCallAuthResolved` returns it to `running` once the - client has pushed a token via `authenticate` — preserving the confirmation - context and any partial content across the pause. The challenge only applies to - MCP-contributed calls, so `ToolCallAuthRequiredState.Contributor` is narrowed to - `ToolCallMcpContributor` and the reducer ignores the action for any other - contributor. A client MAY cancel instead of authenticating by completing with a - **failed** result, which always finishes terminally (`requiresResultConfirmation` - is ignored on that path); a **successful** result from `auth-required` is invalid - and is ignored. A blocked call reports `InputNeeded` at the session level, and - `session/inputNeededSet` surfaces it as the new `toolAuthentication` request kind - (`SessionToolAuthenticationRequest`). `McpAuthRequirement` is now shared with - `McpServerAuthRequiredState`, and `AuthenticateParams` gains an optional - `Scopes` so a server can tell whether a challenge is satisfied without decoding - the token. -- **Model-judge risk assessment on tool calls** (upstream - microsoft/agent-host-protocol#333): `ToolCallRiskAssessment` (a `loading` / - `complete` union carrying the judge's `Reason` and normalized `Safety` score) on - `ChatToolCallReadyAction` and `ToolCallPendingConfirmationState`. Because the - judge resolves asynchronously, `chat/toolCallReady` is now also accepted for a - tool call already in `pending-confirmation`: the follow-up updates the fields it - carries and leaves the rest of the confirmation context intact rather than - clearing it. -- **Input requests are live response parts** (upstream - microsoft/agent-host-protocol#324 and #338): the new `InputRequestResponsePart` - and the `inputRequest` `ResponsePart` variant. `chat/inputRequested` inserts the - part *unresolved* (`Response` absent) into the active turn's `ResponseParts`; - `chat/inputAnswerChanged` syncs answer drafts onto it; and `chat/inputCompleted` - resolves that same part **in place**, setting `Response` (`accept`, `decline`, or - `cancel`) and the final merged `Answers`. Because the part is never moved, the - request's position in the response stream is the position it was asked at, and the - whole "asked X → answered Y" interaction is durable and backfillable via - `fetchTurns`. If a turn ends with a request still unresolved, the part remains in - the completed turn's transcript with `Response` absent. -- **Resource convenience methods** (upstream microsoft/agent-host-protocol#321): - ten typed wrappers over the symmetrical `resource*` command family — - `ResourceReadAsync`, `ResourceWriteAsync`, `ResourceListAsync`, - `ResourceCopyAsync`, `ResourceDeleteAsync`, `ResourceMoveAsync`, - `ResourceResolveAsync`, `ResourceMkdirAsync`, `ResourceRequestAsync`, and - `CreateResourceWatchAsync` — each forcing the channel to the root resource URI - so callers omit it. For the reverse (server-initiated) direction, the typed - `ResourceRequestHandlers` registry plus `SetResourceRequestHandlers` layers a - per-method handler set over the existing generic server-request handler; an - unset method is reported to the peer as `MethodNotFound`. Client-only - ergonomics — no protocol change. -- **Terminal-command prefix marker** (upstream microsoft/agent-host-protocol#313): - `InitializeResult.TerminalCommandPrefix` (optional `string?`) lets a host - advertise the prefix (conventionally `"!"`) that marks a user message as a - shorthand terminal command. -- **Uniform provider metadata on customizations** (upstream microsoft/agent-host-protocol#318): - the optional `_meta` provider-metadata slot is hoisted onto every customization - type (`PluginCustomization`, `ClientPluginCustomization`, - `DirectoryCustomization`, `SkillCustomization`, `PromptCustomization`, - `RuleCustomization`, `HookCustomization`, `McpServerCustomization`), not only - `AgentCustomization`. Additive and wire-compatible. -- **MCP server start/stop requests** (upstream microsoft/agent-host-protocol#319): - the client-dispatchable `session/mcpServerStartRequested` and - `session/mcpServerStopRequested` actions (`SessionMcpServerStartRequestedAction` - / `SessionMcpServerStopRequestedAction`, each carrying the target `Id`) let a - client request that the host start/restart or stop an existing - `McpServerCustomization`. The reducer locates the entry by id — searching the - top-level customization list and every container's `children` — and - optimistically moves it to `starting` / `stopped`, clearing any previous - `channel`; the host stays authoritative. Both are no-ops when no matching MCP - server is found. The `session/mcpServerStateChanged` reducer now shares the same - `UpdateMcpServerCustomization` traversal helper. -- **Terminal-command completion metadata** (upstream microsoft/agent-host-protocol#314): - `ToolResultTerminalCompleteContent` (wire `type: "terminalComplete"`) records a - terminal-style tool command's exit — optional `Resource` (the `ahp-terminal:` - channel that carried live output), `ExitCode`, `Cwd`, `Preview`, and - `Truncated` — as a new variant of the `ToolResultContent` union. -- **Plugin version provenance** (upstream microsoft/agent-host-protocol#317): - `PluginCustomization.Version` (optional `string?`) carries the semver from the - Open Plugins manifest's optional `version` field, for display/provenance only. -- **System-notification metadata slot** (upstream microsoft/agent-host-protocol#308): - `SystemNotificationResponsePart.Meta` (optional `Dictionary?`, - wire `_meta`) lets a host attach a machine-readable descriptor of what triggered - a harness notification so clients can categorize, icon, group, filter, or - localize it without string-matching `Content`. Additive and optional; clients - render coherently from `Content` alone when it is absent. -- **Changeset review as a first-class capability** (upstream microsoft/agent-host-protocol#328): - a changeset advertises review support up-front via `Changeset.Capabilities` - (`ChangesetCapabilities`, whose presence-flag `Review` mirrors the MCP-capability - convention). `ChangesetFile.Reviewed` (optional `bool?`) is the GitHub-style - "Viewed" flag; absent is equivalent to not-yet-reviewed. It is toggled by the - `changeset/filesReviewChanged` action (`ChangesetFilesReviewChangedAction`, - carrying `Files` and `Reviewed`) — which supersedes the earlier server-only - `changeset/filesReviewedChanged` (field renamed `fileIds` → `files`) and is now - the first and only **client-dispatchable** `changeset/*` action, so a reviewer - can toggle review state optimistically through the write-ahead reducer. The - reducer flips `Reviewed` on each matching file and is a no-op when no listed file - needs changing (already in the target state, or an id that matches no current - file). -- **Agent-advertised capabilities** (upstream microsoft/agent-host-protocol#292): - `AgentInfo.Capabilities` (an optional `AgentCapabilities`) lets an agent - advertise static capabilities so clients gate features by capability instead of - by provider id. The first capability is `MultipleChats` (a - `MultipleChatsCapability`): its presence advertises concurrent chats per - session, and its optional `Fork` flag additionally allows forking a chat from a - turn. Presence semantics follow MCP capabilities — an empty object advertises - support, absence means the feature is unsupported. -- **Advisory subscription delivery preferences** (upstream microsoft/agent-host-protocol#293): - `SubscribeAsync` gains an optional `SubscriptionDeliveryOptions delivery` - argument threaded onto `SubscribeParams.Delivery`. Its `MaxLatencyMs` bounds how - long a server may intentionally delay delivery while buffering/coalescing - high-frequency updates (`0` requests immediate, un-coalesced delivery). Omit it - for the server's default behavior. -- **Cursor-based `listSessions` pagination** (upstream microsoft/agent-host-protocol#295): - `ListSessionsParams` gains opaque `Limit`/`Cursor` inputs and `ListSessionsResult` - gains a `NextCursor` output so a large session catalogue can be fetched - incrementally, most-recently-modified first. Pagination is fully additive — - omitting `Limit`/`Cursor` and ignoring `NextCursor` preserves the single-page - behavior. The previously-unused `ListSessionsParams.Filter` field is removed. -- **Session-level `inputNeeded` aggregate** (upstream microsoft/agent-host-protocol#265): - `SessionState.InputNeeded` surfaces every outstanding input request a session - is blocked on, aggregated across all chats so a client can discover and answer - it from the session channel alone. Each entry is a `SessionInputRequest` - discriminated union — `SessionChatInputRequest` (a mirrored chat elicitation), - `SessionToolConfirmationRequest` (a tool call awaiting confirmation), or - `SessionToolClientExecutionRequest` (a running tool delegated to a client) — - carrying the owning chat URI plus every id needed to respond. The host upserts - entries with the new `session/inputNeededSet` action (`SessionInputNeededSetAction`) - and removes them with `session/inputNeededRemoved` (`SessionInputNeededRemovedAction`); - the session reducer keys both by the entry `id` and reflects a non-empty queue - as `SessionStatus.InputNeeded`. -- **First-class `model` and `tools` on custom agents** (upstream microsoft/agent-host-protocol#289): - `AgentCustomization` gains optional `Model` (the model the agent is pinned to) - and `Tools` (an allowlist of tool names the agent is scoped to), sourced from - the agent file's frontmatter. -- **Optional tool invocation intention** (upstream microsoft/agent-host-protocol#283): - `ToolCallBase` (every `ToolCall*State`) and `ChatToolCallStartAction` gain an - optional `Intention` (`intention?`) — a human-readable description of what the - tool invocation intends to do. The chat reducer sets it on tool-call-start and - carries it through every tool-call state transition. -- **Chat draft + activity actions** (upstream AHP 0.5.0, microsoft/agent-host-protocol#264): - `ChatDraftChangedAction` (`chat/draftChanged`, client-dispatchable) syncs the - user's in-progress `ChatState.Draft` (a `Message`, carrying its model/agent - selection and attachments) so it survives reloads and is visible to other - clients; `ChatActivityChangedAction` (`chat/activityChanged`) updates - `ChatState.Activity`. -- **`root/progress` notification** (upstream #263): a generic `ProgressParams` - progress notification for long-running operations (e.g. the first-use SDK - download), correlated back to the request via the new - `CreateSessionParams.ProgressToken`. -- **OpenTelemetry-native self-instrumentation**: the client originates traces + - metrics from a single `System.Diagnostics.ActivitySource` and `Meter` - (`AhpTelemetry.Name == "Microsoft.AgentHostProtocol"`), near zero-cost when - nothing is listening. One `ahp.request {method}` span per JSON-RPC request - (`rpc.system` / `rpc.method` / `ahp.outcome` tags) and the `ahp.client.*` - metric family (messages sent/received, request duration, requests in-flight, - active subscriptions, reconnects, dropped events, malformed frames). The - span / metric / attribute NAMES are codegen'd into `AhpTelemetryNames` from a - client-private registry (`clients/dotnet/codegen/telemetry/registry.ts`) so - they live in one place; the registry is structured for promotion to a shared - cross-client contract if AHP ever specs one. See `TELEMETRY.md`. -- `IMultiHostClient` — an interface extracted from `MultiHostClient` so consumers - can depend on (and mock) the multi-host runtime rather than the concrete sealed - facade. `AddAgentHostProtocol()` now also registers `IMultiHostClient`, - forwarding to the same singleton. -- `AhpServiceCollectionExtensions.TelemetrySourceName` — the instrumentation-scope - name constant to pass to OpenTelemetry's `AddSource(...)` / `AddMeter(...)`, with - the wiring snippet in its XML docs (the library takes no OpenTelemetry - dependency itself). -- `AhpTelemetryNames.StreamHost{Event,Subscription,Resource,Snapshot,Summaries}` - constants for the multi-host per-stream `ahp.stream` drop-tag values, and a - generated `AhpTelemetryNames.*Description` constant per metric (the single - source for each instrument's runtime description). -- A new **`examples/OtelExport`** sample (Shape C): wires the AHP instrumentation - scope into an OpenTelemetry pipeline with a console exporter and drives one - client operation so the request span + metrics print. -- `ChangesetOperationStatus.Disabled` — new value for changeset operations - that are currently unavailable and cannot be invoked (upstream #233). -- `ChangesetOperation.Group` — optional identifier for grouping related - changeset operations together in the UI (upstream #233). -- Full support for the **`ahp-chat:` channel** (upstream #213, multi-chat - sessions): `ChatState` (turns, active turn, steering message, queued - messages, input requests), the `SessionState.Chats` catalog (`ChatSummary`) - + `SessionState.DefaultChat` input-routing hint, the full `Chat*Action` - family (`ChatTurnStarted`, `ChatDelta`, `ChatResponsePart`, and the chat - tool-call / input / messaging actions), the `ChatInputQuestion` / - `ChatInputAnswer` / `ChatInputRequest` types, `ChatOrigin` provenance, and - the `CreateChat` / `DisposeChat` commands; reduced by the new - `Reducers.ApplyToChat`, a faithful port of the canonical TypeScript chat - reducer exercised by all 90 shared `reducer: "chat"` fixtures. Brings the - .NET client to cross-language conformance parity on the chat channel. -- `SessionChatAddedAction`, `SessionChatRemovedAction`, - `SessionChatUpdatedAction`, and `DefaultChatChangedAction` handling for - incremental chat-catalog updates on `SessionState`. -- The per-turn chat actions (`ChatTurnStarted`, `ChatDelta`, - `ChatResponsePart`, `ChatReasoning`, `ChatUsage`, `ChatTurnComplete`, - `ChatTurnCancelled`, `ChatError`) now carry an optional `_meta` property bag - (`Dictionary? Meta`) so agent hosts can stamp portable - per-event metadata on the action stream, mirroring the MCP `_meta` - convention (upstream #240). -- `SessionSummary` and `PartialSessionSummary` now carry an optional `_meta` - property bag (`Dictionary? Meta`) for lightweight - server-defined session-list presentation hints; the protocol does not - interpret the values (upstream #254). -- Error metadata fields from upstream #216. -- `RootState` now exposes an optional `_meta` property bag - (`Dictionary? Meta`) for implementation-defined - agent-host metadata, such as a well-known `hostBuild` key carrying the - host's build version/commit/date. -- Full support for the per-session **annotations channel** - (`ahp-session://annotations`): the `AnnotationsState`, `Annotation`, - `AnnotationEntry`, and `AnnotationsSummary` wire types; the four - `annotations/{set,removed,entrySet,entryRemoved}` actions; and - `Reducers.ApplyToAnnotations`, a faithful port of the canonical reducer - (append-or-replace an annotation by id, drop a matching annotation, - append-or-replace an entry within an annotation, drop a matching entry; - unknown target ids are no-ops). Brings the .NET client to full - cross-language conformance parity on the annotations channel. -- `SessionSummary.Annotations` (and `PartialSessionSummary.Annotations`), - an optional `AnnotationsSummary` carrying annotation / entry counts for - badge UI without subscribing to the channel. -- `MessageAnnotationsAttachment` — the `annotations` variant of the - `MessageAttachment` union, referencing annotations on a session's - annotations channel. -- `IAhpSerializer.SerializeToElement(T)` — serializes a value directly to a - `JsonElement` without the intermediate string + `JsonDocument.Parse`. Custom - `IAhpSerializer` implementations must implement this member. -- `IAhpSerializer.Deserialize(JsonElement)`: deserializes directly from an - already-parsed `JsonElement`, avoiding the `GetRawText()` string + re-parse on - the inbound hot path. Custom `IAhpSerializer` implementations must implement - this member. -- `HostReconnectFailedException` (a `HostException` subclass), surfaced on - `HostState.Error` when a host's reconnection cannot proceed: the transport - dropped while the reconnect policy was disabled, or the attempt budget was - exhausted. -- Trim / AOT support: the three shipping packages declare `IsTrimmable` / - `IsAotCompatible` and annotate the reflection-based serialization entry points - with `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]`, so trimmed or - Native-AOT consumers receive accurate analyzer warnings. -- Tracks protocol 0.5.0. New `ChangesetContentChangedAction` - (`changeset/contentChanged`) with its reducer: full-replacement semantics - where `files` always replaces the file list, `operations` replaces the - operation list only when present, and `error` is set when present and cleared - otherwise (parity with the canonical reducer; upstream #159 fixtures). New - `MessageOrigin` type and `MessageKind.Agent` / `MessageKind.Tool` members for - non-user-initiated turns (`Message.Origin` is now the typed `MessageOrigin` - rather than an opaque `JsonElement`; upstream #247). `ConfigPropertySchema` - and `SessionConfigPropertySchema` gain `AdditionalProperties` (upstream #245). -- `SessionModelInfo.MaxOutputTokens` and `SessionModelInfo.MaxPromptTokens` - optional fields for communicating model token limits (upstream). -- Tracks protocol 0.8.0. A tool call's final input is now the `ToolInput` union - — inline text or a lazy `ContentRef` — instead of a bare string (upstream - #374). `chat/toolCallReady` carries it forward from the existing state when - the action omits it (except out of `streaming`, where nothing had settled - yet), and `chat/toolCallConfirmed`'s `EditedToolInput` overrides only an - *inline* input, leaving a content reference for the host to replace. A - `chat/toolCallDelta` with no `content` no longer materializes an empty - `PartialInput`. -- `chat/toolCallReady` may finalize a tool call's provisional `Intention` and - MCP/server `Contributor` without changing client-execution ownership, which - stays fixed at `chat/toolCallStart`: a client-owned call keeps its owner - unless the refinement names the same client, and a call that was not - client-owned can never become client-owned late (upstream #364). -- A `toolClientExecution` entry in the session input queue no longer raises - `SessionStatus.InputNeeded`. That work is delegated to a client, not a prompt - — the call has already cleared its confirmation gate — so counting it made a - session report "input needed" for the whole duration of every client tool - call (upstream #380). -- `ResourceResponsePart` is generated from the correctly-spelled upstream type - name (upstream #375 fixed the `ResourceReponsePart` typo). +Implements AHP 1.0.0. -### Changed +### Added -- **Tracks protocol 0.7.0** (the AHP 0.6.0 release plus the 0.7.0 multiroot - line): negotiated protocol versions are now `[0.7.0, 0.6.0, 0.5.2, 0.5.1]`. -- **BREAKING — terminal completion folded into the terminal content block** - (upstream microsoft/agent-host-protocol#352): the standalone - `terminalComplete` tool-result variant is **removed** (see Removed) and its - outcome now hangs off `ToolResultTerminalContent.Result` as the new - `TerminalCommandResult` (`ExitCode`, `Preview`, `Truncated`), present once the - command has exited. `ToolResultTerminalContent` also gains `IsPty` — `false` - means output is plain text and needs no VT-sequence parsing — as does - `TerminalState`. Note `Cwd` did not survive the fold: code that read - `ToolResultTerminalCompleteContent.Cwd` has no replacement on this block. -- **Tracks protocol 0.5.1** (the AHP 0.5.0 release line, upstream - microsoft/agent-host-protocol#264/#278/#263): negotiated protocol versions are - now `[0.5.1, 0.5.0]`. -- **BREAKING — input requests live in the turn, not beside it** (upstream - microsoft/agent-host-protocol#338): `InputRequestResponsePart.Response` is now - **optional** (`ChatInputResponseKind?`) — it is absent while the request is - open — so any code that read it as a non-nullable value must now handle `null`. - An *open* request is no longer a separate collection but an - `InputRequestResponsePart` in the active turn whose `Response` is `null`, and - the chat's `InputNeeded` status is derived from that rather than from a stored - field. Three behavior changes follow: - `chat/inputRequested` **no-ops without an active turn** (the part is turn-scoped, - so there is nowhere to record it); `chat/inputCompleted` updates the existing - part instead of removing the request and appending a new part; and an - outstanding request is **no longer discarded when its turn ends** — it stays in - the completed turn's transcript unresolved, where previously it vanished - without a trace. Answer drafts are still preserved across a re-request that - omits `Answers`, and a request id is matched only against *unresolved* parts, so - a resolved request can never be reopened. -- **Flat `SessionState`** (upstream #264): `SessionState` no longer nests a - `Summary`. The fields shared with the catalog representation - (`Provider`/`Title`/`Status`/`Activity`/`Project`/`WorkingDirectory`/ - `Annotations` — the new `SessionMetadata` interface) are denormalized directly - onto `SessionState`, so a subscriber receives one flat object. The session - reducers act on the flat fields, and — because `SessionState` no longer carries - a `modifiedAt` clock — the session reducers are now pure (no host-authoritative - `modifiedAt` overlay is needed for convergence). `SessionSummary.CreatedAt` and - `SessionSummary.ModifiedAt` are now ISO 8601 `string`s (were epoch-ms numbers). -- **Per-message model/agent selection** (upstream #264): `session/modelChanged` - and `session/agentChanged` actions are removed, and `Model`/`Agent` no longer - live on `SessionState`, `SessionSummary`, `ChatState`, `ChatSummary`, - `CreateSessionParams`, or `CreateChatParams`. The selection now travels on the - individual `Message` (new `Message.Model` / `Message.Agent`), recording the - model/agent a message was — or, for a `draft`, will be — sent with. -- **Multiple active clients per session** (upstream - microsoft/agent-host-protocol#261): `SessionState.ActiveClient` - (`SessionActiveClient?`) becomes `SessionState.ActiveClients` - (`List`, required), so several clients can provide tools - and customizations to one session at once. The two session actions are - replaced accordingly: `SessionActiveClientChangedAction` - (`session/activeClientChanged`) → `SessionActiveClientSetAction` - (`session/activeClientSet`, upsert keyed by `clientId`, no longer nullable), - and `SessionActiveClientToolsChangedAction` - (`session/activeClientToolsChanged`) → `SessionActiveClientRemovedAction` - (`session/activeClientRemoved`, carrying the `clientId` to remove). The - reducer upserts on `activeClientSet` and removes-by-`clientId` (no-op on miss) - on `activeClientRemoved`. -- `ConfigPropertySchema.Enum` and `SessionConfigPropertySchema.Enum` are now - `List?` instead of `List?`, allowing numeric, boolean, - and null enum values (the `JsonPrimitive` widening in `types/common/state.ts`). -- `ModelSelection.Config` values are now `Dictionary?` - instead of `Dictionary?`, allowing numeric, boolean, and null - configuration values carried through as-is. - -- The `MultiHostClient` reconnect supervisor now emits the `ahp.client.reconnects` - counter — tagged `ahp.outcome=ok` on a successful reconnect and `ahp.outcome=error` - on each failed attempt and on attempt-budget exhaustion — so multi-host reconnects - are observable, matching the single-host client's instrumentation. -- The multi-host per-stream drop tags and the metric instrument descriptions now - reference the generated `AhpTelemetryNames` constants instead of hand-copied - literals, so they cannot drift from the generated contract. The drop tags are - also cached (one allocation per stream kind, not per evicted event). -- Per-turn / tool-call / input / messaging reducer logic moved from - `SessionState` to `ChatState`, matching upstream #213's split of the session - turn surface into the per-chat channel. -- `NowIso()` now emits ISO 8601 UTC with a `Z` suffix (was the C# round-trip - format ending `+00:00`), matching the cross-client wire timestamp format. -- Generated write-once wire payloads (every `*Action` / `*Command` / - `*Notification` and value object) are now `sealed record` types with - `init`-only properties; the state types the reducers mutate in place - (`SessionState`, `RootState`, `TerminalState`, `SessionSummary`, … ) remain - mutable `sealed class`. Named-initializer construction is unchanged. -- Required non-nullable wire fields now use the C# `required` modifier instead - of fabricated `""` / `null!` defaults. A payload that omits a required field - is rejected on deserialize (matching the schema's `required` array) rather - than silently materializing an empty value. -- The tool-call lifecycle reducers (`delta`, `ready`, `confirmed`, `complete`, - `resultConfirmed`, `contentChanged`) now propagate the action's `_meta` onto - the resulting tool-call state, so provider metadata stays synchronized as a - tool call advances beyond its initial `start` event (parity with the - canonical reducer; upstream #211). -- `AhpClient.RequestAsync` now returns `Task` (was - `Task`), making the empty-result case explicit for callers. The typed - protocol methods (`InitializeAsync`, `ReconnectAsync`, the subscribe helpers) - throw `AhpRpcException` when the server returns no result rather than handing - back a null. -- Failures surfaced on `AhpClient.Error` and `HostState.Error` are now typed - (`AhpTransportException`, `HostReconnectFailedException`) instead of a bare - `System.Exception`, so callers can pattern-match them. -- `HostConfig.Id` and `HostHandle.Id` now use the C# `required` modifier. -- Reduced per-message allocations on the hottest paths: inbound notifications and - request results deserialize straight from the parsed `JsonElement` (no string - round-trip), the notification fan-out skips its snapshot allocations when there - are no subscribers, and the WebSocket receive loop decodes single-frame - messages without a `MemoryStream`. +- A hermetic real-WebSocket conformance lane now verifies .NET session negotiation, initialize snapshot seeding, and streamed reducer convergence against a repository-local TypeScript host. +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. -### Removed +### Changed -- **BREAKING — `ToolResultTerminalCompleteContent` and - `ToolResultContentType.TerminalComplete`** (upstream - microsoft/agent-host-protocol#352): the standalone completion variant is gone. - Read the outcome from `ToolResultTerminalContent.Result` instead (see Changed). -- **BREAKING — `ChatForkSource`** (upstream - microsoft/agent-host-protocol#334): renamed to `ForkChatSource` and folded into - the new `ChatSource` union alongside `SideChatSource` (see Added). -- **BREAKING — `ChatState.InputRequests`** (upstream - microsoft/agent-host-protocol#338): the live `List?` surface - is gone. The open input requests are now derived from the active turn's - `ResponseParts` — the `InputRequestResponsePart`s whose `Response` is `null` - (see Changed). `ChatInputRequest` itself remains, as the request payload the - part carries. -- **Stale session-level input-elicitation surface** (upstream removed it in the - multi-chat sessions change, microsoft/agent-host-protocol#213): the - `session/inputRequested` / `session/inputAnswerChanged` / `session/inputCompleted` - actions and their supporting types (the old `SessionInputRequest`, - `SessionInputQuestion`, `SessionInputAnswer`, and the `SessionInput*` question/ - answer/enum families) were carried by the .NET client after upstream had already - dropped them, with no sibling-client analog. They are removed; the canonical - `SessionInputRequest` name is now the session `inputNeeded` union (see Added). +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- The .NET assemblies are now strong-name signed with the Microsoft Shared Libraries key. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. ### Fixed -- **`SnapshotState` no longer mis-routes a session snapshot to the root - fallback** (parity with upstream microsoft/agent-host-protocol#265's SnapshotState - decoder cleanup): the shape-probe discriminated `SessionState` on a `summary` - field that was removed when the session state was flattened, so a session - snapshot fell through to `RootState`. It now discriminates on the required - `lifecycle` field, matching the sibling clients. - -- `ChangesetOperationRangeTarget.Range` now serializes as the canonical - `TextRange` (nested `{ line, character }` start/end positions) instead of a - flat `{ Start, End }` integer index pair. The flat shape was a - code-generation drift from the schema (`ChangesetOperationTarget.range` is a - `TextRange`) and could not represent a real source range; the .NET wire form - now matches the other language clients. -- `ActionEnvelope.Origin` is omitted from the wire when absent - (server-originated) instead of being serialized as `"origin": null`, matching - the `ActionOrigin | undefined` schema (`undefined` ⇒ omit). -- Client teardown could deadlock when a keep-alive ping failure triggered - shutdown from within the keep-alive loop itself (the loop awaited its own - task). Teardown now skips that self-await. -- A fragmented WebSocket text message whose frames exactly filled the 64 KiB - receive buffer dropped a frame; the receive loop now grows the buffer after - copying the previous frame rather than before. +- Generated action and union-variant records now pin their own literal discriminator as the property initializer, so constructing one without setting the discriminator serializes the correct wire value. Previously the property fell back to the type's zero value — the *first* enum member, which silently emitted the wrong discriminator on every record but the first. (#366) +- Open (`@nonexhaustive`) protocol enums no longer fail decoding when a newer peer sends a wire value this build does not recognize. They are now generated as readonly structs wrapping the raw wire string (with the known values as static members) instead of closed C# enums, so an added enum value round-trips verbatim as `versioning.md` requires. Each open enum gets a generated converter, so the path no longer relies on reflection. (#366) +- Discriminated unions now derive whether an unrecognized discriminator is preserved from the discriminator enum's `@exhaustive` / `@nonexhaustive` annotation, matching the other generators. `SessionOrigin` and `CustomizationEnablement` previously rejected a discriminator added by a newer peer even though their discriminants are open; `ChangesetOperationTarget` is likewise derived now. (#366) +- Preserve unknown future chat move destinations when decoding and re-encoding with the .NET client. ## [0.9.0] — 2026-08-28 diff --git a/clients/dotnet/VERSION b/clients/dotnet/VERSION index ac39a106c..3eefcb9dd 100644 --- a/clients/dotnet/VERSION +++ b/clients/dotnet/VERSION @@ -1 +1 @@ -0.9.0 +1.0.0 diff --git a/clients/dotnet/release-metadata.json b/clients/dotnet/release-metadata.json index c745db9d9..b1c8b524a 100644 --- a/clients/dotnet/release-metadata.json +++ b/clients/dotnet/release-metadata.json @@ -1,13 +1,8 @@ { "client": "dotnet", - "packageVersion": "0.9.0", + "packageVersion": "1.0.0", "supportedProtocolVersions": [ - "0.10.0", - "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1" + "1.0.0", + "0.9.0" ] } diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 9d0877928..25945d5a3 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -288,7 +288,9 @@ public sealed record InitializeParams /// preferred to least preferred. Each entry is a [SemVer](https://semver.org) /// `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). /// - /// The server selects one entry and returns it as `InitializeResult.protocolVersion`. + /// The server selects the highest offered SemVer within its supported + /// caret-compatible ranges and returns that exact entry as + /// `InitializeResult.protocolVersion`. /// If the server cannot speak any of the offered versions, it MUST return /// error code `-32005` (`UnsupportedProtocolVersion`) with required /// `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 0418dbbb8..7fa5d0f70 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,26 +14,71 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "0.10.0"; + public const string Current = "1.0.0"; private static readonly string[] s_supported = { - "0.10.0", + "1.0.0", "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1", }; /// /// Every protocol version this client is willing to negotiate, ordered - /// most-preferred-first. The first entry always equals . + /// most-preferred-first, independently of the development . /// A fresh copy is returned on every call so callers may mutate it freely. /// public static IReadOnlyList Supported => (string[])s_supported.Clone(); + /// + /// Selects the highest offered version in a supported caret range. + /// Null means the host must send UnsupportedProtocolVersion and close. + /// Malformed versions throw ArgumentException. + /// + public static string? Negotiate(IEnumerable offered) + { + Guard.ThrowIfNull(offered, nameof(offered)); + static long[] Parse(string version) + { + if (version is null || !System.Text.RegularExpressions.Regex.IsMatch( + version, @"\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z")) + throw new ArgumentException($"Invalid protocol version: {version}", nameof(version)); + var parts = version.Split('.'); + var values = new long[3]; + for (var i = 0; i < 3; i++) + { + if (!long.TryParse(parts[i], System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out values[i])) + throw new ArgumentException($"Invalid protocol version: {version}", nameof(version)); + } + return values; + } + static int Compare(long[] a, long[] b) + { + for (var i = 0; i < 3; i++) + { + var comparison = a[i].CompareTo(b[i]); + if (comparison != 0) return comparison; + } + return 0; + } + var baselines = new List(); + foreach (var baseline in s_supported) baselines.Add(Parse(baseline)); + string? selected = null; + long[]? previous = null; + foreach (var version in offered) + { + var parts = Parse(version); + if (baselines.Exists(b => parts[0] == b[0] && (parts[0] > 0 || parts[1] == b[1]) + && (parts[0] > 0 || parts[1] > 0 || parts[2] == b[2]) && Compare(parts, b) >= 0) + && (previous is null || Compare(parts, previous) > 0)) + { + selected = version; + previous = parts; + } + } + return selected; + } + /// The well-known channel URI for the root channel. public const string RootResourceUri = "ahp-root://"; } diff --git a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs index b61c619ec..d4aed706c 100644 --- a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs +++ b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs @@ -97,14 +97,14 @@ static async Task RunTransportScenarioAsync(SystemTextJsonAhpSerializer serializ "initialize", new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, + ProtocolVersion = ProtocolVersion.Supported[0], ServerSeq = 4, Snapshots = new List(), }, cancellationToken); InitializeResult initializeResult = await client.InitializeAsync("native-aot-client", cancellationToken: cancellationToken); JsonRpcRequest initializeRequest = await initializeResponse; - Ensure(initializeResult.ProtocolVersion == ProtocolVersion.Current, "Initialize result failed."); + Ensure(initializeResult.ProtocolVersion == ProtocolVersion.Supported[0], "Initialize result failed."); Ensure( initializeRequest.Params?.GetProperty("clientId").GetString() == "native-aot-client", "Initialize params failed."); diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs index cc3beed99..580dfbffd 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs @@ -90,7 +90,10 @@ public static async Task HandleOneInitialize(MemTransport serverSide, Cancellati Assert.NotNull(msg.Request); Assert.Equal("initialize", msg.Request!.Method); - var result = new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }; + var offered = Ser.Deserialize(msg.Request.Params!.Value.GetRawText()).ProtocolVersions; + var selected = ProtocolVersion.Negotiate(offered) + ?? throw new InvalidOperationException("Fake server received no compatible protocol version"); + var result = new InitializeResult { ProtocolVersion = selected, Snapshots = new() }; var response = new JsonRpcMessage { SuccessResponse = new JsonRpcSuccessResponse @@ -123,7 +126,7 @@ public async Task RequestRoundTrip_InitializeReturnsProtocolVersion() await using var client = AhpClient.Connect(clientSide); var result = await client.InitializeAsync("test-client", cancellationToken: cts.Token); - Assert.Equal(ProtocolVersion.Current, result.ProtocolVersion); + Assert.Equal(ProtocolVersion.Supported[0], result.ProtocolVersion); await serverTask; } @@ -365,7 +368,7 @@ public async Task Request_HappyPath_StillResolves() await using var client = AhpClient.Connect(clientSide); var result = await client.InitializeAsync("test-client", cancellationToken: cts.Token); - Assert.Equal(ProtocolVersion.Current, result.ProtocolVersion); + Assert.Equal(ProtocolVersion.Supported[0], result.ProtocolVersion); // The resolved request left no pending entry behind. Assert.Equal(0, client.PendingRequestCount); await serverTask; @@ -548,7 +551,7 @@ public async Task Initialize_SnapshotDeliveredInResult() // Server replies to `initialize` with a result carrying one snapshot. var initResult = new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, + ProtocolVersion = ProtocolVersion.Supported[0], ServerSeq = 7, Snapshots = new System.Collections.Generic.List { @@ -572,7 +575,7 @@ public async Task Initialize_SnapshotDeliveredInResult() initialSubscriptions: new[] { "ahp-session:/s1" }, cancellationToken: cts.Token); - Assert.Equal(ProtocolVersion.Current, result.ProtocolVersion); + Assert.Equal(ProtocolVersion.Supported[0], result.ProtocolVersion); Assert.NotNull(result.Snapshots); var snapshot = Assert.Single(result.Snapshots); Assert.Equal("ahp-session:/s1", snapshot.Resource); diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs index 4e1c99790..27813e282 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/HostStreamDropTagTests.cs @@ -152,7 +152,7 @@ private static async Task RespondInitializeLoopAsync(MemTransport serverSide, Ca if (msg.Request?.Method == "initialize") await FakeHost.RespondResultAsync( serverSide, msg.Request.Id, - new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, + new InitializeResult { ProtocolVersion = ProtocolVersion.Supported[0], Snapshots = new() }, ct).ConfigureAwait(false); } } diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs index 48683571e..3f94c9d0c 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/HostsTests.cs @@ -20,7 +20,7 @@ public sealed class HostsTests private static Task RunFakeServerAsync(MemTransport serverSide, CancellationToken ct) => FakeHost.New() .OnInitialize((req, side, c) => FakeHost.RespondResultAsync( - side, req.Id, new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, c)) + side, req.Id, new InitializeResult { ProtocolVersion = ProtocolVersion.Supported[0], Snapshots = new() }, c)) .RunAsync(serverSide, ct); // ── Single host handshake ───────────────────────────────────────────── @@ -44,7 +44,7 @@ public async Task SingleHostHandshake_ConnectedWithProtocolVersion() await using var disposeMulti = multi; Assert.Equal(HostStateKind.Connected, handle.State.Kind); - Assert.Equal(ProtocolVersion.Current, handle.ProtocolVersion); + Assert.Equal(ProtocolVersion.Supported[0], handle.ProtocolVersion); Assert.False(string.IsNullOrEmpty(handle.ClientId), "ClientID should be auto-generated and non-empty"); _ = serverTask; // referenced to avoid unused-variable warning diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs index 37c6aebd0..24943f234 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs @@ -114,7 +114,7 @@ private static async Task RepeatActionAsync( private static Task RespondInitializeAsync(MemTransport serverSide, ulong id, CancellationToken ct) => FakeHost.RespondResultAsync( - serverSide, id, new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, ct); + serverSide, id, new InitializeResult { ProtocolVersion = ProtocolVersion.Supported[0], Snapshots = new() }, ct); private static Task SendActionAsync( MemTransport serverSide, string channel, long serverSeq, CancellationToken ct) => @@ -174,7 +174,7 @@ public async Task MultiHost_AddHostSnapshotsConfigurationBeforeFirstAwait() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var store = new BlockingClientIdStore(); var subscriptions = new List { "ahp-test://original" }; - var protocolVersions = new List { ProtocolVersion.Current }; + var protocolVersions = new List { ProtocolVersion.Supported[0] }; var observedInitialize = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); await using var multiHost = new MultiHostClient(store); @@ -208,7 +208,7 @@ public async Task MultiHost_AddHostSnapshotsConfigurationBeforeFirstAwait() await addTask; var initialize = await observedInitialize.Task.WaitAsync(cts.Token); Assert.Equal("ahp-test://original", Assert.Single(initialize.InitialSubscriptions!)); - Assert.Equal(ProtocolVersion.Current, Assert.Single(initialize.ProtocolVersions)); + Assert.Equal(ProtocolVersion.Supported[0], Assert.Single(initialize.ProtocolVersions)); } // ── H: events tagged hostId ──────────────────────────────────────────── @@ -965,7 +965,7 @@ private static Task RespondInitializeWithRootAsync( { var result = new InitializeResult { - ProtocolVersion = ProtocolVersion.Current, + ProtocolVersion = ProtocolVersion.Supported[0], ServerSeq = 0, Snapshots = new List { diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs index 06d74b5aa..504744781 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TelemetryTests.cs @@ -461,7 +461,7 @@ private static TransportMessage BuildActionNotification(string uri, long serverS private static Task RespondInitializeAsync(MemTransport serverSide, ulong id, CancellationToken ct) => FakeHost.RespondResultAsync( serverSide, id, - new InitializeResult { ProtocolVersion = ProtocolVersion.Current, Snapshots = new() }, ct); + new InitializeResult { ProtocolVersion = ProtocolVersion.Supported[0], Snapshots = new() }, ct); private static async Task DriveOneInitializeAsync() { diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs index d0fa9c1d6..b609ca48a 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TypesRoundTripFixtures.cs @@ -65,7 +65,7 @@ public void ProtocolVersionConstants() string.IsNullOrWhiteSpace(ProtocolVersion.Current), "ProtocolVersion.Current must be non-empty"); Assert.NotEmpty(ProtocolVersion.Supported); - Assert.Equal(ProtocolVersion.Current, ProtocolVersion.Supported[0]); + Assert.Equal(new[] { "1.0.0", "0.9.0" }, ProtocolVersion.Supported); } // ── Verifier ────────────────────────────────────────────────────────── diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/VersionNegotiationTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/VersionNegotiationTests.cs new file mode 100644 index 000000000..8cf57bf50 --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/VersionNegotiationTests.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class VersionNegotiationTests +{ + [Fact] + public void SharedCorpus() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (!File.Exists(Path.Combine(root.FullName, "types", "test-cases", "version-negotiation.json"))) + { + root = root.Parent ?? throw new DirectoryNotFoundException("Cannot locate version negotiation corpus"); + } + using var document = JsonDocument.Parse(File.ReadAllText( + Path.Combine(root.FullName, "types", "test-cases", "version-negotiation.json"))); + Assert.NotEqual(0, document.RootElement.GetArrayLength()); + foreach (var fixture in document.RootElement.EnumerateArray()) + { + var offered = fixture.GetProperty("offered").EnumerateArray() + .Select(value => value.GetString() ?? throw new InvalidDataException("Null offered version")).ToArray(); + if (fixture.TryGetProperty("invalid", out var invalid) && invalid.GetBoolean()) + { + Assert.Throws(() => ProtocolVersion.Negotiate(offered)); + } + else + { + Assert.Equal(fixture.GetProperty("expected").GetString(), ProtocolVersion.Negotiate(offered)); + } + } + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts index 0ac294c62..b232ff0b8 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/interop/session-host.ts @@ -3,6 +3,8 @@ import { WebSocketServer, type WebSocket } from 'ws'; import { ActionType, PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + negotiateProtocolVersion, SessionLifecycle, SessionStatus, sessionReducer, @@ -42,8 +44,8 @@ function success(socket: WebSocket, id: number, result: unknown): void { send(socket, { jsonrpc: '2.0', id, result }); } -function failure(socket: WebSocket, id: number, code: number, message: string): void { - send(socket, { jsonrpc: '2.0', id, error: { code, message } }); +function failure(socket: WebSocket, id: number, code: number, message: string, data?: unknown): void { + send(socket, { jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } }); } function isRequest(value: unknown): value is JsonRpcRequest { @@ -74,16 +76,32 @@ function handleRequest(socket: WebSocket, request: JsonRpcRequest, initialized: || typeof params.clientId !== 'string' || !Array.isArray(offered) || !offered.every(version => typeof version === 'string') - || !offered.includes(PROTOCOL_VERSION) || !Array.isArray(subscriptions) || subscriptions.length !== 1 || subscriptions[0] !== sessionUri) { failure(socket, request.id, -32602, 'invalid current-protocol initialize request'); return; } + let protocolVersion: string | undefined; + try { + protocolVersion = negotiateProtocolVersion(offered); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + failure(socket, request.id, -32602, error.message); + return; + } + if (protocolVersion === undefined) { + failure(socket, request.id, -32005, 'unsupported protocol version', { + supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, + }); + socket.close(1002, 'unsupported protocol version'); + return; + } initialized.value = true; success(socket, request.id, { - protocolVersion: PROTOCOL_VERSION, + protocolVersion, serverSeq: 0, serverInfo: { name: 'repository-local-typescript-conformance-host', version: PROTOCOL_VERSION }, snapshots: [{ resource: sessionUri, state: initialState, fromSeq: 0 }], diff --git a/clients/go/CHANGELOG.md b/clients/go/CHANGELOG.md index 36cc79e49..b5fa786e4 100644 --- a/clients/go/CHANGELOG.md +++ b/clients/go/CHANGELOG.md @@ -14,6 +14,35 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file. ## [Unreleased] +## [1.0.0] — 2026-10-02 + +Implements AHP 1.0.0. + +### Added + +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. + +### Changed + +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. + ## [0.9.0] — 2026-08-28 Implements AHP 0.9.0. diff --git a/clients/go/VERSION b/clients/go/VERSION index ac39a106c..3eefcb9dd 100644 --- a/clients/go/VERSION +++ b/clients/go/VERSION @@ -1 +1 @@ -0.9.0 +1.0.0 diff --git a/clients/go/ahp/version_negotiation_test.go b/clients/go/ahp/version_negotiation_test.go new file mode 100644 index 000000000..b56f2cf3f --- /dev/null +++ b/clients/go/ahp/version_negotiation_test.go @@ -0,0 +1,44 @@ +package ahp + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/microsoft/agent-host-protocol/clients/go/ahptypes" +) + +func TestSharedVersionNegotiationCorpus(t *testing.T) { + data, err := os.ReadFile(filepath.Join(findFixtureDir(t), "..", "version-negotiation.json")) + if err != nil { + t.Fatal(err) + } + var cases []struct { + Offered []string `json:"offered"` + Expected *string `json:"expected"` + Invalid bool `json:"invalid"` + } + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + for _, fixture := range cases { + actual, err := ahptypes.NegotiateProtocolVersion(fixture.Offered) + if fixture.Invalid { + if err == nil { + t.Fatalf("expected invalid offer: %v", fixture.Offered) + } + continue + } + if err != nil { + t.Fatal(err) + } + expected := "" + if fixture.Expected != nil { + expected = *fixture.Expected + } + if actual != expected { + t.Fatalf("%v: got %q, want %q", fixture.Offered, actual, expected) + } + } +} diff --git a/clients/go/ahptypes/ahptypes_test.go b/clients/go/ahptypes/ahptypes_test.go index 505278e92..48fd7787a 100644 --- a/clients/go/ahptypes/ahptypes_test.go +++ b/clients/go/ahptypes/ahptypes_test.go @@ -15,8 +15,8 @@ func TestProtocolVersion(t *testing.T) { if len(supported) == 0 { t.Fatal("SupportedProtocolVersions is empty") } - if supported[0] != ProtocolVersion { - t.Fatalf("SupportedProtocolVersions[0] = %q, want %q (ProtocolVersion)", supported[0], ProtocolVersion) + if len(supported) != 2 || supported[0] != "1.0.0" || supported[1] != "0.9.0" { + t.Fatalf("unexpected released compatibility baselines: %v", supported) } } diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index cd3b2a6ad..096f97b77 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -111,7 +111,9 @@ type InitializeParams struct { // preferred to least preferred. Each entry is a [SemVer](https://semver.org) // `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). // - // The server selects one entry and returns it as `InitializeResult.protocolVersion`. + // The server selects the highest offered SemVer within its supported + // caret-compatible ranges and returns that exact entry as + // `InitializeResult.protocolVersion`. // If the server cannot speak any of the offered versions, it MUST return // error code `-32005` (`UnsupportedProtocolVersion`) with required // `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. diff --git a/clients/go/ahptypes/roundtrip_fixture_test.go b/clients/go/ahptypes/roundtrip_fixture_test.go index 0f7181385..d62481424 100644 --- a/clients/go/ahptypes/roundtrip_fixture_test.go +++ b/clients/go/ahptypes/roundtrip_fixture_test.go @@ -340,8 +340,7 @@ func TestProtocolVersionConstants(t *testing.T) { t.Errorf("SupportedProtocolVersions() must be non-empty") } - if len(supported) > 0 && supported[0] != ProtocolVersion { - t.Errorf("first SupportedProtocolVersions entry %q must equal ProtocolVersion %q", - supported[0], ProtocolVersion) + if len(supported) != 2 || supported[0] != "1.0.0" || supported[1] != "0.9.0" { + t.Errorf("unexpected released compatibility baselines: %v", supported) } } diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index 546b8583f..a71947394 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -4,29 +4,94 @@ package ahptypes +import ( + "fmt" + "strconv" + "strings" +) + // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "0.10.0" +const ProtocolVersion = "1.0.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the // shared backing array. var supportedProtocolVersions = []string{ - "0.10.0", + "1.0.0", "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1", } // SupportedProtocolVersions returns every protocol version this client -// is willing to negotiate, ordered most-preferred-first. The first -// entry always equals [ProtocolVersion]. The returned slice is a fresh +// is willing to negotiate, ordered most-preferred-first, independently +// of the development [ProtocolVersion]. The returned slice is a fresh // copy on every call so callers may mutate it freely. func SupportedProtocolVersions() []string { out := make([]string, len(supportedProtocolVersions)) copy(out, supportedProtocolVersions) return out } + +// NegotiateProtocolVersion selects the highest offered version in a supported +// caret range. An empty result means the host must send UnsupportedProtocolVersion +// and close. Malformed versions return an error. +func NegotiateProtocolVersion(offered []string) (string, error) { + parse := func(version string) ([3]uint64, error) { + var result [3]uint64 + parts := strings.Split(version, ".") + if len(parts) != 3 { + return result, fmt.Errorf("invalid protocol version: %s", version) + } + for i, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return result, fmt.Errorf("invalid protocol version: %s", version) + } + for _, digit := range part { + if digit < '0' || digit > '9' { + return result, fmt.Errorf("invalid protocol version: %s", version) + } + } + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return result, fmt.Errorf("invalid protocol version: %s", version) + } + result[i] = value + } + return result, nil + } + compare := func(a, b [3]uint64) int { + for i := range a { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + return 0 + } + var baselines [][3]uint64 + for _, baseline := range supportedProtocolVersions { + parts, err := parse(baseline) + if err != nil { + return "", err + } + baselines = append(baselines, parts) + } + var selected string + var previous [3]uint64 + for _, version := range offered { + parts, err := parse(version) + if err != nil { + return "", err + } + for _, base := range baselines { + if parts[0] == base[0] && (parts[0] > 0 || parts[1] == base[1]) && + (parts[0] > 0 || parts[1] > 0 || parts[2] == base[2]) && + compare(parts, base) >= 0 && (selected == "" || compare(parts, previous) > 0) { + selected, previous = version, parts + } + } + } + return selected, nil +} diff --git a/clients/go/release-metadata.json b/clients/go/release-metadata.json index 3f1b9a894..0a5fa75c6 100644 --- a/clients/go/release-metadata.json +++ b/clients/go/release-metadata.json @@ -1,13 +1,8 @@ { "client": "go", - "packageVersion": "0.9.0", + "packageVersion": "1.0.0", "supportedProtocolVersions": [ - "0.10.0", - "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1" + "1.0.0", + "0.9.0" ] } diff --git a/clients/kotlin/CHANGELOG.md b/clients/kotlin/CHANGELOG.md index 0631c7704..31e09f38f 100644 --- a/clients/kotlin/CHANGELOG.md +++ b/clients/kotlin/CHANGELOG.md @@ -15,6 +15,35 @@ versions (`*-SNAPSHOT`) are explicitly rejected by the publish pipeline; bump ## [Unreleased] +## [1.0.0] — 2026-10-02 + +Implements AHP 1.0.0. + +### Added + +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. + +### Changed + +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. + ## [0.9.0] — 2026-08-28 Implements AHP 0.9.0. diff --git a/clients/kotlin/gradle.properties b/clients/kotlin/gradle.properties index 29fc323b9..fd635ce5a 100644 --- a/clients/kotlin/gradle.properties +++ b/clients/kotlin/gradle.properties @@ -8,7 +8,7 @@ kotlin.code.style=official # Maven coordinates — consumed by build.gradle.kts and validated by the # publish workflow against the `kotlin/v*` git tag. GROUP=com.microsoft.agenthostprotocol -VERSION_NAME=0.9.0 +VERSION_NAME=1.0.0 POM_NAME=agent-host-protocol POM_DESCRIPTION=Kotlin/JVM client for the Agent Host Protocol (AHP) POM_INCEPTION_YEAR=2026 diff --git a/clients/kotlin/release-metadata.json b/clients/kotlin/release-metadata.json index 63e469469..fa5f5e229 100644 --- a/clients/kotlin/release-metadata.json +++ b/clients/kotlin/release-metadata.json @@ -1,13 +1,8 @@ { "client": "kotlin", - "packageVersion": "0.9.0", + "packageVersion": "1.0.0", "supportedProtocolVersions": [ - "0.10.0", - "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1" + "1.0.0", + "0.9.0" ] } diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index 48ed5f7b7..5c19a8055 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -315,7 +315,9 @@ data class InitializeParams( * preferred to least preferred. Each entry is a [SemVer](https://semver.org) * `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). * - * The server selects one entry and returns it as `InitializeResult.protocolVersion`. + * The server selects the highest offered SemVer within its supported + * caret-compatible ranges and returns that exact entry as + * `InitializeResult.protocolVersion`. * If the server cannot speak any of the offered versions, it MUST return * error code `-32005` (`UnsupportedProtocolVersion`) with required * `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index d4da30550..598f42a8c 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,22 +5,55 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "0.10.0" +public const val PROTOCOL_VERSION: String = "1.0.0" /** * Every protocol version this library is willing to negotiate, ordered - * most-preferred-first. The first entry equals [PROTOCOL_VERSION]. + * most-preferred-first, independently of the development [PROTOCOL_VERSION]. * * Pass this list (or a derived `List`) as `protocolVersions` on * `InitializeParams` so the same client binary can fall back to older * protocol versions if the host doesn't accept the newest one. */ public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf( - "0.10.0", + "1.0.0", "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1", ) + +/** Select the highest offered version in a supported caret range. + * Null means the host must send UnsupportedProtocolVersion and close. + * Malformed versions throw IllegalArgumentException. + */ +public fun negotiateProtocolVersion(offered: List): String? { + fun parse(version: String): List { + require(Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)").matches(version)) { + "Invalid protocol version: $version" + } + return version.split('.').map { + val value = it.toLongOrNull() + require(value != null) { "Invalid protocol version: $version" } + value + } + } + fun compare(a: List, b: List): Int { + for (i in 0..2) { + val comparison = a[i].compareTo(b[i]) + if (comparison != 0) return comparison + } + return 0 + } + val baselines = SUPPORTED_PROTOCOL_VERSIONS.map(::parse) + var selected: String? = null + var previous: List? = null + for (version in offered) { + val parts = parse(version) + if (baselines.any { base -> + parts[0] == base[0] && (parts[0] > 0 || parts[1] == base[1]) && + (parts[0] > 0 || parts[1] > 0 || parts[2] == base[2]) && compare(parts, base) >= 0 + } && (previous == null || compare(parts, previous) > 0)) { + selected = version + previous = parts + } + } + return selected +} diff --git a/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/VersionNegotiationTest.kt b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/VersionNegotiationTest.kt new file mode 100644 index 000000000..ee3e701a0 --- /dev/null +++ b/clients/kotlin/src/test/kotlin/com/microsoft/agenthostprotocol/VersionNegotiationTest.kt @@ -0,0 +1,32 @@ +package com.microsoft.agenthostprotocol + +import com.microsoft.agenthostprotocol.generated.negotiateProtocolVersion +import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class VersionNegotiationTest { + @Test + fun sharedCorpus() { + val reducers = File(requireNotNull(System.getProperty("ahp.reducerFixturesDir"))) + val cases = Json.parseToJsonElement(File(reducers.parentFile, "version-negotiation.json").readText()).jsonArray + assertFalse(cases.isEmpty()) + for (fixture in cases) { + val item = fixture.jsonObject + val offered = item.getValue("offered").jsonArray.map { it.jsonPrimitive.content } + if (item["invalid"]?.jsonPrimitive?.booleanOrNull == true) { + assertThrows(IllegalArgumentException::class.java) { negotiateProtocolVersion(offered) } + } else { + assertEquals(item["expected"]?.jsonPrimitive?.contentOrNull, negotiateProtocolVersion(offered)) + } + } + } +} diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md index 57980ce35..816b83805 100644 --- a/clients/rust/CHANGELOG.md +++ b/clients/rust/CHANGELOG.md @@ -15,6 +15,39 @@ matching `## [X.Y.Z]` heading is missing from this file. ## [Unreleased] +## [1.0.0] — 2026-10-02 + +Implements AHP 1.0.0. + +### Added + +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. + +### Changed + +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. + +### Fixed + +- `ClientPluginCustomization` now serializes its `"type": "plugin"` discriminant, so plugins published in `SessionActiveClient.customizations` are no longer sent without `type`. + ## [0.9.0] — 2026-08-28 Implements AHP 0.9.0. diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock index 2b4da79a4..2e5f01dea 100644 --- a/clients/rust/Cargo.lock +++ b/clients/rust/Cargo.lock @@ -4,7 +4,7 @@ version = 3 [[package]] name = "ahp" -version = "0.9.0" +version = "1.0.0" dependencies = [ "ahp-types", "ahp-ws", @@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "ahp-types" -version = "0.9.0" +version = "1.0.0" dependencies = [ "serde", "serde_json", @@ -28,7 +28,7 @@ dependencies = [ [[package]] name = "ahp-ws" -version = "0.9.0" +version = "1.0.0" dependencies = [ "ahp", "futures-util", diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml index 6f98c75b7..f1f43d987 100644 --- a/clients/rust/Cargo.toml +++ b/clients/rust/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.9.0" +version = "1.0.0" edition = "2021" rust-version = "1.75" license = "MIT" @@ -27,5 +27,5 @@ tokio-util = "0.7" futures-util = "0.3" tracing = "0.1" jiff = { version = "0.2", default-features = false, features = ["std"] } -ahp-types = { path = "crates/ahp-types", version = "0.9.0" } -ahp = { path = "crates/ahp", version = "0.9.0" } +ahp-types = { path = "crates/ahp-types", version = "1.0.0" } +ahp = { path = "crates/ahp", version = "1.0.0" } diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 37e8955b5..8c876185d 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -238,7 +238,9 @@ pub struct InitializeParams { /// preferred to least preferred. Each entry is a [SemVer](https://semver.org) /// `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). /// - /// The server selects one entry and returns it as `InitializeResult.protocolVersion`. + /// The server selects the highest offered SemVer within its supported + /// caret-compatible ranges and returns that exact entry as + /// `InitializeResult.protocolVersion`. /// If the server cannot speak any of the offered versions, it MUST return /// error code `-32005` (`UnsupportedProtocolVersion`) with required /// `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. diff --git a/clients/rust/crates/ahp-types/src/lib.rs b/clients/rust/crates/ahp-types/src/lib.rs index 87faf68d3..5692c80e5 100644 --- a/clients/rust/crates/ahp-types/src/lib.rs +++ b/clients/rust/crates/ahp-types/src/lib.rs @@ -140,4 +140,4 @@ pub use notifications::{ AuthRequiredParams, SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, }; pub use state::{Icon, ProtectedResourceMetadata, RootState, SessionState, TerminalState}; -pub use version::{PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; +pub use version::{negotiate_protocol_version, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 042f44922..51c725dfd 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,14 +5,55 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "0.10.0"; +pub const PROTOCOL_VERSION: &str = "1.0.0"; /// Every protocol version this crate is willing to negotiate, ordered -/// most-preferred-first. The first entry equals [`PROTOCOL_VERSION`]. +/// most-preferred-first, independently of the development [`PROTOCOL_VERSION`]. /// /// Consumers building `InitializeParams` should pass this slice (or a /// derived `Vec`) so the same client binary can fall back to /// older protocol versions if the host doesn't accept the newest one. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ - "0.10.0", "0.9.0", "0.8.0", "0.7.0", "0.6.0", "0.5.2", "0.5.1", -]; +pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["1.0.0", "0.9.0"]; + +/// Select the highest offered version in a supported caret range. +/// None means the host must send UnsupportedProtocolVersion and close. +pub fn negotiate_protocol_version(offered: &[String]) -> Result, String> { + fn parse(version: &str) -> Result<(u64, u64, u64), String> { + let invalid = || format!("Invalid protocol version: {version}"); + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() != 3 { + return Err(invalid()); + } + let mut values = [0; 3]; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(invalid()); + } + values[index] = part.parse::().map_err(|_| invalid())?; + } + Ok((values[0], values[1], values[2])) + } + let baselines = SUPPORTED_PROTOCOL_VERSIONS + .iter() + .map(|version| parse(version)) + .collect::, _>>()?; + let mut selected: Option<(&str, (u64, u64, u64))> = None; + for version in offered { + let parts = parse(version)?; + if baselines.iter().any(|base| { + parts.0 == base.0 + && (parts.0 > 0 || parts.1 == base.1) + && (parts.0 > 0 || parts.1 > 0 || parts.2 == base.2) + && parts >= *base + }) && selected + .as_ref() + .map_or(true, |(_, previous)| parts > *previous) + { + selected = Some((version.as_str(), parts)); + } + } + Ok(selected.map(|(version, _)| version)) +} diff --git a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs index 1b81a9caf..53fb11e5d 100644 --- a/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs +++ b/clients/rust/crates/ahp-types/tests/roundtrip_corpus.rs @@ -297,9 +297,5 @@ fn protocol_version_constants() { !supported.is_empty(), "SUPPORTED_PROTOCOL_VERSIONS must be non-empty" ); - assert_eq!( - supported[0], PROTOCOL_VERSION, - "first SUPPORTED_PROTOCOL_VERSIONS entry {:?} must equal PROTOCOL_VERSION {:?}", - supported[0], PROTOCOL_VERSION - ); + assert_eq!(supported, &["1.0.0", "0.9.0"]); } diff --git a/clients/rust/crates/ahp-types/tests/version_negotiation.rs b/clients/rust/crates/ahp-types/tests/version_negotiation.rs new file mode 100644 index 000000000..2476cea88 --- /dev/null +++ b/clients/rust/crates/ahp-types/tests/version_negotiation.rs @@ -0,0 +1,31 @@ +use ahp_types::negotiate_protocol_version; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Case { + offered: Vec, + expected: Option, + #[serde(default)] + invalid: bool, +} + +#[test] +fn shared_negotiation_corpus() -> Result<(), Box> { + let cases: Vec = serde_json::from_str(include_str!( + "../../../../../types/test-cases/version-negotiation.json" + ))?; + for case in cases { + let result = negotiate_protocol_version(&case.offered); + if case.invalid { + assert!(result.is_err(), "{:?}", case.offered); + } else { + assert_eq!( + result?.map(str::to_owned), + case.expected, + "{:?}", + case.offered + ); + } + } + Ok(()) +} diff --git a/clients/rust/release-metadata.json b/clients/rust/release-metadata.json index a756c2ac8..8b0ce2952 100644 --- a/clients/rust/release-metadata.json +++ b/clients/rust/release-metadata.json @@ -1,13 +1,8 @@ { "client": "rust", - "packageVersion": "0.9.0", + "packageVersion": "1.0.0", "supportedProtocolVersions": [ - "0.10.0", - "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1" + "1.0.0", + "0.9.0" ] } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index f50b96186..838526497 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -268,7 +268,9 @@ public struct InitializeParams: Codable, Sendable { /// preferred to least preferred. Each entry is a [SemVer](https://semver.org) /// `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). /// - /// The server selects one entry and returns it as `InitializeResult.protocolVersion`. + /// The server selects the highest offered SemVer within its supported + /// caret-compatible ranges and returns that exact entry as + /// `InitializeResult.protocolVersion`. /// If the server cannot speak any of the offered versions, it MUST return /// error code `-32005` (`UnsupportedProtocolVersion`) with required /// `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index 35825e387..87059d7bb 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,21 +3,53 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "0.10.0" +public let PROTOCOL_VERSION: String = "1.0.0" /// Every protocol version this package is willing to negotiate, -/// ordered most-preferred-first. The first entry equals +/// ordered most-preferred-first, independently of the development /// ``PROTOCOL_VERSION``. /// /// Pass this list (or a derived `[String]`) as `protocolVersions` on /// `InitializeParams` so the same client binary can fall back to older /// protocol versions if the host doesn't accept the newest one. public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [ - "0.10.0", + "1.0.0", "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1", ] + +/// Select the highest offered version in a supported caret range. +/// Nil means the host must send UnsupportedProtocolVersion and close. +/// Malformed versions throw. +public func negotiateProtocolVersion(_ offered: [String]) throws -> String? { + func parse(_ version: String) throws -> [UInt64] { + let parts = version.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { + throw NSError(domain: "AgentHostProtocol.InvalidProtocolVersion", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid protocol version: \(version)"]) + } + return try parts.map { part in + guard !part.isEmpty, !(part.count > 1 && part.first == "0"), + part.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), + let value = UInt64(part) else { + throw NSError(domain: "AgentHostProtocol.InvalidProtocolVersion", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid protocol version: \(version)"]) + } + return value + } + } + let baselines = try SUPPORTED_PROTOCOL_VERSIONS.map(parse) + var selected: String? + var previous: [UInt64]? + for version in offered { + let parts = try parse(version) + if baselines.contains(where: { base in + parts[0] == base[0] && (parts[0] > 0 || parts[1] == base[1]) && + (parts[0] > 0 || parts[1] > 0 || parts[2] == base[2]) && + !parts.lexicographicallyPrecedes(base) + }) && (previous.map { $0.lexicographicallyPrecedes(parts) } ?? true) { + selected = version + previous = parts + } + } + return selected +} diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift index fc3031a3f..7d2f1350f 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolClientTests/TypesRoundTripFixtureTests.swift @@ -226,9 +226,9 @@ final class TypesRoundTripFixtureTests: XCTestCase { "SUPPORTED_PROTOCOL_VERSIONS must be non-empty" ) XCTAssertEqual( - SUPPORTED_PROTOCOL_VERSIONS.first, - PROTOCOL_VERSION, - "first SUPPORTED_PROTOCOL_VERSIONS entry must equal PROTOCOL_VERSION" + SUPPORTED_PROTOCOL_VERSIONS, + ["1.0.0", "0.9.0"], + "only released compatibility baselines should be advertised" ) } diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/VersionNegotiationTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/VersionNegotiationTests.swift new file mode 100644 index 000000000..56a887430 --- /dev/null +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/VersionNegotiationTests.swift @@ -0,0 +1,26 @@ +import Foundation +import XCTest +import AgentHostProtocol + +final class VersionNegotiationTests: XCTestCase { + private struct Case: Decodable { + let offered: [String] + let expected: String? + let invalid: Bool? + } + + func testSharedCorpus() throws { + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<6 { root.deleteLastPathComponent() } + let data = try Data(contentsOf: root.appendingPathComponent("types/test-cases/version-negotiation.json")) + let cases = try JSONDecoder().decode([Case].self, from: data) + XCTAssertFalse(cases.isEmpty) + for fixture in cases { + if fixture.invalid == true { + XCTAssertThrowsError(try negotiateProtocolVersion(fixture.offered)) + } else { + XCTAssertEqual(try negotiateProtocolVersion(fixture.offered), fixture.expected) + } + } + } +} diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index d3bd18a09..9839cec7f 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -17,6 +17,35 @@ the tag matches the version pinned in [`VERSION`](VERSION). ## [Unreleased] +## [1.0.0] — 2026-10-02 + +Implements AHP 1.0.0. + +### Added + +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. + +### Changed + +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. + ## [0.9.0] — 2026-08-28 Implements AHP 0.9.0. diff --git a/clients/swift/VERSION b/clients/swift/VERSION index ac39a106c..3eefcb9dd 100644 --- a/clients/swift/VERSION +++ b/clients/swift/VERSION @@ -1 +1 @@ -0.9.0 +1.0.0 diff --git a/clients/swift/release-metadata.json b/clients/swift/release-metadata.json index 485b6ec3d..c2c60723c 100644 --- a/clients/swift/release-metadata.json +++ b/clients/swift/release-metadata.json @@ -1,13 +1,8 @@ { "client": "swift", - "packageVersion": "0.9.0", + "packageVersion": "1.0.0", "supportedProtocolVersions": [ - "0.10.0", - "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1" + "1.0.0", + "0.9.0" ] } diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index d8db7cdde..b395ed07a 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -20,6 +20,36 @@ hotfix escape hatch. ## [Unreleased] +## [1.0.0] — 2026-10-02 + +Implements AHP 1.0.0. + +### Added + +- `ManagedSubscriptionManager` provides typed shared leases, initial event buffering, disposable last-holder unsubscribe, and retry-safe failure cleanup. +- `AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds. +- `SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription. +- `chat/isArchivedChanged` action for archiving a chat independently of its session. +- `ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel. +- `AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation. +- `ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation. +- `ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: "array"` config properties. (#432) +- `McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup. +- `AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`. +- Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels. +- Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs. +- Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime. +- `ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription. +- `SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription. +- `chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields. +- `SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription. + +### Changed + +- Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output. +- `FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged. +- Advertise only the 1.0.0 and 0.9.0 protocol compatibility baselines independently of the development version. Host-side negotiation helpers select the highest client-offered version within either caret-compatible range and report malformed offers explicitly. + ## [0.9.0] — 2026-08-28 Implements AHP 0.9.0. diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json index 163dd8648..2a6eebb49 100644 --- a/clients/typescript/package-lock.json +++ b/clients/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microsoft/agent-host-protocol", - "version": "0.9.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/agent-host-protocol", - "version": "0.9.0", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@types/node": "^22.10.0", diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 0494576d6..46a64c56e 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@microsoft/agent-host-protocol", - "version": "0.9.0", + "version": "1.0.0", "description": "TypeScript client for the Agent Host Protocol (AHP).", "type": "module", "license": "MIT", diff --git a/clients/typescript/release-metadata.json b/clients/typescript/release-metadata.json index c7d90aa5d..39397903c 100644 --- a/clients/typescript/release-metadata.json +++ b/clients/typescript/release-metadata.json @@ -1,13 +1,8 @@ { "client": "typescript", - "packageVersion": "0.9.0", + "packageVersion": "1.0.0", "supportedProtocolVersions": [ - "0.10.0", - "0.9.0", - "0.8.0", - "0.7.0", - "0.6.0", - "0.5.2", - "0.5.1" + "1.0.0", + "0.9.0" ] } diff --git a/clients/typescript/test/types-round-trip.test.ts b/clients/typescript/test/types-round-trip.test.ts index f5736a2be..e7f9aece6 100644 --- a/clients/typescript/test/types-round-trip.test.ts +++ b/clients/typescript/test/types-round-trip.test.ts @@ -287,9 +287,5 @@ test('ProtocolVersion constants', () => { 'SUPPORTED_PROTOCOL_VERSIONS must be a non-empty array', ); - assert.equal( - SUPPORTED_PROTOCOL_VERSIONS[0], - PROTOCOL_VERSION, - `first SUPPORTED_PROTOCOL_VERSIONS entry "${SUPPORTED_PROTOCOL_VERSIONS[0]}" must equal PROTOCOL_VERSION "${PROTOCOL_VERSION}"`, - ); + assert.deepEqual(SUPPORTED_PROTOCOL_VERSIONS, ['1.0.0', '0.9.0']); }); diff --git a/docs/.changes/20260826-typescript-managed-subscriptions.json b/docs/.changes/20260826-typescript-managed-subscriptions.json deleted file mode 100644 index 0dc0b9211..000000000 --- a/docs/.changes/20260826-typescript-managed-subscriptions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "added", - "message": "`ManagedSubscriptionManager` provides typed shared leases, initial event buffering, disposable last-holder unsubscribe, and retry-safe failure cleanup.", - "targets": ["typescript"] -} diff --git a/docs/.changes/20260827-dotnet-typescript-session-conformance.json b/docs/.changes/20260827-dotnet-typescript-session-conformance.json deleted file mode 100644 index c83382b85..000000000 --- a/docs/.changes/20260827-dotnet-typescript-session-conformance.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "added", - "message": "A hermetic real-WebSocket conformance lane now verifies .NET session negotiation, initialize snapshot seeding, and streamed reducer convergence against a repository-local TypeScript host.", - "targets": ["dotnet"] -} diff --git a/docs/.changes/20260901-authenticate-token-lifetime.json b/docs/.changes/20260901-authenticate-token-lifetime.json deleted file mode 100644 index 5014ccfc2..000000000 --- a/docs/.changes/20260901-authenticate-token-lifetime.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds." -} diff --git a/docs/.changes/20260907-terminal-full-output.json b/docs/.changes/20260907-terminal-full-output.json deleted file mode 100644 index 232ce7631..000000000 --- a/docs/.changes/20260907-terminal-full-output.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "changed", - "message": "Historical `ToolResultTerminalContent.resource` subscriptions return lazily reconstructed exited terminal state with retained output." -} diff --git a/docs/.changes/20260914-typed-file-edits.json b/docs/.changes/20260914-typed-file-edits.json deleted file mode 100644 index 1a52cc925..000000000 --- a/docs/.changes/20260914-typed-file-edits.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "changed", - "message": "`FileEdit` and preview `edits` now use named models in generated SDKs. This breaks affected SDK APIs but keeps JSON shapes unchanged." -} diff --git a/docs/.changes/20260915-session-summary-chat-catalog.json b/docs/.changes/20260915-session-summary-chat-catalog.json deleted file mode 100644 index 6e9ae3989..000000000 --- a/docs/.changes/20260915-session-summary-chat-catalog.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`SessionSummary.chats`, `SessionChatSummary.interactivity`, and `SessionSummary.defaultChat` expose lightweight chat navigation data without requiring a session subscription." -} diff --git a/docs/.changes/20260920-chat-archive-action.json b/docs/.changes/20260920-chat-archive-action.json deleted file mode 100644 index 00bca8a6f..000000000 --- a/docs/.changes/20260920-chat-archive-action.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`chat/isArchivedChanged` action for archiving a chat independently of its session." -} diff --git a/docs/.changes/20260921-per-chat-changesets.json b/docs/.changes/20260921-per-chat-changesets.json deleted file mode 100644 index c52eb933d..000000000 --- a/docs/.changes/20260921-per-chat-changesets.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`ChatState.changesets` and `chat/changesetsChanged` expose each chat's changeset catalogue through the subscribed chat channel." -} diff --git a/docs/.changes/20260922-automation-disable-conditions.json b/docs/.changes/20260922-automation-disable-conditions.json deleted file mode 100644 index e18b600ec..000000000 --- a/docs/.changes/20260922-automation-disable-conditions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`AutomationDefinition.disableConditions` supports `afterRuns` (`max`) and `afterDate` (`date`) rules combined with logical OR, with at most one condition per kind. Host-owned `AutomationEntry.runCount` tracks the current scheduled-run allowance. Edit conditions through `automation/updateRequested`: omission leaves them unchanged, an array replaces them, and `[]` removes all conditions without re-enabling the automation." -} diff --git a/docs/.changes/20260922-changeset-recomputing-status.json b/docs/.changes/20260922-changeset-recomputing-status.json deleted file mode 100644 index b7cee8200..000000000 --- a/docs/.changes/20260922-changeset-recomputing-status.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`ChangesetStatus.Recomputing` distinguishes refreshes of a completed result from initial changeset computation." -} diff --git a/docs/.changes/20260923-config-array-cardinality.json b/docs/.changes/20260923-config-array-cardinality.json deleted file mode 100644 index a6e5d53b5..000000000 --- a/docs/.changes/20260923-config-array-cardinality.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "added", - "message": "`ConfigPropertySchema.minItems` and `ConfigPropertySchema.maxItems` express array cardinality for `type: \"array\"` config properties.", - "issues": [432] -} diff --git a/docs/.changes/20260923-dotnet-discriminator-defaults.json b/docs/.changes/20260923-dotnet-discriminator-defaults.json deleted file mode 100644 index e4b22c57a..000000000 --- a/docs/.changes/20260923-dotnet-discriminator-defaults.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "fixed", - "message": "Generated action and union-variant records now pin their own literal discriminator as the property initializer, so constructing one without setting the discriminator serializes the correct wire value. Previously the property fell back to the type's zero value — the *first* enum member, which silently emitted the wrong discriminator on every record but the first.", - "targets": ["dotnet"], - "issues": [366] -} diff --git a/docs/.changes/20260923-dotnet-open-enum-forward-compat.json b/docs/.changes/20260923-dotnet-open-enum-forward-compat.json deleted file mode 100644 index 67d51badc..000000000 --- a/docs/.changes/20260923-dotnet-open-enum-forward-compat.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "fixed", - "message": "Open (`@nonexhaustive`) protocol enums no longer fail decoding when a newer peer sends a wire value this build does not recognize. They are now generated as readonly structs wrapping the raw wire string (with the known values as static members) instead of closed C# enums, so an added enum value round-trips verbatim as `versioning.md` requires. Each open enum gets a generated converter, so the path no longer relies on reflection.", - "targets": ["dotnet"], - "issues": [366] -} diff --git a/docs/.changes/20260923-dotnet-union-discriminator-forward-compat.json b/docs/.changes/20260923-dotnet-union-discriminator-forward-compat.json deleted file mode 100644 index 3941bacf6..000000000 --- a/docs/.changes/20260923-dotnet-union-discriminator-forward-compat.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "fixed", - "message": "Discriminated unions now derive whether an unrecognized discriminator is preserved from the discriminator enum's `@exhaustive` / `@nonexhaustive` annotation, matching the other generators. `SessionOrigin` and `CustomizationEnablement` previously rejected a discriminator added by a newer peer even though their discriminants are open; `ChangesetOperationTarget` is likewise derived now.", - "targets": ["dotnet"], - "issues": [366] -} diff --git a/docs/.changes/20260923-mcp-server-background-requested.json b/docs/.changes/20260923-mcp-server-background-requested.json deleted file mode 100644 index 95f6b72e6..000000000 --- a/docs/.changes/20260923-mcp-server-background-requested.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`McpServerStartingState.blocking` flag indicating that message processing may be held on the server, and the client-dispatchable `session/mcpServerBackgroundRequested` action asking the host to background such a startup." -} diff --git a/docs/.changes/20260924-automation-customizations.json b/docs/.changes/20260924-automation-customizations.json deleted file mode 100644 index 3b93d1988..000000000 --- a/docs/.changes/20260924-automation-customizations.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`AutomationSessionTemplate.customizations` lets automations carry client plugins (skills, agents, prompts, rules) that the host captures when the definition is saved, with the host-owned copies reported in `AutomationEntry.customizations` and support advertised by `AutomationCapabilities.customizations`." -} diff --git a/docs/.changes/20260924-chat-canvases.json b/docs/.changes/20260924-chat-canvases.json deleted file mode 100644 index 1e1cc8489..000000000 --- a/docs/.changes/20260924-chat-canvases.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "Hosts can advertise chat-owned canvas channel references and synchronize live presentation metadata and source URLs on experimental per-canvas channels." -} diff --git a/docs/.changes/20260924-chat-move.json b/docs/.changes/20260924-chat-move.json deleted file mode 100644 index e47cf42c7..000000000 --- a/docs/.changes/20260924-chat-move.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "Atomic stable-URI `moveChat` transfer and same-session ordering for host-authorized chats, with per-chat `movable` discovery and durable authoritative catalogs." -} diff --git a/docs/.changes/20260924-rust-client-plugin-type.json b/docs/.changes/20260924-rust-client-plugin-type.json deleted file mode 100644 index 886869dbb..000000000 --- a/docs/.changes/20260924-rust-client-plugin-type.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "fixed", - "message": "`ClientPluginCustomization` now serializes its `\"type\": \"plugin\"` discriminant, so plugins published in `SessionActiveClient.customizations` are no longer sent without `type`.", - "targets": ["rust"] -} diff --git a/docs/.changes/20260925-dotnet-chat-move-unknown.json b/docs/.changes/20260925-dotnet-chat-move-unknown.json deleted file mode 100644 index bffeff9ed..000000000 --- a/docs/.changes/20260925-dotnet-chat-move-unknown.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "fixed", - "message": "Preserve unknown future chat move destinations when decoding and re-encoding with the .NET client.", - "targets": ["dotnet"] -} diff --git a/docs/.changes/20260929-chat-background-work.json b/docs/.changes/20260929-chat-background-work.json deleted file mode 100644 index 479c8be16..000000000 --- a/docs/.changes/20260929-chat-background-work.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "Expose chat-owned background work (background shells and subagents) in chat state, with `chat/backgroundWorkSet` and `chat/backgroundWorkRemoved` actions independent of turn lifetime." -} diff --git a/docs/.changes/20260929-chat-changes-summary.json b/docs/.changes/20260929-chat-changes-summary.json deleted file mode 100644 index 1f8792d2d..000000000 --- a/docs/.changes/20260929-chat-changes-summary.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`ChatSummary.changes` and `ChatState.changes` provide aggregate file-change counts without requiring a changeset subscription." -} diff --git a/docs/.changes/20260929-session-chat-summary-archived.json b/docs/.changes/20260929-session-chat-summary-archived.json deleted file mode 100644 index 208a962fb..000000000 --- a/docs/.changes/20260929-session-chat-summary-archived.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription." -} diff --git a/docs/.changes/20261001-chat-read-state-action.json b/docs/.changes/20261001-chat-read-state-action.json deleted file mode 100644 index f03094090..000000000 --- a/docs/.changes/20261001-chat-read-state-action.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields." -} diff --git a/docs/.changes/20261001-session-chat-summary-changes.json b/docs/.changes/20261001-session-chat-summary-changes.json deleted file mode 100644 index ba2e78d31..000000000 --- a/docs/.changes/20261001-session-chat-summary-changes.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "added", - "message": "`SessionChatSummary.changes` exposes per-chat change counts in the lightweight chat catalog without requiring a session subscription." -} diff --git a/docs/.changes/20261002-dotnet-strong-name-signing.json b/docs/.changes/20261002-dotnet-strong-name-signing.json deleted file mode 100644 index 3ee9fd412..000000000 --- a/docs/.changes/20261002-dotnet-strong-name-signing.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "changed", - "message": "The .NET assemblies are now strong-name signed with the Microsoft Shared Libraries key.", - "targets": ["dotnet"] -} diff --git a/docs/specification/lifecycle.md b/docs/specification/lifecycle.md index a2dd20cc3..9e8acb52e 100644 --- a/docs/specification/lifecycle.md +++ b/docs/specification/lifecycle.md @@ -31,7 +31,7 @@ The client initiates the connection with an `initialize` **request**. The client } ``` -`protocolVersions` is ordered from most preferred to least preferred. The server picks one entry and returns it as `InitializeResult.protocolVersion`. If the server cannot speak any of the offered versions it MUST return [`UnsupportedProtocolVersion`](/reference/error-codes) (`-32005`) with required `data.supportedVersions` instead of a result. See [Versioning](/specification/versioning) for the negotiation rules. +`protocolVersions` is ordered from most preferred to least preferred. The server selects the highest offered SemVer within its supported caret-compatible ranges and returns that exact entry as `InitializeResult.protocolVersion`. If the server cannot speak any of the offered versions it MUST return [`UnsupportedProtocolVersion`](/reference/error-codes) (`-32005`) with required `data.supportedVersions` instead of a result. See [Versioning](/specification/versioning) for the negotiation rules. `initialSubscriptions` allows the client to subscribe to channels in the same round-trip as the handshake — typically `ahp-root://` plus any previously-open session URIs. diff --git a/docs/specification/versioning.md b/docs/specification/versioning.md index 6f61c91ad..713103a2a 100644 --- a/docs/specification/versioning.md +++ b/docs/specification/versioning.md @@ -54,11 +54,19 @@ Protocol versions are [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` strings ( Version selection happens once, during the [`initialize`](/specification/lifecycle) handshake — modelled after WebSocket subprotocol negotiation: 1. The client sends `InitializeParams.protocolVersions`: an array of every protocol version it is willing to speak, ordered from most preferred to least preferred. -2. The server picks one entry it can speak and returns it as `InitializeResult.protocolVersion`. Servers SHOULD honor the client's preference order when multiple offered versions are acceptable. +2. The server selects the highest SemVer entry within a supported caret-compatible range and returns that exact offered string as `InitializeResult.protocolVersion`. The released compatibility baselines are `1.0.0` and `0.9.0`: hosts accept `>=1.0.0 <2.0.0` or `>=0.9.0 <0.10.0`, regardless of offer order. Development versions are independent of these advertised baselines. 3. If the server cannot speak any of the offered versions, it MUST respond with [`UnsupportedProtocolVersion`](/reference/error-codes) (`-32005`) and required `data.supportedVersions` instead of a result, and close the connection. Both peers MUST use the selected version for the rest of the connection. There is no per-message renegotiation. +The SDKs expose host-side selection helpers: TypeScript, Kotlin, and Swift +`negotiateProtocolVersion`, Rust `negotiate_protocol_version`, Go +`NegotiateProtocolVersion`, and .NET `ProtocolVersion.Negotiate`. +Pass the client's offered version list. A missing result means the host must send +`UnsupportedProtocolVersion` with its supported baselines and close the connection. +Malformed versions produce an explicit error; versions must contain three +non-negative integer components without leading zeros, prerelease or build metadata. + ## Compatibility Guarantee AHP follows standard SemVer compatibility: diff --git a/schema/commands.schema.json b/schema/commands.schema.json index a3750b147..b091b38f4 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -88,7 +88,7 @@ "items": { "type": "string" }, - "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects one entry and returns it as `InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." + "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects the highest offered SemVer within its supported\ncaret-compatible ranges and returns that exact entry as\n`InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." }, "clientId": { "type": "string", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index fb08af031..238f4a7cf 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -6068,7 +6068,7 @@ "items": { "type": "string" }, - "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects one entry and returns it as `InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." + "description": "Protocol versions the client is willing to speak, ordered from most\npreferred to least preferred. Each entry is a [SemVer](https://semver.org)\n`MAJOR.MINOR.PATCH` string (e.g. `\"0.1.0\"`).\n\nThe server selects the highest offered SemVer within its supported\ncaret-compatible ranges and returns that exact entry as\n`InitializeResult.protocolVersion`.\nIf the server cannot speak any of the offered versions, it MUST return\nerror code `-32005` (`UnsupportedProtocolVersion`) with required\n`UnsupportedProtocolVersionErrorData` containing `supportedVersions`." }, "clientId": { "type": "string", diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index bcc6f595a..61e397f03 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -2720,11 +2720,61 @@ ${supportedLiteral} /// /// Every protocol version this client is willing to negotiate, ordered - /// most-preferred-first. The first entry always equals . + /// most-preferred-first, independently of the development . /// A fresh copy is returned on every call so callers may mutate it freely. /// public static IReadOnlyList Supported => (string[])s_supported.Clone(); + /// + /// Selects the highest offered version in a supported caret range. + /// Null means the host must send UnsupportedProtocolVersion and close. + /// Malformed versions throw ArgumentException. + /// + public static string? Negotiate(IEnumerable offered) + { + Guard.ThrowIfNull(offered, nameof(offered)); + static long[] Parse(string version) + { + if (version is null || !System.Text.RegularExpressions.Regex.IsMatch( + version, @"\\A(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\z")) + throw new ArgumentException($"Invalid protocol version: {version}", nameof(version)); + var parts = version.Split('.'); + var values = new long[3]; + for (var i = 0; i < 3; i++) + { + if (!long.TryParse(parts[i], System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out values[i])) + throw new ArgumentException($"Invalid protocol version: {version}", nameof(version)); + } + return values; + } + static int Compare(long[] a, long[] b) + { + for (var i = 0; i < 3; i++) + { + var comparison = a[i].CompareTo(b[i]); + if (comparison != 0) return comparison; + } + return 0; + } + var baselines = new List(); + foreach (var baseline in s_supported) baselines.Add(Parse(baseline)); + string? selected = null; + long[]? previous = null; + foreach (var version in offered) + { + var parts = Parse(version); + if (baselines.Exists(b => parts[0] == b[0] && (parts[0] > 0 || parts[1] == b[1]) + && (parts[0] > 0 || parts[1] > 0 || parts[2] == b[2]) && Compare(parts, b) >= 0) + && (previous is null || Compare(parts, previous) > 0)) + { + selected = version; + previous = parts; + } + } + return selected; + } + /// The well-known channel URI for the root channel. public const string RootResourceUri = "ahp-root://"; } diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts index 10cc2754c..80e675d9d 100644 --- a/scripts/generate-go.ts +++ b/scripts/generate-go.ts @@ -2304,6 +2304,12 @@ function generateVersionFile(project: Project): string { const { current, supported } = readProtocolVersions(project); const supportedLiteral = supported.map((v) => `\t${JSON.stringify(v)},`).join('\n'); return `${GENERATED_BANNER} +import ( + "fmt" + "strconv" + "strings" +) + // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. const ProtocolVersion = ${JSON.stringify(current)} @@ -2316,14 +2322,64 @@ ${supportedLiteral} } // SupportedProtocolVersions returns every protocol version this client -// is willing to negotiate, ordered most-preferred-first. The first -// entry always equals [ProtocolVersion]. The returned slice is a fresh +// is willing to negotiate, ordered most-preferred-first, independently +// of the development [ProtocolVersion]. The returned slice is a fresh // copy on every call so callers may mutate it freely. func SupportedProtocolVersions() []string { \tout := make([]string, len(supportedProtocolVersions)) \tcopy(out, supportedProtocolVersions) \treturn out } + +// NegotiateProtocolVersion selects the highest offered version in a supported +// caret range. An empty result means the host must send UnsupportedProtocolVersion +// and close. Malformed versions return an error. +func NegotiateProtocolVersion(offered []string) (string, error) { + parse := func(version string) ([3]uint64, error) { + var result [3]uint64 + parts := strings.Split(version, ".") + if len(parts) != 3 { return result, fmt.Errorf("invalid protocol version: %s", version) } + for i, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return result, fmt.Errorf("invalid protocol version: %s", version) + } + for _, digit := range part { + if digit < '0' || digit > '9' { return result, fmt.Errorf("invalid protocol version: %s", version) } + } + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { return result, fmt.Errorf("invalid protocol version: %s", version) } + result[i] = value + } + return result, nil + } + compare := func(a, b [3]uint64) int { + for i := range a { + if a[i] < b[i] { return -1 } + if a[i] > b[i] { return 1 } + } + return 0 + } + var baselines [][3]uint64 + for _, baseline := range supportedProtocolVersions { + parts, err := parse(baseline) + if err != nil { return "", err } + baselines = append(baselines, parts) + } + var selected string + var previous [3]uint64 + for _, version := range offered { + parts, err := parse(version) + if err != nil { return "", err } + for _, base := range baselines { + if parts[0] == base[0] && (parts[0] > 0 || parts[1] == base[1]) && + (parts[0] > 0 || parts[1] > 0 || parts[2] == base[2]) && + compare(parts, base) >= 0 && (selected == "" || compare(parts, previous) > 0) { + selected, previous = version, parts + } + } + } + return selected, nil +} `; } diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts index bc475a6c8..3b979e9d8 100644 --- a/scripts/generate-kotlin.ts +++ b/scripts/generate-kotlin.ts @@ -2464,7 +2464,7 @@ function generateVersionFile(project: Project): string { `public const val PROTOCOL_VERSION: String = ${JSON.stringify(current)}\n\n` + '/**\n' + ' * Every protocol version this library is willing to negotiate, ordered\n' + - ' * most-preferred-first. The first entry equals [PROTOCOL_VERSION].\n' + + ' * most-preferred-first, independently of the development [PROTOCOL_VERSION].\n' + ' *\n' + ' * Pass this list (or a derived `List`) as `protocolVersions` on\n' + ' * `InitializeParams` so the same client binary can fall back to older\n' + @@ -2472,7 +2472,46 @@ function generateVersionFile(project: Project): string { ' */\n' + 'public val SUPPORTED_PROTOCOL_VERSIONS: List = listOf(\n' + items + - '\n)\n' + '\n)\n' + + ` +/** Select the highest offered version in a supported caret range. + * Null means the host must send UnsupportedProtocolVersion and close. + * Malformed versions throw IllegalArgumentException. + */ +public fun negotiateProtocolVersion(offered: List): String? { + fun parse(version: String): List { + require(Regex("(0|[1-9][0-9]*)\\\\.(0|[1-9][0-9]*)\\\\.(0|[1-9][0-9]*)").matches(version)) { + "Invalid protocol version: $version" + } + return version.split('.').map { + val value = it.toLongOrNull() + require(value != null) { "Invalid protocol version: $version" } + value + } + } + fun compare(a: List, b: List): Int { + for (i in 0..2) { + val comparison = a[i].compareTo(b[i]) + if (comparison != 0) return comparison + } + return 0 + } + val baselines = SUPPORTED_PROTOCOL_VERSIONS.map(::parse) + var selected: String? = null + var previous: List? = null + for (version in offered) { + val parts = parse(version) + if (baselines.any { base -> + parts[0] == base[0] && (parts[0] > 0 || parts[1] == base[1]) && + (parts[0] > 0 || parts[1] > 0 || parts[2] == base[2]) && compare(parts, base) >= 0 + } && (previous == null || compare(parts, previous) > 0)) { + selected = version + previous = parts + } + } + return selected +} +` ); } diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts index f1d4ce2dd..8d0accf0c 100644 --- a/scripts/generate-rust.ts +++ b/scripts/generate-rust.ts @@ -2265,7 +2265,7 @@ function generateVersionFile(project: Project): string { pub const PROTOCOL_VERSION: &str = ${JSON.stringify(current)}; /// Every protocol version this crate is willing to negotiate, ordered -/// most-preferred-first. The first entry equals [\`PROTOCOL_VERSION\`]. +/// most-preferred-first, independently of the development [\`PROTOCOL_VERSION\`]. /// /// Consumers building \`InitializeParams\` should pass this slice (or a /// derived \`Vec\`) so the same client binary can fall back to @@ -2273,6 +2273,41 @@ pub const PROTOCOL_VERSION: &str = ${JSON.stringify(current)}; pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[ ${supportedLiteral} ]; + +/// Select the highest offered version in a supported caret range. +/// None means the host must send UnsupportedProtocolVersion and close. +pub fn negotiate_protocol_version(offered: &[String]) -> Result, String> { + fn parse(version: &str) -> Result<(u64, u64, u64), String> { + let invalid = || format!("Invalid protocol version: {version}"); + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() != 3 { + return Err(invalid()); + } + let mut values = [0; 3]; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid()); + } + values[index] = part.parse::().map_err(|_| invalid())?; + } + Ok((values[0], values[1], values[2])) + } + let baselines = SUPPORTED_PROTOCOL_VERSIONS.iter() + .map(|version| parse(version)).collect::, _>>()?; + let mut selected: Option<(&str, (u64, u64, u64))> = None; + for version in offered { + let parts = parse(version)?; + if baselines.iter().any(|base| parts.0 == base.0 + && (parts.0 > 0 || parts.1 == base.1) + && (parts.0 > 0 || parts.1 > 0 || parts.2 == base.2) + && parts >= *base) + && selected.as_ref().map_or(true, |(_, previous)| parts > *previous) { + selected = Some((version.as_str(), parts)); + } + } + Ok(selected.map(|(version, _)| version)) +} `; } diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 51db38837..8a23e60fb 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -2477,7 +2477,7 @@ function generateVersionFile(project: Project): string { '/// Current protocol version (SemVer `MAJOR.MINOR.PATCH`).\n' + `public let PROTOCOL_VERSION: String = ${JSON.stringify(current)}\n\n` + '/// Every protocol version this package is willing to negotiate,\n' + - '/// ordered most-preferred-first. The first entry equals\n' + + '/// ordered most-preferred-first, independently of the development\n' + '/// ``PROTOCOL_VERSION``.\n' + '///\n' + '/// Pass this list (or a derived `[String]`) as `protocolVersions` on\n' + @@ -2485,7 +2485,45 @@ function generateVersionFile(project: Project): string { "/// protocol versions if the host doesn't accept the newest one.\n" + 'public let SUPPORTED_PROTOCOL_VERSIONS: [String] = [\n' + items + - '\n]\n' + '\n]\n' + + ` +/// Select the highest offered version in a supported caret range. +/// Nil means the host must send UnsupportedProtocolVersion and close. +/// Malformed versions throw. +public func negotiateProtocolVersion(_ offered: [String]) throws -> String? { + func parse(_ version: String) throws -> [UInt64] { + let parts = version.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { + throw NSError(domain: "AgentHostProtocol.InvalidProtocolVersion", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid protocol version: \\(version)"]) + } + return try parts.map { part in + guard !part.isEmpty, !(part.count > 1 && part.first == "0"), + part.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), + let value = UInt64(part) else { + throw NSError(domain: "AgentHostProtocol.InvalidProtocolVersion", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid protocol version: \\(version)"]) + } + return value + } + } + let baselines = try SUPPORTED_PROTOCOL_VERSIONS.map(parse) + var selected: String? + var previous: [UInt64]? + for version in offered { + let parts = try parse(version) + if baselines.contains(where: { base in + parts[0] == base[0] && (parts[0] > 0 || parts[1] == base[1]) && + (parts[0] > 0 || parts[1] > 0 || parts[2] == base[2]) && + !parts.lexicographicallyPrecedes(base) + }) && (previous.map { $0.lexicographicallyPrecedes(parts) } ?? true) { + selected = version + previous = parts + } + } + return selected +} +` ); } diff --git a/scripts/read-protocol-versions.ts b/scripts/read-protocol-versions.ts index 892826bc7..a5946eb88 100644 --- a/scripts/read-protocol-versions.ts +++ b/scripts/read-protocol-versions.ts @@ -27,8 +27,8 @@ export interface ProtocolVersions { /** * Read the protocol version constants from `types/version/registry.ts`. * Throws if the registry source file is not present in `project`, or if - * either constant is missing, malformed, or violates the ordering - * invariant. Callers building a partial ts-morph project must include + * either constant is missing or has an unexpected shape. Released baselines + * may differ from the development version. Callers building a partial project must include * `types/version/registry.ts` among the source files. */ export function readProtocolVersions(project: Project): ProtocolVersions { @@ -63,13 +63,6 @@ export function readProtocolVersions(project: Project): ProtocolVersions { 'readProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS missing or empty', ); } - if (supported[0] !== current) { - throw new Error( - `readProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS[0] (${supported[0]}) ` + - `must equal PROTOCOL_VERSION (${current})`, - ); - } - return { current, supported }; } diff --git a/types/common/commands.ts b/types/common/commands.ts index b7adb7c60..6afc4e5bb 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -162,7 +162,9 @@ export interface InitializeParams extends BaseParams { * preferred to least preferred. Each entry is a [SemVer](https://semver.org) * `MAJOR.MINOR.PATCH` string (e.g. `"0.1.0"`). * - * The server selects one entry and returns it as `InitializeResult.protocolVersion`. + * The server selects the highest offered SemVer within its supported + * caret-compatible ranges and returns that exact entry as + * `InitializeResult.protocolVersion`. * If the server cannot speak any of the offered versions, it MUST return * error code `-32005` (`UnsupportedProtocolVersion`) with required * `UnsupportedProtocolVersionErrorData` containing `supportedVersions`. diff --git a/types/test-cases/version-negotiation.json b/types/test-cases/version-negotiation.json new file mode 100644 index 000000000..f47bc93e5 --- /dev/null +++ b/types/test-cases/version-negotiation.json @@ -0,0 +1,24 @@ +[ + { "offered": [], "expected": null }, + { "offered": ["0.9.0"], "expected": "0.9.0" }, + { "offered": ["1.0.0"], "expected": "1.0.0" }, + { "offered": ["0.9.0", "1.0.0"], "expected": "1.0.0" }, + { "offered": ["1.0.0", "0.9.0"], "expected": "1.0.0" }, + { "offered": ["0.9.8", "0.9.10"], "expected": "0.9.10" }, + { "offered": ["1.1.0", "1.10.0", "1.2.99"], "expected": "1.10.0" }, + { "offered": ["1.2.99", "1.10.0", "1.1.0"], "expected": "1.10.0" }, + { "offered": ["2.0.0", "0.10.0", "0.8.99"], "expected": null }, + { "offered": ["2.0.0", "0.10.0", "0.9.1"], "expected": "0.9.1" }, + { "offered": ["1.0.0", "1.0.0"], "expected": "1.0.0" }, + { "offered": ["1.0"], "invalid": true }, + { "offered": ["1.0.0.0"], "invalid": true }, + { "offered": ["01.0.0"], "invalid": true }, + { "offered": ["1.00.0"], "invalid": true }, + { "offered": ["1.0.01"], "invalid": true }, + { "offered": ["1.0.0-beta"], "invalid": true }, + { "offered": ["1.0.0+build"], "invalid": true }, + { "offered": [" 1.0.0"], "invalid": true }, + { "offered": ["1.0.0\n"], "invalid": true }, + { "offered": ["1.-1.0"], "invalid": true }, + { "offered": ["1.0.0", "invalid"], "invalid": true } +] diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts index 93509bab3..9d49290a9 100644 --- a/types/version/registry.test.ts +++ b/types/version/registry.test.ts @@ -5,13 +5,14 @@ * - {@link PROTOCOL_VERSION} parses as `MAJOR.MINOR.PATCH`. * - {@link SUPPORTED_PROTOCOL_VERSIONS} is non-empty. * - Every entry parses as `MAJOR.MINOR.PATCH`. - * - The first entry equals {@link PROTOCOL_VERSION} (most-preferred-first). + * - Released compatibility baselines are independent of the development version. * - The list is strictly descending by SemVer. * - No duplicate entries. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { ACTION_INTRODUCED_IN, @@ -19,11 +20,24 @@ import { SUPPORTED_PROTOCOL_VERSIONS, compareProtocolVersions, isActionKnownToVersion, + negotiateProtocolVersion, } from './registry.js'; import { ActionType } from '../actions.js'; const SEMVER_RE = /^\d+\.\d+\.\d+$/; +test('host negotiation follows the shared compatibility corpus', () => { + const cases: { offered: string[]; expected?: string | null; invalid?: boolean }[] = + JSON.parse(readFileSync(new URL('../test-cases/version-negotiation.json', import.meta.url), 'utf8')); + for (const fixture of cases) { + if (fixture.invalid) { + assert.throws(() => negotiateProtocolVersion(fixture.offered), /Invalid protocol version/); + } else { + assert.equal(negotiateProtocolVersion(fixture.offered), fixture.expected ?? undefined); + } + } +}); + test('PROTOCOL_VERSION is MAJOR.MINOR.PATCH', () => { assert.match(PROTOCOL_VERSION, SEMVER_RE); }); @@ -38,8 +52,8 @@ test('every SUPPORTED_PROTOCOL_VERSIONS entry is MAJOR.MINOR.PATCH', () => { } }); -test('SUPPORTED_PROTOCOL_VERSIONS[0] equals PROTOCOL_VERSION', () => { - assert.equal(SUPPORTED_PROTOCOL_VERSIONS[0], PROTOCOL_VERSION); +test('SUPPORTED_PROTOCOL_VERSIONS contains only released compatibility baselines', () => { + assert.deepEqual(SUPPORTED_PROTOCOL_VERSIONS, ['1.0.0', '0.9.0']); }); test('SUPPORTED_PROTOCOL_VERSIONS is strictly descending', () => { diff --git a/types/version/registry.ts b/types/version/registry.ts index 5823b5c4e..806f9a4d2 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.10.0'; +export const PROTOCOL_VERSION = '1.0.0'; /** * Every protocol version a client built from this source tree is willing @@ -23,10 +23,9 @@ export const PROTOCOL_VERSION = '0.10.0'; * first** so a server picking the first acceptable entry honors the * client's preference (see [versioning](../../docs/specification/versioning.md)). * - * The first entry MUST equal {@link PROTOCOL_VERSION} — the version - * "new code speaks" is by definition the most preferred one. Older - * versions may be appended if a client retains the ability to fall back - * to them; today only one version is advertised. + * These are released compatibility baselines, independent of the current + * development {@link PROTOCOL_VERSION}. Hosts accept client-offered versions + * in the caret-compatible range of either baseline. * * Every generated client (Rust, Kotlin, Swift) re-exports this constant * verbatim. The TypeScript client consumes it directly. The per-client @@ -34,13 +33,8 @@ export const PROTOCOL_VERSION = '0.10.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '0.10.0', + '1.0.0', '0.9.0', - '0.8.0', - '0.7.0', - '0.6.0', - '0.5.2', - '0.5.1', ]); // ─── SemVer Comparison ─────────────────────────────────────────────────────── @@ -53,11 +47,35 @@ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ * Throws if `version` is not a well-formed `MAJOR.MINOR.PATCH` string. */ function parseSemver(version: string): readonly [number, number, number] { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); - if (!match) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if (!match || match[0] !== version) { throw new Error(`Invalid protocol version: ${version}`); } - return [Number(match[1]), Number(match[2]), Number(match[3])] as const; + const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + return parts; +} + +/** + * Selects the highest client-offered version in a supported caret range. + * Returns undefined when none match; the host must send UnsupportedProtocolVersion + * with SUPPORTED_PROTOCOL_VERSIONS and close the connection. Malformed versions throw. + */ +export function negotiateProtocolVersion(offered: readonly string[]): string | undefined { + let selected: string | undefined; + for (const version of offered) { + const [major, minor, patch] = parseSemver(version); + const compatible = SUPPORTED_PROTOCOL_VERSIONS.some(baseline => { + const [baseMajor, baseMinor, basePatch] = parseSemver(baseline); + return major === baseMajor + && (major > 0 || minor === baseMinor) + && (major > 0 || minor > 0 || patch === basePatch) + && compareProtocolVersions(version, baseline) >= 0; + }); + if (compatible && (selected === undefined || compareProtocolVersions(version, selected) > 0)) { + selected = version; + } + } + return selected; } /** From 66b3b123802f7ef76766534eb8713a9a6ace5395 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 2 Oct 2026 15:55:49 -0700 Subject: [PATCH 2/2] chore: bump PROTOCOL_VERSION to 1.1.0 for ongoing development Keep advertised compatibility baselines at 1.0.0 and 0.9.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ .../Generated/Version.generated.cs | 2 +- clients/go/ahptypes/version.generated.go | 2 +- .../agenthostprotocol/generated/Version.generated.kt | 2 +- clients/rust/crates/ahp-types/src/version.rs | 2 +- .../AgentHostProtocol/Generated/Version.generated.swift | 2 +- types/version/registry.ts | 2 +- 7 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2012008..28035fc28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ changes accumulate. Track in-flight protocol changes via PRs touching `NOTIFICATION_INTRODUCED_IN` maps in [`types/version/registry.ts`](types/version/registry.ts). +## [1.1.0] — Unreleased + +Spec version: `1.1.0` + ## [1.0.0] — 2026-10-02 Spec version: `1.0.0` diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs index 7fa5d0f70..e7d2ed1d5 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Version.generated.cs @@ -14,7 +14,7 @@ public static class ProtocolVersion /// The current protocol version (SemVer MAJOR.MINOR.PATCH) this /// generated source speaks. /// - public const string Current = "1.0.0"; + public const string Current = "1.1.0"; private static readonly string[] s_supported = { diff --git a/clients/go/ahptypes/version.generated.go b/clients/go/ahptypes/version.generated.go index a71947394..df91681d4 100644 --- a/clients/go/ahptypes/version.generated.go +++ b/clients/go/ahptypes/version.generated.go @@ -12,7 +12,7 @@ import ( // ProtocolVersion is the current protocol version (SemVer // MAJOR.MINOR.PATCH) that this generated source speaks. -const ProtocolVersion = "1.0.0" +const ProtocolVersion = "1.1.0" // supportedProtocolVersions backs [SupportedProtocolVersions] — held // in an unexported slice so callers cannot accidentally mutate the diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt index 598f42a8c..ab37bc7ef 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Version.generated.kt @@ -5,7 +5,7 @@ package com.microsoft.agenthostprotocol.generated /** * Current protocol version (SemVer `MAJOR.MINOR.PATCH`). */ -public const val PROTOCOL_VERSION: String = "1.0.0" +public const val PROTOCOL_VERSION: String = "1.1.0" /** * Every protocol version this library is willing to negotiate, ordered diff --git a/clients/rust/crates/ahp-types/src/version.rs b/clients/rust/crates/ahp-types/src/version.rs index 51c725dfd..b3fc617e2 100644 --- a/clients/rust/crates/ahp-types/src/version.rs +++ b/clients/rust/crates/ahp-types/src/version.rs @@ -5,7 +5,7 @@ #![allow(missing_docs)] /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -pub const PROTOCOL_VERSION: &str = "1.0.0"; +pub const PROTOCOL_VERSION: &str = "1.1.0"; /// Every protocol version this crate is willing to negotiate, ordered /// most-preferred-first, independently of the development [`PROTOCOL_VERSION`]. diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift index 87059d7bb..2468a2034 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Version.generated.swift @@ -3,7 +3,7 @@ import Foundation /// Current protocol version (SemVer `MAJOR.MINOR.PATCH`). -public let PROTOCOL_VERSION: String = "1.0.0" +public let PROTOCOL_VERSION: String = "1.1.0" /// Every protocol version this package is willing to negotiate, /// ordered most-preferred-first, independently of the development diff --git a/types/version/registry.ts b/types/version/registry.ts index 806f9a4d2..acd0f0b79 100644 --- a/types/version/registry.ts +++ b/types/version/registry.ts @@ -15,7 +15,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 = '1.1.0'; /** * Every protocol version a client built from this source tree is willing